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.
- package/README.md +1 -1
- package/build/database/drivers/base.js +52 -18
- package/build/database/drivers/mssql/index.js +48 -3
- 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/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/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()
|
package/package.json
CHANGED
|
@@ -2742,32 +2742,66 @@ export default class VelociousDatabaseDriversBase {
|
|
|
2742
2742
|
*/
|
|
2743
2743
|
async truncateAllTables() {
|
|
2744
2744
|
this._assertNotReadOnly()
|
|
2745
|
-
|
|
2745
|
+
const tables = (await this.getTables()).filter((table) => table.getName() != "schema_migrations")
|
|
2746
2746
|
|
|
2747
2747
|
if (tables.length == 0) return
|
|
2748
2748
|
|
|
2749
|
+
await this._truncateAllTables(tables)
|
|
2750
|
+
await this.flushPendingWrites()
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
/**
|
|
2754
|
+
* Owns the foreign-key boundary around every cleanup retry. Drivers that
|
|
2755
|
+
* require one physical request for the full operation may override it.
|
|
2756
|
+
* @protected
|
|
2757
|
+
* @param {Array<import("./base-table.js").default>} tables - Eligible tables.
|
|
2758
|
+
* @returns {Promise<void>} - Resolves after cleanup succeeds.
|
|
2759
|
+
*/
|
|
2760
|
+
async _truncateAllTables(tables) {
|
|
2749
2761
|
await this.withDisabledForeignKeys(async () => {
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
return
|
|
2754
|
-
} catch (error) {
|
|
2755
|
-
console.error(error)
|
|
2762
|
+
await this._truncateAllTablesWithRetries(tables)
|
|
2763
|
+
})
|
|
2764
|
+
}
|
|
2756
2765
|
|
|
2757
|
-
|
|
2766
|
+
/**
|
|
2767
|
+
* Retries cleanup against refreshed schema snapshots. Drivers may override
|
|
2768
|
+
* `_truncateAllTablesAttempt` to own a complete driver-specific attempt.
|
|
2769
|
+
* @protected
|
|
2770
|
+
* @param {Array<import("./base-table.js").default>} initialTables - Initial eligible tables.
|
|
2771
|
+
* @returns {Promise<void>} - Resolves after one cleanup attempt succeeds.
|
|
2772
|
+
*/
|
|
2773
|
+
async _truncateAllTablesWithRetries(initialTables) {
|
|
2774
|
+
let tables = initialTables
|
|
2758
2775
|
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2776
|
+
for (let tries = 1; tries <= 6; tries++) {
|
|
2777
|
+
try {
|
|
2778
|
+
await this._truncateAllTablesAttempt(tables)
|
|
2779
|
+
return
|
|
2780
|
+
} catch (error) {
|
|
2781
|
+
console.error(error)
|
|
2765
2782
|
|
|
2766
|
-
|
|
2767
|
-
|
|
2783
|
+
if (tries == 6) throw error
|
|
2784
|
+
|
|
2785
|
+
// A truncate failed — the schema cache may still list a table that was
|
|
2786
|
+
// dropped out from under us (e.g. a db:rollback test that left the
|
|
2787
|
+
// shared DB rolled back). Clear it so the next pass re-reads the live
|
|
2788
|
+
// table list and no longer tries to truncate a table that is gone.
|
|
2789
|
+
this.clearSchemaCache()
|
|
2790
|
+
tables = (await this.getTables()).filter((table) => table.getName() != "schema_migrations")
|
|
2791
|
+
|
|
2792
|
+
if (tables.length == 0) return
|
|
2768
2793
|
}
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* Runs one cleanup attempt while the default owner has constraints disabled.
|
|
2799
|
+
* @protected
|
|
2800
|
+
* @param {Array<import("./base-table.js").default>} tables - Current eligible tables.
|
|
2801
|
+
* @returns {Promise<void>} - Resolves when the attempt completes.
|
|
2802
|
+
*/
|
|
2803
|
+
async _truncateAllTablesAttempt(tables) {
|
|
2804
|
+
await this.truncateTables(tables)
|
|
2771
2805
|
}
|
|
2772
2806
|
|
|
2773
2807
|
/**
|
|
@@ -33,6 +33,8 @@ import UUID from "pure-uuid"
|
|
|
33
33
|
* @type {number}
|
|
34
34
|
*/
|
|
35
35
|
const APPLOCK_NOT_HELD_ERROR_NUMBER = 1223
|
|
36
|
+
const DISABLE_FOREIGN_KEYS_SQL = "EXEC sp_MSforeachtable \"ALTER TABLE ? NOCHECK CONSTRAINT all\""
|
|
37
|
+
const ENABLE_FOREIGN_KEYS_SQL = "EXEC sp_MSforeachtable @command1=\"print '?'\", @command2=\"ALTER TABLE ? WITH CHECK CHECK CONSTRAINT all\""
|
|
36
38
|
|
|
37
39
|
export default class VelociousDatabaseDriversMssql extends Base{
|
|
38
40
|
/** @type {import("mssql").Transaction | null} */
|
|
@@ -177,7 +179,7 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
177
179
|
* @returns {Promise<void>} - Resolves when foreign keys are disabled.
|
|
178
180
|
*/
|
|
179
181
|
async disableForeignKeys() {
|
|
180
|
-
await this._execConstraintToggle(
|
|
182
|
+
await this._execConstraintToggle(DISABLE_FOREIGN_KEYS_SQL, "disableForeignKeys")
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
/**
|
|
@@ -185,7 +187,7 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
185
187
|
* @returns {Promise<void>} - Resolves when foreign keys are enabled.
|
|
186
188
|
*/
|
|
187
189
|
async enableForeignKeys() {
|
|
188
|
-
await this._execConstraintToggle(
|
|
190
|
+
await this._execConstraintToggle(ENABLE_FOREIGN_KEYS_SQL, "enableForeignKeys")
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
/**
|
|
@@ -514,6 +516,40 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
514
516
|
})
|
|
515
517
|
}
|
|
516
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Keeps constraint toggles and cleanup inside each physical SQL Server
|
|
521
|
+
* request so a pool cannot split lifecycle ownership across sessions.
|
|
522
|
+
* @protected
|
|
523
|
+
* @param {Array<import("../base-table.js").default>} tables - Eligible tables.
|
|
524
|
+
* @returns {Promise<void>} - Resolves after one cleanup attempt succeeds.
|
|
525
|
+
*/
|
|
526
|
+
async _truncateAllTables(tables) {
|
|
527
|
+
await this._truncateAllTablesWithRetries(tables)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Runs one fail-loud cleanup batch with restoration on both success and
|
|
532
|
+
* failure. The outer retry owner refreshes stale table snapshots.
|
|
533
|
+
* @protected
|
|
534
|
+
* @param {Array<import("../base-table.js").default>} tables - Current eligible tables.
|
|
535
|
+
* @returns {Promise<void>} - Resolves after the batch completes.
|
|
536
|
+
*/
|
|
537
|
+
async _truncateAllTablesAttempt(tables) {
|
|
538
|
+
const statements = [
|
|
539
|
+
"BEGIN TRY",
|
|
540
|
+
` ${DISABLE_FOREIGN_KEYS_SQL};`,
|
|
541
|
+
...this._truncateTableStatements(tables),
|
|
542
|
+
` ${ENABLE_FOREIGN_KEYS_SQL};`,
|
|
543
|
+
"END TRY",
|
|
544
|
+
"BEGIN CATCH",
|
|
545
|
+
` ${ENABLE_FOREIGN_KEYS_SQL};`,
|
|
546
|
+
" THROW;",
|
|
547
|
+
"END CATCH;"
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
await this.query(statements.join("\n"))
|
|
551
|
+
}
|
|
552
|
+
|
|
517
553
|
/**
|
|
518
554
|
* Truncates all eligible tables in one SQL Server request, retaining the
|
|
519
555
|
* recognized foreign-key fallback used by the per-table implementation.
|
|
@@ -521,6 +557,15 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
521
557
|
* @returns {Promise<void>} - Resolves when the batch completes.
|
|
522
558
|
*/
|
|
523
559
|
async truncateTables(tables) {
|
|
560
|
+
await this.query(this._truncateTableStatements(tables).join("\n"))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Builds the per-table truncate/delete-fallback statements.
|
|
565
|
+
* @param {Array<import("../base-table.js").default>} tables - Eligible tables.
|
|
566
|
+
* @returns {string[]} - SQL statements.
|
|
567
|
+
*/
|
|
568
|
+
_truncateTableStatements(tables) {
|
|
524
569
|
const statements = []
|
|
525
570
|
|
|
526
571
|
for (const table of tables) {
|
|
@@ -543,7 +588,7 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
543
588
|
)
|
|
544
589
|
}
|
|
545
590
|
|
|
546
|
-
|
|
591
|
+
return statements
|
|
547
592
|
}
|
|
548
593
|
|
|
549
594
|
async lastInsertID(options = {}) {
|
|
@@ -195,6 +195,42 @@ export default class LocalMutationLog {
|
|
|
195
195
|
})
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Resolves a durable conflict explicitly. Keeping the server acknowledges the
|
|
200
|
+
* preserved local intent without replaying it; retrying local intent rebases
|
|
201
|
+
* it onto the conflict's authoritative serverVersion and returns it to the
|
|
202
|
+
* pending queue. The prior conflict result stays attached as audit metadata.
|
|
203
|
+
* @param {{id: string, resolution: "keep-server" | "retry-local"}} args - Resolution.
|
|
204
|
+
* @returns {Promise<LocalMutationLogRecord>} Resolved record.
|
|
205
|
+
*/
|
|
206
|
+
async resolveConflict({id, resolution}) {
|
|
207
|
+
if (!["keep-server", "retry-local"].includes(resolution)) throw new Error(`Unknown local mutation conflict resolution '${resolution}'`)
|
|
208
|
+
|
|
209
|
+
return await withStorageKeyLock(this.storageKey, async () => {
|
|
210
|
+
const rawRecord = await this.storage.record(this.storageKey, id)
|
|
211
|
+
|
|
212
|
+
if (!rawRecord) throw new Error(`No local mutation log record '${id}'`)
|
|
213
|
+
|
|
214
|
+
const record = normalizeRecord(rawRecord)
|
|
215
|
+
|
|
216
|
+
if (record.status !== "conflict") throw new Error(`Cannot resolve ${record.status} local mutation '${id}' as a conflict`)
|
|
217
|
+
|
|
218
|
+
if (resolution === "keep-server") {
|
|
219
|
+
record.status = "synced"
|
|
220
|
+
} else {
|
|
221
|
+
const serverVersion = conflictServerVersion(record)
|
|
222
|
+
|
|
223
|
+
record.mutation = {...record.mutation, baseVersion: serverVersion}
|
|
224
|
+
record.status = "pending"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
record.updatedAt = this.currentTimestamp()
|
|
228
|
+
await this.storage.updateRecord(this.storageKey, cloneRecord(record))
|
|
229
|
+
|
|
230
|
+
return restoreSyncResultTypes(cloneRecord(record))
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
198
234
|
/**
|
|
199
235
|
* Prunes terminal records that are no longer needed for replay dependencies.
|
|
200
236
|
* @param {object} [args] - Compaction options.
|
|
@@ -267,6 +303,23 @@ function validStorage(storage) {
|
|
|
267
303
|
&& typeof storageObject.updateRecord === "function"
|
|
268
304
|
}
|
|
269
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Reads the authoritative version from a conflicted mutation.
|
|
308
|
+
* @param {LocalMutationLogRecord} record - Conflicted record.
|
|
309
|
+
* @returns {string | number | null} - Authoritative version.
|
|
310
|
+
*/
|
|
311
|
+
function conflictServerVersion(record) {
|
|
312
|
+
const conflict = record.syncResult?.conflict
|
|
313
|
+
|
|
314
|
+
if (!conflict || conflict instanceof Date || typeof conflict !== "object" || Array.isArray(conflict) || !Object.hasOwn(conflict, "serverVersion")) {
|
|
315
|
+
throw new Error(`Cannot retry local mutation '${record.id}' without a conflict serverVersion`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const conflictMetadata = /** @type {Record<string, unknown>} */ (conflict)
|
|
319
|
+
|
|
320
|
+
return cloneBaseVersion(conflictMetadata.serverVersion)
|
|
321
|
+
}
|
|
322
|
+
|
|
270
323
|
/**
|
|
271
324
|
* Sorts records by newest update/sequence first.
|
|
272
325
|
* @param {LocalMutationLogRecord} left - Left record.
|
|
@@ -72,12 +72,13 @@ export default class SyncApiClient {
|
|
|
72
72
|
* @param {number} [args.batchSize] - Batch size.
|
|
73
73
|
* @param {import("./sync-client-types.js").SyncClientConflictTrackingConfig} args.conflictTracking - Tracking configuration.
|
|
74
74
|
* @param {(payload: {authenticationToken: string, syncs: Array<Record<string, ReturnType<typeof JSON.parse>>>}, options?: {signal?: AbortSignal}) => Promise<SyncReplayResponse>} args.postReplay - Transport boundary.
|
|
75
|
+
* @param {(args: {record: import("./local-mutation-log.js").LocalMutationLogRecord, result: SyncReplayItem}) => Promise<void>} [args.applyConflict] - Applies authoritative conflict state to the local replica before preserving the rejected intent.
|
|
75
76
|
* @param {(identity: string) => number} args.remoteGeneration - Current remote generation.
|
|
76
77
|
* @param {string} args.resourceType - Resource whose log records should drain.
|
|
77
78
|
* @param {AbortSignal} [args.signal] - Lifecycle cancellation signal.
|
|
78
79
|
* @returns {Promise<void>} Resolves when no ready intent remains.
|
|
79
80
|
*/
|
|
80
|
-
static async replayConflictTrackedSyncs({authenticationToken, batchSize, conflictTracking, postReplay, remoteGeneration, resourceType, signal}) {
|
|
81
|
+
static async replayConflictTrackedSyncs({applyConflict, authenticationToken, batchSize, conflictTracking, postReplay, remoteGeneration, resourceType, signal}) {
|
|
81
82
|
const maxBatchSize = this.normalizedBatchSize(batchSize)
|
|
82
83
|
|
|
83
84
|
while (true) {
|
|
@@ -111,6 +112,11 @@ export default class SyncApiClient {
|
|
|
111
112
|
throw new Error(`Invalid sync state returned for mutation ${group[0].mutation.clientMutationId}: ${result.syncState}`)
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
if (result.syncState === "conflict" && applyConflict) {
|
|
116
|
+
await applyConflict({record: group[0], result})
|
|
117
|
+
throwIfSyncAborted(signal)
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
for (const record of group) {
|
|
115
121
|
await applySyncReplayResultToLocalMutationLog({mutationLog: conflictTracking.mutationLog, record, result: /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (result)})
|
|
116
122
|
throwIfSyncAborted(signal)
|