@kubun/store-connector 0.17.2 → 0.18.0

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.
@@ -0,0 +1,223 @@
1
+ import { chunked, IN_LIST_CHUNK } from '@kubun/db-adapter';
2
+ import { encodeJSONColumn, rowToOperation } from './serialization.js';
3
+ // The `send_marker` of an attempt that more than one run marked. It matches
4
+ // no run's marker, so no run of that attempt can refund it.
5
+ const CONTESTED_SEND_MARKER = '*contested*';
6
+ // The `listTrackedOperations` order: oldest first, then caller and operation id.
7
+ function compareOperations(a, b) {
8
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
9
+ if (a.callerDID !== b.callerDID) return a.callerDID < b.callerDID ? -1 : 1;
10
+ if (a.operationID !== b.operationID) return a.operationID < b.operationID ? -1 : 1;
11
+ return 0;
12
+ }
13
+ export function createOperationsMethods(context) {
14
+ const { db, getRandomID } = context;
15
+ const methods = {
16
+ // --- Connector write-back operation envelope ---
17
+ async claimOperation (input) {
18
+ const now = Date.now();
19
+ // INSERT ... ON CONFLICT DO NOTHING RETURNING *, mirroring the insert-race
20
+ // half of `updateSyncProgress`: a fresh key wins the insert and is
21
+ // returned directly; a conflicting key returns nothing here and falls
22
+ // through to the read+compare below.
23
+ const insertedRow = await db.insertInto('kubun_connector_operation').values({
24
+ caller_did: input.callerDID,
25
+ operation_id: input.operationID,
26
+ node_key: getRandomID(),
27
+ owner_did: input.ownerDID,
28
+ connector_name: input.connectorName,
29
+ model: input.model,
30
+ op: input.op,
31
+ account_key: input.accountKey,
32
+ target: input.target,
33
+ source_id: input.sourceID,
34
+ deterministic_event_id: input.deterministicEventID,
35
+ payload_hash: input.payloadHash,
36
+ reissue_intent: encodeJSONColumn(input.reissueIntent) ?? 'null',
37
+ provider_etag: input.providerEtag ?? null,
38
+ document_id: input.documentID ?? null,
39
+ result_entity: null,
40
+ state: 'attempted',
41
+ attempts: 0,
42
+ next_attempt_at: null,
43
+ last_kind: null,
44
+ last_http_status: null,
45
+ version: 0,
46
+ incarnation: getRandomID(),
47
+ lease_token: input.leaseToken,
48
+ lease_expires_at: input.leaseExpiresAt,
49
+ created_at: now,
50
+ updated_at: now
51
+ }).onConflict((oc)=>oc.columns([
52
+ 'caller_did',
53
+ 'operation_id'
54
+ ]).doNothing()).returningAll().executeTakeFirst();
55
+ if (insertedRow != null) {
56
+ return {
57
+ status: 'claimed',
58
+ record: rowToOperation(insertedRow)
59
+ };
60
+ }
61
+ const row = await db.selectFrom('kubun_connector_operation').selectAll().where('caller_did', '=', input.callerDID).where('operation_id', '=', input.operationID).executeTakeFirst();
62
+ if (row == null) {
63
+ // The conflicting row vanished between the failed insert and this read
64
+ // (e.g. a concurrent delete) — neither a match nor a safe claim.
65
+ return {
66
+ status: 'conflict'
67
+ };
68
+ }
69
+ // The immutable envelope tuple: equal on every field → the identical
70
+ // request replayed (`exists`); any drift is a genuine conflict, and the
71
+ // stored row is never overwritten.
72
+ 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;
73
+ return envelopeMatches ? {
74
+ status: 'exists',
75
+ record: rowToOperation(row)
76
+ } : {
77
+ status: 'conflict'
78
+ };
79
+ },
80
+ async acquireLease ({ callerDID, operationID, token, expiresAt }) {
81
+ const now = Date.now();
82
+ const result = await db.updateTable('kubun_connector_operation').set({
83
+ lease_token: token,
84
+ lease_expires_at: expiresAt,
85
+ updated_at: now
86
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
87
+ eb('lease_token', 'is', null),
88
+ eb('lease_expires_at', '<', now),
89
+ eb('lease_token', '=', token)
90
+ ])).returning('operation_id').executeTakeFirst();
91
+ return result != null;
92
+ },
93
+ async renewLease ({ callerDID, operationID, token, expiresAt }) {
94
+ const now = Date.now();
95
+ const result = await db.updateTable('kubun_connector_operation').set({
96
+ lease_expires_at: expiresAt,
97
+ updated_at: now
98
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
99
+ eb('lease_token', 'is', null),
100
+ eb('lease_expires_at', '<', now),
101
+ eb('lease_token', '=', token)
102
+ ])).returning('operation_id').executeTakeFirst();
103
+ return result != null;
104
+ },
105
+ async releaseLease ({ callerDID, operationID, token }) {
106
+ const now = Date.now();
107
+ const result = await db.updateTable('kubun_connector_operation').set({
108
+ lease_token: null,
109
+ lease_expires_at: null,
110
+ updated_at: now
111
+ }).where('caller_did', '=', callerDID).where('operation_id', '=', operationID).where((eb)=>eb.or([
112
+ eb('lease_token', 'is', null),
113
+ eb('lease_expires_at', '<', now),
114
+ eb('lease_token', '=', token)
115
+ ])).returning('operation_id').executeTakeFirst();
116
+ return result != null;
117
+ },
118
+ async getOperation (callerDID, operationID) {
119
+ const row = await db.selectFrom('kubun_connector_operation').selectAll().where('caller_did', '=', callerDID).where('operation_id', '=', operationID).executeTakeFirst();
120
+ return row != null ? rowToOperation(row) : null;
121
+ },
122
+ async listTrackedOperations (filter) {
123
+ if (filter.states?.length === 0 || filter.documentIDs?.length === 0) return [];
124
+ if (filter.documentIDs != null && filter.documentIDs.length > IN_LIST_CHUNK) {
125
+ const chunks = [];
126
+ for (const documentIDs of chunked(filter.documentIDs)){
127
+ chunks.push(await methods.listTrackedOperations({
128
+ ...filter,
129
+ documentIDs
130
+ }));
131
+ }
132
+ return chunks.flat().sort(compareOperations);
133
+ }
134
+ 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([
135
+ eb('caller_did', '=', filter.visibleTo),
136
+ eb('owner_did', '=', filter.visibleTo)
137
+ ]))).$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();
138
+ return rows.map(rowToOperation);
139
+ },
140
+ async hasNewerOperationOnDocument (callerDID, operationID) {
141
+ 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([
142
+ order('n.created_at', '>', order.ref('o.created_at')),
143
+ order.and([
144
+ order('n.created_at', '=', order.ref('o.created_at')),
145
+ order('n.caller_did', '>', order.ref('o.caller_did'))
146
+ ]),
147
+ order.and([
148
+ order('n.created_at', '=', order.ref('o.created_at')),
149
+ order('n.caller_did', '=', order.ref('o.caller_did')),
150
+ order('n.operation_id', '>', order.ref('o.operation_id'))
151
+ ])
152
+ ])).limit(1).executeTakeFirst();
153
+ return row != null;
154
+ },
155
+ async markSendStarted (params) {
156
+ if (params.marker === CONTESTED_SEND_MARKER) {
157
+ throw new Error('markSendStarted: marker is reserved');
158
+ }
159
+ const result = await db.updateTable('kubun_connector_operation').set((eb)=>({
160
+ // A second run marking the same attempt means an earlier run may
161
+ // already have sent: the attempt becomes refundable by no run.
162
+ send_marker: eb.case().when(eb.or([
163
+ eb('send_marker', 'is', null),
164
+ eb('send_marker', '=', params.marker)
165
+ ])).then(params.marker).else(CONTESTED_SEND_MARKER).end(),
166
+ updated_at: Date.now()
167
+ })).where('caller_did', '=', params.callerDID).where('operation_id', '=', params.operationID).where('state', '=', 'attempted').where('lease_token', '=', params.leaseToken).executeTakeFirst();
168
+ return (result?.numUpdatedRows ?? 0n) > 0n;
169
+ },
170
+ async deferOperation (params) {
171
+ const now = Date.now();
172
+ const result = await db.updateTable('kubun_connector_operation').set({
173
+ next_attempt_at: params.nextAttemptAt,
174
+ defer_reason: params.reason ?? null,
175
+ updated_at: now
176
+ }).where('caller_did', '=', params.callerDID).where('operation_id', '=', params.operationID).where('state', '=', 'attempted').where((eb)=>{
177
+ const free = [
178
+ eb('lease_token', 'is', null),
179
+ eb('lease_expires_at', '<', now)
180
+ ];
181
+ return eb.or(params.leaseToken != null ? [
182
+ ...free,
183
+ eb('lease_token', '=', params.leaseToken)
184
+ ] : free);
185
+ }).executeTakeFirst();
186
+ return (result?.numUpdatedRows ?? 0n) > 0n;
187
+ },
188
+ async recordOperation (input) {
189
+ // The type excludes `'committed'` from `toState`; this guards a caller
190
+ // that forces it through with a cast.
191
+ if (input.toState === 'committed') {
192
+ throw new Error('recordOperation cannot reach committed; use finalizeOperationAndArmWatch');
193
+ }
194
+ const now = Date.now();
195
+ const result = await db.updateTable('kubun_connector_operation').set((eb)=>({
196
+ state: input.toState,
197
+ provider_etag: input.providerEtag ?? null,
198
+ attempts: input.refundAttempt === true ? eb('attempts', '-', 1) : input.countAttempt === false ? eb.ref('attempts') : eb('attempts', '+', 1),
199
+ next_attempt_at: input.nextAttemptAt ?? null,
200
+ defer_reason: input.deferReason ?? null,
201
+ // A newly consumed attempt has started no send yet. A refund
202
+ // clears the marker too: it only applies when the attempt is
203
+ // unmarked or marked by the refunding run alone (see the guard
204
+ // below), so a contested or other run's mark is never erased, and
205
+ // the next run of the attempt starts unmarked.
206
+ ...input.refundAttempt === true || input.countAttempt !== false ? {
207
+ send_marker: null
208
+ } : {},
209
+ last_kind: input.kind ?? null,
210
+ last_http_status: input.httpStatus ?? null,
211
+ version: eb('version', '+', 1),
212
+ updated_at: now
213
+ })).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([
214
+ eb('send_marker', 'is', null),
215
+ ...input.sendMarker != null ? [
216
+ eb('send_marker', '=', input.sendMarker)
217
+ ] : []
218
+ ]))).executeTakeFirst();
219
+ return (result?.numUpdatedRows ?? 0n) > 0n;
220
+ }
221
+ };
222
+ return methods;
223
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export type PendingAuthMethods = Pick<ConnectorStoreAPI, 'createPendingAuth' | 'getPendingAuth' | 'consumePendingAuth' | 'deleteExpiredPendingAuth'>;
4
+ export declare function createPendingAuthMethods(context: ConnectorStoreContext): PendingAuthMethods;
@@ -0,0 +1,69 @@
1
+ export function createPendingAuthMethods(context) {
2
+ const { db } = context;
3
+ const methods = {
4
+ // --- Pending OAuth authorization operations ---
5
+ async createPendingAuth (record) {
6
+ await db.insertInto('kubun_connector_pending_auth').values({
7
+ state: record.state,
8
+ code_verifier: record.codeVerifier,
9
+ provider: record.provider,
10
+ owner_did: record.ownerDID,
11
+ owner_wrappable_did: record.ownerWrappableDID ?? null,
12
+ controller_wrappable_did: record.controllerWrappableDID ?? null,
13
+ created_at: record.createdAt,
14
+ redirect_url: record.redirectURL,
15
+ scopes: JSON.stringify(record.scopes),
16
+ is_incremental: record.incremental ? 1 : 0,
17
+ expires_at: record.expiresAt
18
+ }).execute();
19
+ },
20
+ async getPendingAuth (state) {
21
+ const row = await db.selectFrom('kubun_connector_pending_auth').selectAll().where('state', '=', state).executeTakeFirst();
22
+ return row == null ? null : {
23
+ state: row.state,
24
+ codeVerifier: row.code_verifier,
25
+ provider: row.provider,
26
+ ownerDID: row.owner_did,
27
+ ownerWrappableDID: row.owner_wrappable_did,
28
+ controllerWrappableDID: row.controller_wrappable_did,
29
+ createdAt: row.created_at,
30
+ redirectURL: row.redirect_url,
31
+ scopes: typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes,
32
+ incremental: row.is_incremental === 1,
33
+ expiresAt: row.expires_at
34
+ };
35
+ },
36
+ async consumePendingAuth (state) {
37
+ // Atomic single-use read: DELETE ... RETURNING removes and returns the row
38
+ // in one statement so concurrent callbacks cannot both consume the state.
39
+ const row = await db.deleteFrom('kubun_connector_pending_auth').where('state', '=', state).returningAll().executeTakeFirst();
40
+ if (row == null) return null;
41
+ return {
42
+ state: row.state,
43
+ codeVerifier: row.code_verifier,
44
+ provider: row.provider,
45
+ ownerDID: row.owner_did,
46
+ ownerWrappableDID: row.owner_wrappable_did,
47
+ controllerWrappableDID: row.controller_wrappable_did,
48
+ createdAt: row.created_at,
49
+ redirectURL: row.redirect_url,
50
+ // ParseJSONResultsPlugin may have already parsed this column, same as
51
+ // `checkpoint` in `rowToSyncState`.
52
+ scopes: typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes,
53
+ incremental: row.is_incremental === 1,
54
+ expiresAt: row.expires_at
55
+ };
56
+ },
57
+ async deleteExpiredPendingAuth (cutoffMs) {
58
+ // Strict `<` matches @kokuin/oauth `completeAuthorization`, which treats a
59
+ // record as expired only when `expiresAt < now`. A record at exactly
60
+ // `expiresAt === cutoffMs` is therefore still consumable and is kept here
61
+ // (the next sweep, once `now > expiresAt`, removes it). This intentionally
62
+ // keeps one more record than the `PendingAuthStore.deleteExpired` port doc
63
+ // ("at or before nowMs") so the sweep and the consume check agree on the
64
+ // expiry boundary.
65
+ await db.deleteFrom('kubun_connector_pending_auth').where('expires_at', '<', cutoffMs).execute();
66
+ }
67
+ };
68
+ return methods;
69
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export type ProjectionWatchesMethods = Pick<ConnectorStoreAPI, 'getWatch' | 'getWatchByDocument' | 'listWatchesByDocuments' | 'armWatch' | 'resumeOfflineDeferred' | 'resumeAuthDeferred' | 'deferWatch' | 'claimWatchAttempt' | 'recordVerification'>;
4
+ export declare function createProjectionWatchesMethods(context: ConnectorStoreContext): ProjectionWatchesMethods;
@@ -0,0 +1,265 @@
1
+ import { chunked } from '@kubun/db-adapter';
2
+ import { rowToWatch } from './serialization.js';
3
+ export function createProjectionWatchesMethods(context) {
4
+ const { db, deps, isPostgres } = context;
5
+ const methods = {
6
+ // --- Connector projection verify/repair watch reads ---
7
+ async getWatch (identity) {
8
+ 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();
9
+ return row != null ? rowToWatch(row) : null;
10
+ },
11
+ async getWatchByDocument (documentID) {
12
+ const row = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('document_id', '=', documentID).executeTakeFirst();
13
+ return row != null ? rowToWatch(row) : null;
14
+ },
15
+ async listWatchesByDocuments (documentIDs) {
16
+ const watches = [];
17
+ for (const chunk of chunked(documentIDs)){
18
+ const rows = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('document_id', 'in', chunk).execute();
19
+ watches.push(...rows.map(rowToWatch));
20
+ }
21
+ return watches;
22
+ },
23
+ async armWatch (identity, reason, opts) {
24
+ if (reason === 'finalize') {
25
+ throw new Error("armWatch: reason 'finalize' is reserved — the finalize-arm transition is inlined " + 'in finalizeOperationAndArmWatch and must not be invoked through armWatch');
26
+ }
27
+ if (deps.readWinner == null) {
28
+ throw new Error('armWatch: createConnectorStore was not given a readWinner dependency ' + "(required for reasons 'ingress'/'manual')");
29
+ }
30
+ const readWinner = deps.readWinner;
31
+ const maxRetries = 5;
32
+ for(let attempt = 0; attempt <= maxRetries; attempt++){
33
+ // The ENTIRE gate — row read, current-winner read, and the
34
+ // ingress/manual decision — is re-run on every attempt, including
35
+ // retries. A concurrent writer between attempts may have changed the
36
+ // watch's status/watermarks such that the correct outcome flips
37
+ // (e.g. it already opened the generation, so this call must
38
+ // coalesce/noop rather than open a second) — caching the first
39
+ // read's decision and only retrying the write would miss that.
40
+ const attemptResult = await db.transaction().execute(async (tx)=>{
41
+ 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);
42
+ const row = await (isPostgres ? base.forUpdate() : base).executeTakeFirst();
43
+ // No watch row for this identity yet: nothing to arm (the row is
44
+ // created by `finalizeOperationAndArmWatch`, not here).
45
+ if (row == null) {
46
+ return {
47
+ settled: true,
48
+ outcome: 'noop'
49
+ };
50
+ }
51
+ // Read the CURRENT winner on the same transaction/lock as the row
52
+ // above — never the top-level db (see `ReadWinner`'s doc).
53
+ const currentWinner = await readWinner(row.document_id, tx);
54
+ const now = Date.now();
55
+ const openGeneration = async ()=>{
56
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
57
+ repair_generation: row.repair_generation + 1,
58
+ verification_attempts: 0,
59
+ verification_status: 'pending_verification',
60
+ observed_winner_digest: currentWinner,
61
+ next_attempt_at: opts.dueAt,
62
+ // A claim on the superseded generation does not carry over.
63
+ claimed_until: null,
64
+ defer_reason: null,
65
+ version: row.version + 1,
66
+ updated_at: now
67
+ }).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();
68
+ return {
69
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
70
+ outcome: 'opened'
71
+ };
72
+ };
73
+ if (reason === 'manual') {
74
+ // Unconditional: overrides status and the coalesce rule.
75
+ return await openGeneration();
76
+ }
77
+ // reason === 'ingress': the authoritative in-CAS re-check. `E`
78
+ // must equal the freshly-read current winner AND differ from both
79
+ // watermarks — this is what a caller-side pre-filter can only
80
+ // approximate; this is the check that actually gates the write.
81
+ const triggeringWinner = opts.triggeringWinner;
82
+ const gatePasses = triggeringWinner != null && triggeringWinner === currentWinner && triggeringWinner !== row.verified_state_digest && triggeringWinner !== row.observed_winner_digest;
83
+ if (!gatePasses) {
84
+ return {
85
+ settled: true,
86
+ outcome: 'noop'
87
+ };
88
+ }
89
+ if (row.verification_status === 'verified' || row.verification_status === 'repair_failed') {
90
+ return await openGeneration();
91
+ }
92
+ // row.verification_status === 'pending_verification': coalesce.
93
+ // `min` computed in JS from the already-read row value and written
94
+ // as a literal — SQL LEAST/MIN differ across adapters.
95
+ const coalescedNextAttemptAt = row.next_attempt_at == null ? opts.dueAt : Math.min(row.next_attempt_at, opts.dueAt);
96
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
97
+ next_attempt_at: coalescedNextAttemptAt,
98
+ version: row.version + 1,
99
+ updated_at: now
100
+ }).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();
101
+ return {
102
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
103
+ outcome: 'coalesced'
104
+ };
105
+ });
106
+ if (attemptResult.settled) {
107
+ return attemptResult.outcome;
108
+ }
109
+ }
110
+ throw new Error(`armWatch: CAS failed after ${maxRetries + 1} attempts for ${identity.ownerDID}/${identity.connectorName}/${identity.model}/${identity.accountKey}/${identity.sourceID}`);
111
+ },
112
+ async resumeOfflineDeferred (now) {
113
+ await db.updateTable('kubun_connector_operation').set({
114
+ next_attempt_at: now,
115
+ defer_reason: null,
116
+ updated_at: now
117
+ }).where('state', '=', 'attempted').where('defer_reason', '=', 'offline').execute();
118
+ await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
119
+ next_attempt_at: now,
120
+ defer_reason: null,
121
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
122
+ updated_at: now
123
+ })).where('verification_status', '=', 'pending_verification').where('defer_reason', '=', 'offline').execute();
124
+ },
125
+ async resumeAuthDeferred (params) {
126
+ if (params.connectorNames.length === 0) return;
127
+ await db.updateTable('kubun_connector_operation').set({
128
+ next_attempt_at: params.now,
129
+ defer_reason: null,
130
+ updated_at: params.now
131
+ }).where('state', '=', 'attempted').where('defer_reason', '=', 'auth').where('owner_did', '=', params.ownerDID).where('connector_name', 'in', params.connectorNames).execute();
132
+ await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
133
+ next_attempt_at: params.now,
134
+ defer_reason: null,
135
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
136
+ updated_at: params.now
137
+ })).where('verification_status', '=', 'pending_verification').where('defer_reason', '=', 'auth').where('owner_did', '=', params.ownerDID).where('connector_name', 'in', params.connectorNames).execute();
138
+ },
139
+ async deferWatch (identity, opts) {
140
+ const result = await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
141
+ next_attempt_at: opts.nextAttemptAt,
142
+ defer_reason: opts.reason ?? null,
143
+ claimed_until: null,
144
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
145
+ updated_at: Date.now()
146
+ })).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();
147
+ return (result?.numUpdatedRows ?? 0n) > 0n;
148
+ },
149
+ async claimWatchAttempt (identity, opts) {
150
+ const result = await db.updateTable('kubun_connector_projection_watch').set((eb)=>({
151
+ claimed_until: opts.until,
152
+ version: eb('kubun_connector_projection_watch.version', '+', 1),
153
+ updated_at: Date.now()
154
+ })).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([
155
+ eb('claimed_until', 'is', null),
156
+ eb('claimed_until', '<=', opts.now)
157
+ ])).executeTakeFirst();
158
+ return (result?.numUpdatedRows ?? 0n) > 0n;
159
+ },
160
+ async recordVerification (identity, input) {
161
+ if (deps.readWinner == null) {
162
+ throw new Error('recordVerification: createConnectorStore was not given a readWinner dependency');
163
+ }
164
+ const readWinner = deps.readWinner;
165
+ const maxRetries = 5;
166
+ for(let attempt = 0; attempt <= maxRetries; attempt++){
167
+ // The ENTIRE gate — row read, generation check, current-winner
168
+ // read, and the transition decision — is re-run on every attempt,
169
+ // mirroring armWatch: caching the first read's decision and only
170
+ // retrying the write would miss a concurrent re-arm or a
171
+ // concurrent winner move between attempts.
172
+ const attemptResult = await db.transaction().execute(async (tx)=>{
173
+ 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);
174
+ const row = await (isPostgres ? base.forUpdate() : base).executeTakeFirst();
175
+ // No watch row for this identity: nothing to verify against. See
176
+ // `recordVerification`'s doc for why this maps to
177
+ // 'stale-generation' rather than a throw or a fabricated
178
+ // transition.
179
+ if (row == null) {
180
+ return {
181
+ settled: true,
182
+ outcome: 'stale-generation'
183
+ };
184
+ }
185
+ // GENERATION GATE FIRST, before the winner read or any decision:
186
+ // a re-arm bumped repair_generation past what this verify call
187
+ // was scheduled against. Terminal — no write, no retry.
188
+ if (row.repair_generation !== input.generation) {
189
+ return {
190
+ settled: true,
191
+ outcome: 'stale-generation'
192
+ };
193
+ }
194
+ if (input.claimedUntil != null && row.claimed_until !== input.claimedUntil) {
195
+ return {
196
+ settled: true,
197
+ outcome: 'stale-claim'
198
+ };
199
+ }
200
+ // Re-read the CURRENT winner inside this transaction/lock — never
201
+ // the caller's apply-time `input.fetchedDigest`, and never the
202
+ // top-level db (see `ReadWinner`'s doc). This is the assumption
203
+ // under test: a concurrent ingress that moved the winner between
204
+ // project and verify must be caught here, not trusted from the
205
+ // caller.
206
+ const currentWinner = await readWinner(row.document_id, tx);
207
+ const now = Date.now();
208
+ if (input.fetchedDigest != null && input.fetchedDigest === currentWinner) {
209
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
210
+ verification_status: 'verified',
211
+ observed_winner_digest: currentWinner,
212
+ verified_state_digest: currentWinner,
213
+ next_attempt_at: null,
214
+ claimed_until: null,
215
+ defer_reason: null,
216
+ version: row.version + 1,
217
+ updated_at: now
218
+ }).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();
219
+ return {
220
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
221
+ outcome: 'verified'
222
+ };
223
+ }
224
+ if (row.verification_attempts + 1 >= input.maxAttempts) {
225
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
226
+ verification_status: 'repair_failed',
227
+ observed_winner_digest: currentWinner,
228
+ verification_attempts: row.verification_attempts + 1,
229
+ next_attempt_at: null,
230
+ claimed_until: null,
231
+ defer_reason: null,
232
+ version: row.version + 1,
233
+ updated_at: now
234
+ }).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();
235
+ return {
236
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
237
+ outcome: 'repair_failed'
238
+ };
239
+ }
240
+ // Non-terminal mismatch: bump attempts, reschedule — LEAVE BOTH
241
+ // WATERMARKS UNTOUCHED. Load-bearing for verify-races-ingress: a
242
+ // concurrent winner move must not advance
243
+ // observed_winner_digest while the row is still pending.
244
+ const updated = await tx.updateTable('kubun_connector_projection_watch').set({
245
+ verification_attempts: row.verification_attempts + 1,
246
+ next_attempt_at: input.dueAt,
247
+ claimed_until: null,
248
+ defer_reason: null,
249
+ version: row.version + 1,
250
+ updated_at: now
251
+ }).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();
252
+ return {
253
+ settled: (updated?.numUpdatedRows ?? 0n) > 0n,
254
+ outcome: 'pending'
255
+ };
256
+ });
257
+ if (attemptResult.settled) {
258
+ return attemptResult.outcome;
259
+ }
260
+ }
261
+ throw new Error(`recordVerification: CAS failed after ${maxRetries + 1} attempts for ${identity.ownerDID}/${identity.connectorName}/${identity.model}/${identity.accountKey}/${identity.sourceID}`);
262
+ }
263
+ };
264
+ return methods;
265
+ }
@@ -0,0 +1,4 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export type RetentionMethods = Pick<ConnectorStoreAPI, 'listDueOperations' | 'collectFinishedOperations'>;
4
+ export declare function createRetentionMethods(context: ConnectorStoreContext): RetentionMethods;