velocious 1.0.671 → 1.0.673

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.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/build/database/drivers/base.js +52 -18
  3. package/build/database/drivers/mssql/index.js +48 -3
  4. package/build/database/pool/async-tracked-multi-connection.js +36 -0
  5. package/build/src/database/drivers/base.d.ts +23 -0
  6. package/build/src/database/drivers/base.d.ts.map +1 -1
  7. package/build/src/database/drivers/base.js +51 -21
  8. package/build/src/database/drivers/mssql/index.d.ts +22 -0
  9. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  10. package/build/src/database/drivers/mssql/index.js +45 -4
  11. package/build/src/database/pool/async-tracked-multi-connection.d.ts +15 -0
  12. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  13. package/build/src/database/pool/async-tracked-multi-connection.js +32 -1
  14. package/build/src/sync/local-mutation-log.d.ts +12 -0
  15. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  16. package/build/src/sync/local-mutation-log.js +45 -1
  17. package/build/src/sync/sync-api-client.d.ts +6 -1
  18. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  19. package/build/src/sync/sync-api-client.js +7 -2
  20. package/build/src/sync/sync-client.d.ts +60 -0
  21. package/build/src/sync/sync-client.d.ts.map +1 -1
  22. package/build/src/sync/sync-client.js +172 -3
  23. package/build/src/sync/sync-coordinator-types.d.ts +190 -0
  24. package/build/src/sync/sync-coordinator-types.d.ts.map +1 -0
  25. package/build/src/sync/sync-coordinator-types.js +65 -0
  26. package/build/src/sync/sync-coordinator.d.ts +246 -0
  27. package/build/src/sync/sync-coordinator.d.ts.map +1 -0
  28. package/build/src/sync/sync-coordinator.js +683 -0
  29. package/build/src/sync/sync-envelope-replay-service.d.ts +90 -2
  30. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  31. package/build/src/sync/sync-envelope-replay-service.js +101 -9
  32. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  33. package/build/src/sync/sync-realtime-bridge.js +8 -1
  34. package/build/sync/local-mutation-log.js +53 -0
  35. package/build/sync/sync-api-client.js +7 -1
  36. package/build/sync/sync-client.js +190 -2
  37. package/build/sync/sync-coordinator-types.js +73 -0
  38. package/build/sync/sync-coordinator.js +738 -0
  39. package/build/sync/sync-envelope-replay-service.js +107 -8
  40. package/build/sync/sync-realtime-bridge.js +9 -0
  41. package/package.json +1 -1
  42. package/src/database/drivers/base.js +52 -18
  43. package/src/database/drivers/mssql/index.js +48 -3
  44. package/src/database/pool/async-tracked-multi-connection.js +36 -0
  45. package/src/sync/local-mutation-log.js +53 -0
  46. package/src/sync/sync-api-client.js +7 -1
  47. package/src/sync/sync-client.js +190 -2
  48. package/src/sync/sync-coordinator-types.js +73 -0
  49. package/src/sync/sync-coordinator.js +738 -0
  50. package/src/sync/sync-envelope-replay-service.js +107 -8
  51. package/src/sync/sync-realtime-bridge.js +9 -0
@@ -0,0 +1,738 @@
1
+ // @ts-check
2
+
3
+ import restArgsError from "../utils/rest-args-error.js"
4
+
5
+ const COORDINATOR_STATES = new Set(["backoff", "conflicted", "failed", "idle", "offline", "pending", "stopped", "syncing"])
6
+ const DEFAULT_RETRY = Object.freeze({initialDelayMs: 1_000, maxAttempts: 4, maxDelayMs: 30_000})
7
+
8
+ /** Expected cooperative cancellation raised by a SyncCoordinator lifecycle transition. */
9
+ export class SyncCoordinatorLifecycleAbortError extends Error {
10
+ /**
11
+ * Creates a lifecycle cancellation error.
12
+ * @param {string} message - Cancellation reason.
13
+ */
14
+ constructor(message) {
15
+ super(message)
16
+ this.name = "SyncCoordinatorLifecycleAbortError"
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Reusable observable lifecycle around SyncClient. It serializes replay,
22
+ * realtime subscription, and stable-cursor pull into one cycle; coalesces any
23
+ * triggers received during that cycle into one rerun; owns bounded retry; and
24
+ * generation-fences status updates after stop/restart.
25
+ */
26
+ export default class SyncCoordinator {
27
+ /**
28
+ * Creates one reusable sync lifecycle owner.
29
+ * @param {object} args - Coordinator dependencies.
30
+ * @param {(error: Error) => {code: string, message?: string, retryable: boolean}} [args.classifyError] - Maps errors to safe retry/display metadata. Defaults to permanent `sync_failed` without persisting the error message.
31
+ * @param {import("./sync-coordinator-types.js").SyncCoordinatorConnectivity} [args.connectivity] - Optional connectivity event source.
32
+ * @param {() => Date} [args.now] - Injected clock.
33
+ * @param {(args: {signal: AbortSignal, syncClient: import("./sync-client.js").default}) => Promise<(() => Promise<void> | void) | void> | (() => Promise<void> | void) | void} [args.prepare] - Activates scopes/acquires app-owned resources before the first cycle and returns their teardown.
34
+ * @param {boolean} [args.realtime] - Whether cycles subscribe realtime before pulling. Defaults to true.
35
+ * @param {{initialDelayMs?: number, maxAttempts?: number, maxDelayMs?: number}} [args.retry] - Bounded automatic retry policy.
36
+ * @param {import("./sync-coordinator-types.js").SyncCoordinatorScheduler} [args.scheduler] - Injected timer owner.
37
+ * @param {import("./sync-coordinator-types.js").SyncCoordinatorStatusStore} [args.statusStore] - Optional privacy-safe durable status store.
38
+ * @param {import("./sync-client.js").default} args.syncClient - SyncClient owning queue, scopes, cursors, apply, and realtime.
39
+ */
40
+ constructor({classifyError = defaultErrorClassification, connectivity, now = () => new Date(), prepare = () => undefined, realtime = true, retry = {}, scheduler = defaultScheduler(), statusStore, syncClient, ...restArgs}) {
41
+ restArgsError(restArgs)
42
+ requireCoordinatorClient(syncClient)
43
+ requireFunction(classifyError, "classifyError")
44
+ requireFunction(now, "now")
45
+ requireFunction(prepare, "prepare")
46
+ if (typeof realtime !== "boolean") throw new Error("SyncCoordinator realtime must be boolean")
47
+ if (connectivity) requireFunction(connectivity.subscribe, "connectivity.subscribe")
48
+ if (statusStore) {
49
+ requireFunction(statusStore.load, "statusStore.load")
50
+ requireFunction(statusStore.save, "statusStore.save")
51
+ }
52
+ requireFunction(scheduler.clearTimeout, "scheduler.clearTimeout")
53
+ requireFunction(scheduler.setTimeout, "scheduler.setTimeout")
54
+
55
+ this.syncClient = syncClient
56
+ this.classifyError = classifyError
57
+ this.connectivity = connectivity || null
58
+ this.now = now
59
+ this.prepare = prepare
60
+ this.realtime = realtime
61
+ this.retryPolicy = normalizeRetryPolicy(retry)
62
+ this.scheduler = scheduler
63
+ this.statusStore = statusStore || null
64
+
65
+ this._active = false
66
+ this._attempt = 0
67
+ this._generation = 0
68
+ this._lifecycleAbortController = new AbortController()
69
+ /** @type {Set<(status: import("./sync-coordinator-types.js").SyncCoordinatorStatus) => void>} */
70
+ this._listeners = new Set()
71
+ /** @type {Array<() => Promise<void> | void>} */
72
+ this._lifecycleCleanups = []
73
+ /** @type {Promise<void> | null} */
74
+ this._runPromise = null
75
+ this._rerunRequested = false
76
+ /** @type {unknown} */
77
+ this._retryTimer = null
78
+ /** @type {Promise<void> | null} */
79
+ this._startPromise = null
80
+ /** @type {Promise<void> | null} */
81
+ this._stopPromise = null
82
+ /** @type {import("./sync-coordinator-types.js").SyncCoordinatorStatus} */
83
+ this._status = immutableStatus({
84
+ conflicts: [],
85
+ failure: null,
86
+ lastSuccessAt: null,
87
+ nextRetryAt: null,
88
+ pendingCount: 0,
89
+ rejectedCount: 0,
90
+ state: "stopped"
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Installs local ownership and schedules the initial network cycle without
96
+ * awaiting it, so cached reads never depend on network completion.
97
+ * @returns {Promise<void>} - Resolves after local ownership is installed.
98
+ */
99
+ start() {
100
+ if (this._active && !this._startPromise) return Promise.resolve()
101
+ if (this._startPromise) return this._startPromise
102
+ if (this._stopPromise) return this._stopPromise.then(async () => await this.start())
103
+
104
+ this._active = true
105
+ this._attempt = 0
106
+ const generation = ++this._generation
107
+
108
+ this._lifecycleAbortController = new AbortController()
109
+ this._startPromise = this._start(generation).finally(() => {
110
+ this._startPromise = null
111
+ })
112
+
113
+ return this._startPromise
114
+ }
115
+
116
+ /**
117
+ * Starts one lifecycle generation with rollback on failure.
118
+ * @param {number} generation - Owning generation.
119
+ * @returns {Promise<void>} - Resolves after activation completes.
120
+ */
121
+ async _start(generation) {
122
+ try {
123
+ await this._activate(generation)
124
+ } catch (error) {
125
+ if (this.isLifecycleAbort(error) || !this._ownsGeneration(generation)) throw error
126
+
127
+ await this._rollbackFailedStart(/** @type {Error} */ (error), generation)
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Acquires coordinator, connectivity, client, and application resources.
133
+ * @param {number} generation - Owning generation.
134
+ * @returns {Promise<void>} - Resolves after every owner is active.
135
+ */
136
+ async _activate(generation) {
137
+ if (this.statusStore) {
138
+ const persistedStatus = await this.statusStore.load()
139
+
140
+ this._assertActive(generation)
141
+ if (persistedStatus) this._publish(restoredStatus(persistedStatus))
142
+ }
143
+
144
+ const detachCoordinator = this.syncClient.attachCoordinator(async (reason) => await this.trigger(reason))
145
+
146
+ this._lifecycleCleanups.push(detachCoordinator)
147
+
148
+ if (this.connectivity) {
149
+ const unsubscribeConnectivity = this.connectivity.subscribe((online) => this._connectivityChanged({generation, online}))
150
+
151
+ this._lifecycleCleanups.push(unsubscribeConnectivity)
152
+ }
153
+
154
+ this._assertActive(generation)
155
+ await this.syncClient.start()
156
+ this._assertActive(generation)
157
+
158
+ const release = await this.prepare({signal: this._lifecycleAbortController.signal, syncClient: this.syncClient})
159
+
160
+ if (release !== undefined) requireFunction(release, "prepare teardown")
161
+ if (!this._ownsGeneration(generation)) {
162
+ if (release) await release()
163
+ throw new SyncCoordinatorLifecycleAbortError("Sync coordinator stopped during preparation")
164
+ }
165
+ if (release) this._lifecycleCleanups.push(release)
166
+
167
+ this._rerunRequested = true
168
+ void this._startRequestedRun()
169
+ }
170
+
171
+ /**
172
+ * Releases every partially acquired owner after a failed start, publishes a
173
+ * safe terminal failure, and rethrows the original error (or an aggregate if
174
+ * teardown also failed).
175
+ * @param {Error} error - Start failure.
176
+ * @param {number} generation - Failed generation.
177
+ * @returns {Promise<never>} - Always rejects with the start or aggregate error.
178
+ */
179
+ async _rollbackFailedStart(error, generation) {
180
+ this._active = false
181
+ this._generation += 1
182
+ this._lifecycleAbortController.abort(new SyncCoordinatorLifecycleAbortError("Sync coordinator start failed"))
183
+ this._rerunRequested = false
184
+ this._clearRetryTimer()
185
+
186
+ /** @type {unknown[]} */
187
+ const teardownErrors = []
188
+ const cleanups = this._lifecycleCleanups.splice(0).reverse()
189
+
190
+ try {
191
+ await this.syncClient.stop()
192
+ } catch (stopError) {
193
+ teardownErrors.push(stopError)
194
+ }
195
+
196
+ for (const cleanup of cleanups) {
197
+ try {
198
+ await cleanup()
199
+ } catch (cleanupError) {
200
+ teardownErrors.push(cleanupError)
201
+ }
202
+ }
203
+
204
+ const classified = normalizeErrorClassification(this.classifyError(error))
205
+ const failure = {
206
+ attempt: 1,
207
+ at: this._nowIso(),
208
+ code: classified.code,
209
+ ...(classified.message === undefined ? {} : {message: classified.message}),
210
+ retryable: classified.retryable
211
+ }
212
+
213
+ this._attempt = 1
214
+ this._publish({...this._status, failure, nextRetryAt: null, state: "failed"})
215
+
216
+ if (this.statusStore) {
217
+ try {
218
+ await this.statusStore.save(this._status)
219
+ } catch (persistenceError) {
220
+ teardownErrors.push(persistenceError)
221
+ }
222
+ }
223
+
224
+ if (teardownErrors.length > 0) throw new AggregateError([error, ...teardownErrors], `Sync coordinator generation ${generation} failed to start and tear down cleanly`)
225
+
226
+ throw error
227
+ }
228
+
229
+ /**
230
+ * Stops current work, clears timers/listeners, drains SyncClient, and releases
231
+ * app-owned resources exactly once.
232
+ * @returns {Promise<void>} - Resolves after the lifecycle is fully stopped.
233
+ */
234
+ stop() {
235
+ if (this._stopPromise) return this._stopPromise
236
+ if (!this._active && !this._startPromise && !this._runPromise) {
237
+ if (this._status.state !== "stopped") this._publish({...this._status, nextRetryAt: null, state: "stopped"})
238
+
239
+ return Promise.resolve()
240
+ }
241
+
242
+ this._active = false
243
+ this._generation += 1
244
+ this._lifecycleAbortController.abort(new SyncCoordinatorLifecycleAbortError("Sync coordinator was stopped"))
245
+ this._rerunRequested = false
246
+ this._clearRetryTimer()
247
+
248
+ const cleanups = this._lifecycleCleanups.splice(0).reverse()
249
+ const startPromise = this._startPromise
250
+ const runPromise = this._runPromise
251
+
252
+ this._stopPromise = this._stop({cleanups, runPromise, startPromise}).finally(() => {
253
+ this._stopPromise = null
254
+ })
255
+
256
+ return this._stopPromise
257
+ }
258
+
259
+ /**
260
+ * Drains captured lifecycle resources after a stop transition.
261
+ * @param {{cleanups: Array<() => Promise<void> | void>, runPromise: Promise<void> | null, startPromise: Promise<void> | null}} args - Captured generation resources.
262
+ * @returns {Promise<void>} - Resolves after teardown completes.
263
+ */
264
+ async _stop({cleanups, runPromise, startPromise}) {
265
+ /** @type {unknown[]} */
266
+ const errors = []
267
+
268
+ try {
269
+ await this.syncClient.stop()
270
+ } catch (error) {
271
+ errors.push(error)
272
+ }
273
+
274
+ for (const promise of [startPromise, runPromise]) {
275
+ if (!promise) continue
276
+
277
+ try {
278
+ await promise
279
+ } catch (error) {
280
+ if (!this.isLifecycleAbort(error)) errors.push(error)
281
+ }
282
+ }
283
+
284
+ for (const cleanup of cleanups) {
285
+ try {
286
+ await cleanup()
287
+ } catch (error) {
288
+ errors.push(error)
289
+ }
290
+ }
291
+
292
+ this._attempt = 0
293
+ this._publish({...this._status, failure: null, nextRetryAt: null, state: "stopped"})
294
+
295
+ if (errors.length === 1) throw errors[0]
296
+ if (errors.length > 1) throw new AggregateError(errors, "Sync coordinator teardown failed")
297
+ }
298
+
299
+ /**
300
+ * Requests a cycle. Overlapping requests share the active flight and produce
301
+ * at most one queued rerun.
302
+ * @param {string} [reason] - Diagnostic trigger label (never persisted).
303
+ * @returns {Promise<void>} - Resolves after the current or queued cycle drains.
304
+ */
305
+ trigger(reason = "manual") {
306
+ void reason
307
+ if (!this._active) return Promise.resolve()
308
+
309
+ this._rerunRequested = true
310
+ if (this._startPromise) {
311
+ return this._startPromise.then(
312
+ async () => await this._startRequestedRun(),
313
+ () => undefined
314
+ )
315
+ }
316
+
317
+ return this._startRequestedRun()
318
+ }
319
+
320
+ /**
321
+ * Starts requested work only after lifecycle preparation has completed.
322
+ * @returns {Promise<void>} - Current or newly started coordinator flight.
323
+ */
324
+ _startRequestedRun() {
325
+ if (!this._active) return Promise.resolve()
326
+ if (this._retryTimer !== null) return this._runPromise || Promise.resolve()
327
+ if (!this._runPromise) {
328
+ const generation = this._generation
329
+
330
+ this._runPromise = this._drain(generation).finally(() => {
331
+ this._runPromise = null
332
+ })
333
+ }
334
+
335
+ return this._runPromise
336
+ }
337
+
338
+ /**
339
+ * Clears backoff and requests one single-flight user retry.
340
+ * @returns {Promise<void>} - Resolves after the retry cycle drains.
341
+ */
342
+ retry() {
343
+ if (!this._active) throw new Error("Cannot retry a stopped SyncCoordinator")
344
+
345
+ this._clearRetryTimer()
346
+ this._attempt = 0
347
+
348
+ return this.trigger("manual-retry")
349
+ }
350
+
351
+ /**
352
+ * Resolves one durable conflict through SyncClient, then refreshes status and
353
+ * replays retry-local intent through the same cycle.
354
+ * @param {{recordId: string, resolution: "keep-server" | "retry-local", resourceType: string}} args - Explicit resolution.
355
+ * @returns {Promise<void>} - Resolves after resolution state is refreshed.
356
+ */
357
+ async resolveConflict(args) {
358
+ if (!this._active) throw new Error("Cannot resolve a conflict on a stopped SyncCoordinator")
359
+
360
+ await this.syncClient.resolveConflict(args)
361
+ this._clearRetryTimer()
362
+ this._attempt = 0
363
+ if (args.resolution === "retry-local") {
364
+ await this.syncClient.waitForScheduledReplay()
365
+ } else {
366
+ await this.trigger("conflict-resolution")
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Drains requested work serially for one lifecycle generation.
372
+ * @param {number} generation - Owning generation.
373
+ * @returns {Promise<void>} - Resolves when no immediate rerun remains.
374
+ */
375
+ async _drain(generation) {
376
+ while (this._rerunRequested && this._ownsGeneration(generation)) {
377
+ this._rerunRequested = false
378
+ await this._runCycle(generation)
379
+
380
+ if (this._rerunRequested) this._clearRetryTimer()
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Runs one replay, realtime-subscribe, and pull cycle.
386
+ * @param {number} generation - Owning generation.
387
+ * @returns {Promise<void>} - Resolves after this cycle settles.
388
+ */
389
+ async _runCycle(generation) {
390
+ try {
391
+ const online = await this.syncClient.isOnline()
392
+
393
+ this._assertActive(generation)
394
+ if (!online) {
395
+ const inspection = await this.syncClient.inspectSyncState()
396
+
397
+ this._assertActive(generation)
398
+ this._publish({...this._status, ...inspection, failure: null, nextRetryAt: null, state: "offline"})
399
+ await this._persistStatus(generation)
400
+
401
+ return
402
+ }
403
+
404
+ this._publish({...this._status, nextRetryAt: null, state: "syncing"})
405
+ await this.syncClient.replayPending()
406
+ this._assertActive(generation)
407
+ if (this.realtime) {
408
+ await this.syncClient.subscribeRealtime()
409
+ this._assertActive(generation)
410
+ }
411
+ await this.syncClient.pull()
412
+ this._assertActive(generation)
413
+
414
+ const inspection = await this.syncClient.inspectSyncState()
415
+
416
+ this._assertActive(generation)
417
+ this._publish({
418
+ ...this._status,
419
+ ...inspection,
420
+ failure: null,
421
+ lastSuccessAt: this._nowIso(),
422
+ nextRetryAt: null,
423
+ state: inspectionState(inspection)
424
+ })
425
+ await this._persistStatus(generation)
426
+ this._attempt = 0
427
+
428
+ return
429
+ } catch (error) {
430
+ if (!this._ownsGeneration(generation) || this.isLifecycleAbort(error) || this.syncClient.isLifecycleAbort(error)) return
431
+
432
+ await this._handleFailure(/** @type {Error} */ (error), generation)
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Publishes a classified failure and owns its bounded retry timer.
438
+ * @param {Error} error - Cycle failure.
439
+ * @param {number} generation - Owning generation.
440
+ * @returns {Promise<void>} - Resolves after status persistence and scheduling.
441
+ */
442
+ async _handleFailure(error, generation) {
443
+ this._attempt += 1
444
+ let classified = normalizeErrorClassification(this.classifyError(error))
445
+ const failedAt = this._nowIso()
446
+ let failureStatus = failureStatusFor({attempt: this._attempt, classified, failedAt, retryPolicy: this.retryPolicy, status: this._status})
447
+
448
+ this._publish(failureStatus.status)
449
+
450
+ if (this.statusStore) {
451
+ try {
452
+ await this.statusStore.save(this._status)
453
+ this._assertActive(generation)
454
+ } catch (persistenceError) {
455
+ this._assertActive(generation)
456
+ classified = normalizeErrorClassification(this.classifyError(/** @type {Error} */ (persistenceError)))
457
+ failureStatus = failureStatusFor({attempt: this._attempt, classified, failedAt, retryPolicy: this.retryPolicy, status: this._status})
458
+ this._publish(failureStatus.status)
459
+ }
460
+ }
461
+
462
+ if (failureStatus.delayMs !== null) {
463
+ this._assertActive(generation)
464
+ this._retryTimer = this.scheduler.setTimeout(() => {
465
+ this._retryTimer = null
466
+ if (!this._ownsGeneration(generation)) return
467
+
468
+ void this.trigger("automatic-retry")
469
+ }, failureStatus.delayMs)
470
+ }
471
+
472
+ this.syncClient.reportError(error)
473
+ }
474
+
475
+ /**
476
+ * Persists the current safe status for an active generation.
477
+ * @param {number} generation - Owning generation.
478
+ * @returns {Promise<void>} - Resolves after persistence.
479
+ */
480
+ async _persistStatus(generation) {
481
+ if (this.statusStore) await this.statusStore.save(this._status)
482
+ this._assertActive(generation)
483
+ }
484
+
485
+ /**
486
+ * Coalesces one connectivity change into the coordinator cycle.
487
+ * @param {{generation: number, online: boolean}} args - Connectivity event.
488
+ * @returns {void}
489
+ */
490
+ _connectivityChanged({generation, online}) {
491
+ if (!this._ownsGeneration(generation)) return
492
+
493
+ this._clearRetryTimer()
494
+ if (online) this._attempt = 0
495
+ void this.trigger(online ? "connectivity-online" : "connectivity-offline")
496
+ }
497
+
498
+ /**
499
+ * Clears the currently owned retry timer, if present.
500
+ * @returns {void}
501
+ */
502
+ _clearRetryTimer() {
503
+ if (this._retryTimer === null) return
504
+
505
+ this.scheduler.clearTimeout(this._retryTimer)
506
+ this._retryTimer = null
507
+ }
508
+
509
+ /**
510
+ * Checks whether a lifecycle generation still owns state updates.
511
+ * @param {number} generation - Expected generation.
512
+ * @returns {boolean} - Whether the generation is current and active.
513
+ */
514
+ _ownsGeneration(generation) {
515
+ return this._active && this._generation === generation
516
+ }
517
+
518
+ /**
519
+ * Fails when work no longer belongs to the active generation.
520
+ * @param {number} generation - Expected generation.
521
+ * @returns {void}
522
+ */
523
+ _assertActive(generation) {
524
+ if (this._ownsGeneration(generation)) return
525
+
526
+ throw new SyncCoordinatorLifecycleAbortError("Sync coordinator work belongs to an inactive generation")
527
+ }
528
+
529
+ /**
530
+ * Identifies coordinator-owned cooperative cancellation.
531
+ * @param {unknown} error - Candidate error.
532
+ * @returns {boolean} - Whether this coordinator created the abort error.
533
+ */
534
+ isLifecycleAbort(error) {
535
+ return error instanceof SyncCoordinatorLifecycleAbortError
536
+ }
537
+
538
+ /**
539
+ * Reads and validates the injected clock.
540
+ * @returns {string} - Valid ISO clock value.
541
+ */
542
+ _nowIso() {
543
+ const value = this.now()
544
+
545
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new Error("SyncCoordinator now() must return a valid Date")
546
+
547
+ return value.toISOString()
548
+ }
549
+
550
+ /**
551
+ * Freezes and publishes a new observable snapshot.
552
+ * @param {import("./sync-coordinator-types.js").SyncCoordinatorStatus} status - New status.
553
+ * @returns {void}
554
+ */
555
+ _publish(status) {
556
+ this._status = immutableStatus(status)
557
+
558
+ for (const listener of this._listeners) listener(this._status)
559
+ }
560
+
561
+ /**
562
+ * Returns the current observable snapshot.
563
+ * @returns {import("./sync-coordinator-types.js").SyncCoordinatorStatus} - Current immutable snapshot.
564
+ */
565
+ status() {
566
+ return this._status
567
+ }
568
+
569
+ /**
570
+ * Observes status and receives the current snapshot immediately.
571
+ * @param {(status: import("./sync-coordinator-types.js").SyncCoordinatorStatus) => void} listener - Observer.
572
+ * @returns {() => void} - Idempotent unsubscribe.
573
+ */
574
+ subscribe(listener) {
575
+ requireFunction(listener, "status listener")
576
+ this._listeners.add(listener)
577
+ listener(this._status)
578
+
579
+ return () => this._listeners.delete(listener)
580
+ }
581
+
582
+ /**
583
+ * Awaits only the active or queued cycle, not a future backoff timer.
584
+ * @returns {Promise<void>} - Resolves when current work drains.
585
+ */
586
+ async waitForCurrentRun() {
587
+ while (this._runPromise) await this._runPromise
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Builds the default global timer adapter.
593
+ * @returns {import("./sync-coordinator-types.js").SyncCoordinatorScheduler} - Global timer adapter.
594
+ */
595
+ function defaultScheduler() {
596
+ return {
597
+ clearTimeout: (timer) => globalThis.clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (timer)),
598
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs)
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Validates and fills retry policy defaults.
604
+ * @param {Record<string, number>} retry - Retry overrides.
605
+ * @returns {{initialDelayMs: number, maxAttempts: number, maxDelayMs: number}} - Complete policy.
606
+ */
607
+ function normalizeRetryPolicy(retry) {
608
+ const policy = {...DEFAULT_RETRY, ...retry}
609
+
610
+ for (const [name, value] of Object.entries(policy)) {
611
+ if (!Number.isInteger(value) || value < 1) throw new Error(`SyncCoordinator retry.${name} must be a positive integer`)
612
+ }
613
+ if (policy.maxDelayMs < policy.initialDelayMs) throw new Error("SyncCoordinator retry.maxDelayMs must be greater than or equal to initialDelayMs")
614
+
615
+ return policy
616
+ }
617
+
618
+ /**
619
+ * Classifies unknown errors as permanent without exposing their messages.
620
+ * @param {Error} _error - Unclassified error.
621
+ * @returns {{code: string, retryable: boolean}} - Safe default classification.
622
+ */
623
+ function defaultErrorClassification(_error) {
624
+ return {code: "sync_failed", retryable: false}
625
+ }
626
+
627
+ /**
628
+ * Validates application-provided safe error metadata.
629
+ * @param {ReturnType<typeof defaultErrorClassification> & {message?: string}} classification - Raw classification.
630
+ * @returns {{code: string, message?: string, retryable: boolean}} - Validated classification.
631
+ */
632
+ function normalizeErrorClassification(classification) {
633
+ if (!classification || typeof classification !== "object" || Array.isArray(classification)) throw new Error("SyncCoordinator classifyError must return an object")
634
+ if (typeof classification.code !== "string" || classification.code.length === 0) throw new Error("SyncCoordinator error classification code must be a non-empty string")
635
+ if (typeof classification.retryable !== "boolean") throw new Error("SyncCoordinator error classification retryable must be boolean")
636
+ if (classification.message !== undefined && typeof classification.message !== "string") throw new Error("SyncCoordinator error classification message must be a string")
637
+
638
+ return classification
639
+ }
640
+
641
+ /**
642
+ * Builds one failure snapshot and its optional automatic-retry delay.
643
+ * @param {{attempt: number, classified: {code: string, message?: string, retryable: boolean}, failedAt: string, retryPolicy: {initialDelayMs: number, maxAttempts: number, maxDelayMs: number}, status: import("./sync-coordinator-types.js").SyncCoordinatorStatus}} args - Failure state.
644
+ * @returns {{delayMs: number | null, status: import("./sync-coordinator-types.js").SyncCoordinatorStatus}} - Failure snapshot and retry delay.
645
+ */
646
+ function failureStatusFor({attempt, classified, failedAt, retryPolicy, status}) {
647
+ const retryable = classified.retryable && attempt < retryPolicy.maxAttempts
648
+ const delayMs = retryable ? Math.min(retryPolicy.initialDelayMs * (2 ** (attempt - 1)), retryPolicy.maxDelayMs) : null
649
+ const failure = {
650
+ attempt,
651
+ at: failedAt,
652
+ code: classified.code,
653
+ ...(classified.message === undefined ? {} : {message: classified.message}),
654
+ retryable: classified.retryable
655
+ }
656
+ const nextRetryAt = delayMs === null ? null : new Date(new Date(failedAt).getTime() + delayMs).toISOString()
657
+
658
+ return {
659
+ delayMs,
660
+ status: immutableStatus({...status, failure, nextRetryAt, state: retryable ? "backoff" : "failed"})
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Maps durable queue state to an observable resting state.
666
+ * @param {import("./sync-coordinator-types.js").SyncClientInspection} inspection - Durable inspection.
667
+ * @returns {import("./sync-coordinator-types.js").SyncCoordinatorState} - Resting state.
668
+ */
669
+ function inspectionState(inspection) {
670
+ if (inspection.conflicts.length > 0) return "conflicted"
671
+ if (inspection.rejectedCount > 0) return "failed"
672
+ if (inspection.pendingCount > 0) return "pending"
673
+
674
+ return "idle"
675
+ }
676
+
677
+ /**
678
+ * Removes stale in-flight timing from a restored status snapshot.
679
+ * @param {import("./sync-coordinator-types.js").SyncCoordinatorStatus} status - Stored status.
680
+ * @returns {import("./sync-coordinator-types.js").SyncCoordinatorStatus} - Restored observable status.
681
+ */
682
+ function restoredStatus(status) {
683
+ if (!COORDINATOR_STATES.has(status.state)) throw new Error(`Unknown persisted SyncCoordinator state: ${String(status.state)}`)
684
+
685
+ return {
686
+ conflicts: status.conflicts,
687
+ failure: status.failure,
688
+ lastSuccessAt: status.lastSuccessAt,
689
+ nextRetryAt: null,
690
+ pendingCount: status.pendingCount,
691
+ rejectedCount: status.rejectedCount,
692
+ state: status.state === "syncing" || status.state === "backoff" ? inspectionState(status) : status.state
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Deep-freezes the status-owned diagnostic collections.
698
+ * @param {import("./sync-coordinator-types.js").SyncCoordinatorStatus} status - Snapshot.
699
+ * @returns {import("./sync-coordinator-types.js").SyncCoordinatorStatus} - Immutable snapshot.
700
+ */
701
+ function immutableStatus(status) {
702
+ const conflicts = status.conflicts.map((conflict) => Object.freeze({...conflict}))
703
+ const failure = status.failure ? Object.freeze({...status.failure}) : null
704
+
705
+ return Object.freeze({...status, conflicts: Object.freeze(conflicts), failure})
706
+ }
707
+
708
+ /**
709
+ * Validates one required callback.
710
+ * @param {unknown} value - Function candidate.
711
+ * @param {string} label - Contract label.
712
+ * @returns {void} - Validates the function candidate.
713
+ */
714
+ function requireFunction(value, label) {
715
+ if (typeof value !== "function") throw new Error(`SyncCoordinator ${label} must be a function`)
716
+ }
717
+
718
+ /**
719
+ * Validates the SyncClient surface required by the coordinator.
720
+ * @param {import("./sync-client.js").default} client - Client.
721
+ * @returns {void}
722
+ */
723
+ function requireCoordinatorClient(client) {
724
+ if (!client) throw new Error("SyncCoordinator requires a SyncClient")
725
+
726
+ requireFunction(client.attachCoordinator, "syncClient.attachCoordinator")
727
+ requireFunction(client.inspectSyncState, "syncClient.inspectSyncState")
728
+ requireFunction(client.isLifecycleAbort, "syncClient.isLifecycleAbort")
729
+ requireFunction(client.isOnline, "syncClient.isOnline")
730
+ requireFunction(client.pull, "syncClient.pull")
731
+ requireFunction(client.reportError, "syncClient.reportError")
732
+ requireFunction(client.replayPending, "syncClient.replayPending")
733
+ requireFunction(client.resolveConflict, "syncClient.resolveConflict")
734
+ requireFunction(client.start, "syncClient.start")
735
+ requireFunction(client.stop, "syncClient.stop")
736
+ requireFunction(client.subscribeRealtime, "syncClient.subscribeRealtime")
737
+ requireFunction(client.waitForScheduledReplay, "syncClient.waitForScheduledReplay")
738
+ }