@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.
- package/dist/index-Ct_YbBmV.d.ts +1159 -0
- package/dist/index.d.ts +2 -351
- package/dist/index.js +904 -1285
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +3 -0
- package/dist/make-crdt-table-BvsEnvu-.js +107 -0
- package/dist/make-crdt-table-BvsEnvu-.js.map +1 -0
- package/dist/reset-state-D3nsfx_t.js +2018 -0
- package/dist/reset-state-D3nsfx_t.js.map +1 -0
- package/dist/server.d.ts +65 -67
- package/dist/server.js +30 -32
- package/dist/server.js.map +1 -1
- package/dist/system-schema-CkglcfTS.js +185 -0
- package/dist/system-schema-CkglcfTS.js.map +1 -0
- package/dist/utils-Dr5JwcTP.js +143 -0
- package/dist/utils-Dr5JwcTP.js.map +1 -0
- package/dist/worker.d.ts +35 -37
- package/dist/worker.js +399 -479
- package/dist/worker.js.map +1 -1
- package/package.json +10 -6
- package/dist/chunk-GMF45NFH.js +0 -2298
- package/dist/chunk-GMF45NFH.js.map +0 -1
- package/dist/chunk-NHT3ELMN.js +0 -160
- package/dist/chunk-NHT3ELMN.js.map +0 -1
- package/dist/crdt-sync-remote-source-CnB4p4Ry.d.ts +0 -713
- package/dist/reset-state-D6UslXaj.d.ts +0 -19
|
@@ -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
|