@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 CHANGED
@@ -1,4 +1,4 @@
1
- import type { Adapter } from '@kubun/db-adapter';
1
+ import { type Adapter } from '@kubun/db-adapter';
2
2
  import type { Kysely } from 'kysely';
3
3
  import type { ConnectorTables } from './tables.js';
4
4
  export type SyncStatus = 'idle' | 'syncing' | 'error';
@@ -9,11 +9,28 @@ export type SyncState = {
9
9
  status: SyncStatus;
10
10
  error?: string;
11
11
  leaseExpiresAt?: number | null;
12
+ recovery?: unknown;
13
+ version?: number;
14
+ incarnation?: string;
12
15
  };
13
16
  export type SyncStateEntry = SyncState & {
14
17
  connectorName: string;
15
18
  ownerDID: string;
16
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>;
17
34
  export type PendingAuthRecord = {
18
35
  state: string;
19
36
  codeVerifier: string;
@@ -30,19 +47,282 @@ export type PendingAuthRecord = {
30
47
  scopes: Array<string>;
31
48
  expiresAt: number;
32
49
  };
50
+ export type ConnectorOperationOp = 'create' | 'update';
51
+ export type ConnectorOperationState = 'attempted' | 'committed' | 'reconcile_only' | 'dead';
52
+ export type ClaimOperationInput = {
53
+ callerDID: string;
54
+ operationID: string;
55
+ ownerDID: string;
56
+ connectorName: string;
57
+ model: string;
58
+ op: ConnectorOperationOp;
59
+ accountKey: string;
60
+ target: string;
61
+ sourceID: string | null;
62
+ deterministicEventID: string;
63
+ payloadHash: string;
64
+ reissueIntent: unknown;
65
+ /**
66
+ * The provider's conditional-write token (e.g. an ETag) of the state the
67
+ * write is based on, pinned for every attempt of the operation. Captured
68
+ * state rather than part of the envelope: a replay never compares it.
69
+ */
70
+ providerEtag?: string | null;
71
+ /**
72
+ * The document the operation writes, when it is known before any provider
73
+ * I/O (an update's target, a create whose provider id is predetermined).
74
+ * Recorded on the first claim only; like `providerEtag`, a replay never
75
+ * compares it. Finalize records the document the write actually projected
76
+ * into, which takes precedence.
77
+ */
78
+ documentID?: string | null;
79
+ leaseToken: string;
80
+ leaseExpiresAt: number;
81
+ };
82
+ export type RecordOperationInput = {
83
+ callerDID: string;
84
+ operationID: string;
85
+ fromState: ConnectorOperationState;
86
+ toState: Exclude<ConnectorOperationState, 'committed'>;
87
+ version: number;
88
+ leaseToken: string;
89
+ providerEtag?: string | null;
90
+ nextAttemptAt?: number | null;
91
+ kind?: string | null;
92
+ httpStatus?: number | null;
93
+ /**
94
+ * Whether this transition consumes a recovery attempt (default `true`).
95
+ * `false` records a failure's classification against an attempt already
96
+ * consumed earlier in the same recovery pass.
97
+ */
98
+ countAttempt?: boolean;
99
+ /**
100
+ * Gives back the attempt this recovery pass consumed (`attempts - 1`), for a
101
+ * pass that turned out never to reach the provider. Takes precedence over
102
+ * `countAttempt`.
103
+ */
104
+ refundAttempt?: boolean;
105
+ /**
106
+ * Marks the `nextAttemptAt` this records as a deferral that lapses on its
107
+ * own (see `DeferReason`). Omitted clears any earlier mark.
108
+ */
109
+ deferReason?: DeferReason;
110
+ /**
111
+ * With `refundAttempt`: the marker of the run asking (see
112
+ * `markSendStarted`). The refund is refused when a different run started
113
+ * a provider write in this attempt, including when this run marked too.
114
+ */
115
+ sendMarker?: string;
116
+ };
117
+ /**
118
+ * A deferral that lapses on its own: `'offline'` — the device was offline,
119
+ * made due again on reconnect (`resumeOfflineDeferred`); `'auth'` — the
120
+ * owner's credential could not authorize the write (none stored, or refused by
121
+ * the provider), made due again once the owner authorizes
122
+ * (`resumeAuthDeferred`).
123
+ */
124
+ export type DeferReason = 'offline' | 'auth';
125
+ export type FinalizeOperationInput = {
126
+ key: {
127
+ callerDID: string;
128
+ operationID: string;
129
+ };
130
+ version: number;
131
+ leaseToken: string;
132
+ documentID: string;
133
+ resultEntity: unknown;
134
+ watchIdentity: ProjectionWatchIdentity;
135
+ currentWinnerDigest: string | null;
136
+ };
137
+ export type ConnectorOperation = {
138
+ callerDID: string;
139
+ operationID: string;
140
+ /**
141
+ * Opaque random key assigned when the operation was first claimed: its
142
+ * public identity, unlike `operationID`, which is the caller's idempotency
143
+ * key.
144
+ */
145
+ nodeKey: string;
146
+ ownerDID: string;
147
+ connectorName: string;
148
+ model: string;
149
+ op: ConnectorOperationOp;
150
+ accountKey: string;
151
+ target: string;
152
+ sourceID: string | null;
153
+ deterministicEventID: string;
154
+ payloadHash: string;
155
+ reissueIntent: unknown;
156
+ providerEtag: string | null;
157
+ documentID: string | null;
158
+ resultEntity: unknown;
159
+ state: ConnectorOperationState;
160
+ attempts: number;
161
+ nextAttemptAt: number | null;
162
+ lastKind: string | null;
163
+ lastHTTPStatus: number | null;
164
+ version: number;
165
+ incarnation: string | null;
166
+ leaseToken: string | null;
167
+ leaseExpiresAt: number | null;
168
+ createdAt: number;
169
+ updatedAt: number;
170
+ };
171
+ export type ProjectionWatchIdentity = {
172
+ ownerDID: string;
173
+ connectorName: string;
174
+ model: string;
175
+ accountKey: string;
176
+ sourceID: string;
177
+ };
178
+ export type ProjectionWatchVerificationStatus = 'pending_verification' | 'verified' | 'repair_failed';
179
+ export type ProjectionWatch = {
180
+ ownerDID: string;
181
+ connectorName: string;
182
+ model: string;
183
+ accountKey: string;
184
+ sourceID: string;
185
+ documentID: string;
186
+ verifiedStateDigest: string | null;
187
+ observedWinnerDigest: string | null;
188
+ verificationStatus: ProjectionWatchVerificationStatus;
189
+ verificationAttempts: number;
190
+ repairGeneration: number;
191
+ nextAttemptAt: number | null;
192
+ version: number;
193
+ createdAt: number;
194
+ updatedAt: number;
195
+ };
196
+ /**
197
+ * Filters for `listTrackedOperations`, all optional and combined with AND. An
198
+ * empty `states` or `documentIDs` list matches nothing.
199
+ */
200
+ export type TrackedOperationFilter = {
201
+ nodeKey?: string;
202
+ callerDID?: string;
203
+ operationID?: string;
204
+ /** Operations the DID claimed as caller, or owns as credential owner. */
205
+ visibleTo?: string;
206
+ connectorName?: string;
207
+ states?: Array<ConnectorOperationState>;
208
+ /** Operations linked to one of these documents. */
209
+ documentIDs?: Array<string>;
210
+ };
211
+ export type ArmWatchReason = 'finalize' | 'ingress' | 'manual';
212
+ export type ArmWatchOutcome = 'opened' | 'coalesced' | 'noop';
213
+ export type ArmWatchOptions = {
214
+ triggeringWinner?: string;
215
+ dueAt: number;
216
+ };
217
+ export type RecordVerificationInput = {
218
+ fetchedDigest: string | null;
219
+ generation: number;
220
+ maxAttempts: number;
221
+ dueAt: number;
222
+ claimedUntil?: number;
223
+ };
224
+ export type RecordVerificationOutcome = 'verified' | 'pending' | 'repair_failed' | 'stale-generation' | 'stale-claim';
225
+ /**
226
+ * Reads a document's current winner state digest (`W`) INSIDE the caller's
227
+ * transaction — wraps `readWinnerStateDigest(tx, documentID)`
228
+ * (exported from `@kubun/store-graph`). Graph and connector tables share one
229
+ * DB/connection via the KubunDB coordinator, so the wired implementation
230
+ * must be invoked with the `tx` handed to this callback, never a top-level
231
+ * `db` — passing the top-level db would nest a transaction and, on SQLite's
232
+ * single connection, deadlock.
233
+ */
234
+ export type ReadWinner = (documentID: string, tx: Kysely<ConnectorTables>) => Promise<string>;
235
+ export type ConnectorStoreDeps = {
236
+ /**
237
+ * Required by `armWatch`'s `'ingress'`/`'manual'` reasons and by
238
+ * `recordVerification`; a caller that reaches either without it gets a clear
239
+ * throw rather than a silent no-op. The connector plugin passes one over
240
+ * `readWinnerStateDigest`; tests pass a scripted fake so the CAS interleaves
241
+ * are deterministic.
242
+ */
243
+ readWinner?: ReadWinner;
244
+ /**
245
+ * The source of every random id the store mints: an operation's node key and
246
+ * a sync state's incarnation. The connector plugin passes its runtime's
247
+ * `getRandomID`.
248
+ */
249
+ getRandomID: () => string;
250
+ };
33
251
  export type ConnectorStoreAPI = {
34
252
  getSyncState(connectorName: string, ownerDID: string): Promise<SyncState | null>;
35
253
  setSyncState(connectorName: string, ownerDID: string, state: SyncState): Promise<void>;
36
- updateSyncProgress(connectorName: string, ownerDID: string, progress: {
37
- checkpoint: unknown;
38
- lastSyncedAt: string;
39
- entityCount: number;
40
- error?: string | null;
254
+ updateSyncProgress(connectorName: string, ownerDID: string, recompute: SyncProgressRecompute, options?: {
255
+ maxRetries?: number;
41
256
  }): Promise<void>;
42
257
  deleteSyncState(connectorName: string, ownerDID: string): Promise<void>;
43
258
  listSyncStatesForConnector(connectorName: string): Promise<Array<SyncStateEntry>>;
44
259
  createPendingAuth(record: PendingAuthRecord): Promise<void>;
45
260
  consumePendingAuth(state: string): Promise<PendingAuthRecord | null>;
46
261
  deleteExpiredPendingAuth(cutoffMs: number): Promise<void>;
262
+ claimOperation(input: ClaimOperationInput): Promise<{
263
+ status: 'claimed';
264
+ record: ConnectorOperation;
265
+ } | {
266
+ status: 'exists';
267
+ record: ConnectorOperation;
268
+ } | {
269
+ status: 'conflict';
270
+ }>;
271
+ acquireLease(callerDID: string, operationID: string, token: string, expiresAt: number): Promise<boolean>;
272
+ renewLease(callerDID: string, operationID: string, token: string, expiresAt: number): Promise<boolean>;
273
+ releaseLease(callerDID: string, operationID: string, token: string): Promise<boolean>;
274
+ getOperation(callerDID: string, operationID: string): Promise<ConnectorOperation | null>;
275
+ listTrackedOperations(filter: TrackedOperationFilter): Promise<Array<ConnectorOperation>>;
276
+ hasNewerOperationOnDocument(callerDID: string, operationID: string): Promise<boolean>;
277
+ markSendStarted(params: {
278
+ callerDID: string;
279
+ operationID: string;
280
+ leaseToken: string;
281
+ marker: string;
282
+ }): Promise<boolean>;
283
+ deferOperation(params: {
284
+ callerDID: string;
285
+ operationID: string;
286
+ nextAttemptAt: number;
287
+ leaseToken?: string;
288
+ reason?: DeferReason;
289
+ }): Promise<boolean>;
290
+ recordOperation(input: RecordOperationInput): Promise<boolean>;
291
+ finalizeOperationAndArmWatch(input: FinalizeOperationInput): Promise<boolean>;
292
+ getWatch(identity: ProjectionWatchIdentity): Promise<ProjectionWatch | null>;
293
+ getWatchByDocument(documentID: string): Promise<ProjectionWatch | null>;
294
+ listWatchesByDocuments(documentIDs: Array<string>): Promise<Array<ProjectionWatch>>;
295
+ armWatch(identity: ProjectionWatchIdentity, reason: ArmWatchReason, opts: ArmWatchOptions): Promise<ArmWatchOutcome>;
296
+ deferWatch(identity: ProjectionWatchIdentity, opts: {
297
+ generation: number;
298
+ nextAttemptAt: number;
299
+ reason?: DeferReason;
300
+ claimedUntil: number;
301
+ }): Promise<boolean>;
302
+ resumeOfflineDeferred(now: number): Promise<void>;
303
+ resumeAuthDeferred(params: {
304
+ now: number;
305
+ ownerDID: string;
306
+ connectorNames: Array<string>;
307
+ }): Promise<void>;
308
+ claimWatchAttempt(identity: ProjectionWatchIdentity, opts: {
309
+ generation: number;
310
+ now: number;
311
+ until: number;
312
+ }): Promise<boolean>;
313
+ recordVerification(identity: ProjectionWatchIdentity, input: RecordVerificationInput): Promise<RecordVerificationOutcome>;
314
+ requestRepull(identity: ProjectionWatchIdentity, opts: {
315
+ dueAt: number;
316
+ }): Promise<void>;
317
+ listDueOperations(now: number, opts: {
318
+ readWinner: ReadWinner;
319
+ }): Promise<{
320
+ operations: Array<ConnectorOperation>;
321
+ watches: Array<ProjectionWatch>;
322
+ }>;
323
+ collectFinishedOperations(params: {
324
+ before: number;
325
+ limit: number;
326
+ }): Promise<number>;
47
327
  };
48
- export declare function createConnectorStore(db: Kysely<ConnectorTables>, _adapter: Adapter): ConnectorStoreAPI;
328
+ export declare function createConnectorStore(db: Kysely<ConnectorTables>, adapter: Adapter, deps: ConnectorStoreDeps): ConnectorStoreAPI;