velocious 1.0.672 → 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 (46) 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/src/database/drivers/base.d.ts +23 -0
  5. package/build/src/database/drivers/base.d.ts.map +1 -1
  6. package/build/src/database/drivers/base.js +51 -21
  7. package/build/src/database/drivers/mssql/index.d.ts +22 -0
  8. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  9. package/build/src/database/drivers/mssql/index.js +45 -4
  10. package/build/src/sync/local-mutation-log.d.ts +12 -0
  11. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  12. package/build/src/sync/local-mutation-log.js +45 -1
  13. package/build/src/sync/sync-api-client.d.ts +6 -1
  14. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  15. package/build/src/sync/sync-api-client.js +7 -2
  16. package/build/src/sync/sync-client.d.ts +60 -0
  17. package/build/src/sync/sync-client.d.ts.map +1 -1
  18. package/build/src/sync/sync-client.js +172 -3
  19. package/build/src/sync/sync-coordinator-types.d.ts +190 -0
  20. package/build/src/sync/sync-coordinator-types.d.ts.map +1 -0
  21. package/build/src/sync/sync-coordinator-types.js +65 -0
  22. package/build/src/sync/sync-coordinator.d.ts +246 -0
  23. package/build/src/sync/sync-coordinator.d.ts.map +1 -0
  24. package/build/src/sync/sync-coordinator.js +683 -0
  25. package/build/src/sync/sync-envelope-replay-service.d.ts +90 -2
  26. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  27. package/build/src/sync/sync-envelope-replay-service.js +101 -9
  28. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  29. package/build/src/sync/sync-realtime-bridge.js +8 -1
  30. package/build/sync/local-mutation-log.js +53 -0
  31. package/build/sync/sync-api-client.js +7 -1
  32. package/build/sync/sync-client.js +190 -2
  33. package/build/sync/sync-coordinator-types.js +73 -0
  34. package/build/sync/sync-coordinator.js +738 -0
  35. package/build/sync/sync-envelope-replay-service.js +107 -8
  36. package/build/sync/sync-realtime-bridge.js +9 -0
  37. package/package.json +1 -1
  38. package/src/database/drivers/base.js +52 -18
  39. package/src/database/drivers/mssql/index.js +48 -3
  40. package/src/sync/local-mutation-log.js +53 -0
  41. package/src/sync/sync-api-client.js +7 -1
  42. package/src/sync/sync-client.js +190 -2
  43. package/src/sync/sync-coordinator-types.js +73 -0
  44. package/src/sync/sync-coordinator.js +738 -0
  45. package/src/sync/sync-envelope-replay-service.js +107 -8
  46. package/src/sync/sync-realtime-bridge.js +9 -0
@@ -40,6 +40,19 @@ import {scalarModelPrimaryKey, scalarModelPrimaryKeyValue} from "../utils/model-
40
40
  * @property {(args: Record<string, ReturnType<typeof JSON.parse>>) => ReturnType<typeof JSON.parse>} body - Broadcast body.
41
41
  * @property {(args: Record<string, ReturnType<typeof JSON.parse>>) => boolean} [when] - Optional gate; skipped when it returns false.
42
42
  */
43
+ /**
44
+ * Private durable idempotency metadata stored outside an application's public change feed.
45
+ * @typedef {object} SyncReplayReceipt
46
+ * @property {string | number | null} acknowledgementVersion - Authoritative version returned for an exact retry.
47
+ * @property {string} clientMutationId - Stable client-owned mutation identity.
48
+ * @property {string} mutationFingerprint - Hash of the complete normalized mutation identity and intent.
49
+ */
50
+ /**
51
+ * Application-owned durable receipt storage.
52
+ * @typedef {object} SyncReplayReceiptStore
53
+ * @property {(args: {actor: ReturnType<typeof JSON.parse>, context: Record<string, ReturnType<typeof JSON.parse>>, mutation: SyncReplayMutation}) => Promise<SyncReplayReceipt | null>} find - Finds a receipt in the already-authorized replay partition.
54
+ * @property {(args: {actor: ReturnType<typeof JSON.parse>, context: Record<string, ReturnType<typeof JSON.parse>>, mutation: SyncReplayMutation, receipt: SyncReplayReceipt}) => Promise<void>} save - Durably records a successful apply before its response is returned.
55
+ */
43
56
 
44
57
  /**
45
58
  * Replays client sync envelopes through project supplied authentication,
@@ -75,6 +88,7 @@ export default class SyncEnvelopeReplayService {
75
88
  * @param {import("../configuration.js").default} [args.configuration] - Configuration whose frontend-model registry routes mutations to resource classes.
76
89
  * @param {{strategy?: "optimisticVersion" | "serverWins", versionAttribute: string} | null} [args.conflictStrategy] - Optional base-version conflict detection for routed upserts. Only `optimisticVersion` and `serverWins` are supported for backend replay because the server does not have the client's base snapshot. When `strategy` is omitted it defaults to `optimisticVersion`, matching `resolveSyncConflict` and normalized resource config. When configured, a mutation whose baseVersion does not match the current server versionAttribute is rejected with a structured conflict result instead of being applied.
77
90
  * @param {Record<string, import("../configuration-types.js").FrontendModelResourceClassType | string>} [args.resourceTypeOverrides] - Per-resourceType routing overrides: a resource class, or a string alias resolved through the registry.
91
+ * @param {SyncReplayReceiptStore} [args.replayReceiptStore] - Private durable idempotency storage, separate from public sync/change rows.
78
92
  * @param {import("../authorization/ability.js").default} [args.ability] - Ability scoping routed record lookups and create membership checks.
79
93
  * @param {Record<string, ReturnType<typeof JSON.parse>>} [args.abilityContext] - Ability context passed to routed resources.
80
94
  * @param {Record<string, ReturnType<typeof JSON.parse>>} [args.locals] - Locals passed to routed resources.
@@ -94,6 +108,7 @@ export default class SyncEnvelopeReplayService {
94
108
  this.configuration = args.configuration || null
95
109
  this.conflictStrategy = args.conflictStrategy || null
96
110
  this.resourceTypeOverrides = args.resourceTypeOverrides || null
111
+ this.replayReceiptStore = args.replayReceiptStore || null
97
112
  this.ability = args.ability || null
98
113
  this.abilityContext = args.abilityContext || null
99
114
  this.locals = args.locals || null
@@ -106,6 +121,9 @@ export default class SyncEnvelopeReplayService {
106
121
  if (this.broadcasts && !this.broadcaster) {
107
122
  throw new Error("SyncEnvelopeReplayService broadcasts require a broadcaster option delivering them")
108
123
  }
124
+ if (this.replayReceiptStore && (typeof this.replayReceiptStore.find !== "function" || typeof this.replayReceiptStore.save !== "function")) {
125
+ throw new Error("SyncEnvelopeReplayService replayReceiptStore requires find and save functions")
126
+ }
109
127
  if (this.conflictStrategy) {
110
128
  const supportedConflictStrategies = new Set(["optimisticVersion", "serverWins"])
111
129
 
@@ -163,6 +181,7 @@ export default class SyncEnvelopeReplayService {
163
181
  }
164
182
 
165
183
  const mutation = normalizedResult.mutation
184
+ const mutationFingerprint = this.replayMutationFingerprint(mutation)
166
185
  const accessResult = await this.authorizeReplayMutation({actor: actorResult.actor, context, mutation})
167
186
 
168
187
  if (!accessResult.allowed) {
@@ -175,8 +194,18 @@ export default class SyncEnvelopeReplayService {
175
194
  }
176
195
 
177
196
  const existingSync = await this.findExistingReplaySync({actor: actorResult.actor, context, mutation})
178
- const shouldApply = await this.shouldApplyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
179
- const duplicate = !shouldApply && this.isDuplicateReplayMutation({existingSync, mutation})
197
+ const replayReceipt = await this.findReplayReceipt({actor: actorResult.actor, context, mutation})
198
+ const receiptDuplicate = replayReceipt ? this.isDuplicateReplayReceipt({mutationFingerprint, mutation, receipt: replayReceipt}) : false
199
+
200
+ if (replayReceipt && !receiptDuplicate) {
201
+ syncResponses.push({id: mutation.id, reason: "sync-client-mutation-id-reused", syncState: "failed"})
202
+ continue
203
+ }
204
+
205
+ const shouldApply = receiptDuplicate
206
+ ? false
207
+ : await this.shouldApplyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
208
+ const duplicate = receiptDuplicate || (!shouldApply && this.isDuplicateReplayMutation({existingSync, mutation}))
180
209
 
181
210
  /** @type {ReturnType<typeof JSON.parse>} */
182
211
  let applyResult
@@ -184,7 +213,7 @@ export default class SyncEnvelopeReplayService {
184
213
  try {
185
214
  applyResult = shouldApply
186
215
  ? await this.applyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
187
- : await this.skippedReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
216
+ : await this.skippedReplayMutation({actor: actorResult.actor, context, duplicate, existingSync, mutation})
188
217
  } catch (error) {
189
218
  // Client-safe apply failures (schema validation, model validation,
190
219
  // authorization denials, unknown resource types) fail this sync and
@@ -212,12 +241,13 @@ export default class SyncEnvelopeReplayService {
212
241
  }
213
242
 
214
243
  await this.persistReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
244
+ if (shouldApply) await this.persistReplayReceipt({actor: actorResult.actor, context, mutation, mutationFingerprint, applyResult})
215
245
  await this.afterReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
216
246
 
217
247
  /** @type {Record<string, ReturnType<typeof JSON.parse>>} */
218
248
  const successfulResponse = {id: mutation.id, syncState: duplicate ? "duplicate" : "successful"}
219
249
 
220
- const persistedReplayMetadata = duplicate ? this.replayPersistedMetadata(existingSync) : null
250
+ const persistedReplayMetadata = duplicate ? replayReceipt ?? this.replayPersistedMetadata(existingSync) : null
221
251
 
222
252
  if (persistedReplayMetadata) {
223
253
  successfulResponse.serverVersion = persistedReplayMetadata.acknowledgementVersion
@@ -470,6 +500,75 @@ export default class SyncEnvelopeReplayService {
470
500
  return decodeReplayPersistedData(this.replaySyncRecordValue(syncRecord, "data")).metadata
471
501
  }
472
502
 
503
+ /**
504
+ * Loads private idempotency metadata from the application-owned durable store.
505
+ * @param {{actor: ReturnType<typeof JSON.parse>, context: Record<string, ReturnType<typeof JSON.parse>>, mutation: SyncReplayMutation}} args - Authorized replay context.
506
+ * @returns {Promise<SyncReplayReceipt | null>} Durable receipt or null.
507
+ */
508
+ async findReplayReceipt(args) {
509
+ if (!this.replayReceiptStore) return null
510
+
511
+ const receipt = await this.replayReceiptStore.find(args)
512
+
513
+ if (receipt === null) return null
514
+ if (!receipt || typeof receipt !== "object" || typeof receipt.clientMutationId !== "string" || typeof receipt.mutationFingerprint !== "string") {
515
+ throw new Error("Sync replay receipt store returned invalid receipt metadata")
516
+ }
517
+
518
+ return receipt
519
+ }
520
+
521
+ /**
522
+ * Checks an incoming normalized mutation against private receipt metadata.
523
+ * @param {{mutation: SyncReplayMutation, mutationFingerprint?: string, receipt: SyncReplayReceipt}} args - Mutation and durable receipt.
524
+ * @returns {boolean} Whether this is the exact mutation whose apply was acknowledged.
525
+ */
526
+ isDuplicateReplayReceipt({mutation, mutationFingerprint = this.replayMutationFingerprint(mutation), receipt}) {
527
+ return receipt.clientMutationId === String(mutation.clientMutationId || mutation.id)
528
+ && receipt.mutationFingerprint === mutationFingerprint
529
+ }
530
+
531
+ /**
532
+ * Hashes the complete normalized mutation intent without retaining its payload.
533
+ * @param {SyncReplayMutation} mutation - Normalized replay mutation.
534
+ * @returns {string} Stable SHA-256 fingerprint.
535
+ */
536
+ replayMutationFingerprint(mutation) {
537
+ return sha256Hex(stableJsonStringify({
538
+ baseVersion: mutation.baseVersion,
539
+ clientUpdatedAt: mutation.clientUpdatedAt.toISOString(),
540
+ data: mutation.data,
541
+ resourceId: mutation.resourceId,
542
+ resourceType: mutation.resourceType,
543
+ syncType: mutation.syncType
544
+ }))
545
+ }
546
+
547
+ /**
548
+ * Persists private receipt metadata after a successful apply and feed write.
549
+ * @param {{actor: ReturnType<typeof JSON.parse>, applyResult: ReturnType<typeof JSON.parse>, context: Record<string, ReturnType<typeof JSON.parse>>, mutation: SyncReplayMutation, mutationFingerprint: string}} args - Applied replay context.
550
+ * @returns {Promise<void>} Completion after the receipt is durable.
551
+ */
552
+ async persistReplayReceipt({actor, applyResult, context, mutation, mutationFingerprint}) {
553
+ if (!this.replayReceiptStore) return
554
+
555
+ let acknowledgementVersion = null
556
+ if (this.conflictStrategy && applyResult?.record) {
557
+ acknowledgementVersion = normalizeConflictValue(applyResult.record.readAttribute(this.conflictStrategy.versionAttribute))
558
+ }
559
+
560
+ await this.replayReceiptStore.save({
561
+ actor,
562
+ context,
563
+ mutation,
564
+ receipt: {
565
+ acknowledgementVersion,
566
+ clientMutationId: String(mutation.clientMutationId || mutation.id),
567
+ mutationFingerprint
568
+ }
569
+ })
570
+ }
571
+
473
572
  /**
474
573
  * Applies one normalized mutation to domain models.
475
574
  *
@@ -1059,11 +1158,11 @@ export default class SyncEnvelopeReplayService {
1059
1158
  * Resolves an apply result for stale mutations that should not touch domain models.
1060
1159
  * Exact duplicates resolve the current routed record so the acknowledgement
1061
1160
  * can include its authoritative version without applying the mutation again.
1062
- * @param {{actor: ReturnType<typeof JSON.parse>, context: Record<string, ReturnType<typeof JSON.parse>>, existingSync: ReturnType<typeof JSON.parse>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and mutation.
1161
+ * @param {{actor: ReturnType<typeof JSON.parse>, context: Record<string, ReturnType<typeof JSON.parse>>, duplicate?: boolean, existingSync: ReturnType<typeof JSON.parse>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and duplicate decision.
1063
1162
  * @returns {Promise<ReturnType<typeof JSON.parse>>} Project-specific apply result.
1064
1163
  */
1065
- async skippedReplayMutation({actor, context, existingSync, mutation}) {
1066
- if (!this.isDuplicateReplayMutation({existingSync, mutation}) || !this.routingConfigured()) return null
1164
+ async skippedReplayMutation({actor, context, duplicate = false, existingSync, mutation}) {
1165
+ if ((!duplicate && !this.isDuplicateReplayMutation({existingSync, mutation})) || !this.routingConfigured()) return null
1067
1166
 
1068
1167
  const registration = this.replayResourceRegistration(mutation.resourceType)
1069
1168
 
@@ -1102,7 +1201,7 @@ export default class SyncEnvelopeReplayService {
1102
1201
  }
1103
1202
  }
1104
1203
 
1105
- if (this.conflictStrategy && shouldApply && mutation.baseVersion !== undefined && applyResult?.record) {
1204
+ if (!this.replayReceiptStore && this.conflictStrategy && shouldApply && mutation.baseVersion !== undefined && applyResult?.record) {
1106
1205
  const publicPayload = decodeReplayPersistedData(attributes.data).payload
1107
1206
  const acknowledgementVersion = normalizeConflictValue(applyResult.record.readAttribute(this.conflictStrategy.versionAttribute))
1108
1207
 
@@ -400,6 +400,15 @@ export default class SyncRealtimeBridge {
400
400
  if (generation !== this._generation) return
401
401
  if (this.realtimeConfiguration()?.pullOnReconnect === false) return
402
402
 
403
+ const coordinatorRun = this.syncClient.requestCoordinatorSync("realtime")
404
+
405
+ if (coordinatorRun) {
406
+ this._scheduledPull ||= coordinatorRun.finally(() => {
407
+ this._scheduledPull = null
408
+ })
409
+ return
410
+ }
411
+
403
412
  this._scheduledPull ||= (async () => {
404
413
  try {
405
414
  await this.syncClient.pull()