velocious 1.0.672 → 1.0.674

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 (56) hide show
  1. package/README.md +1 -1
  2. package/build/database/drivers/base.js +52 -18
  3. package/build/database/drivers/mssql/index.js +48 -3
  4. package/build/frontend-model-controller.js +10 -2
  5. package/build/frontend-model-resource/base-resource.js +21 -2
  6. package/build/src/database/drivers/base.d.ts +23 -0
  7. package/build/src/database/drivers/base.d.ts.map +1 -1
  8. package/build/src/database/drivers/base.js +51 -21
  9. package/build/src/database/drivers/mssql/index.d.ts +22 -0
  10. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  11. package/build/src/database/drivers/mssql/index.js +45 -4
  12. package/build/src/frontend-model-controller.d.ts +5 -0
  13. package/build/src/frontend-model-controller.d.ts.map +1 -1
  14. package/build/src/frontend-model-controller.js +10 -3
  15. package/build/src/frontend-model-resource/base-resource.d.ts +10 -0
  16. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  17. package/build/src/frontend-model-resource/base-resource.js +19 -3
  18. package/build/src/sync/local-mutation-log.d.ts +12 -0
  19. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  20. package/build/src/sync/local-mutation-log.js +45 -1
  21. package/build/src/sync/sync-api-client.d.ts +6 -1
  22. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  23. package/build/src/sync/sync-api-client.js +7 -2
  24. package/build/src/sync/sync-client.d.ts +60 -0
  25. package/build/src/sync/sync-client.d.ts.map +1 -1
  26. package/build/src/sync/sync-client.js +172 -3
  27. package/build/src/sync/sync-coordinator-types.d.ts +190 -0
  28. package/build/src/sync/sync-coordinator-types.d.ts.map +1 -0
  29. package/build/src/sync/sync-coordinator-types.js +65 -0
  30. package/build/src/sync/sync-coordinator.d.ts +246 -0
  31. package/build/src/sync/sync-coordinator.d.ts.map +1 -0
  32. package/build/src/sync/sync-coordinator.js +683 -0
  33. package/build/src/sync/sync-envelope-replay-service.d.ts +90 -2
  34. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  35. package/build/src/sync/sync-envelope-replay-service.js +101 -9
  36. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  37. package/build/src/sync/sync-realtime-bridge.js +8 -1
  38. package/build/sync/local-mutation-log.js +53 -0
  39. package/build/sync/sync-api-client.js +7 -1
  40. package/build/sync/sync-client.js +190 -2
  41. package/build/sync/sync-coordinator-types.js +73 -0
  42. package/build/sync/sync-coordinator.js +738 -0
  43. package/build/sync/sync-envelope-replay-service.js +107 -8
  44. package/build/sync/sync-realtime-bridge.js +9 -0
  45. package/package.json +1 -1
  46. package/src/database/drivers/base.js +52 -18
  47. package/src/database/drivers/mssql/index.js +48 -3
  48. package/src/frontend-model-controller.js +10 -2
  49. package/src/frontend-model-resource/base-resource.js +21 -2
  50. package/src/sync/local-mutation-log.js +53 -0
  51. package/src/sync/sync-api-client.js +7 -1
  52. package/src/sync/sync-client.js +190 -2
  53. package/src/sync/sync-coordinator-types.js +73 -0
  54. package/src/sync/sync-coordinator.js +738 -0
  55. package/src/sync/sync-envelope-replay-service.js +107 -8
  56. package/src/sync/sync-realtime-bridge.js +9 -0
@@ -180,6 +180,8 @@ export default class SyncClient {
180
180
  this._scopeStore = scopeStore || null
181
181
  /** @type {Promise<void> | null} */
182
182
  this._scheduledReplay = null
183
+ /** @type {((reason: "mutation" | "realtime") => Promise<void>) | null} */
184
+ this._coordinatorTrigger = null
183
185
  /** @type {Record<string, import("./sync-api-client-types.js").SyncResourceConfig> | null} */
184
186
  this._pullResourceConfigs = null
185
187
  /** @type {Array<{callback: (record: ReturnType<typeof JSON.parse>) => Promise<void> | void, callbackName: "afterCreate" | "afterUpdate" | "afterDestroy" | "beforeUpdate" | "beforeDestroy", modelClass: ReturnType<typeof JSON.parse>}>} */
@@ -200,6 +202,7 @@ export default class SyncClient {
200
202
  this._lifecycleTransitionCount = 0
201
203
  /** @type {Promise<void>} */
202
204
  this._lifecycleTransitionPromise = Promise.resolve()
205
+ this._trackingGeneration = 0
203
206
  this._started = false
204
207
  }
205
208
 
@@ -212,7 +215,10 @@ export default class SyncClient {
212
215
  * @returns {Promise<void>}
213
216
  */
214
217
  async start() {
218
+ const trackingGeneration = this._trackingGeneration
219
+
215
220
  await this._lifecycleTransitionPromise
221
+ if (trackingGeneration !== this._trackingGeneration) return
216
222
  this.assertTenantReady()
217
223
  if (this._started) return
218
224
 
@@ -257,6 +263,8 @@ export default class SyncClient {
257
263
  * @returns {Promise<void>}
258
264
  */
259
265
  stop(options = {}) {
266
+ this._trackingGeneration += 1
267
+
260
268
  for (const {callback, callbackName, modelClass} of this._trackedCallbacks) {
261
269
  modelClass.unregisterLifecycleCallback(callbackName, callback)
262
270
  }
@@ -639,6 +647,33 @@ export default class SyncClient {
639
647
  setCurrentSyncClient(this)
640
648
  }
641
649
 
650
+ /**
651
+ * Attaches the one reusable coordinator that owns mutation and reconnect
652
+ * cycles for this client. The returned detach only removes the same owner.
653
+ * @param {(reason: "mutation" | "realtime") => Promise<void>} trigger - Coordinator trigger.
654
+ * @returns {() => void} Idempotent detach callback.
655
+ */
656
+ attachCoordinator(trigger) {
657
+ if (typeof trigger !== "function") throw new Error("SyncClient coordinator trigger must be a function")
658
+ if (this._coordinatorTrigger) throw new Error("SyncClient already has an attached coordinator")
659
+
660
+ this._coordinatorTrigger = trigger
661
+
662
+ return () => {
663
+ if (this._coordinatorTrigger === trigger) this._coordinatorTrigger = null
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Routes framework-owned work through the attached coordinator, or returns
669
+ * null so the legacy direct scheduling owner may run.
670
+ * @param {"mutation" | "realtime"} reason - Trigger reason.
671
+ * @returns {Promise<void> | null} Coordinator flight, or null without an owner.
672
+ */
673
+ requestCoordinatorSync(reason) {
674
+ return this._coordinatorTrigger ? this._coordinatorTrigger(reason) : null
675
+ }
676
+
642
677
  /**
643
678
  * Returns the app's current sync client.
644
679
  * @returns {SyncClient} Current sync client.
@@ -892,6 +927,26 @@ export default class SyncClient {
892
927
  await this._subscribeUserScopePromise
893
928
  }
894
929
 
930
+ /**
931
+ * Declares and activates the server-enumerated user scope without starting
932
+ * realtime or pulling. Use this from SyncCoordinator.prepare() so the
933
+ * coordinator remains the sole network ordering owner.
934
+ * @returns {Promise<void>}
935
+ */
936
+ async activateUserScope() {
937
+ await this._runLifecycleWork(async (signal) => await this._activateUserScope(signal))
938
+ }
939
+
940
+ /**
941
+ * Activates the user scope under the current lifecycle generation.
942
+ * @param {AbortSignal} signal - Lifecycle cancellation signal.
943
+ * @returns {Promise<void>} - Resolves after the scope is durable.
944
+ */
945
+ async _activateUserScope(signal) {
946
+ await this.scopeStore().findOrCreateScope(await this.userScope())
947
+ this._throwIfLifecycleAborted(signal)
948
+ }
949
+
895
950
  /**
896
951
  * Declares and activates the user scope for every pullable resource, then
897
952
  * subscribes realtime and pulls.
@@ -902,8 +957,7 @@ export default class SyncClient {
902
957
  this._userScopeState = "subscribing"
903
958
 
904
959
  try {
905
- await this.scopeStore().findOrCreateScope(await this.userScope())
906
- this._throwIfLifecycleAborted(signal)
960
+ await this._activateUserScope(signal)
907
961
 
908
962
  await this.subscribeRealtime()
909
963
  this._throwIfLifecycleAborted(signal)
@@ -1087,6 +1141,7 @@ export default class SyncClient {
1087
1141
  if (!resourceConfig.conflictTracking) continue
1088
1142
 
1089
1143
  await SyncApiClient.replayConflictTrackedSyncs({
1144
+ applyConflict: async ({record, result}) => await this.applyConflictReplayResult({record, result, resourceType}),
1090
1145
  authenticationToken: await this.config.authenticationToken(),
1091
1146
  batchSize: this.config.batchSize,
1092
1147
  conflictTracking: resourceConfig.conflictTracking,
@@ -1110,6 +1165,97 @@ export default class SyncClient {
1110
1165
  this._throwIfLifecycleAborted(signal)
1111
1166
  }
1112
1167
 
1168
+ /**
1169
+ * Applies an authoritative serverModel returned by a conflict through the
1170
+ * same tenant-bound, tracking-suppressed applier used by pull/realtime.
1171
+ * A conflict result without serverModel has no authoritative state to apply
1172
+ * and remains diagnostic-only.
1173
+ * @param {{record: import("./local-mutation-log.js").LocalMutationLogRecord, resourceType: string, result: import("./sync-api-client-types.js").SyncReplayItem}} args - Conflict result.
1174
+ * @returns {Promise<void>}
1175
+ */
1176
+ async applyConflictReplayResult({record, resourceType, result}) {
1177
+ const conflict = result.conflict
1178
+
1179
+ if (!conflict || !Object.hasOwn(conflict, "serverModel")) return
1180
+
1181
+ const serverModel = conflict.serverModel
1182
+
1183
+ if (serverModel !== null && (typeof serverModel !== "object" || Array.isArray(serverModel))) {
1184
+ throw new Error(`Sync conflict serverModel for ${resourceType} must be an object or null`)
1185
+ }
1186
+
1187
+ const resourceId = record.mutation.payload?.resourceId
1188
+
1189
+ if (typeof resourceId !== "string" || resourceId.length === 0) throw new Error(`Sync conflict for ${resourceType} is missing resourceId`)
1190
+
1191
+ const sync = SyncApiClient.syncEnvelopeFromPayload({
1192
+ data: serverModel,
1193
+ resourceId,
1194
+ resourceType,
1195
+ syncType: serverModel === null ? "delete" : "update"
1196
+ })
1197
+
1198
+ await this.remoteApplySync({source: "conflict server version"})(sync)
1199
+ }
1200
+
1201
+ /**
1202
+ * Returns queue counts and privacy-safe conflicts for coordinator/UI status.
1203
+ * Full local/server payloads remain only in the durable mutation log.
1204
+ * @returns {Promise<import("./sync-coordinator-types.js").SyncClientInspection>} Durable sync state.
1205
+ */
1206
+ async inspectSyncState() {
1207
+ return await this._runLifecycleWork(async (signal) => {
1208
+ this._throwIfLifecycleAborted(signal)
1209
+ let pendingCount = 0
1210
+ let rejectedCount = 0
1211
+ /** @type {import("./sync-coordinator-types.js").SyncConflictDiagnostic[]} */
1212
+ const conflicts = []
1213
+
1214
+ for (const [resourceType, resourceConfig] of Object.entries(this.config.resources)) {
1215
+ if (!resourceConfig.conflictTracking) continue
1216
+
1217
+ for (const record of await resourceConfig.conflictTracking.mutationLog.records()) {
1218
+ if (record.mutation.model !== resourceType) continue
1219
+ if (["pending", "applied-locally", "peer-applied"].includes(record.status)) pendingCount += 1
1220
+ if (record.status === "rejected") rejectedCount += 1
1221
+ if (record.status === "conflict") conflicts.push(syncConflictDiagnostic(record))
1222
+ }
1223
+ this._throwIfLifecycleAborted(signal)
1224
+ }
1225
+
1226
+ pendingCount += await this.withTenantOperation(async (operation) => {
1227
+ const syncModel = operation ? operation.modelClass(this.config.syncModel) : this.config.syncModel
1228
+ const pendingRows = await syncModel.preload({resource: true}).where({state: "pending"}).order("created_at").toArray()
1229
+
1230
+ return pendingRows.length
1231
+ })
1232
+ this._throwIfLifecycleAborted(signal)
1233
+
1234
+ return {conflicts, pendingCount, rejectedCount}
1235
+ })
1236
+ }
1237
+
1238
+ /**
1239
+ * Resolves a durable conflict on its declared resource log. Retry-local is
1240
+ * scheduled through the current coordinator when attached.
1241
+ * @param {{recordId: string, resolution: "keep-server" | "retry-local", resourceType: string}} args - Resolution.
1242
+ * @returns {Promise<import("./local-mutation-log.js").LocalMutationLogRecord>} - Resolved durable record.
1243
+ */
1244
+ async resolveConflict({recordId, resolution, resourceType}) {
1245
+ return await this._runLifecycleWork(async (signal) => {
1246
+ const conflictTracking = this.config.resources[resourceType]?.conflictTracking
1247
+
1248
+ if (!conflictTracking) throw new Error(`No conflict-tracked sync resource configured for: ${resourceType}`)
1249
+
1250
+ const record = await conflictTracking.mutationLog.resolveConflict({id: recordId, resolution})
1251
+
1252
+ this._throwIfLifecycleAborted(signal)
1253
+ if (resolution === "retry-local") this.scheduleReplay()
1254
+
1255
+ return record
1256
+ })
1257
+ }
1258
+
1113
1259
  /**
1114
1260
  * Records an authoritative remote observation so an in-flight acknowledgement
1115
1261
  * cannot rebase a successor across that observation.
@@ -1192,6 +1338,13 @@ export default class SyncClient {
1192
1338
  * @returns {void}
1193
1339
  */
1194
1340
  scheduleReplay() {
1341
+ const coordinatorRun = this.requestCoordinatorSync("mutation")
1342
+
1343
+ if (coordinatorRun) {
1344
+ this._scheduledReplay = coordinatorRun
1345
+ return
1346
+ }
1347
+
1195
1348
  this._scheduledReplay = (async () => {
1196
1349
  try {
1197
1350
  await this.replayPending()
@@ -1414,6 +1567,41 @@ export default class SyncClient {
1414
1567
  }
1415
1568
  }
1416
1569
 
1570
+ /**
1571
+ * Builds privacy-safe conflict metadata from a durable record.
1572
+ * @param {import("./local-mutation-log.js").LocalMutationLogRecord} record - Durable conflict record.
1573
+ * @returns {import("./sync-coordinator-types.js").SyncConflictDiagnostic} - Safe diagnostic.
1574
+ */
1575
+ function syncConflictDiagnostic(record) {
1576
+ const conflict = record.syncResult?.conflict
1577
+ const conflictObject = conflict && !(conflict instanceof Date) && typeof conflict === "object" && !Array.isArray(conflict)
1578
+ ? /** @type {Record<string, unknown>} */ (conflict)
1579
+ : {}
1580
+ const resourceId = record.mutation.payload?.resourceId
1581
+
1582
+ if (typeof resourceId !== "string" || resourceId.length === 0) throw new Error(`Sync conflict ${record.id} is missing a safe resourceId`)
1583
+
1584
+ return {
1585
+ baseVersion: safeConflictVersion(conflictObject.baseVersion ?? record.mutation.baseVersion ?? null),
1586
+ clientMutationId: record.mutation.clientMutationId,
1587
+ localVersion: safeConflictVersion(conflictObject.localVersion ?? null),
1588
+ recordId: record.id,
1589
+ resourceId,
1590
+ resourceType: record.mutation.model,
1591
+ serverVersion: safeConflictVersion(conflictObject.serverVersion ?? null),
1592
+ versionAttribute: typeof conflictObject.versionAttribute === "string" ? conflictObject.versionAttribute : null
1593
+ }
1594
+ }
1595
+
1596
+ /**
1597
+ * Restricts diagnostic versions to safe scalar values.
1598
+ * @param {unknown} value - Version candidate.
1599
+ * @returns {string | number | null} - Safe scalar version.
1600
+ */
1601
+ function safeConflictVersion(value) {
1602
+ return value === null || typeof value === "string" || typeof value === "number" ? value : null
1603
+ }
1604
+
1417
1605
  /**
1418
1606
  * Builds one resource config from a model's `static sync` declaration plus its
1419
1607
  * derived column metadata.
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+ /* eslint-disable jsdoc/no-undefined-types -- ReadonlyArray is a TypeScript utility type used by checkJs. */
3
+
4
+ /** @typedef {"backoff" | "conflicted" | "failed" | "idle" | "offline" | "pending" | "stopped" | "syncing"} SyncCoordinatorState */
5
+
6
+ /**
7
+ * Privacy-safe conflict metadata for status surfaces. Mutation attributes,
8
+ * authoritative record data, credentials, and rejection reasons are excluded.
9
+ * @typedef {object} SyncConflictDiagnostic
10
+ * @property {string | number | null} baseVersion - Version the local mutation was based on.
11
+ * @property {string} clientMutationId - Durable client idempotency identifier.
12
+ * @property {string | number | null} localVersion - Optional client version returned by the conflict contract.
13
+ * @property {string} recordId - Durable local mutation-log record id.
14
+ * @property {string} resourceId - Stable resource identifier.
15
+ * @property {string} resourceType - Stable resource/model type.
16
+ * @property {string | number | null} serverVersion - Authoritative server version.
17
+ * @property {string | null} versionAttribute - Authoritative version attribute name.
18
+ */
19
+
20
+ /**
21
+ * Safe classified failure metadata. Applications own the classifier and must
22
+ * return only user-safe codes/messages; the original Error is never persisted.
23
+ * @typedef {object} SyncCoordinatorFailure
24
+ * @property {number} attempt - Consecutive cycle attempt number.
25
+ * @property {string} at - ISO timestamp of the failure.
26
+ * @property {string} code - Stable safe error code.
27
+ * @property {string} [message] - Optional safe display message.
28
+ * @property {boolean} retryable - Whether automatic retry is allowed.
29
+ */
30
+
31
+ /**
32
+ * Immutable coordinator status snapshot.
33
+ * @typedef {object} SyncCoordinatorStatus
34
+ * @property {ReadonlyArray<SyncConflictDiagnostic>} conflicts - Privacy-safe unresolved conflicts.
35
+ * @property {SyncCoordinatorFailure | null} failure - Last unresolved cycle failure.
36
+ * @property {string | null} lastSuccessAt - Last successful online cycle timestamp.
37
+ * @property {string | null} nextRetryAt - Scheduled automatic retry timestamp.
38
+ * @property {number} pendingCount - Durable pending mutation count.
39
+ * @property {number} rejectedCount - Durable permanently rejected mutation count.
40
+ * @property {SyncCoordinatorState} state - Observable lifecycle state.
41
+ */
42
+
43
+ /**
44
+ * Durable sync state reported by SyncClient.
45
+ * @typedef {object} SyncClientInspection
46
+ * @property {ReadonlyArray<SyncConflictDiagnostic>} conflicts - Privacy-safe conflict diagnostics.
47
+ * @property {number} pendingCount - Durable pending mutation count.
48
+ * @property {number} rejectedCount - Durable rejected mutation count.
49
+ */
50
+
51
+ /**
52
+ * Application connectivity signal adapter. SyncClient remains the authority
53
+ * for the current online check; this adapter only notifies the coordinator.
54
+ * @typedef {object} SyncCoordinatorConnectivity
55
+ * @property {(listener: (online: boolean) => void) => (() => void)} subscribe - Subscribes to connectivity changes.
56
+ */
57
+
58
+ /**
59
+ * Injected timer ownership for deterministic retry tests and teardown.
60
+ * @typedef {object} SyncCoordinatorScheduler
61
+ * @property {(timer: unknown) => void} clearTimeout - Clears a scheduled retry.
62
+ * @property {(callback: () => void, delayMs: number) => unknown} setTimeout - Schedules a retry.
63
+ */
64
+
65
+ /**
66
+ * Optional durable status metadata store. Conflict payloads remain owned by
67
+ * LocalMutationLog; this store receives only privacy-safe snapshots.
68
+ * @typedef {object} SyncCoordinatorStatusStore
69
+ * @property {() => Promise<SyncCoordinatorStatus | null> | SyncCoordinatorStatus | null} load - Loads the last safe snapshot.
70
+ * @property {(status: SyncCoordinatorStatus) => Promise<void> | void} save - Persists one safe snapshot.
71
+ */
72
+
73
+ export {}