@quereus/sync 3.3.0 → 4.0.0

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 (92) hide show
  1. package/README.md +17 -7
  2. package/dist/src/clock/hlc.d.ts +43 -3
  3. package/dist/src/clock/hlc.d.ts.map +1 -1
  4. package/dist/src/clock/hlc.js +60 -10
  5. package/dist/src/clock/hlc.js.map +1 -1
  6. package/dist/src/create-sync-module.d.ts +46 -9
  7. package/dist/src/create-sync-module.d.ts.map +1 -1
  8. package/dist/src/create-sync-module.js +11 -8
  9. package/dist/src/create-sync-module.js.map +1 -1
  10. package/dist/src/index.d.ts +10 -8
  11. package/dist/src/index.d.ts.map +1 -1
  12. package/dist/src/index.js +8 -5
  13. package/dist/src/index.js.map +1 -1
  14. package/dist/src/metadata/basis-lifecycle.d.ts +180 -0
  15. package/dist/src/metadata/basis-lifecycle.d.ts.map +1 -0
  16. package/dist/src/metadata/basis-lifecycle.js +211 -0
  17. package/dist/src/metadata/basis-lifecycle.js.map +1 -0
  18. package/dist/src/metadata/change-log.d.ts +1 -1
  19. package/dist/src/metadata/change-log.d.ts.map +1 -1
  20. package/dist/src/metadata/change-log.js +6 -6
  21. package/dist/src/metadata/change-log.js.map +1 -1
  22. package/dist/src/metadata/column-version.d.ts +14 -5
  23. package/dist/src/metadata/column-version.d.ts.map +1 -1
  24. package/dist/src/metadata/column-version.js +22 -12
  25. package/dist/src/metadata/column-version.js.map +1 -1
  26. package/dist/src/metadata/index.d.ts +2 -0
  27. package/dist/src/metadata/index.d.ts.map +1 -1
  28. package/dist/src/metadata/index.js +2 -0
  29. package/dist/src/metadata/index.js.map +1 -1
  30. package/dist/src/metadata/keys.d.ts +52 -2
  31. package/dist/src/metadata/keys.d.ts.map +1 -1
  32. package/dist/src/metadata/keys.js +99 -14
  33. package/dist/src/metadata/keys.js.map +1 -1
  34. package/dist/src/metadata/peer-state.d.ts +1 -1
  35. package/dist/src/metadata/peer-state.js +5 -5
  36. package/dist/src/metadata/quarantine.d.ts +90 -0
  37. package/dist/src/metadata/quarantine.d.ts.map +1 -0
  38. package/dist/src/metadata/quarantine.js +167 -0
  39. package/dist/src/metadata/quarantine.js.map +1 -0
  40. package/dist/src/metadata/schema-migration.d.ts +1 -1
  41. package/dist/src/metadata/schema-migration.js +7 -7
  42. package/dist/src/metadata/schema-version.d.ts +1 -1
  43. package/dist/src/metadata/schema-version.js +7 -7
  44. package/dist/src/metadata/tombstones.d.ts +26 -9
  45. package/dist/src/metadata/tombstones.d.ts.map +1 -1
  46. package/dist/src/metadata/tombstones.js +42 -19
  47. package/dist/src/metadata/tombstones.js.map +1 -1
  48. package/dist/src/sql/basis-lifecycle-tvf.d.ts +34 -0
  49. package/dist/src/sql/basis-lifecycle-tvf.d.ts.map +1 -0
  50. package/dist/src/sql/basis-lifecycle-tvf.js +101 -0
  51. package/dist/src/sql/basis-lifecycle-tvf.js.map +1 -0
  52. package/dist/src/sync/admission.d.ts +81 -0
  53. package/dist/src/sync/admission.d.ts.map +1 -0
  54. package/dist/src/sync/admission.js +94 -0
  55. package/dist/src/sync/admission.js.map +1 -0
  56. package/dist/src/sync/change-applicator.d.ts +65 -2
  57. package/dist/src/sync/change-applicator.d.ts.map +1 -1
  58. package/dist/src/sync/change-applicator.js +493 -68
  59. package/dist/src/sync/change-applicator.js.map +1 -1
  60. package/dist/src/sync/change-grouping.d.ts +43 -0
  61. package/dist/src/sync/change-grouping.d.ts.map +1 -0
  62. package/dist/src/sync/change-grouping.js +103 -0
  63. package/dist/src/sync/change-grouping.js.map +1 -0
  64. package/dist/src/sync/events.d.ts +159 -1
  65. package/dist/src/sync/events.d.ts.map +1 -1
  66. package/dist/src/sync/events.js +50 -0
  67. package/dist/src/sync/events.js.map +1 -1
  68. package/dist/src/sync/manager.d.ts +87 -0
  69. package/dist/src/sync/manager.d.ts.map +1 -1
  70. package/dist/src/sync/protocol.d.ts +174 -11
  71. package/dist/src/sync/protocol.d.ts.map +1 -1
  72. package/dist/src/sync/protocol.js +4 -1
  73. package/dist/src/sync/protocol.js.map +1 -1
  74. package/dist/src/sync/snapshot-stream.d.ts.map +1 -1
  75. package/dist/src/sync/snapshot-stream.js +89 -19
  76. package/dist/src/sync/snapshot-stream.js.map +1 -1
  77. package/dist/src/sync/snapshot.d.ts.map +1 -1
  78. package/dist/src/sync/snapshot.js +63 -44
  79. package/dist/src/sync/snapshot.js.map +1 -1
  80. package/dist/src/sync/store-adapter.d.ts +117 -15
  81. package/dist/src/sync/store-adapter.d.ts.map +1 -1
  82. package/dist/src/sync/store-adapter.js +247 -91
  83. package/dist/src/sync/store-adapter.js.map +1 -1
  84. package/dist/src/sync/sync-context.d.ts +44 -1
  85. package/dist/src/sync/sync-context.d.ts.map +1 -1
  86. package/dist/src/sync/sync-context.js +26 -0
  87. package/dist/src/sync/sync-context.js.map +1 -1
  88. package/dist/src/sync/sync-manager-impl.d.ts +247 -13
  89. package/dist/src/sync/sync-manager-impl.d.ts.map +1 -1
  90. package/dist/src/sync/sync-manager-impl.js +788 -193
  91. package/dist/src/sync/sync-manager-impl.js.map +1 -1
  92. package/package.json +6 -6
@@ -6,16 +6,52 @@
6
6
  * 2. applyToStore callback — write data to store
7
7
  * 3. commitChangeMetadata — persist CRDT metadata
8
8
  */
9
- import { compareHLC } from '../clock/hlc.js';
9
+ import { compareHLC, maxHLC } from '../clock/hlc.js';
10
10
  import { siteIdEquals } from '../clock/site.js';
11
- import { persistHLCState, toError } from './sync-context.js';
11
+ import { encodePK } from '../metadata/keys.js';
12
+ import { admitGroup } from './admission.js';
13
+ /** Stable `schema.table` map key for batch deltas and diversion groups. */
14
+ function tableKey(schema, table) {
15
+ return `${schema}.${table}`;
16
+ }
17
+ /**
18
+ * In-batch table delta from the batch's schema migrations: `create_table` adds a
19
+ * table, `drop_table` removes one. Detection runs at Phase 1 (before any DDL
20
+ * executes in Phase 2), so a referenced table is "known" if it is in the current
21
+ * basis OR created by this batch, AND not dropped by this batch. Computed over the
22
+ * WHOLE batch because admission applies all schema changes before all data.
23
+ */
24
+ function computeBatchTableDelta(changes) {
25
+ const created = new Set();
26
+ const dropped = new Set();
27
+ for (const changeSet of changes) {
28
+ for (const migration of changeSet.schemaMigrations) {
29
+ const key = tableKey(migration.schema, migration.table);
30
+ if (migration.type === 'create_table') {
31
+ created.add(key);
32
+ }
33
+ else if (migration.type === 'drop_table') {
34
+ dropped.add(key);
35
+ }
36
+ }
37
+ }
38
+ return { created, dropped };
39
+ }
12
40
  /**
13
41
  * Apply change sets from a remote peer.
14
42
  *
15
43
  * Three-phase process:
16
- * 1. Resolve all changes (no writes)
44
+ * 1. Resolve all changes (no writes); divert out-of-basis straggler changes
17
45
  * 2. Apply data to store via callback
18
- * 3. Commit CRDT metadata
46
+ * 3. Commit CRDT metadata (+ durable quarantine of diverted changes)
47
+ *
48
+ * Unknown-table disposition: a change referencing a table outside the local basis
49
+ * (a retired-table straggler delta — see `docs/migration.md` § 4 Contract) is
50
+ * **diverted** during resolution — never resolved, applied, or recorded as CRDT
51
+ * metadata, so the change log stays clean (no survivor-HLC pollution). Diverted
52
+ * changes are held (durably, idempotently — `quarantine`, or `store-and-forward`
53
+ * which additionally marks them forwardable) or ignored per
54
+ * {@link SyncConfig.unknownTableDisposition}, and telemetered either way.
19
55
  */
20
56
  export async function applyChanges(ctx, changes) {
21
57
  let applied = 0;
@@ -26,9 +62,14 @@ export async function applyChanges(ctx, changes) {
26
62
  const appliedChanges = [];
27
63
  const resolvedDataChanges = [];
28
64
  const pendingSchemaMigrations = [];
29
- // PHASE 1: Resolve all changes (no writes yet)
65
+ // Out-of-basis straggler changes diverted out of the resolve/apply/metadata
66
+ // path, grouped per table for disposition + telemetry.
67
+ const unknownByTable = new Map();
68
+ const { created: batchCreated, dropped: batchDropped } = computeBatchTableDelta(changes);
69
+ // PHASE 1: Resolve all changes (no writes yet). The clock watermark is merged
70
+ // once after a successful admission (see admitGroup below), not per changeset —
71
+ // resolution reads stored versions via compareHLC, never the live clock.
30
72
  for (const changeSet of changes) {
31
- ctx.hlcManager.receive(changeSet.hlc);
32
73
  // Process schema migrations first (DDL before DML)
33
74
  for (const migration of changeSet.schemaMigrations) {
34
75
  const schemaVersion = migration.schemaVersion ??
@@ -51,6 +92,29 @@ export async function applyChanges(ctx, changes) {
51
92
  }
52
93
  // Resolve data changes
53
94
  for (const change of changeSet.changes) {
95
+ // Self-origin echo skip BEFORE unknown-table detection, so a self-change
96
+ // to a retired table is skipped (counted), never quarantined. resolveChange
97
+ // re-checks this defensively for any other caller.
98
+ if (siteIdEquals(change.hlc.siteId, ctx.getSiteId())) {
99
+ skipped++;
100
+ continue;
101
+ }
102
+ // Structural unknown-table detection: in the current basis OR created by
103
+ // this batch, AND not dropped by this batch. Diverted changes never reach
104
+ // resolveChange / dataChangesToApply / commitChangeMetadata, so no CRDT
105
+ // metadata is written for a table the receiver does not have.
106
+ const key = tableKey(change.schema, change.table);
107
+ const known = (ctx.isTableInBasis(change.schema, change.table) || batchCreated.has(key))
108
+ && !batchDropped.has(key);
109
+ if (!known) {
110
+ let group = unknownByTable.get(key);
111
+ if (!group) {
112
+ group = { schema: change.schema, table: change.table, siteId: changeSet.siteId, changes: [] };
113
+ unknownByTable.set(key, group);
114
+ }
115
+ group.changes.push(change);
116
+ continue;
117
+ }
54
118
  const resolved = await resolveChange(ctx, change);
55
119
  if (resolved.outcome === 'applied') {
56
120
  applied++;
@@ -68,64 +132,289 @@ export async function applyChanges(ctx, changes) {
68
132
  }
69
133
  }
70
134
  }
71
- // PHASE 2: Apply data and schema changes to the store via callback
72
- if (ctx.applyToStore && (dataChangesToApply.length > 0 || schemaChangesToApply.length > 0)) {
73
- try {
74
- await ctx.applyToStore(dataChangesToApply, schemaChangesToApply, { remote: true });
75
- }
76
- catch (error) {
77
- // Emit error state so UI can react. CRDT metadata is NOT committed,
78
- // allowing the same changes to be re-resolved on the next sync attempt.
79
- ctx.syncEvents.emitSyncStateChange({
80
- status: 'error',
81
- error: toError(error),
82
- });
83
- throw error;
84
- }
85
- }
86
- // PHASE 3: Commit CRDT metadata
87
- await commitChangeMetadata(ctx, resolvedDataChanges);
88
- // Commit schema migration metadata
89
- for (const { migration, schemaVersion } of pendingSchemaMigrations) {
90
- await ctx.schemaMigrations.recordMigration(migration.schema, migration.table, {
91
- type: migration.type,
92
- ddl: migration.ddl,
93
- hlc: migration.hlc,
94
- schemaVersion,
95
- });
96
- }
97
- // Emit remote change events
98
- if (appliedChanges.length > 0) {
99
- const changesBySite = new Map();
100
- for (const { change, siteId } of appliedChanges) {
101
- const siteKey = Array.from(siteId).join(',');
102
- const siteChanges = changesBySite.get(siteKey);
103
- if (siteChanges) {
104
- siteChanges.push(change);
135
+ const disposition = ctx.config.unknownTableDisposition;
136
+ // Single receive timestamp for every quarantine entry this apply (GC horizon).
137
+ const receivedAt = Date.now();
138
+ // Admit the whole resolved batch as one all-or-nothing unit: data first (PHASE
139
+ // 2), then CRDT metadata (PHASE 3), then the merged clock watermark — aborting
140
+ // with no metadata committed on any data-apply failure. The batch is admitted
141
+ // once (not per ChangeSet): the single per-peer lastSyncHLC watermark cannot
142
+ // express a partial commit, so selective commit is intentionally not done.
143
+ await admitGroup(ctx, {
144
+ dataChanges: dataChangesToApply,
145
+ schemaChanges: schemaChangesToApply,
146
+ applyOptions: { remote: true },
147
+ commitMetadata: async () => {
148
+ await commitChangeMetadata(ctx, resolvedDataChanges);
149
+ // Commit schema migration metadata
150
+ for (const { migration, schemaVersion } of pendingSchemaMigrations) {
151
+ await ctx.schemaMigrations.recordMigration(migration.schema, migration.table, {
152
+ type: migration.type,
153
+ ddl: migration.ddl,
154
+ hlc: migration.hlc,
155
+ schemaVersion,
156
+ });
105
157
  }
106
- else {
107
- changesBySite.set(siteKey, [change]);
158
+ // Hold diverted changes as part of the admission unit — durable BEFORE
159
+ // the watermark advances below, so a crash never strands a straggler's
160
+ // change with no re-delivery (the batch re-resolves and re-holds
161
+ // idempotently, HLC-keyed). Both `quarantine` and `store-and-forward`
162
+ // hold identically; `store-and-forward` additionally marks each entry
163
+ // forwardable so the relay (sibling ticket) can re-offer it. `ignore`
164
+ // writes nothing.
165
+ if ((disposition === 'quarantine' || disposition === 'store-and-forward') && unknownByTable.size > 0) {
166
+ const forwardable = disposition === 'store-and-forward';
167
+ const qBatch = ctx.kv.batch();
168
+ for (const group of unknownByTable.values()) {
169
+ for (const change of group.changes) {
170
+ ctx.quarantine.put(qBatch, change, receivedAt, forwardable);
171
+ }
172
+ }
173
+ await qBatch.write();
108
174
  }
109
- }
110
- const appliedAt = ctx.hlcManager.now();
111
- for (const [siteKey, siteChanges] of changesBySite) {
112
- const siteIdBytes = new Uint8Array(siteKey.split(',').map(Number));
113
- ctx.syncEvents.emitRemoteChange({
114
- siteId: siteIdBytes,
115
- transactionId: crypto.randomUUID(),
116
- changes: siteChanges,
117
- appliedAt,
118
- });
119
- }
175
+ // Dynamic basis-retirement signal: a remote write to a non-directly-mapped
176
+ // tracked table bumps its `lastDirectlyMappedWriteAt`, deferring eviction
177
+ // (docs/migration.md § 2 Converge). Advisory — never aborts the apply.
178
+ await bumpLastDirectlyMappedWrites(ctx, appliedChanges);
179
+ },
180
+ // Merging the batch max once is equivalent to receiving each changeset's
181
+ // HLC (receive is a monotonic max-merge); on a mid-batch abort the clock
182
+ // does not advance and the batch re-resolves next sync.
183
+ watermarkHLC: maxHLC(changes.map(cs => cs.hlc)),
184
+ });
185
+ // Emit remote change events (grouped by the relaying changeset's siteId).
186
+ emitRemoteChanges(ctx, appliedChanges);
187
+ // Reactive low-latency drain (sync-drain-reappear-inbound-ddl): every APPLIED
188
+ // create_table may have revived a previously-retired table that has held
189
+ // out-of-basis changes waiting on it. Replay them NOW — as a SEPARATE post-commit
190
+ // apply unit, after the admitting batch above has fully committed (fresh data lands
191
+ // first, held changes LWW-resolve against it) — instead of waiting up to one
192
+ // periodic-sweep interval. Only applied migrations are in `pendingSchemaMigrations`
193
+ // (an HLC-dominated create_table `continue`s before being pushed), so a losing
194
+ // create never triggers a drain. A create+drop in the same batch leaves the table
195
+ // absent, so its key is skipped to avoid a wasted scoped `quarantine.list`.
196
+ // Advisory: `drainReappearedTables` logs + swallows any failure, so a drain throw
197
+ // never turns this successful apply into an error.
198
+ const reappeared = new Map();
199
+ for (const { migration } of pendingSchemaMigrations) {
200
+ if (migration.type !== 'create_table')
201
+ continue;
202
+ const key = tableKey(migration.schema, migration.table);
203
+ if (batchDropped.has(key))
204
+ continue;
205
+ if (!reappeared.has(key))
206
+ reappeared.set(key, { schema: migration.schema, table: migration.table });
207
+ }
208
+ await drainReappearedTables(ctx, [...reappeared.values()]);
209
+ // Unknown-table telemetry AFTER successful admission, regardless of disposition
210
+ // (the operator must see straggler traffic even when it is being dropped).
211
+ let unknownTableCount = 0;
212
+ for (const group of unknownByTable.values()) {
213
+ unknownTableCount += group.changes.length;
214
+ ctx.recordUnknownTable(disposition, group.schema, group.table, group.changes.length);
215
+ ctx.syncEvents.emitUnknownTable({
216
+ schema: group.schema,
217
+ table: group.table,
218
+ disposition,
219
+ changeCount: group.changes.length,
220
+ siteId: group.siteId,
221
+ // Group is non-empty by construction, so maxHLC is defined.
222
+ latestHLC: maxHLC(group.changes.map(c => c.hlc)),
223
+ });
120
224
  }
121
- await persistHLCState(ctx);
122
225
  return {
123
226
  applied,
124
227
  skipped,
125
228
  conflicts,
126
229
  transactions: changes.length,
230
+ ...(unknownTableCount > 0 ? { unknownTable: unknownTableCount } : {}),
127
231
  };
128
232
  }
233
+ /**
234
+ * Emit `onRemoteChange` for applied changes, grouped by a per-change origin site
235
+ * id. Shared by the wire apply (grouping by the relaying changeset's `siteId`) and
236
+ * the drain path (grouping by each held change's original origin `hlc.siteId`), so
237
+ * downstream reactivity — MV maintenance, `Database.watch`, UI — fires identically
238
+ * on a revival as on a fresh remote apply. No-op for an empty set.
239
+ */
240
+ function emitRemoteChanges(ctx, entries) {
241
+ if (entries.length === 0)
242
+ return;
243
+ const changesBySite = new Map();
244
+ for (const { change, siteId } of entries) {
245
+ const siteKey = Array.from(siteId).join(',');
246
+ const siteChanges = changesBySite.get(siteKey);
247
+ if (siteChanges) {
248
+ siteChanges.push(change);
249
+ }
250
+ else {
251
+ changesBySite.set(siteKey, [change]);
252
+ }
253
+ }
254
+ const appliedAt = ctx.hlcManager.now();
255
+ for (const [siteKey, siteChanges] of changesBySite) {
256
+ const siteIdBytes = new Uint8Array(siteKey.split(',').map(Number));
257
+ ctx.syncEvents.emitRemoteChange({
258
+ siteId: siteIdBytes,
259
+ transactionId: crypto.randomUUID(),
260
+ changes: siteChanges,
261
+ appliedAt,
262
+ });
263
+ }
264
+ }
265
+ /**
266
+ * Replay held out-of-basis changes (`quarantine` + forwardable `store-and-forward`
267
+ * entries) into tables that have since reappeared in the local basis — the revival
268
+ * half of the unknown-table contract (`docs/migration.md` § 4 Contract). The
269
+ * sibling of {@link applyChanges}' data branch, minus the schema-migration and
270
+ * unknown-table-divert machinery: held changes have no DDL and their table is, by
271
+ * the basis gate, already present.
272
+ *
273
+ * Driven two ways, never interleaved: the host calls {@link SyncManager.drainHeldChanges}
274
+ * from its periodic maintenance sweep, and {@link drainReappearedTables} calls it
275
+ * reactively from {@link applyChanges} the moment an inbound `create_table` revives a
276
+ * held table (gated by {@link SyncConfig.drainOnReappear}). Either way drain runs as a
277
+ * SEPARATE apply, after any re-creating batch has fully committed — fresh data is
278
+ * already in storage and the older held changes simply LWW-resolve against it. The
279
+ * invariant is "never INTERLEAVE drain into the admitting batch", not "never drain
280
+ * inline": a reactive post-commit drain is its own admission unit, distinct from the
281
+ * batch it follows.
282
+ *
283
+ * Scope mirrors {@link QuarantineStore.list}: `(schema, table)` drains one table,
284
+ * `(schema)` drains a schema, `()` sweeps every held entry whose table is back.
285
+ * Bounded by the held set (itself bounded by the retention horizon); zero-cost when
286
+ * nothing is held. With NO basis oracle every group's table reports `undefined`
287
+ * column names ⇒ every group is skipped ⇒ this returns 0 (the relay-only no-op).
288
+ *
289
+ * @returns the number of held entries cleared from the hold (across present tables).
290
+ */
291
+ export async function drainHeldChanges(ctx, schema, table) {
292
+ const held = await ctx.quarantine.list(schema, table);
293
+ if (held.length === 0)
294
+ return 0;
295
+ // Group held changes by (schema, table) — drain admits one table at a time.
296
+ const groups = new Map();
297
+ for (const entry of held) {
298
+ const change = entry.change;
299
+ const key = tableKey(change.schema, change.table);
300
+ let group = groups.get(key);
301
+ if (!group) {
302
+ group = { schema: change.schema, table: change.table, changes: [] };
303
+ groups.set(key, group);
304
+ }
305
+ group.changes.push(change);
306
+ }
307
+ let totalDrained = 0;
308
+ for (const group of groups.values()) {
309
+ totalDrained += await drainTableGroup(ctx, group);
310
+ }
311
+ return totalDrained;
312
+ }
313
+ /**
314
+ * Drain one `(schema, table)` group of held changes. Returns the number of held
315
+ * entries cleared for this table (0 when the table is still absent).
316
+ */
317
+ async function drainTableGroup(ctx, group) {
318
+ // Basis gate: a table not back in the local basis (oracle returns undefined —
319
+ // also the no-oracle case) stays held; skip the whole group, drain nothing.
320
+ const columns = ctx.getTableColumnNames(group.schema, group.table);
321
+ if (columns === undefined)
322
+ return 0;
323
+ const columnSet = new Set(columns);
324
+ const dataChangesToApply = [];
325
+ const resolvedDataChanges = [];
326
+ const appliedEntries = [];
327
+ let applied = 0;
328
+ let skipped = 0;
329
+ for (const change of group.changes) {
330
+ // Schema-drift filter: a held column change for a column the re-created table
331
+ // no longer has is resolved-and-dropped (never sent to resolveChange or the
332
+ // store), so one stale entry cannot abort the table's whole drain admission.
333
+ // Its held entry is still cleared below. Deletes are never drift-filtered — a
334
+ // delete of an absent pk is a store no-op, not a poison.
335
+ if (change.type === 'column' && !columnSet.has(change.column)) {
336
+ skipped++;
337
+ continue;
338
+ }
339
+ // Identical LWW / tombstone-blocking / allowResurrection semantics as a fresh
340
+ // receive — the held change is just an older inbound change resolved late.
341
+ const resolved = await resolveChange(ctx, change);
342
+ if (resolved.outcome === 'applied') {
343
+ applied++;
344
+ resolvedDataChanges.push(resolved);
345
+ if (resolved.dataChange)
346
+ dataChangesToApply.push(resolved.dataChange);
347
+ // Group revival events by the held change's ORIGINAL origin (its HLC
348
+ // siteId) — a held change carries no relaying changeset.
349
+ appliedEntries.push({ change, siteId: change.hlc.siteId });
350
+ }
351
+ else {
352
+ skipped++;
353
+ }
354
+ }
355
+ // One admission unit: data first → CRDT metadata + held-entry deletes second.
356
+ // No watermarkHLC — these HLCs were already merged into the local clock at the
357
+ // original receive (applyChanges merges the batch maxHLC even for diverted
358
+ // changes), so re-merging would be a no-op; omitting it keeps drain a pure replay.
359
+ await admitGroup(ctx, {
360
+ dataChanges: dataChangesToApply,
361
+ schemaChanges: [],
362
+ applyOptions: { remote: true },
363
+ commitMetadata: async () => {
364
+ await commitChangeMetadata(ctx, resolvedDataChanges);
365
+ // Clear every held entry CONSIDERED this drain (applied, LWW-lost, blocked,
366
+ // or drift-dropped) in the SAME unit, so the hold clears atomically with the
367
+ // apply. Once a held change has resolved against the present table, holding
368
+ // it longer changes nothing — a future drain resolves it identically — so a
369
+ // non-applied outcome is cleared too. A data-apply failure aborts before
370
+ // this callback, so on a crash the entries stay held and re-drain next sweep.
371
+ const qBatch = ctx.kv.batch();
372
+ for (const change of group.changes) {
373
+ ctx.quarantine.delete(qBatch, change);
374
+ }
375
+ await qBatch.write();
376
+ },
377
+ });
378
+ // Revival events AFTER the unit commits (so a listener reading back sees the
379
+ // applied data): remote-change per origin, then one drained event for the table.
380
+ emitRemoteChanges(ctx, appliedEntries);
381
+ ctx.syncEvents.emitHeldChangesDrained({
382
+ schema: group.schema,
383
+ table: group.table,
384
+ drained: group.changes.length,
385
+ applied,
386
+ skipped,
387
+ });
388
+ return group.changes.length;
389
+ }
390
+ /**
391
+ * Best-effort scoped drain of tables that just reappeared in the local basis, run as
392
+ * SEPARATE post-commit apply unit(s) after the re-creating batch committed. Advisory:
393
+ * each table is drained independently and any failure is logged + swallowed (the held
394
+ * entries stay held for the periodic sweep). No-op when {@link SyncConfig.drainOnReappear}
395
+ * is disabled or the list is empty. Each {@link drainHeldChanges} call is cheap when
396
+ * nothing is held (a scoped `quarantine.list` returning `[]`) and a no-op when the table
397
+ * is still absent (the oracle gate in {@link drainTableGroup}).
398
+ */
399
+ export async function drainReappearedTables(ctx, tables) {
400
+ if (!ctx.config.drainOnReappear || tables.length === 0)
401
+ return;
402
+ // Per-table try/catch so one table's drain failure never aborts the others — and,
403
+ // crucially, never propagates out of applyChanges to turn a committed apply into a
404
+ // throw. The create_table + data are already durable; a swallowed drain leaves the
405
+ // held entries for the next periodic sweep (drain is idempotent — a re-drain of an
406
+ // already-drained table returns 0).
407
+ for (const { schema, table } of tables) {
408
+ try {
409
+ await drainHeldChanges(ctx, schema, table);
410
+ }
411
+ catch (error) {
412
+ console.warn(`[Sync] drainReappearedTables failed for ${schema}.${table} `
413
+ + `(advisory; held entries stay held for the periodic sweep): `
414
+ + `${error instanceof Error ? error.message : String(error)}`);
415
+ }
416
+ }
417
+ }
129
418
  /**
130
419
  * Resolve CRDT conflicts for a single change WITHOUT writing metadata.
131
420
  *
@@ -144,6 +433,7 @@ export async function resolveChange(ctx, change) {
144
433
  return {
145
434
  outcome: 'applied',
146
435
  change,
436
+ oldTombstone: existingTombstone ?? undefined,
147
437
  dataChange: {
148
438
  type: 'delete',
149
439
  schema: change.schema,
@@ -155,6 +445,11 @@ export async function resolveChange(ctx, change) {
155
445
  else {
156
446
  // Column change: single getColumnVersion read, then decide via resolver or HLC
157
447
  const localVersion = await ctx.columnVersions.getColumnVersion(change.schema, change.table, change.pk, change.column);
448
+ // Surface the incoming change's before-image to the resolver and conflict
449
+ // events (spread only when present — keeps the no-prior fast path identical).
450
+ const remotePrior = change.priorHlc !== undefined
451
+ ? { remotePriorHlc: change.priorHlc, remotePriorValue: change.priorValue }
452
+ : undefined;
158
453
  if (localVersion) {
159
454
  const remoteWins = ctx.config.conflictResolver
160
455
  ? ctx.config.conflictResolver({
@@ -166,6 +461,7 @@ export async function resolveChange(ctx, change) {
166
461
  localHlc: localVersion.hlc,
167
462
  remoteValue: change.value,
168
463
  remoteHlc: change.hlc,
464
+ ...remotePrior,
169
465
  }) === 'remote'
170
466
  : compareHLC(change.hlc, localVersion.hlc) > 0;
171
467
  if (!remoteWins) {
@@ -178,6 +474,7 @@ export async function resolveChange(ctx, change) {
178
474
  remoteValue: change.value,
179
475
  winner: 'local',
180
476
  winningHLC: localVersion.hlc,
477
+ ...remotePrior,
181
478
  });
182
479
  return { outcome: 'conflict', change };
183
480
  }
@@ -197,6 +494,7 @@ export async function resolveChange(ctx, change) {
197
494
  remoteValue: change.value,
198
495
  winner: 'remote',
199
496
  winningHLC: change.hlc,
497
+ ...remotePrior,
200
498
  });
201
499
  }
202
500
  return {
@@ -217,36 +515,163 @@ export async function resolveChange(ctx, change) {
217
515
  * Commit CRDT metadata for resolved changes.
218
516
  *
219
517
  * Phase 3 of the 3-phase apply pattern: called AFTER data is written to store.
518
+ *
519
+ * In-batch repeats of one key are collapsed to the max-HLC winner before any write.
520
+ * Two versions of the same key can land in a single `applyChanges` batch (e.g.
521
+ * concurrent deletes of the same pk relayed together), and Phase 1 resolved BOTH
522
+ * against the same pre-batch prior version — neither saw the other. Writing both would
523
+ * leave two change-log entries for one key, re-attributing the older entry to the later
524
+ * HLC and breaking {@link SyncManagerImpl}'s `collectChangesSince` LOAD-BEARING
525
+ * INVARIANT (survivor's log HLC == its current version's HLC). So only the winner's
526
+ * metadata + change-log entry are written; losers are never written and the single
527
+ * pre-batch prior entry is deleted once. Mirrors the local write-path dedup in
528
+ * `recordDataEvent` / `recordColumnVersions`, keeping the delete and column paths
529
+ * symmetric.
220
530
  */
221
531
  export async function commitChangeMetadata(ctx, resolvedChanges) {
222
532
  if (resolvedChanges.length === 0)
223
533
  return;
224
- const batch = ctx.kv.batch();
534
+ // Collapse in-batch repeats per key, keeping the max-HLC change. Deletes key by
535
+ // (schema, table, pk); columns by (schema, table, pk, column) — distinct change-log
536
+ // entry types that never collide.
537
+ const deleteWinners = new Map();
538
+ const columnWinners = new Map();
225
539
  for (const resolved of resolvedChanges) {
226
540
  if (resolved.outcome !== 'applied')
227
541
  continue;
228
542
  const change = resolved.change;
229
543
  if (change.type === 'delete') {
230
- ctx.tombstones.setTombstoneBatch(batch, change.schema, change.table, change.pk, change.hlc);
231
- ctx.changeLog.recordDeletionBatch(batch, change.hlc, change.schema, change.table, change.pk);
544
+ keepMaxHLC(deleteWinners, deleteKey(change), resolved);
232
545
  }
233
546
  else {
234
- // Delete old change log entry if exists
235
- if (resolved.oldColumnVersion) {
236
- ctx.changeLog.deleteEntryBatch(batch, resolved.oldColumnVersion.hlc, 'column', change.schema, change.table, change.pk, change.column);
237
- }
238
- ctx.columnVersions.setColumnVersionBatch(batch, change.schema, change.table, change.pk, change.column, { hlc: change.hlc, value: change.value });
239
- ctx.changeLog.recordColumnChangeBatch(batch, change.hlc, change.schema, change.table, change.pk, change.column);
547
+ keepMaxHLC(columnWinners, columnKey(change), resolved);
240
548
  }
241
549
  }
550
+ const batch = ctx.kv.batch();
551
+ for (const resolved of deleteWinners.values()) {
552
+ const change = resolved.change;
553
+ if (change.type !== 'delete')
554
+ continue; // homogeneous map; narrows the union
555
+ commitDeleteMetadata(ctx, batch, change, resolved.oldTombstone);
556
+ }
557
+ for (const resolved of columnWinners.values()) {
558
+ const change = resolved.change;
559
+ if (change.type !== 'column')
560
+ continue; // homogeneous map; narrows the union
561
+ commitColumnMetadata(ctx, batch, change, resolved.oldColumnVersion);
562
+ }
242
563
  await batch.write();
243
- // Handle column version deletions for delete operations (requires async iteration)
244
- for (const resolved of resolvedChanges) {
245
- if (resolved.outcome !== 'applied')
564
+ // Column-version cleanup for deletes requires async iteration — once per winning
565
+ // delete (losers were never written, so there is nothing of theirs to clean).
566
+ for (const resolved of deleteWinners.values()) {
567
+ const change = resolved.change;
568
+ if (change.type !== 'delete')
246
569
  continue;
247
- if (resolved.change.type === 'delete') {
248
- await ctx.columnVersions.deleteRowVersions(resolved.change.schema, resolved.change.table, resolved.change.pk);
570
+ await ctx.columnVersions.deleteRowVersions(change.schema, change.table, change.pk);
571
+ }
572
+ }
573
+ // Collapse keys reuse encodePK so in-batch grouping matches the canonical pk encoding of
574
+ // the actual KV keys (buildTombstoneKey / buildColumnVersionKey) — two pks collapse here
575
+ // iff they would collide on disk.
576
+ /** Stable per-pk key for collapsing repeated delete entries within one batch. */
577
+ function deleteKey(change) {
578
+ return `delete:${change.schema}.${change.table}:${encodePK(change.pk)}`;
579
+ }
580
+ /** Stable per-(pk, column) key for collapsing repeated column entries within one batch. */
581
+ function columnKey(change) {
582
+ return `column:${change.schema}.${change.table}:${encodePK(change.pk)}:${change.column}`;
583
+ }
584
+ /** Keep the max-HLC resolved change per key, collapsing in-batch repeats to one winner. */
585
+ function keepMaxHLC(winners, key, resolved) {
586
+ const prev = winners.get(key);
587
+ if (!prev || compareHLC(resolved.change.hlc, prev.change.hlc) > 0) {
588
+ winners.set(key, resolved);
589
+ }
590
+ }
591
+ /** Write a winning delete's tombstone + change-log entry, deleting any prior delete entry. */
592
+ function commitDeleteMetadata(ctx, batch, change, oldTombstone) {
593
+ // Dedupe: a newer tombstone overwrites the prior one, leaving its delete change-log
594
+ // entry stale — remove it so at most one survives per pk (mirrors the column dedup
595
+ // and the write path in recordDataEvent).
596
+ if (oldTombstone) {
597
+ ctx.changeLog.deleteEntryBatch(batch, oldTombstone.hlc, 'delete', change.schema, change.table, change.pk);
598
+ }
599
+ // Persist the incoming change's before-image (when present) onto the tombstone so
600
+ // a receiver relaying via getChangesSince re-emits it. Only the winning delete's
601
+ // metadata is written here (in-batch losers never persist), so the surviving
602
+ // priorRow is the latest delete's row image.
603
+ ctx.tombstones.setTombstoneBatch(batch, change.schema, change.table, change.pk, change.hlc, change.priorRow);
604
+ ctx.changeLog.recordDeletionBatch(batch, change.hlc, change.schema, change.table, change.pk);
605
+ }
606
+ /** Write a winning column's version + change-log entry, deleting any prior column entry. */
607
+ function commitColumnMetadata(ctx, batch, change, oldColumnVersion) {
608
+ // Delete the prior (pk, column) change-log entry if one exists.
609
+ if (oldColumnVersion) {
610
+ ctx.changeLog.deleteEntryBatch(batch, oldColumnVersion.hlc, 'column', change.schema, change.table, change.pk, change.column);
611
+ }
612
+ // Persist the before-image as this replica's local lineage: the version this
613
+ // write replaced here (`oldColumnVersion`). In causal-order delivery that equals
614
+ // the origin's prior, so re-relay forwards the origin's chain (the prior's own
615
+ // origin HLC, never reset to this receiver's clock). A first write here records
616
+ // no prior, matching the local write path in `recordColumnVersions`.
617
+ const prior = oldColumnVersion
618
+ ? { priorHlc: oldColumnVersion.hlc, priorValue: oldColumnVersion.value }
619
+ : undefined;
620
+ ctx.columnVersions.setColumnVersionBatch(batch, change.schema, change.table, change.pk, change.column, { hlc: change.hlc, value: change.value, ...prior });
621
+ ctx.changeLog.recordColumnChangeBatch(batch, change.hlc, change.schema, change.table, change.pk, change.column);
622
+ }
623
+ /**
624
+ * Bump `lastDirectlyMappedWriteAt` for each non-directly-mapped tracked basis
625
+ * table that received an inbound (remote) write this apply — the dynamic
626
+ * basis-retirement signal (docs/migration.md § 2 Converge). Every `appliedChange`
627
+ * is remote (self-origin is skipped before resolution), so any write to a legacy
628
+ * table is presumed to originate at a peer that still maps it directly: a
629
+ * deliberately conservative over-estimate that errs toward RETAINING storage
630
+ * (under-counting would risk a premature drop). A `directly-mapped` table (the
631
+ * local peer still maps it) is ordinary sync traffic, not a retirement signal —
632
+ * skipped, so the common pre-migration case touches nothing.
633
+ *
634
+ * Cheap in the common case: one bounded `getAll` scan, and an immediate return
635
+ * when no lifecycle records exist. Batched — one KV update per touched table, not
636
+ * per change. Advisory: a failure is logged and swallowed so it can never abort
637
+ * the apply (the next inbound write re-bumps the clock).
638
+ */
639
+ async function bumpLastDirectlyMappedWrites(ctx, appliedChanges) {
640
+ if (appliedChanges.length === 0)
641
+ return;
642
+ try {
643
+ const stored = await ctx.basisLifecycle.getAll();
644
+ if (stored.size === 0)
645
+ return; // pre-migration: nothing tracked, zero overhead
646
+ // Per-table max inbound wall-time among writes to a NON-directly-mapped record.
647
+ const maxWall = new Map();
648
+ for (const { change } of appliedChanges) {
649
+ const key = `${change.schema}.${change.table}`.toLowerCase();
650
+ const record = stored.get(key);
651
+ if (!record || record.state === 'directly-mapped')
652
+ continue;
653
+ const cur = maxWall.get(key);
654
+ if (cur === undefined || change.hlc.wallTime > cur)
655
+ maxWall.set(key, change.hlc.wallTime);
249
656
  }
657
+ if (maxWall.size === 0)
658
+ return;
659
+ const batch = ctx.kv.batch();
660
+ let wrote = false;
661
+ for (const [key, wall] of maxWall) {
662
+ const record = stored.get(key);
663
+ const next = Math.max(record.lastDirectlyMappedWriteAt ?? 0, Number(wall));
664
+ if (next !== (record.lastDirectlyMappedWriteAt ?? 0)) {
665
+ ctx.basisLifecycle.put(batch, { ...record, lastDirectlyMappedWriteAt: next });
666
+ wrote = true;
667
+ }
668
+ }
669
+ if (wrote)
670
+ await batch.write();
671
+ }
672
+ catch (error) {
673
+ console.warn(`[Sync] bumpLastDirectlyMappedWrites failed (advisory; eviction clock not updated this apply): `
674
+ + `${error instanceof Error ? error.message : String(error)}`);
250
675
  }
251
676
  }
252
677
  //# sourceMappingURL=change-applicator.js.map