@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.
- package/README.md +155 -0
- package/dist/src/clock/hlc.d.ts +105 -0
- package/dist/src/clock/hlc.d.ts.map +1 -0
- package/dist/src/clock/hlc.js +251 -0
- package/dist/src/clock/hlc.js.map +1 -0
- package/dist/src/clock/index.d.ts +6 -0
- package/dist/src/clock/index.d.ts.map +1 -0
- package/dist/src/clock/index.js +6 -0
- package/dist/src/clock/index.js.map +1 -0
- package/dist/src/clock/site.d.ts +58 -0
- package/dist/src/clock/site.d.ts.map +1 -0
- package/dist/src/clock/site.js +137 -0
- package/dist/src/clock/site.js.map +1 -0
- package/dist/src/create-sync-module.d.ts +85 -0
- package/dist/src/create-sync-module.d.ts.map +1 -0
- package/dist/src/create-sync-module.js +54 -0
- package/dist/src/create-sync-module.js.map +1 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +42 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/metadata/change-log.d.ts +67 -0
- package/dist/src/metadata/change-log.d.ts.map +1 -0
- package/dist/src/metadata/change-log.js +107 -0
- package/dist/src/metadata/change-log.js.map +1 -0
- package/dist/src/metadata/column-version.d.ts +58 -0
- package/dist/src/metadata/column-version.d.ts.map +1 -0
- package/dist/src/metadata/column-version.js +100 -0
- package/dist/src/metadata/column-version.js.map +1 -0
- package/dist/src/metadata/index.d.ts +11 -0
- package/dist/src/metadata/index.d.ts.map +1 -0
- package/dist/src/metadata/index.js +11 -0
- package/dist/src/metadata/index.js.map +1 -0
- package/dist/src/metadata/keys.d.ts +180 -0
- package/dist/src/metadata/keys.d.ts.map +1 -0
- package/dist/src/metadata/keys.js +390 -0
- package/dist/src/metadata/keys.js.map +1 -0
- package/dist/src/metadata/peer-state.d.ts +52 -0
- package/dist/src/metadata/peer-state.d.ts.map +1 -0
- package/dist/src/metadata/peer-state.js +87 -0
- package/dist/src/metadata/peer-state.js.map +1 -0
- package/dist/src/metadata/schema-migration.d.ts +60 -0
- package/dist/src/metadata/schema-migration.d.ts.map +1 -0
- package/dist/src/metadata/schema-migration.js +126 -0
- package/dist/src/metadata/schema-migration.js.map +1 -0
- package/dist/src/metadata/schema-version.d.ts +163 -0
- package/dist/src/metadata/schema-version.d.ts.map +1 -0
- package/dist/src/metadata/schema-version.js +307 -0
- package/dist/src/metadata/schema-version.js.map +1 -0
- package/dist/src/metadata/tombstones.d.ts +67 -0
- package/dist/src/metadata/tombstones.d.ts.map +1 -0
- package/dist/src/metadata/tombstones.js +125 -0
- package/dist/src/metadata/tombstones.js.map +1 -0
- package/dist/src/sync/events.d.ts +117 -0
- package/dist/src/sync/events.d.ts.map +1 -0
- package/dist/src/sync/events.js +56 -0
- package/dist/src/sync/events.js.map +1 -0
- package/dist/src/sync/index.d.ts +8 -0
- package/dist/src/sync/index.d.ts.map +1 -0
- package/dist/src/sync/index.js +8 -0
- package/dist/src/sync/index.js.map +1 -0
- package/dist/src/sync/manager.d.ts +146 -0
- package/dist/src/sync/manager.d.ts.map +1 -0
- package/dist/src/sync/manager.js +8 -0
- package/dist/src/sync/manager.js.map +1 -0
- package/dist/src/sync/protocol.d.ts +282 -0
- package/dist/src/sync/protocol.d.ts.map +1 -0
- package/dist/src/sync/protocol.js +16 -0
- package/dist/src/sync/protocol.js.map +1 -0
- package/dist/src/sync/store-adapter.d.ts +42 -0
- package/dist/src/sync/store-adapter.d.ts.map +1 -0
- package/dist/src/sync/store-adapter.js +232 -0
- package/dist/src/sync/store-adapter.js.map +1 -0
- package/dist/src/sync/sync-manager-impl.d.ts +106 -0
- package/dist/src/sync/sync-manager-impl.d.ts.map +1 -0
- package/dist/src/sync/sync-manager-impl.js +1307 -0
- package/dist/src/sync/sync-manager-impl.js.map +1 -0
- 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
|