@quereus/sync 0.3.4

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 (78) hide show
  1. package/README.md +155 -0
  2. package/dist/src/clock/hlc.d.ts +105 -0
  3. package/dist/src/clock/hlc.d.ts.map +1 -0
  4. package/dist/src/clock/hlc.js +251 -0
  5. package/dist/src/clock/hlc.js.map +1 -0
  6. package/dist/src/clock/index.d.ts +6 -0
  7. package/dist/src/clock/index.d.ts.map +1 -0
  8. package/dist/src/clock/index.js +6 -0
  9. package/dist/src/clock/index.js.map +1 -0
  10. package/dist/src/clock/site.d.ts +58 -0
  11. package/dist/src/clock/site.d.ts.map +1 -0
  12. package/dist/src/clock/site.js +137 -0
  13. package/dist/src/clock/site.js.map +1 -0
  14. package/dist/src/create-sync-module.d.ts +85 -0
  15. package/dist/src/create-sync-module.d.ts.map +1 -0
  16. package/dist/src/create-sync-module.js +54 -0
  17. package/dist/src/create-sync-module.js.map +1 -0
  18. package/dist/src/index.d.ts +31 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +42 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/metadata/change-log.d.ts +67 -0
  23. package/dist/src/metadata/change-log.d.ts.map +1 -0
  24. package/dist/src/metadata/change-log.js +107 -0
  25. package/dist/src/metadata/change-log.js.map +1 -0
  26. package/dist/src/metadata/column-version.d.ts +58 -0
  27. package/dist/src/metadata/column-version.d.ts.map +1 -0
  28. package/dist/src/metadata/column-version.js +100 -0
  29. package/dist/src/metadata/column-version.js.map +1 -0
  30. package/dist/src/metadata/index.d.ts +11 -0
  31. package/dist/src/metadata/index.d.ts.map +1 -0
  32. package/dist/src/metadata/index.js +11 -0
  33. package/dist/src/metadata/index.js.map +1 -0
  34. package/dist/src/metadata/keys.d.ts +180 -0
  35. package/dist/src/metadata/keys.d.ts.map +1 -0
  36. package/dist/src/metadata/keys.js +390 -0
  37. package/dist/src/metadata/keys.js.map +1 -0
  38. package/dist/src/metadata/peer-state.d.ts +52 -0
  39. package/dist/src/metadata/peer-state.d.ts.map +1 -0
  40. package/dist/src/metadata/peer-state.js +87 -0
  41. package/dist/src/metadata/peer-state.js.map +1 -0
  42. package/dist/src/metadata/schema-migration.d.ts +60 -0
  43. package/dist/src/metadata/schema-migration.d.ts.map +1 -0
  44. package/dist/src/metadata/schema-migration.js +126 -0
  45. package/dist/src/metadata/schema-migration.js.map +1 -0
  46. package/dist/src/metadata/schema-version.d.ts +163 -0
  47. package/dist/src/metadata/schema-version.d.ts.map +1 -0
  48. package/dist/src/metadata/schema-version.js +307 -0
  49. package/dist/src/metadata/schema-version.js.map +1 -0
  50. package/dist/src/metadata/tombstones.d.ts +67 -0
  51. package/dist/src/metadata/tombstones.d.ts.map +1 -0
  52. package/dist/src/metadata/tombstones.js +125 -0
  53. package/dist/src/metadata/tombstones.js.map +1 -0
  54. package/dist/src/sync/events.d.ts +117 -0
  55. package/dist/src/sync/events.d.ts.map +1 -0
  56. package/dist/src/sync/events.js +56 -0
  57. package/dist/src/sync/events.js.map +1 -0
  58. package/dist/src/sync/index.d.ts +8 -0
  59. package/dist/src/sync/index.d.ts.map +1 -0
  60. package/dist/src/sync/index.js +8 -0
  61. package/dist/src/sync/index.js.map +1 -0
  62. package/dist/src/sync/manager.d.ts +146 -0
  63. package/dist/src/sync/manager.d.ts.map +1 -0
  64. package/dist/src/sync/manager.js +8 -0
  65. package/dist/src/sync/manager.js.map +1 -0
  66. package/dist/src/sync/protocol.d.ts +282 -0
  67. package/dist/src/sync/protocol.d.ts.map +1 -0
  68. package/dist/src/sync/protocol.js +16 -0
  69. package/dist/src/sync/protocol.js.map +1 -0
  70. package/dist/src/sync/store-adapter.d.ts +42 -0
  71. package/dist/src/sync/store-adapter.d.ts.map +1 -0
  72. package/dist/src/sync/store-adapter.js +232 -0
  73. package/dist/src/sync/store-adapter.js.map +1 -0
  74. package/dist/src/sync/sync-manager-impl.d.ts +106 -0
  75. package/dist/src/sync/sync-manager-impl.d.ts.map +1 -0
  76. package/dist/src/sync/sync-manager-impl.js +1307 -0
  77. package/dist/src/sync/sync-manager-impl.js.map +1 -0
  78. package/package.json +64 -0
@@ -0,0 +1,1307 @@
1
+ /**
2
+ * SyncManager implementation.
3
+ *
4
+ * Coordinates CRDT metadata tracking and sync operations.
5
+ */
6
+ import { HLCManager, compareHLC } from '../clock/hlc.js';
7
+ import { generateSiteId, SITE_ID_KEY, serializeSiteIdentity, deserializeSiteIdentity, siteIdEquals, } from '../clock/site.js';
8
+ import { ColumnVersionStore, deserializeColumnVersion } from '../metadata/column-version.js';
9
+ import { TombstoneStore, deserializeTombstone } from '../metadata/tombstones.js';
10
+ import { PeerStateStore } from '../metadata/peer-state.js';
11
+ import { SchemaMigrationStore, deserializeMigration } from '../metadata/schema-migration.js';
12
+ import { ChangeLogStore } from '../metadata/change-log.js';
13
+ import { SYNC_KEY_PREFIX, buildAllColumnVersionsScanBounds, buildAllTombstonesScanBounds, buildAllSchemaMigrationsScanBounds, buildAllChangeLogScanBounds, parseColumnVersionKey, parseTombstoneKey, parseSchemaMigrationKey, encodePK, } from '../metadata/keys.js';
14
+ /** Default chunk size for streaming snapshots. */
15
+ const DEFAULT_SNAPSHOT_CHUNK_SIZE = 1000;
16
+ /** Key prefix for snapshot checkpoints. */
17
+ const CHECKPOINT_PREFIX = 'sc:';
18
+ /**
19
+ * Implementation of SyncManager.
20
+ */
21
+ export class SyncManagerImpl {
22
+ kv;
23
+ config;
24
+ hlcManager;
25
+ columnVersions;
26
+ tombstones;
27
+ peerStates;
28
+ changeLog;
29
+ schemaMigrations;
30
+ syncEvents;
31
+ applyToStore;
32
+ getTableSchema;
33
+ // Pending changes for the current transaction
34
+ pendingChanges = [];
35
+ currentTransactionId = null;
36
+ constructor(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema) {
37
+ this.kv = kv;
38
+ this.config = config;
39
+ this.hlcManager = hlcManager;
40
+ this.syncEvents = syncEvents;
41
+ this.applyToStore = applyToStore;
42
+ this.getTableSchema = getTableSchema;
43
+ this.columnVersions = new ColumnVersionStore(kv);
44
+ this.tombstones = new TombstoneStore(kv, config.tombstoneTTL);
45
+ this.peerStates = new PeerStateStore(kv);
46
+ this.changeLog = new ChangeLogStore(kv);
47
+ this.schemaMigrations = new SchemaMigrationStore(kv);
48
+ }
49
+ /**
50
+ * Create a new SyncManager, initializing or loading site identity.
51
+ *
52
+ * @param kv - KV store for sync metadata
53
+ * @param storeEvents - Store event emitter to subscribe to local changes
54
+ * @param config - Sync configuration
55
+ * @param syncEvents - Sync event emitter for UI integration
56
+ * @param applyToStore - Optional callback for applying remote changes to the store
57
+ * @param getTableSchema - Optional callback for getting table schema by name
58
+ */
59
+ static async create(kv, storeEvents, config, syncEvents, applyToStore, getTableSchema) {
60
+ // Load or create site identity
61
+ const siteIdKey = new TextEncoder().encode(SITE_ID_KEY);
62
+ let siteId;
63
+ const existingIdentity = await kv.get(siteIdKey);
64
+ if (existingIdentity) {
65
+ const identity = deserializeSiteIdentity(existingIdentity);
66
+ siteId = identity.siteId;
67
+ }
68
+ else if (config.siteId) {
69
+ siteId = config.siteId;
70
+ await kv.put(siteIdKey, serializeSiteIdentity({ siteId, createdAt: Date.now() }));
71
+ }
72
+ else {
73
+ siteId = generateSiteId();
74
+ await kv.put(siteIdKey, serializeSiteIdentity({ siteId, createdAt: Date.now() }));
75
+ }
76
+ // Load HLC state
77
+ const hlcKey = SYNC_KEY_PREFIX.HLC_STATE;
78
+ const hlcData = await kv.get(hlcKey);
79
+ let hlcState;
80
+ if (hlcData) {
81
+ const view = new DataView(hlcData.buffer, hlcData.byteOffset, hlcData.byteLength);
82
+ hlcState = {
83
+ wallTime: view.getBigUint64(0, false),
84
+ counter: view.getUint16(8, false),
85
+ };
86
+ }
87
+ const hlcManager = new HLCManager(siteId, hlcState);
88
+ const manager = new SyncManagerImpl(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema);
89
+ // Subscribe to store events
90
+ storeEvents.onDataChange((event) => manager.handleDataChange(event));
91
+ storeEvents.onSchemaChange((event) => manager.handleSchemaChange(event));
92
+ return manager;
93
+ }
94
+ getSiteId() {
95
+ return this.hlcManager.getSiteId();
96
+ }
97
+ getCurrentHLC() {
98
+ return this.hlcManager.now();
99
+ }
100
+ /**
101
+ * Handle a data change event from the store.
102
+ * Records CRDT metadata for the change.
103
+ * Skips remote events to prevent duplicate recording.
104
+ */
105
+ async handleDataChange(event) {
106
+ // Skip events from remote sync - metadata already recorded by the originating replica
107
+ if (event.remote)
108
+ return;
109
+ const hlc = this.hlcManager.tick();
110
+ const { schemaName, tableName, type, oldRow, newRow } = event;
111
+ // Support both 'key' and 'pk' property names
112
+ const pk = event.key ?? event.pk;
113
+ if (!pk) {
114
+ // Cannot record change without primary key
115
+ return;
116
+ }
117
+ const batch = this.kv.batch();
118
+ if (type === 'delete') {
119
+ // Record tombstone
120
+ this.tombstones.setTombstoneBatch(batch, schemaName, tableName, pk, hlc);
121
+ // Record in change log for efficient delta queries
122
+ this.changeLog.recordDeletionBatch(batch, hlc, schemaName, tableName, pk);
123
+ // Delete column versions for this row
124
+ await this.columnVersions.deleteRowVersions(schemaName, tableName, pk);
125
+ const change = {
126
+ type: 'delete',
127
+ schema: schemaName,
128
+ table: tableName,
129
+ pk,
130
+ hlc,
131
+ };
132
+ this.pendingChanges.push(change);
133
+ }
134
+ else {
135
+ // Insert or update: record column versions
136
+ if (newRow) {
137
+ await this.recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, hlc);
138
+ }
139
+ }
140
+ // Persist HLC state in batch
141
+ const hlcState = this.hlcManager.getState();
142
+ const hlcBuffer = new Uint8Array(10);
143
+ const hlcView = new DataView(hlcBuffer.buffer);
144
+ hlcView.setBigUint64(0, hlcState.wallTime, false);
145
+ hlcView.setUint16(8, hlcState.counter, false);
146
+ batch.put(SYNC_KEY_PREFIX.HLC_STATE, hlcBuffer);
147
+ await batch.write();
148
+ // Emit local change event with current pending changes, then clear them
149
+ const changesToEmit = [...this.pendingChanges];
150
+ this.pendingChanges = [];
151
+ this.syncEvents.emitLocalChange({
152
+ transactionId: this.currentTransactionId || crypto.randomUUID(),
153
+ changes: changesToEmit,
154
+ pendingSync: true,
155
+ });
156
+ }
157
+ /**
158
+ * Handle a schema change event from the store.
159
+ * Records schema migrations for sync.
160
+ * Skips remote events to prevent duplicate recording.
161
+ */
162
+ async handleSchemaChange(event) {
163
+ // Skip events from remote sync - metadata already recorded by the originating replica
164
+ if (event.remote)
165
+ return;
166
+ const hlc = this.hlcManager.tick();
167
+ const { type, objectType, schemaName, objectName, ddl } = event;
168
+ // Map store event type to migration type
169
+ let migrationType;
170
+ if (objectType === 'table') {
171
+ switch (type) {
172
+ case 'create':
173
+ migrationType = 'create_table';
174
+ break;
175
+ case 'drop':
176
+ migrationType = 'drop_table';
177
+ break;
178
+ case 'alter':
179
+ migrationType = 'alter_column';
180
+ break;
181
+ default: return; // Unknown type
182
+ }
183
+ }
184
+ else if (objectType === 'index') {
185
+ switch (type) {
186
+ case 'create':
187
+ migrationType = 'add_index';
188
+ break;
189
+ case 'drop':
190
+ migrationType = 'drop_index';
191
+ break;
192
+ default: return; // Unknown type
193
+ }
194
+ }
195
+ else {
196
+ return; // Unknown object type
197
+ }
198
+ // Get next schema version for this table
199
+ const currentVersion = await this.schemaMigrations.getCurrentVersion(schemaName, objectName);
200
+ const newVersion = currentVersion + 1;
201
+ // Record the migration
202
+ await this.schemaMigrations.recordMigration(schemaName, objectName, {
203
+ type: migrationType,
204
+ ddl: ddl || '',
205
+ hlc,
206
+ schemaVersion: newVersion,
207
+ });
208
+ // Persist HLC state
209
+ const hlcState = this.hlcManager.getState();
210
+ const hlcBuffer = new Uint8Array(10);
211
+ const hlcView = new DataView(hlcBuffer.buffer);
212
+ hlcView.setBigUint64(0, hlcState.wallTime, false);
213
+ hlcView.setUint16(8, hlcState.counter, false);
214
+ await this.kv.put(SYNC_KEY_PREFIX.HLC_STATE, hlcBuffer);
215
+ // Emit local change event for the schema migration
216
+ this.syncEvents.emitLocalChange({
217
+ transactionId: crypto.randomUUID(),
218
+ changes: [],
219
+ pendingSync: true,
220
+ });
221
+ }
222
+ async recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, hlc) {
223
+ // Try to get actual column names from schema
224
+ const tableSchema = this.getTableSchema?.(schemaName, tableName);
225
+ const columnNames = tableSchema?.columns?.map(c => c.name);
226
+ // Debug logging for column name resolution
227
+ if (!tableSchema) {
228
+ console.warn(`[Sync] No table schema found for ${schemaName}.${tableName} - using fallback column names`);
229
+ }
230
+ // For each column that changed, record the new version
231
+ for (let i = 0; i < newRow.length; i++) {
232
+ const oldValue = oldRow?.[i];
233
+ const newValue = newRow[i];
234
+ // Only record if value changed (or it's an insert)
235
+ if (!oldRow || oldValue !== newValue) {
236
+ // Use actual column name if available, otherwise fall back to index-based
237
+ const column = columnNames?.[i] ?? `col_${i}`;
238
+ // Look up old column version to delete stale change log entry
239
+ const oldVersion = await this.columnVersions.getColumnVersion(schemaName, tableName, pk, column);
240
+ // Delete old change log entry if exists (keeps change log as a true secondary index)
241
+ if (oldVersion) {
242
+ this.changeLog.deleteEntryBatch(batch, oldVersion.hlc, 'column', schemaName, tableName, pk, column);
243
+ }
244
+ // Record new column version
245
+ const version = { hlc, value: newValue };
246
+ this.columnVersions.setColumnVersionBatch(batch, schemaName, tableName, pk, column, version);
247
+ // Record in change log for efficient delta queries
248
+ this.changeLog.recordColumnChangeBatch(batch, hlc, schemaName, tableName, pk, column);
249
+ const change = {
250
+ type: 'column',
251
+ schema: schemaName,
252
+ table: tableName,
253
+ pk,
254
+ column,
255
+ value: newValue,
256
+ hlc,
257
+ };
258
+ this.pendingChanges.push(change);
259
+ }
260
+ }
261
+ }
262
+ async persistHLCState() {
263
+ const state = this.hlcManager.getState();
264
+ const buffer = new Uint8Array(10);
265
+ const view = new DataView(buffer.buffer);
266
+ view.setBigUint64(0, state.wallTime, false);
267
+ view.setUint16(8, state.counter, false);
268
+ await this.kv.put(SYNC_KEY_PREFIX.HLC_STATE, buffer);
269
+ }
270
+ async getChangesSince(peerSiteId, sinceHLC) {
271
+ const changes = [];
272
+ if (sinceHLC) {
273
+ // Use change log for efficient delta query
274
+ for await (const logEntry of this.changeLog.getChangesSince(sinceHLC)) {
275
+ // Don't include changes from the requesting peer
276
+ if (siteIdEquals(logEntry.hlc.siteId, peerSiteId))
277
+ continue;
278
+ if (logEntry.entryType === 'column') {
279
+ // Look up the column version to get the value
280
+ const cv = await this.columnVersions.getColumnVersion(logEntry.schema, logEntry.table, logEntry.pk, logEntry.column);
281
+ if (!cv)
282
+ continue; // May have been superseded
283
+ const columnChange = {
284
+ type: 'column',
285
+ schema: logEntry.schema,
286
+ table: logEntry.table,
287
+ pk: logEntry.pk,
288
+ column: logEntry.column,
289
+ value: cv.value,
290
+ hlc: cv.hlc,
291
+ };
292
+ changes.push(columnChange);
293
+ }
294
+ else {
295
+ // Look up tombstone for the deletion
296
+ const tombstone = await this.tombstones.getTombstone(logEntry.schema, logEntry.table, logEntry.pk);
297
+ if (!tombstone)
298
+ continue; // May have been pruned
299
+ const deletion = {
300
+ type: 'delete',
301
+ schema: logEntry.schema,
302
+ table: logEntry.table,
303
+ pk: logEntry.pk,
304
+ hlc: tombstone.hlc,
305
+ };
306
+ changes.push(deletion);
307
+ }
308
+ }
309
+ }
310
+ else {
311
+ // No sinceHLC - need all changes (full scan fallback)
312
+ await this.collectAllChanges(peerSiteId, changes);
313
+ }
314
+ // Collect schema migrations since sinceHLC
315
+ const schemaMigrations = [];
316
+ const smBounds = buildAllSchemaMigrationsScanBounds();
317
+ for await (const entry of this.kv.iterate(smBounds)) {
318
+ const parsed = parseSchemaMigrationKey(entry.key);
319
+ if (!parsed)
320
+ continue;
321
+ const migration = deserializeMigration(entry.value);
322
+ // Filter by HLC if provided
323
+ if (sinceHLC && compareHLC(migration.hlc, sinceHLC) <= 0)
324
+ continue;
325
+ // Don't include changes from the requesting peer
326
+ if (siteIdEquals(migration.hlc.siteId, peerSiteId))
327
+ continue;
328
+ schemaMigrations.push({
329
+ type: migration.type,
330
+ schema: parsed.schema,
331
+ table: parsed.table,
332
+ ddl: migration.ddl,
333
+ hlc: migration.hlc,
334
+ schemaVersion: migration.schemaVersion,
335
+ });
336
+ }
337
+ // If no changes, return empty array
338
+ if (changes.length === 0 && schemaMigrations.length === 0) {
339
+ return [];
340
+ }
341
+ // Changes from change log are already in HLC order
342
+ // Schema migrations need sorting
343
+ schemaMigrations.sort((a, b) => compareHLC(a.hlc, b.hlc));
344
+ // Batch changes up to config.batchSize
345
+ const result = [];
346
+ for (let i = 0; i < changes.length; i += this.config.batchSize) {
347
+ const batch = changes.slice(i, i + this.config.batchSize);
348
+ const maxHLC = batch.reduce((max, c) => compareHLC(c.hlc, max) > 0 ? c.hlc : max, batch[0].hlc);
349
+ result.push({
350
+ siteId: this.getSiteId(),
351
+ transactionId: crypto.randomUUID(),
352
+ hlc: maxHLC,
353
+ changes: batch,
354
+ schemaMigrations: i === 0 ? schemaMigrations : [], // Only include schema migrations in first batch
355
+ });
356
+ }
357
+ // If no data changes but we have schema migrations, create a changeset for them
358
+ if (result.length === 0 && schemaMigrations.length > 0) {
359
+ const maxHLC = schemaMigrations.reduce((max, m) => compareHLC(m.hlc, max) > 0 ? m.hlc : max, schemaMigrations[0].hlc);
360
+ result.push({
361
+ siteId: this.getSiteId(),
362
+ transactionId: crypto.randomUUID(),
363
+ hlc: maxHLC,
364
+ changes: [],
365
+ schemaMigrations,
366
+ });
367
+ }
368
+ return result;
369
+ }
370
+ /**
371
+ * Fallback: collect all changes when no sinceHLC is provided.
372
+ */
373
+ async collectAllChanges(peerSiteId, changes) {
374
+ // Collect all column changes
375
+ const cvBounds = buildAllColumnVersionsScanBounds();
376
+ for await (const entry of this.kv.iterate(cvBounds)) {
377
+ const parsed = parseColumnVersionKey(entry.key);
378
+ if (!parsed)
379
+ continue;
380
+ const cv = deserializeColumnVersion(entry.value);
381
+ // Don't include changes from the requesting peer
382
+ if (siteIdEquals(cv.hlc.siteId, peerSiteId))
383
+ continue;
384
+ const columnChange = {
385
+ type: 'column',
386
+ schema: parsed.schema,
387
+ table: parsed.table,
388
+ pk: parsed.pk,
389
+ column: parsed.column,
390
+ value: cv.value,
391
+ hlc: cv.hlc,
392
+ };
393
+ changes.push(columnChange);
394
+ }
395
+ // Collect all deletions (tombstones)
396
+ const tbBounds = buildAllTombstonesScanBounds();
397
+ for await (const entry of this.kv.iterate(tbBounds)) {
398
+ const parsed = parseTombstoneKey(entry.key);
399
+ if (!parsed)
400
+ continue;
401
+ const tombstone = deserializeTombstone(entry.value);
402
+ // Don't include changes from the requesting peer
403
+ if (siteIdEquals(tombstone.hlc.siteId, peerSiteId))
404
+ continue;
405
+ const deletion = {
406
+ type: 'delete',
407
+ schema: parsed.schema,
408
+ table: parsed.table,
409
+ pk: parsed.pk,
410
+ hlc: tombstone.hlc,
411
+ };
412
+ changes.push(deletion);
413
+ }
414
+ // Sort by HLC for consistent ordering
415
+ changes.sort((a, b) => compareHLC(a.hlc, b.hlc));
416
+ }
417
+ async applyChanges(changes) {
418
+ let applied = 0;
419
+ let skipped = 0;
420
+ let conflicts = 0;
421
+ // Collect changes to apply to the store (grouped by row for column merging)
422
+ const dataChangesToApply = [];
423
+ const schemaChangesToApply = [];
424
+ const appliedChanges = [];
425
+ // Track resolved changes for the commit phase (CRDT metadata)
426
+ const resolvedDataChanges = [];
427
+ // Track schema migrations that will be applied (for metadata commit)
428
+ const pendingSchemaMigrations = [];
429
+ // PHASE 1: Resolve all changes (no writes yet)
430
+ for (const changeSet of changes) {
431
+ // Update our clock with the remote HLC
432
+ this.hlcManager.receive(changeSet.hlc);
433
+ // Process schema migrations first (DDL before DML)
434
+ for (const migration of changeSet.schemaMigrations) {
435
+ // Use the incoming schemaVersion if provided, otherwise calculate next version
436
+ const schemaVersion = migration.schemaVersion ??
437
+ (await this.schemaMigrations.getCurrentVersion(migration.schema, migration.table)) + 1;
438
+ // Check if we've already recorded this migration
439
+ const existingMigration = await this.schemaMigrations.getMigration(migration.schema, migration.table, schemaVersion);
440
+ if (existingMigration) {
441
+ // Already have this migration version - skip
442
+ // (HLC comparison for first-writer-wins is done via checkConflict if needed)
443
+ if (compareHLC(migration.hlc, existingMigration.hlc) <= 0) {
444
+ skipped++;
445
+ continue;
446
+ }
447
+ }
448
+ // Queue schema change for application (don't record metadata yet)
449
+ schemaChangesToApply.push({
450
+ type: migration.type,
451
+ schema: migration.schema,
452
+ table: migration.table,
453
+ ddl: migration.ddl,
454
+ });
455
+ pendingSchemaMigrations.push({ migration, schemaVersion });
456
+ applied++;
457
+ }
458
+ // Resolve data changes (no metadata writes)
459
+ for (const change of changeSet.changes) {
460
+ const resolved = await this.resolveChange(change, changeSet.siteId);
461
+ if (resolved.outcome === 'applied') {
462
+ applied++;
463
+ appliedChanges.push({ change, siteId: changeSet.siteId });
464
+ resolvedDataChanges.push(resolved);
465
+ if (resolved.dataChange) {
466
+ dataChangesToApply.push(resolved.dataChange);
467
+ }
468
+ }
469
+ else if (resolved.outcome === 'skipped') {
470
+ skipped++;
471
+ }
472
+ else if (resolved.outcome === 'conflict') {
473
+ conflicts++;
474
+ }
475
+ }
476
+ }
477
+ // PHASE 2: Apply data and schema changes to the store via callback
478
+ // This happens BEFORE writing CRDT metadata for crash safety
479
+ if (this.applyToStore && (dataChangesToApply.length > 0 || schemaChangesToApply.length > 0)) {
480
+ await this.applyToStore(dataChangesToApply, schemaChangesToApply, { remote: true });
481
+ }
482
+ // PHASE 3: Commit CRDT metadata (after data is safely written)
483
+ // If crash occurs here, re-sync will re-apply same changes (idempotent)
484
+ await this.commitChangeMetadata(resolvedDataChanges);
485
+ // Commit schema migration metadata
486
+ for (const { migration, schemaVersion } of pendingSchemaMigrations) {
487
+ await this.schemaMigrations.recordMigration(migration.schema, migration.table, {
488
+ type: migration.type,
489
+ ddl: migration.ddl,
490
+ hlc: migration.hlc,
491
+ schemaVersion,
492
+ });
493
+ }
494
+ // Emit a single remote change event with all applied changes for UI reactivity
495
+ if (appliedChanges.length > 0) {
496
+ // Group by siteId to emit one event per originating site
497
+ const changesBySite = new Map();
498
+ for (const { change, siteId } of appliedChanges) {
499
+ const siteKey = Array.from(siteId).join(',');
500
+ const siteChanges = changesBySite.get(siteKey);
501
+ if (siteChanges) {
502
+ siteChanges.push(change);
503
+ }
504
+ else {
505
+ changesBySite.set(siteKey, [change]);
506
+ }
507
+ }
508
+ const appliedAt = this.hlcManager.now();
509
+ for (const [siteKey, siteChanges] of changesBySite) {
510
+ const siteIdBytes = new Uint8Array(siteKey.split(',').map(Number));
511
+ this.syncEvents.emitRemoteChange({
512
+ siteId: siteIdBytes,
513
+ transactionId: crypto.randomUUID(),
514
+ changes: siteChanges,
515
+ appliedAt,
516
+ });
517
+ }
518
+ }
519
+ await this.persistHLCState();
520
+ return {
521
+ applied,
522
+ skipped,
523
+ conflicts,
524
+ transactions: changes.length,
525
+ };
526
+ }
527
+ /**
528
+ * Resolve CRDT conflicts for a change WITHOUT writing metadata.
529
+ * This is phase 1 of the two-phase apply: resolve first, then after
530
+ * data is written to store, call commitChangeMetadata to persist CRDT state.
531
+ *
532
+ * Write order (for crash safety):
533
+ * 1. Resolve all changes (this method) - no writes
534
+ * 2. Apply data to store (applyToStore callback)
535
+ * 3. Commit CRDT metadata (commitChangeMetadata)
536
+ *
537
+ * If crash occurs after data but before metadata, re-sync will re-apply
538
+ * the same changes. Since CRDT operations are idempotent (same HLC → same
539
+ * LWW outcome), this is safe.
540
+ */
541
+ async resolveChange(change, _remoteSiteId) {
542
+ // Skip changes that originated from ourselves (echo prevention).
543
+ // This can happen when a peer re-sends changes it received from us,
544
+ // or when changes propagate through a coordinator back to the originator.
545
+ if (siteIdEquals(change.hlc.siteId, this.getSiteId())) {
546
+ return { outcome: 'skipped', change };
547
+ }
548
+ if (change.type === 'delete') {
549
+ // Check if we should apply this deletion
550
+ const existingTombstone = await this.tombstones.getTombstone(change.schema, change.table, change.pk);
551
+ if (existingTombstone && compareHLC(change.hlc, existingTombstone.hlc) <= 0) {
552
+ return { outcome: 'skipped', change };
553
+ }
554
+ // Will apply - return the data change (metadata written in commit phase)
555
+ return {
556
+ outcome: 'applied',
557
+ change,
558
+ dataChange: {
559
+ type: 'delete',
560
+ schema: change.schema,
561
+ table: change.table,
562
+ pk: change.pk,
563
+ },
564
+ };
565
+ }
566
+ else {
567
+ // Column change
568
+ const shouldApply = await this.columnVersions.shouldApplyWrite(change.schema, change.table, change.pk, change.column, change.hlc);
569
+ if (!shouldApply) {
570
+ // Local version is newer - this is a conflict where local wins
571
+ const localVersion = await this.columnVersions.getColumnVersion(change.schema, change.table, change.pk, change.column);
572
+ if (localVersion) {
573
+ const conflictEvent = {
574
+ table: change.table,
575
+ pk: change.pk,
576
+ column: change.column,
577
+ localValue: localVersion.value,
578
+ remoteValue: change.value,
579
+ winner: 'local',
580
+ winningHLC: localVersion.hlc,
581
+ };
582
+ this.syncEvents.emitConflictResolved(conflictEvent);
583
+ }
584
+ return { outcome: 'conflict', change };
585
+ }
586
+ // Check for tombstone blocking
587
+ const isBlocked = await this.tombstones.isDeletedAndBlocking(change.schema, change.table, change.pk, change.hlc, this.config.allowResurrection);
588
+ if (isBlocked) {
589
+ return { outcome: 'skipped', change };
590
+ }
591
+ // Get old column version for change log cleanup (done in commit phase)
592
+ const oldColumnVersion = await this.columnVersions.getColumnVersion(change.schema, change.table, change.pk, change.column) ?? undefined;
593
+ // Will apply - return the data change (metadata written in commit phase)
594
+ return {
595
+ outcome: 'applied',
596
+ change,
597
+ oldColumnVersion,
598
+ dataChange: {
599
+ type: 'update', // Column changes are updates (or inserts handled by store)
600
+ schema: change.schema,
601
+ table: change.table,
602
+ pk: change.pk,
603
+ columns: { [change.column]: change.value },
604
+ },
605
+ };
606
+ }
607
+ }
608
+ /**
609
+ * Commit CRDT metadata for resolved changes.
610
+ * This is phase 2 of the two-phase apply: called AFTER data is written to store.
611
+ */
612
+ async commitChangeMetadata(resolvedChanges) {
613
+ if (resolvedChanges.length === 0)
614
+ return;
615
+ const batch = this.kv.batch();
616
+ for (const resolved of resolvedChanges) {
617
+ if (resolved.outcome !== 'applied')
618
+ continue;
619
+ const change = resolved.change;
620
+ if (change.type === 'delete') {
621
+ // Record tombstone
622
+ this.tombstones.setTombstoneBatch(batch, change.schema, change.table, change.pk, change.hlc);
623
+ // Record in change log for delta sync
624
+ this.changeLog.recordDeletionBatch(batch, change.hlc, change.schema, change.table, change.pk);
625
+ // Note: column versions deletion is done outside the batch since it requires iteration
626
+ }
627
+ else {
628
+ // Column change
629
+ // Delete old change log entry if exists (keeps change log as a true secondary index)
630
+ if (resolved.oldColumnVersion) {
631
+ this.changeLog.deleteEntryBatch(batch, resolved.oldColumnVersion.hlc, 'column', change.schema, change.table, change.pk, change.column);
632
+ }
633
+ // Record the column version
634
+ this.columnVersions.setColumnVersionBatch(batch, change.schema, change.table, change.pk, change.column, { hlc: change.hlc, value: change.value });
635
+ // Record in change log for delta sync
636
+ this.changeLog.recordColumnChangeBatch(batch, change.hlc, change.schema, change.table, change.pk, change.column);
637
+ }
638
+ }
639
+ await batch.write();
640
+ // Handle column version deletions for delete operations (requires async iteration)
641
+ for (const resolved of resolvedChanges) {
642
+ if (resolved.outcome !== 'applied')
643
+ continue;
644
+ if (resolved.change.type === 'delete') {
645
+ await this.columnVersions.deleteRowVersions(resolved.change.schema, resolved.change.table, resolved.change.pk);
646
+ }
647
+ }
648
+ }
649
+ async canDeltaSync(peerSiteId, sinceHLC) {
650
+ const peerState = await this.peerStates.getPeerState(peerSiteId);
651
+ if (!peerState) {
652
+ // Never synced with this peer - need full snapshot
653
+ return false;
654
+ }
655
+ // Check if the sinceHLC is within tombstone TTL
656
+ const now = Date.now();
657
+ const sinceTime = Number(sinceHLC.wallTime);
658
+ if (now - sinceTime > this.config.tombstoneTTL) {
659
+ // Too old - tombstones may have been pruned
660
+ return false;
661
+ }
662
+ return true;
663
+ }
664
+ async getSnapshot() {
665
+ const tableData = new Map();
666
+ const cvBounds = buildAllColumnVersionsScanBounds();
667
+ for await (const entry of this.kv.iterate(cvBounds)) {
668
+ const parsed = parseColumnVersionKey(entry.key);
669
+ if (!parsed)
670
+ continue;
671
+ const cv = deserializeColumnVersion(entry.value);
672
+ const tableKey = `${parsed.schema}.${parsed.table}`;
673
+ const rowKey = encodePK(parsed.pk);
674
+ if (!tableData.has(tableKey)) {
675
+ tableData.set(tableKey, new Map());
676
+ }
677
+ const tableRows = tableData.get(tableKey);
678
+ if (!tableRows.has(rowKey)) {
679
+ tableRows.set(rowKey, new Map());
680
+ }
681
+ const rowVersions = tableRows.get(rowKey);
682
+ rowVersions.set(parsed.column, cv);
683
+ }
684
+ // Build table snapshots
685
+ const tables = [];
686
+ for (const [tableKey, rows] of tableData) {
687
+ const [schema, table] = tableKey.split('.');
688
+ const columnVersions = new Map();
689
+ const rowsArray = [];
690
+ for (const [rowKey, rowVersions] of rows) {
691
+ // Convert column map to array (row representation)
692
+ const row = Array.from(rowVersions.values()).map(cv => cv.value);
693
+ rowsArray.push(row);
694
+ // Add column versions with their values
695
+ for (const [column, cv] of rowVersions) {
696
+ const versionKey = `${rowKey}:${column}`;
697
+ columnVersions.set(versionKey, { hlc: cv.hlc, value: cv.value });
698
+ }
699
+ }
700
+ tables.push({
701
+ schema,
702
+ table,
703
+ rows: rowsArray,
704
+ columnVersions,
705
+ });
706
+ }
707
+ // Collect all schema migrations
708
+ const schemaMigrations = [];
709
+ const smBounds = buildAllSchemaMigrationsScanBounds();
710
+ for await (const entry of this.kv.iterate(smBounds)) {
711
+ const parsed = parseSchemaMigrationKey(entry.key);
712
+ if (!parsed)
713
+ continue;
714
+ const migration = deserializeMigration(entry.value);
715
+ schemaMigrations.push({
716
+ type: migration.type,
717
+ schema: parsed.schema,
718
+ table: parsed.table,
719
+ ddl: migration.ddl,
720
+ hlc: migration.hlc,
721
+ schemaVersion: migration.schemaVersion,
722
+ });
723
+ }
724
+ return {
725
+ siteId: this.getSiteId(),
726
+ hlc: this.getCurrentHLC(),
727
+ tables,
728
+ schemaMigrations,
729
+ };
730
+ }
731
+ async applySnapshot(snapshot) {
732
+ // PHASE 1: Build data changes from snapshot (before any writes)
733
+ const dataChangesToApply = [];
734
+ const schemaChangesToApply = [];
735
+ // Build schema migrations for the store
736
+ for (const migration of snapshot.schemaMigrations) {
737
+ schemaChangesToApply.push({
738
+ type: migration.type,
739
+ schema: migration.schema,
740
+ table: migration.table,
741
+ ddl: migration.ddl,
742
+ });
743
+ }
744
+ // Build row data from column versions (grouped by pk)
745
+ for (const tableSnapshot of snapshot.tables) {
746
+ // Group column versions by pk to reconstruct rows
747
+ const rowsByPk = new Map();
748
+ for (const [versionKey, cvEntry] of tableSnapshot.columnVersions) {
749
+ const lastColon = versionKey.lastIndexOf(':');
750
+ if (lastColon === -1)
751
+ continue;
752
+ const rowKey = versionKey.slice(0, lastColon);
753
+ const column = versionKey.slice(lastColon + 1);
754
+ if (!rowsByPk.has(rowKey)) {
755
+ rowsByPk.set(rowKey, {});
756
+ }
757
+ rowsByPk.get(rowKey)[column] = cvEntry.value;
758
+ }
759
+ // Create data change for each row
760
+ for (const [rowKey, columns] of rowsByPk) {
761
+ const pk = JSON.parse(rowKey);
762
+ dataChangesToApply.push({
763
+ type: 'update', // Snapshot rows are upserts
764
+ schema: tableSnapshot.schema,
765
+ table: tableSnapshot.table,
766
+ pk,
767
+ columns,
768
+ });
769
+ }
770
+ }
771
+ // PHASE 2: Apply data to store via callback (before metadata)
772
+ if (this.applyToStore && (dataChangesToApply.length > 0 || schemaChangesToApply.length > 0)) {
773
+ await this.applyToStore(dataChangesToApply, schemaChangesToApply, { remote: true });
774
+ }
775
+ // PHASE 3: Clear existing CRDT metadata and apply new metadata
776
+ const clearBatch = this.kv.batch();
777
+ // Delete all existing column versions
778
+ const cvBounds = buildAllColumnVersionsScanBounds();
779
+ for await (const entry of this.kv.iterate(cvBounds)) {
780
+ clearBatch.delete(entry.key);
781
+ }
782
+ // Delete all existing tombstones
783
+ const tbBounds = buildAllTombstonesScanBounds();
784
+ for await (const entry of this.kv.iterate(tbBounds)) {
785
+ clearBatch.delete(entry.key);
786
+ }
787
+ // Delete all existing change log entries
788
+ const clBounds = buildAllChangeLogScanBounds();
789
+ for await (const entry of this.kv.iterate(clBounds)) {
790
+ clearBatch.delete(entry.key);
791
+ }
792
+ await clearBatch.write();
793
+ // Apply the snapshot's column versions and rebuild change log
794
+ const applyBatch = this.kv.batch();
795
+ for (const tableSnapshot of snapshot.tables) {
796
+ for (const [versionKey, cvEntry] of tableSnapshot.columnVersions) {
797
+ const lastColon = versionKey.lastIndexOf(':');
798
+ if (lastColon === -1)
799
+ continue;
800
+ const rowKey = versionKey.slice(0, lastColon);
801
+ const column = versionKey.slice(lastColon + 1);
802
+ const pk = JSON.parse(rowKey);
803
+ this.columnVersions.setColumnVersionBatch(applyBatch, tableSnapshot.schema, tableSnapshot.table, pk, column, { hlc: cvEntry.hlc, value: cvEntry.value });
804
+ // Rebuild change log entry
805
+ this.changeLog.recordColumnChangeBatch(applyBatch, cvEntry.hlc, tableSnapshot.schema, tableSnapshot.table, pk, column);
806
+ }
807
+ }
808
+ // Record schema migrations
809
+ for (const migration of snapshot.schemaMigrations) {
810
+ const schemaVersion = migration.schemaVersion ??
811
+ (await this.schemaMigrations.getCurrentVersion(migration.schema, migration.table)) + 1;
812
+ await this.schemaMigrations.recordMigration(migration.schema, migration.table, {
813
+ type: migration.type,
814
+ ddl: migration.ddl,
815
+ hlc: migration.hlc,
816
+ schemaVersion,
817
+ });
818
+ }
819
+ await applyBatch.write();
820
+ // Update our HLC to be at least as high as the snapshot
821
+ this.hlcManager.receive(snapshot.hlc);
822
+ await this.persistHLCState();
823
+ // Emit sync state change
824
+ this.syncEvents.emitSyncStateChange({ status: 'synced', lastSyncHLC: snapshot.hlc });
825
+ }
826
+ async updatePeerSyncState(peerSiteId, hlc) {
827
+ await this.peerStates.setPeerState(peerSiteId, hlc);
828
+ }
829
+ async getPeerSyncState(peerSiteId) {
830
+ const state = await this.peerStates.getPeerState(peerSiteId);
831
+ return state?.lastSyncHLC;
832
+ }
833
+ async pruneTombstones() {
834
+ const now = Date.now();
835
+ let count = 0;
836
+ const batch = this.kv.batch();
837
+ const tbBounds = buildAllTombstonesScanBounds();
838
+ for await (const entry of this.kv.iterate(tbBounds)) {
839
+ const tombstone = deserializeTombstone(entry.value);
840
+ if (now - tombstone.createdAt > this.config.tombstoneTTL) {
841
+ batch.delete(entry.key);
842
+ count++;
843
+ }
844
+ }
845
+ await batch.write();
846
+ return count;
847
+ }
848
+ /**
849
+ * Get the sync event emitter for UI integration.
850
+ */
851
+ getEventEmitter() {
852
+ return this.syncEvents;
853
+ }
854
+ // ============================================================================
855
+ // Streaming Snapshot API
856
+ // ============================================================================
857
+ async *getSnapshotStream(chunkSize = DEFAULT_SNAPSHOT_CHUNK_SIZE) {
858
+ const snapshotId = crypto.randomUUID();
859
+ const siteId = this.getSiteId();
860
+ const hlc = this.getCurrentHLC();
861
+ // Count tables and migrations for header
862
+ const tableKeys = new Set();
863
+ const cvBounds = buildAllColumnVersionsScanBounds();
864
+ for await (const entry of this.kv.iterate(cvBounds)) {
865
+ const parsed = parseColumnVersionKey(entry.key);
866
+ if (parsed)
867
+ tableKeys.add(`${parsed.schema}.${parsed.table}`);
868
+ }
869
+ let migrationCount = 0;
870
+ const smBounds = buildAllSchemaMigrationsScanBounds();
871
+ for await (const _entry of this.kv.iterate(smBounds)) {
872
+ migrationCount++;
873
+ }
874
+ // Yield header
875
+ const header = {
876
+ type: 'header',
877
+ siteId,
878
+ hlc,
879
+ tableCount: tableKeys.size,
880
+ migrationCount,
881
+ snapshotId,
882
+ };
883
+ yield header;
884
+ // Stream each table
885
+ let totalEntries = 0;
886
+ for (const tableKey of tableKeys) {
887
+ const [schema, table] = tableKey.split('.');
888
+ // Estimate entries for this table
889
+ let tableEntryCount = 0;
890
+ const tableCvBounds = buildAllColumnVersionsScanBounds();
891
+ for await (const entry of this.kv.iterate(tableCvBounds)) {
892
+ const parsed = parseColumnVersionKey(entry.key);
893
+ if (parsed && parsed.schema === schema && parsed.table === table) {
894
+ tableEntryCount++;
895
+ }
896
+ }
897
+ // Yield table start
898
+ const tableStart = {
899
+ type: 'table-start',
900
+ schema,
901
+ table,
902
+ estimatedEntries: tableEntryCount,
903
+ };
904
+ yield tableStart;
905
+ // Stream column versions in chunks
906
+ let entries = [];
907
+ let entriesWritten = 0;
908
+ for await (const entry of this.kv.iterate(tableCvBounds)) {
909
+ const parsed = parseColumnVersionKey(entry.key);
910
+ if (!parsed || parsed.schema !== schema || parsed.table !== table)
911
+ continue;
912
+ const cv = deserializeColumnVersion(entry.value);
913
+ const versionKey = `${encodePK(parsed.pk)}:${parsed.column}`;
914
+ entries.push([versionKey, cv.hlc, cv.value]);
915
+ entriesWritten++;
916
+ if (entries.length >= chunkSize) {
917
+ const chunk = {
918
+ type: 'column-versions',
919
+ schema,
920
+ table,
921
+ entries,
922
+ };
923
+ yield chunk;
924
+ entries = [];
925
+ }
926
+ }
927
+ // Yield remaining entries
928
+ if (entries.length > 0) {
929
+ const chunk = {
930
+ type: 'column-versions',
931
+ schema,
932
+ table,
933
+ entries,
934
+ };
935
+ yield chunk;
936
+ }
937
+ // Yield table end
938
+ const tableEnd = {
939
+ type: 'table-end',
940
+ schema,
941
+ table,
942
+ entriesWritten,
943
+ };
944
+ yield tableEnd;
945
+ totalEntries += entriesWritten;
946
+ }
947
+ // Stream schema migrations
948
+ for await (const entry of this.kv.iterate(smBounds)) {
949
+ const parsed = parseSchemaMigrationKey(entry.key);
950
+ if (!parsed)
951
+ continue;
952
+ const migration = deserializeMigration(entry.value);
953
+ const migrationChunk = {
954
+ type: 'schema-migration',
955
+ migration: {
956
+ type: migration.type,
957
+ schema: parsed.schema,
958
+ table: parsed.table,
959
+ ddl: migration.ddl,
960
+ hlc: migration.hlc,
961
+ schemaVersion: migration.schemaVersion,
962
+ },
963
+ };
964
+ yield migrationChunk;
965
+ }
966
+ // Yield footer
967
+ const footer = {
968
+ type: 'footer',
969
+ snapshotId,
970
+ totalTables: tableKeys.size,
971
+ totalEntries,
972
+ totalMigrations: migrationCount,
973
+ };
974
+ yield footer;
975
+ }
976
+ async applySnapshotStream(chunks, onProgress) {
977
+ let snapshotId;
978
+ let snapshotHLC;
979
+ let totalTables = 0;
980
+ let totalEntries = 0;
981
+ let tablesProcessed = 0;
982
+ let entriesProcessed = 0;
983
+ let currentTable;
984
+ const completedTables = [];
985
+ // Pending data to apply to store (batched for efficiency)
986
+ let pendingDataChanges = [];
987
+ let pendingSchemaChanges = [];
988
+ const DATA_FLUSH_SIZE = 100;
989
+ // Helper to flush pending data changes to store
990
+ const flushDataToStore = async () => {
991
+ if (this.applyToStore && (pendingDataChanges.length > 0 || pendingSchemaChanges.length > 0)) {
992
+ await this.applyToStore(pendingDataChanges, pendingSchemaChanges, { remote: true });
993
+ pendingDataChanges = [];
994
+ pendingSchemaChanges = [];
995
+ }
996
+ };
997
+ // Clear existing metadata before applying
998
+ const clearBatch = this.kv.batch();
999
+ for await (const entry of this.kv.iterate(buildAllColumnVersionsScanBounds())) {
1000
+ clearBatch.delete(entry.key);
1001
+ }
1002
+ for await (const entry of this.kv.iterate(buildAllTombstonesScanBounds())) {
1003
+ clearBatch.delete(entry.key);
1004
+ }
1005
+ for await (const entry of this.kv.iterate(buildAllChangeLogScanBounds())) {
1006
+ clearBatch.delete(entry.key);
1007
+ }
1008
+ await clearBatch.write();
1009
+ // Process chunks - track column versions per row for grouping
1010
+ let batch = this.kv.batch();
1011
+ let batchSize = 0;
1012
+ const BATCH_FLUSH_SIZE = 1000;
1013
+ // Track current table's rows for data application
1014
+ let currentTableSchema;
1015
+ let currentTableName;
1016
+ const rowColumns = new Map();
1017
+ for await (const chunk of chunks) {
1018
+ switch (chunk.type) {
1019
+ case 'header':
1020
+ snapshotId = chunk.snapshotId;
1021
+ snapshotHLC = chunk.hlc;
1022
+ totalTables = chunk.tableCount;
1023
+ break;
1024
+ case 'table-start':
1025
+ currentTable = `${chunk.schema}.${chunk.table}`;
1026
+ currentTableSchema = chunk.schema;
1027
+ currentTableName = chunk.table;
1028
+ totalEntries += chunk.estimatedEntries;
1029
+ rowColumns.clear();
1030
+ break;
1031
+ case 'column-versions':
1032
+ for (const [versionKey, hlc, value] of chunk.entries) {
1033
+ const lastColon = versionKey.lastIndexOf(':');
1034
+ if (lastColon === -1)
1035
+ continue;
1036
+ const rowKey = versionKey.slice(0, lastColon);
1037
+ const column = versionKey.slice(lastColon + 1);
1038
+ const pk = JSON.parse(rowKey);
1039
+ // Track column for data application
1040
+ if (!rowColumns.has(rowKey)) {
1041
+ rowColumns.set(rowKey, {});
1042
+ }
1043
+ rowColumns.get(rowKey)[column] = value;
1044
+ // Write CRDT metadata
1045
+ this.columnVersions.setColumnVersionBatch(batch, chunk.schema, chunk.table, pk, column, { hlc, value });
1046
+ this.changeLog.recordColumnChangeBatch(batch, hlc, chunk.schema, chunk.table, pk, column);
1047
+ batchSize++;
1048
+ entriesProcessed++;
1049
+ if (batchSize >= BATCH_FLUSH_SIZE) {
1050
+ await batch.write();
1051
+ batch = this.kv.batch();
1052
+ batchSize = 0;
1053
+ // Save checkpoint with completed tables
1054
+ if (snapshotId && snapshotHLC) {
1055
+ await this.saveSnapshotCheckpoint({
1056
+ snapshotId,
1057
+ siteId: this.getSiteId(),
1058
+ hlc: snapshotHLC,
1059
+ lastTableIndex: tablesProcessed,
1060
+ lastEntryIndex: entriesProcessed,
1061
+ completedTables: [...completedTables],
1062
+ entriesProcessed,
1063
+ createdAt: Date.now(),
1064
+ });
1065
+ }
1066
+ }
1067
+ }
1068
+ if (onProgress && snapshotId) {
1069
+ onProgress({
1070
+ snapshotId,
1071
+ tablesProcessed,
1072
+ totalTables,
1073
+ entriesProcessed,
1074
+ totalEntries,
1075
+ currentTable,
1076
+ });
1077
+ }
1078
+ break;
1079
+ case 'table-end':
1080
+ // Flush accumulated rows to store
1081
+ if (currentTableSchema && currentTableName) {
1082
+ for (const [rowKey, columns] of rowColumns) {
1083
+ const pk = JSON.parse(rowKey);
1084
+ pendingDataChanges.push({
1085
+ type: 'update',
1086
+ schema: currentTableSchema,
1087
+ table: currentTableName,
1088
+ pk,
1089
+ columns,
1090
+ });
1091
+ if (pendingDataChanges.length >= DATA_FLUSH_SIZE) {
1092
+ await flushDataToStore();
1093
+ }
1094
+ }
1095
+ rowColumns.clear();
1096
+ }
1097
+ tablesProcessed++;
1098
+ if (currentTable) {
1099
+ completedTables.push(currentTable);
1100
+ }
1101
+ break;
1102
+ case 'schema-migration': {
1103
+ const migration = chunk.migration;
1104
+ pendingSchemaChanges.push({
1105
+ type: migration.type,
1106
+ schema: migration.schema,
1107
+ table: migration.table,
1108
+ ddl: migration.ddl,
1109
+ });
1110
+ // Record migration metadata
1111
+ const schemaVersion = migration.schemaVersion ??
1112
+ (await this.schemaMigrations.getCurrentVersion(migration.schema, migration.table)) + 1;
1113
+ await this.schemaMigrations.recordMigration(migration.schema, migration.table, {
1114
+ type: migration.type,
1115
+ ddl: migration.ddl,
1116
+ hlc: migration.hlc,
1117
+ schemaVersion,
1118
+ });
1119
+ break;
1120
+ }
1121
+ case 'footer':
1122
+ // Flush remaining data to store
1123
+ await flushDataToStore();
1124
+ // Flush remaining metadata batch
1125
+ if (batchSize > 0) {
1126
+ await batch.write();
1127
+ }
1128
+ // Update HLC
1129
+ if (snapshotHLC) {
1130
+ this.hlcManager.receive(snapshotHLC);
1131
+ await this.persistHLCState();
1132
+ }
1133
+ // Clear checkpoint
1134
+ if (snapshotId) {
1135
+ await this.clearSnapshotCheckpoint(snapshotId);
1136
+ }
1137
+ // Emit sync state change
1138
+ if (snapshotHLC) {
1139
+ this.syncEvents.emitSyncStateChange({ status: 'synced', lastSyncHLC: snapshotHLC });
1140
+ }
1141
+ break;
1142
+ }
1143
+ }
1144
+ }
1145
+ async getSnapshotCheckpoint(snapshotId) {
1146
+ const key = new TextEncoder().encode(`${CHECKPOINT_PREFIX}${snapshotId}`);
1147
+ const data = await this.kv.get(key);
1148
+ if (!data)
1149
+ return undefined;
1150
+ const json = new TextDecoder().decode(data);
1151
+ const obj = JSON.parse(json);
1152
+ // Reconstruct HLC with proper types
1153
+ return {
1154
+ ...obj,
1155
+ hlc: {
1156
+ wallTime: BigInt(obj.hlc.wallTime),
1157
+ counter: obj.hlc.counter,
1158
+ siteId: new Uint8Array(obj.hlc.siteId),
1159
+ },
1160
+ siteId: new Uint8Array(obj.siteId),
1161
+ };
1162
+ }
1163
+ async saveSnapshotCheckpoint(checkpoint) {
1164
+ const key = new TextEncoder().encode(`${CHECKPOINT_PREFIX}${checkpoint.snapshotId}`);
1165
+ const json = JSON.stringify({
1166
+ ...checkpoint,
1167
+ hlc: {
1168
+ wallTime: checkpoint.hlc.wallTime.toString(),
1169
+ counter: checkpoint.hlc.counter,
1170
+ siteId: Array.from(checkpoint.hlc.siteId),
1171
+ },
1172
+ siteId: Array.from(checkpoint.siteId),
1173
+ });
1174
+ await this.kv.put(key, new TextEncoder().encode(json));
1175
+ }
1176
+ async clearSnapshotCheckpoint(snapshotId) {
1177
+ const key = new TextEncoder().encode(`${CHECKPOINT_PREFIX}${snapshotId}`);
1178
+ await this.kv.delete(key);
1179
+ }
1180
+ async *resumeSnapshotStream(checkpoint) {
1181
+ // Resume streaming from checkpoint position
1182
+ // Skip tables that have already been completed
1183
+ const completedSet = new Set(checkpoint.completedTables);
1184
+ const snapshotId = checkpoint.snapshotId;
1185
+ const siteId = checkpoint.siteId;
1186
+ const hlc = checkpoint.hlc;
1187
+ // Count tables and migrations for header
1188
+ const tableKeys = new Set();
1189
+ const cvBounds = buildAllColumnVersionsScanBounds();
1190
+ for await (const entry of this.kv.iterate(cvBounds)) {
1191
+ const parsed = parseColumnVersionKey(entry.key);
1192
+ if (parsed)
1193
+ tableKeys.add(`${parsed.schema}.${parsed.table}`);
1194
+ }
1195
+ let migrationCount = 0;
1196
+ const smBounds = buildAllSchemaMigrationsScanBounds();
1197
+ for await (const _entry of this.kv.iterate(smBounds)) {
1198
+ migrationCount++;
1199
+ }
1200
+ // Yield header (receiver needs to know this is a resume)
1201
+ const header = {
1202
+ type: 'header',
1203
+ siteId,
1204
+ hlc,
1205
+ tableCount: tableKeys.size,
1206
+ migrationCount,
1207
+ snapshotId,
1208
+ };
1209
+ yield header;
1210
+ // Stream each table, skipping completed ones
1211
+ let totalEntries = checkpoint.entriesProcessed;
1212
+ for (const tableKey of tableKeys) {
1213
+ // Skip already completed tables
1214
+ if (completedSet.has(tableKey))
1215
+ continue;
1216
+ const [schema, table] = tableKey.split('.');
1217
+ // Count entries for this table
1218
+ let tableEntryCount = 0;
1219
+ const tableCvBounds = buildAllColumnVersionsScanBounds();
1220
+ for await (const entry of this.kv.iterate(tableCvBounds)) {
1221
+ const parsed = parseColumnVersionKey(entry.key);
1222
+ if (parsed && parsed.schema === schema && parsed.table === table) {
1223
+ tableEntryCount++;
1224
+ }
1225
+ }
1226
+ // Yield table start
1227
+ const tableStart = {
1228
+ type: 'table-start',
1229
+ schema,
1230
+ table,
1231
+ estimatedEntries: tableEntryCount,
1232
+ };
1233
+ yield tableStart;
1234
+ // Stream column versions in chunks
1235
+ let entries = [];
1236
+ let entriesWritten = 0;
1237
+ const chunkSize = DEFAULT_SNAPSHOT_CHUNK_SIZE;
1238
+ for await (const entry of this.kv.iterate(tableCvBounds)) {
1239
+ const parsed = parseColumnVersionKey(entry.key);
1240
+ if (!parsed || parsed.schema !== schema || parsed.table !== table)
1241
+ continue;
1242
+ const cv = deserializeColumnVersion(entry.value);
1243
+ const versionKey = `${encodePK(parsed.pk)}:${parsed.column}`;
1244
+ entries.push([versionKey, cv.hlc, cv.value]);
1245
+ entriesWritten++;
1246
+ if (entries.length >= chunkSize) {
1247
+ const chunk = {
1248
+ type: 'column-versions',
1249
+ schema,
1250
+ table,
1251
+ entries,
1252
+ };
1253
+ yield chunk;
1254
+ entries = [];
1255
+ }
1256
+ }
1257
+ // Yield remaining entries
1258
+ if (entries.length > 0) {
1259
+ const chunk = {
1260
+ type: 'column-versions',
1261
+ schema,
1262
+ table,
1263
+ entries,
1264
+ };
1265
+ yield chunk;
1266
+ }
1267
+ // Yield table end
1268
+ const tableEnd = {
1269
+ type: 'table-end',
1270
+ schema,
1271
+ table,
1272
+ entriesWritten,
1273
+ };
1274
+ yield tableEnd;
1275
+ totalEntries += entriesWritten;
1276
+ }
1277
+ // Stream schema migrations
1278
+ for await (const entry of this.kv.iterate(smBounds)) {
1279
+ const parsed = parseSchemaMigrationKey(entry.key);
1280
+ if (!parsed)
1281
+ continue;
1282
+ const migration = deserializeMigration(entry.value);
1283
+ const migrationChunk = {
1284
+ type: 'schema-migration',
1285
+ migration: {
1286
+ type: migration.type,
1287
+ schema: parsed.schema,
1288
+ table: parsed.table,
1289
+ ddl: migration.ddl,
1290
+ hlc: migration.hlc,
1291
+ schemaVersion: migration.schemaVersion,
1292
+ },
1293
+ };
1294
+ yield migrationChunk;
1295
+ }
1296
+ // Yield footer
1297
+ const footer = {
1298
+ type: 'footer',
1299
+ snapshotId,
1300
+ totalTables: tableKeys.size,
1301
+ totalEntries,
1302
+ totalMigrations: migrationCount,
1303
+ };
1304
+ yield footer;
1305
+ }
1306
+ }
1307
+ //# sourceMappingURL=sync-manager-impl.js.map