@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.
- package/lib/api-types.d.ts +434 -0
- package/lib/api-types.js +2 -0
- package/lib/api.d.ts +2 -324
- package/lib/api.js +26 -880
- package/lib/document-sweep.d.ts +8 -0
- package/lib/document-sweep.js +46 -0
- package/lib/entity-trim.d.ts +4 -0
- package/lib/entity-trim.js +36 -0
- package/lib/index.d.ts +5 -2
- package/lib/index.js +3 -0
- package/lib/migrations.js +51 -1
- package/lib/observation-records.d.ts +52 -0
- package/lib/observation-records.js +310 -0
- package/lib/operations.d.ts +4 -0
- package/lib/operations.js +223 -0
- package/lib/pending-auth.d.ts +4 -0
- package/lib/pending-auth.js +69 -0
- package/lib/projection-watches.d.ts +4 -0
- package/lib/projection-watches.js +265 -0
- package/lib/retention.d.ts +4 -0
- package/lib/retention.js +193 -0
- package/lib/serialization.d.ts +63 -0
- package/lib/serialization.js +79 -0
- package/lib/store-context.d.ts +11 -0
- package/lib/store-context.js +1 -0
- package/lib/sweep-state.d.ts +5 -0
- package/lib/sweep-state.js +180 -0
- package/lib/sync-state.d.ts +4 -0
- package/lib/sync-state.js +107 -0
- package/lib/tables.d.ts +73 -0
- package/package.json +13 -7
|
@@ -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;
|