@kubun/store-connector 0.17.0 → 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.d.ts +287 -7
- package/lib/api.js +882 -35
- package/lib/definition.d.ts +7 -2
- package/lib/definition.js +11 -5
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/lib/migrations.js +75 -1
- package/lib/tables.d.ts +57 -0
- package/package.json +3 -3
package/lib/api.js
CHANGED
|
@@ -1,25 +1,135 @@
|
|
|
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 ---
|
|
6
|
+
// `checkpoint` and `recovery` are JSON columns: ParseJSONResultsPlugin may have
|
|
7
|
+
// already parsed them (Postgres) or left them as strings (SQLite), so handle
|
|
8
|
+
// both. Mirrors the `scopes` handling in `consumePendingAuth`.
|
|
9
|
+
function decodeJSONColumn(value) {
|
|
10
|
+
if (value == null) return null;
|
|
11
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
12
|
+
}
|
|
13
|
+
function encodeJSONColumn(value) {
|
|
14
|
+
return value != null ? JSON.stringify(value) : null;
|
|
15
|
+
}
|
|
2
16
|
function rowToSyncState(row) {
|
|
3
|
-
// ParseJSONResultsPlugin may have already parsed the checkpoint value
|
|
4
|
-
let checkpoint = null;
|
|
5
|
-
if (row.checkpoint != null) {
|
|
6
|
-
checkpoint = typeof row.checkpoint === 'string' ? JSON.parse(row.checkpoint) : row.checkpoint;
|
|
7
|
-
}
|
|
8
17
|
const state = {
|
|
9
|
-
checkpoint,
|
|
18
|
+
checkpoint: decodeJSONColumn(row.checkpoint),
|
|
10
19
|
lastSyncedAt: row.last_synced_at,
|
|
11
20
|
entityCount: row.entity_count,
|
|
12
21
|
status: row.status,
|
|
13
|
-
leaseExpiresAt: row.lease_expires_at
|
|
22
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
23
|
+
recovery: decodeJSONColumn(row.recovery),
|
|
24
|
+
version: row.version
|
|
14
25
|
};
|
|
26
|
+
if (row.incarnation != null) {
|
|
27
|
+
state.incarnation = row.incarnation;
|
|
28
|
+
}
|
|
15
29
|
if (row.error != null) {
|
|
16
30
|
state.error = row.error;
|
|
17
31
|
}
|
|
18
32
|
return state;
|
|
19
33
|
}
|
|
20
|
-
|
|
21
|
-
|
|
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) {
|
|
22
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 = {
|
|
23
133
|
// --- Sync state operations ---
|
|
24
134
|
async getSyncState (connectorName, ownerDID) {
|
|
25
135
|
const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
@@ -28,48 +138,86 @@ export function createConnectorStore(db, _adapter) {
|
|
|
28
138
|
},
|
|
29
139
|
async setSyncState (connectorName, ownerDID, state) {
|
|
30
140
|
// `lease_expires_at` is dead: a fresh INSERT sets it null and a conflicting
|
|
31
|
-
// update leaves it untouched.
|
|
141
|
+
// update leaves it untouched. A fresh INSERT seeds version 0 and a random
|
|
142
|
+
// incarnation; a conflicting update leaves both untouched (this path is not
|
|
143
|
+
// the CAS write — it does not bump version or churn the incarnation).
|
|
144
|
+
const checkpoint = encodeJSONColumn(state.checkpoint);
|
|
145
|
+
const recovery = encodeJSONColumn(state.recovery);
|
|
32
146
|
await db.insertInto('kubun_connector_sync_state').values({
|
|
33
147
|
connector_name: connectorName,
|
|
34
148
|
owner_did: ownerDID,
|
|
35
|
-
checkpoint
|
|
149
|
+
checkpoint,
|
|
36
150
|
last_synced_at: state.lastSyncedAt,
|
|
37
151
|
entity_count: state.entityCount,
|
|
38
152
|
status: state.status,
|
|
39
153
|
error: state.error ?? null,
|
|
40
|
-
lease_expires_at: null
|
|
154
|
+
lease_expires_at: null,
|
|
155
|
+
recovery,
|
|
156
|
+
version: 0,
|
|
157
|
+
incarnation: getRandomID()
|
|
41
158
|
}).onConflict((oc)=>oc.columns([
|
|
42
159
|
'connector_name',
|
|
43
160
|
'owner_did'
|
|
44
161
|
]).doUpdateSet({
|
|
45
|
-
checkpoint
|
|
162
|
+
checkpoint,
|
|
46
163
|
last_synced_at: state.lastSyncedAt,
|
|
47
164
|
entity_count: state.entityCount,
|
|
48
165
|
status: state.status,
|
|
49
|
-
error: state.error ?? null
|
|
166
|
+
error: state.error ?? null,
|
|
167
|
+
recovery
|
|
50
168
|
})).execute();
|
|
51
169
|
},
|
|
52
|
-
async updateSyncProgress (connectorName, ownerDID,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
owner_did
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
170
|
+
async updateSyncProgress (connectorName, ownerDID, recompute, options) {
|
|
171
|
+
// The caller passes the authoritative MAX_CAS_RETRIES; 5 is a defensive
|
|
172
|
+
// fallback for direct callers/tests.
|
|
173
|
+
const maxRetries = options?.maxRetries ?? 5;
|
|
174
|
+
for(let attempt = 0; attempt <= maxRetries; attempt++){
|
|
175
|
+
const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
176
|
+
if (row == null) {
|
|
177
|
+
// No row yet: INSERT ... ON CONFLICT DO NOTHING so a lost insert race
|
|
178
|
+
// (a concurrent writer created the row first) falls through to reload
|
|
179
|
+
// and take the update path.
|
|
180
|
+
const result = await recompute(null);
|
|
181
|
+
const inserted = await db.insertInto('kubun_connector_sync_state').values({
|
|
182
|
+
connector_name: connectorName,
|
|
183
|
+
owner_did: ownerDID,
|
|
184
|
+
checkpoint: encodeJSONColumn(result.checkpoint),
|
|
185
|
+
last_synced_at: result.lastSyncedAt,
|
|
186
|
+
entity_count: result.entityCount,
|
|
187
|
+
status: 'idle',
|
|
188
|
+
error: result.error ?? null,
|
|
189
|
+
lease_expires_at: null,
|
|
190
|
+
recovery: encodeJSONColumn(result.recovery),
|
|
191
|
+
version: 0,
|
|
192
|
+
incarnation: getRandomID()
|
|
193
|
+
}).onConflict((oc)=>oc.columns([
|
|
194
|
+
'connector_name',
|
|
195
|
+
'owner_did'
|
|
196
|
+
]).doNothing()).executeTakeFirst();
|
|
197
|
+
if ((inserted?.numInsertedOrUpdatedRows ?? 0n) > 0n) return;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const result = await recompute({
|
|
201
|
+
checkpoint: decodeJSONColumn(row.checkpoint),
|
|
202
|
+
recovery: decodeJSONColumn(row.recovery),
|
|
203
|
+
version: row.version,
|
|
204
|
+
incarnation: row.incarnation ?? ''
|
|
205
|
+
});
|
|
206
|
+
// Single conditional UPDATE guarded on the read version AND incarnation.
|
|
207
|
+
// `status` is deliberately omitted (workflow owns it). Zero rows means a
|
|
208
|
+
// concurrent writer won, or the row was delete+recreated (incarnation
|
|
209
|
+
// differs) ⇒ reload and recompute.
|
|
210
|
+
const updated = await db.updateTable('kubun_connector_sync_state').set({
|
|
211
|
+
checkpoint: encodeJSONColumn(result.checkpoint),
|
|
212
|
+
last_synced_at: result.lastSyncedAt,
|
|
213
|
+
entity_count: result.entityCount,
|
|
214
|
+
error: result.error ?? null,
|
|
215
|
+
recovery: encodeJSONColumn(result.recovery),
|
|
216
|
+
version: row.version + 1
|
|
217
|
+
}).where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).where('version', '=', row.version).where('incarnation', '=', row.incarnation).executeTakeFirst();
|
|
218
|
+
if ((updated?.numUpdatedRows ?? 0n) > 0n) return;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`updateSyncProgress: CAS failed after ${maxRetries + 1} attempts for ${connectorName}/${ownerDID}`);
|
|
73
221
|
},
|
|
74
222
|
async deleteSyncState (connectorName, ownerDID) {
|
|
75
223
|
await db.deleteFrom('kubun_connector_sync_state').where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).execute();
|
|
@@ -126,6 +274,705 @@ export function createConnectorStore(db, _adapter) {
|
|
|
126
274
|
// ("at or before nowMs") so the sweep and the consume check agree on the
|
|
127
275
|
// expiry boundary.
|
|
128
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);
|
|
129
975
|
}
|
|
130
976
|
};
|
|
977
|
+
return api;
|
|
131
978
|
}
|