@kubun/store-connector 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { AbstractPostgresAdapter } from '@kubun/db-adapter';
2
+ // The `send_marker` of an attempt that more than one run marked. It matches
3
+ // no run's marker, so no run of that attempt can refund it.
4
+ const CONTESTED_SEND_MARKER = '*contested*';
1
5
  // --- Serialization helpers ---
2
6
  // `checkpoint` and `recovery` are JSON columns: ParseJSONResultsPlugin may have
3
7
  // already parsed them (Postgres) or left them as strings (SQLite), so handle
@@ -27,9 +31,105 @@ function rowToSyncState(row) {
27
31
  }
28
32
  return state;
29
33
  }
30
- // --- Factory function ---
31
- export function createConnectorStore(db, _adapter) {
34
+ function rowToOperation(row) {
35
+ return {
36
+ callerDID: row.caller_did,
37
+ operationID: row.operation_id,
38
+ nodeKey: row.node_key,
39
+ ownerDID: row.owner_did,
40
+ connectorName: row.connector_name,
41
+ model: row.model,
42
+ op: row.op,
43
+ accountKey: row.account_key,
44
+ target: row.target,
45
+ sourceID: row.source_id,
46
+ deterministicEventID: row.deterministic_event_id,
47
+ payloadHash: row.payload_hash,
48
+ reissueIntent: decodeJSONColumn(row.reissue_intent),
49
+ providerEtag: row.provider_etag,
50
+ documentID: row.document_id,
51
+ resultEntity: decodeJSONColumn(row.result_entity),
52
+ state: row.state,
53
+ attempts: row.attempts,
54
+ nextAttemptAt: row.next_attempt_at,
55
+ lastKind: row.last_kind,
56
+ lastHTTPStatus: row.last_http_status,
57
+ version: row.version,
58
+ incarnation: row.incarnation,
59
+ leaseToken: row.lease_token,
60
+ leaseExpiresAt: row.lease_expires_at,
61
+ createdAt: row.created_at,
62
+ updatedAt: row.updated_at
63
+ };
64
+ }
65
+ function rowToWatch(row) {
32
66
  return {
67
+ ownerDID: row.owner_did,
68
+ connectorName: row.connector_name,
69
+ model: row.model,
70
+ accountKey: row.account_key,
71
+ sourceID: row.source_id,
72
+ documentID: row.document_id,
73
+ verifiedStateDigest: row.verified_state_digest,
74
+ observedWinnerDigest: row.observed_winner_digest,
75
+ verificationStatus: row.verification_status,
76
+ verificationAttempts: row.verification_attempts,
77
+ repairGeneration: row.repair_generation,
78
+ nextAttemptAt: row.next_attempt_at,
79
+ version: row.version,
80
+ createdAt: row.created_at,
81
+ updatedAt: row.updated_at
82
+ };
83
+ }
84
+ // The most ids one `IN (...)` list binds; longer lists are read in chunks,
85
+ // well inside every adapter's bind-parameter limit.
86
+ const IN_LIST_CHUNK = 500;
87
+ function chunked(values) {
88
+ // Deduplicated first, as a single `IN` would be: a value repeated across
89
+ // two chunks would otherwise return its rows twice.
90
+ const unique = [
91
+ ...new Set(values)
92
+ ];
93
+ const chunks = [];
94
+ for(let start = 0; start < unique.length; start += IN_LIST_CHUNK){
95
+ chunks.push(unique.slice(start, start + IN_LIST_CHUNK));
96
+ }
97
+ return chunks;
98
+ }
99
+ // A retention cleanup's cutoff and batch size: finite, and a positive whole
100
+ // batch. Fails closed on anything else rather than deleting unbounded.
101
+ function assertCollectParams(method, params) {
102
+ if (!Number.isFinite(params.before)) {
103
+ throw new Error(`${method}: before must be a finite number`);
104
+ }
105
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
106
+ throw new Error(`${method}: limit must be a positive integer`);
107
+ }
108
+ }
109
+ // The `listTrackedOperations` order: oldest first, then caller and operation id.
110
+ function compareOperations(a, b) {
111
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
112
+ if (a.callerDID !== b.callerDID) return a.callerDID < b.callerDID ? -1 : 1;
113
+ if (a.operationID !== b.operationID) return a.operationID < b.operationID ? -1 : 1;
114
+ return 0;
115
+ }
116
+ // --- Factory function ---
117
+ export function createConnectorStore(db, adapter, deps) {
118
+ // `.forUpdate()` compiles to a `FOR UPDATE` clause via Kysely's default
119
+ // compiler; SQLite has no dedicated override and errors on it (no such
120
+ // clause), so it is applied conditionally, never on SQLite. Postgres row
121
+ // locking is what makes a concurrent `armWatch`/`finalizeOperationAndArmWatch`
122
+ // on the same identity block instead of interleave; SQLite's single
123
+ // connection already serializes the whole transaction with no clause
124
+ // needed.
125
+ const isPostgres = adapter instanceof AbstractPostgresAdapter;
126
+ const { getRandomID } = deps;
127
+ // Declared as a named const (not an inline `return {...}`) so
128
+ // `requestRepull` can delegate to `api.armWatch` below — by the time
129
+ // `requestRepull` is actually invoked, `api` has long since finished
130
+ // initializing (closures capture the binding, not a snapshot), so the
131
+ // forward reference is safe.
132
+ const api = {
33
133
  // --- Sync state operations ---
34
134
  async getSyncState (connectorName, ownerDID) {
35
135
  const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
@@ -54,7 +154,7 @@ export function createConnectorStore(db, _adapter) {
54
154
  lease_expires_at: null,
55
155
  recovery,
56
156
  version: 0,
57
- incarnation: globalThis.crypto.randomUUID()
157
+ incarnation: getRandomID()
58
158
  }).onConflict((oc)=>oc.columns([
59
159
  'connector_name',
60
160
  'owner_did'
@@ -89,7 +189,7 @@ export function createConnectorStore(db, _adapter) {
89
189
  lease_expires_at: null,
90
190
  recovery: encodeJSONColumn(result.recovery),
91
191
  version: 0,
92
- incarnation: globalThis.crypto.randomUUID()
192
+ incarnation: getRandomID()
93
193
  }).onConflict((oc)=>oc.columns([
94
194
  'connector_name',
95
195
  'owner_did'
@@ -174,6 +274,705 @@ export function createConnectorStore(db, _adapter) {
174
274
  // ("at or before nowMs") so the sweep and the consume check agree on the
175
275
  // expiry boundary.
176
276
  await db.deleteFrom('kubun_connector_pending_auth').where('expires_at', '<', cutoffMs).execute();
277
+ },
278
+ // --- Connector write-back operation envelope ---
279
+ async claimOperation (input) {
280
+ const now = Date.now();
281
+ // INSERT ... ON CONFLICT DO NOTHING RETURNING *, mirroring the insert-race
282
+ // half of `updateSyncProgress`: a fresh key wins the insert and is
283
+ // returned directly; a conflicting key returns nothing here and falls
284
+ // through to the read+compare below.
285
+ const insertedRow = await db.insertInto('kubun_connector_operation').values({
286
+ caller_did: input.callerDID,
287
+ operation_id: input.operationID,
288
+ node_key: getRandomID(),
289
+ owner_did: input.ownerDID,
290
+ connector_name: input.connectorName,
291
+ model: input.model,
292
+ op: input.op,
293
+ account_key: input.accountKey,
294
+ target: input.target,
295
+ source_id: input.sourceID,
296
+ deterministic_event_id: input.deterministicEventID,
297
+ payload_hash: input.payloadHash,
298
+ reissue_intent: encodeJSONColumn(input.reissueIntent) ?? 'null',
299
+ provider_etag: input.providerEtag ?? null,
300
+ document_id: input.documentID ?? null,
301
+ result_entity: null,
302
+ state: 'attempted',
303
+ attempts: 0,
304
+ next_attempt_at: null,
305
+ last_kind: null,
306
+ last_http_status: null,
307
+ version: 0,
308
+ incarnation: getRandomID(),
309
+ lease_token: input.leaseToken,
310
+ lease_expires_at: input.leaseExpiresAt,
311
+ created_at: now,
312
+ updated_at: now
313
+ }).onConflict((oc)=>oc.columns([
314
+ 'caller_did',
315
+ 'operation_id'
316
+ ]).doNothing()).returningAll().executeTakeFirst();
317
+ if (insertedRow != null) {
318
+ return {
319
+ status: 'claimed',
320
+ record: rowToOperation(insertedRow)
321
+ };
322
+ }
323
+ const row = await db.selectFrom('kubun_connector_operation').selectAll().where('caller_did', '=', input.callerDID).where('operation_id', '=', input.operationID).executeTakeFirst();
324
+ if (row == null) {
325
+ // The conflicting row vanished between the failed insert and this read
326
+ // (e.g. a concurrent delete) — neither a match nor a safe claim.
327
+ return {
328
+ status: 'conflict'
329
+ };
330
+ }
331
+ // The immutable envelope tuple: equal on every field → the identical
332
+ // request replayed (`exists`); any drift is a genuine conflict, and the
333
+ // stored row is never overwritten.
334
+ const envelopeMatches = row.owner_did === input.ownerDID && row.connector_name === input.connectorName && row.model === input.model && row.op === input.op && row.account_key === input.accountKey && row.source_id === input.sourceID && row.deterministic_event_id === input.deterministicEventID && row.target === input.target && row.payload_hash === input.payloadHash;
335
+ return envelopeMatches ? {
336
+ status: 'exists',
337
+ record: rowToOperation(row)
338
+ } : {
339
+ status: 'conflict'
340
+ };
341
+ },
342
+ async acquireLease (callerDID, operationID, token, expiresAt) {
343
+ const now = Date.now();
344
+ const result = await db.updateTable('kubun_connector_operation').set({
345
+ lease_token: token,
346
+ lease_expires_at: expiresAt,
347
+ updated_at: now
348
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
349
+ eb('lease_token', 'is', null),
350
+ eb('lease_expires_at', '<', now),
351
+ eb('lease_token', '=', token)
352
+ ])).returning('operation_id').executeTakeFirst();
353
+ return result != null;
354
+ },
355
+ async renewLease (callerDID, operationID, token, expiresAt) {
356
+ const now = Date.now();
357
+ const result = await db.updateTable('kubun_connector_operation').set({
358
+ lease_expires_at: expiresAt,
359
+ updated_at: now
360
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
361
+ eb('lease_token', 'is', null),
362
+ eb('lease_expires_at', '<', now),
363
+ eb('lease_token', '=', token)
364
+ ])).returning('operation_id').executeTakeFirst();
365
+ return result != null;
366
+ },
367
+ async releaseLease (callerDID, operationID, token) {
368
+ const now = Date.now();
369
+ const result = await db.updateTable('kubun_connector_operation').set({
370
+ lease_token: null,
371
+ lease_expires_at: null,
372
+ updated_at: now
373
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
374
+ eb('lease_token', 'is', null),
375
+ eb('lease_expires_at', '<', now),
376
+ eb('lease_token', '=', token)
377
+ ])).returning('operation_id').executeTakeFirst();
378
+ return result != null;
379
+ },
380
+ async getOperation (callerDID, operationID) {
381
+ const row = await db.selectFrom('kubun_connector_operation').selectAll().where('caller_did', '=', callerDID).where('operation_id', '=', operationID).executeTakeFirst();
382
+ return row != null ? rowToOperation(row) : null;
383
+ },
384
+ async listTrackedOperations (filter) {
385
+ if (filter.states?.length === 0 || filter.documentIDs?.length === 0) return [];
386
+ if (filter.documentIDs != null && filter.documentIDs.length > IN_LIST_CHUNK) {
387
+ const chunks = [];
388
+ for (const documentIDs of chunked(filter.documentIDs)){
389
+ chunks.push(await api.listTrackedOperations({
390
+ ...filter,
391
+ documentIDs
392
+ }));
393
+ }
394
+ return chunks.flat().sort(compareOperations);
395
+ }
396
+ const rows = await db.selectFrom('kubun_connector_operation').selectAll().$if(filter.nodeKey != null, (qb)=>qb.where('node_key', '=', filter.nodeKey)).$if(filter.callerDID != null, (qb)=>qb.where('caller_did', '=', filter.callerDID)).$if(filter.operationID != null, (qb)=>qb.where('operation_id', '=', filter.operationID)).$if(filter.visibleTo != null, (qb)=>qb.where((eb)=>eb.or([
397
+ eb('caller_did', '=', filter.visibleTo),
398
+ eb('owner_did', '=', filter.visibleTo)
399
+ ]))).$if(filter.connectorName != null, (qb)=>qb.where('connector_name', '=', filter.connectorName)).$if(filter.states != null, (qb)=>qb.where('state', 'in', filter.states)).$if(filter.documentIDs != null, (qb)=>qb.where('document_id', 'in', filter.documentIDs)).orderBy('created_at').orderBy('caller_did').orderBy('operation_id').execute();
400
+ return rows.map(rowToOperation);
401
+ },
402
+ async hasNewerOperationOnDocument (callerDID, operationID) {
403
+ const row = await db.selectFrom('kubun_connector_operation as o').innerJoin('kubun_connector_operation as n', 'n.document_id', 'o.document_id').select('n.operation_id').where('o.caller_did', '=', callerDID).where('o.operation_id', '=', operationID).where((order)=>order.or([
404
+ order('n.created_at', '>', order.ref('o.created_at')),
405
+ order.and([
406
+ order('n.created_at', '=', order.ref('o.created_at')),
407
+ order('n.caller_did', '>', order.ref('o.caller_did'))
408
+ ]),
409
+ order.and([
410
+ order('n.created_at', '=', order.ref('o.created_at')),
411
+ order('n.caller_did', '=', order.ref('o.caller_did')),
412
+ order('n.operation_id', '>', order.ref('o.operation_id'))
413
+ ])
414
+ ])).limit(1).executeTakeFirst();
415
+ return row != null;
416
+ },
417
+ async markSendStarted (params) {
418
+ if (params.marker === CONTESTED_SEND_MARKER) {
419
+ throw new Error('markSendStarted: marker is reserved');
420
+ }
421
+ const result = await db.updateTable('kubun_connector_operation').set((eb)=>({
422
+ // A second run marking the same attempt means an earlier run may
423
+ // already have sent: the attempt becomes refundable by no run.
424
+ send_marker: eb.case().when(eb.or([
425
+ eb('send_marker', 'is', null),
426
+ eb('send_marker', '=', params.marker)
427
+ ])).then(params.marker).else(CONTESTED_SEND_MARKER).end(),
428
+ updated_at: Date.now()
429
+ })).where('caller_did', '=', params.callerDID).where('operation_id', '=', params.operationID).where('state', '=', 'attempted').where('lease_token', '=', params.leaseToken).executeTakeFirst();
430
+ return (result?.numUpdatedRows ?? 0n) > 0n;
431
+ },
432
+ async deferOperation (params) {
433
+ const now = Date.now();
434
+ const result = await db.updateTable('kubun_connector_operation').set({
435
+ next_attempt_at: params.nextAttemptAt,
436
+ defer_reason: params.reason ?? null,
437
+ updated_at: now
438
+ }).where('caller_did', '=', params.callerDID).where('operation_id', '=', params.operationID).where('state', '=', 'attempted').where((eb)=>{
439
+ const free = [
440
+ eb('lease_token', 'is', null),
441
+ eb('lease_expires_at', '<', now)
442
+ ];
443
+ return eb.or(params.leaseToken != null ? [
444
+ ...free,
445
+ eb('lease_token', '=', params.leaseToken)
446
+ ] : free);
447
+ }).executeTakeFirst();
448
+ return (result?.numUpdatedRows ?? 0n) > 0n;
449
+ },
450
+ async recordOperation (input) {
451
+ // The type excludes `'committed'` from `toState`; this guards a caller
452
+ // that forces it through with a cast.
453
+ if (input.toState === 'committed') {
454
+ throw new Error('recordOperation cannot reach committed; use finalizeOperationAndArmWatch');
455
+ }
456
+ const now = Date.now();
457
+ const result = await db.updateTable('kubun_connector_operation').set((eb)=>({
458
+ state: input.toState,
459
+ provider_etag: input.providerEtag ?? null,
460
+ attempts: input.refundAttempt === true ? eb('attempts', '-', 1) : input.countAttempt === false ? eb.ref('attempts') : eb('attempts', '+', 1),
461
+ next_attempt_at: input.nextAttemptAt ?? null,
462
+ defer_reason: input.deferReason ?? null,
463
+ // A newly consumed attempt has started no send yet. A refund
464
+ // clears the marker too: it only applies when the attempt is
465
+ // unmarked or marked by the refunding run alone (see the guard
466
+ // below), so a contested or other run's mark is never erased, and
467
+ // the next run of the attempt starts unmarked.
468
+ ...input.refundAttempt === true || input.countAttempt !== false ? {
469
+ send_marker: null
470
+ } : {},
471
+ last_kind: input.kind ?? null,
472
+ last_http_status: input.httpStatus ?? null,
473
+ version: eb('version', '+', 1),
474
+ updated_at: now
475
+ })).where('caller_did', '=', input.callerDID).where('operation_id', '=', input.operationID).where('state', '=', input.fromState).where('version', '=', input.version).where('lease_token', '=', input.leaseToken).$if(input.refundAttempt === true, (qb)=>qb.where('attempts', '>', 0).where((eb)=>eb.or([
476
+ eb('send_marker', 'is', null),
477
+ ...input.sendMarker != null ? [
478
+ eb('send_marker', '=', input.sendMarker)
479
+ ] : []
480
+ ]))).executeTakeFirst();
481
+ return (result?.numUpdatedRows ?? 0n) > 0n;
482
+ },
483
+ async finalizeOperationAndArmWatch (input) {
484
+ const now = Date.now();
485
+ return await db.transaction().execute(async (tx)=>{
486
+ // Conditional UPDATE first: the fenced lease CAS gate. `version` is
487
+ // deliberately NOT bumped here (see `FinalizeOperationInput`'s doc) —
488
+ // only the watch row's own `version` advances below.
489
+ const updated = await tx.updateTable('kubun_connector_operation').set({
490
+ state: 'committed',
491
+ document_id: input.documentID,
492
+ result_entity: encodeJSONColumn(input.resultEntity),
493
+ updated_at: now
494
+ }).where('caller_did', '=', input.key.callerDID).where('operation_id', '=', input.key.operationID).where('state', 'in', [
495
+ 'attempted',
496
+ 'committed'
497
+ ]).where('version', '=', input.version).where('lease_token', '=', input.leaseToken).executeTakeFirst();
498
+ // Lost CAS: the transaction commits with NO watch write — the point
499
+ // is that the upsert below never runs, so a stale-lease finalize
500
+ // leaves the watch row untouched (absent if it never existed).
501
+ if ((updated?.numUpdatedRows ?? 0n) === 0n) {
502
+ return false;
503
+ }
504
+ // One watch per document: a watch the document still has under
505
+ // another provider identity (the same provider id written under a
506
+ // second account, say) is superseded by this one, not kept beside it.
507
+ const identity = input.watchIdentity;
508
+ await tx.deleteFrom('kubun_connector_projection_watch').where('owner_did', '=', identity.ownerDID).where('model', '=', identity.model).where('document_id', '=', input.documentID).where((eb)=>eb.or([
509
+ eb('connector_name', '!=', identity.connectorName),
510
+ eb('account_key', '!=', identity.accountKey),
511
+ eb('source_id', '!=', identity.sourceID)
512
+ ])).execute();
513
+ await tx.insertInto('kubun_connector_projection_watch').values({
514
+ owner_did: input.watchIdentity.ownerDID,
515
+ connector_name: input.watchIdentity.connectorName,
516
+ model: input.watchIdentity.model,
517
+ account_key: input.watchIdentity.accountKey,
518
+ source_id: input.watchIdentity.sourceID,
519
+ document_id: input.documentID,
520
+ verified_state_digest: null,
521
+ observed_winner_digest: input.currentWinnerDigest,
522
+ verification_status: 'pending_verification',
523
+ verification_attempts: 0,
524
+ repair_generation: 0,
525
+ next_attempt_at: now,
526
+ version: 0,
527
+ created_at: now,
528
+ updated_at: now
529
+ }).onConflict((oc)=>oc.columns([
530
+ 'owner_did',
531
+ 'connector_name',
532
+ 'model',
533
+ 'account_key',
534
+ 'source_id'
535
+ ]).doUpdateSet((eb)=>({
536
+ repair_generation: eb('kubun_connector_projection_watch.repair_generation', '+', 1),
537
+ verification_attempts: 0,
538
+ verification_status: 'pending_verification',
539
+ observed_winner_digest: input.currentWinnerDigest,
540
+ verified_state_digest: null,
541
+ next_attempt_at: now,
542
+ claimed_until: null,
543
+ defer_reason: null,
544
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
545
+ updated_at: now
546
+ }))).execute();
547
+ return true;
548
+ });
549
+ },
550
+ // --- Connector projection verify/repair watch reads ---
551
+ async getWatch (identity) {
552
+ const row = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).executeTakeFirst();
553
+ return row != null ? rowToWatch(row) : null;
554
+ },
555
+ async getWatchByDocument (documentID) {
556
+ const row = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('document_id', '=', documentID).executeTakeFirst();
557
+ return row != null ? rowToWatch(row) : null;
558
+ },
559
+ async listWatchesByDocuments (documentIDs) {
560
+ const watches = [];
561
+ for (const chunk of chunked(documentIDs)){
562
+ const rows = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('document_id', 'in', chunk).execute();
563
+ watches.push(...rows.map(rowToWatch));
564
+ }
565
+ return watches;
566
+ },
567
+ async armWatch (identity, reason, opts) {
568
+ if (reason === 'finalize') {
569
+ throw new Error("armWatch: reason 'finalize' is reserved — the finalize-arm transition is inlined " + 'in finalizeOperationAndArmWatch and must not be invoked through armWatch');
570
+ }
571
+ if (deps.readWinner == null) {
572
+ throw new Error('armWatch: createConnectorStore was not given a readWinner dependency ' + "(required for reasons 'ingress'/'manual')");
573
+ }
574
+ const readWinner = deps.readWinner;
575
+ const maxRetries = 5;
576
+ for(let attempt = 0; attempt <= maxRetries; attempt++){
577
+ // The ENTIRE gate — row read, current-winner read, and the
578
+ // ingress/manual decision — is re-run on every attempt, including
579
+ // retries. A concurrent writer between attempts may have changed the
580
+ // watch's status/watermarks such that the correct outcome flips
581
+ // (e.g. it already opened the generation, so this call must
582
+ // coalesce/noop rather than open a second) — caching the first
583
+ // read's decision and only retrying the write would miss that.
584
+ const attemptResult = await db.transaction().execute(async (tx)=>{
585
+ const base = tx.selectFrom('kubun_connector_projection_watch').selectAll().where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID);
586
+ const row = await (isPostgres ? base.forUpdate() : base).executeTakeFirst();
587
+ // No watch row for this identity yet: nothing to arm (the row is
588
+ // created by `finalizeOperationAndArmWatch`, not here).
589
+ if (row == null) {
590
+ return {
591
+ settled: true,
592
+ outcome: 'noop'
593
+ };
594
+ }
595
+ // Read the CURRENT winner on the same transaction/lock as the row
596
+ // above — never the top-level db (see `ReadWinner`'s doc).
597
+ const currentWinner = await readWinner(row.document_id, tx);
598
+ const now = Date.now();
599
+ const openGeneration = async ()=>{
600
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
601
+ repair_generation: row.repair_generation + 1,
602
+ verification_attempts: 0,
603
+ verification_status: 'pending_verification',
604
+ observed_winner_digest: currentWinner,
605
+ next_attempt_at: opts.dueAt,
606
+ // A claim on the superseded generation does not carry over.
607
+ claimed_until: null,
608
+ defer_reason: null,
609
+ version: row.version + 1,
610
+ updated_at: now
611
+ }).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('version', '=', row.version).executeTakeFirst();
612
+ return {
613
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
614
+ outcome: 'opened'
615
+ };
616
+ };
617
+ if (reason === 'manual') {
618
+ // Unconditional: overrides status and the coalesce rule.
619
+ return await openGeneration();
620
+ }
621
+ // reason === 'ingress': the authoritative in-CAS re-check. `E`
622
+ // must equal the freshly-read current winner AND differ from both
623
+ // watermarks — this is what a caller-side pre-filter can only
624
+ // approximate; this is the check that actually gates the write.
625
+ const triggeringWinner = opts.triggeringWinner;
626
+ const gatePasses = triggeringWinner != null && triggeringWinner === currentWinner && triggeringWinner !== row.verified_state_digest && triggeringWinner !== row.observed_winner_digest;
627
+ if (!gatePasses) {
628
+ return {
629
+ settled: true,
630
+ outcome: 'noop'
631
+ };
632
+ }
633
+ if (row.verification_status === 'verified' || row.verification_status === 'repair_failed') {
634
+ return await openGeneration();
635
+ }
636
+ // row.verification_status === 'pending_verification': coalesce.
637
+ // `min` computed in JS from the already-read row value and written
638
+ // as a literal — SQL LEAST/MIN differ across adapters.
639
+ const coalescedNextAttemptAt = row.next_attempt_at == null ? opts.dueAt : Math.min(row.next_attempt_at, opts.dueAt);
640
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
641
+ next_attempt_at: coalescedNextAttemptAt,
642
+ version: row.version + 1,
643
+ updated_at: now
644
+ }).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('version', '=', row.version).executeTakeFirst();
645
+ return {
646
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
647
+ outcome: 'coalesced'
648
+ };
649
+ });
650
+ if (attemptResult.settled) {
651
+ return attemptResult.outcome;
652
+ }
653
+ }
654
+ throw new Error(`armWatch: CAS failed after ${maxRetries + 1} attempts for ${identity.ownerDID}/${identity.connectorName}/${identity.model}/${identity.accountKey}/${identity.sourceID}`);
655
+ },
656
+ async resumeOfflineDeferred (now) {
657
+ await db.updateTable('kubun_connector_operation').set({
658
+ next_attempt_at: now,
659
+ defer_reason: null,
660
+ updated_at: now
661
+ }).where('state', '=', 'attempted').where('defer_reason', '=', 'offline').execute();
662
+ await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
663
+ next_attempt_at: now,
664
+ defer_reason: null,
665
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
666
+ updated_at: now
667
+ })).where('verification_status', '=', 'pending_verification').where('defer_reason', '=', 'offline').execute();
668
+ },
669
+ async resumeAuthDeferred (params) {
670
+ if (params.connectorNames.length === 0) return;
671
+ await db.updateTable('kubun_connector_operation').set({
672
+ next_attempt_at: params.now,
673
+ defer_reason: null,
674
+ updated_at: params.now
675
+ }).where('state', '=', 'attempted').where('defer_reason', '=', 'auth').where('owner_did', '=', params.ownerDID).where('connector_name', 'in', params.connectorNames).execute();
676
+ await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
677
+ next_attempt_at: params.now,
678
+ defer_reason: null,
679
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
680
+ updated_at: params.now
681
+ })).where('verification_status', '=', 'pending_verification').where('defer_reason', '=', 'auth').where('owner_did', '=', params.ownerDID).where('connector_name', 'in', params.connectorNames).execute();
682
+ },
683
+ async deferWatch (identity, opts) {
684
+ const result = await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
685
+ next_attempt_at: opts.nextAttemptAt,
686
+ defer_reason: opts.reason ?? null,
687
+ claimed_until: null,
688
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
689
+ updated_at: Date.now()
690
+ })).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('verification_status', '=', 'pending_verification').where('repair_generation', '=', opts.generation).where('claimed_until', '=', opts.claimedUntil).executeTakeFirst();
691
+ return (result?.numUpdatedRows ?? 0n) > 0n;
692
+ },
693
+ async claimWatchAttempt (identity, opts) {
694
+ const result = await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
695
+ claimed_until: opts.until,
696
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
697
+ updated_at: Date.now()
698
+ })).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('verification_status', '=', 'pending_verification').where('repair_generation', '=', opts.generation).where('next_attempt_at', 'is not', null).where('next_attempt_at', '<=', opts.now).where((eb)=>eb.or([
699
+ eb('claimed_until', 'is', null),
700
+ eb('claimed_until', '<=', opts.now)
701
+ ])).executeTakeFirst();
702
+ return (result?.numUpdatedRows ?? 0n) > 0n;
703
+ },
704
+ async recordVerification (identity, input) {
705
+ if (deps.readWinner == null) {
706
+ throw new Error('recordVerification: createConnectorStore was not given a readWinner dependency');
707
+ }
708
+ const readWinner = deps.readWinner;
709
+ const maxRetries = 5;
710
+ for(let attempt = 0; attempt <= maxRetries; attempt++){
711
+ // The ENTIRE gate — row read, generation check, current-winner
712
+ // read, and the transition decision — is re-run on every attempt,
713
+ // mirroring armWatch: caching the first read's decision and only
714
+ // retrying the write would miss a concurrent re-arm or a
715
+ // concurrent winner move between attempts.
716
+ const attemptResult = await db.transaction().execute(async (tx)=>{
717
+ const base = tx.selectFrom('kubun_connector_projection_watch').selectAll().where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID);
718
+ const row = await (isPostgres ? base.forUpdate() : base).executeTakeFirst();
719
+ // No watch row for this identity: nothing to verify against. See
720
+ // `recordVerification`'s doc for why this maps to
721
+ // 'stale-generation' rather than a throw or a fabricated
722
+ // transition.
723
+ if (row == null) {
724
+ return {
725
+ settled: true,
726
+ outcome: 'stale-generation'
727
+ };
728
+ }
729
+ // GENERATION GATE FIRST, before the winner read or any decision:
730
+ // a re-arm bumped repair_generation past what this verify call
731
+ // was scheduled against. Terminal — no write, no retry.
732
+ if (row.repair_generation !== input.generation) {
733
+ return {
734
+ settled: true,
735
+ outcome: 'stale-generation'
736
+ };
737
+ }
738
+ if (input.claimedUntil != null && row.claimed_until !== input.claimedUntil) {
739
+ return {
740
+ settled: true,
741
+ outcome: 'stale-claim'
742
+ };
743
+ }
744
+ // Re-read the CURRENT winner inside this transaction/lock — never
745
+ // the caller's apply-time `input.fetchedDigest`, and never the
746
+ // top-level db (see `ReadWinner`'s doc). This is the assumption
747
+ // under test: a concurrent ingress that moved the winner between
748
+ // project and verify must be caught here, not trusted from the
749
+ // caller.
750
+ const currentWinner = await readWinner(row.document_id, tx);
751
+ const now = Date.now();
752
+ if (input.fetchedDigest != null && input.fetchedDigest === currentWinner) {
753
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
754
+ verification_status: 'verified',
755
+ observed_winner_digest: currentWinner,
756
+ verified_state_digest: currentWinner,
757
+ next_attempt_at: null,
758
+ claimed_until: null,
759
+ defer_reason: null,
760
+ version: row.version + 1,
761
+ updated_at: now
762
+ }).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('version', '=', row.version).executeTakeFirst();
763
+ return {
764
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
765
+ outcome: 'verified'
766
+ };
767
+ }
768
+ if (row.verification_attempts + 1 >= input.maxAttempts) {
769
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
770
+ verification_status: 'repair_failed',
771
+ observed_winner_digest: currentWinner,
772
+ verification_attempts: row.verification_attempts + 1,
773
+ next_attempt_at: null,
774
+ claimed_until: null,
775
+ defer_reason: null,
776
+ version: row.version + 1,
777
+ updated_at: now
778
+ }).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('version', '=', row.version).executeTakeFirst();
779
+ return {
780
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
781
+ outcome: 'repair_failed'
782
+ };
783
+ }
784
+ // Non-terminal mismatch: bump attempts, reschedule — LEAVE BOTH
785
+ // WATERMARKS UNTOUCHED. Load-bearing for verify-races-ingress: a
786
+ // concurrent winner move must not advance
787
+ // observed_winner_digest while the row is still pending.
788
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
789
+ verification_attempts: row.verification_attempts + 1,
790
+ next_attempt_at: input.dueAt,
791
+ claimed_until: null,
792
+ defer_reason: null,
793
+ version: row.version + 1,
794
+ updated_at: now
795
+ }).where('owner_did', '=', identity.ownerDID).where('connector_name', '=', identity.connectorName).where('model', '=', identity.model).where('account_key', '=', identity.accountKey).where('source_id', '=', identity.sourceID).where('version', '=', row.version).executeTakeFirst();
796
+ return {
797
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
798
+ outcome: 'pending'
799
+ };
800
+ });
801
+ if (attemptResult.settled) {
802
+ return attemptResult.outcome;
803
+ }
804
+ }
805
+ throw new Error(`recordVerification: CAS failed after ${maxRetries + 1} attempts for ${identity.ownerDID}/${identity.connectorName}/${identity.model}/${identity.accountKey}/${identity.sourceID}`);
806
+ },
807
+ // --- Boot/periodic sweep: due operations + due/diverged watches ---
808
+ async requestRepull (identity, opts) {
809
+ // Thin wrapper: re-arm only. The caller is responsible for scheduling
810
+ // the actual sweep tick that picks this back up via
811
+ // `listDueOperations` — this method's whole job is to make the watch
812
+ // due again (unconditionally opening a new generation, same as any
813
+ // other `'manual'` arm).
814
+ await api.armWatch(identity, 'manual', {
815
+ dueAt: opts.dueAt
816
+ });
817
+ },
818
+ async listDueOperations (now, opts) {
819
+ // The one non-terminal operation state, `attempted`, once its re-drive
820
+ // is due. The terminal states are excluded: a `committed` write already
821
+ // landed (its projection is tracked by the watch, below), and `dead` /
822
+ // `reconcile_only` need a user's action, not another re-drive.
823
+ const operationRows = await db.selectFrom('kubun_connector_operation').selectAll().where('state', '=', 'attempted')// A re-drive backed off into the future is not due yet.
824
+ .where((eb)=>eb.or([
825
+ eb('next_attempt_at', 'is', null),
826
+ eb('next_attempt_at', '<=', now)
827
+ ])).execute();
828
+ const operations = operationRows.map(rowToOperation);
829
+ // --- watches: union of the SCHEDULE branch and the AUDIT branch ---
830
+ // SCHEDULE branch: the ordinary due-by-time verify loop. A
831
+ // `repair_failed` row has its schedule cleared to null by
832
+ // `recordVerification` on exhaustion, so this branch naturally
833
+ // excludes exhausted rows without needing to name the status
834
+ // explicitly — a `pending_verification` row with a null
835
+ // `verified_state_digest` is serviced here, by its own schedule, and
836
+ // never by the audit branch below.
837
+ const scheduleRows = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('verification_status', '!=', 'verified').where('next_attempt_at', 'is not', null).where('next_attempt_at', '<=', now).execute();
838
+ // AUDIT branch: the crash-durable divergence sweep. `armWatch`'s
839
+ // 'ingress' reason re-arms the fast path the instant an event proves
840
+ // the winner moved — but that re-arm write can itself be lost to a
841
+ // crash between the winner moving and the write landing. This branch
842
+ // is the boot/periodic backstop that catches exactly that case,
843
+ // without reopening a K-exhausted row whose winner has not actually
844
+ // moved (which would make K unbounded).
845
+ //
846
+ // Deliberately NOT filtered on `next_attempt_at`: both `verified` and
847
+ // exhausted `repair_failed` rows have it cleared to null — filtering
848
+ // on it here would drop precisely the rows this branch exists to
849
+ // reopen. Candidates are gathered by a plain SELECT (no
850
+ // store-connector transaction held open across it — this is a
851
+ // read-only sweep, and `readWinner` is called per candidate in code
852
+ // below, never inside a `db.transaction()`, to avoid nesting a
853
+ // transaction on SQLite's single connection).
854
+ //
855
+ // The winner digest is not a store-connector column — it lives in the
856
+ // store-graph registers (`readWinnerStateDigest`) — so this
857
+ // predicate cannot be expressed in pure SQL over this table alone;
858
+ // the actual divergence check runs in code against `readWinner`.
859
+ const auditCandidateRows = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('verification_status', 'in', [
860
+ 'verified',
861
+ 'repair_failed'
862
+ ]).execute();
863
+ const identityKey = (row)=>`${row.owner_did}\u0000${row.connector_name}\u0000${row.model}\u0000${row.account_key}\u0000${row.source_id}`;
864
+ const dueByIdentity = new Map();
865
+ for (const row of scheduleRows){
866
+ dueByIdentity.set(identityKey(row), row);
867
+ }
868
+ for (const row of auditCandidateRows){
869
+ // Each candidate gets its own short-lived read via the top-level
870
+ // `db` handle — not a held-open transaction spanning every
871
+ // candidate, and not a nested store-connector transaction.
872
+ // A document whose winner cannot be read (none stored yet, say) has
873
+ // no divergence to audit: skip it rather than abort the whole sweep
874
+ // and every re-drive with it.
875
+ let currentWinner;
876
+ try {
877
+ currentWinner = await opts.readWinner(row.document_id, db);
878
+ } catch {
879
+ continue;
880
+ }
881
+ // Null-safe by construction: `currentWinner` is always a real
882
+ // digest string, so `currentWinner !== null` is trivially true when
883
+ // a watermark is null — a `repair_failed` row with
884
+ // `verified_state_digest: null` is excluded here whenever the
885
+ // current winner already equals `observed_winner_digest` (the
886
+ // K-bounded negative), and included only when the winner differs
887
+ // from BOTH watermarks.
888
+ const diverged = currentWinner !== row.verified_state_digest && currentWinner !== row.observed_winner_digest;
889
+ if (diverged) {
890
+ dueByIdentity.set(identityKey(row), row);
891
+ }
892
+ }
893
+ const watches = Array.from(dueByIdentity.values()).map(rowToWatch);
894
+ return {
895
+ operations,
896
+ watches
897
+ };
898
+ },
899
+ // --- Retention cleanup ---
900
+ async collectFinishedOperations (params) {
901
+ assertCollectParams('collectFinishedOperations', params);
902
+ const now = Date.now();
903
+ // One statement: the keep rule and the delete see the same rows, so no
904
+ // two batches (or two concurrent cleanups) can each take another row to
905
+ // be the newest. A row is only ever collected while a newer row of its
906
+ // kind exists in the same partition; the newest of a partition never has
907
+ // one, and operations never leave a terminal state, so it is never
908
+ // collected.
909
+ //
910
+ // No change events: a collected row is one a newer row supersedes (or
911
+ // one linked to no document), so no document's derived write state
912
+ // changes.
913
+ const result = await db.deleteFrom('kubun_connector_operation').where((eb)=>eb(eb.refTuple('caller_did', 'operation_id'), 'in', eb.selectFrom('kubun_connector_operation as o').select([
914
+ 'o.caller_did',
915
+ 'o.operation_id'
916
+ ]).where('o.state', 'in', [
917
+ 'committed',
918
+ 'dead'
919
+ ]).where('o.updated_at', '<', params.before).where((inner)=>inner.or([
920
+ inner('o.lease_token', 'is', null),
921
+ inner('o.lease_expires_at', '<', now)
922
+ ])).where((inner)=>{
923
+ // A newer operation of `states` on the same document with the
924
+ // same `column` (caller or owner) as `o`.
925
+ const newerExists = (column, states)=>inner.exists(inner.selectFrom('kubun_connector_operation as n').select('n.operation_id').whereRef('n.document_id', '=', 'o.document_id').whereRef(`n.${column}`, '=', `o.${column}`).where('n.state', 'in', states).where((order)=>order.or([
926
+ order('n.created_at', '>', order.ref('o.created_at')),
927
+ order.and([
928
+ order('n.created_at', '=', order.ref('o.created_at')),
929
+ order('n.caller_did', '>', order.ref('o.caller_did'))
930
+ ]),
931
+ order.and([
932
+ order('n.created_at', '=', order.ref('o.created_at')),
933
+ order('n.caller_did', '=', order.ref('o.caller_did')),
934
+ order('n.operation_id', '>', order.ref('o.operation_id'))
935
+ ])
936
+ ])));
937
+ return inner.or([
938
+ inner('o.document_id', 'is', null),
939
+ inner.and([
940
+ // Not the newest commit of its caller, nor of its owner:
941
+ // the latest commit bounds which older rows still count.
942
+ inner.or([
943
+ inner('o.state', '!=', 'committed'),
944
+ newerExists('caller_did', [
945
+ 'committed'
946
+ ])
947
+ ]),
948
+ inner.or([
949
+ inner('o.state', '!=', 'committed'),
950
+ newerExists('owner_did', [
951
+ 'committed'
952
+ ])
953
+ ]),
954
+ // Not the newest finished row of its caller, nor of its
955
+ // owner: the newest one decides whether the document shows
956
+ // a failure.
957
+ newerExists('caller_did', [
958
+ 'committed',
959
+ 'dead'
960
+ ]),
961
+ newerExists('owner_did', [
962
+ 'committed',
963
+ 'dead'
964
+ ])
965
+ ])
966
+ ]);
967
+ }).orderBy('o.updated_at').limit(params.limit).$asTuple('caller_did', 'operation_id')))// Re-checked on the row being deleted: a row a lease was taken on
968
+ // between the candidate read and the delete (Postgres, read committed)
969
+ // was written since, and is kept.
970
+ .where('updated_at', '<', params.before).where((eb)=>eb.or([
971
+ eb('lease_token', 'is', null),
972
+ eb('lease_expires_at', '<', now)
973
+ ])).executeTakeFirst();
974
+ return Number(result?.numDeletedRows ?? 0n);
177
975
  }
178
976
  };
977
+ return api;
179
978
  }