velocious 1.0.591 → 1.0.592

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/src/sync/conflict-strategy.d.ts.map +1 -1
  3. package/build/src/sync/conflict-strategy.js +7 -1
  4. package/build/src/sync/local-mutation-log.d.ts +10 -0
  5. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  6. package/build/src/sync/local-mutation-log.js +21 -1
  7. package/build/src/sync/sync-api-client-types.d.ts +15 -0
  8. package/build/src/sync/sync-api-client-types.d.ts.map +1 -1
  9. package/build/src/sync/sync-api-client-types.js +4 -1
  10. package/build/src/sync/sync-api-client.d.ts +91 -0
  11. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  12. package/build/src/sync/sync-api-client.js +184 -1
  13. package/build/src/sync/sync-client-types.d.ts +57 -0
  14. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  15. package/build/src/sync/sync-client-types.js +16 -1
  16. package/build/src/sync/sync-client.d.ts +56 -7
  17. package/build/src/sync/sync-client.d.ts.map +1 -1
  18. package/build/src/sync/sync-client.js +180 -15
  19. package/build/src/sync/sync-envelope-replay-service.d.ts +31 -2
  20. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  21. package/build/src/sync/sync-envelope-replay-service.js +98 -16
  22. package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
  23. package/build/src/sync/sync-model-change-feed-service.js +3 -4
  24. package/build/src/sync/sync-replay-persisted-data.d.ts +26 -0
  25. package/build/src/sync/sync-replay-persisted-data.d.ts.map +1 -0
  26. package/build/src/sync/sync-replay-persisted-data.js +43 -0
  27. package/build/sync/conflict-strategy.js +3 -0
  28. package/build/sync/local-mutation-log.js +24 -0
  29. package/build/sync/sync-api-client-types.js +3 -0
  30. package/build/sync/sync-api-client.js +203 -0
  31. package/build/sync/sync-client-types.js +16 -0
  32. package/build/sync/sync-client.js +198 -14
  33. package/build/sync/sync-envelope-replay-service.js +109 -12
  34. package/build/sync/sync-model-change-feed-service.js +2 -4
  35. package/build/sync/sync-replay-persisted-data.js +50 -0
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +1 -1
  38. package/src/sync/conflict-strategy.js +3 -0
  39. package/src/sync/local-mutation-log.js +24 -0
  40. package/src/sync/sync-api-client-types.js +3 -0
  41. package/src/sync/sync-api-client.js +203 -0
  42. package/src/sync/sync-client-types.js +16 -0
  43. package/src/sync/sync-client.js +198 -14
  44. package/src/sync/sync-envelope-replay-service.js +109 -12
  45. package/src/sync/sync-model-change-feed-service.js +2 -4
  46. package/src/sync/sync-replay-persisted-data.js +50 -0
@@ -119,10 +119,14 @@ export default class SyncClient {
119
119
  this._scheduledReplay = null
120
120
  /** @type {Record<string, import("./sync-api-client-types.js").SyncResourceConfig> | null} */
121
121
  this._pullResourceConfigs = null
122
- /** @type {Array<{callback: (record: ReturnType<typeof JSON.parse>) => Promise<void>, callbackName: "afterCreate" | "afterUpdate" | "afterDestroy", modelClass: ReturnType<typeof JSON.parse>}>} */
122
+ /** @type {Array<{callback: (record: ReturnType<typeof JSON.parse>) => Promise<void> | void, callbackName: "afterCreate" | "afterUpdate" | "afterDestroy" | "beforeUpdate" | "beforeDestroy", modelClass: ReturnType<typeof JSON.parse>}>} */
123
123
  this._trackedCallbacks = []
124
124
  /** @type {WeakSet<object>} */
125
125
  this._remoteApplyRecords = new WeakSet()
126
+ /** @type {Map<string, number>} */
127
+ this._remoteGenerations = new Map()
128
+ /** @type {WeakMap<object, Array<string | number | null>>} */
129
+ this._capturedBaseVersions = new WeakMap()
126
130
  this._withoutTrackingDepth = 0
127
131
  /** @type {Logger | {error: (...messages: Array<ReturnType<typeof JSON.parse>>) => Promise<void>} | null} */
128
132
  this._logger = null
@@ -145,6 +149,23 @@ export default class SyncClient {
145
149
  for (const [resourceType, resourceConfig] of Object.entries(this.config.resources)) {
146
150
  const operations = this.trackedOperations({resourceConfig, resourceType})
147
151
 
152
+ if (resourceConfig.conflictTracking) {
153
+ for (const operation of operations.filter((candidate) => candidate !== "create")) {
154
+ const callbackName = operation === "destroy" ? "beforeDestroy" : "beforeUpdate"
155
+ const callback = (/** @type {ReturnType<typeof JSON.parse>} */ record) => {
156
+ if (this.isTrackingSuppressed(record)) return
157
+
158
+ const capturedVersions = this._capturedBaseVersions.get(record) || []
159
+
160
+ capturedVersions.push(this.preMutationBaseVersionFor({operation, record, resourceConfig}))
161
+ this._capturedBaseVersions.set(record, capturedVersions)
162
+ }
163
+
164
+ resourceConfig.modelClass[callbackName](callback)
165
+ this._trackedCallbacks.push({callback, callbackName, modelClass: resourceConfig.modelClass})
166
+ }
167
+ }
168
+
148
169
  for (const operation of operations) {
149
170
  const callbackName = TRACKED_CALLBACK_NAMES[operation]
150
171
  const callback = this.trackedMutationCallback({operation, resourceConfig})
@@ -220,6 +241,9 @@ export default class SyncClient {
220
241
  resource: record
221
242
  })
222
243
  const syncType = this.defaultSyncType({operation, record, resourceConfig})
244
+ const baseVersion = resourceConfig.conflictTracking
245
+ ? this.capturedBaseVersionFor({operation, record, resourceConfig})
246
+ : null
223
247
  const databaseOperation = record.databaseOperation()
224
248
  const operationScope = databaseOperation
225
249
  ? databaseOperation.forModel(this.config.syncModel)
@@ -227,12 +251,19 @@ export default class SyncClient {
227
251
 
228
252
  await record.connection().afterCommit(async () => {
229
253
  try {
230
- await SyncApiClient.queueLocalSync({
231
- data,
232
- resource: record,
233
- syncModel: operationScope,
234
- syncType
235
- })
254
+ if (resourceConfig.conflictTracking) {
255
+ await SyncApiClient.queueConflictTrackedSync({
256
+ baseVersion,
257
+ conflictTracking: resourceConfig.conflictTracking,
258
+ data,
259
+ operation,
260
+ resource: record,
261
+ resourceType: record.constructor.getModelName(),
262
+ syncType
263
+ })
264
+ } else {
265
+ await SyncApiClient.queueLocalSync({data, resource: record, syncModel: operationScope, syncType})
266
+ }
236
267
  } catch (error) {
237
268
  await this.reportAfterCommitError(/** @type {Error} */ (error))
238
269
 
@@ -483,6 +514,17 @@ export default class SyncClient {
483
514
  throw new Error(`No sync resource with pull attributes configured for ${source}: ${String(resourceType)}`)
484
515
  }
485
516
 
517
+ const data = sync.data()
518
+ const versionAttribute = this.config.resources[resourceType].conflictTracking?.versionAttribute
519
+
520
+ if (versionAttribute) {
521
+ const dataAttributes = data && typeof data === "object" && !Array.isArray(data)
522
+ ? /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (data)
523
+ : {}
524
+
525
+ this.noteRemoteVersion({resourceId: String(sync.resourceId()), resourceType, version: dataAttributes[versionAttribute]})
526
+ }
527
+
486
528
  return await applier(sync)
487
529
  }
488
530
  }
@@ -658,18 +700,42 @@ export default class SyncClient {
658
700
  /**
659
701
  * Queues a local model change as a pending sync row and schedules an immediate
660
702
  * replay attempt (kept pending while offline or when the backend rejects it).
661
- * @param {{resource: ReturnType<typeof JSON.parse>, data?: Record<string, ReturnType<typeof JSON.parse>>, syncType?: string}} args - Queue args.
662
- * @returns {Promise<ReturnType<typeof JSON.parse>>} Pending local sync row.
703
+ * @param {{baseVersion?: string | number | null, resource: ReturnType<typeof JSON.parse>, data?: Record<string, ReturnType<typeof JSON.parse>>, operation?: "create" | "update" | "destroy", syncType?: string}} args - Queue args.
704
+ * @returns {Promise<ReturnType<typeof JSON.parse> | import("./local-mutation-log.js").LocalMutationLogRecord>} Pending local sync row or durable conflict-tracked intent.
663
705
  */
664
- async queue({data, resource, syncType}) {
706
+ async queue({baseVersion, data, operation = "update", resource, syncType}) {
665
707
  const resourceConfig = this.resourceConfigFor(resource)
708
+ const resolvedSyncType = syncType ?? this.defaultSyncType({operation, record: resource, resourceConfig})
709
+
710
+ if (resourceConfig.conflictTracking) {
711
+ const queuedData = SyncApiClient.queuedSyncData({
712
+ booleanAttributes: resourceConfig.booleanAttributes || [],
713
+ data,
714
+ localOnlyAttributes: resourceConfig.localOnlyAttributes || [],
715
+ resource
716
+ })
717
+ const record = await SyncApiClient.queueConflictTrackedSync({
718
+ baseVersion: baseVersion === undefined ? this.baseVersionFor({operation, record: resource, resourceConfig}) : baseVersion,
719
+ conflictTracking: resourceConfig.conflictTracking,
720
+ data: queuedData,
721
+ operation,
722
+ resource,
723
+ resourceType: resource.constructor.getModelName(),
724
+ syncType: resolvedSyncType
725
+ })
726
+
727
+ this.scheduleReplay()
728
+
729
+ return record
730
+ }
731
+
666
732
  const syncRow = await SyncApiClient.queueLocalSync({
667
733
  booleanAttributes: resourceConfig.booleanAttributes || [],
668
734
  data,
669
735
  localOnlyAttributes: resourceConfig.localOnlyAttributes || [],
670
736
  resource,
671
737
  syncModel: this.config.syncModel,
672
- syncType: syncType ?? this.defaultSyncType({operation: "update", record: resource, resourceConfig})
738
+ syncType: resolvedSyncType
673
739
  })
674
740
 
675
741
  this.scheduleReplay()
@@ -686,6 +752,19 @@ export default class SyncClient {
686
752
  if (!(await this.isOnline())) return
687
753
 
688
754
  await SyncApiClient.singleFlight(`velocious-sync-client-replay-${this._clientNumber}`, async () => {
755
+ for (const [resourceType, resourceConfig] of Object.entries(this.config.resources)) {
756
+ if (!resourceConfig.conflictTracking) continue
757
+
758
+ await SyncApiClient.replayConflictTrackedSyncs({
759
+ authenticationToken: await this.config.authenticationToken(),
760
+ batchSize: this.config.batchSize,
761
+ conflictTracking: resourceConfig.conflictTracking,
762
+ postReplay: this.config.postReplay,
763
+ remoteGeneration: (identity) => this._remoteGenerations.get(identity) || 0,
764
+ resourceType
765
+ })
766
+ }
767
+
689
768
  await SyncApiClient.replayLocalSyncs({
690
769
  authenticationToken: await this.config.authenticationToken(),
691
770
  batchSize: this.config.batchSize,
@@ -695,6 +774,82 @@ export default class SyncClient {
695
774
  })
696
775
  }
697
776
 
777
+ /**
778
+ * Records an authoritative remote observation so an in-flight acknowledgement
779
+ * cannot rebase a successor across that observation.
780
+ * @param {{resourceId: string | number, resourceType: string, version?: string | number | null}} args - Remote identity.
781
+ * @returns {void}
782
+ */
783
+ noteRemoteVersion({resourceId, resourceType, version}) {
784
+ void version
785
+ const identity = `${resourceType}:${String(resourceId)}`
786
+
787
+ this._remoteGenerations.set(identity, (this._remoteGenerations.get(identity) || 0) + 1)
788
+ }
789
+
790
+ /**
791
+ * Reads the authoritative base version observed before a local mutation.
792
+ * @param {{operation: "create" | "update" | "destroy", record: ReturnType<typeof JSON.parse>, resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Version args.
793
+ * @returns {string | number | null} Base version.
794
+ */
795
+ baseVersionFor({operation, record, resourceConfig}) {
796
+ if (operation === "create") return null
797
+
798
+ const versionAttribute = resourceConfig.conflictTracking?.versionAttribute
799
+
800
+ if (!versionAttribute) return null
801
+
802
+ const value = record.readAttribute(versionAttribute)
803
+
804
+ if (value instanceof Date) return value.toISOString()
805
+ if (value === null || typeof value === "string" || typeof value === "number") return value
806
+
807
+ throw new Error(`Sync conflict version ${versionAttribute} must be a Date, string, number, or null`)
808
+ }
809
+
810
+ /**
811
+ * Reads the pre-assignment value exposed by record changes during beforeUpdate.
812
+ * Deletes have no version change pair and use the record's current version.
813
+ * @param {{operation: "create" | "update" | "destroy", record: ReturnType<typeof JSON.parse>, resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Version args.
814
+ * @returns {string | number | null} Pre-mutation base version.
815
+ */
816
+ preMutationBaseVersionFor({operation, record, resourceConfig}) {
817
+ const versionAttribute = resourceConfig.conflictTracking?.versionAttribute
818
+ const versionColumn = versionAttribute
819
+ ? record.constructor.getAttributeNameToColumnNameMap()[versionAttribute]
820
+ : undefined
821
+ const versionChange = operation === "update" && versionColumn
822
+ ? record.changes()[versionColumn]
823
+ : undefined
824
+
825
+ if (!versionChange) return this.baseVersionFor({operation, record, resourceConfig})
826
+
827
+ const value = versionChange[0]
828
+
829
+ if (value instanceof Date) return value.toISOString()
830
+ if (value === null || typeof value === "string" || typeof value === "number") return value
831
+
832
+ throw new Error(`Sync conflict version ${versionAttribute} must be a Date, string, number, or null`)
833
+ }
834
+
835
+ /**
836
+ * Consumes the base captured for this lifecycle event before its after-commit
837
+ * closure is deferred, preserving repeated same-record writes in one transaction.
838
+ * @param {{operation: "create" | "update" | "destroy", record: ReturnType<typeof JSON.parse>, resourceConfig: import("./sync-client-types.js").SyncClientResourceConfig}} args - Capture args.
839
+ * @returns {string | number | null} Captured base version.
840
+ */
841
+ capturedBaseVersionFor({operation, record, resourceConfig}) {
842
+ if (operation === "create") return null
843
+
844
+ const capturedVersions = this._capturedBaseVersions.get(record)
845
+ const baseVersion = capturedVersions?.shift()
846
+
847
+ if (capturedVersions?.length === 0) this._capturedBaseVersions.delete(record)
848
+ if (baseVersion !== undefined) return baseVersion
849
+
850
+ return this.baseVersionFor({operation, record, resourceConfig})
851
+ }
852
+
698
853
  /**
699
854
  * Schedules a background replay attempt without blocking the caller.
700
855
  * Failures go to config.onError (or rethrow when none is configured).
@@ -822,7 +977,7 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
822
977
  throw new Error(`${resourceType} static sync must be true or a sync declaration object, got: ${String(declaration)}`)
823
978
  }
824
979
 
825
- const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
980
+ const {afterApply, attributes, booleanAttributes, conflictTracking, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
826
981
  const unknownKeys = Object.keys(restDeclaration)
827
982
 
828
983
  // `publish` is the server-side half of the shared `static sync` declaration
@@ -831,7 +986,7 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
831
986
  void publish
832
987
 
833
988
  if (unknownKeys.length > 0) {
834
- throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData)`)
989
+ throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, conflictTracking, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData)`)
835
990
  }
836
991
  if (syncType !== undefined && typeof syncType !== "function" && syncType !== "upsert") {
837
992
  throw new Error(`${resourceType} static sync syncType must be a function or the string "upsert", got: ${String(syncType)}`)
@@ -839,13 +994,19 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
839
994
 
840
995
  const derived = derivedSyncAttributes({modelClass, resourceType})
841
996
 
997
+ if (conflictTracking) validateConflictTracking({conflictTracking, derived, resourceType})
998
+
842
999
  return {
843
1000
  afterApply,
844
1001
  attributes,
845
1002
  booleanAttributes: mergedAttributeNames(derived.booleanAttributes, booleanAttributes),
1003
+ conflictTracking: conflictTracking ? {...conflictTracking, versionAttribute: conflictTracking.versionAttribute || "updatedAt"} : undefined,
846
1004
  findRecord,
847
1005
  findRecordForDelete,
848
- localOnlyAttributes: mergedAttributeNames(derived.localOnlyAttributes, localOnlyAttributes),
1006
+ localOnlyAttributes: mergedAttributeNames(
1007
+ derived.localOnlyAttributes,
1008
+ [...(localOnlyAttributes || []), ...(conflictTracking ? [conflictTracking.versionAttribute || "updatedAt"] : [])]
1009
+ ),
849
1010
  modelClass,
850
1011
  realtime,
851
1012
  syncType,
@@ -854,6 +1015,29 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
854
1015
  }
855
1016
  }
856
1017
 
1018
+ /**
1019
+ * Validates one resource's durable conflict-tracking declaration.
1020
+ * @param {{conflictTracking: import("./sync-client-types.js").SyncClientConflictTrackingConfig, derived: {booleanAttributes: string[], localOnlyAttributes: string[]}, resourceType: string}} args - Validation args.
1021
+ * @returns {void}
1022
+ */
1023
+ function validateConflictTracking({conflictTracking, derived, resourceType}) {
1024
+ const requiredStrings = {
1025
+ actorDeviceId: conflictTracking.actorDeviceId,
1026
+ actorUserId: conflictTracking.actorUserId,
1027
+ offlineGrantId: conflictTracking.offlineGrantId,
1028
+ policyHash: conflictTracking.policyHash
1029
+ }
1030
+
1031
+ for (const [key, value] of Object.entries(requiredStrings)) {
1032
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${resourceType} conflictTracking.${key} must be a non-empty string`)
1033
+ }
1034
+ if (!conflictTracking.mutationLog || typeof conflictTracking.mutationLog.append !== "function") throw new Error(`${resourceType} conflictTracking.mutationLog must be a LocalMutationLog`)
1035
+ if (typeof conflictTracking.clientMutationId !== "function") throw new Error(`${resourceType} conflictTracking.clientMutationId must be a function`)
1036
+ if (!conflictTracking.versionAttribute && !derived.localOnlyAttributes.includes("updatedAt")) {
1037
+ throw new Error(`${resourceType} conflictTracking requires versionAttribute because the model has no updatedAt column`)
1038
+ }
1039
+ }
1040
+
857
1041
  /**
858
1042
  * Derives boolean and local-only attribute names from a model's column metadata:
859
1043
  * booleans from boolean column types; local-only from the primary key,
@@ -7,6 +7,7 @@ import {resolveSyncConflict} from "./conflict-strategy.js"
7
7
  import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
8
8
  import stableJsonStringify from "./stable-json.js"
9
9
  import sha256Hex from "../utils/sha256-hex.js"
10
+ import {decodeReplayPersistedData, serializeReplayPersistedData} from "./sync-replay-persisted-data.js"
10
11
  import {ValidationError} from "../database/record/index.js"
11
12
  import VelociousError from "../velocious-error.js"
12
13
 
@@ -173,6 +174,7 @@ export default class SyncEnvelopeReplayService {
173
174
 
174
175
  const existingSync = await this.findExistingReplaySync({actor: actorResult.actor, context, mutation})
175
176
  const shouldApply = await this.shouldApplyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
177
+ const duplicate = !shouldApply && this.isDuplicateReplayMutation({existingSync, mutation})
176
178
 
177
179
  /** @type {ReturnType<typeof JSON.parse>} */
178
180
  let applyResult
@@ -210,7 +212,18 @@ export default class SyncEnvelopeReplayService {
210
212
  await this.persistReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
211
213
  await this.afterReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
212
214
 
213
- syncResponses.push({id: mutation.id, syncState: "successful"})
215
+ /** @type {Record<string, ReturnType<typeof JSON.parse>>} */
216
+ const successfulResponse = {id: mutation.id, syncState: duplicate ? "duplicate" : "successful"}
217
+
218
+ const persistedReplayMetadata = duplicate ? this.replayPersistedMetadata(existingSync) : null
219
+
220
+ if (persistedReplayMetadata) {
221
+ successfulResponse.serverVersion = persistedReplayMetadata.acknowledgementVersion
222
+ } else if (this.conflictStrategy && mutation.baseVersion !== undefined && applyResult?.record) {
223
+ successfulResponse.serverVersion = normalizeConflictValue(applyResult.record.readAttribute(this.conflictStrategy.versionAttribute))
224
+ }
225
+
226
+ syncResponses.push(successfulResponse)
214
227
  }
215
228
 
216
229
  return {syncs: syncResponses}
@@ -405,6 +418,56 @@ export default class SyncEnvelopeReplayService {
405
418
  return Number.isNaN(parsedValue.getTime()) ? null : parsedValue
406
419
  }
407
420
 
421
+ /**
422
+ * Checks whether a skipped mutation exactly matches the persisted replay row.
423
+ * Older distinct mutations retain the established successful stale-skip response.
424
+ * @param {{existingSync: ReturnType<typeof JSON.parse>, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Existing row and incoming mutation.
425
+ * @returns {boolean} Whether this is a duplicate replay.
426
+ */
427
+ isDuplicateReplayMutation({existingSync, mutation}) {
428
+ if (!existingSync) return false
429
+
430
+ const metadata = this.replayPersistedMetadata(existingSync)
431
+
432
+ if (metadata) {
433
+ return metadata.clientMutationId === String(mutation.clientMutationId || mutation.id)
434
+ && metadata.payloadFingerprint === sha256Hex(mutation.serializedData)
435
+ }
436
+
437
+ const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
438
+ const existingData = this.replaySyncRecordValue(existingSync, "data")
439
+ const existingSyncType = this.replaySyncRecordValue(existingSync, "syncType")
440
+ const serializedExistingData = typeof existingData === "string" ? existingData : JSON.stringify(existingData)
441
+
442
+ return existingClientUpdatedAt?.getTime() === mutation.clientUpdatedAt.getTime()
443
+ && serializedExistingData === mutation.serializedData
444
+ && existingSyncType === mutation.syncType
445
+ }
446
+
447
+ /**
448
+ * Reads a model-backed sync-row value through its accessor or plain property.
449
+ * @param {ReturnType<typeof JSON.parse>} syncRecord - Existing sync row.
450
+ * @param {string} attributeName - Attribute name.
451
+ * @returns {ReturnType<typeof JSON.parse>} Stored value.
452
+ */
453
+ replaySyncRecordValue(syncRecord, attributeName) {
454
+ const record = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (syncRecord)
455
+ const value = record[attributeName]
456
+
457
+ return typeof value === "function" ? value.call(syncRecord) : value
458
+ }
459
+
460
+ /**
461
+ * Reads durable replay acknowledgement metadata from a model-backed sync row.
462
+ * @param {ReturnType<typeof JSON.parse>} syncRecord - Existing sync row.
463
+ * @returns {{acknowledgementVersion: string | number | null, clientMutationId: string, payloadFingerprint: string} | null} Persisted metadata.
464
+ */
465
+ replayPersistedMetadata(syncRecord) {
466
+ if (!syncRecord) return null
467
+
468
+ return decodeReplayPersistedData(this.replaySyncRecordValue(syncRecord, "data")).metadata
469
+ }
470
+
408
471
  /**
409
472
  * Applies one normalized mutation to domain models.
410
473
  *
@@ -638,19 +701,30 @@ export default class SyncEnvelopeReplayService {
638
701
  * @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} Apply result with the deleted flag.
639
702
  */
640
703
  async applyRoutedReplayDelete({mutation, resource}) {
641
- const record = await resource.findSyncRecord({forDelete: true, mutation})
704
+ const ModelClass = resource.modelClass()
705
+ const runDelete = async () => {
706
+ const record = await resource.findSyncRecord({forDelete: true, mutation})
642
707
 
643
- if (!record) return {created: false, deleted: false, record: null}
708
+ if (!record) return {created: false, deleted: false, record: null}
644
709
 
645
- const releaseServerApply = markServerApply(record)
710
+ const conflictResult = await this.routedReplayConflictResult({attributes: {}, existingRecord: record, mutation, resource})
646
711
 
647
- try {
648
- await record.destroy()
649
- } finally {
650
- releaseServerApply()
712
+ if (conflictResult) return conflictResult
713
+
714
+ const releaseServerApply = markServerApply(record)
715
+
716
+ try {
717
+ await record.destroy()
718
+ } finally {
719
+ releaseServerApply()
720
+ }
721
+
722
+ return {created: false, deleted: true, record}
651
723
  }
652
724
 
653
- return {created: false, deleted: true, record}
725
+ if (!this.conflictStrategy) return await runDelete()
726
+
727
+ return await ModelClass.withAdvisoryLock(syncReplayConflictLockName({resourceId: mutation.resourceId, resourceType: mutation.resourceType}), runDelete, {dedicatedConnection: true})
654
728
  }
655
729
 
656
730
  /**
@@ -973,11 +1047,22 @@ export default class SyncEnvelopeReplayService {
973
1047
 
974
1048
  /**
975
1049
  * Resolves an apply result for stale mutations that should not touch domain models.
976
- * @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.
1050
+ * Exact duplicates resolve the current routed record so the acknowledgement
1051
+ * can include its authoritative version without applying the mutation again.
1052
+ * @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.
977
1053
  * @returns {Promise<ReturnType<typeof JSON.parse>>} Project-specific apply result.
978
1054
  */
979
- async skippedReplayMutation(_args) {
980
- return null
1055
+ async skippedReplayMutation({actor, context, existingSync, mutation}) {
1056
+ if (!this.isDuplicateReplayMutation({existingSync, mutation}) || !this.routingConfigured()) return null
1057
+
1058
+ const registration = this.replayResourceRegistration(mutation.resourceType)
1059
+
1060
+ if (!registration) return null
1061
+
1062
+ const resource = await this.buildReplayResource({actor, context, mutation, registration})
1063
+ const record = await resource.findSyncRecord({forDelete: mutation.syncType === "delete", mutation})
1064
+
1065
+ return {created: false, deleted: false, duplicate: true, record}
981
1066
  }
982
1067
 
983
1068
  /**
@@ -1007,6 +1092,18 @@ export default class SyncEnvelopeReplayService {
1007
1092
  }
1008
1093
  }
1009
1094
 
1095
+ if (this.conflictStrategy && shouldApply && mutation.baseVersion !== undefined && applyResult?.record) {
1096
+ const publicPayload = decodeReplayPersistedData(attributes.data).payload
1097
+ const acknowledgementVersion = normalizeConflictValue(applyResult.record.readAttribute(this.conflictStrategy.versionAttribute))
1098
+
1099
+ attributes.data = serializeReplayPersistedData({
1100
+ acknowledgementVersion,
1101
+ clientMutationId: String(mutation.clientMutationId || mutation.id),
1102
+ payload: publicPayload,
1103
+ payloadFingerprint: sha256Hex(mutation.serializedData)
1104
+ })
1105
+ }
1106
+
1010
1107
  if (existingSync) {
1011
1108
  const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
1012
1109
 
@@ -3,6 +3,7 @@
3
3
  import VelociousError from "../velocious-error.js"
4
4
 
5
5
  import {declaredSyncScopeAttributes} from "./sync-scope-attributes.js"
6
+ import {decodeReplayPersistedData} from "./sync-replay-persisted-data.js"
6
7
 
7
8
  /**
8
9
  * Generic cursor-paginated change feed over an app-owned sync/change model.
@@ -243,9 +244,7 @@ export default class SyncModelChangeFeedService {
243
244
  const data = this.recordValue(record, "data")
244
245
 
245
246
  if (data === "" || data === null || data === undefined) return null
246
- if (typeof data !== "string") return data
247
-
248
- return JSON.parse(data)
247
+ return decodeReplayPersistedData(data).payload
249
248
  }
250
249
 
251
250
  /**
@@ -285,4 +284,3 @@ export default class SyncModelChangeFeedService {
285
284
  return date.toISOString()
286
285
  }
287
286
  }
288
-
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ const METADATA_KEY = "$velociousReplay"
4
+ const PAYLOAD_KEY = "payload"
5
+
6
+ /**
7
+ * Wraps change-feed data with replay acknowledgement metadata in the existing
8
+ * durable sync-row data column.
9
+ * @param {{acknowledgementVersion: string | number | null, clientMutationId: string, payload: ReturnType<typeof JSON.parse>, payloadFingerprint: string}} args - Durable replay metadata and public payload.
10
+ * @returns {string} Serialized durable value.
11
+ */
12
+ export function serializeReplayPersistedData({acknowledgementVersion, clientMutationId, payload, payloadFingerprint}) {
13
+ return JSON.stringify({
14
+ [METADATA_KEY]: {acknowledgementVersion, clientMutationId, payloadFingerprint},
15
+ [PAYLOAD_KEY]: payload
16
+ })
17
+ }
18
+
19
+ /**
20
+ * Decodes framework-owned replay metadata while leaving ordinary sync data unchanged.
21
+ * @param {ReturnType<typeof JSON.parse>} value - Parsed or serialized sync-row data.
22
+ * @returns {{metadata: {acknowledgementVersion: string | number | null, clientMutationId: string, payloadFingerprint: string} | null, payload: ReturnType<typeof JSON.parse>}} Decoded metadata and public payload.
23
+ */
24
+ export function decodeReplayPersistedData(value) {
25
+ const parsed = typeof value === "string" ? JSON.parse(value) : value
26
+
27
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {metadata: null, payload: parsed}
28
+
29
+ const record = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (parsed)
30
+ const metadata = record[METADATA_KEY]
31
+
32
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || !Object.hasOwn(record, PAYLOAD_KEY)) {
33
+ return {metadata: null, payload: parsed}
34
+ }
35
+
36
+ const metadataRecord = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (metadata)
37
+
38
+ if (typeof metadataRecord.clientMutationId !== "string" || typeof metadataRecord.payloadFingerprint !== "string") {
39
+ return {metadata: null, payload: parsed}
40
+ }
41
+
42
+ return {
43
+ metadata: {
44
+ acknowledgementVersion: metadataRecord.acknowledgementVersion,
45
+ clientMutationId: metadataRecord.clientMutationId,
46
+ payloadFingerprint: metadataRecord.payloadFingerprint
47
+ },
48
+ payload: record[PAYLOAD_KEY]
49
+ }
50
+ }