@kubun/store-connector 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { ObservationRecordMethods } from './observation-records.js';
3
+ import type { ConnectorTables } from './tables.js';
4
+ export type SyncStatus = 'idle' | 'syncing' | 'error';
5
+ export type SyncState = {
6
+ checkpoint: unknown;
7
+ lastSyncedAt: string;
8
+ entityCount: number;
9
+ status: SyncStatus;
10
+ error?: string;
11
+ leaseExpiresAt?: number | null;
12
+ recovery?: unknown;
13
+ version?: number;
14
+ incarnation?: string;
15
+ };
16
+ export type SyncStateEntry = SyncState & {
17
+ connectorName: string;
18
+ ownerDID: string;
19
+ };
20
+ export type SweepKey = {
21
+ connectorName: string;
22
+ ownerDID: string;
23
+ };
24
+ export type SweepReceiptKey = SweepKey & {
25
+ partition: string;
26
+ provenance: string;
27
+ };
28
+ export type SweepCandidateKey = SweepReceiptKey & {
29
+ documentID: string;
30
+ };
31
+ export type SweepReceipt = SweepReceiptKey & {
32
+ modelID: string;
33
+ scope: Record<string, string>;
34
+ keep: Array<string>;
35
+ sourceFetchHLC: string;
36
+ predicate: {
37
+ field: string;
38
+ from: string | null;
39
+ to: string | null;
40
+ } | null;
41
+ complete: boolean;
42
+ kind: 'relist' | 'partition-removal';
43
+ };
44
+ export type SweepCandidateInput = {
45
+ documentID: string;
46
+ sourceID: string;
47
+ modelID: string;
48
+ };
49
+ export type SweepCandidate = SweepCandidateKey & SweepCandidateInput & {
50
+ state: 'pending' | 'present' | 'absent' | 'cancelled' | 'uncertain' | 'applied' | 'refused';
51
+ attempts: number;
52
+ applyFailures: number;
53
+ lastError: string | null;
54
+ updatedAt: number;
55
+ nextAttemptAt: number;
56
+ };
57
+ export type SweepOutcomeInput = SweepCandidateKey & {
58
+ outcome: Exclude<SweepCandidate['state'], 'pending' | 'applied' | 'refused'>;
59
+ lastError?: string;
60
+ nextAttemptAt?: number;
61
+ };
62
+ export type SweepApplyFailureInput = SweepCandidateKey & {
63
+ lastError: string;
64
+ nextAttemptAt: number;
65
+ };
66
+ export type SyncProgressCASCurrent = {
67
+ checkpoint: unknown;
68
+ recovery: unknown;
69
+ version: number;
70
+ incarnation: string;
71
+ };
72
+ export type SyncProgressCASResult = {
73
+ checkpoint: unknown;
74
+ recovery: unknown;
75
+ lastSyncedAt: string;
76
+ entityCount: number;
77
+ error?: string | null;
78
+ };
79
+ export type SyncProgressRecompute = (current: SyncProgressCASCurrent | null) => SyncProgressCASResult | Promise<SyncProgressCASResult>;
80
+ export type UpdateSyncProgressParams = {
81
+ connectorName: string;
82
+ ownerDID: string;
83
+ recompute: SyncProgressRecompute;
84
+ options?: {
85
+ maxRetries?: number;
86
+ };
87
+ };
88
+ export type EntityTrimInput = {
89
+ documentID: string;
90
+ ownerDID: string;
91
+ connectorName: string;
92
+ trimmedFields: unknown;
93
+ };
94
+ export type EntityTrimRecord = EntityTrimInput & {
95
+ updatedAt: number;
96
+ };
97
+ export type PendingAuthRecord = {
98
+ state: string;
99
+ codeVerifier: string;
100
+ provider: string;
101
+ ownerDID: string;
102
+ /** The owner's DID in a form that can be encrypted to. See the table type. */
103
+ ownerWrappableDID?: string | null;
104
+ /** A recovery recipient's DID in a form that can be encrypted to. See the table type. */
105
+ controllerWrappableDID?: string | null;
106
+ createdAt: number;
107
+ /** The callback's redirect target, needed to complete the token exchange. */
108
+ redirectURL: string;
109
+ /** The scopes requested at authorize time. */
110
+ scopes: Array<string>;
111
+ /** Whether start-auth requested scopes incrementally. */
112
+ incremental: boolean;
113
+ expiresAt: number;
114
+ };
115
+ export type ConnectorOperationOp = 'create' | 'update';
116
+ export type ConnectorOperationState = 'attempted' | 'committed' | 'reconcile_only' | 'dead';
117
+ export type ClaimOperationInput = {
118
+ callerDID: string;
119
+ operationID: string;
120
+ ownerDID: string;
121
+ connectorName: string;
122
+ model: string;
123
+ op: ConnectorOperationOp;
124
+ accountKey: string;
125
+ target: string;
126
+ sourceID: string | null;
127
+ deterministicEventID: string;
128
+ payloadHash: string;
129
+ reissueIntent: unknown;
130
+ /**
131
+ * The provider's conditional-write token (e.g. an ETag) of the state the
132
+ * write is based on, pinned for every attempt of the operation. Captured
133
+ * state rather than part of the envelope: a replay never compares it.
134
+ */
135
+ providerEtag?: string | null;
136
+ /**
137
+ * The document the operation writes, when it is known before any provider
138
+ * I/O (an update's target, a create whose provider id is predetermined).
139
+ * Recorded on the first claim only; like `providerEtag`, a replay never
140
+ * compares it. Finalize records the document the write actually projected
141
+ * into, which takes precedence.
142
+ */
143
+ documentID?: string | null;
144
+ leaseToken: string;
145
+ leaseExpiresAt: number;
146
+ };
147
+ export type RecordOperationInput = {
148
+ callerDID: string;
149
+ operationID: string;
150
+ fromState: ConnectorOperationState;
151
+ toState: Exclude<ConnectorOperationState, 'committed'>;
152
+ version: number;
153
+ leaseToken: string;
154
+ providerEtag?: string | null;
155
+ nextAttemptAt?: number | null;
156
+ kind?: string | null;
157
+ httpStatus?: number | null;
158
+ /**
159
+ * Whether this transition consumes a recovery attempt (default `true`).
160
+ * `false` records a failure's classification against an attempt already
161
+ * consumed earlier in the same recovery pass.
162
+ */
163
+ countAttempt?: boolean;
164
+ /**
165
+ * Gives back the attempt this recovery pass consumed (`attempts - 1`), for a
166
+ * pass that turned out never to reach the provider. Takes precedence over
167
+ * `countAttempt`.
168
+ */
169
+ refundAttempt?: boolean;
170
+ /**
171
+ * Marks the `nextAttemptAt` this records as a deferral that lapses on its
172
+ * own (see `DeferReason`). Omitted clears any earlier mark.
173
+ */
174
+ deferReason?: DeferReason;
175
+ /**
176
+ * With `refundAttempt`: the marker of the run asking (see
177
+ * `markSendStarted`). The refund is refused when a different run started
178
+ * a provider write in this attempt, including when this run marked too.
179
+ */
180
+ sendMarker?: string;
181
+ };
182
+ /**
183
+ * A deferral that lapses on its own: `'offline'` — the device was offline,
184
+ * made due again on reconnect (`resumeOfflineDeferred`); `'auth'` — the
185
+ * owner's credential could not authorize the write (none stored, or refused by
186
+ * the provider), made due again once the owner authorizes
187
+ * (`resumeAuthDeferred`).
188
+ */
189
+ export type DeferReason = 'offline' | 'auth';
190
+ export type FinalizeOperationInput = {
191
+ key: {
192
+ callerDID: string;
193
+ operationID: string;
194
+ };
195
+ version: number;
196
+ leaseToken: string;
197
+ documentID: string;
198
+ resultEntity: unknown;
199
+ watchIdentity: ProjectionWatchIdentity;
200
+ currentWinnerDigest: string | null;
201
+ };
202
+ export type ConnectorOperation = {
203
+ callerDID: string;
204
+ operationID: string;
205
+ /**
206
+ * Opaque random key assigned when the operation was first claimed: its
207
+ * public identity, unlike `operationID`, which is the caller's idempotency
208
+ * key.
209
+ */
210
+ nodeKey: string;
211
+ ownerDID: string;
212
+ connectorName: string;
213
+ model: string;
214
+ op: ConnectorOperationOp;
215
+ accountKey: string;
216
+ target: string;
217
+ sourceID: string | null;
218
+ deterministicEventID: string;
219
+ payloadHash: string;
220
+ reissueIntent: unknown;
221
+ providerEtag: string | null;
222
+ documentID: string | null;
223
+ resultEntity: unknown;
224
+ state: ConnectorOperationState;
225
+ attempts: number;
226
+ nextAttemptAt: number | null;
227
+ lastKind: string | null;
228
+ lastHTTPStatus: number | null;
229
+ version: number;
230
+ incarnation: string | null;
231
+ leaseToken: string | null;
232
+ leaseExpiresAt: number | null;
233
+ createdAt: number;
234
+ updatedAt: number;
235
+ };
236
+ export type AcquireLeaseParams = {
237
+ callerDID: string;
238
+ operationID: string;
239
+ token: string;
240
+ expiresAt: number;
241
+ };
242
+ export type RenewLeaseParams = {
243
+ callerDID: string;
244
+ operationID: string;
245
+ token: string;
246
+ expiresAt: number;
247
+ };
248
+ export type ReleaseLeaseParams = {
249
+ callerDID: string;
250
+ operationID: string;
251
+ token: string;
252
+ };
253
+ export type ProjectionWatchIdentity = {
254
+ ownerDID: string;
255
+ connectorName: string;
256
+ model: string;
257
+ accountKey: string;
258
+ sourceID: string;
259
+ };
260
+ export type ProjectionWatchVerificationStatus = 'pending_verification' | 'verified' | 'repair_failed';
261
+ export type ProjectionWatch = {
262
+ ownerDID: string;
263
+ connectorName: string;
264
+ model: string;
265
+ accountKey: string;
266
+ sourceID: string;
267
+ documentID: string;
268
+ verifiedStateDigest: string | null;
269
+ observedWinnerDigest: string | null;
270
+ verificationStatus: ProjectionWatchVerificationStatus;
271
+ verificationAttempts: number;
272
+ repairGeneration: number;
273
+ nextAttemptAt: number | null;
274
+ version: number;
275
+ createdAt: number;
276
+ updatedAt: number;
277
+ };
278
+ /**
279
+ * Filters for `listTrackedOperations`, all optional and combined with AND. An
280
+ * empty `states` or `documentIDs` list matches nothing.
281
+ */
282
+ export type TrackedOperationFilter = {
283
+ nodeKey?: string;
284
+ callerDID?: string;
285
+ operationID?: string;
286
+ /** Operations the DID claimed as caller, or owns as credential owner. */
287
+ visibleTo?: string;
288
+ connectorName?: string;
289
+ states?: Array<ConnectorOperationState>;
290
+ /** Operations linked to one of these documents. */
291
+ documentIDs?: Array<string>;
292
+ };
293
+ export type ArmWatchReason = 'finalize' | 'ingress' | 'manual';
294
+ export type ArmWatchOutcome = 'opened' | 'coalesced' | 'noop';
295
+ export type ArmWatchOptions = {
296
+ triggeringWinner?: string;
297
+ dueAt: number;
298
+ };
299
+ export type RecordVerificationInput = {
300
+ fetchedDigest: string | null;
301
+ generation: number;
302
+ maxAttempts: number;
303
+ dueAt: number;
304
+ claimedUntil?: number;
305
+ };
306
+ export type RecordVerificationOutcome = 'verified' | 'pending' | 'repair_failed' | 'stale-generation' | 'stale-claim';
307
+ /**
308
+ * Reads a document's current winner state digest (`W`) INSIDE the caller's
309
+ * transaction — wraps `readWinnerStateDigest(tx, documentID)`
310
+ * (exported from `@kubun/store-graph`). Graph and connector tables share one
311
+ * DB/connection via the KubunDB coordinator, so the wired implementation
312
+ * must be invoked with the `tx` handed to this callback, never a top-level
313
+ * `db` — passing the top-level db would nest a transaction and, on SQLite's
314
+ * single connection, deadlock.
315
+ */
316
+ export type ReadWinner = (documentID: string, tx: Kysely<ConnectorTables>) => Promise<string>;
317
+ /**
318
+ * Batch sibling of `ReadWinner`: the winner digests of many documents in one
319
+ * pass. A document whose winner cannot be read is absent from the map, where
320
+ * `ReadWinner` would throw.
321
+ */
322
+ export type ReadWinners = (documentIDs: ReadonlyArray<string>, tx: Kysely<ConnectorTables>) => Promise<Map<string, string>>;
323
+ export type ConnectorStoreDeps = {
324
+ now?: () => number;
325
+ /**
326
+ * Required by `armWatch`'s `'ingress'`/`'manual'` reasons and by
327
+ * `recordVerification`; a caller that reaches either without it gets a clear
328
+ * throw rather than a silent no-op. The connector plugin passes one over
329
+ * `readWinnerStateDigest`; tests pass a scripted fake so the CAS interleaves
330
+ * are deterministic.
331
+ */
332
+ readWinner?: ReadWinner;
333
+ /**
334
+ * The source of every random id the store mints: an operation's node key and
335
+ * a sync state's incarnation. The connector plugin passes its runtime's
336
+ * `getRandomID`.
337
+ */
338
+ getRandomID: () => string;
339
+ };
340
+ export type ConnectorStoreAPI = ObservationRecordMethods & {
341
+ recordRelistReceipt(receipt: SweepReceipt, candidates: Array<SweepCandidateInput>): Promise<void>;
342
+ listSweepReceipts(key: SweepKey): Promise<Array<SweepReceipt>>;
343
+ listSweepCandidates(key: SweepKey & {
344
+ dueAt?: number;
345
+ }): Promise<Array<SweepCandidate>>;
346
+ recordSweepOutcome(input: SweepOutcomeInput): Promise<void>;
347
+ /** Returns true only when this failure transitions the candidate to refused. */
348
+ recordSweepApplyFailure(input: SweepApplyFailureInput): Promise<boolean>;
349
+ markSweepCandidateApplied(key: SweepCandidateKey): Promise<void>;
350
+ commitSweepCheckpoint(key: SweepReceiptKey): Promise<boolean>;
351
+ getSweepCheckpoint(key: SweepKey & {
352
+ partition: string;
353
+ }): Promise<string | null>;
354
+ getSyncState(connectorName: string, ownerDID: string): Promise<SyncState | null>;
355
+ setSyncState(connectorName: string, ownerDID: string, state: SyncState): Promise<void>;
356
+ updateSyncProgress(params: UpdateSyncProgressParams): Promise<void>;
357
+ deleteSyncState(connectorName: string, ownerDID: string): Promise<void>;
358
+ listSyncStatesForConnector(connectorName: string): Promise<Array<SyncStateEntry>>;
359
+ getEntityTrim(documentID: string): Promise<EntityTrimRecord | null>;
360
+ setEntityTrim(input: EntityTrimInput): Promise<void>;
361
+ clearEntityTrim(documentID: string): Promise<void>;
362
+ hasDocumentConnectorWork(documentID: string): Promise<boolean>;
363
+ sweepDocumentRows(documentID: string): Promise<void>;
364
+ createPendingAuth(record: PendingAuthRecord): Promise<void>;
365
+ getPendingAuth(state: string): Promise<PendingAuthRecord | null>;
366
+ consumePendingAuth(state: string): Promise<PendingAuthRecord | null>;
367
+ deleteExpiredPendingAuth(cutoffMs: number): Promise<void>;
368
+ claimOperation(input: ClaimOperationInput): Promise<{
369
+ status: 'claimed';
370
+ record: ConnectorOperation;
371
+ } | {
372
+ status: 'exists';
373
+ record: ConnectorOperation;
374
+ } | {
375
+ status: 'conflict';
376
+ }>;
377
+ acquireLease(params: AcquireLeaseParams): Promise<boolean>;
378
+ renewLease(params: RenewLeaseParams): Promise<boolean>;
379
+ releaseLease(params: ReleaseLeaseParams): Promise<boolean>;
380
+ getOperation(callerDID: string, operationID: string): Promise<ConnectorOperation | null>;
381
+ listTrackedOperations(filter: TrackedOperationFilter): Promise<Array<ConnectorOperation>>;
382
+ hasNewerOperationOnDocument(callerDID: string, operationID: string): Promise<boolean>;
383
+ markSendStarted(params: {
384
+ callerDID: string;
385
+ operationID: string;
386
+ leaseToken: string;
387
+ marker: string;
388
+ }): Promise<boolean>;
389
+ deferOperation(params: {
390
+ callerDID: string;
391
+ operationID: string;
392
+ nextAttemptAt: number;
393
+ leaseToken?: string;
394
+ reason?: DeferReason;
395
+ }): Promise<boolean>;
396
+ recordOperation(input: RecordOperationInput): Promise<boolean>;
397
+ finalizeOperationAndArmWatch(input: FinalizeOperationInput): Promise<boolean>;
398
+ getWatch(identity: ProjectionWatchIdentity): Promise<ProjectionWatch | null>;
399
+ getWatchByDocument(documentID: string): Promise<ProjectionWatch | null>;
400
+ listWatchesByDocuments(documentIDs: Array<string>): Promise<Array<ProjectionWatch>>;
401
+ armWatch(identity: ProjectionWatchIdentity, reason: ArmWatchReason, opts: ArmWatchOptions): Promise<ArmWatchOutcome>;
402
+ deferWatch(identity: ProjectionWatchIdentity, opts: {
403
+ generation: number;
404
+ nextAttemptAt: number;
405
+ reason?: DeferReason;
406
+ claimedUntil: number;
407
+ }): Promise<boolean>;
408
+ resumeOfflineDeferred(now: number): Promise<void>;
409
+ resumeAuthDeferred(params: {
410
+ now: number;
411
+ ownerDID: string;
412
+ connectorNames: Array<string>;
413
+ }): Promise<void>;
414
+ claimWatchAttempt(identity: ProjectionWatchIdentity, opts: {
415
+ generation: number;
416
+ now: number;
417
+ until: number;
418
+ }): Promise<boolean>;
419
+ recordVerification(identity: ProjectionWatchIdentity, input: RecordVerificationInput): Promise<RecordVerificationOutcome>;
420
+ requestRepull(identity: ProjectionWatchIdentity, opts: {
421
+ dueAt: number;
422
+ }): Promise<void>;
423
+ listDueOperations(now: number, opts: {
424
+ readWinner: ReadWinner;
425
+ readWinners?: ReadWinners;
426
+ }): Promise<{
427
+ operations: Array<ConnectorOperation>;
428
+ watches: Array<ProjectionWatch>;
429
+ }>;
430
+ collectFinishedOperations(params: {
431
+ before: number;
432
+ limit: number;
433
+ }): Promise<number>;
434
+ };
@@ -0,0 +1,2 @@
1
+ // --- ConnectorStoreAPI type ---
2
+ export { };
package/lib/api.d.ts CHANGED
@@ -1,62 +1,6 @@
1
- import type { Adapter } from '@kubun/db-adapter';
1
+ import { type Adapter } from '@kubun/db-adapter';
2
2
  import type { Kysely } from 'kysely';
3
+ import type { ConnectorStoreAPI, ConnectorStoreDeps } from './api-types.js';
3
4
  import type { ConnectorTables } from './tables.js';
4
- export type SyncStatus = 'idle' | 'syncing' | 'error';
5
- export type SyncState = {
6
- checkpoint: unknown;
7
- lastSyncedAt: string;
8
- entityCount: number;
9
- status: SyncStatus;
10
- error?: string;
11
- leaseExpiresAt?: number | null;
12
- recovery?: unknown;
13
- version?: number;
14
- incarnation?: string;
15
- };
16
- export type SyncStateEntry = SyncState & {
17
- connectorName: string;
18
- ownerDID: string;
19
- };
20
- export type SyncProgressCASCurrent = {
21
- checkpoint: unknown;
22
- recovery: unknown;
23
- version: number;
24
- incarnation: string;
25
- };
26
- export type SyncProgressCASResult = {
27
- checkpoint: unknown;
28
- recovery: unknown;
29
- lastSyncedAt: string;
30
- entityCount: number;
31
- error?: string | null;
32
- };
33
- export type SyncProgressRecompute = (current: SyncProgressCASCurrent | null) => SyncProgressCASResult | Promise<SyncProgressCASResult>;
34
- export type PendingAuthRecord = {
35
- state: string;
36
- codeVerifier: string;
37
- provider: string;
38
- ownerDID: string;
39
- /** The owner's DID in a form that can be encrypted to. See the table type. */
40
- ownerWrappableDID?: string | null;
41
- /** A recovery recipient's DID in a form that can be encrypted to. See the table type. */
42
- controllerWrappableDID?: string | null;
43
- createdAt: number;
44
- /** The callback's redirect target, needed to complete the token exchange. */
45
- redirectURL: string;
46
- /** The scopes requested at authorize time. */
47
- scopes: Array<string>;
48
- expiresAt: number;
49
- };
50
- export type ConnectorStoreAPI = {
51
- getSyncState(connectorName: string, ownerDID: string): Promise<SyncState | null>;
52
- setSyncState(connectorName: string, ownerDID: string, state: SyncState): Promise<void>;
53
- updateSyncProgress(connectorName: string, ownerDID: string, recompute: SyncProgressRecompute, options?: {
54
- maxRetries?: number;
55
- }): Promise<void>;
56
- deleteSyncState(connectorName: string, ownerDID: string): Promise<void>;
57
- listSyncStatesForConnector(connectorName: string): Promise<Array<SyncStateEntry>>;
58
- createPendingAuth(record: PendingAuthRecord): Promise<void>;
59
- consumePendingAuth(state: string): Promise<PendingAuthRecord | null>;
60
- deleteExpiredPendingAuth(cutoffMs: number): Promise<void>;
61
- };
62
- export declare function createConnectorStore(db: Kysely<ConnectorTables>, _adapter: Adapter): ConnectorStoreAPI;
5
+ export type * from './api-types.js';
6
+ export declare function createConnectorStore(db: Kysely<ConnectorTables>, adapter: Adapter, deps: ConnectorStoreDeps): ConnectorStoreAPI;