@@ -24,6 +24,7 @@ import path from 'node:path';
2424import {
2525 closeDatabase ,
2626 ensureWorkspaceResource ,
27+ getWorkspaceResourceByResourceId ,
2728 openDatabase ,
2829 updateWorkspaceResource ,
2930} from '../src/db.js' ;
@@ -33,6 +34,7 @@ import {
3334 resolveAndActivateWorkspaceTeamPlugin ,
3435 resolveWorkspaceTeamPluginWithBindingGate ,
3536 upsertInstalledPlugin ,
37+ workspaceTeamPluginBindingActivationFence ,
3638 workspaceTeamPluginBindingAllowsRead ,
3739 workspaceTeamPluginBindingResourceId ,
3840} from '../src/plugins/registry.js' ;
@@ -236,7 +238,9 @@ describe('listInstalledPlugins workspace scope', () => {
236238 resolveStarted ( ) ;
237239 return resolveGate ;
238240 } ,
241+ captureActivationFence : ( ) => 'active' ,
239242 stillShared : async ( ) => false ,
243+ activationFenceIsCurrent : ( ) => false ,
240244 activate : ( ) => {
241245 updateWorkspaceResource ( db , 'plugin' , workspaceId , bindingId , {
242246 resourceState : 'active' ,
@@ -276,10 +280,14 @@ describe('listInstalledPlugins workspace scope', () => {
276280 readStarted = resolve ;
277281 } ) ;
278282 const oldListing = activateWorkspaceTeamPluginIfStillShared ( {
283+ captureActivationFence : ( ) =>
284+ workspaceTeamPluginBindingActivationFence ( db , workspaceId , pluginId ) ,
279285 stillShared : async ( ) => {
280286 readStarted ( ) ;
281287 return authoritativeRead ;
282288 } ,
289+ activationFenceIsCurrent : ( fence ) =>
290+ workspaceTeamPluginBindingActivationFence ( db , workspaceId , pluginId ) === fence ,
283291 activate : ( ) => {
284292 updateWorkspaceResource ( db , 'plugin' , workspaceId , bindingId , {
285293 resourceState : 'active' ,
@@ -299,4 +307,56 @@ describe('listInstalledPlugins workspace scope', () => {
299307 ) . toBe ( false ) ;
300308 } ,
301309 ) ;
310+
311+ it ( 'rejects a superseded positive shared read after a newer binding tombstone' , async ( ) => {
312+ const db = openDatabase ( tempDir , { dataDir : tempDir } ) ;
313+ const pluginId = 'plugin-superseded-positive' ;
314+ const workspaceId = 'ws-team' ;
315+ const bindingId = workspaceTeamPluginBindingResourceId ( workspaceId , pluginId ) ;
316+ ensureWorkspaceResource ( db , 'plugin' , workspaceId , bindingId , {
317+ visibility : 'team' ,
318+ resourceState : 'active' ,
319+ } ) ;
320+
321+ let finishAuthoritativeRead ! : ( stillShared : boolean ) => void ;
322+ const authoritativeRead = new Promise < boolean > ( ( resolve ) => {
323+ finishAuthoritativeRead = resolve ;
324+ } ) ;
325+ let readStarted ! : ( ) => void ;
326+ const started = new Promise < void > ( ( resolve ) => {
327+ readStarted = resolve ;
328+ } ) ;
329+ const staleActivation = activateWorkspaceTeamPluginIfStillShared ( {
330+ captureActivationFence : ( ) =>
331+ workspaceTeamPluginBindingActivationFence ( db , workspaceId , pluginId ) ,
332+ stillShared : async ( ) => {
333+ readStarted ( ) ;
334+ return authoritativeRead ;
335+ } ,
336+ activationFenceIsCurrent : ( fence ) =>
337+ workspaceTeamPluginBindingActivationFence ( db , workspaceId , pluginId ) === fence ,
338+ activate : ( ) => {
339+ updateWorkspaceResource ( db , 'plugin' , workspaceId , bindingId , {
340+ resourceState : 'active' ,
341+ } ) ;
342+ return true ;
343+ } ,
344+ } ) ;
345+ await started ;
346+ const originalUpdatedAt = Number (
347+ getWorkspaceResourceByResourceId ( db , 'plugin' , bindingId ) ?. updatedAt ,
348+ ) ;
349+ updateWorkspaceResource ( db , 'plugin' , workspaceId , bindingId , {
350+ resourceState : 'deleted' ,
351+ // Simulate two writes in the same millisecond: resourceState must keep
352+ // the tombstone visible even when updatedAt alone cannot distinguish it.
353+ updatedAt : originalUpdatedAt ,
354+ } ) ;
355+ finishAuthoritativeRead ( true ) ;
356+
357+ await expect ( staleActivation ) . resolves . toBe ( false ) ;
358+ expect (
359+ workspaceTeamPluginBindingAllowsRead ( db , workspaceId , pluginId ) ,
360+ ) . toBe ( false ) ;
361+ } ) ;
302362} ) ;
0 commit comments