@sqlite-sync/core 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2018 @@
1
+ import { a as ensureSingletonExecution, f as tryCatchAsync, i as createTypedEventTarget, o as generateId, t as TypedBroadcastChannel, u as quoteId } from "./utils-Dr5JwcTP.js";
2
+ import { d as CRDT_EVENT_NO_OP_PAYLOAD, f as isNoOpCrdtEventPayload, u as createStoredValue } from "./system-schema-CkglcfTS.js";
3
+ import { DummyDriver, Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from "kysely";
4
+ import initXxhash from "xxhash-wasm";
5
+ import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
6
+ import { createStore, del, get, set } from "idb-keyval";
7
+ //#region src/dummy-kysely.ts
8
+ const dummyKysely = new Kysely({ dialect: {
9
+ createAdapter: () => new SqliteAdapter(),
10
+ createDriver: () => new DummyDriver(),
11
+ createQueryCompiler: () => new SqliteQueryCompiler(),
12
+ createIntrospector: (db) => new SqliteIntrospector(db)
13
+ } });
14
+ //#endregion
15
+ //#region src/export-import.ts
16
+ function createTableNameNormalizer(tablesConfig) {
17
+ const baseNamesByAlias = /* @__PURE__ */ new Map();
18
+ for (const { baseTableName, crdtTableName } of tablesConfig ?? []) {
19
+ baseNamesByAlias.set(baseTableName, baseTableName);
20
+ baseNamesByAlias.set(crdtTableName, baseTableName);
21
+ }
22
+ return (tableName) => baseNamesByAlias.get(tableName) ?? tableName;
23
+ }
24
+ function createImportData({ migrator, tablesConfig, applyEvents }) {
25
+ const normalizeTableName = createTableNameNormalizer(tablesConfig);
26
+ /**
27
+ * Replay an export as a sequence of `item-created` CRDT events, seeding the
28
+ * rows through the provided CRDT event applicator.
29
+ *
30
+ * Schema migration: a dump exported at an older schema version is forward-
31
+ * migrated up to the DB's current version (running the same event transformers
32
+ * remote events go through) before being authored — so an old backup can be
33
+ * restored into an upgraded app. Rows in tables removed by a later migration
34
+ * are dropped. The reverse (a dump from a *newer* version than this DB) cannot
35
+ * be down-migrated and is rejected.
36
+ *
37
+ * Overwrite-by-default: because the generated events carry fresh (newest) HLC
38
+ * timestamps, importing a row whose `id` already exists overwrites every field
39
+ * under last-write-wins, while new ids are inserted. Existing rows absent from
40
+ * the dump are never touched. This is a restore/seed, not a CRDT merge —
41
+ * original timestamps are not preserved.
42
+ *
43
+ * @param opts.validate When `false`, skip the too-new guard and author the rows
44
+ * as-is at the current version (footgun — payloads are not down-migrated).
45
+ * Forward migration and per-row payload validation always run regardless.
46
+ */
47
+ return (data, opts) => {
48
+ const validate = opts?.validate ?? true;
49
+ const currentVersion = migrator.currentSchemaVersion;
50
+ if (validate && data.schemaVersion > currentVersion) throw new Error(`Cannot import data from schema version ${data.schemaVersion} into a database at older schema version ${currentVersion}. Exports migrate forward to newer schema versions, not backward.`);
51
+ const sourceEvents = [];
52
+ for (const [tableName, rows] of Object.entries(data.tables)) for (const row of rows) sourceEvents.push({
53
+ schema_version: data.schemaVersion,
54
+ type: "item-created",
55
+ dataset: normalizeTableName(tableName),
56
+ item_id: row.id,
57
+ payload: JSON.stringify(row)
58
+ });
59
+ const events = migrator.migrateEvents(sourceEvents, currentVersion).map((event) => ({
60
+ type: event.type,
61
+ dataset: event.dataset,
62
+ item_id: event.item_id,
63
+ payload: event.payload
64
+ }));
65
+ const applied = applyEvents(events);
66
+ if (applied instanceof Promise) return applied.then(() => ({ imported: events.length }));
67
+ return { imported: events.length };
68
+ };
69
+ }
70
+ function createExportData({ reactiveDb, tablesConfig, schemaVersion }) {
71
+ const tableNames = tablesConfig.flatMap((config) => [config.crdtTableName, config.baseTableName]);
72
+ const resolveTables = (requested) => {
73
+ if (!requested) return tablesConfig;
74
+ const seenBaseTableNames = /* @__PURE__ */ new Set();
75
+ const resolved = [];
76
+ for (const name of requested) {
77
+ const config = tablesConfig.find((tableConfig) => tableConfig.crdtTableName === name || tableConfig.baseTableName === name);
78
+ if (!config) throw new Error(`Unknown table "${name}". Known synced tables: ${tableNames.join(", ")}`);
79
+ if (!seenBaseTableNames.has(config.baseTableName)) {
80
+ seenBaseTableNames.add(config.baseTableName);
81
+ resolved.push(config);
82
+ }
83
+ }
84
+ return resolved;
85
+ };
86
+ /**
87
+ * Dump every active entity (the `tombstone = 0` rows) from the synced tables.
88
+ *
89
+ * Reads this tab's reactive (in-memory) DB — the state the user currently sees,
90
+ * which may differ slightly from the worker's authoritative DB while local
91
+ * writes are in flight.
92
+ */
93
+ return (opts) => {
94
+ const tables = {};
95
+ for (const { baseTableName, crdtTableName } of resolveTables(opts?.tables)) {
96
+ const { rows } = reactiveDb.db.execute(`select * from ${quoteId(crdtTableName)}`, { loggerLevel: "system" });
97
+ tables[baseTableName] = rows.map(({ tombstone: _tombstone, ...rest }) => rest);
98
+ }
99
+ return {
100
+ schemaVersion,
101
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
102
+ tables
103
+ };
104
+ };
105
+ }
106
+ //#endregion
107
+ //#region src/hash.ts
108
+ let loadPromise = null;
109
+ let api = null;
110
+ function ensureLoaded() {
111
+ if (!loadPromise) loadPromise = initXxhash().then((hasher) => {
112
+ api = hasher;
113
+ });
114
+ return loadPromise;
115
+ }
116
+ function h64(input, seed = 0n) {
117
+ if (!api) throw new Error("xxhash is not initialized; call xxhash.ensureLoaded() first");
118
+ return api.h64(input, seed);
119
+ }
120
+ const xxhash = {
121
+ ensureLoaded,
122
+ h64
123
+ };
124
+ //#endregion
125
+ //#region src/hlc.ts
126
+ const MAX_COUNTER = 36 ** 5 - 1;
127
+ const DEFAULT_MAX_DRIFT_MS = 216e5;
128
+ var HLCCounter = class {
129
+ timestamp;
130
+ counter;
131
+ nodeId;
132
+ getTimestamp;
133
+ maxDrift;
134
+ constructor(nodeId, getTimestamp, maxDrift = DEFAULT_MAX_DRIFT_MS) {
135
+ this.timestamp = getTimestamp();
136
+ this.counter = 0;
137
+ this.nodeId = nodeId;
138
+ this.getTimestamp = getTimestamp;
139
+ this.maxDrift = maxDrift;
140
+ }
141
+ getCurrentHLC() {
142
+ return {
143
+ timestamp: this.timestamp,
144
+ counter: this.counter,
145
+ nodeId: this.nodeId
146
+ };
147
+ }
148
+ getNextHLC() {
149
+ const now = this.getTimestamp();
150
+ if (now > this.timestamp) {
151
+ this.timestamp = now;
152
+ this.counter = 0;
153
+ return this.getCurrentHLC();
154
+ }
155
+ this.counter++;
156
+ if (this.counter > MAX_COUNTER) throw new Error(`HLC counter overflow: exceeded max value ${MAX_COUNTER}`);
157
+ return this.getCurrentHLC();
158
+ }
159
+ mergeHLC(hlc) {
160
+ const now = this.getTimestamp();
161
+ if (hlc.timestamp - now > this.maxDrift) {
162
+ console.warn(`HLC: ignoring far-future timestamp (remote=${hlc.timestamp}, local=${now}, drift=${hlc.timestamp - now}ms)`);
163
+ return;
164
+ }
165
+ if (this.timestamp === hlc.timestamp) this.counter = Math.max(this.counter, hlc.counter) + 1;
166
+ else if (this.timestamp > hlc.timestamp) this.counter++;
167
+ else {
168
+ this.timestamp = hlc.timestamp;
169
+ this.counter = hlc.counter + 1;
170
+ }
171
+ if (this.counter > MAX_COUNTER) throw new Error(`HLC counter overflow: exceeded max value ${MAX_COUNTER}`);
172
+ }
173
+ };
174
+ function serializeHLC(hlc) {
175
+ return `${hlc.timestamp.toString().padStart(15, "0")}:${hlc.counter.toString(36).padStart(5, "0")}:${hlc.nodeId}`;
176
+ }
177
+ function deserializeHLC(serialized) {
178
+ const parts = serialized.split(":");
179
+ if (parts.length < 3) throw new Error(`Invalid HLC format: expected at least 3 colon-separated segments, got ${parts.length}`);
180
+ const timestamp = parseInt(parts[0], 10);
181
+ const counter = parseInt(parts[1], 36);
182
+ if (Number.isNaN(timestamp) || Number.isNaN(counter)) throw new Error(`Invalid HLC values: timestamp=${parts[0]}, counter=${parts[1]}`);
183
+ return {
184
+ timestamp,
185
+ counter,
186
+ nodeId: parts.slice(2).join(":")
187
+ };
188
+ }
189
+ function compareHLC(one, two) {
190
+ if (one.timestamp === two.timestamp) {
191
+ if (one.counter === two.counter) {
192
+ if (one.nodeId === two.nodeId) return 0;
193
+ return one.nodeId < two.nodeId ? -1 : 1;
194
+ }
195
+ return one.counter - two.counter;
196
+ }
197
+ return one.timestamp - two.timestamp;
198
+ }
199
+ //#endregion
200
+ //#region src/introspection.ts
201
+ function tablesQuery(qb) {
202
+ return qb.selectFrom("sqlite_master").where("type", "in", ["table", "view"]).where("name", "not like", "sqlite_%").select([
203
+ "name",
204
+ "sql",
205
+ "type"
206
+ ]).orderBy("name");
207
+ }
208
+ function introspectDb(_db) {
209
+ const db = _db;
210
+ const tables = db.executeKysely((db) => tablesQuery(db), { loggerLevel: "system" }).rows;
211
+ const tablesMetadata = db.executeKysely((db) => db.with("table_list", (qb) => tablesQuery(qb)).selectFrom(["table_list as tl", sql`pragma_table_info(tl.name)`.as("p")]).select([
212
+ "tl.name as table",
213
+ "p.cid",
214
+ "p.name",
215
+ "p.type",
216
+ "p.notnull",
217
+ "p.dflt_value",
218
+ "p.pk"
219
+ ]).orderBy("tl.name").orderBy("p.cid"), { loggerLevel: "system" }).rows;
220
+ const columnsByTable = {};
221
+ for (const row of tablesMetadata) {
222
+ columnsByTable[row.table] ??= [];
223
+ columnsByTable[row.table].push(row);
224
+ }
225
+ return Object.fromEntries(tables.map(({ name, sql, type }) => {
226
+ let autoIncrementCol = sql?.split(/[(),]/)?.find((it) => it.toLowerCase().includes("autoincrement"))?.trimStart()?.split(/\s+/)?.[0]?.replace(/["`]/g, "");
227
+ const columns = columnsByTable[name] ?? [];
228
+ if (!autoIncrementCol) {
229
+ const pkCols = columns.filter((r) => r.pk > 0);
230
+ if (pkCols.length === 1 && pkCols[0].type.toLowerCase() === "integer") autoIncrementCol = pkCols[0].name;
231
+ }
232
+ return [name, {
233
+ name,
234
+ isView: type === "view",
235
+ columns: columns.map((col) => ({
236
+ name: col.name,
237
+ dataType: col.type,
238
+ isNullable: !col.notnull,
239
+ isAutoIncrementing: col.name === autoIncrementCol,
240
+ hasDefaultValue: col.dflt_value != null,
241
+ defaultValueSql: col.dflt_value == null ? null : String(col.dflt_value),
242
+ isPrimaryKey: col.pk > 0,
243
+ comment: void 0
244
+ }))
245
+ }];
246
+ }));
247
+ }
248
+ //#endregion
249
+ //#region src/logger.ts
250
+ const noopPerformanceLogger = {
251
+ restart() {},
252
+ logEnd() {}
253
+ };
254
+ const startPerformanceLogger = (logger, level = "info") => {
255
+ if (!logger || level === "system") return noopPerformanceLogger;
256
+ let startTime = performance.now();
257
+ return {
258
+ restart: () => {
259
+ startTime = performance.now();
260
+ },
261
+ logEnd: (type, message, logLevel = level) => {
262
+ logger(type, `${(performance.now() - startTime).toFixed(2)}ms - ${message}`, logLevel);
263
+ }
264
+ };
265
+ };
266
+ //#endregion
267
+ //#region src/sqlite-db-wrapper.ts
268
+ var SQLiteDbWrapper = class {
269
+ db = null;
270
+ sqlite3;
271
+ logger;
272
+ loggerPrefix;
273
+ loadedDbSchema = null;
274
+ dataPointers = [];
275
+ preparedStatements = [];
276
+ preparedStatementsMap = /* @__PURE__ */ new Map();
277
+ preparedRawStatementsMap = /* @__PURE__ */ new Map();
278
+ afterMutatingStatement = null;
279
+ constructor(opts) {
280
+ this.db = opts.db();
281
+ this.sqlite3 = opts.sqlite3;
282
+ this.logger = opts.logger;
283
+ this.loggerPrefix = opts.loggerPrefix;
284
+ }
285
+ get ensureDb() {
286
+ if (!this.db) throw new Error("Database is already closed");
287
+ return this.db;
288
+ }
289
+ get dbSchema() {
290
+ if (!this.loadedDbSchema) this.loadedDbSchema = introspectDb(this);
291
+ return this.loadedDbSchema;
292
+ }
293
+ executeSql(sql, bind, loggerLevel, skipAfterMutatingStatement) {
294
+ const perf = startPerformanceLogger(this.logger, loggerLevel);
295
+ const statement = this.ensureDb.prepare(sql);
296
+ let transaction;
297
+ try {
298
+ const statementPointer = statement.pointer;
299
+ if (!statementPointer) throw new Error("Prepared statement is already finalized");
300
+ const afterMutatingStatement = !skipAfterMutatingStatement && this.afterMutatingStatement && this.sqlite3.capi.sqlite3_stmt_readonly(statementPointer) === 0 ? this.afterMutatingStatement : null;
301
+ let totalChangesBefore = 0;
302
+ if (afterMutatingStatement) {
303
+ transaction = this.beginStatementTransaction();
304
+ totalChangesBefore = this.sqlite3.capi.sqlite3_total_changes(this.ensureDb);
305
+ }
306
+ if (bind && bind.length > 0) statement.bind(bind);
307
+ const rows = [];
308
+ let columnNames;
309
+ while (statement.step()) {
310
+ columnNames ??= statement.getColumnNames([]);
311
+ const values = statement.get([]);
312
+ const row = Object.create(null);
313
+ for (let index = 0; index < columnNames.length; index++) row[columnNames[index]] = values[index];
314
+ rows.push(row);
315
+ }
316
+ statement.finalize();
317
+ perf.logEnd(`${this.loggerPrefix ?? ""}:query`, sql, loggerLevel);
318
+ if (transaction) {
319
+ if (this.sqlite3.capi.sqlite3_total_changes(this.ensureDb) !== totalChangesBefore) afterMutatingStatement?.(this);
320
+ transaction.commit();
321
+ }
322
+ return { rows };
323
+ } catch (error) {
324
+ statement.finalize();
325
+ transaction?.rollback();
326
+ throw error;
327
+ }
328
+ }
329
+ execute(opts, options) {
330
+ const sql = typeof opts === "string" ? opts : opts.sql;
331
+ const bind = typeof opts === "string" ? void 0 : opts.parameters;
332
+ return this.executeSql(sql, bind, options?.loggerLevel, options?.skipAfterMutatingStatement ?? false);
333
+ }
334
+ setAfterMutatingStatement(callback) {
335
+ if (this.afterMutatingStatement) throw new Error("An afterMutatingStatement callback is already registered");
336
+ this.afterMutatingStatement = callback;
337
+ }
338
+ executeTransaction(callback) {
339
+ const transaction = this.beginTransaction();
340
+ try {
341
+ const result = callback(this);
342
+ transaction.commit();
343
+ return result;
344
+ } catch (error) {
345
+ transaction.rollback();
346
+ throw error;
347
+ }
348
+ }
349
+ isInTransaction() {
350
+ return this.sqlite3.capi.sqlite3_get_autocommit(this.ensureDb) === 0;
351
+ }
352
+ beginTransaction() {
353
+ this.executePreparedRaw({
354
+ key: "$begin-transaction",
355
+ sql: "begin",
356
+ meta: { loggerLevel: "system" }
357
+ });
358
+ return {
359
+ commit: () => {
360
+ this.executePreparedRaw({
361
+ key: "$commit-transaction",
362
+ sql: "commit",
363
+ meta: { loggerLevel: "system" }
364
+ });
365
+ },
366
+ rollback: () => {
367
+ this.executePreparedRaw({
368
+ key: "$rollback-transaction",
369
+ sql: "rollback",
370
+ meta: { loggerLevel: "system" }
371
+ });
372
+ }
373
+ };
374
+ }
375
+ beginStatementTransaction() {
376
+ if (!this.isInTransaction()) return this.beginTransaction();
377
+ this.executePreparedRaw({
378
+ key: "$begin-statement-savepoint",
379
+ sql: "savepoint sqlite_sync_statement",
380
+ meta: { loggerLevel: "system" }
381
+ });
382
+ return {
383
+ commit: () => {
384
+ this.executePreparedRaw({
385
+ key: "$commit-statement-savepoint",
386
+ sql: "release savepoint sqlite_sync_statement",
387
+ meta: { loggerLevel: "system" }
388
+ });
389
+ },
390
+ rollback: () => {
391
+ this.executePreparedRaw({
392
+ key: "$rollback-statement-savepoint",
393
+ sql: "rollback to savepoint sqlite_sync_statement",
394
+ meta: { loggerLevel: "system" }
395
+ });
396
+ this.executePreparedRaw({
397
+ key: "$release-rolled-back-statement-savepoint",
398
+ sql: "release savepoint sqlite_sync_statement",
399
+ meta: { loggerLevel: "system" }
400
+ });
401
+ }
402
+ };
403
+ }
404
+ prepare(sql, opts) {
405
+ const perf = startPerformanceLogger(this.logger, opts?.loggerLevel);
406
+ const stmt = this.ensureDb.prepare(sql);
407
+ perf.logEnd(`${this.loggerPrefix ?? ""}:prepare`, sql, opts?.loggerLevel);
408
+ let isFinalized = false;
409
+ const execute = (params) => {
410
+ if (isFinalized) throw new Error("Statement is finalized");
411
+ const perf = startPerformanceLogger(this.logger, opts?.loggerLevel);
412
+ try {
413
+ if (params.length > 0) stmt.bind(params);
414
+ const results = [];
415
+ let columnNames;
416
+ while (stmt.step()) {
417
+ columnNames ??= stmt.getColumnNames([]);
418
+ const values = stmt.get([]);
419
+ const row = {};
420
+ for (let index = 0; index < columnNames.length; index++) row[columnNames[index]] = values[index];
421
+ results.push(row);
422
+ }
423
+ return results;
424
+ } finally {
425
+ stmt.reset(true);
426
+ perf.logEnd(`${this.loggerPrefix ?? ""}:prepare-execute`, sql, opts?.loggerLevel);
427
+ }
428
+ };
429
+ const finalize = () => {
430
+ isFinalized = true;
431
+ stmt.finalize();
432
+ };
433
+ const preparedStatement = {
434
+ execute,
435
+ finalize,
436
+ get isFinalized() {
437
+ return isFinalized;
438
+ }
439
+ };
440
+ this.preparedStatements.push(preparedStatement);
441
+ return preparedStatement;
442
+ }
443
+ prepareKysely(opts) {
444
+ return (factory) => {
445
+ const query = factory(dummyKysely, (key) => key).compile();
446
+ const statement = this.prepare(query.sql, opts);
447
+ return { execute: (parameters) => {
448
+ const params = query.parameters.map((param) => parameters[param]);
449
+ return statement.execute(params);
450
+ } };
451
+ };
452
+ }
453
+ executeKysely(factory, options) {
454
+ const query = factory(dummyKysely).compile();
455
+ return this.execute(query, options);
456
+ }
457
+ executePrepared(key, params, factory, meta) {
458
+ let statement = this.preparedStatementsMap.get(key);
459
+ if (!statement) {
460
+ statement = this.prepareKysely(meta)(factory);
461
+ this.preparedStatementsMap.set(key, statement);
462
+ }
463
+ return statement.execute(params);
464
+ }
465
+ executePreparedRaw({ key, sql, params, meta }) {
466
+ let statement = this.preparedRawStatementsMap.get(key);
467
+ if (!statement) {
468
+ statement = this.prepare(sql, meta);
469
+ this.preparedRawStatementsMap.set(key, statement);
470
+ }
471
+ return statement.execute(params ?? []);
472
+ }
473
+ sql(templateOrString, ...parameters) {
474
+ if (typeof templateOrString === "string") return this.execute({
475
+ sql: templateOrString,
476
+ parameters
477
+ });
478
+ return this.execute({
479
+ sql: templateOrString.join("?"),
480
+ parameters
481
+ });
482
+ }
483
+ createScalarFunction({ name, callback, deterministic, directOnly, innocuous }) {
484
+ return this.ensureDb.createFunction({
485
+ name,
486
+ xFunc: (_, ...args) => {
487
+ return callback(...args);
488
+ },
489
+ arity: callback.length,
490
+ deterministic,
491
+ directOnly,
492
+ innocuous
493
+ });
494
+ }
495
+ useSnapshot(snapshot) {
496
+ const perf = startPerformanceLogger(this.logger, "system");
497
+ const dataPointer = this.sqlite3.wasm.allocFromTypedArray(snapshot);
498
+ this.dataPointers.push(dataPointer);
499
+ const resultCode = this.sqlite3.capi.sqlite3_deserialize(this.ensureDb, "main", dataPointer, snapshot.byteLength, snapshot.byteLength, this.sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE | this.sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE);
500
+ this.ensureDb.checkRc(resultCode);
501
+ this.invalidateDbSchema();
502
+ perf.logEnd("useSnapshot", "success", "system");
503
+ }
504
+ createSnapshot() {
505
+ return this.sqlite3.capi.sqlite3_js_db_export(this.ensureDb);
506
+ }
507
+ invalidateDbSchema() {
508
+ this.loadedDbSchema = null;
509
+ }
510
+ cleanup() {
511
+ this.preparedStatements.forEach((stmt) => {
512
+ stmt.finalize();
513
+ });
514
+ this.preparedStatements.splice(0);
515
+ this.preparedStatementsMap.clear();
516
+ this.preparedRawStatementsMap.clear();
517
+ }
518
+ close() {
519
+ this.cleanup();
520
+ this.db?.close();
521
+ this.db = null;
522
+ }
523
+ };
524
+ //#endregion
525
+ //#region src/migrations/migrator.ts
526
+ const protectedColumns = ["id", "tombstone"];
527
+ function assertColumnNotProtected(column, operation) {
528
+ if (protectedColumns.includes(column)) throw new Error(`Cannot ${operation} protected column "${column}"`);
529
+ }
530
+ const migrationSteps = {
531
+ createTable: (table, build) => ({ sql: (db) => build(db.schema.createTable(table)) }),
532
+ dropTable: (table) => ({
533
+ sql: (db) => db.schema.dropTable(table),
534
+ eventTransformer: { [table]: () => null },
535
+ updateLogMutations: [{
536
+ type: "drop-table",
537
+ table
538
+ }]
539
+ }),
540
+ createIndex: (indexName, build) => ({ sql: (db) => build(db.schema.createIndex(indexName)) }),
541
+ dropIndex: (indexName) => ({ sql: (db) => db.schema.dropIndex(indexName) }),
542
+ renameTable: ({ oldTable, newTable }) => ({
543
+ sql: (db) => db.schema.alterTable(oldTable).renameTo(newTable),
544
+ eventTransformer: { [oldTable]: (event) => {
545
+ event.dataset = newTable;
546
+ return event;
547
+ } },
548
+ updateLogMutations: [{
549
+ type: "rename-table",
550
+ oldTable,
551
+ newTable
552
+ }]
553
+ }),
554
+ renameColumn: ({ table, oldColumn, newColumn }) => {
555
+ assertColumnNotProtected(oldColumn, "rename");
556
+ assertColumnNotProtected(newColumn, "rename to");
557
+ return {
558
+ sql: (db) => db.schema.alterTable(table).renameColumn(oldColumn, newColumn),
559
+ eventTransformer: { [table]: (event) => {
560
+ if (event.type !== "item-updated" && event.type !== "item-created" || !(oldColumn in event.payload)) return event;
561
+ const oldVal = event.payload[oldColumn];
562
+ delete event.payload[oldColumn];
563
+ event.payload[newColumn] = oldVal;
564
+ return event;
565
+ } },
566
+ updateLogMutations: [{
567
+ type: "rename-column",
568
+ table,
569
+ oldColumn,
570
+ newColumn
571
+ }]
572
+ };
573
+ },
574
+ addColumn: ({ table, column, type, defaultValue, build = (e) => e }) => ({
575
+ sql: (db) => db.schema.alterTable(table).addColumn(column, type, (x) => build(x).defaultTo(defaultValue)),
576
+ eventTransformer: { [table]: (event) => {
577
+ if (event.type !== "item-created") return event;
578
+ event.payload[column] = defaultValue;
579
+ return event;
580
+ } },
581
+ updateLogMutations: [{
582
+ type: "add-column",
583
+ table,
584
+ column
585
+ }]
586
+ }),
587
+ dropColumn: ({ table, column }) => {
588
+ assertColumnNotProtected(column, "drop");
589
+ return {
590
+ sql: (db) => db.schema.alterTable(table).dropColumn(column),
591
+ eventTransformer: { [table]: (event) => {
592
+ if (!(column in event.payload)) return event;
593
+ delete event.payload[column];
594
+ if (event.type === "item-updated" && Object.keys(event.payload).length === 0) return null;
595
+ return event;
596
+ } },
597
+ updateLogMutations: [{
598
+ type: "drop-column",
599
+ table,
600
+ column
601
+ }]
602
+ };
603
+ }
604
+ };
605
+ function buildMigrationSql(steps) {
606
+ return steps.flatMap((step) => Array.isArray(step.sql) ? step.sql : [step.sql]).map((sql) => {
607
+ if (typeof sql === "string") return {
608
+ sql,
609
+ parameters: []
610
+ };
611
+ if (typeof sql === "function") {
612
+ const query = sql(dummyKysely).compile();
613
+ return {
614
+ sql: query.sql,
615
+ parameters: query.parameters
616
+ };
617
+ }
618
+ if ("compile" in sql) {
619
+ const query = sql.compile();
620
+ return {
621
+ sql: query.sql,
622
+ parameters: query.parameters
623
+ };
624
+ }
625
+ return {
626
+ sql: sql.sql,
627
+ parameters: sql.parameters ?? []
628
+ };
629
+ });
630
+ }
631
+ function buildMigrationEventTransformer(steps) {
632
+ const transformers = [];
633
+ for (const step of steps) if (step.eventTransformer) transformers.push(...Object.entries(step.eventTransformer));
634
+ if (transformers.length === 0) return;
635
+ return (event) => {
636
+ let transformedEvent = event;
637
+ for (const [table, transformer] of transformers) {
638
+ if (transformedEvent === null) return null;
639
+ if (transformedEvent.dataset !== table) continue;
640
+ transformedEvent = transformer(transformedEvent);
641
+ if (transformedEvent === null) return null;
642
+ }
643
+ return transformedEvent;
644
+ };
645
+ }
646
+ function createMigrations(buildMigrations) {
647
+ return Object.fromEntries(Object.entries(buildMigrations(migrationSteps)).map(([version, steps]) => {
648
+ const versionNumber = Number(version);
649
+ if (Number.isNaN(versionNumber)) throw new Error(`Invalid migration version: ${version}`);
650
+ if (versionNumber < 0) throw new Error(`Migration version cannot be negative: ${version}`);
651
+ const updateLogMutations = steps.flatMap((step) => step.updateLogMutations ?? []);
652
+ return [version, {
653
+ sql: buildMigrationSql(steps),
654
+ eventTransformer: buildMigrationEventTransformer(steps),
655
+ ...updateLogMutations.length > 0 && { updateLogMutations }
656
+ }];
657
+ }));
658
+ }
659
+ function createMigrator({ migrations, schemaVersion, updateLogTableName }) {
660
+ const latestSchemaVersion = Math.max(...Object.keys(migrations).map(Number));
661
+ const sortedMigrations = Object.entries(migrations).map(([v, m]) => [Number(v), m]).sort((a, b) => a[0] - b[0]);
662
+ const applyMigration = (db, version, migration) => {
663
+ if (version <= schemaVersion.current) throw new Error(`Cannot apply migration ${version} to schema version ${schemaVersion.current}`);
664
+ db.startTransaction((tx) => {
665
+ for (const statement of migration.sql) tx.execute(statement.sql, statement.parameters);
666
+ if (updateLogTableName && migration.updateLogMutations) for (const mutation of migration.updateLogMutations) switch (mutation.type) {
667
+ case "rename-table":
668
+ tx.execute(`DELETE FROM ${updateLogTableName} WHERE "dataset" = ?`, [mutation.newTable], { loggerLevel: "system" });
669
+ tx.execute(`UPDATE ${updateLogTableName} SET "dataset" = ? WHERE "dataset" = ?`, [mutation.newTable, mutation.oldTable], { loggerLevel: "system" });
670
+ break;
671
+ case "drop-table":
672
+ tx.execute(`DELETE FROM ${updateLogTableName} WHERE "dataset" = ?`, [mutation.table], { loggerLevel: "system" });
673
+ break;
674
+ case "rename-column":
675
+ tx.execute(`UPDATE ${updateLogTableName}
676
+ SET "payload" = COALESCE(
677
+ (
678
+ SELECT json_group_object(CASE WHEN "key" = ? THEN ? ELSE "key" END, "value")
679
+ FROM json_each("payload")
680
+ WHERE "key" != ?
681
+ ),
682
+ '{}'
683
+ )
684
+ WHERE "dataset" = ?
685
+ AND EXISTS (SELECT 1 FROM json_each("payload") WHERE "key" = ? OR "key" = ?)`, [
686
+ mutation.oldColumn,
687
+ mutation.newColumn,
688
+ mutation.newColumn,
689
+ mutation.table,
690
+ mutation.oldColumn,
691
+ mutation.newColumn
692
+ ], { loggerLevel: "system" });
693
+ break;
694
+ case "add-column":
695
+ case "drop-column":
696
+ tx.execute(`UPDATE ${updateLogTableName}
697
+ SET "payload" = COALESCE(
698
+ (SELECT json_group_object("key", "value") FROM json_each("payload") WHERE "key" != ?),
699
+ '{}'
700
+ )
701
+ WHERE "dataset" = ?
702
+ AND EXISTS (SELECT 1 FROM json_each("payload") WHERE "key" = ?)`, [
703
+ mutation.column,
704
+ mutation.table,
705
+ mutation.column
706
+ ], { loggerLevel: "system" });
707
+ break;
708
+ default: throw new Error(`Unsupported update-log mutation: ${JSON.stringify(mutation)}`);
709
+ }
710
+ schemaVersion.current = version;
711
+ });
712
+ };
713
+ const migrateEvent = (event, targetVersion) => {
714
+ targetVersion ??= latestSchemaVersion;
715
+ if (targetVersion > schemaVersion.current) throw new Error(`Target schema version ${targetVersion} is greater than current schema version ${schemaVersion.current}`);
716
+ if (isNoOpCrdtEventPayload(event.payload)) {
717
+ if (event.schema_version < targetVersion) event.schema_version = targetVersion;
718
+ return event;
719
+ }
720
+ if (event.schema_version >= targetVersion) return event;
721
+ const fromVersion = event.schema_version;
722
+ let crdtEvent = {
723
+ dataset: event.dataset,
724
+ item_id: event.item_id,
725
+ type: event.type,
726
+ payload: JSON.parse(event.payload)
727
+ };
728
+ for (let i = 0; i < sortedMigrations.length; i++) {
729
+ const [version, migration] = sortedMigrations[i];
730
+ if (version <= fromVersion) continue;
731
+ if (version > targetVersion) break;
732
+ const transformer = migration.eventTransformer;
733
+ if (transformer) {
734
+ crdtEvent = transformer(crdtEvent);
735
+ if (crdtEvent === null) return null;
736
+ }
737
+ }
738
+ if (crdtEvent === null) return null;
739
+ event.schema_version = targetVersion;
740
+ event.dataset = crdtEvent.dataset;
741
+ event.item_id = crdtEvent.item_id;
742
+ event.type = crdtEvent.type;
743
+ event.payload = JSON.stringify(crdtEvent.payload);
744
+ return event;
745
+ };
746
+ const migrateEvents = (events, targetVersion) => {
747
+ return events.map((event) => migrateEvent(event, targetVersion ?? latestSchemaVersion)).filter((event) => event !== null);
748
+ };
749
+ return {
750
+ latestSchemaVersion,
751
+ get currentSchemaVersion() {
752
+ return schemaVersion.current;
753
+ },
754
+ migrateDbToLatest: (db) => {
755
+ const currentSchemaVersion = schemaVersion.current;
756
+ if (currentSchemaVersion >= latestSchemaVersion) return;
757
+ for (let i = 0; i < sortedMigrations.length; i++) {
758
+ const [version, migration] = sortedMigrations[i];
759
+ if (version <= currentSchemaVersion) continue;
760
+ applyMigration(db, version, migration);
761
+ }
762
+ },
763
+ migrateEvent,
764
+ migrateEvents
765
+ };
766
+ }
767
+ //#endregion
768
+ //#region src/schema/validate-crdt-event.ts
769
+ var CrdtEventValidationError = class extends Error {
770
+ errors;
771
+ constructor(errors) {
772
+ super(`Invalid CRDT events: ${errors.join("; ")}`);
773
+ this.errors = errors;
774
+ this.name = "CrdtEventValidationError";
775
+ }
776
+ };
777
+ const crdtEventTypes = [
778
+ "item-created",
779
+ "item-updated",
780
+ "item-deleted"
781
+ ];
782
+ function validateNewCrdtEvent(schema, input) {
783
+ const errors = [];
784
+ if (!isRecord(input)) return {
785
+ success: false,
786
+ errors: ["Event must be an object"]
787
+ };
788
+ const type = input.type;
789
+ const dataset = input.dataset;
790
+ const itemId = input.item_id;
791
+ let payload = input.payload;
792
+ let payloadParseFailed = false;
793
+ if (typeof payload === "string") try {
794
+ payload = JSON.parse(payload);
795
+ } catch {
796
+ payloadParseFailed = true;
797
+ errors.push(`Event payload is not valid JSON`);
798
+ }
799
+ if (!isCrdtEventType(type)) errors.push(`Invalid event type "${String(type)}"`);
800
+ if (typeof dataset !== "string") errors.push(`Event dataset must be a string`);
801
+ if (typeof itemId !== "string") errors.push(`Event item_id must be a string`);
802
+ if (!payloadParseFailed && !isRecord(payload)) errors.push(`Event payload must be an object`);
803
+ const datasetMatch = typeof dataset === "string" ? findTableForDataset(schema, dataset) : null;
804
+ if (typeof dataset === "string" && !datasetMatch) errors.push(`Unknown dataset "${dataset}"`);
805
+ if (isCrdtEventType(type) && datasetMatch && isRecord(payload)) switch (type) {
806
+ case "item-created": {
807
+ const result = datasetMatch.table.validatePayload(payload, { event: "item-created" });
808
+ if (!result.success) errors.push(...result.errors.map((error) => `payload: ${error}`));
809
+ if (typeof itemId === "string" && "id" in payload && payload.id !== itemId) errors.push(`payload: Column "id" must match item_id "${itemId}"`);
810
+ break;
811
+ }
812
+ case "item-updated": {
813
+ const result = datasetMatch.table.validatePayload(payload, { event: "item-updated" });
814
+ if (!result.success) errors.push(...result.errors.map((error) => `payload: ${error}`));
815
+ break;
816
+ }
817
+ case "item-deleted": if (Object.keys(payload).length > 0) errors.push(`payload: item-deleted events must have an empty payload`);
818
+ }
819
+ if (errors.length > 0) return {
820
+ success: false,
821
+ errors
822
+ };
823
+ return {
824
+ success: true,
825
+ event: {
826
+ type,
827
+ dataset: datasetMatch?.baseTableName ?? dataset,
828
+ item_id: itemId,
829
+ payload
830
+ }
831
+ };
832
+ }
833
+ function findTableForDataset(schema, dataset) {
834
+ const config = schema.tablesConfig.find((tableConfig) => tableConfig.baseTableName === dataset || tableConfig.crdtTableName === dataset);
835
+ if (config) {
836
+ const table = schema.tables[config.crdtTableName];
837
+ return table ? {
838
+ table,
839
+ baseTableName: config.baseTableName
840
+ } : null;
841
+ }
842
+ const table = schema.tables[dataset];
843
+ return table ? {
844
+ table,
845
+ baseTableName: dataset
846
+ } : null;
847
+ }
848
+ function isRecord(value) {
849
+ return typeof value === "object" && value !== null && !Array.isArray(value);
850
+ }
851
+ function isCrdtEventType(value) {
852
+ return typeof value === "string" && crdtEventTypes.includes(value);
853
+ }
854
+ //#endregion
855
+ //#region src/schema/verify-sync-schema.ts
856
+ const acceptedDataTypes = {
857
+ text: ["TEXT"],
858
+ integer: ["INTEGER", "INT"],
859
+ real: [
860
+ "REAL",
861
+ "DOUBLE",
862
+ "FLOAT"
863
+ ],
864
+ boolean: [
865
+ "BOOLEAN",
866
+ "INTEGER",
867
+ "INT"
868
+ ],
869
+ enum: ["TEXT"]
870
+ };
871
+ let sqliteModule = null;
872
+ /**
873
+ * Verify that replaying the full migration history on a fresh database produces
874
+ * exactly the declared table schema. Creates a throwaway in-memory SQLite db,
875
+ * applies all migrations, introspects the result, and diffs it against the
876
+ * `t.table()` declarations.
877
+ *
878
+ * Returns an empty array when migrations and schema agree. Intended for dev-time
879
+ * checks (`startDbWorker({ verifySchema: true })`) and tests:
880
+ * `expect(await verifySyncSchema(syncDbSchema)).toEqual([])`.
881
+ */
882
+ async function verifySyncSchema(schema) {
883
+ if (!sqliteModule) sqliteModule = await sqlite3InitModule();
884
+ const sqlite3 = sqliteModule;
885
+ const db = new SQLiteDbWrapper({
886
+ db: () => new sqlite3.oo1.DB({ filename: ":memory:" }),
887
+ logger: () => {},
888
+ loggerPrefix: "schema-verify",
889
+ sqlite3
890
+ });
891
+ try {
892
+ const migrator = createMigrator({
893
+ migrations: schema.migrations,
894
+ schemaVersion: createStoredValue({ initialValue: -1 })
895
+ });
896
+ try {
897
+ migrator.migrateDbToLatest({ startTransaction: (callback) => {
898
+ db.executeTransaction((tx) => callback({ execute: (sql, parameters, meta) => tx.execute({
899
+ sql,
900
+ parameters
901
+ }, meta) }));
902
+ } });
903
+ } catch (error) {
904
+ return [{
905
+ table: "(migrations)",
906
+ message: `migration replay failed on an empty database: ${error instanceof Error ? error.message : String(error)}`
907
+ }];
908
+ }
909
+ const introspection = introspectDb(db);
910
+ const issues = [];
911
+ for (const { crdtTableName, baseTableName } of schema.tablesConfig) {
912
+ const declared = schema.tables[crdtTableName];
913
+ if (!declared) continue;
914
+ verifyTable(db, baseTableName, declared.columns, introspection[baseTableName], issues);
915
+ }
916
+ return issues;
917
+ } finally {
918
+ db.close();
919
+ }
920
+ }
921
+ function formatSchemaVerificationIssues(issues) {
922
+ const lines = issues.map((issue) => ` - table "${issue.table}"${issue.column ? ` column "${issue.column}"` : ""}: ${issue.message}`);
923
+ return `sync schema verification failed (${issues.length} issue${issues.length === 1 ? "" : "s"}):\n${lines.join("\n")}`;
924
+ }
925
+ function verifyTable(db, table, declaredColumns, actual, issues) {
926
+ if (!actual) {
927
+ issues.push({
928
+ table,
929
+ message: "declared in the schema but missing after migrations — add a createTable step in a new version"
930
+ });
931
+ return;
932
+ }
933
+ if (actual.isView) {
934
+ issues.push({
935
+ table,
936
+ message: "expected a base table, but migrations created a view with this name"
937
+ });
938
+ return;
939
+ }
940
+ const actualColumns = new Map(actual.columns.map((column) => [column.name, column]));
941
+ for (const [name, meta] of Object.entries(declaredColumns)) {
942
+ const column = actualColumns.get(name);
943
+ if (!column) {
944
+ issues.push({
945
+ table,
946
+ column: name,
947
+ message: "declared in the schema but missing after migrations — add an addColumn step in a new version"
948
+ });
949
+ continue;
950
+ }
951
+ const accepted = acceptedDataTypes[meta.kind];
952
+ if (!accepted.includes(column.dataType.toUpperCase())) issues.push({
953
+ table,
954
+ column: name,
955
+ message: `type mismatch — schema declares ${meta.kind} (${accepted.join("/")}), migrated column is ${column.dataType.toUpperCase()}`
956
+ });
957
+ if (column.isNullable !== meta.nullable) issues.push({
958
+ table,
959
+ column: name,
960
+ message: meta.nullable ? "schema declares the column nullable, but the migrated column is NOT NULL" : "schema declares the column NOT NULL, but the migrated column is nullable"
961
+ });
962
+ if (name === "id" && !column.isPrimaryKey) issues.push({
963
+ table,
964
+ column: name,
965
+ message: "the id column must be the PRIMARY KEY"
966
+ });
967
+ verifyDefault(db, table, name, meta, column.defaultValueSql, issues);
968
+ }
969
+ for (const column of actual.columns) if (!(column.name in declaredColumns)) issues.push({
970
+ table,
971
+ column: column.name,
972
+ message: "created by migrations but not declared in the schema — sync event payloads include every column, so declare it (or drop it in a new version)"
973
+ });
974
+ }
975
+ function verifyDefault(db, table, column, meta, defaultValueSql, issues) {
976
+ if (meta.hasDefault && meta.defaultValue !== null && defaultValueSql === null) {
977
+ issues.push({
978
+ table,
979
+ column,
980
+ message: `schema declares default ${formatExpected(meta)}, but the migrated column has no default`
981
+ });
982
+ return;
983
+ }
984
+ if (!meta.hasDefault && defaultValueSql !== null) {
985
+ issues.push({
986
+ table,
987
+ column,
988
+ message: `migrated column has default ${defaultValueSql}, but the schema declares none`
989
+ });
990
+ return;
991
+ }
992
+ if (!meta.hasDefault || defaultValueSql === null) return;
993
+ let actual;
994
+ try {
995
+ actual = db.execute({
996
+ sql: `SELECT (${defaultValueSql}) AS value`,
997
+ parameters: []
998
+ }).rows[0]?.value;
999
+ } catch {
1000
+ actual = void 0;
1001
+ }
1002
+ if (actual !== expectedDefaultValue(meta)) issues.push({
1003
+ table,
1004
+ column,
1005
+ message: `default mismatch — schema declares ${formatExpected(meta)}, migrated column default is ${defaultValueSql}`
1006
+ });
1007
+ }
1008
+ /** The declared default normalized to how SQLite stores it (booleans as 0/1). */
1009
+ function expectedDefaultValue(meta) {
1010
+ if (meta.defaultValue === null) return null;
1011
+ switch (meta.kind) {
1012
+ case "boolean": return meta.defaultValue ? 1 : 0;
1013
+ case "integer":
1014
+ case "real": return Number(meta.defaultValue);
1015
+ case "text":
1016
+ case "enum": return String(meta.defaultValue);
1017
+ }
1018
+ }
1019
+ function formatExpected(meta) {
1020
+ const expected = expectedDefaultValue(meta);
1021
+ return typeof expected === "string" ? `'${expected}'` : String(expected);
1022
+ }
1023
+ //#endregion
1024
+ //#region src/sqlite-crdt/apply-crdt-event.ts
1025
+ const createSQLiteCrdtApplyFunction = ({ db, dbConfig }) => {
1026
+ return createCrdtApplyFunction({
1027
+ getCrdtUpdateLog(opts) {
1028
+ const [metaRow] = db.executePrepared("get-item-crdt-meta", {
1029
+ item_id: opts.itemId,
1030
+ dataset: opts.dataset
1031
+ }, (db, params) => {
1032
+ return db.selectFrom(dbConfig.updateLogTable.fullIdentifier).select("payload").where("item_id", "=", params("item_id")).where("dataset", "=", params("dataset"));
1033
+ }, { loggerLevel: "system" });
1034
+ return metaRow ? JSON.parse(metaRow.payload) : null;
1035
+ },
1036
+ insertCrdtUpdateLog(opts) {
1037
+ db.executePrepared("insert-crdt-update-log", {
1038
+ item_id: opts.itemId,
1039
+ dataset: opts.dataset,
1040
+ payload: opts.payload
1041
+ }, (db, params) => db.insertInto(dbConfig.updateLogTable.fullIdentifier).values({
1042
+ item_id: params("item_id"),
1043
+ dataset: params("dataset"),
1044
+ payload: params("payload")
1045
+ }), { loggerLevel: "system" });
1046
+ },
1047
+ updateCrdtUpdateLog(opts) {
1048
+ db.executePrepared("update-crdt-update-log", {
1049
+ item_id: opts.itemId,
1050
+ dataset: opts.dataset,
1051
+ payload: opts.payload
1052
+ }, (db, params) => db.updateTable(dbConfig.updateLogTable.fullIdentifier).set({ payload: params("payload") }).where("item_id", "=", params("item_id")).where("dataset", "=", params("dataset")), { loggerLevel: "system" });
1053
+ },
1054
+ insertItem(opts) {
1055
+ const keys = Object.keys(opts.payload).sort();
1056
+ const insertPayload = {};
1057
+ for (const key of keys) insertPayload[key] = key;
1058
+ db.executePrepared(`crdt-insert-item-${opts.dataset}-${keys.join("-")}`, opts.payload, (db) => db.insertInto(opts.dataset).values(insertPayload), { loggerLevel: "system" });
1059
+ },
1060
+ updateItem(opts) {
1061
+ const keys = Array.from(Object.keys(opts.payload));
1062
+ keys.sort();
1063
+ db.executePreparedRaw({
1064
+ key: `update-item-${opts.dataset}-${keys.join("-")}`,
1065
+ sql: `update ${quoteId(opts.dataset)} set ${keys.map((key) => `${quoteId(key)} = ?`).join(",")} where id = ?`,
1066
+ params: [...keys.map((key) => opts.payload[key]), opts.itemId],
1067
+ meta: { loggerLevel: "system" }
1068
+ });
1069
+ }
1070
+ });
1071
+ };
1072
+ function createCrdtApplyFunction({ getCrdtUpdateLog, insertItem, insertCrdtUpdateLog, updateItem, updateCrdtUpdateLog }) {
1073
+ const applyItemCreated = ({ event }) => {
1074
+ const eventPayload = JSON.parse(event.payload);
1075
+ eventPayload.tombstone = 0;
1076
+ insertItem({
1077
+ dataset: event.dataset,
1078
+ payload: eventPayload
1079
+ });
1080
+ const newUpdateLog = {};
1081
+ for (const key of Object.keys(eventPayload)) newUpdateLog[key] = event.timestamp;
1082
+ insertCrdtUpdateLog({
1083
+ dataset: event.dataset,
1084
+ itemId: event.item_id,
1085
+ payload: JSON.stringify(newUpdateLog)
1086
+ });
1087
+ };
1088
+ const applyItemUpdated = ({ event, meta }) => {
1089
+ if (!meta) throw new Error(`Item ${event.item_id} in dataset ${event.dataset} not found`);
1090
+ const eventPayload = event.type === "item-deleted" ? { tombstone: 1 } : JSON.parse(event.payload);
1091
+ const updatePayload = {};
1092
+ let hasUpdates = false;
1093
+ for (const [key, value] of Object.entries(eventPayload)) {
1094
+ if (key === "id") continue;
1095
+ const lastUpdateTimestamp = meta[key];
1096
+ const currentUpdateTimestamp = event.timestamp;
1097
+ if (!lastUpdateTimestamp || !currentUpdateTimestamp || currentUpdateTimestamp > lastUpdateTimestamp) {
1098
+ updatePayload[key] = value;
1099
+ meta[key] = currentUpdateTimestamp;
1100
+ hasUpdates = true;
1101
+ }
1102
+ }
1103
+ if (!hasUpdates) return;
1104
+ updateItem({
1105
+ dataset: event.dataset,
1106
+ itemId: event.item_id,
1107
+ payload: updatePayload
1108
+ });
1109
+ updateCrdtUpdateLog({
1110
+ dataset: event.dataset,
1111
+ itemId: event.item_id,
1112
+ payload: JSON.stringify(meta)
1113
+ });
1114
+ };
1115
+ return (event) => {
1116
+ if (isNoOpCrdtEventPayload(event.payload)) return;
1117
+ const meta = getCrdtUpdateLog({
1118
+ itemId: event.item_id,
1119
+ dataset: event.dataset
1120
+ });
1121
+ if (event.type !== "item-created" && event.type !== "item-updated" && event.type !== "item-deleted") throw new Error(`Unknown event type: ${event.type}`);
1122
+ if (meta) {
1123
+ applyItemUpdated({
1124
+ event,
1125
+ meta
1126
+ });
1127
+ return;
1128
+ }
1129
+ if (event.type === "item-created") {
1130
+ applyItemCreated({ event });
1131
+ return;
1132
+ }
1133
+ throw new Error(`Item ${event.item_id} in dataset ${event.dataset} not found`);
1134
+ };
1135
+ }
1136
+ //#endregion
1137
+ //#region src/sqlite-crdt/event-consistency.ts
1138
+ const MASK_128 = (1n << 128n) - 1n;
1139
+ const HEX_128_PATTERN = /^[0-9a-f]{32}$/;
1140
+ function createEventHlcAccumulator(initialValue) {
1141
+ let current = parseHex128(initialValue);
1142
+ return {
1143
+ add(timestamp) {
1144
+ current = current + hash128BigInt(timestamp) & MASK_128;
1145
+ },
1146
+ get current() {
1147
+ return toHex128(current);
1148
+ }
1149
+ };
1150
+ }
1151
+ function parseHex128(value) {
1152
+ if (value === "") return 0n;
1153
+ const normalized = value.toLowerCase();
1154
+ if (!HEX_128_PATTERN.test(normalized)) throw new Error(`Invalid event HLC accumulator value: ${value}`);
1155
+ return BigInt(`0x${normalized}`);
1156
+ }
1157
+ function toHex128(value) {
1158
+ return value.toString(16).padStart(32, "0");
1159
+ }
1160
+ function hash128BigInt(value) {
1161
+ return xxhash.h64(value, 0n) | xxhash.h64(value, 1n) << 64n;
1162
+ }
1163
+ //#endregion
1164
+ //#region src/sqlite-crdt/crdt-storage.ts
1165
+ function createCrdtStorage(storage) {
1166
+ let localSyncId = storage.initialLocalSyncId;
1167
+ const db = storage.db;
1168
+ const crdtEventsTable = storage.dbConfig.eventsTable.fullIdentifier;
1169
+ const quotedEventsTable = quoteId(storage.dbConfig.eventsTable.fullIdentifier);
1170
+ const noOpPayloadSqlLiteral = `'${CRDT_EVENT_NO_OP_PAYLOAD}'`;
1171
+ const getInitialSequentialSyncId = () => {
1172
+ const [firstPendingEvent] = db.executePrepared("get-first-pending-event", { status: "pending" }, (db, params) => db.selectFrom(crdtEventsTable).select("sync_id").where("status", "=", params("status")).orderBy("sync_id", "asc").limit(sql.lit(1)), { loggerLevel: "system" });
1173
+ return firstPendingEvent ? firstPendingEvent.sync_id - 1 : localSyncId;
1174
+ };
1175
+ let sequentialSyncId = getInitialSequentialSyncId();
1176
+ const eventTarget = createTypedEventTarget();
1177
+ const EVENT_COLUMNS = [
1178
+ "sync_id",
1179
+ "schema_version",
1180
+ "status",
1181
+ "type",
1182
+ "timestamp",
1183
+ "origin",
1184
+ "source_node_id",
1185
+ "dataset",
1186
+ "item_id",
1187
+ "payload"
1188
+ ];
1189
+ const quotedEventColumns = EVENT_COLUMNS.map((column) => quoteId(column)).join(", ");
1190
+ const persistEventsSql = `insert into ${quotedEventsTable} (${quotedEventColumns}) select ${EVENT_COLUMNS.map((_, index) => `"value"->>${index}`).join(", ")} from json_each(?)`;
1191
+ const pendingEventsSql = `select json_group_array(json_array(${quotedEventColumns})) as "batch" from (select ${quotedEventColumns} from ${quotedEventsTable} where "status" = ? order by "sync_id" asc limit ?)`;
1192
+ const PERSIST_EVENTS_CHUNK_SIZE = 100;
1193
+ const eventToRow = (event) => [
1194
+ event.sync_id,
1195
+ event.schema_version,
1196
+ event.status,
1197
+ event.type,
1198
+ event.timestamp,
1199
+ event.origin,
1200
+ event.source_node_id,
1201
+ event.dataset,
1202
+ event.item_id,
1203
+ event.payload
1204
+ ];
1205
+ const rowToEvent = (row) => ({
1206
+ sync_id: row[0],
1207
+ schema_version: row[1],
1208
+ status: row[2],
1209
+ type: row[3],
1210
+ timestamp: row[4],
1211
+ origin: row[5],
1212
+ source_node_id: row[6],
1213
+ dataset: row[7],
1214
+ item_id: row[8],
1215
+ payload: row[9]
1216
+ });
1217
+ const persistEvents = (tx, events) => {
1218
+ for (let offset = 0; offset < events.length; offset += PERSIST_EVENTS_CHUNK_SIZE) {
1219
+ const chunk = events.slice(offset, offset + PERSIST_EVENTS_CHUNK_SIZE);
1220
+ tx.executePreparedRaw({
1221
+ key: "persist-crdt-events",
1222
+ sql: persistEventsSql,
1223
+ params: [JSON.stringify(chunk.map(eventToRow))],
1224
+ meta: { loggerLevel: "system" }
1225
+ });
1226
+ }
1227
+ };
1228
+ const readPendingEvents = (limit) => {
1229
+ const [row] = db.executePreparedRaw({
1230
+ key: "get-enqueued-pending-events",
1231
+ sql: pendingEventsSql,
1232
+ params: ["pending", limit],
1233
+ meta: { loggerLevel: "system" }
1234
+ });
1235
+ return JSON.parse(row.batch).map(rowToEvent);
1236
+ };
1237
+ const enqueueEvents = (origin, sourceNodeId, events) => {
1238
+ const beforeSyncId = localSyncId;
1239
+ if (events.length === 0) return {
1240
+ beforeSyncId,
1241
+ afterSyncId: beforeSyncId,
1242
+ processed: Promise.resolve()
1243
+ };
1244
+ db.executeTransaction((tx) => {
1245
+ const persistedEvents = events.map((event) => ({
1246
+ schema_version: event.schema_version ?? storage.migrator.currentSchemaVersion,
1247
+ timestamp: event.timestamp ?? serializeHLC(storage.hlc.getNextHLC()),
1248
+ type: event.type,
1249
+ dataset: event.dataset,
1250
+ item_id: event.item_id,
1251
+ origin,
1252
+ source_node_id: sourceNodeId,
1253
+ payload: event.payload,
1254
+ sync_id: ++localSyncId,
1255
+ status: "pending"
1256
+ }));
1257
+ persistEvents(tx, persistedEvents);
1258
+ });
1259
+ return {
1260
+ beforeSyncId,
1261
+ afterSyncId: localSyncId,
1262
+ processed: processEnqueuedEvents()
1263
+ };
1264
+ };
1265
+ const enqueueLocalEvents = (events, sourceNodeId) => {
1266
+ return enqueueEvents("local", sourceNodeId, events);
1267
+ };
1268
+ const enqueueOwnEvents = (events) => {
1269
+ return enqueueEvents("own", storage.nodeId, events);
1270
+ };
1271
+ const enqueueRemoteEvents = (events) => {
1272
+ return enqueueEvents("remote", "", events);
1273
+ };
1274
+ const notifyEventsApplied = (events) => {
1275
+ const onEventApplied = storage.onEventApplied;
1276
+ if (!onEventApplied || events.length === 0) return;
1277
+ queueMicrotask(() => {
1278
+ let callbackFailed = false;
1279
+ let firstError;
1280
+ for (const event of events) try {
1281
+ onEventApplied(event);
1282
+ } catch (error) {
1283
+ callbackFailed = true;
1284
+ firstError ??= error;
1285
+ }
1286
+ if (callbackFailed) throw firstError;
1287
+ });
1288
+ };
1289
+ const applyOwnEvents = (events) => {
1290
+ const schema = storage.schema;
1291
+ if (!schema) throw new Error("applyOwnEvents requires a sync schema; none was provided to createCrdtStorage");
1292
+ const validatedEvents = [];
1293
+ const errors = [];
1294
+ for (const [index, event] of events.entries()) {
1295
+ const result = validateNewCrdtEvent(schema, {
1296
+ type: event.type,
1297
+ dataset: event.dataset,
1298
+ item_id: event.item_id,
1299
+ payload: event.payload
1300
+ });
1301
+ if (!result.success) {
1302
+ errors.push(...result.errors.map((error) => `[${index}] ${error}`));
1303
+ continue;
1304
+ }
1305
+ validatedEvents.push(result.event);
1306
+ }
1307
+ if (errors.length > 0) throw new CrdtEventValidationError(errors);
1308
+ db.executeTransaction((tx) => {
1309
+ const persistedEvents = validatedEvents.map((event) => ({
1310
+ schema_version: storage.migrator.currentSchemaVersion,
1311
+ timestamp: serializeHLC(storage.hlc.getNextHLC()),
1312
+ type: event.type,
1313
+ dataset: event.dataset,
1314
+ item_id: event.item_id,
1315
+ origin: "own-applied",
1316
+ source_node_id: storage.nodeId,
1317
+ payload: JSON.stringify(event.payload),
1318
+ sync_id: ++localSyncId,
1319
+ status: "pending"
1320
+ }));
1321
+ persistEvents(tx, persistedEvents);
1322
+ for (const persistedEvent of persistedEvents) applyCrdtEvent(persistedEvent);
1323
+ });
1324
+ processEnqueuedEvents();
1325
+ };
1326
+ const applyOwnSnapshot = (snapshot) => {
1327
+ const syncId = localSyncId + 1;
1328
+ db.executeTransaction((tx) => {
1329
+ const [currentRow] = tx.executePreparedRaw({
1330
+ key: `get-crdt-snapshot-row-${snapshot.dataset}`,
1331
+ sql: `select * from ${quoteId(snapshot.dataset)} where "id" = ? limit 1`,
1332
+ params: [snapshot.item_id],
1333
+ meta: { loggerLevel: "system" }
1334
+ });
1335
+ const event = {
1336
+ schema_version: storage.migrator.currentSchemaVersion,
1337
+ timestamp: serializeHLC(storage.hlc.getNextHLC()),
1338
+ type: "item-created",
1339
+ dataset: snapshot.dataset,
1340
+ item_id: snapshot.item_id,
1341
+ origin: "own-applied",
1342
+ source_node_id: storage.nodeId,
1343
+ payload: JSON.stringify({
1344
+ ...currentRow ?? {},
1345
+ ...snapshot.patch,
1346
+ id: snapshot.item_id,
1347
+ tombstone: false
1348
+ }),
1349
+ sync_id: syncId,
1350
+ status: "pending"
1351
+ };
1352
+ tx.executePreparedRaw({
1353
+ key: "replace-crdt-row-history-with-no-ops",
1354
+ sql: `update ${quotedEventsTable}
1355
+ set "payload" = ?
1356
+ where "dataset" = ? and "item_id" = ? and "payload" <> ${noOpPayloadSqlLiteral}`,
1357
+ params: [
1358
+ CRDT_EVENT_NO_OP_PAYLOAD,
1359
+ event.dataset,
1360
+ event.item_id
1361
+ ],
1362
+ meta: { loggerLevel: "system" }
1363
+ });
1364
+ persistEvents(tx, [event]);
1365
+ applyCrdtEvent(event);
1366
+ });
1367
+ localSyncId = syncId;
1368
+ processEnqueuedEvents();
1369
+ };
1370
+ const applyOwnEventFromTransaction = (tx, event) => {
1371
+ const persistedEvent = {
1372
+ schema_version: storage.migrator.currentSchemaVersion,
1373
+ timestamp: serializeHLC(storage.hlc.getNextHLC()),
1374
+ type: event.type,
1375
+ dataset: event.dataset,
1376
+ item_id: event.item_id,
1377
+ origin: "own-applied",
1378
+ source_node_id: storage.nodeId,
1379
+ payload: event.payload,
1380
+ sync_id: ++localSyncId,
1381
+ status: "pending"
1382
+ };
1383
+ persistEvents(tx, [persistedEvent]);
1384
+ applyCrdtEvent(persistedEvent);
1385
+ };
1386
+ const applyOwnIntentsFromTransaction = (tx, intents) => {
1387
+ let appliedEvents = 0;
1388
+ for (const intent of intents) {
1389
+ if (intent.type === "item-updated") {
1390
+ if (intent.item_id !== intent.new_item_id) throw new Error(`Cannot update the "id" column of an item. It is used to identify the item and must be immutable.`);
1391
+ if (intent.payload_json === "{}") continue;
1392
+ }
1393
+ applyOwnEventFromTransaction(tx, {
1394
+ type: intent.type,
1395
+ dataset: intent.dataset,
1396
+ item_id: intent.item_id,
1397
+ payload: intent.payload_json
1398
+ });
1399
+ appliedEvents++;
1400
+ }
1401
+ return { appliedEvents };
1402
+ };
1403
+ const dispatchEventsApplied = (previousSequentialSyncId) => {
1404
+ if (sequentialSyncId <= previousSequentialSyncId) return;
1405
+ eventTarget.dispatchEvent("events-applied", {
1406
+ syncId: sequentialSyncId,
1407
+ eventHlcSum: sequentialSyncId === localSyncId ? eventHlcAccumulator?.current ?? null : null
1408
+ });
1409
+ };
1410
+ const publishProcessedBatch = ({ previousSequentialSyncId, nextSequentialSyncId, appliedEvents }) => {
1411
+ sequentialSyncId = nextSequentialSyncId;
1412
+ notifyEventsApplied(appliedEvents);
1413
+ dispatchEventsApplied(previousSequentialSyncId);
1414
+ };
1415
+ const hasPendingEvents = () => {
1416
+ return db.executePrepared("has-pending-events", { status: "pending" }, (db, params) => db.selectFrom(crdtEventsTable).select("sync_id").where("status", "=", params("status")).limit(sql.lit(1)), { loggerLevel: "system" }).length > 0;
1417
+ };
1418
+ const getEventsBatch = (options) => {
1419
+ const limit = options.limit ?? 50;
1420
+ const queryParams = {
1421
+ limit: limit + 1,
1422
+ status: options.status ?? null,
1423
+ afterSyncId: options.afterSyncId ?? null,
1424
+ excludeOrigin: options.excludeOrigin ?? null,
1425
+ excludeNodeId: options.excludeNodeId ?? null,
1426
+ maxSyncId: options.status === "applied" ? sequentialSyncId : localSyncId
1427
+ };
1428
+ const filterKeys = [queryParams.excludeNodeId ? "nodeid" : "no-nodeid", queryParams.excludeOrigin ? "origin" : "no-origin"];
1429
+ const events = db.executePrepared(`get-events-batch-${filterKeys.join("-")}`, queryParams, (db, params) => db.selectFrom(crdtEventsTable).where("sync_id", ">", params("afterSyncId")).where("sync_id", "<=", params("maxSyncId")).where("status", "=", params("status")).$if(!!queryParams.excludeNodeId, (qb) => qb.where("source_node_id", "!=", params("excludeNodeId"))).$if(!!queryParams.excludeOrigin, (qb) => qb.where("origin", "!=", params("excludeOrigin"))).selectAll().limit(params("limit")).orderBy("sync_id", "asc"), { loggerLevel: "system" });
1430
+ const hasMore = events.length > limit;
1431
+ if (hasMore) events.pop();
1432
+ const lastReturnedSyncId = events[events.length - 1]?.sync_id ?? options.afterSyncId ?? 0;
1433
+ let nextSyncId = lastReturnedSyncId;
1434
+ if (!hasMore && options.status === "applied") nextSyncId = Math.max(lastReturnedSyncId, queryParams.maxSyncId);
1435
+ return {
1436
+ events,
1437
+ hasMore,
1438
+ nextSyncId
1439
+ };
1440
+ };
1441
+ const checkIsQuiescent = (pushedSyncId) => {
1442
+ if (hasPendingEvents()) return false;
1443
+ return getEventsBatch({
1444
+ status: "applied",
1445
+ afterSyncId: pushedSyncId,
1446
+ excludeOrigin: "remote",
1447
+ limit: 1
1448
+ }).events.length === 0;
1449
+ };
1450
+ const applyCrdtEvent = createSQLiteCrdtApplyFunction({
1451
+ db,
1452
+ dbConfig: storage.dbConfig
1453
+ });
1454
+ const eventHlcAccumulator = storage.eventHlcAccumulator ? createEventHlcAccumulator(storage.eventHlcAccumulator.current) : null;
1455
+ const persistEventHlcAccumulator = () => {
1456
+ if (eventHlcAccumulator && storage.eventHlcAccumulator) storage.eventHlcAccumulator.current = eventHlcAccumulator.current;
1457
+ };
1458
+ const recomputeEventHlcAccumulatorIfNeeded = () => {
1459
+ if (!eventHlcAccumulator || !storage.eventHlcAccumulator) return;
1460
+ if (storage.eventHlcAccumulator.current !== "") return;
1461
+ const batchSize = 1e3;
1462
+ let afterSyncId = 0;
1463
+ for (;;) {
1464
+ const rows = db.executePrepared("get-applied-event-timestamps", {
1465
+ status: "applied",
1466
+ afterSyncId,
1467
+ limit: batchSize
1468
+ }, (db, params) => db.selectFrom(crdtEventsTable).select(["sync_id", "timestamp"]).where("status", "=", params("status")).where("sync_id", ">", params("afterSyncId")).orderBy("sync_id", "asc").limit(params("limit")), { loggerLevel: "system" });
1469
+ if (rows.length === 0) break;
1470
+ for (const row of rows) {
1471
+ eventHlcAccumulator.add(row.timestamp);
1472
+ afterSyncId = row.sync_id;
1473
+ }
1474
+ if (rows.length < batchSize) break;
1475
+ }
1476
+ persistEventHlcAccumulator();
1477
+ };
1478
+ const getFirstAcceptedSyncIdsByTimestamp = (tx, events) => {
1479
+ const timestamps = Array.from(new Set(events.filter((event) => event.origin !== "own-applied").map((event) => event.timestamp)));
1480
+ if (timestamps.length === 0) return /* @__PURE__ */ new Map();
1481
+ const rows = timestamps.length === 1 ? tx.executePreparedRaw({
1482
+ key: "get-first-accepted-sync-id-by-timestamp",
1483
+ sql: `select "timestamp", min("sync_id") as "first_sync_id" from ${quotedEventsTable} where "status" = 'applied' and "timestamp" = ? group by "timestamp"`,
1484
+ params: timestamps,
1485
+ meta: { loggerLevel: "system" }
1486
+ }) : tx.executePreparedRaw({
1487
+ key: "get-first-accepted-sync-ids-by-timestamp",
1488
+ sql: `select "timestamp", min("sync_id") as "first_sync_id" from ${quotedEventsTable} where "status" = 'applied' and "timestamp" in (select "value" from json_each(?)) group by "timestamp"`,
1489
+ params: [JSON.stringify(timestamps)],
1490
+ meta: { loggerLevel: "system" }
1491
+ });
1492
+ return new Map(rows.map((row) => [row.timestamp, row.first_sync_id]));
1493
+ };
1494
+ const updateEventStatuses = (tx, events) => {
1495
+ const syncIdsByStatus = /* @__PURE__ */ new Map();
1496
+ for (const event of events) {
1497
+ if (event.status === "pending") throw new Error(`Event ${event.sync_id} is still pending after processing`);
1498
+ const syncIds = syncIdsByStatus.get(event.status) ?? [];
1499
+ syncIds.push(event.sync_id);
1500
+ syncIdsByStatus.set(event.status, syncIds);
1501
+ }
1502
+ for (const [status, syncIds] of syncIdsByStatus) {
1503
+ if (syncIds.length === 1) {
1504
+ tx.executePreparedRaw({
1505
+ key: "update-crdt-event-status",
1506
+ sql: `update ${quotedEventsTable} set "status" = ? where "sync_id" = ?`,
1507
+ params: [status, syncIds[0]],
1508
+ meta: { loggerLevel: "system" }
1509
+ });
1510
+ continue;
1511
+ }
1512
+ tx.executePreparedRaw({
1513
+ key: "update-crdt-event-status-batch",
1514
+ sql: `update ${quotedEventsTable} set "status" = ? where "sync_id" in (select "value" from json_each(?))`,
1515
+ params: [status, JSON.stringify(syncIds)],
1516
+ meta: { loggerLevel: "system" }
1517
+ });
1518
+ }
1519
+ };
1520
+ const processPersistedEvent = (tx, event, firstAcceptedSyncIdByTimestamp, statusOnlyEvents) => {
1521
+ if (event.status !== "pending") throw new Error(`Event ${event.sync_id} is not pending`);
1522
+ const persistedEventData = {
1523
+ schema_version: event.schema_version,
1524
+ type: event.type,
1525
+ dataset: event.dataset,
1526
+ item_id: event.item_id,
1527
+ payload: event.payload
1528
+ };
1529
+ try {
1530
+ if (event.origin === "own-applied") {
1531
+ event.status = "applied";
1532
+ return event;
1533
+ }
1534
+ const firstAcceptedSyncId = firstAcceptedSyncIdByTimestamp.get(event.timestamp);
1535
+ if (firstAcceptedSyncId !== void 0 && firstAcceptedSyncId < event.sync_id) {
1536
+ event.status = "deduped";
1537
+ return event;
1538
+ }
1539
+ if (event.origin === "local" || event.origin === "remote") storage.hlc.mergeHLC(deserializeHLC(event.timestamp));
1540
+ if (isNoOpCrdtEventPayload(event.payload)) {
1541
+ applyCrdtEvent(event);
1542
+ event.status = "applied";
1543
+ return event;
1544
+ }
1545
+ const migratedEvent = storage.migrator.migrateEvent(event, storage.migrator.latestSchemaVersion);
1546
+ if (migratedEvent === null) {
1547
+ event.schema_version = storage.migrator.latestSchemaVersion;
1548
+ event.payload = CRDT_EVENT_NO_OP_PAYLOAD;
1549
+ applyCrdtEvent(event);
1550
+ event.status = "applied";
1551
+ return event;
1552
+ }
1553
+ event.schema_version = migratedEvent.schema_version;
1554
+ event.type = migratedEvent.type;
1555
+ event.dataset = migratedEvent.dataset;
1556
+ event.item_id = migratedEvent.item_id;
1557
+ event.payload = migratedEvent.payload;
1558
+ applyCrdtEvent(event);
1559
+ event.status = "applied";
1560
+ } catch (error) {
1561
+ console.error("Error applying enqueued CRDT event", error);
1562
+ event.status = "failed";
1563
+ } finally {
1564
+ if (event.schema_version !== persistedEventData.schema_version || event.type !== persistedEventData.type || event.dataset !== persistedEventData.dataset || event.item_id !== persistedEventData.item_id || event.payload !== persistedEventData.payload) tx.executePrepared("update-crdt-event-full", event, (db, params) => db.updateTable(crdtEventsTable).set({
1565
+ status: params("status"),
1566
+ schema_version: params("schema_version"),
1567
+ type: params("type"),
1568
+ dataset: params("dataset"),
1569
+ item_id: params("item_id"),
1570
+ payload: params("payload")
1571
+ }).where("sync_id", "=", params("sync_id")), { loggerLevel: "system" });
1572
+ else statusOnlyEvents.push(event);
1573
+ }
1574
+ };
1575
+ const processEnqueuedEvents = ensureSingletonExecution(async () => {
1576
+ let hasMore = true;
1577
+ while (hasMore) {
1578
+ await Promise.resolve();
1579
+ const batchSize = 100;
1580
+ const events = readPendingEvents(101);
1581
+ hasMore = events.length > batchSize;
1582
+ if (hasMore) events.pop();
1583
+ const previousSequentialSyncId = sequentialSyncId;
1584
+ if (events.length === 0) {
1585
+ publishProcessedBatch({
1586
+ previousSequentialSyncId,
1587
+ nextSequentialSyncId: localSyncId,
1588
+ appliedEvents: []
1589
+ });
1590
+ break;
1591
+ }
1592
+ const failedRemoteSyncIds = [];
1593
+ const appliedEventsToNotify = [];
1594
+ db.executeTransaction((tx) => {
1595
+ const firstAcceptedSyncIdByTimestamp = getFirstAcceptedSyncIdsByTimestamp(tx, events);
1596
+ const statusOnlyEvents = [];
1597
+ for (const event of events) {
1598
+ processPersistedEvent(tx, event, firstAcceptedSyncIdByTimestamp, statusOnlyEvents);
1599
+ if (event.status === "applied") {
1600
+ const firstAcceptedSyncId = firstAcceptedSyncIdByTimestamp.get(event.timestamp);
1601
+ if (firstAcceptedSyncId === void 0 || event.sync_id < firstAcceptedSyncId) firstAcceptedSyncIdByTimestamp.set(event.timestamp, event.sync_id);
1602
+ eventHlcAccumulator?.add(event.timestamp);
1603
+ if (storage.onEventApplied) appliedEventsToNotify.push(event);
1604
+ }
1605
+ if (event.status === "failed" && event.origin === "remote") failedRemoteSyncIds.push(event.sync_id);
1606
+ }
1607
+ updateEventStatuses(tx, statusOnlyEvents);
1608
+ persistEventHlcAccumulator();
1609
+ });
1610
+ publishProcessedBatch({
1611
+ previousSequentialSyncId,
1612
+ nextSequentialSyncId: hasMore ? events[events.length - 1]?.sync_id ?? sequentialSyncId : localSyncId,
1613
+ appliedEvents: appliedEventsToNotify
1614
+ });
1615
+ for (const syncId of failedRemoteSyncIds) eventTarget.dispatchEvent("remote-event-apply-failed", { syncId });
1616
+ }
1617
+ });
1618
+ recomputeEventHlcAccumulatorIfNeeded();
1619
+ processEnqueuedEvents();
1620
+ return {
1621
+ getEventsBatch,
1622
+ enqueueLocalEvents,
1623
+ enqueueOwnEvents,
1624
+ enqueueRemoteEvents,
1625
+ applyOwnEvents,
1626
+ applyOwnSnapshot,
1627
+ checkIsQuiescent,
1628
+ getEventHlcAccumulator: () => eventHlcAccumulator?.current ?? null,
1629
+ addEventListener: eventTarget.addEventListener,
1630
+ removeEventListener: eventTarget.removeEventListener,
1631
+ internal: {
1632
+ applyOwnEventFromTransaction,
1633
+ applyOwnIntentsFromTransaction,
1634
+ processEnqueuedEvents
1635
+ }
1636
+ };
1637
+ }
1638
+ //#endregion
1639
+ //#region src/sqlite-crdt/crdt-sync-producer.ts
1640
+ const createCrdtSyncProducer = ({ storage, broadcastEvents }) => {
1641
+ storage.addEventListener("events-applied", (event) => {
1642
+ broadcastEvents({
1643
+ newSyncId: event.payload.syncId,
1644
+ eventHlcSum: event.payload.eventHlcSum
1645
+ });
1646
+ });
1647
+ };
1648
+ //#endregion
1649
+ //#region src/sqlite-crdt/retry-remote-operation.ts
1650
+ const REMOTE_RETRY_OPTIONS = {
1651
+ maxAttempts: 3,
1652
+ backoffBaseMs: 100,
1653
+ backoffExponent: 1.5,
1654
+ backoffJitterMs: 150,
1655
+ timeoutMs: 1e4
1656
+ };
1657
+ var RetryTimeoutError = class extends Error {
1658
+ previous;
1659
+ constructor(message, previous) {
1660
+ super(message);
1661
+ this.previous = previous;
1662
+ this.name = "TimeoutError";
1663
+ }
1664
+ };
1665
+ const applyJitter = (delayMs, maxJitterMs) => {
1666
+ const jitter = Math.random() * maxJitterMs * (Math.random() > .5 ? 1 : -1);
1667
+ return Math.max(0, delayMs + jitter);
1668
+ };
1669
+ const delay = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
1670
+ const withTimeout = async (operation, timeoutMs, previousError) => {
1671
+ let timeoutId;
1672
+ try {
1673
+ return await Promise.race([operation(), new Promise((_, reject) => {
1674
+ timeoutId = setTimeout(() => reject(new RetryTimeoutError("Remote operation timed out", previousError)), timeoutMs);
1675
+ })]);
1676
+ } finally {
1677
+ if (timeoutId) clearTimeout(timeoutId);
1678
+ }
1679
+ };
1680
+ const retryRemoteOperation = async (operation, options) => {
1681
+ let lastError;
1682
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt++) try {
1683
+ return await withTimeout(operation, options.timeoutMs, lastError);
1684
+ } catch (error) {
1685
+ lastError = error;
1686
+ if (attempt >= options.maxAttempts) throw error;
1687
+ const backoffDelay = applyJitter(options.backoffBaseMs * options.backoffExponent ** (attempt - 1), options.backoffJitterMs);
1688
+ if (backoffDelay > 0) await delay(backoffDelay);
1689
+ }
1690
+ throw lastError;
1691
+ };
1692
+ //#endregion
1693
+ //#region src/sqlite-crdt/crdt-sync-remote-source.ts
1694
+ var SchemaVersionMismatchError = class extends Error {
1695
+ remoteSchemaVersion;
1696
+ localSchemaVersion;
1697
+ constructor(remoteSchemaVersion, localSchemaVersion) {
1698
+ super(`Schema version mismatch: remote ${remoteSchemaVersion} != local ${localSchemaVersion}`);
1699
+ this.remoteSchemaVersion = remoteSchemaVersion;
1700
+ this.localSchemaVersion = localSchemaVersion;
1701
+ this.name = "SchemaVersionMismatchError";
1702
+ }
1703
+ };
1704
+ const createCrdtSyncRemoteSource = ({ bufferSize, storage, migrator, pullSyncId, pushSyncId, nodeId, remoteFactory }) => {
1705
+ const eventTarget = createTypedEventTarget();
1706
+ let remoteState = {
1707
+ type: "offline",
1708
+ reason: "NOT_INITIALIZED",
1709
+ deSynced: false,
1710
+ schemaVersionMismatched: false
1711
+ };
1712
+ const patchRemoteState = (state) => {
1713
+ remoteState = {
1714
+ ...remoteState,
1715
+ ...state
1716
+ };
1717
+ eventTarget.dispatchEvent("state-changed", remoteState.type);
1718
+ };
1719
+ const initRemote = ensureSingletonExecution(async () => {
1720
+ if (remoteState.type !== "offline") throw new Error("Remote source is not offline");
1721
+ if (!remoteFactory) {
1722
+ console.warn("Remote source factory not provided. Going offline.");
1723
+ patchRemoteState({
1724
+ type: "offline",
1725
+ reason: "NOT_INITIALIZED"
1726
+ });
1727
+ return;
1728
+ }
1729
+ patchRemoteState({ type: "pending" });
1730
+ const factoryResult = await tryCatchAsync(async () => {
1731
+ return await remoteFactory?.({ onEventsAvailable: ({ newSyncId, remoteEventHlcSum }) => {
1732
+ pullEvents({
1733
+ remoteSyncId: newSyncId,
1734
+ remoteEventHlcSum,
1735
+ includeSelf: false
1736
+ });
1737
+ } });
1738
+ });
1739
+ if (!factoryResult.success) {
1740
+ patchRemoteState({
1741
+ type: "offline",
1742
+ reason: "INITIALIZATION_FAILED"
1743
+ });
1744
+ console.warn("Failed to create remote source", factoryResult.error);
1745
+ return;
1746
+ }
1747
+ patchRemoteState({
1748
+ type: "online",
1749
+ source: factoryResult.data,
1750
+ deSynced: false,
1751
+ schemaVersionMismatched: false
1752
+ });
1753
+ }, { queueReExecution: false });
1754
+ const syncWithRemote = ensureSingletonExecution(async () => {
1755
+ if (remoteState.type !== "online") return;
1756
+ await pullEvents();
1757
+ await startPushingEvents();
1758
+ }, { queueReExecution: false });
1759
+ const goOffline = ensureSingletonExecution(async (reason) => {
1760
+ if (remoteState.type !== "online") return;
1761
+ const source = remoteState.source;
1762
+ patchRemoteState({ type: "pending" });
1763
+ const disconnectResult = await tryCatchAsync(async () => {
1764
+ return await source.disconnect?.();
1765
+ });
1766
+ if (!disconnectResult.success) console.warn("Error while disconnecting from remote source", disconnectResult.error);
1767
+ patchRemoteState({
1768
+ type: "offline",
1769
+ reason
1770
+ });
1771
+ }, { queueReExecution: false });
1772
+ const goOnline = async () => {
1773
+ if (remoteState.type !== "online") await initRemote();
1774
+ if (remoteState.type === "online") await syncWithRemote();
1775
+ };
1776
+ let requestedPullSyncId = null;
1777
+ let pullPromise = null;
1778
+ const pullEvents = (request) => {
1779
+ if (remoteState.type !== "online") return Promise.resolve();
1780
+ const remoteSyncId = request?.remoteSyncId;
1781
+ if (remoteSyncId !== void 0 && remoteSyncId <= pullSyncId.current) {
1782
+ checkRemoteConsistency(remoteSyncId, request?.remoteEventHlcSum ?? null);
1783
+ return Promise.resolve();
1784
+ }
1785
+ if (pullPromise) {
1786
+ if (remoteSyncId !== void 0 && (!requestedPullSyncId || requestedPullSyncId < remoteSyncId)) requestedPullSyncId = remoteSyncId;
1787
+ return pullPromise;
1788
+ }
1789
+ pullPromise = pullAllEvents({
1790
+ afterSyncId: pullSyncId.current,
1791
+ excludeNodeId: request?.includeSelf ? void 0 : nodeId
1792
+ }).catch((error) => {
1793
+ console.error("Error pulling events. Going offline.", error);
1794
+ goOffline("REMOTE_PULL_ERROR");
1795
+ }).finally(() => {
1796
+ pullPromise = null;
1797
+ const nextTarget = requestedPullSyncId;
1798
+ requestedPullSyncId = null;
1799
+ if (nextTarget && nextTarget > pullSyncId.current) pullEvents({ remoteSyncId: nextTarget });
1800
+ });
1801
+ return pullPromise;
1802
+ };
1803
+ const pullAllEvents = async (opts) => {
1804
+ let hasMore = true;
1805
+ let afterSyncId = opts.afterSyncId;
1806
+ while (hasMore) {
1807
+ if (remoteState.type !== "online") return;
1808
+ const source = remoteState.source;
1809
+ const response = await retryRemoteOperation(() => source.pullEvents({
1810
+ ...opts,
1811
+ afterSyncId
1812
+ }), REMOTE_RETRY_OPTIONS);
1813
+ hasMore = response.hasMore;
1814
+ afterSyncId = response.nextSyncId;
1815
+ if (response.events) storage.enqueueRemoteEvents(response.events.map((x) => {
1816
+ if (x.schema_version > migrator.currentSchemaVersion) {
1817
+ eventTarget.dispatchEvent("remote-schema-version-mismatch", {
1818
+ remoteSchemaVersion: x.schema_version,
1819
+ localSchemaVersion: migrator.currentSchemaVersion
1820
+ });
1821
+ if (remoteState.type === "online" && !remoteState.schemaVersionMismatched) patchRemoteState({ schemaVersionMismatched: true });
1822
+ throw new SchemaVersionMismatchError(x.schema_version, migrator.currentSchemaVersion);
1823
+ }
1824
+ return x;
1825
+ }));
1826
+ if (response.nextSyncId <= pullSyncId.current) break;
1827
+ if (response.nextSyncId > pullSyncId.current) pullSyncId.current = response.nextSyncId;
1828
+ }
1829
+ };
1830
+ const checkRemoteConsistency = (remoteSyncId, remoteEventHlcSum) => {
1831
+ if (remoteEventHlcSum === null) return;
1832
+ if (remoteSyncId !== pullSyncId.current) return;
1833
+ if (!storage.checkIsQuiescent(pushSyncId.current)) return;
1834
+ const localEventHlcSum = storage.getEventHlcAccumulator();
1835
+ if (localEventHlcSum === null) return;
1836
+ if (localEventHlcSum === remoteEventHlcSum) return;
1837
+ eventTarget.dispatchEvent("de-sync-detected", { reason: "CHECKSUM_MISMATCH" });
1838
+ console.warn(`[sqlite-sync] De-sync detected at syncId ${remoteSyncId}: local HLC checksum ${localEventHlcSum} != remote ${remoteEventHlcSum}. Local and remote have diverged despite being caught up.`);
1839
+ if (remoteState.type === "online" && !remoteState.deSynced) patchRemoteState({ deSynced: true });
1840
+ };
1841
+ const startPushingEvents = ensureSingletonExecution(async () => {
1842
+ while (true) {
1843
+ const eventsBatch = storage.getEventsBatch({
1844
+ status: "applied",
1845
+ afterSyncId: pushSyncId.current,
1846
+ excludeOrigin: "remote",
1847
+ limit: bufferSize
1848
+ });
1849
+ if (eventsBatch.events.length === 0) break;
1850
+ if (remoteState.type !== "online") break;
1851
+ const source = remoteState.source;
1852
+ let response;
1853
+ try {
1854
+ response = await retryRemoteOperation(() => source.pushEvents({
1855
+ nodeId,
1856
+ events: eventsBatch.events.map((event) => ({
1857
+ schema_version: event.schema_version,
1858
+ timestamp: event.timestamp,
1859
+ type: event.type,
1860
+ dataset: event.dataset,
1861
+ item_id: event.item_id,
1862
+ payload: event.payload
1863
+ }))
1864
+ }), REMOTE_RETRY_OPTIONS);
1865
+ } catch (error) {
1866
+ console.error("Error pushing events. Going offline.", error);
1867
+ goOffline("REMOTE_PUSH_ERROR");
1868
+ return;
1869
+ }
1870
+ if (!response.ok) {
1871
+ console.error("Remote rejected pushed events. Going offline.");
1872
+ goOffline("REMOTE_PUSH_ERROR");
1873
+ return;
1874
+ }
1875
+ pushSyncId.current = eventsBatch.nextSyncId;
1876
+ if (response.beforeSyncId !== void 0 && response.afterSyncId !== void 0 && response.beforeSyncId <= pullSyncId.current && response.afterSyncId > pullSyncId.current) pullSyncId.current = response.afterSyncId;
1877
+ if (!eventsBatch.hasMore) break;
1878
+ }
1879
+ });
1880
+ const eventsAppliedSubscription = storage.addEventListener("events-applied", () => {
1881
+ startPushingEvents();
1882
+ });
1883
+ const remoteEventApplyFailedSubscription = storage.addEventListener("remote-event-apply-failed", () => {
1884
+ eventTarget.dispatchEvent("de-sync-detected", { reason: "ERROR_APPLYING_REMOTE_EVENT" });
1885
+ if (remoteState.type === "online" && !remoteState.deSynced) patchRemoteState({ deSynced: true });
1886
+ });
1887
+ const getState = () => ({
1888
+ remoteState: remoteState.type,
1889
+ deSynced: remoteState.deSynced,
1890
+ schemaVersionMismatched: remoteState.schemaVersionMismatched
1891
+ });
1892
+ const dispose = async () => {
1893
+ await goOffline("DISCONNECTED");
1894
+ eventsAppliedSubscription.unsubscribe();
1895
+ remoteEventApplyFailedSubscription.unsubscribe();
1896
+ };
1897
+ return {
1898
+ goOnline,
1899
+ goOffline,
1900
+ syncWithRemote,
1901
+ getState,
1902
+ dispose,
1903
+ addEventListener: eventTarget.addEventListener,
1904
+ removeEventListener: eventTarget.removeEventListener
1905
+ };
1906
+ };
1907
+ //#endregion
1908
+ //#region src/worker-db/worker-common.ts
1909
+ const syncDbWorkerLockName = "sync-db-worker-lock";
1910
+ const syncDbClientLockName = "sync-db-client-lock";
1911
+ const broadcastChannelNames = {
1912
+ requests: "sync-db-worker-requests",
1913
+ responses: "sync-db-worker-responses"
1914
+ };
1915
+ const createBroadcastChannels = (prefix) => {
1916
+ return {
1917
+ requests: new TypedBroadcastChannel(`${prefix}-${broadcastChannelNames.requests}`),
1918
+ responses: new TypedBroadcastChannel(`${prefix}-${broadcastChannelNames.responses}`)
1919
+ };
1920
+ };
1921
+ function isWorkerInitMessage(message) {
1922
+ return typeof message === "object" && message !== null && "type" in message && message.type === "init";
1923
+ }
1924
+ function isWorkerRequestMessage(message) {
1925
+ return typeof message === "object" && message !== null && "type" in message && message.type === "request";
1926
+ }
1927
+ function isWorkerResponseMessage(message) {
1928
+ return typeof message === "object" && message !== null && "type" in message && "requestId" in message && "data" in message;
1929
+ }
1930
+ function isWorkerErrorResponseMessage(message) {
1931
+ return typeof message === "object" && message !== null && "type" in message && message.type === "error-response";
1932
+ }
1933
+ function isWorkerNotificationMessage(message) {
1934
+ return typeof message === "object" && message !== null && "notificationType" in message && !!message.notificationType;
1935
+ }
1936
+ //#endregion
1937
+ //#region src/worker-db/reset-state.ts
1938
+ /** Default IndexedDB-backed reset store (idb-keyval over a dedicated database). */
1939
+ function createIdbResetStore() {
1940
+ const store = createStore("sqlite-sync", "kv");
1941
+ return {
1942
+ get: (key) => get(key, store),
1943
+ set: (key, value) => set(key, value, store),
1944
+ delete: (key) => del(key, store)
1945
+ };
1946
+ }
1947
+ /**
1948
+ * A clean reset is a recovery action for a de-sync detected now. If the reload
1949
+ * never happens (broadcast lost, tab crashed mid-reload, browser killed the page),
1950
+ * the request must not fire on an arbitrary later cold start and silently wipe
1951
+ * local-only writes accumulated since.
1952
+ */
1953
+ const RESET_REQUEST_TTL_MS = 6e5;
1954
+ const resetRequestKey = (dbId) => `sqlite-sync-reset-request-${dbId}`;
1955
+ const resetAppliedKey = (dbId) => `sqlite-sync-reset-applied-${dbId}`;
1956
+ /**
1957
+ * Worker-owned durable reset state. The reset decision must be owned by the
1958
+ * elected worker, not by tabs during `createSyncedDb` — otherwise a later
1959
+ * worker election could repeat an already-applied wipe.
1960
+ */
1961
+ function createResetStateStore({ store, dbId, now = () => Date.now() }) {
1962
+ const requestKey = resetRequestKey(dbId);
1963
+ const appliedKey = resetAppliedKey(dbId);
1964
+ return {
1965
+ async writeResetRequest(epoch) {
1966
+ const request = {
1967
+ epoch,
1968
+ requestedAt: now()
1969
+ };
1970
+ await store.set(requestKey, request);
1971
+ return request;
1972
+ },
1973
+ /**
1974
+ * Read the pending reset request after winning the worker election.
1975
+ * Returns the request only when it has not been applied yet and is within
1976
+ * the TTL. Stale requests are deleted so they cannot fire on a later cold start.
1977
+ */
1978
+ async resolvePendingReset() {
1979
+ const request = await store.get(requestKey);
1980
+ if (!request) return;
1981
+ if (now() - request.requestedAt > 6e5) {
1982
+ await store.delete(requestKey);
1983
+ return;
1984
+ }
1985
+ const appliedEpoch = await store.get(appliedKey);
1986
+ if (request.epoch === appliedEpoch) return;
1987
+ return request;
1988
+ },
1989
+ /**
1990
+ * Record the epoch as applied. Must be called only after the worker has
1991
+ * successfully initialized with `clearOnInit: true`, so a failed init can
1992
+ * be retried by a later elected worker.
1993
+ */
1994
+ async markResetApplied(epoch) {
1995
+ await store.set(appliedKey, epoch);
1996
+ }
1997
+ };
1998
+ }
1999
+ /**
2000
+ * Worker-side `requestReload` RPC implementation. For `clean: true` the reset
2001
+ * request is durably stored before broadcasting and before the RPC resolves,
2002
+ * so the epoch survives no matter which path triggers the reload.
2003
+ */
2004
+ function createReloadRequestHandler({ resetState, broadcast, generateEpoch = generateId }) {
2005
+ return async (options) => {
2006
+ const reloadEpoch = generateEpoch();
2007
+ if (options.clean) await resetState.writeResetRequest(reloadEpoch);
2008
+ broadcast({
2009
+ notificationType: "reload-requested",
2010
+ reloadEpoch,
2011
+ clean: options.clean
2012
+ });
2013
+ };
2014
+ }
2015
+ //#endregion
2016
+ export { serializeHLC as A, createMigrator as C, HLCCounter as D, introspectDb as E, createExportData as M, createImportData as N, compareHLC as O, dummyKysely as P, createMigrations as S, startPerformanceLogger as T, createSQLiteCrdtApplyFunction as _, createBroadcastChannels as a, CrdtEventValidationError as b, isWorkerNotificationMessage as c, syncDbClientLockName as d, syncDbWorkerLockName as f, createCrdtApplyFunction as g, createCrdtStorage as h, createResetStateStore as i, xxhash as j, deserializeHLC as k, isWorkerRequestMessage as l, createCrdtSyncProducer as m, createIdbResetStore as n, isWorkerErrorResponseMessage as o, createCrdtSyncRemoteSource as p, createReloadRequestHandler as r, isWorkerInitMessage as s, RESET_REQUEST_TTL_MS as t, isWorkerResponseMessage as u, formatSchemaVerificationIssues as v, SQLiteDbWrapper as w, validateNewCrdtEvent as x, verifySyncSchema as y };
2017
+
2018
+ //# sourceMappingURL=reset-state-D3nsfx_t.js.map