@quereus/sync 3.2.1 → 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
@@ -4,18 +4,57 @@
4
4
  * Coordinates CRDT metadata tracking and sync operations.
5
5
  * Delegates to focused sub-modules for snapshot, streaming, and change application.
6
6
  */
7
- import { HLCManager, compareHLC } from '../clock/hlc.js';
7
+ import { QuereusError, StatusCode, SYNC_EVICT_TAG } from '@quereus/quereus';
8
+ import { HLCManager, compareHLC, createHLC, deterministicTxnId, MAX_OPSEQ } from '../clock/hlc.js';
8
9
  import { generateSiteId, SITE_ID_KEY, serializeSiteIdentity, deserializeSiteIdentity, siteIdEquals, } from '../clock/site.js';
9
10
  import { ColumnVersionStore, deserializeColumnVersion } from '../metadata/column-version.js';
10
11
  import { TombstoneStore, deserializeTombstone } from '../metadata/tombstones.js';
11
12
  import { PeerStateStore } from '../metadata/peer-state.js';
12
13
  import { SchemaMigrationStore, deserializeMigration } from '../metadata/schema-migration.js';
13
14
  import { ChangeLogStore } from '../metadata/change-log.js';
15
+ import { QuarantineStore } from '../metadata/quarantine.js';
16
+ import { BasisLifecycleStore, classifyBasisLifecycle, basisLifecycleRecordChanged, splitRelKey, parseEvictPolicyTag, isEvictable, quietSince, } from '../metadata/basis-lifecycle.js';
14
17
  import { SYNC_KEY_PREFIX, buildAllColumnVersionsScanBounds, buildAllTombstonesScanBounds, buildAllSchemaMigrationsScanBounds, parseColumnVersionKey, parseTombstoneKey, parseSchemaMigrationKey, } from '../metadata/keys.js';
15
- import { persistHLCState, persistHLCStateBatch, toError } from './sync-context.js';
16
- import { applyChanges as applyChangesImpl } from './change-applicator.js';
18
+ import { persistHLCStateBatch, toError } from './sync-context.js';
19
+ import { applyChanges as applyChangesImpl, drainHeldChanges as drainHeldChangesImpl, drainReappearedTables } from './change-applicator.js';
20
+ import { buildTransactionChangeSets } from './change-grouping.js';
17
21
  import { getSnapshot as getSnapshotImpl, applySnapshot as applySnapshotImpl } from './snapshot.js';
18
22
  import { getSnapshotStream as getSnapshotStreamImpl, applySnapshotStream as applySnapshotStreamImpl, getSnapshotCheckpoint as getSnapshotCheckpointImpl, resumeSnapshotStream as resumeSnapshotStreamImpl, } from './snapshot-stream.js';
23
+ /**
24
+ * Guard a transaction's running `opSeq` against the uint32 bound. `opSeq` is
25
+ * serialized as a big-endian uint32 in the HLC comparison key, so a fact count
26
+ * exceeding {@link MAX_OPSEQ} would silently wrap and corrupt ordering — throw
27
+ * instead. Practically unreachable (4 billion facts in one transaction); the
28
+ * write side telemeters the throw via an error sync-state event.
29
+ */
30
+ export function assertOpSeqInRange(opSeq) {
31
+ if (opSeq > MAX_OPSEQ) {
32
+ throw new QuereusError(`Transaction exceeds ${MAX_OPSEQ + 1} facts; opSeq exhausted`, StatusCode.ERROR);
33
+ }
34
+ }
35
+ /**
36
+ * Map an engine schema-change event `(objectType, type)` to the sync
37
+ * {@link SchemaMigrationType} it records, or `undefined` when the combination is
38
+ * not tracked for replication (e.g. column-level or view/trigger objects, or an
39
+ * `alter` on an index). A `'table' alter` is recorded as `alter_column` — the
40
+ * coarse "table definition changed" migration the schema-sync layer replays.
41
+ */
42
+ function mapSchemaMigrationType(objectType, type) {
43
+ if (objectType === 'table') {
44
+ switch (type) {
45
+ case 'create': return 'create_table';
46
+ case 'drop': return 'drop_table';
47
+ case 'alter': return 'alter_column';
48
+ }
49
+ }
50
+ else if (objectType === 'index') {
51
+ switch (type) {
52
+ case 'create': return 'add_index';
53
+ case 'drop': return 'drop_index';
54
+ }
55
+ }
56
+ return undefined;
57
+ }
19
58
  /**
20
59
  * Implementation of SyncManager.
21
60
  *
@@ -31,36 +70,61 @@ export class SyncManagerImpl {
31
70
  peerStates;
32
71
  changeLog;
33
72
  schemaMigrations;
73
+ quarantine;
74
+ basisLifecycle;
34
75
  syncEvents;
35
76
  applyToStore;
36
77
  getTableSchema;
37
- // Pending changes for the current transaction
38
- pendingChanges = [];
39
- currentTransactionId = null;
40
- constructor(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema) {
78
+ /** Reclaim-by-name callback for the eviction sweep; absent ⇒ sweep is a no-op. */
79
+ dropLocalTable;
80
+ // Last basis hash recorded per basis schema (in-memory, advisory). Drives the
81
+ // basis-drift warning in recordLensDeployment — a warning, not durable state.
82
+ lastBasisHash = new Map();
83
+ // Cumulative unknown-table disposition counters (in-memory, observe-only —
84
+ // mirrors the engine's materialized-view collision stats).
85
+ unknownTableIgnored = 0;
86
+ unknownTableQuarantined = 0;
87
+ unknownTableForwarded = 0;
88
+ // Cumulative forwardable changes re-offered through getChangesSince (the
89
+ // store-and-forward relay's outbound activity). Distinct from
90
+ // unknownTableForwarded, which counts apply-time holds: a held entry is held
91
+ // ONCE but relayed possibly MANY times until GC, so this counter grows with
92
+ // relay activity, not with distinct stragglers. Bumped in collectForwardableChanges.
93
+ unknownTableRelayed = 0;
94
+ unknownTableByTable = new Map();
95
+ constructor(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema, dropLocalTable) {
41
96
  this.kv = kv;
42
97
  this.config = config;
43
98
  this.hlcManager = hlcManager;
44
99
  this.syncEvents = syncEvents;
45
100
  this.applyToStore = applyToStore;
46
101
  this.getTableSchema = getTableSchema;
102
+ this.dropLocalTable = dropLocalTable;
47
103
  this.columnVersions = new ColumnVersionStore(kv);
48
- this.tombstones = new TombstoneStore(kv, config.tombstoneTTL);
104
+ this.tombstones = new TombstoneStore(kv, config.retentionHorizonMs);
49
105
  this.peerStates = new PeerStateStore(kv);
50
106
  this.changeLog = new ChangeLogStore(kv);
51
107
  this.schemaMigrations = new SchemaMigrationStore(kv);
108
+ this.quarantine = new QuarantineStore(kv);
109
+ this.basisLifecycle = new BasisLifecycleStore(kv);
52
110
  }
53
111
  /**
54
112
  * Create a new SyncManager, initializing or loading site identity.
55
113
  *
56
114
  * @param kv - KV store for sync metadata
57
- * @param storeEvents - Store event emitter to subscribe to local changes
115
+ * @param transactionSource - Engine transaction-commit source for local change
116
+ * capture (a `Database`, or any `onTransactionCommit` emitter). When provided,
117
+ * sync subscribes to `onTransactionCommit` and records CRDT metadata for each
118
+ * committed local transaction — one HLC per transaction. Pass `undefined` for a
119
+ * relay-only deployment (e.g. a coordinator) that captures no local DML.
58
120
  * @param config - Sync configuration
59
121
  * @param syncEvents - Sync event emitter for UI integration
60
122
  * @param applyToStore - Optional callback for applying remote changes to the store
61
123
  * @param getTableSchema - Optional callback for getting table schema by name
124
+ * @param dropLocalTable - Optional reclaim-by-name callback for the basis-table
125
+ * eviction sweep; when absent (e.g. a relay-only coordinator) the sweep is a no-op.
62
126
  */
63
- static async create(kv, storeEvents, config, syncEvents, applyToStore, getTableSchema) {
127
+ static async create(kv, transactionSource, config, syncEvents, applyToStore, getTableSchema, dropLocalTable) {
64
128
  // Load or create site identity
65
129
  const siteIdKey = new TextEncoder().encode(SITE_ID_KEY);
66
130
  let siteId;
@@ -89,10 +153,15 @@ export class SyncManagerImpl {
89
153
  };
90
154
  }
91
155
  const hlcManager = new HLCManager(siteId, hlcState);
92
- const manager = new SyncManagerImpl(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema);
93
- // Subscribe to store events
94
- storeEvents.onDataChange((event) => manager.handleDataChange(event));
95
- storeEvents.onSchemaChange((event) => manager.handleSchemaChange(event));
156
+ const manager = new SyncManagerImpl(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema, dropLocalTable);
157
+ // Capture local changes at the engine transaction boundary: one grouped
158
+ // `onTransactionCommit` batch ⇒ one transaction ⇒ one HLC. The store's
159
+ // per-table emitter is below the transaction boundary and cannot group a
160
+ // multi-table transaction (docs/sync.md § Transaction-Based Change Grouping),
161
+ // so we subscribe here, not there. Omitted for relay-only deployments.
162
+ transactionSource?.onTransactionCommit((batch) => {
163
+ void manager.handleTransactionCommit(batch);
164
+ });
96
165
  return manager;
97
166
  }
98
167
  // ============================================================================
@@ -104,126 +173,451 @@ export class SyncManagerImpl {
104
173
  getCurrentHLC() {
105
174
  return this.hlcManager.now();
106
175
  }
176
+ /**
177
+ * Basis-membership oracle for unknown-table detection. `getTableSchema(s,t)
178
+ * === undefined` ⇒ the table is outside the local basis. When no oracle was
179
+ * provided (e.g. a relay-only coordinator), detection is inert: every table
180
+ * reports in-basis and the store adapter's defensive throw governs.
181
+ */
182
+ isTableInBasis(schema, table) {
183
+ return this.getTableSchema ? this.getTableSchema(schema, table) !== undefined : true;
184
+ }
185
+ /**
186
+ * Current column names for an in-basis table via the `getTableSchema` oracle, or
187
+ * `undefined` when the table is outside the basis (oracle returns nothing) OR no
188
+ * oracle was wired. Backs the drain path's basis gate + schema-drift filter (see
189
+ * {@link SyncContext.getTableColumnNames}). The no-oracle `undefined` makes drain
190
+ * a clean no-op on a relay-only coordinator, exactly as detection is inert there.
191
+ */
192
+ getTableColumnNames(schema, table) {
193
+ return this.getTableSchema?.(schema, table)?.columns?.map(c => c.name);
194
+ }
195
+ /**
196
+ * Accumulate unknown-table disposition stats (called by the apply path after a
197
+ * diverted group is successfully admitted).
198
+ */
199
+ recordUnknownTable(disposition, schema, table, changeCount) {
200
+ if (disposition === 'quarantine') {
201
+ this.unknownTableQuarantined += changeCount;
202
+ }
203
+ else if (disposition === 'store-and-forward') {
204
+ this.unknownTableForwarded += changeCount;
205
+ }
206
+ else {
207
+ this.unknownTableIgnored += changeCount;
208
+ }
209
+ // byTable is the union across all dispositions (the per-disposition counters
210
+ // partition it) — accumulated unconditionally, never against `forwarded` alone.
211
+ const key = `${schema}.${table}`;
212
+ this.unknownTableByTable.set(key, (this.unknownTableByTable.get(key) ?? 0) + changeCount);
213
+ }
214
+ getUnknownTableStats() {
215
+ return {
216
+ ignored: this.unknownTableIgnored,
217
+ quarantined: this.unknownTableQuarantined,
218
+ forwarded: this.unknownTableForwarded,
219
+ relayed: this.unknownTableRelayed,
220
+ byTable: new Map(this.unknownTableByTable),
221
+ };
222
+ }
107
223
  // ============================================================================
108
- // Event Handlers (local store changes)
224
+ // Basis-table lifecycle (legacy-table retirement bookkeeping)
109
225
  // ============================================================================
110
226
  /**
111
- * Handle a data change event from the store.
112
- * Records CRDT metadata for the change.
227
+ * Record one logical schema's lens deployment over its basis, recomputing the
228
+ * durable per-basis-table lifecycle classification (`docs/migration.md`
229
+ * § 2 Converge). See {@link SyncManager.recordLensDeployment}.
230
+ *
231
+ * The snapshot is scoped to ONE logical schema, so this schema's directly-mapped
232
+ * contribution is stored per-schema (`mappedBy`) and the aggregate state ORs all
233
+ * schemas — a basis table stays `directly-mapped` until the last mapper drops it.
234
+ * The classification is a pure function of `(snapshot, basis schema)`, both
235
+ * reachable from `db`: the directly-mapped set is the union of every table
236
+ * snapshot's `relationBacking` keys (plus deferred surrogate-split members), and
237
+ * the basis membership / derivation sources come from enumerating the basis
238
+ * schema's tables.
113
239
  */
114
- async handleDataChange(event) {
115
- try {
116
- if (event.remote)
117
- return;
118
- const hlc = this.hlcManager.tick();
119
- const { schemaName, tableName, type, oldRow, newRow } = event;
120
- const pk = event.key ?? event.pk;
121
- if (!pk) {
122
- console.warn(`[Sync] Missing primary key for ${schemaName}.${tableName} ${type} event — change not tracked`);
123
- return;
240
+ async recordLensDeployment(db, logicalSchemaName, snapshot) {
241
+ const logical = logicalSchemaName.toLowerCase();
242
+ // This schema's directly-mapped contribution: the union of every table
243
+ // snapshot's basis relations. surrogateMemberKeys mark deferred surrogate-split
244
+ // members that back the table INDIRECTLY — fold them in as referenced so a
245
+ // deferred member is never misclassified as an eviction candidate.
246
+ const directlyMapped = new Set();
247
+ for (const tableSnapshot of snapshot.tables.values()) {
248
+ for (const key of tableSnapshot.relationBacking.keys())
249
+ directlyMapped.add(key);
250
+ if (tableSnapshot.surrogateMemberKeys) {
251
+ for (const key of tableSnapshot.surrogateMemberKeys)
252
+ directlyMapped.add(key);
124
253
  }
125
- const batch = this.kv.batch();
126
- if (type === 'delete') {
127
- this.tombstones.setTombstoneBatch(batch, schemaName, tableName, pk, hlc);
128
- this.changeLog.recordDeletionBatch(batch, hlc, schemaName, tableName, pk);
129
- await this.columnVersions.deleteRowVersions(schemaName, tableName, pk);
130
- const change = {
131
- type: 'delete',
132
- schema: schemaName,
133
- table: tableName,
134
- pk,
135
- hlc,
136
- };
137
- this.pendingChanges.push(change);
138
- }
139
- else {
140
- if (newRow) {
141
- await this.recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, hlc);
254
+ }
255
+ // Enumerate the basis schema (the common case is `main`, but the basis may be
256
+ // an attached schema — resolve by name). basisMembership = all table keys;
257
+ // derivationSources = union of every maintained table's sourceTables.
258
+ const basisSchema = db.schemaManager.getSchema(snapshot.basisSchemaName);
259
+ const basisMembership = new Set();
260
+ const derivationSources = new Set();
261
+ const displayName = new Map();
262
+ // Snapshot the eviction-policy tag + secondary-index names of each in-basis
263
+ // table NOW: both are gone once the table detaches (its schema + tag vanish),
264
+ // and the eviction sweep needs the index list to reclaim index stores by name.
265
+ const evictPolicyByKey = new Map();
266
+ const indexNamesByKey = new Map();
267
+ if (basisSchema) {
268
+ for (const table of basisSchema.getAllTables()) {
269
+ const key = `${table.schemaName}.${table.name}`.toLowerCase();
270
+ basisMembership.add(key);
271
+ displayName.set(key, { schema: table.schemaName, table: table.name });
272
+ for (const src of table.derivation?.sourceTables ?? []) {
273
+ derivationSources.add(src.toLowerCase());
142
274
  }
275
+ const policy = parseEvictPolicyTag(table.tags?.[SYNC_EVICT_TAG] ?? null);
276
+ if (policy !== undefined)
277
+ evictPolicyByKey.set(key, policy);
278
+ const indexNames = (table.indexes ?? []).map(i => i.name);
279
+ if (indexNames.length > 0)
280
+ indexNamesByKey.set(key, indexNames);
281
+ }
282
+ }
283
+ else if (snapshot.basisSchemaName) {
284
+ console.warn(`[Sync] recordLensDeployment: basis schema '${snapshot.basisSchemaName}' not found; basis membership treated as empty`);
285
+ }
286
+ // Basis-drift detection: warn (don't silently reclassify) when the basis hash
287
+ // changed out-of-band vs. the last deploy we recorded for this basis.
288
+ const priorHash = this.lastBasisHash.get(snapshot.basisSchemaName);
289
+ if (priorHash !== undefined && priorHash !== snapshot.basisHash) {
290
+ console.warn(`[Sync] recordLensDeployment: basis '${snapshot.basisSchemaName}' hash drifted out-of-band `
291
+ + `(recorded ${priorHash || '∅'}, current ${snapshot.basisHash || '∅'})`);
292
+ }
293
+ this.lastBasisHash.set(snapshot.basisSchemaName, snapshot.basisHash);
294
+ const stored = await this.basisLifecycle.getAll();
295
+ // The universe of basis-relation keys to (re)classify: the current basis +
296
+ // its derivation sources + this schema's directly-mapped set + every stored
297
+ // record. directlyMapped is folded in (beyond the ticket's literal
298
+ // membership/derivation/stored universe) so every relation the lens maps is
299
+ // tracked even in the defensive case where the basis schema can't be resolved
300
+ // — a no-op normally, since a mapped relation is a real basis table. Stored
301
+ // keys keep an empty/detach deploy revisiting tables it no longer maps.
302
+ const keys = new Set([...basisMembership, ...derivationSources, ...directlyMapped, ...stored.keys()]);
303
+ const now = Date.now();
304
+ const batch = this.kv.batch();
305
+ let wroteAny = false;
306
+ const pendingEvents = [];
307
+ // Tables transitioning detached → present this deploy: a retired table the lens
308
+ // re-maps back into the basis. Its held out-of-basis changes should replay NOW
309
+ // (after the lifecycle records are durable) rather than waiting on the periodic
310
+ // sweep — the lens-redeploy sibling of the inbound-create_table reappearance path.
311
+ const reappeared = [];
312
+ for (const key of keys) {
313
+ const prior = stored.get(key);
314
+ // OR this schema's contribution into the per-schema mapper set: add when it
315
+ // maps the table now, remove when it no longer does (so the LAST mapper
316
+ // dropping it is what flips the aggregate off directly-mapped).
317
+ const mapped = new Set((prior?.mappedBy ?? []).map(s => s.toLowerCase()));
318
+ if (directlyMapped.has(key))
319
+ mapped.add(logical);
320
+ else
321
+ mapped.delete(logical);
322
+ const mappedBy = [...mapped].sort();
323
+ const derivationSource = derivationSources.has(key);
324
+ const inBasis = basisMembership.has(key);
325
+ const state = classifyBasisLifecycle(mappedBy, derivationSource, inBasis);
326
+ // Stamp mapped-since on entry into directly-mapped (clearing any prior
327
+ // unmapped-since); unmapped-since on exit from it (the retirement hint).
328
+ const wasMapped = prior?.state === 'directly-mapped';
329
+ const isMapped = state === 'directly-mapped';
330
+ let mappedSince = prior?.mappedSince;
331
+ let unmappedSince = prior?.unmappedSince;
332
+ if (isMapped && !wasMapped) {
333
+ mappedSince = now;
334
+ unmappedSince = undefined;
335
+ }
336
+ else if (!isMapped && wasMapped) {
337
+ unmappedSince = now;
338
+ }
339
+ // Stamp detached-at on entry into detached (the eviction quiet-clock
340
+ // fallback when the table was never directly mapped); clear it on re-attach.
341
+ const wasDetached = prior?.state === 'detached';
342
+ const isDetached = state === 'detached';
343
+ let detachedAt = prior?.detachedAt;
344
+ if (isDetached && !wasDetached)
345
+ detachedAt = now;
346
+ else if (!isDetached)
347
+ detachedAt = undefined;
348
+ // Eviction override + index list: while in-basis the tag/index list are
349
+ // authoritative (re-captured each deploy, so a removed tag clears the
350
+ // override); once out of basis they are gone, so carry the prior record's.
351
+ const evictPolicy = inBasis ? evictPolicyByKey.get(key) : prior?.evictPolicy;
352
+ const indexNames = inBasis ? indexNamesByKey.get(key) : prior?.indexNames;
353
+ const display = displayName.get(key)
354
+ ?? (prior ? { schema: prior.schema, table: prior.table } : splitRelKey(key));
355
+ const record = {
356
+ schema: display.schema,
357
+ table: display.table,
358
+ state,
359
+ mappedBy,
360
+ derivationSource,
361
+ inBasis,
362
+ ...(mappedSince !== undefined ? { mappedSince } : {}),
363
+ ...(unmappedSince !== undefined ? { unmappedSince } : {}),
364
+ ...(detachedAt !== undefined ? { detachedAt } : {}),
365
+ // The dynamic signal is owned by the change applicator — carry through here.
366
+ ...(prior?.lastDirectlyMappedWriteAt !== undefined ? { lastDirectlyMappedWriteAt: prior.lastDirectlyMappedWriteAt } : {}),
367
+ ...(evictPolicy !== undefined ? { evictPolicy } : {}),
368
+ ...(indexNames && indexNames.length > 0 ? { indexNames } : {}),
369
+ };
370
+ if (!prior || basisLifecycleRecordChanged(prior, record)) {
371
+ this.basisLifecycle.put(batch, record);
372
+ wroteAny = true;
373
+ }
374
+ // Emit only on an ACTUAL state change of an already-tracked table — a
375
+ // brand-new table's first classification and an idempotent re-apply emit
376
+ // nothing (a spurious event would mislead the retirement hint).
377
+ if (prior && prior.state !== state) {
378
+ pendingEvents.push({
379
+ schema: record.schema,
380
+ table: record.table,
381
+ previousState: prior.state,
382
+ newState: state,
383
+ at: now,
384
+ });
385
+ }
386
+ // Detached → present (any in-basis state): a previously-retired table this
387
+ // deploy re-mapped back into the basis — its held out-of-basis changes should
388
+ // replay now. Use the precise `detached → present` transition rather than the
389
+ // broader "inBasis newly true": held entries only ever exist for previously
390
+ // out-of-basis tables, so the precise check skips spurious scoped scans for
391
+ // brand-new tables (excluded anyway by the `prior` guard). A cheapness
392
+ // optimization, not a correctness gate — drainHeldChanges' oracle gate makes
393
+ // any over-trigger a harmless no-op.
394
+ if (wasDetached && !isDetached) {
395
+ reappeared.push({ schema: record.schema, table: record.table });
143
396
  }
144
- // Persist HLC state in batch (DRY: uses shared helper)
145
- persistHLCStateBatch(this, batch);
397
+ }
398
+ if (wroteAny)
146
399
  await batch.write();
147
- const changesToEmit = [...this.pendingChanges];
148
- this.pendingChanges = [];
149
- this.syncEvents.emitLocalChange({
150
- transactionId: this.currentTransactionId || crypto.randomUUID(),
151
- changes: changesToEmit,
152
- pendingSync: true,
153
- });
400
+ // Emit AFTER the records are durable, so a listener that reads back
401
+ // getBasisTableLifecycle() sees the committed transition.
402
+ for (const event of pendingEvents) {
403
+ this.syncEvents.emitBasisTableLifecycle(event);
154
404
  }
155
- catch (error) {
156
- console.error('[Sync] Error handling data change:', error);
157
- this.syncEvents.emitSyncStateChange({
158
- status: 'error',
159
- error: toError(error),
405
+ // Reactive low-latency drain (sync-drain-reappear-lens-redeploy): a table this
406
+ // deploy re-mapped detached present may hold out-of-basis changes waiting on it.
407
+ // Replay them as a SEPARATE post-commit apply unit, after the lifecycle batch above
408
+ // is durable and its events have fired, so the oracle (getTableColumnNames) sees the
409
+ // re-attached table and the held changes LWW-resolve against the present basis —
410
+ // instead of waiting up to one periodic-sweep interval. Symmetric with the
411
+ // inbound-create_table path in applyChanges. Advisory: drainReappearedTables logs +
412
+ // swallows any per-table failure, so a drain throw never aborts the deploy (which is
413
+ // itself advisory bookkeeping — the store-module forwarder wraps it in try/catch).
414
+ //
415
+ // RE-ENTRANCY: in production this runs inside the firing `apply schema` statement,
416
+ // which holds the engine exec mutex (the module `notifyLensDeployment` hook is awaited
417
+ // mid-statement). The drain re-enters the engine via `applyToStore` →
418
+ // `db.ingestExternalRowChanges`, which acquires that same mutex — awaiting it inline
419
+ // would deadlock (the statement can't release the mutex until this returns). So when
420
+ // the engine reports it is mid-statement, defer the drain to fire-and-forget: it queues
421
+ // on the mutex and runs the instant `apply schema` commits and releases it (callers
422
+ // observe the drained rows after the capture settle, exactly like the local-change
423
+ // capture). Outside a live statement (e.g. unit tests over a stub store that never
424
+ // touches the engine mutex), await inline so the drain completes before returning.
425
+ if (reappeared.length > 0) {
426
+ if (db._isExecuting?.()) {
427
+ void drainReappearedTables(this, reappeared);
428
+ }
429
+ else {
430
+ await drainReappearedTables(this, reappeared);
431
+ }
432
+ }
433
+ }
434
+ async getBasisTableLifecycle() {
435
+ return this.basisLifecycle.list();
436
+ }
437
+ /**
438
+ * Reclaim the local storage of every detached basis table that has been quiet
439
+ * past its effective retention horizon (`docs/migration.md` § 4 Contract).
440
+ * Host-driven (like {@link pruneTombstones} / {@link pruneQuarantine}) — the
441
+ * host schedules it; the library adds no timer. Returns the number of tables
442
+ * evicted.
443
+ *
444
+ * No-op when no `dropLocalTable` reclaim callback is wired (e.g. a relay-only
445
+ * coordinator with no store). For each evictable record it re-reads the record
446
+ * and re-checks eligibility immediately before dropping — guarding against a
447
+ * concurrent re-deploy that re-mapped the table between the scan and the drop —
448
+ * then drops the storage, emits `onBasisTableEvicted`, and clears the record. A
449
+ * `dropLocalTable` throw leaves the record in place to retry next sweep (the
450
+ * drop is idempotent).
451
+ */
452
+ async evictExpiredBasisTables(now = Date.now()) {
453
+ if (!this.dropLocalTable)
454
+ return 0;
455
+ const records = await this.basisLifecycle.list();
456
+ let evicted = 0;
457
+ for (const candidate of records) {
458
+ if (!isEvictable(candidate, now, this.config))
459
+ continue;
460
+ // Re-read + re-check immediately before the drop: a concurrent re-deploy
461
+ // may have re-mapped (and so re-attached) the table since the scan.
462
+ const fresh = await this.basisLifecycle.get(candidate.schema, candidate.table);
463
+ if (!fresh || fresh.state !== 'detached' || !isEvictable(fresh, now, this.config))
464
+ continue;
465
+ try {
466
+ await this.dropLocalTable(fresh.schema, fresh.table, fresh.indexNames ?? []);
467
+ }
468
+ catch (error) {
469
+ // Leave the record; retry next sweep. The drop is idempotent, so a
470
+ // partial reclaim is re-attempted rather than stranded.
471
+ console.warn(`[Sync] evictExpiredBasisTables: dropLocalTable failed for '${fresh.schema}.${fresh.table}'; `
472
+ + `record retained for retry: ${error instanceof Error ? error.message : String(error)}`);
473
+ continue;
474
+ }
475
+ // Storage reclaimed — clear the record (a later re-create starts fresh) and
476
+ // emit AFTER the drop succeeded.
477
+ await this.basisLifecycle.delete(fresh.schema, fresh.table);
478
+ this.syncEvents.emitBasisTableEvicted({
479
+ schema: fresh.schema,
480
+ table: fresh.table,
481
+ at: now,
482
+ quietForMs: now - quietSince(fresh),
160
483
  });
484
+ evicted++;
161
485
  }
486
+ return evicted;
162
487
  }
488
+ // ============================================================================
489
+ // Local change capture (engine transaction boundary)
490
+ // ============================================================================
163
491
  /**
164
- * Handle a schema change event from the store.
165
- * Records schema migrations for sync.
492
+ * Record CRDT metadata for one committed local transaction.
493
+ *
494
+ * Driven by the engine's `onTransactionCommit` group — the authoritative
495
+ * "one transaction = one HLC" boundary. The clock is ticked exactly **once**;
496
+ * every fact of the transaction shares that base `(wallTime, counter, siteId)`
497
+ * and differs only in `opSeq` (a contiguous, 0-based sub-order). DDL is recorded
498
+ * before DML so migrations sort below the same transaction's data facts
499
+ * (docs/sync.md § DDL Application Order). All metadata for the transaction —
500
+ * schema migrations, column versions, tombstones, change-log entries, and the
501
+ * HLC clock state — lands in a single KV batch.
166
502
  */
167
- async handleSchemaChange(event) {
503
+ async handleTransactionCommit(batch) {
168
504
  try {
169
- if (event.remote)
505
+ // Capture only LOCAL facts. An all-remote group is a pure sync-apply echo:
506
+ // its metadata was already recorded by the apply path. A mixed group
507
+ // (local + remote in one transaction — unusual) records only its local
508
+ // facts; opSeq is assigned only to recorded facts so they stay contiguous.
509
+ const localSchema = batch.schemaEvents.filter(e => !e.remote);
510
+ const localData = batch.dataEvents.filter(e => !e.remote);
511
+ if (localSchema.length === 0 && localData.length === 0)
170
512
  return;
171
- const hlc = this.hlcManager.tick();
172
- const { type, objectType, schemaName, objectName, ddl } = event;
173
- let migrationType;
174
- if (objectType === 'table') {
175
- switch (type) {
176
- case 'create':
177
- migrationType = 'create_table';
178
- break;
179
- case 'drop':
180
- migrationType = 'drop_table';
181
- break;
182
- case 'alter':
183
- migrationType = 'alter_column';
184
- break;
185
- default: return;
186
- }
187
- }
188
- else if (objectType === 'index') {
189
- switch (type) {
190
- case 'create':
191
- migrationType = 'add_index';
192
- break;
193
- case 'drop':
194
- migrationType = 'drop_index';
195
- break;
196
- default: return;
197
- }
513
+ // ONE tick per committed transaction. tick() returns opSeq 0; the closure
514
+ // below stamps each successive fact with the next opSeq off the same base.
515
+ const base = this.hlcManager.tick();
516
+ const transactionId = deterministicTxnId(base);
517
+ let opSeq = 0;
518
+ const nextHlc = () => {
519
+ // Throw (telemetered via the catch below) rather than wrap the uint32.
520
+ assertOpSeqInRange(opSeq);
521
+ return createHLC(base.wallTime, base.counter, base.siteId, opSeq++);
522
+ };
523
+ const kvBatch = this.kv.batch();
524
+ const changes = [];
525
+ // DDL before DML: migrations take the lowest opSeqs.
526
+ const versionCounters = new Map();
527
+ for (const event of localSchema) {
528
+ await this.recordSchemaMigration(kvBatch, event, nextHlc, versionCounters);
198
529
  }
199
- else {
200
- return;
530
+ for (const event of localData) {
531
+ await this.recordDataEvent(kvBatch, event, nextHlc, changes);
201
532
  }
202
- const currentVersion = await this.schemaMigrations.getCurrentVersion(schemaName, objectName);
203
- const newVersion = currentVersion + 1;
204
- await this.schemaMigrations.recordMigration(schemaName, objectName, {
205
- type: migrationType,
206
- ddl: ddl || '',
207
- hlc,
208
- schemaVersion: newVersion,
209
- });
210
- // Persist HLC state (DRY: uses shared helper)
211
- await persistHLCState(this);
533
+ // Persist HLC clock state (wallTime/counter only — opSeq is never persisted).
534
+ persistHLCStateBatch(this, kvBatch);
535
+ await kvBatch.write();
212
536
  this.syncEvents.emitLocalChange({
213
- transactionId: crypto.randomUUID(),
214
- changes: [],
537
+ transactionId,
538
+ changes,
215
539
  pendingSync: true,
216
540
  });
217
541
  }
218
542
  catch (error) {
219
- console.error('[Sync] Error handling schema change:', error);
543
+ console.error('[Sync] Error handling transaction commit:', error);
220
544
  this.syncEvents.emitSyncStateChange({
221
545
  status: 'error',
222
546
  error: toError(error),
223
547
  });
224
548
  }
225
549
  }
226
- async recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, hlc) {
550
+ /**
551
+ * Record one local schema-change event as a migration. Allocates an `opSeq`
552
+ * (via `nextHlc`) only when the event maps to a tracked migration, so
553
+ * unsupported object types consume no sub-order. `versionCounters` carries the
554
+ * running per-table schema version across this transaction's migrations.
555
+ */
556
+ async recordSchemaMigration(batch, event, nextHlc, versionCounters) {
557
+ const migrationType = mapSchemaMigrationType(event.objectType, event.type);
558
+ if (!migrationType)
559
+ return;
560
+ const { schemaName, objectName, ddl } = event;
561
+ const counterKey = `${schemaName}.${objectName}`;
562
+ let version = versionCounters.get(counterKey);
563
+ if (version === undefined) {
564
+ version = await this.schemaMigrations.getCurrentVersion(schemaName, objectName);
565
+ }
566
+ version += 1;
567
+ versionCounters.set(counterKey, version);
568
+ this.schemaMigrations.recordMigrationBatch(batch, schemaName, objectName, {
569
+ type: migrationType,
570
+ ddl: ddl || '',
571
+ hlc: nextHlc(),
572
+ schemaVersion: version,
573
+ });
574
+ }
575
+ /**
576
+ * Record one local data-change event: a tombstone (delete) or the changed
577
+ * column versions (insert/update). Each recorded fact consumes one `opSeq`.
578
+ */
579
+ async recordDataEvent(batch, event, nextHlc, changes) {
580
+ const { schemaName, tableName, type, oldRow, newRow } = event;
581
+ const pk = event.key;
582
+ if (!pk) {
583
+ console.warn(`[Sync] Missing primary key for ${schemaName}.${tableName} ${type} event — change not tracked`);
584
+ return;
585
+ }
586
+ if (type === 'delete') {
587
+ // Dedupe the change-log delete entry the same way columns are deduped: if a
588
+ // prior tombstone exists for this pk (a delete→reinsert→delete key reuse),
589
+ // its stale delete entry must go, so at most one delete entry survives per pk
590
+ // with HLC equal to the current tombstone. This keeps collectChangesSince's
591
+ // boundary detection (keyed on the log HLC) in lockstep with grouping (keyed
592
+ // on the resolved tombstone HLC) — see its LOAD-BEARING INVARIANT.
593
+ const existing = await this.tombstones.getTombstone(schemaName, tableName, pk);
594
+ if (existing) {
595
+ this.changeLog.deleteEntryBatch(batch, existing.hlc, 'delete', schemaName, tableName, pk);
596
+ }
597
+ const hlc = nextHlc();
598
+ // Carry the row's last-known image (the engine `oldRow`) as a best-effort
599
+ // before-image on both the persisted tombstone and the inline change. Copy
600
+ // it so neither aliases the engine's row buffer; absent when the event
601
+ // carried no `oldRow` (relayed/synthesized deletes).
602
+ const priorRow = oldRow ? [...oldRow] : undefined;
603
+ this.tombstones.setTombstoneBatch(batch, schemaName, tableName, pk, hlc, priorRow);
604
+ this.changeLog.recordDeletionBatch(batch, hlc, schemaName, tableName, pk);
605
+ await this.columnVersions.deleteRowVersions(schemaName, tableName, pk);
606
+ const change = {
607
+ type: 'delete',
608
+ schema: schemaName,
609
+ table: tableName,
610
+ pk,
611
+ hlc,
612
+ ...(priorRow !== undefined ? { priorRow } : {}),
613
+ };
614
+ changes.push(change);
615
+ }
616
+ else if (newRow) {
617
+ await this.recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, nextHlc, changes);
618
+ }
619
+ }
620
+ async recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, nextHlc, changes) {
227
621
  const tableSchema = this.getTableSchema?.(schemaName, tableName);
228
622
  const columnNames = tableSchema?.columns?.map(c => c.name);
229
623
  if (!tableSchema && this.getTableSchema) {
@@ -238,7 +632,14 @@ export class SyncManagerImpl {
238
632
  if (oldVersion) {
239
633
  this.changeLog.deleteEntryBatch(batch, oldVersion.hlc, 'column', schemaName, tableName, pk, column);
240
634
  }
241
- const version = { hlc, value: newValue };
635
+ const hlc = nextHlc();
636
+ // Carry the overwritten cell version as a best-effort before-image on
637
+ // both the persisted version and the inline change. Spread only when a
638
+ // prior exists — first writes carry neither field (absent, not undefined).
639
+ const prior = oldVersion
640
+ ? { priorHlc: oldVersion.hlc, priorValue: oldVersion.value }
641
+ : undefined;
642
+ const version = { hlc, value: newValue, ...prior };
242
643
  this.columnVersions.setColumnVersionBatch(batch, schemaName, tableName, pk, column, version);
243
644
  this.changeLog.recordColumnChangeBatch(batch, hlc, schemaName, tableName, pk, column);
244
645
  const change = {
@@ -249,103 +650,186 @@ export class SyncManagerImpl {
249
650
  column,
250
651
  value: newValue,
251
652
  hlc,
653
+ ...prior,
252
654
  };
253
- this.pendingChanges.push(change);
655
+ changes.push(change);
254
656
  }
255
657
  }
256
658
  }
257
659
  // ============================================================================
258
660
  // Delta Sync API
259
661
  // ============================================================================
662
+ /**
663
+ * Extract changes for a peer as **one {@link ChangeSet} per source
664
+ * transaction** (grouped by HLC identity `(wallTime, counter, siteId)`),
665
+ * bounded at transaction granularity by `config.batchSize`. A transaction is
666
+ * never split across ChangeSets and two transactions are never merged, so a
667
+ * consumer that advances `lastSyncHLC = ChangeSet.hlc` always lands on a real
668
+ * commit boundary (docs/sync.md § Transaction-Based Change Grouping → Read side).
669
+ */
260
670
  async getChangesSince(peerSiteId, sinceHLC) {
671
+ const dataChanges = sinceHLC
672
+ ? await this.collectChangesSince(peerSiteId, sinceHLC, this.config.batchSize)
673
+ : await this.collectAllChanges(peerSiteId);
674
+ // Fold forwardable held changes (the `store-and-forward` relay's outbound
675
+ // half) into the SAME ChangeSet[] return — no new transport surface. Each
676
+ // keeps its ORIGINAL hlc + siteId, so buildTransactionChangeSets re-forms the
677
+ // straggler's transaction by deterministicTxnId: a forwarded change rejoins any
678
+ // applied part of the same straggler txn (e.g. a txn that wrote both a live and
679
+ // a now-retired-here table), correct by construction.
680
+ //
681
+ // BOUND + ORDERING (verify, no extra code): collectChangesSince early-exits its
682
+ // change-log scan at a transaction cut C; collectForwardableChanges is FULLY
683
+ // scanned (all `> sinceHLC`). buildTransactionChangeSets re-bounds the UNION at
684
+ // batchSize at a transaction boundary M ≤ C. Everything ≤ M from BOTH sources is
685
+ // present (change-log up to C ⊇ ≤ M; forwardable fully scanned ⊇ ≤ M), so the
686
+ // returned prefix is contiguous, advancing the consumer's watermark to M loses
687
+ // nothing, and forwardable entries with HLC in (M, …] are re-collected next round
688
+ // (still `> sinceHLC`). Groups are HLC-ordered, so a forwarded change interleaves
689
+ // with change-log changes in global HLC order — never out of order.
690
+ const forwardable = await this.collectForwardableChanges(peerSiteId, sinceHLC);
691
+ const changes = forwardable.length > 0 ? [...dataChanges, ...forwardable] : dataChanges;
692
+ const schemaMigrations = await this.collectSchemaMigrations(peerSiteId, sinceHLC);
693
+ return buildTransactionChangeSets(changes, schemaMigrations, this.config.batchSize, (transactionId, changeCount) => {
694
+ // Oversized transactions are returned whole (never split); telemeter
695
+ // rather than silently chunk so the bound breach is observable.
696
+ console.warn(`[Sync] Oversized transaction ${transactionId}: ${changeCount} changes exceed batchSize ${this.config.batchSize}; returned as one ChangeSet`);
697
+ });
698
+ }
699
+ /**
700
+ * Delta extraction: facts after `sinceHLC`, in change-log (HLC) order, bounded
701
+ * at **scan time** to whole transactions whose cumulative data-change count
702
+ * reaches `batchSize`.
703
+ *
704
+ * The change-log scan is keyed by `(wallTime, counter, siteId, opSeq)`, so a
705
+ * transaction's facts arrive contiguously and transactions arrive in commit
706
+ * order. That lets us stop scanning as soon as enough WHOLE transactions have
707
+ * accumulated, instead of draining the entire iterator into memory and letting
708
+ * {@link buildTransactionChangeSets} truncate afterward (the pre-
709
+ * `sync-getchangessince-bounded-extraction` behavior, where `batchSize` capped
710
+ * the response but not the scan). The bound here mirrors that grouping step
711
+ * exactly — whole transactions accumulate until the cumulative data-change count
712
+ * reaches `batchSize`, never splitting a transaction — so the grouped response
713
+ * is byte-identical; only the scan footprint shrinks.
714
+ *
715
+ * LOAD-BEARING INVARIANT: boundary detection keys off `logEntry.hlc`, but the
716
+ * grouper keys off the *resolved* version's HLC ({@link resolveLogEntry} returns
717
+ * `cv.hlc` / `tombstone.hlc`). These agree only when each non-null-resolving log
718
+ * entry's HLC equals its resolved version's HLC, which holds for BOTH entry types
719
+ * because each is deduped on overwrite — at most one entry survives per key, and
720
+ * its HLC equals the current version's:
721
+ * - COLUMN: an overwrite deletes the prior `(pk, column)` entry (see
722
+ * {@link recordColumnVersions} and `commitChangeMetadata`), so the survivor's
723
+ * HLC is the current `cv.hlc`.
724
+ * - DELETE: a newer tombstone deletes the prior `pk` delete entry (see
725
+ * {@link recordDataEvent} and `commitChangeMetadata`), so a
726
+ * delete→reinsert→delete key reuse no longer leaves a stale entry that
727
+ * re-attributes to a later tombstone HLC; the survivor's HLC is the current
728
+ * `tombstone.hlc`.
729
+ *
730
+ * The overwrite dedup above covers entries written across SEPARATE writes/applies
731
+ * (each later one sees the prior committed version). The apply path additionally
732
+ * collapses in-batch repeats: two versions of one key inside a single
733
+ * `applyChanges` call resolve against the SAME pre-batch prior version, so neither
734
+ * sees the other — `commitChangeMetadata` keeps only the max-HLC winner per key and
735
+ * writes a single entry, preserving the invariant regardless of how many versions
736
+ * of a key were batched.
737
+ *
738
+ * (Schema migrations are still fully scanned by {@link collectSchemaMigrations};
739
+ * the `sm:` range is not HLC-ordered, but migrations are few and the grouping
740
+ * step drops any that sort past the bounded fact watermark.)
741
+ */
742
+ async collectChangesSince(peerSiteId, sinceHLC, batchSize) {
261
743
  const changes = [];
262
- if (sinceHLC) {
263
- for await (const logEntry of this.changeLog.getChangesSince(sinceHLC)) {
264
- if (siteIdEquals(logEntry.hlc.siteId, peerSiteId))
265
- continue;
266
- if (logEntry.entryType === 'column') {
267
- const cv = await this.columnVersions.getColumnVersion(logEntry.schema, logEntry.table, logEntry.pk, logEntry.column);
268
- if (!cv)
269
- continue;
270
- const columnChange = {
271
- type: 'column',
272
- schema: logEntry.schema,
273
- table: logEntry.table,
274
- pk: logEntry.pk,
275
- column: logEntry.column,
276
- value: cv.value,
277
- hlc: cv.hlc,
278
- };
279
- changes.push(columnChange);
280
- }
281
- else {
282
- const tombstone = await this.tombstones.getTombstone(logEntry.schema, logEntry.table, logEntry.pk);
283
- if (!tombstone)
284
- continue;
285
- const deletion = {
286
- type: 'delete',
287
- schema: logEntry.schema,
288
- table: logEntry.table,
289
- pk: logEntry.pk,
290
- hlc: tombstone.hlc,
291
- };
292
- changes.push(deletion);
293
- }
294
- }
295
- }
296
- else {
297
- await this.collectAllChanges(peerSiteId, changes);
298
- }
299
- // Collect schema migrations
300
- const schemaMigrations = [];
301
- const smBounds = buildAllSchemaMigrationsScanBounds();
302
- for await (const entry of this.kv.iterate(smBounds)) {
303
- const parsed = parseSchemaMigrationKey(entry.key);
304
- if (!parsed)
305
- continue;
306
- const migration = deserializeMigration(entry.value);
307
- if (sinceHLC && compareHLC(migration.hlc, sinceHLC) <= 0)
744
+ // Data-change count over fully-scanned (whole) transactions. The in-flight
745
+ // transaction's own count is held separately until its boundary is crossed,
746
+ // so it is only folded in once the transaction is known to be complete.
747
+ let completedChangeCount = 0;
748
+ let currentTxnId = null;
749
+ let currentTxnChangeCount = 0;
750
+ for await (const logEntry of this.changeLog.getChangesSince(sinceHLC)) {
751
+ // A transaction is wholly one site's, so skipping the peer's own facts
752
+ // filters whole transactions cleanly (no half-empty ChangeSet).
753
+ if (siteIdEquals(logEntry.hlc.siteId, peerSiteId))
308
754
  continue;
309
- if (siteIdEquals(migration.hlc.siteId, peerSiteId))
755
+ // deterministicTxnId excludes opSeq, so it is exactly the transaction
756
+ // identity. Facts of one transaction are contiguous in the HLC-ordered
757
+ // scan, so a change of id means the prior transaction is complete and can
758
+ // be folded into the bound.
759
+ const txnId = deterministicTxnId(logEntry.hlc);
760
+ if (currentTxnId !== null && txnId !== currentTxnId) {
761
+ completedChangeCount += currentTxnChangeCount;
762
+ // Enough whole transactions accumulated — stop before touching the next
763
+ // one. buildTransactionChangeSets re-applies the same bound, so feeding
764
+ // it this prefix yields the same ChangeSets the full scan would have.
765
+ if (completedChangeCount >= batchSize)
766
+ break;
767
+ currentTxnChangeCount = 0;
768
+ }
769
+ currentTxnId = txnId;
770
+ const change = await this.resolveLogEntry(logEntry);
771
+ if (!change)
310
772
  continue;
311
- schemaMigrations.push({
312
- type: migration.type,
313
- schema: parsed.schema,
314
- table: parsed.table,
315
- ddl: migration.ddl,
316
- hlc: migration.hlc,
317
- schemaVersion: migration.schemaVersion,
318
- });
319
- }
320
- if (changes.length === 0 && schemaMigrations.length === 0) {
321
- return [];
322
- }
323
- schemaMigrations.sort((a, b) => compareHLC(a.hlc, b.hlc));
324
- const result = [];
325
- for (let i = 0; i < changes.length; i += this.config.batchSize) {
326
- const batch = changes.slice(i, i + this.config.batchSize);
327
- const maxHLC = batch.reduce((max, c) => compareHLC(c.hlc, max) > 0 ? c.hlc : max, batch[0].hlc);
328
- result.push({
329
- siteId: this.getSiteId(),
330
- transactionId: crypto.randomUUID(),
331
- hlc: maxHLC,
332
- changes: batch,
333
- schemaMigrations: i === 0 ? schemaMigrations : [],
334
- });
773
+ changes.push(change);
774
+ currentTxnChangeCount++;
335
775
  }
336
- if (result.length === 0 && schemaMigrations.length > 0) {
337
- const maxHLC = schemaMigrations.reduce((max, m) => compareHLC(m.hlc, max) > 0 ? m.hlc : max, schemaMigrations[0].hlc);
338
- result.push({
339
- siteId: this.getSiteId(),
340
- transactionId: crypto.randomUUID(),
341
- hlc: maxHLC,
342
- changes: [],
343
- schemaMigrations,
344
- });
776
+ return changes;
777
+ }
778
+ /**
779
+ * Resolve a change-log entry to the live {@link Change} it references, or
780
+ * `null` when the underlying column version / tombstone is gone — a stale log
781
+ * entry (e.g. a column overwritten, or a row deleted after the entry was
782
+ * written). The authoritative HLC and value come from the current version, not
783
+ * the log key.
784
+ */
785
+ async resolveLogEntry(logEntry) {
786
+ if (logEntry.entryType === 'column') {
787
+ const cv = await this.columnVersions.getColumnVersion(logEntry.schema, logEntry.table, logEntry.pk, logEntry.column);
788
+ if (!cv)
789
+ return null;
790
+ const columnChange = {
791
+ type: 'column',
792
+ schema: logEntry.schema,
793
+ table: logEntry.table,
794
+ pk: logEntry.pk,
795
+ column: logEntry.column,
796
+ value: cv.value,
797
+ hlc: cv.hlc,
798
+ // Re-emit the stored before-image (spread only when present), so a
799
+ // receiver/relay forwards the origin's prior chain unchanged.
800
+ ...(cv.priorHlc !== undefined ? { priorHlc: cv.priorHlc, priorValue: cv.priorValue } : {}),
801
+ };
802
+ return columnChange;
345
803
  }
346
- return result;
804
+ const tombstone = await this.tombstones.getTombstone(logEntry.schema, logEntry.table, logEntry.pk);
805
+ if (!tombstone)
806
+ return null;
807
+ const deletion = {
808
+ type: 'delete',
809
+ schema: logEntry.schema,
810
+ table: logEntry.table,
811
+ pk: logEntry.pk,
812
+ hlc: tombstone.hlc,
813
+ // Re-emit the stored before-image (spread only when present) so a
814
+ // receiver/relay forwards the origin's last-known row image unchanged.
815
+ ...(tombstone.priorRow !== undefined ? { priorRow: tombstone.priorRow } : {}),
816
+ };
817
+ return deletion;
347
818
  }
348
- async collectAllChanges(peerSiteId, changes) {
819
+ /**
820
+ * Full extraction (initial sync / delta-from-zero): every column version and
821
+ * tombstone not originating from the peer. Ordering is owned by
822
+ * {@link buildTransactionChangeSets}, so no pre-sort is needed here.
823
+ *
824
+ * Unlike {@link collectChangesSince}, this path cannot early-exit at scan time:
825
+ * the `cv:`/`tb:` scans are keyed by table/pk, NOT by HLC, so transactions are
826
+ * interleaved arbitrarily and no transaction boundary is reachable without a
827
+ * full scan + sort. It is left unbounded because it is the from-zero path: a
828
+ * large initial range is expected to be served by a snapshot rather than this
829
+ * delta path (docs/sync.md).
830
+ */
831
+ async collectAllChanges(peerSiteId) {
832
+ const changes = [];
349
833
  const cvBounds = buildAllColumnVersionsScanBounds();
350
834
  for await (const entry of this.kv.iterate(cvBounds)) {
351
835
  const parsed = parseColumnVersionKey(entry.key);
@@ -362,6 +846,7 @@ export class SyncManagerImpl {
362
846
  column: parsed.column,
363
847
  value: cv.value,
364
848
  hlc: cv.hlc,
849
+ ...(cv.priorHlc !== undefined ? { priorHlc: cv.priorHlc, priorValue: cv.priorValue } : {}),
365
850
  };
366
851
  changes.push(columnChange);
367
852
  }
@@ -379,10 +864,107 @@ export class SyncManagerImpl {
379
864
  table: parsed.table,
380
865
  pk: parsed.pk,
381
866
  hlc: tombstone.hlc,
867
+ ...(tombstone.priorRow !== undefined ? { priorRow: tombstone.priorRow } : {}),
382
868
  };
383
869
  changes.push(deletion);
384
870
  }
385
- changes.sort((a, b) => compareHLC(a.hlc, b.hlc));
871
+ return changes;
872
+ }
873
+ /**
874
+ * Collect forwardable held changes to re-offer to a peer — the outbound half of
875
+ * the `store-and-forward` unknown-table disposition. Source is
876
+ * {@link QuarantineStore.listForwardable}: straggler changes this peer held
877
+ * because it has retired the table, marked forwardable. Each is re-offered with
878
+ * its ORIGINAL `hlc` + `siteId` (the straggler's fact, not a new local one) — the
879
+ * identity that makes the relay loop-free and convergent across hops with NO
880
+ * per-table peer-membership oracle (none exists): a peer that already holds the
881
+ * change re-holds it idempotently (HLC-keyed) and the per-peer watermark stops
882
+ * re-send after one exchange.
883
+ *
884
+ * Two filters, mirroring {@link collectChangesSince} / {@link collectAllChanges}:
885
+ * - **Echo exclusion** — drop a change whose origin (`change.hlc.siteId`) is the
886
+ * peer itself, so a straggler's fact is never echoed back to its own author.
887
+ * - **Watermark filter** — when `sinceHLC` is defined (delta path), keep only
888
+ * `compareHLC(change.hlc, sinceHLC) > 0`. REQUIRED: the consumer advances its
889
+ * per-peer `lastSyncHLC` to `max(returned ChangeSet.hlc)`, so a round that
890
+ * returned a forwarded change with HLC ≤ the watermark would REGRESS it and
891
+ * trigger a re-scan/re-deliver flood. Filtering `> sinceHLC` keeps the merged
892
+ * response a safe contiguous prefix — exactly the change-log contract. When
893
+ * `sinceHLC` is undefined (from-zero / {@link collectAllChanges} path) there is
894
+ * no lower bound, so all (origin ≠ peer) are kept.
895
+ *
896
+ * Accepted limitation (documented intent, not a defect): a straggler whose change
897
+ * is causally older than the holder's sync recency with a peer (`HLC ≤ sinceHLC`)
898
+ * is NOT relayed via this delta path — the same scalar-watermark limitation the
899
+ * base delta layer already has. store-and-forward targets the transitional
900
+ * uneven-retirement window, where the straggler's writes are recent enough to
901
+ * exceed holder watermarks; quarantine already prevents write loss outside it.
902
+ *
903
+ * Snapshot carve-out: forwardable entries are DELTA-ONLY. The snapshot collectors
904
+ * scan only `cv:`/`tb:`/`sm:` (never `qt:`), since a snapshot transfers the
905
+ * offering peer's OWN basis and a forwarded change is for a table that peer does
906
+ * not have.
907
+ *
908
+ * Full horizon-bounded scan (no early-exit), paralleling {@link collectAllChanges}:
909
+ * the forwardable set is bounded by the retention horizon and is EMPTY — so this
910
+ * is zero-cost — in the no-straggler case.
911
+ */
912
+ async collectForwardableChanges(peerSiteId, sinceHLC) {
913
+ const held = await this.quarantine.listForwardable();
914
+ const changes = [];
915
+ for (const entry of held) {
916
+ const change = entry.change;
917
+ // Echo exclusion: the change's HLC siteId is the straggler origin.
918
+ if (siteIdEquals(change.hlc.siteId, peerSiteId))
919
+ continue;
920
+ // Watermark filter on the delta path (see contract above).
921
+ if (sinceHLC && compareHLC(change.hlc, sinceHLC) <= 0)
922
+ continue;
923
+ changes.push(change);
924
+ }
925
+ // Relay-activity telemetry: a held entry is counted on EVERY getChangesSince
926
+ // that re-offers it (repeatedly until GC), and the batch bound may defer some to
927
+ // a later round (re-counted then) — so this measures relay ACTIVITY, not distinct
928
+ // deliveries (distinct from the apply-time `forwarded` hold count).
929
+ this.unknownTableRelayed += changes.length;
930
+ return changes;
931
+ }
932
+ /**
933
+ * Collect schema migrations after `sinceHLC` not originating from the peer.
934
+ * Each migration shares its transaction's base HLC, so the grouping step
935
+ * rejoins it with that transaction's data facts (or forms a DDL-only ChangeSet).
936
+ *
937
+ * The `sm:` range is keyed by `{schema}.{table}:{version}`, not by HLC, so this
938
+ * scan cannot early-exit the way {@link collectChangesSince} does — it is drained
939
+ * in full even when the fact side stops early. That is acceptable because
940
+ * migrations are few, and {@link buildTransactionChangeSets} drops any migration
941
+ * that sorts past the bounded fact watermark (its DDL-only group falls beyond the
942
+ * `batchSize` cut), so over-scanning here costs work but never correctness. A
943
+ * peer with a pathological volume of un-synced DDL is the one case this leaves
944
+ * unbounded; see the bounded-extraction ticket's handoff.
945
+ */
946
+ async collectSchemaMigrations(peerSiteId, sinceHLC) {
947
+ const schemaMigrations = [];
948
+ const smBounds = buildAllSchemaMigrationsScanBounds();
949
+ for await (const entry of this.kv.iterate(smBounds)) {
950
+ const parsed = parseSchemaMigrationKey(entry.key);
951
+ if (!parsed)
952
+ continue;
953
+ const migration = deserializeMigration(entry.value);
954
+ if (sinceHLC && compareHLC(migration.hlc, sinceHLC) <= 0)
955
+ continue;
956
+ if (siteIdEquals(migration.hlc.siteId, peerSiteId))
957
+ continue;
958
+ schemaMigrations.push({
959
+ type: migration.type,
960
+ schema: parsed.schema,
961
+ table: parsed.table,
962
+ ddl: migration.ddl,
963
+ hlc: migration.hlc,
964
+ schemaVersion: migration.schemaVersion,
965
+ });
966
+ }
967
+ return schemaMigrations;
386
968
  }
387
969
  // ============================================================================
388
970
  // Delegated: Change Application
@@ -390,15 +972,18 @@ export class SyncManagerImpl {
390
972
  async applyChanges(changes) {
391
973
  return applyChangesImpl(this, changes);
392
974
  }
975
+ async drainHeldChanges(schema, table) {
976
+ return drainHeldChangesImpl(this, schema, table);
977
+ }
393
978
  async canDeltaSync(peerSiteId, sinceHLC) {
394
979
  const peerState = await this.peerStates.getPeerState(peerSiteId);
395
980
  if (!peerState) {
396
981
  return false;
397
982
  }
398
- // Check if tombstone TTL covers the requested time range
983
+ // Check if retention horizon covers the requested time range
399
984
  const now = Date.now();
400
985
  const sinceTime = Number(sinceHLC.wallTime);
401
- if (now - sinceTime > this.config.tombstoneTTL) {
986
+ if (now - sinceTime > this.config.retentionHorizonMs) {
402
987
  return false;
403
988
  }
404
989
  return true;
@@ -429,7 +1014,7 @@ export class SyncManagerImpl {
429
1014
  const tbBounds = buildAllTombstonesScanBounds();
430
1015
  for await (const entry of this.kv.iterate(tbBounds)) {
431
1016
  const tombstone = deserializeTombstone(entry.value);
432
- if (now - tombstone.createdAt > this.config.tombstoneTTL) {
1017
+ if (now - tombstone.createdAt > this.config.retentionHorizonMs) {
433
1018
  batch.delete(entry.key);
434
1019
  count++;
435
1020
  }
@@ -437,6 +1022,16 @@ export class SyncManagerImpl {
437
1022
  await batch.write();
438
1023
  return count;
439
1024
  }
1025
+ /**
1026
+ * GC quarantined out-of-basis straggler changes past the retention horizon —
1027
+ * the quarantine sibling of {@link pruneTombstones}, keyed off the same
1028
+ * `retentionHorizonMs`. A held change older than the horizon was already
1029
+ * outside the delivery guarantee.
1030
+ */
1031
+ async pruneQuarantine() {
1032
+ const cutoff = Date.now() - this.config.retentionHorizonMs;
1033
+ return this.quarantine.pruneOlderThan(cutoff);
1034
+ }
440
1035
  getEventEmitter() {
441
1036
  return this.syncEvents;
442
1037
  }