@kubun/store-connector 0.17.1 → 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,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;
@@ -0,0 +1,193 @@
1
+ import { chunked } from '@kubun/db-adapter';
2
+ import { rowToOperation, rowToWatch } from './serialization.js';
3
+ // A retention cleanup's cutoff and batch size: finite, and a positive whole
4
+ // batch. Fails closed on anything else rather than deleting unbounded.
5
+ function assertCollectParams(method, params) {
6
+ if (!Number.isFinite(params.before)) {
7
+ throw new Error(`${method}: before must be a finite number`);
8
+ }
9
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
10
+ throw new Error(`${method}: limit must be a positive integer`);
11
+ }
12
+ }
13
+ export function createRetentionMethods(context) {
14
+ const { db } = context;
15
+ const methods = {
16
+ // --- Boot/periodic sweep: due operations + due/diverged watches ---
17
+ async listDueOperations (now, opts) {
18
+ // The one non-terminal operation state, `attempted`, once its re-drive
19
+ // is due. The terminal states are excluded: a `committed` write already
20
+ // landed (its projection is tracked by the watch, below), and `dead` /
21
+ // `reconcile_only` need a user's action, not another re-drive.
22
+ const operationRows = await db.selectFrom('kubun_connector_operation').selectAll().where('state', '=', 'attempted')// A re-drive backed off into the future is not due yet.
23
+ .where((eb)=>eb.or([
24
+ eb('next_attempt_at', 'is', null),
25
+ eb('next_attempt_at', '<=', now)
26
+ ])).execute();
27
+ const operations = operationRows.map(rowToOperation);
28
+ // --- watches: union of the SCHEDULE branch and the AUDIT branch ---
29
+ // SCHEDULE branch: the ordinary due-by-time verify loop. A
30
+ // `repair_failed` row has its schedule cleared to null by
31
+ // `recordVerification` on exhaustion, so this branch naturally
32
+ // excludes exhausted rows without needing to name the status
33
+ // explicitly — a `pending_verification` row with a null
34
+ // `verified_state_digest` is serviced here, by its own schedule, and
35
+ // never by the audit branch below.
36
+ 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();
37
+ // AUDIT branch: the crash-durable divergence sweep. `armWatch`'s
38
+ // 'ingress' reason re-arms the fast path the instant an event proves
39
+ // the winner moved — but that re-arm write can itself be lost to a
40
+ // crash between the winner moving and the write landing. This branch
41
+ // is the boot/periodic backstop that catches exactly that case,
42
+ // without reopening a K-exhausted row whose winner has not actually
43
+ // moved (which would make K unbounded).
44
+ //
45
+ // Deliberately NOT filtered on `next_attempt_at`: both `verified` and
46
+ // exhausted `repair_failed` rows have it cleared to null — filtering
47
+ // on it here would drop precisely the rows this branch exists to
48
+ // reopen. Candidates are gathered by a plain SELECT (no
49
+ // store-connector transaction held open across it — this is a
50
+ // read-only sweep, and `readWinner` is called per candidate in code
51
+ // below, never inside a `db.transaction()`, to avoid nesting a
52
+ // transaction on SQLite's single connection).
53
+ //
54
+ // The winner digest is not a store-connector column — it lives in the
55
+ // store-graph registers (`readWinnerStateDigest`) — so this
56
+ // predicate cannot be expressed in pure SQL over this table alone;
57
+ // the actual divergence check runs in code against `readWinner`.
58
+ const auditCandidateRows = await db.selectFrom('kubun_connector_projection_watch').selectAll().where('verification_status', 'in', [
59
+ 'verified',
60
+ 'repair_failed'
61
+ ]).execute();
62
+ const identityKey = (row)=>`${row.owner_did}\u0000${row.connector_name}\u0000${row.model}\u0000${row.account_key}\u0000${row.source_id}`;
63
+ const dueByIdentity = new Map();
64
+ for (const row of scheduleRows){
65
+ dueByIdentity.set(identityKey(row), row);
66
+ }
67
+ // One batched read per chunk of candidates when the caller supplies a
68
+ // batch reader; a candidate absent from the map has no readable winner.
69
+ const winners = new Map();
70
+ if (opts.readWinners !== undefined) {
71
+ for (const ids of chunked(auditCandidateRows.map((row)=>row.document_id))){
72
+ for (const [id, digest] of (await opts.readWinners(ids, db))){
73
+ winners.set(id, digest);
74
+ }
75
+ }
76
+ }
77
+ for (const row of auditCandidateRows){
78
+ // Each candidate gets its own short-lived read via the top-level
79
+ // `db` handle — not a held-open transaction spanning every
80
+ // candidate, and not a nested store-connector transaction.
81
+ // A document whose winner cannot be read (none stored yet, say) has
82
+ // no divergence to audit: skip it rather than abort the whole sweep
83
+ // and every re-drive with it.
84
+ let currentWinner;
85
+ if (opts.readWinners !== undefined) {
86
+ const winner = winners.get(row.document_id);
87
+ if (winner === undefined) continue;
88
+ currentWinner = winner;
89
+ } else {
90
+ try {
91
+ currentWinner = await opts.readWinner(row.document_id, db);
92
+ } catch {
93
+ continue;
94
+ }
95
+ }
96
+ // Null-safe by construction: `currentWinner` is always a real
97
+ // digest string, so `currentWinner !== null` is trivially true when
98
+ // a watermark is null — a `repair_failed` row with
99
+ // `verified_state_digest: null` is excluded here whenever the
100
+ // current winner already equals `observed_winner_digest` (the
101
+ // K-bounded negative), and included only when the winner differs
102
+ // from BOTH watermarks.
103
+ const diverged = currentWinner !== row.verified_state_digest && currentWinner !== row.observed_winner_digest;
104
+ if (diverged) {
105
+ dueByIdentity.set(identityKey(row), row);
106
+ }
107
+ }
108
+ const watches = Array.from(dueByIdentity.values()).map(rowToWatch);
109
+ return {
110
+ operations,
111
+ watches
112
+ };
113
+ },
114
+ // --- Retention cleanup ---
115
+ async collectFinishedOperations (params) {
116
+ assertCollectParams('collectFinishedOperations', params);
117
+ const now = Date.now();
118
+ // One statement: the keep rule and the delete see the same rows, so no
119
+ // two batches (or two concurrent cleanups) can each take another row to
120
+ // be the newest. A row is only ever collected while a newer row of its
121
+ // kind exists in the same partition; the newest of a partition never has
122
+ // one, and operations never leave a terminal state, so it is never
123
+ // collected.
124
+ //
125
+ // No change events: a collected row is one a newer row supersedes (or
126
+ // one linked to no document), so no document's derived write state
127
+ // changes.
128
+ 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([
129
+ 'o.caller_did',
130
+ 'o.operation_id'
131
+ ]).where('o.state', 'in', [
132
+ 'committed',
133
+ 'dead'
134
+ ]).where('o.updated_at', '<', params.before).where((inner)=>inner.or([
135
+ inner('o.lease_token', 'is', null),
136
+ inner('o.lease_expires_at', '<', now)
137
+ ])).where((inner)=>{
138
+ // A newer operation of `states` on the same document with the
139
+ // same `column` (caller or owner) as `o`.
140
+ 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([
141
+ order('n.created_at', '>', order.ref('o.created_at')),
142
+ order.and([
143
+ order('n.created_at', '=', order.ref('o.created_at')),
144
+ order('n.caller_did', '>', order.ref('o.caller_did'))
145
+ ]),
146
+ order.and([
147
+ order('n.created_at', '=', order.ref('o.created_at')),
148
+ order('n.caller_did', '=', order.ref('o.caller_did')),
149
+ order('n.operation_id', '>', order.ref('o.operation_id'))
150
+ ])
151
+ ])));
152
+ return inner.or([
153
+ inner('o.document_id', 'is', null),
154
+ inner.and([
155
+ // Not the newest commit of its caller, nor of its owner:
156
+ // the latest commit bounds which older rows still count.
157
+ inner.or([
158
+ inner('o.state', '!=', 'committed'),
159
+ newerExists('caller_did', [
160
+ 'committed'
161
+ ])
162
+ ]),
163
+ inner.or([
164
+ inner('o.state', '!=', 'committed'),
165
+ newerExists('owner_did', [
166
+ 'committed'
167
+ ])
168
+ ]),
169
+ // Not the newest finished row of its caller, nor of its
170
+ // owner: the newest one decides whether the document shows
171
+ // a failure.
172
+ newerExists('caller_did', [
173
+ 'committed',
174
+ 'dead'
175
+ ]),
176
+ newerExists('owner_did', [
177
+ 'committed',
178
+ 'dead'
179
+ ])
180
+ ])
181
+ ]);
182
+ }).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
183
+ // between the candidate read and the delete (Postgres, read committed)
184
+ // was written since, and is kept.
185
+ .where('updated_at', '<', params.before).where((eb)=>eb.or([
186
+ eb('lease_token', 'is', null),
187
+ eb('lease_expires_at', '<', now)
188
+ ])).executeTakeFirst();
189
+ return Number(result?.numDeletedRows ?? 0n);
190
+ }
191
+ };
192
+ return methods;
193
+ }
@@ -0,0 +1,63 @@
1
+ import type { ConnectorOperation, ProjectionWatch, SyncState } from './api-types.js';
2
+ export type SyncStateRow = {
3
+ checkpoint: string | null;
4
+ last_synced_at: string;
5
+ entity_count: number;
6
+ status: string;
7
+ error: string | null;
8
+ lease_expires_at: number | null;
9
+ recovery: string | null;
10
+ version: number;
11
+ incarnation: string | null;
12
+ };
13
+ export type OperationRow = {
14
+ caller_did: string;
15
+ operation_id: string;
16
+ node_key: string;
17
+ owner_did: string;
18
+ connector_name: string;
19
+ model: string;
20
+ op: string;
21
+ account_key: string;
22
+ target: string;
23
+ source_id: string | null;
24
+ deterministic_event_id: string;
25
+ payload_hash: string;
26
+ reissue_intent: string;
27
+ provider_etag: string | null;
28
+ document_id: string | null;
29
+ result_entity: string | null;
30
+ state: string;
31
+ attempts: number;
32
+ next_attempt_at: number | null;
33
+ last_kind: string | null;
34
+ last_http_status: number | null;
35
+ version: number;
36
+ incarnation: string | null;
37
+ lease_token: string | null;
38
+ lease_expires_at: number | null;
39
+ created_at: number;
40
+ updated_at: number;
41
+ };
42
+ export type WatchRow = {
43
+ owner_did: string;
44
+ connector_name: string;
45
+ model: string;
46
+ account_key: string;
47
+ source_id: string;
48
+ document_id: string;
49
+ verified_state_digest: string | null;
50
+ observed_winner_digest: string | null;
51
+ verification_status: string;
52
+ verification_attempts: number;
53
+ repair_generation: number;
54
+ next_attempt_at: number | null;
55
+ version: number;
56
+ created_at: number;
57
+ updated_at: number;
58
+ };
59
+ export declare function decodeJSONColumn(value: unknown): unknown;
60
+ export declare function encodeJSONColumn(value: unknown): string | null;
61
+ export declare function rowToSyncState(row: SyncStateRow): SyncState;
62
+ export declare function rowToOperation(row: OperationRow): ConnectorOperation;
63
+ export declare function rowToWatch(row: WatchRow): ProjectionWatch;
@@ -0,0 +1,79 @@
1
+ // --- Serialization helpers ---
2
+ // `checkpoint` and `recovery` are JSON columns: ParseJSONResultsPlugin may have
3
+ // already parsed them (Postgres) or left them as strings (SQLite), so handle
4
+ // both. Mirrors the `scopes` handling in `consumePendingAuth`.
5
+ export function decodeJSONColumn(value) {
6
+ if (value == null) return null;
7
+ return typeof value === 'string' ? JSON.parse(value) : value;
8
+ }
9
+ export function encodeJSONColumn(value) {
10
+ return value != null ? JSON.stringify(value) : null;
11
+ }
12
+ export function rowToSyncState(row) {
13
+ const state = {
14
+ checkpoint: decodeJSONColumn(row.checkpoint),
15
+ lastSyncedAt: row.last_synced_at,
16
+ entityCount: row.entity_count,
17
+ status: row.status,
18
+ leaseExpiresAt: row.lease_expires_at,
19
+ recovery: decodeJSONColumn(row.recovery),
20
+ version: row.version
21
+ };
22
+ if (row.incarnation != null) {
23
+ state.incarnation = row.incarnation;
24
+ }
25
+ if (row.error != null) {
26
+ state.error = row.error;
27
+ }
28
+ return state;
29
+ }
30
+ export function rowToOperation(row) {
31
+ return {
32
+ callerDID: row.caller_did,
33
+ operationID: row.operation_id,
34
+ nodeKey: row.node_key,
35
+ ownerDID: row.owner_did,
36
+ connectorName: row.connector_name,
37
+ model: row.model,
38
+ op: row.op,
39
+ accountKey: row.account_key,
40
+ target: row.target,
41
+ sourceID: row.source_id,
42
+ deterministicEventID: row.deterministic_event_id,
43
+ payloadHash: row.payload_hash,
44
+ reissueIntent: decodeJSONColumn(row.reissue_intent),
45
+ providerEtag: row.provider_etag,
46
+ documentID: row.document_id,
47
+ resultEntity: decodeJSONColumn(row.result_entity),
48
+ state: row.state,
49
+ attempts: row.attempts,
50
+ nextAttemptAt: row.next_attempt_at,
51
+ lastKind: row.last_kind,
52
+ lastHTTPStatus: row.last_http_status,
53
+ version: row.version,
54
+ incarnation: row.incarnation,
55
+ leaseToken: row.lease_token,
56
+ leaseExpiresAt: row.lease_expires_at,
57
+ createdAt: row.created_at,
58
+ updatedAt: row.updated_at
59
+ };
60
+ }
61
+ export function rowToWatch(row) {
62
+ return {
63
+ ownerDID: row.owner_did,
64
+ connectorName: row.connector_name,
65
+ model: row.model,
66
+ accountKey: row.account_key,
67
+ sourceID: row.source_id,
68
+ documentID: row.document_id,
69
+ verifiedStateDigest: row.verified_state_digest,
70
+ observedWinnerDigest: row.observed_winner_digest,
71
+ verificationStatus: row.verification_status,
72
+ verificationAttempts: row.verification_attempts,
73
+ repairGeneration: row.repair_generation,
74
+ nextAttemptAt: row.next_attempt_at,
75
+ version: row.version,
76
+ createdAt: row.created_at,
77
+ updatedAt: row.updated_at
78
+ };
79
+ }
@@ -0,0 +1,11 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { ConnectorStoreDeps } from './api-types.js';
4
+ import type { ConnectorTables } from './tables.js';
5
+ export type ConnectorStoreContext = {
6
+ db: Kysely<ConnectorTables>;
7
+ adapter: Adapter;
8
+ isPostgres: boolean;
9
+ deps: ConnectorStoreDeps;
10
+ getRandomID: () => string;
11
+ };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,5 @@
1
+ import type { ConnectorStoreAPI } from './api-types.js';
2
+ import type { ConnectorStoreContext } from './store-context.js';
3
+ export declare const MAX_SWEEP_APPLY_ATTEMPTS = 5;
4
+ export type SweepStateMethods = Pick<ConnectorStoreAPI, 'recordRelistReceipt' | 'listSweepReceipts' | 'listSweepCandidates' | 'recordSweepOutcome' | 'recordSweepApplyFailure' | 'markSweepCandidateApplied' | 'commitSweepCheckpoint' | 'getSweepCheckpoint'>;
5
+ export declare function createSweepStateMethods({ db, deps }: ConnectorStoreContext): SweepStateMethods;