@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
package/lib/retention.js
ADDED
|
@@ -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;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { decodeJSONColumn, encodeJSONColumn } from './serialization.js';
|
|
2
|
+
export const MAX_SWEEP_APPLY_ATTEMPTS = 5;
|
|
3
|
+
export function createSweepStateMethods({ db, deps }) {
|
|
4
|
+
const now = deps.now ?? Date.now;
|
|
5
|
+
return {
|
|
6
|
+
async recordRelistReceipt (receipt, candidates) {
|
|
7
|
+
if (!receipt.complete) return;
|
|
8
|
+
await db.transaction().execute(async (tx)=>{
|
|
9
|
+
const existing = await tx.selectFrom('kubun_connector_sweep_receipt').select('committed_at').where('owner_did', '=', receipt.ownerDID).where('connector_name', '=', receipt.connectorName).where('partition', '=', receipt.partition).where('provenance', '=', receipt.provenance).executeTakeFirst();
|
|
10
|
+
// A replay of a committed receipt has nothing left to verify.
|
|
11
|
+
if (existing?.committed_at != null) return;
|
|
12
|
+
if (existing == null) {
|
|
13
|
+
// Recording order per partition, so an older receipt that commits
|
|
14
|
+
// late never moves the sweep checkpoint back.
|
|
15
|
+
const latest = await tx.selectFrom('kubun_connector_sweep_receipt').select((eb)=>eb.fn.max('sequence').as('sequence')).where('owner_did', '=', receipt.ownerDID).where('connector_name', '=', receipt.connectorName).where('partition', '=', receipt.partition).executeTakeFirst();
|
|
16
|
+
await tx.insertInto('kubun_connector_sweep_receipt').values({
|
|
17
|
+
owner_did: receipt.ownerDID,
|
|
18
|
+
connector_name: receipt.connectorName,
|
|
19
|
+
partition: receipt.partition,
|
|
20
|
+
provenance: receipt.provenance,
|
|
21
|
+
sequence: Number(latest?.sequence ?? 0) + 1,
|
|
22
|
+
model_id: receipt.modelID,
|
|
23
|
+
scope: encodeJSONColumn(receipt.scope),
|
|
24
|
+
keep: encodeJSONColumn(receipt.keep),
|
|
25
|
+
source_fetch_hlc: receipt.sourceFetchHLC,
|
|
26
|
+
predicate: encodeJSONColumn(receipt.predicate),
|
|
27
|
+
kind: receipt.kind,
|
|
28
|
+
complete: 1,
|
|
29
|
+
created_at: now(),
|
|
30
|
+
committed_at: null
|
|
31
|
+
}).execute();
|
|
32
|
+
}
|
|
33
|
+
for (const candidate of candidates){
|
|
34
|
+
await tx.insertInto('kubun_connector_sweep_candidate').values({
|
|
35
|
+
owner_did: receipt.ownerDID,
|
|
36
|
+
connector_name: receipt.connectorName,
|
|
37
|
+
partition: receipt.partition,
|
|
38
|
+
provenance: receipt.provenance,
|
|
39
|
+
document_id: candidate.documentID,
|
|
40
|
+
source_id: candidate.sourceID,
|
|
41
|
+
model_id: candidate.modelID,
|
|
42
|
+
state: 'pending',
|
|
43
|
+
attempts: 0,
|
|
44
|
+
apply_failures: 0,
|
|
45
|
+
last_error: null,
|
|
46
|
+
updated_at: now(),
|
|
47
|
+
next_attempt_at: 0
|
|
48
|
+
}).onConflict((oc)=>oc.doNothing()).execute();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
async listSweepReceipts (key) {
|
|
53
|
+
const rows = await db.selectFrom('kubun_connector_sweep_receipt').selectAll().where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('committed_at', 'is', null).orderBy('created_at').execute();
|
|
54
|
+
return rows.map((row)=>({
|
|
55
|
+
ownerDID: row.owner_did,
|
|
56
|
+
connectorName: row.connector_name,
|
|
57
|
+
partition: row.partition,
|
|
58
|
+
provenance: row.provenance,
|
|
59
|
+
modelID: row.model_id,
|
|
60
|
+
scope: decodeJSONColumn(row.scope),
|
|
61
|
+
keep: decodeJSONColumn(row.keep),
|
|
62
|
+
sourceFetchHLC: row.source_fetch_hlc,
|
|
63
|
+
predicate: decodeJSONColumn(row.predicate),
|
|
64
|
+
complete: row.complete === 1,
|
|
65
|
+
kind: row.kind
|
|
66
|
+
}));
|
|
67
|
+
},
|
|
68
|
+
async listSweepCandidates (key) {
|
|
69
|
+
let query = db.selectFrom('kubun_connector_sweep_candidate').selectAll().where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).orderBy('updated_at').orderBy('document_id');
|
|
70
|
+
if (key.dueAt != null) {
|
|
71
|
+
query = query.where('state', 'not in', [
|
|
72
|
+
'applied',
|
|
73
|
+
'refused'
|
|
74
|
+
]).where('next_attempt_at', '<=', key.dueAt);
|
|
75
|
+
}
|
|
76
|
+
const rows = await query.execute();
|
|
77
|
+
return rows.map((row)=>({
|
|
78
|
+
ownerDID: row.owner_did,
|
|
79
|
+
connectorName: row.connector_name,
|
|
80
|
+
partition: row.partition,
|
|
81
|
+
provenance: row.provenance,
|
|
82
|
+
documentID: row.document_id,
|
|
83
|
+
sourceID: row.source_id,
|
|
84
|
+
modelID: row.model_id,
|
|
85
|
+
state: row.state,
|
|
86
|
+
attempts: row.attempts,
|
|
87
|
+
applyFailures: row.apply_failures,
|
|
88
|
+
lastError: row.last_error,
|
|
89
|
+
updatedAt: Number(row.updated_at),
|
|
90
|
+
nextAttemptAt: Number(row.next_attempt_at)
|
|
91
|
+
}));
|
|
92
|
+
},
|
|
93
|
+
async recordSweepOutcome (input) {
|
|
94
|
+
await db.updateTable('kubun_connector_sweep_candidate').set((eb)=>({
|
|
95
|
+
state: input.outcome,
|
|
96
|
+
attempts: eb('attempts', '+', 1),
|
|
97
|
+
last_error: input.lastError ?? null,
|
|
98
|
+
updated_at: now(),
|
|
99
|
+
next_attempt_at: input.nextAttemptAt ?? 0
|
|
100
|
+
})).where('owner_did', '=', input.ownerDID).where('connector_name', '=', input.connectorName).where('partition', '=', input.partition).where('provenance', '=', input.provenance).where('document_id', '=', input.documentID).where('state', 'not in', [
|
|
101
|
+
'applied',
|
|
102
|
+
'refused'
|
|
103
|
+
]).execute();
|
|
104
|
+
},
|
|
105
|
+
async recordSweepApplyFailure (input) {
|
|
106
|
+
const row = await db.updateTable('kubun_connector_sweep_candidate').set((eb)=>({
|
|
107
|
+
state: eb.case().when('apply_failures', '>=', MAX_SWEEP_APPLY_ATTEMPTS - 1).then('refused').else('pending').end(),
|
|
108
|
+
apply_failures: eb('apply_failures', '+', 1),
|
|
109
|
+
last_error: input.lastError,
|
|
110
|
+
next_attempt_at: input.nextAttemptAt,
|
|
111
|
+
updated_at: now()
|
|
112
|
+
})).where('owner_did', '=', input.ownerDID).where('connector_name', '=', input.connectorName).where('partition', '=', input.partition).where('provenance', '=', input.provenance).where('document_id', '=', input.documentID).where('state', 'not in', [
|
|
113
|
+
'applied',
|
|
114
|
+
'refused'
|
|
115
|
+
]).returning('state').executeTakeFirst();
|
|
116
|
+
return row?.state === 'refused';
|
|
117
|
+
},
|
|
118
|
+
async markSweepCandidateApplied (key) {
|
|
119
|
+
await db.updateTable('kubun_connector_sweep_candidate').set({
|
|
120
|
+
state: 'applied',
|
|
121
|
+
last_error: null,
|
|
122
|
+
next_attempt_at: 0,
|
|
123
|
+
updated_at: now()
|
|
124
|
+
}).where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('partition', '=', key.partition).where('provenance', '=', key.provenance).where('document_id', '=', key.documentID).where('state', 'in', [
|
|
125
|
+
'present',
|
|
126
|
+
'absent',
|
|
127
|
+
'cancelled'
|
|
128
|
+
]).execute();
|
|
129
|
+
},
|
|
130
|
+
async commitSweepCheckpoint (key) {
|
|
131
|
+
return db.transaction().execute(async (tx)=>{
|
|
132
|
+
const receipt = await tx.selectFrom('kubun_connector_sweep_receipt').select([
|
|
133
|
+
'complete',
|
|
134
|
+
'committed_at',
|
|
135
|
+
'sequence'
|
|
136
|
+
]).where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('partition', '=', key.partition).where('provenance', '=', key.provenance).executeTakeFirst();
|
|
137
|
+
if (receipt?.complete !== 1 || receipt.committed_at != null) return false;
|
|
138
|
+
const unresolved = await tx.selectFrom('kubun_connector_sweep_candidate').select('document_id').where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('partition', '=', key.partition).where('provenance', '=', key.provenance).where('state', 'not in', [
|
|
139
|
+
'applied',
|
|
140
|
+
'refused'
|
|
141
|
+
]).executeTakeFirst();
|
|
142
|
+
if (unresolved != null) return false;
|
|
143
|
+
const sequence = Number(receipt.sequence);
|
|
144
|
+
// A later relist must not advance past an earlier unresolved candidate.
|
|
145
|
+
const earlier = await tx.selectFrom('kubun_connector_sweep_candidate as candidate').innerJoin('kubun_connector_sweep_receipt as receipt', (join)=>join.onRef('receipt.owner_did', '=', 'candidate.owner_did').onRef('receipt.connector_name', '=', 'candidate.connector_name').onRef('receipt.partition', '=', 'candidate.partition').onRef('receipt.provenance', '=', 'candidate.provenance')).select('candidate.document_id').where('candidate.owner_did', '=', key.ownerDID).where('candidate.connector_name', '=', key.connectorName).where('candidate.partition', '=', key.partition).where('candidate.state', 'not in', [
|
|
146
|
+
'applied',
|
|
147
|
+
'refused'
|
|
148
|
+
]).where('receipt.sequence', '<', sequence).executeTakeFirst();
|
|
149
|
+
if (earlier != null) return false;
|
|
150
|
+
await tx.insertInto('kubun_connector_sweep_checkpoint').values({
|
|
151
|
+
owner_did: key.ownerDID,
|
|
152
|
+
connector_name: key.connectorName,
|
|
153
|
+
partition: key.partition,
|
|
154
|
+
provenance: key.provenance,
|
|
155
|
+
receipt_sequence: sequence,
|
|
156
|
+
updated_at: now()
|
|
157
|
+
}).onConflict((oc)=>oc.columns([
|
|
158
|
+
'owner_did',
|
|
159
|
+
'connector_name',
|
|
160
|
+
'partition'
|
|
161
|
+
]).doUpdateSet({
|
|
162
|
+
provenance: key.provenance,
|
|
163
|
+
receipt_sequence: sequence,
|
|
164
|
+
updated_at: now()
|
|
165
|
+
}).where('kubun_connector_sweep_checkpoint.receipt_sequence', '<', sequence)).execute();
|
|
166
|
+
// Applied rows can be discarded; refused rows retain their error for inspection.
|
|
167
|
+
// The committed receipt prevents replay from re-verifying either outcome.
|
|
168
|
+
await tx.deleteFrom('kubun_connector_sweep_candidate').where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('partition', '=', key.partition).where('provenance', '=', key.provenance).where('state', '=', 'applied').execute();
|
|
169
|
+
await tx.updateTable('kubun_connector_sweep_receipt').set({
|
|
170
|
+
committed_at: now()
|
|
171
|
+
}).where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('partition', '=', key.partition).where('provenance', '=', key.provenance).execute();
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
async getSweepCheckpoint (key) {
|
|
176
|
+
const row = await db.selectFrom('kubun_connector_sweep_checkpoint').select('provenance').where('owner_did', '=', key.ownerDID).where('connector_name', '=', key.connectorName).where('partition', '=', key.partition).executeTakeFirst();
|
|
177
|
+
return row?.provenance ?? null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ConnectorStoreAPI } from './api-types.js';
|
|
2
|
+
import type { ConnectorStoreContext } from './store-context.js';
|
|
3
|
+
export type SyncStateMethods = Pick<ConnectorStoreAPI, 'getSyncState' | 'setSyncState' | 'updateSyncProgress' | 'deleteSyncState' | 'listSyncStatesForConnector'>;
|
|
4
|
+
export declare function createSyncStateMethods(context: ConnectorStoreContext): SyncStateMethods;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { decodeJSONColumn, encodeJSONColumn, rowToSyncState } from './serialization.js';
|
|
2
|
+
export function createSyncStateMethods(context) {
|
|
3
|
+
const { db, getRandomID } = context;
|
|
4
|
+
const methods = {
|
|
5
|
+
// --- Sync state operations ---
|
|
6
|
+
async getSyncState (connectorName, ownerDID) {
|
|
7
|
+
const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
8
|
+
if (row == null) return null;
|
|
9
|
+
return rowToSyncState(row);
|
|
10
|
+
},
|
|
11
|
+
async setSyncState (connectorName, ownerDID, state) {
|
|
12
|
+
// `lease_expires_at` is dead: a fresh INSERT sets it null and a conflicting
|
|
13
|
+
// update leaves it untouched. A fresh INSERT seeds version 0 and a random
|
|
14
|
+
// incarnation; a conflicting update leaves both untouched (this path is not
|
|
15
|
+
// the CAS write — it does not bump version or churn the incarnation).
|
|
16
|
+
const checkpoint = encodeJSONColumn(state.checkpoint);
|
|
17
|
+
const recovery = encodeJSONColumn(state.recovery);
|
|
18
|
+
await db.insertInto('kubun_connector_sync_state').values({
|
|
19
|
+
connector_name: connectorName,
|
|
20
|
+
owner_did: ownerDID,
|
|
21
|
+
checkpoint,
|
|
22
|
+
last_synced_at: state.lastSyncedAt,
|
|
23
|
+
entity_count: state.entityCount,
|
|
24
|
+
status: state.status,
|
|
25
|
+
error: state.error ?? null,
|
|
26
|
+
lease_expires_at: null,
|
|
27
|
+
recovery,
|
|
28
|
+
version: 0,
|
|
29
|
+
incarnation: getRandomID()
|
|
30
|
+
}).onConflict((oc)=>oc.columns([
|
|
31
|
+
'connector_name',
|
|
32
|
+
'owner_did'
|
|
33
|
+
]).doUpdateSet({
|
|
34
|
+
checkpoint,
|
|
35
|
+
last_synced_at: state.lastSyncedAt,
|
|
36
|
+
entity_count: state.entityCount,
|
|
37
|
+
status: state.status,
|
|
38
|
+
error: state.error ?? null,
|
|
39
|
+
recovery
|
|
40
|
+
})).execute();
|
|
41
|
+
},
|
|
42
|
+
async updateSyncProgress ({ connectorName, ownerDID, recompute, options }) {
|
|
43
|
+
// The caller passes the authoritative MAX_CAS_RETRIES; 5 is a defensive
|
|
44
|
+
// fallback for direct callers/tests.
|
|
45
|
+
const maxRetries = options?.maxRetries ?? 5;
|
|
46
|
+
for(let attempt = 0; attempt <= maxRetries; attempt++){
|
|
47
|
+
const row = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
48
|
+
if (row == null) {
|
|
49
|
+
// No row yet: INSERT ... ON CONFLICT DO NOTHING so a lost insert race
|
|
50
|
+
// (a concurrent writer created the row first) falls through to reload
|
|
51
|
+
// and take the update path.
|
|
52
|
+
const result = await recompute(null);
|
|
53
|
+
const inserted = await db.insertInto('kubun_connector_sync_state').values({
|
|
54
|
+
connector_name: connectorName,
|
|
55
|
+
owner_did: ownerDID,
|
|
56
|
+
checkpoint: encodeJSONColumn(result.checkpoint),
|
|
57
|
+
last_synced_at: result.lastSyncedAt,
|
|
58
|
+
entity_count: result.entityCount,
|
|
59
|
+
status: 'idle',
|
|
60
|
+
error: result.error ?? null,
|
|
61
|
+
lease_expires_at: null,
|
|
62
|
+
recovery: encodeJSONColumn(result.recovery),
|
|
63
|
+
version: 0,
|
|
64
|
+
incarnation: getRandomID()
|
|
65
|
+
}).onConflict((oc)=>oc.columns([
|
|
66
|
+
'connector_name',
|
|
67
|
+
'owner_did'
|
|
68
|
+
]).doNothing()).executeTakeFirst();
|
|
69
|
+
if ((inserted?.numInsertedOrUpdatedRows ?? 0n) > 0n) return;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const result = await recompute({
|
|
73
|
+
checkpoint: decodeJSONColumn(row.checkpoint),
|
|
74
|
+
recovery: decodeJSONColumn(row.recovery),
|
|
75
|
+
version: row.version,
|
|
76
|
+
incarnation: row.incarnation ?? ''
|
|
77
|
+
});
|
|
78
|
+
// Single conditional UPDATE guarded on the read version AND incarnation.
|
|
79
|
+
// `status` is deliberately omitted (workflow owns it). Zero rows means a
|
|
80
|
+
// concurrent writer won, or the row was delete+recreated (incarnation
|
|
81
|
+
// differs) ⇒ reload and recompute.
|
|
82
|
+
const updated = await db.updateTable('kubun_connector_sync_state').set({
|
|
83
|
+
checkpoint: encodeJSONColumn(result.checkpoint),
|
|
84
|
+
last_synced_at: result.lastSyncedAt,
|
|
85
|
+
entity_count: result.entityCount,
|
|
86
|
+
error: result.error ?? null,
|
|
87
|
+
recovery: encodeJSONColumn(result.recovery),
|
|
88
|
+
version: row.version + 1
|
|
89
|
+
}).where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).where('version', '=', row.version).where('incarnation', '=', row.incarnation).executeTakeFirst();
|
|
90
|
+
if ((updated?.numUpdatedRows ?? 0n) > 0n) return;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`updateSyncProgress: CAS failed after ${maxRetries + 1} attempts for ${connectorName}/${ownerDID}`);
|
|
93
|
+
},
|
|
94
|
+
async deleteSyncState (connectorName, ownerDID) {
|
|
95
|
+
await db.deleteFrom('kubun_connector_sync_state').where('connector_name', '=', connectorName).where('owner_did', '=', ownerDID).execute();
|
|
96
|
+
},
|
|
97
|
+
async listSyncStatesForConnector (connectorName) {
|
|
98
|
+
const rows = await db.selectFrom('kubun_connector_sync_state').selectAll().where('connector_name', '=', connectorName).execute();
|
|
99
|
+
return rows.map((row)=>({
|
|
100
|
+
...rowToSyncState(row),
|
|
101
|
+
connectorName: row.connector_name,
|
|
102
|
+
ownerDID: row.owner_did
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return methods;
|
|
107
|
+
}
|