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.
- package/README.md +1 -1
- package/build/src/sync/conflict-strategy.d.ts.map +1 -1
- package/build/src/sync/conflict-strategy.js +7 -1
- package/build/src/sync/local-mutation-log.d.ts +10 -0
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +21 -1
- package/build/src/sync/sync-api-client-types.d.ts +15 -0
- package/build/src/sync/sync-api-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-api-client-types.js +4 -1
- package/build/src/sync/sync-api-client.d.ts +91 -0
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +184 -1
- package/build/src/sync/sync-client-types.d.ts +57 -0
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +16 -1
- package/build/src/sync/sync-client.d.ts +56 -7
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +180 -15
- package/build/src/sync/sync-envelope-replay-service.d.ts +31 -2
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +98 -16
- package/build/src/sync/sync-model-change-feed-service.d.ts.map +1 -1
- package/build/src/sync/sync-model-change-feed-service.js +3 -4
- package/build/src/sync/sync-replay-persisted-data.d.ts +26 -0
- package/build/src/sync/sync-replay-persisted-data.d.ts.map +1 -0
- package/build/src/sync/sync-replay-persisted-data.js +43 -0
- package/build/sync/conflict-strategy.js +3 -0
- package/build/sync/local-mutation-log.js +24 -0
- package/build/sync/sync-api-client-types.js +3 -0
- package/build/sync/sync-api-client.js +203 -0
- package/build/sync/sync-client-types.js +16 -0
- package/build/sync/sync-client.js +198 -14
- package/build/sync/sync-envelope-replay-service.js +109 -12
- package/build/sync/sync-model-change-feed-service.js +2 -4
- package/build/sync/sync-replay-persisted-data.js +50 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/sync/conflict-strategy.js +3 -0
- package/src/sync/local-mutation-log.js +24 -0
- package/src/sync/sync-api-client-types.js +3 -0
- package/src/sync/sync-api-client.js +203 -0
- package/src/sync/sync-client-types.js +16 -0
- package/src/sync/sync-client.js +198 -14
- package/src/sync/sync-envelope-replay-service.js +109 -12
- package/src/sync/sync-model-change-feed-service.js +2 -4
- package/src/sync/sync-replay-persisted-data.js +50 -0
package/src/sync/sync-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
704
|
+
const ModelClass = resource.modelClass()
|
|
705
|
+
const runDelete = async () => {
|
|
706
|
+
const record = await resource.findSyncRecord({forDelete: true, mutation})
|
|
642
707
|
|
|
643
|
-
|
|
708
|
+
if (!record) return {created: false, deleted: false, record: null}
|
|
644
709
|
|
|
645
|
-
|
|
710
|
+
const conflictResult = await this.routedReplayConflictResult({attributes: {}, existingRecord: record, mutation, resource})
|
|
646
711
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|