velocious 1.0.672 → 1.0.673

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