velocious 1.0.671 → 1.0.673
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/build/database/drivers/base.js +52 -18
- package/build/database/drivers/mssql/index.js +48 -3
- package/build/database/pool/async-tracked-multi-connection.js +36 -0
- package/build/src/database/drivers/base.d.ts +23 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +51 -21
- package/build/src/database/drivers/mssql/index.d.ts +22 -0
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +45 -4
- package/build/src/database/pool/async-tracked-multi-connection.d.ts +15 -0
- package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
- package/build/src/database/pool/async-tracked-multi-connection.js +32 -1
- package/build/src/sync/local-mutation-log.d.ts +12 -0
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +45 -1
- package/build/src/sync/sync-api-client.d.ts +6 -1
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +7 -2
- package/build/src/sync/sync-client.d.ts +60 -0
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +172 -3
- package/build/src/sync/sync-coordinator-types.d.ts +190 -0
- package/build/src/sync/sync-coordinator-types.d.ts.map +1 -0
- package/build/src/sync/sync-coordinator-types.js +65 -0
- package/build/src/sync/sync-coordinator.d.ts +246 -0
- package/build/src/sync/sync-coordinator.d.ts.map +1 -0
- package/build/src/sync/sync-coordinator.js +683 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts +90 -2
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +101 -9
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +8 -1
- package/build/sync/local-mutation-log.js +53 -0
- package/build/sync/sync-api-client.js +7 -1
- package/build/sync/sync-client.js +190 -2
- package/build/sync/sync-coordinator-types.js +73 -0
- package/build/sync/sync-coordinator.js +738 -0
- package/build/sync/sync-envelope-replay-service.js +107 -8
- package/build/sync/sync-realtime-bridge.js +9 -0
- package/package.json +1 -1
- package/src/database/drivers/base.js +52 -18
- package/src/database/drivers/mssql/index.js +48 -3
- package/src/database/pool/async-tracked-multi-connection.js +36 -0
- package/src/sync/local-mutation-log.js +53 -0
- package/src/sync/sync-api-client.js +7 -1
- package/src/sync/sync-client.js +190 -2
- package/src/sync/sync-coordinator-types.js +73 -0
- package/src/sync/sync-coordinator.js +738 -0
- package/src/sync/sync-envelope-replay-service.js +107 -8
- 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
|
|
179
|
-
const
|
|
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
|
|
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()
|