55 authOAuthUtilsMock ,
66 dbChainMockFns ,
77 drizzleOrmMock ,
8+ flattenMockConditions ,
89 hasMockCondition ,
910 type MockCondition ,
1011 resetDbChainMock ,
@@ -1103,7 +1104,7 @@ describe('stillHoldsSyncLock', () => {
11031104 */
11041105 expect (
11051106 hasMockCondition (
1106- stillHoldsSyncLock ( 'c-1' ) ,
1107+ stillHoldsSyncLock ( 'c-1' , 'run-a' ) ,
11071108 ( node : MockCondition ) =>
11081109 node . type === 'eq' &&
11091110 node . left === schemaMock . knowledgeConnector . status &&
@@ -1115,7 +1116,7 @@ describe('stillHoldsSyncLock', () => {
11151116 it ( 'still scopes to the connector and skips archived or deleted rows' , async ( ) => {
11161117 const { stillHoldsSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
11171118
1118- const condition = stillHoldsSyncLock ( 'c-1' )
1119+ const condition = stillHoldsSyncLock ( 'c-1' , 'run-a' )
11191120
11201121 expect (
11211122 hasMockCondition (
@@ -1157,7 +1158,7 @@ describe('writeTerminalConnectorState', () => {
11571158 * both terminal paths route through here and neither builds a WHERE clause,
11581159 * so removing the guard is a single-site edit that this assertion catches.
11591160 */
1160- await writeTerminalConnectorState ( 'c-1' , { status : 'active' } )
1161+ await writeTerminalConnectorState ( 'c-1' , 'run-a' , { status : 'active' } )
11611162
11621163 const where = dbChainMockFns . where . mock . calls [ 0 ] [ 0 ]
11631164 expect (
@@ -1178,13 +1179,23 @@ describe('writeTerminalConnectorState', () => {
11781179 node . right === 'c-1'
11791180 )
11801181 ) . toBe ( true )
1182+ // The token must be the run's own, not some other value that merely fills the slot.
1183+ expect (
1184+ hasMockCondition (
1185+ where ,
1186+ ( node : MockCondition ) =>
1187+ node . type === 'eq' &&
1188+ node . left === schemaMock . knowledgeConnector . syncLockToken &&
1189+ node . right === 'run-a'
1190+ )
1191+ ) . toBe ( true )
11811192 } )
11821193
11831194 it ( 'passes the caller values through untouched' , async ( ) => {
11841195 const { writeTerminalConnectorState } = await import ( '@/lib/knowledge/connectors/sync-engine' )
11851196
11861197 const values = { status : 'error' , consecutiveFailures : 4 , nextSyncAt : null }
1187- await writeTerminalConnectorState ( 'c-1' , values )
1198+ await writeTerminalConnectorState ( 'c-1' , 'run-a' , values )
11881199
11891200 expect ( dbChainMockFns . set . mock . calls [ 0 ] [ 0 ] ) . toEqual ( values )
11901201 } )
@@ -1193,10 +1204,10 @@ describe('writeTerminalConnectorState', () => {
11931204 const { writeTerminalConnectorState } = await import ( '@/lib/knowledge/connectors/sync-engine' )
11941205
11951206 dbChainMockFns . returning . mockResolvedValueOnce ( [ { id : 'c-1' } ] )
1196- expect ( await writeTerminalConnectorState ( 'c-1' , { status : 'active' } ) ) . toBe ( true )
1207+ expect ( await writeTerminalConnectorState ( 'c-1' , 'run-a' , { status : 'active' } ) ) . toBe ( true )
11971208
11981209 dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
1199- expect ( await writeTerminalConnectorState ( 'c-1' , { status : 'active' } ) ) . toBe ( false )
1210+ expect ( await writeTerminalConnectorState ( 'c-1' , 'run-a' , { status : 'active' } ) ) . toBe ( false )
12001211 } )
12011212} )
12021213
@@ -1234,3 +1245,99 @@ describe('applySupersededOutcome', () => {
12341245 expect ( applySupersededOutcome ( result , false ) ) . toMatchObject ( result )
12351246 } )
12361247} )
1248+
1249+ /**
1250+ * Evaluates a mocked drizzle condition tree against a plain row.
1251+ *
1252+ * The row-queue mocks return whatever was queued regardless of the predicate, so
1253+ * "this WHERE admits run B and rejects run A" is only observable by interpreting
1254+ * the condition tree the guard emits.
1255+ */
1256+ function conditionMatchesRow ( condition : unknown , row : Record < string , unknown > ) : boolean {
1257+ return flattenMockConditions ( condition ) . every ( ( node ) => {
1258+ if ( node . type === 'eq' ) return row [ node . left as string ] === node . right
1259+ if ( node . type === 'isNull' ) return row [ node . column as string ] == null
1260+ throw new Error ( `unhandled condition node: ${ String ( node . type ) } ` )
1261+ } )
1262+ }
1263+
1264+ describe ( 'sync lock ownership across a reclaim and reacquire' , ( ) => {
1265+ const RUN_A = 'run-a'
1266+ const RUN_B = 'run-b'
1267+
1268+ /** The connector row once run B has taken the lock that run A used to hold. */
1269+ const rowHeldByB = {
1270+ id : 'c-1' ,
1271+ status : 'syncing' ,
1272+ syncLockToken : RUN_B ,
1273+ archivedAt : null ,
1274+ deletedAt : null ,
1275+ }
1276+
1277+ it ( 'rejects the reclaimed run A and admits the live run B' , async ( ) => {
1278+ const { stillHoldsSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1279+
1280+ /**
1281+ * A outlived the TTL, the reaper reclaimed its lock, and replacement B took
1282+ * it — so the row reads `syncing` again. Guarding on status alone matched A
1283+ * here and let the dead run clobber the live one, then rejected B's own
1284+ * write as superseded. Exactly inverted.
1285+ */
1286+ expect ( conditionMatchesRow ( stillHoldsSyncLock ( 'c-1' , RUN_A ) , rowHeldByB ) ) . toBe ( false )
1287+ expect ( conditionMatchesRow ( stillHoldsSyncLock ( 'c-1' , RUN_B ) , rowHeldByB ) ) . toBe ( true )
1288+ } )
1289+
1290+ it ( 'rejects a run whose lock was reclaimed with no replacement yet' , async ( ) => {
1291+ const { stillHoldsSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1292+
1293+ const reclaimed = {
1294+ id : 'c-1' ,
1295+ status : 'error' ,
1296+ syncLockToken : null ,
1297+ archivedAt : null ,
1298+ deletedAt : null ,
1299+ }
1300+
1301+ expect ( conditionMatchesRow ( stillHoldsSyncLock ( 'c-1' , RUN_A ) , reclaimed ) ) . toBe ( false )
1302+ } )
1303+
1304+ it ( 'admits the run that still holds its own lock' , async ( ) => {
1305+ const { stillHoldsSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1306+
1307+ const heldByA = { ...rowHeldByB , syncLockToken : RUN_A }
1308+
1309+ expect ( conditionMatchesRow ( stillHoldsSyncLock ( 'c-1' , RUN_A ) , heldByA ) ) . toBe ( true )
1310+ } )
1311+
1312+ it ( 'rejects a run whose connector was paused mid-sync' , async ( ) => {
1313+ const { stillHoldsSyncLock } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1314+
1315+ const paused = { ...rowHeldByB , status : 'paused' , syncLockToken : RUN_A }
1316+
1317+ expect ( conditionMatchesRow ( stillHoldsSyncLock ( 'c-1' , RUN_A ) , paused ) ) . toBe ( false )
1318+ } )
1319+
1320+ it ( 'releases the token when a run writes its terminal success state' , async ( ) => {
1321+ const { buildSyncSuccessUpdate } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1322+
1323+ // A stale token left behind could match a later run reusing the same id.
1324+ expect ( buildSyncSuccessUpdate ( new Date ( ) , 1 , null , null ) . syncLockToken ) . toBeNull ( )
1325+ } )
1326+ } )
1327+
1328+ describe ( 'buildSyncLockAcquisition' , ( ) => {
1329+ it ( 'claims the lock and stamps ownership in one payload' , async ( ) => {
1330+ const { buildSyncLockAcquisition } = await import ( '@/lib/knowledge/connectors/sync-engine' )
1331+
1332+ const now = new Date ( '2026-08-20T00:00:00.000Z' )
1333+ const acquisition = buildSyncLockAcquisition ( 'run-a' , now )
1334+
1335+ /**
1336+ * Without the token here every terminal write would fail to match its own
1337+ * run, so every sync would report superseded and leave the connector stuck
1338+ * `syncing` until the reaper cleared it.
1339+ */
1340+ expect ( acquisition . syncLockToken ) . toBe ( 'run-a' )
1341+ expect ( acquisition . status ) . toBe ( 'syncing' )
1342+ } )
1343+ } )
0 commit comments