@korajs/store 1.0.0-beta.11 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -37
- package/dist/adapters/better-sqlite3.cjs +90 -4
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +21 -12
- package/dist/adapters/better-sqlite3.d.ts +21 -12
- package/dist/adapters/better-sqlite3.js +69 -5
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +1316 -610
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +29 -3
- package/dist/adapters/indexeddb.d.ts +29 -3
- package/dist/adapters/indexeddb.js +247 -221
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs +803 -62
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +480 -62
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +1032 -344
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +239 -18
- package/dist/adapters/sqlite-wasm.d.ts +239 -18
- package/dist/adapters/sqlite-wasm.js +111 -5
- package/dist/adapters/sqlite-wasm.js.map +1 -1
- package/dist/backup-4NUPDEMB.js +25 -0
- package/dist/chunk-32EDKWMI.js +876 -0
- package/dist/chunk-32EDKWMI.js.map +1 -0
- package/dist/{chunk-CYO3YKFL.js → chunk-5ZRRF6R2.js} +2 -2
- package/dist/chunk-A6GFH5IG.js +36 -0
- package/dist/chunk-A6GFH5IG.js.map +1 -0
- package/dist/chunk-DUKTWVZW.js +175 -0
- package/dist/chunk-DUKTWVZW.js.map +1 -0
- package/dist/chunk-HZTDBLDB.js +607 -0
- package/dist/chunk-HZTDBLDB.js.map +1 -0
- package/dist/{chunk-GSJ7EEUC.js → chunk-J5AGXF3D.js} +58 -9
- package/dist/chunk-J5AGXF3D.js.map +1 -0
- package/dist/chunk-MCQKEHOD.js +112 -0
- package/dist/chunk-MCQKEHOD.js.map +1 -0
- package/dist/chunk-P4V375FY.js +810 -0
- package/dist/chunk-P4V375FY.js.map +1 -0
- package/dist/chunk-QDUSJMFV.js +1095 -0
- package/dist/chunk-QDUSJMFV.js.map +1 -0
- package/dist/chunk-SJP2HTYZ.js +1003 -0
- package/dist/chunk-SJP2HTYZ.js.map +1 -0
- package/dist/chunk-W5F2LWG6.js +591 -0
- package/dist/chunk-W5F2LWG6.js.map +1 -0
- package/dist/chunk-Y5LRH7KI.js +5341 -0
- package/dist/chunk-Y5LRH7KI.js.map +1 -0
- package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
- package/dist/index.cjs +9354 -4157
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1064 -108
- package/dist/index.d.ts +1064 -108
- package/dist/index.js +249 -3092
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +580 -264
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +15 -85
- package/dist/internal.d.ts +15 -85
- package/dist/internal.js +10 -8
- package/dist/restore-FR7XIKT2.js +299 -0
- package/dist/restore-FR7XIKT2.js.map +1 -0
- package/dist/sql-builder-C4u55jKW.d.cts +608 -0
- package/dist/sql-builder-CGckdBDL.d.ts +608 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.cts +141 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.ts +141 -0
- package/dist/sqlite-wasm-channel-MLHLKEED.js +12 -0
- package/dist/store-IMWKZLWB.js +16 -0
- package/dist/store-IMWKZLWB.js.map +1 -0
- package/dist/{types-BslDOTk0.d.ts → types-Dh-xyUjP.d.cts} +99 -26
- package/dist/{types-BslDOTk0.d.cts → types-Dh-xyUjP.d.ts} +99 -26
- package/package.json +3 -2
- package/dist/backup-5S7K2Z35.js +0 -14
- package/dist/chunk-GSJ7EEUC.js.map +0 -1
- package/dist/chunk-HS6DBMST.js +0 -383
- package/dist/chunk-HS6DBMST.js.map +0 -1
- package/dist/chunk-RJIEKEN4.js +0 -751
- package/dist/chunk-RJIEKEN4.js.map +0 -1
- package/dist/chunk-TUR7RGEP.js +0 -84
- package/dist/chunk-TUR7RGEP.js.map +0 -1
- package/dist/chunk-VDBJQ75K.js +0 -78
- package/dist/chunk-VDBJQ75K.js.map +0 -1
- package/dist/chunk-YT7MRFCP.js +0 -687
- package/dist/chunk-YT7MRFCP.js.map +0 -1
- package/dist/sqlite-wasm-channel-GGHA2BXU.js +0 -12
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.cts +0 -92
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.ts +0 -92
- package/dist/types-BOO9EPt1.d.ts +0 -170
- package/dist/types-C_GDavG-.d.cts +0 -170
- /package/dist/{backup-5S7K2Z35.js.map → backup-4NUPDEMB.js.map} +0 -0
- /package/dist/{chunk-CYO3YKFL.js.map → chunk-5ZRRF6R2.js.map} +0 -0
- /package/dist/{export-audit-ODGQTQIK.js.map → export-audit-J2QXRHD2.js.map} +0 -0
- /package/dist/{sqlite-wasm-channel-GGHA2BXU.js.map → sqlite-wasm-channel-MLHLKEED.js.map} +0 -0
|
@@ -21,7 +21,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
21
21
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
22
|
|
|
23
23
|
// src/errors.ts
|
|
24
|
-
var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError, PersistenceError;
|
|
24
|
+
var import_core, AdapterError, StorageDurabilityError, SchemaVersionAheadError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError, LeaderUnresponsiveError, BridgeTerminatedError, RequestAbortedError, StorageBackendMismatchError, PersistenceError;
|
|
25
25
|
var init_errors = __esm({
|
|
26
26
|
"src/errors.ts"() {
|
|
27
27
|
"use strict";
|
|
@@ -32,6 +32,32 @@ var init_errors = __esm({
|
|
|
32
32
|
this.name = "AdapterError";
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
+
StorageDurabilityError = class extends import_core.KoraError {
|
|
36
|
+
constructor(dbName, phase, reason) {
|
|
37
|
+
super(
|
|
38
|
+
`Database "${dbName}" has no durable storage (${reason} during ${phase}); writes are refused so they are not silently lost on reload. Close other tabs or apps using this origin's storage and reload, or set allowNonDurable: true to accept in-memory storage.`,
|
|
39
|
+
"STORAGE_DURABILITY_LOST",
|
|
40
|
+
{ dbName, phase, reason }
|
|
41
|
+
);
|
|
42
|
+
this.name = "StorageDurabilityError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
SchemaVersionAheadError = class extends import_core.KoraError {
|
|
46
|
+
constructor(dbName, storedVersion, codeVersion) {
|
|
47
|
+
super(
|
|
48
|
+
`Database "${dbName}" is at schema version ${storedVersion}, newer than this build's schema version ${codeVersion}: a newer build of the app already migrated it. It was not opened and nothing in it changed. Run the newer build (reload while online, or accept the update); never lower schema.version in a deployed app.`,
|
|
49
|
+
"SCHEMA_VERSION_AHEAD",
|
|
50
|
+
{ dbName, storedVersion, codeVersion }
|
|
51
|
+
);
|
|
52
|
+
this.dbName = dbName;
|
|
53
|
+
this.storedVersion = storedVersion;
|
|
54
|
+
this.codeVersion = codeVersion;
|
|
55
|
+
this.name = "SchemaVersionAheadError";
|
|
56
|
+
}
|
|
57
|
+
dbName;
|
|
58
|
+
storedVersion;
|
|
59
|
+
codeVersion;
|
|
60
|
+
};
|
|
35
61
|
StoreNotOpenError = class extends import_core.KoraError {
|
|
36
62
|
constructor() {
|
|
37
63
|
super("Store is not open. Call store.open() before performing operations.", "STORE_NOT_OPEN");
|
|
@@ -49,13 +75,50 @@ var init_errors = __esm({
|
|
|
49
75
|
}
|
|
50
76
|
};
|
|
51
77
|
NoLeaderError = class extends import_core.KoraError {
|
|
52
|
-
constructor(operation) {
|
|
78
|
+
constructor(operation, message = `No live leader tab is answering multi-tab storage RPC for operation "${operation}"`, code = "NO_LEADER", context = {}) {
|
|
79
|
+
super(message, code, { operation, ...context });
|
|
80
|
+
this.name = "NoLeaderError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
LeaderUnresponsiveError = class extends NoLeaderError {
|
|
84
|
+
constructor(operation, silentMs, requestId) {
|
|
53
85
|
super(
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
86
|
+
operation,
|
|
87
|
+
`The leader tab stopped responding (no heartbeat for ${silentMs}ms) during "${operation}". The request may or may not have been applied; retry it with the same requestId and the leader de-duplicates it.`,
|
|
88
|
+
"LEADER_UNRESPONSIVE",
|
|
89
|
+
{ silentMs, requestId, retriable: true }
|
|
57
90
|
);
|
|
58
|
-
this.name = "
|
|
91
|
+
this.name = "LeaderUnresponsiveError";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
BridgeTerminatedError = class extends import_core.KoraError {
|
|
95
|
+
constructor(operation, reason) {
|
|
96
|
+
super(
|
|
97
|
+
`Storage request "${operation}" was interrupted (${reason}); retry it against the new storage leader.`,
|
|
98
|
+
"BRIDGE_TERMINATED",
|
|
99
|
+
{ operation, reason, retriable: true }
|
|
100
|
+
);
|
|
101
|
+
this.name = "BridgeTerminatedError";
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
RequestAbortedError = class extends import_core.KoraError {
|
|
105
|
+
constructor(operation, requestId) {
|
|
106
|
+
super(
|
|
107
|
+
`Storage request "${operation}" was aborted by the caller. A write may already have reached the leader; retry with the same requestId to stay idempotent.`,
|
|
108
|
+
"REQUEST_ABORTED",
|
|
109
|
+
{ operation, requestId }
|
|
110
|
+
);
|
|
111
|
+
this.name = "RequestAbortedError";
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
StorageBackendMismatchError = class extends import_core.KoraError {
|
|
115
|
+
constructor(dbName, recorded, requested, reason) {
|
|
116
|
+
super(
|
|
117
|
+
`Database "${dbName}" is stored in ${recorded}, but this session can only use ${requested} (${reason}). Kora will not start a separate empty copy; reload in a browser profile where ${recorded} is available.`,
|
|
118
|
+
"STORAGE_BACKEND_MISMATCH",
|
|
119
|
+
{ dbName, recorded, requested, reason }
|
|
120
|
+
);
|
|
121
|
+
this.name = "StorageBackendMismatchError";
|
|
59
122
|
}
|
|
60
123
|
};
|
|
61
124
|
PersistenceError = class extends import_core.KoraError {
|
|
@@ -130,16 +193,21 @@ var init_sqlite_wasm_channel = __esm({
|
|
|
130
193
|
/**
|
|
131
194
|
* @param workerUrl - URL to the sqlite-wasm-worker script
|
|
132
195
|
* @param timeoutMs - Timeout for worker responses in milliseconds (default: 30000)
|
|
196
|
+
* @param options - Status event listener
|
|
133
197
|
*/
|
|
134
|
-
constructor(workerUrl, timeoutMs = 3e4) {
|
|
198
|
+
constructor(workerUrl, timeoutMs = 3e4, options = {}) {
|
|
135
199
|
this.timeoutMs = timeoutMs;
|
|
136
200
|
this.worker = new Worker(workerUrl, { type: "module" });
|
|
137
201
|
this.worker.onmessage = (event) => {
|
|
138
|
-
const
|
|
139
|
-
|
|
202
|
+
const message = event.data;
|
|
203
|
+
if (message.type === "event") {
|
|
204
|
+
options.onEvent?.(message.event);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const entry = this.pending.get(message.id);
|
|
140
208
|
if (entry) {
|
|
141
|
-
this.pending.delete(
|
|
142
|
-
entry.resolve(
|
|
209
|
+
this.pending.delete(message.id);
|
|
210
|
+
entry.resolve(message);
|
|
143
211
|
}
|
|
144
212
|
};
|
|
145
213
|
this.worker.onerror = (event) => {
|
|
@@ -150,7 +218,17 @@ var init_sqlite_wasm_channel = __esm({
|
|
|
150
218
|
}
|
|
151
219
|
};
|
|
152
220
|
}
|
|
153
|
-
|
|
221
|
+
/** True once {@link terminate} ran. */
|
|
222
|
+
isTerminated() {
|
|
223
|
+
return this.terminated;
|
|
224
|
+
}
|
|
225
|
+
/** Post a request without awaiting a response (fire-and-forget control messages). */
|
|
226
|
+
post(request, clientId) {
|
|
227
|
+
if (this.terminated) return;
|
|
228
|
+
const message = { ...request, id: this.nextId++, clientId };
|
|
229
|
+
this.worker.postMessage(message);
|
|
230
|
+
}
|
|
231
|
+
async send(request, clientId, options = {}) {
|
|
154
232
|
if (this.terminated) {
|
|
155
233
|
return {
|
|
156
234
|
id: request.id,
|
|
@@ -160,23 +238,40 @@ var init_sqlite_wasm_channel = __esm({
|
|
|
160
238
|
};
|
|
161
239
|
}
|
|
162
240
|
const id = this.nextId++;
|
|
163
|
-
const
|
|
241
|
+
const message = { ...request, id, clientId };
|
|
242
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
243
|
+
const signal = options.signal;
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
throw new RequestAbortedError(request.type, request.requestId ?? String(id));
|
|
246
|
+
}
|
|
164
247
|
return new Promise((resolve, reject) => {
|
|
165
|
-
const timer = setTimeout(() => {
|
|
248
|
+
const timer = Number.isFinite(timeoutMs) ? setTimeout(() => {
|
|
166
249
|
this.pending.delete(id);
|
|
167
|
-
|
|
168
|
-
|
|
250
|
+
cleanup();
|
|
251
|
+
reject(new WorkerTimeoutError(message.type, timeoutMs));
|
|
252
|
+
}, timeoutMs) : void 0;
|
|
253
|
+
const onAbort = () => {
|
|
254
|
+
this.pending.delete(id);
|
|
255
|
+
cleanup();
|
|
256
|
+
reject(new RequestAbortedError(request.type, request.requestId ?? String(id)));
|
|
257
|
+
};
|
|
258
|
+
const cleanup = () => {
|
|
259
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
260
|
+
signal?.removeEventListener("abort", onAbort);
|
|
261
|
+
};
|
|
262
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
169
263
|
this.pending.set(id, {
|
|
264
|
+
type: request.type,
|
|
170
265
|
resolve: (response) => {
|
|
171
|
-
|
|
266
|
+
cleanup();
|
|
172
267
|
resolve(response);
|
|
173
268
|
},
|
|
174
269
|
reject: (error) => {
|
|
175
|
-
|
|
270
|
+
cleanup();
|
|
176
271
|
reject(error);
|
|
177
272
|
}
|
|
178
273
|
});
|
|
179
|
-
this.worker.postMessage(
|
|
274
|
+
this.worker.postMessage(message);
|
|
180
275
|
});
|
|
181
276
|
}
|
|
182
277
|
terminate() {
|
|
@@ -185,7 +280,7 @@ var init_sqlite_wasm_channel = __esm({
|
|
|
185
280
|
this.worker.terminate();
|
|
186
281
|
for (const [id, entry] of this.pending) {
|
|
187
282
|
this.pending.delete(id);
|
|
188
|
-
entry.reject(new
|
|
283
|
+
entry.reject(new BridgeTerminatedError(entry.type, "worker terminated"));
|
|
189
284
|
}
|
|
190
285
|
}
|
|
191
286
|
};
|
|
@@ -200,9 +295,81 @@ __export(indexeddb_exports, {
|
|
|
200
295
|
module.exports = __toCommonJS(indexeddb_exports);
|
|
201
296
|
|
|
202
297
|
// src/adapters/indexeddb-adapter.ts
|
|
203
|
-
var import_core3 = require("@korajs/core");
|
|
204
298
|
init_errors();
|
|
205
299
|
|
|
300
|
+
// src/migrations/schema-ceiling.ts
|
|
301
|
+
function storedSchemaVersion(rows) {
|
|
302
|
+
const value = Number(rows[0]?.value ?? 0);
|
|
303
|
+
return Number.isFinite(value) ? value : 0;
|
|
304
|
+
}
|
|
305
|
+
var STORED_SCHEMA_VERSION_SQL = "SELECT value FROM _kora_meta WHERE key = 'schema_version'";
|
|
306
|
+
|
|
307
|
+
// src/adapters/database-dump.ts
|
|
308
|
+
var import_core2 = require("@korajs/core");
|
|
309
|
+
init_errors();
|
|
310
|
+
function ensureSafeIdentifier(identifier) {
|
|
311
|
+
if (!/^[a-zA-Z0-9_]+$/.test(identifier)) {
|
|
312
|
+
throw new PersistenceError(`Unsafe SQL identifier: ${identifier}`, {
|
|
313
|
+
code: "UNSAFE_IDENTIFIER",
|
|
314
|
+
identifier
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return identifier;
|
|
318
|
+
}
|
|
319
|
+
async function exportDump(query) {
|
|
320
|
+
const tableRows = await query(
|
|
321
|
+
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
322
|
+
);
|
|
323
|
+
const tables = [];
|
|
324
|
+
for (const tableRow of tableRows) {
|
|
325
|
+
const tableName = ensureSafeIdentifier(tableRow.name);
|
|
326
|
+
const columns = await query(`PRAGMA table_info(${(0, import_core2.quoteIdent)(tableName)})`);
|
|
327
|
+
const rows = await query(`SELECT * FROM ${(0, import_core2.quoteIdent)(tableName)}`);
|
|
328
|
+
tables.push({
|
|
329
|
+
name: tableName,
|
|
330
|
+
...typeof tableRow.sql === "string" ? { sql: tableRow.sql } : {},
|
|
331
|
+
columns: columns.map((column) => column.name),
|
|
332
|
+
rows
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return { tables };
|
|
336
|
+
}
|
|
337
|
+
function restoreDumpStatements(dump, _createMissingTables = true) {
|
|
338
|
+
const statements = [];
|
|
339
|
+
for (const table of dump.tables) {
|
|
340
|
+
const name = (0, import_core2.quoteIdent)(ensureSafeIdentifier(table.name));
|
|
341
|
+
const create = createTableIfNotExists(table.name, table.sql);
|
|
342
|
+
if (create) {
|
|
343
|
+
statements.push({ sql: create });
|
|
344
|
+
} else {
|
|
345
|
+
const columns = table.columns.map((column) => (0, import_core2.quoteIdent)(ensureSafeIdentifier(column)));
|
|
346
|
+
if (columns.length === 0) continue;
|
|
347
|
+
statements.push({ sql: `CREATE TABLE IF NOT EXISTS ${name} (${columns.join(", ")})` });
|
|
348
|
+
}
|
|
349
|
+
statements.push({ sql: `DELETE FROM ${name}` });
|
|
350
|
+
for (const row of table.rows) {
|
|
351
|
+
const columns = table.columns.filter(
|
|
352
|
+
(column) => Object.prototype.hasOwnProperty.call(row, column)
|
|
353
|
+
);
|
|
354
|
+
if (columns.length === 0) continue;
|
|
355
|
+
const quotedColumns = columns.map((column) => (0, import_core2.quoteIdent)(ensureSafeIdentifier(column))).join(", ");
|
|
356
|
+
statements.push({
|
|
357
|
+
sql: `INSERT INTO ${name} (${quotedColumns}) VALUES (${columns.map(() => "?").join(", ")})`,
|
|
358
|
+
params: columns.map((column) => row[column])
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return statements;
|
|
363
|
+
}
|
|
364
|
+
function createTableIfNotExists(tableName, sql) {
|
|
365
|
+
if (typeof sql !== "string" || sql.includes(";")) return null;
|
|
366
|
+
const match = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?("?)([A-Za-z0-9_]+)\1\s*\(/i.exec(
|
|
367
|
+
sql
|
|
368
|
+
);
|
|
369
|
+
if (!match || match[2] !== tableName) return null;
|
|
370
|
+
return `CREATE TABLE IF NOT EXISTS ${(0, import_core2.quoteIdent)(tableName)} ${sql.slice(match[0].length - 1)}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
206
373
|
// src/adapters/indexeddb-persistence-scheduler.ts
|
|
207
374
|
var DEFAULT_DEBOUNCE_MS = 500;
|
|
208
375
|
var IndexedDbPersistenceScheduler = class {
|
|
@@ -212,6 +379,15 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
212
379
|
timer = null;
|
|
213
380
|
inFlight = null;
|
|
214
381
|
disposed = false;
|
|
382
|
+
/**
|
|
383
|
+
* Mutation generations (RT-35): `scheduled` counts schedule() calls (one per committed
|
|
384
|
+
* write), `persisted` is the generation the last SUCCESSFUL snapshot covered. A
|
|
385
|
+
* snapshot covers every generation scheduled before it started.
|
|
386
|
+
*/
|
|
387
|
+
scheduledGeneration = 0;
|
|
388
|
+
persistedGeneration = 0;
|
|
389
|
+
/** Error of the last snapshot attempt, cleared by the next success. */
|
|
390
|
+
lastFlushError = null;
|
|
215
391
|
onVisibilityChange;
|
|
216
392
|
constructor(options) {
|
|
217
393
|
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
@@ -229,6 +405,7 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
229
405
|
/** Schedule a debounced snapshot write. */
|
|
230
406
|
schedule() {
|
|
231
407
|
if (this.disposed) return;
|
|
408
|
+
this.scheduledGeneration++;
|
|
232
409
|
if (this.debounceMs <= 0) {
|
|
233
410
|
void this.flushNow();
|
|
234
411
|
return;
|
|
@@ -241,22 +418,75 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
241
418
|
void this.flushNow();
|
|
242
419
|
}, this.debounceMs);
|
|
243
420
|
}
|
|
244
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Cancel any pending debounce and persist immediately. Resolves only when a snapshot
|
|
423
|
+
* taken after every write scheduled so far was written (STORE-7): a snapshot already in
|
|
424
|
+
* flight may predate the latest writes, so the call waits for it and then writes again
|
|
425
|
+
* while anything is still dirty. A failed snapshot ends the loop (the failure is
|
|
426
|
+
* reported through `onError`; {@link flushBarrier} is the variant that rejects).
|
|
427
|
+
*/
|
|
245
428
|
async flushNow() {
|
|
246
429
|
if (this.disposed) return;
|
|
247
430
|
if (this.timer !== null) {
|
|
248
431
|
clearTimeout(this.timer);
|
|
249
432
|
this.timer = null;
|
|
250
433
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
434
|
+
let mustRun = this.inFlight === null;
|
|
435
|
+
for (; ; ) {
|
|
436
|
+
if (this.inFlight) {
|
|
437
|
+
await this.inFlight;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (!mustRun && this.persistedGeneration >= this.scheduledGeneration) return;
|
|
441
|
+
mustRun = false;
|
|
442
|
+
this.inFlight = this.runFlush();
|
|
443
|
+
try {
|
|
444
|
+
await this.inFlight;
|
|
445
|
+
} finally {
|
|
446
|
+
this.inFlight = null;
|
|
447
|
+
}
|
|
448
|
+
if (this.lastFlushError !== null) return;
|
|
254
449
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
450
|
+
}
|
|
451
|
+
/** Whether a write was scheduled that no successful snapshot covers yet. */
|
|
452
|
+
isDirty() {
|
|
453
|
+
return this.persistedGeneration < this.scheduledGeneration;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Durability barrier (RT-35): resolve once every write scheduled before this call is
|
|
457
|
+
* in a snapshot persisted to IndexedDB. A snapshot already in flight may predate the
|
|
458
|
+
* latest write, so the barrier waits for it and then writes a fresh one when needed.
|
|
459
|
+
* Rejects when the snapshot cannot be written: the caller must treat the writes as
|
|
460
|
+
* not durable (for example, not upload them yet).
|
|
461
|
+
*/
|
|
462
|
+
async flushBarrier() {
|
|
463
|
+
const target = this.scheduledGeneration;
|
|
464
|
+
if (this.persistedGeneration >= target) return;
|
|
465
|
+
if (this.disposed) {
|
|
466
|
+
throw new Error("IndexedDB persistence was disposed before the writes were persisted");
|
|
467
|
+
}
|
|
468
|
+
if (this.timer !== null) {
|
|
469
|
+
clearTimeout(this.timer);
|
|
470
|
+
this.timer = null;
|
|
471
|
+
}
|
|
472
|
+
for (let round = 0; round < 3 && this.persistedGeneration < target; round++) {
|
|
473
|
+
if (this.inFlight) {
|
|
474
|
+
try {
|
|
475
|
+
await this.inFlight;
|
|
476
|
+
} catch {
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
this.inFlight = this.runFlush();
|
|
481
|
+
try {
|
|
482
|
+
await this.inFlight;
|
|
483
|
+
} finally {
|
|
484
|
+
this.inFlight = null;
|
|
485
|
+
}
|
|
486
|
+
if (this.persistedGeneration < target) break;
|
|
487
|
+
}
|
|
488
|
+
if (this.persistedGeneration < target) {
|
|
489
|
+
throw this.lastFlushError instanceof Error ? this.lastFlushError : new Error("IndexedDB snapshot was not persisted");
|
|
260
490
|
}
|
|
261
491
|
}
|
|
262
492
|
dispose() {
|
|
@@ -270,18 +500,30 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
270
500
|
}
|
|
271
501
|
}
|
|
272
502
|
async runFlush() {
|
|
503
|
+
const generation = this.scheduledGeneration;
|
|
273
504
|
try {
|
|
274
505
|
await this.flush();
|
|
506
|
+
this.persistedGeneration = Math.max(this.persistedGeneration, generation);
|
|
507
|
+
this.lastFlushError = null;
|
|
275
508
|
} catch (error) {
|
|
509
|
+
this.lastFlushError = error;
|
|
276
510
|
this.onError?.(error);
|
|
277
511
|
}
|
|
278
512
|
}
|
|
279
513
|
};
|
|
280
514
|
|
|
281
515
|
// src/adapters/sqlite-wasm-adapter.ts
|
|
282
|
-
var
|
|
516
|
+
var import_core3 = require("@korajs/core");
|
|
283
517
|
init_errors();
|
|
284
518
|
|
|
519
|
+
// src/adapters/opfs-names.ts
|
|
520
|
+
function leaderLockName(dbName) {
|
|
521
|
+
return `kora-leader-${dbName}`;
|
|
522
|
+
}
|
|
523
|
+
function storageChannelName(dbName) {
|
|
524
|
+
return `kora-storage-${dbName}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
285
527
|
// src/multi-tab/tab-storage.ts
|
|
286
528
|
init_errors();
|
|
287
529
|
var RPC_REQUEST = "kora-worker-request";
|
|
@@ -289,14 +531,16 @@ var RPC_RESPONSE = "kora-worker-response";
|
|
|
289
531
|
var CLIENT_LEAVE = "kora-client-leave";
|
|
290
532
|
var LEADER_PING = "kora-leader-ping";
|
|
291
533
|
var LEADER_PONG = "kora-leader-pong";
|
|
292
|
-
var
|
|
293
|
-
var
|
|
534
|
+
var LEADER_HEARTBEAT = "kora-leader-heartbeat";
|
|
535
|
+
var DEFAULT_HEARTBEAT_MS = 1e3;
|
|
536
|
+
var MISSED_BEATS_UNRESPONSIVE = 3;
|
|
537
|
+
var MISSED_BEATS_ABSENT = 2;
|
|
294
538
|
function isMultiTabStorageSupported() {
|
|
295
539
|
return typeof globalThis !== "undefined" && typeof BroadcastChannel !== "undefined" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
|
|
296
540
|
}
|
|
297
541
|
async function acquireTabStorageSession(dbName, options) {
|
|
298
|
-
const channelName =
|
|
299
|
-
const lockName =
|
|
542
|
+
const channelName = storageChannelName(dbName);
|
|
543
|
+
const lockName = leaderLockName(dbName);
|
|
300
544
|
if (!isMultiTabStorageSupported()) {
|
|
301
545
|
return { role: "leader", channelName };
|
|
302
546
|
}
|
|
@@ -344,51 +588,6 @@ function startFollowerSession(channelName, lockName, options) {
|
|
|
344
588
|
}
|
|
345
589
|
};
|
|
346
590
|
}
|
|
347
|
-
function startLeaderRpcRelay(channelName, bridge) {
|
|
348
|
-
const channel = new BroadcastChannel(channelName);
|
|
349
|
-
const onMessage = (event) => {
|
|
350
|
-
const data = event.data;
|
|
351
|
-
if (data?.type === LEADER_PING) {
|
|
352
|
-
channel.postMessage({ type: LEADER_PONG });
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
if (data?.type === CLIENT_LEAVE) {
|
|
356
|
-
if (hasReclaimClient(bridge)) {
|
|
357
|
-
bridge.reclaimClient(data.clientId, "client-left");
|
|
358
|
-
}
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
if (data?.type !== RPC_REQUEST) {
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
void bridge.send(data.request, data.clientId).then((response) => {
|
|
365
|
-
const msg = {
|
|
366
|
-
type: RPC_RESPONSE,
|
|
367
|
-
requestId: data.requestId,
|
|
368
|
-
response
|
|
369
|
-
};
|
|
370
|
-
channel.postMessage(msg);
|
|
371
|
-
}).catch((error) => {
|
|
372
|
-
const message = error instanceof Error ? error.message : "Worker RPC failed";
|
|
373
|
-
const msg = {
|
|
374
|
-
type: RPC_RESPONSE,
|
|
375
|
-
requestId: data.requestId,
|
|
376
|
-
response: {
|
|
377
|
-
id: data.request.id,
|
|
378
|
-
type: "error",
|
|
379
|
-
message,
|
|
380
|
-
code: "LEADER_RPC_ERROR"
|
|
381
|
-
}
|
|
382
|
-
};
|
|
383
|
-
channel.postMessage(msg);
|
|
384
|
-
});
|
|
385
|
-
};
|
|
386
|
-
channel.addEventListener("message", onMessage);
|
|
387
|
-
return () => {
|
|
388
|
-
channel.removeEventListener("message", onMessage);
|
|
389
|
-
channel.close();
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
591
|
var FollowerBroadcastBridge = class {
|
|
393
592
|
channel;
|
|
394
593
|
clientId = createClientId();
|
|
@@ -397,17 +596,28 @@ var FollowerBroadcastBridge = class {
|
|
|
397
596
|
};
|
|
398
597
|
pending = /* @__PURE__ */ new Map();
|
|
399
598
|
timeoutMs;
|
|
400
|
-
|
|
599
|
+
heartbeatMs;
|
|
401
600
|
terminated = false;
|
|
402
|
-
|
|
601
|
+
lastHeardAt = 0;
|
|
602
|
+
heardSinceWatch = false;
|
|
603
|
+
watchdog;
|
|
604
|
+
/**
|
|
605
|
+
* @param channelName - Storage channel of the database
|
|
606
|
+
* @param timeoutMs - Hard ceiling per request (default 30s)
|
|
607
|
+
* @param heartbeatMs - Expected leader heartbeat interval, also the watchdog tick
|
|
608
|
+
*/
|
|
609
|
+
constructor(channelName, timeoutMs = 3e4, heartbeatMs = DEFAULT_HEARTBEAT_MS) {
|
|
403
610
|
this.timeoutMs = timeoutMs;
|
|
404
|
-
this.
|
|
611
|
+
this.heartbeatMs = Math.max(10, Math.min(heartbeatMs, timeoutMs));
|
|
405
612
|
this.channel = new BroadcastChannel(channelName);
|
|
406
613
|
if (typeof addEventListener === "function") {
|
|
407
614
|
addEventListener("pagehide", this.onPageHide);
|
|
408
615
|
}
|
|
409
616
|
this.channel.addEventListener("message", (event) => {
|
|
410
617
|
const data = event.data;
|
|
618
|
+
if (data?.type === LEADER_HEARTBEAT || data?.type === LEADER_PONG || data?.type === RPC_RESPONSE) {
|
|
619
|
+
this.markHeard();
|
|
620
|
+
}
|
|
411
621
|
if (data?.type !== RPC_RESPONSE) {
|
|
412
622
|
return;
|
|
413
623
|
}
|
|
@@ -422,7 +632,7 @@ var FollowerBroadcastBridge = class {
|
|
|
422
632
|
* Readiness handshake: resolves `true` as soon as a live leader answers a ping,
|
|
423
633
|
* or `false` if none answers within the budget. The adapter calls this before
|
|
424
634
|
* its first RPC so a follower created before a leader relay is live retries the
|
|
425
|
-
* handshake instead of firing into the void
|
|
635
|
+
* handshake instead of firing into the void.
|
|
426
636
|
*/
|
|
427
637
|
async waitForLeader(timeoutMs = 3e3, attempts = 3) {
|
|
428
638
|
const perAttempt = Math.max(50, Math.floor(timeoutMs / Math.max(1, attempts)));
|
|
@@ -460,7 +670,7 @@ var FollowerBroadcastBridge = class {
|
|
|
460
670
|
this.channel.postMessage({ type: LEADER_PING });
|
|
461
671
|
});
|
|
462
672
|
}
|
|
463
|
-
async send(request) {
|
|
673
|
+
async send(request, _clientId, options = {}) {
|
|
464
674
|
if (this.terminated) {
|
|
465
675
|
return {
|
|
466
676
|
id: request.id,
|
|
@@ -469,34 +679,42 @@ var FollowerBroadcastBridge = class {
|
|
|
469
679
|
code: "BRIDGE_TERMINATED"
|
|
470
680
|
};
|
|
471
681
|
}
|
|
472
|
-
const requestId = createClientId();
|
|
682
|
+
const requestId = request.requestId ?? createClientId();
|
|
683
|
+
const signal = options.signal;
|
|
684
|
+
if (signal?.aborted) {
|
|
685
|
+
throw new RequestAbortedError(request.type, requestId);
|
|
686
|
+
}
|
|
473
687
|
const msg = {
|
|
474
688
|
type: RPC_REQUEST,
|
|
475
689
|
requestId,
|
|
476
690
|
clientId: this.clientId,
|
|
477
|
-
request
|
|
691
|
+
request: { ...request, requestId }
|
|
478
692
|
};
|
|
693
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
479
694
|
return new Promise((resolve, reject) => {
|
|
695
|
+
let timer;
|
|
696
|
+
const onAbort = () => {
|
|
697
|
+
settle(() => reject(new RequestAbortedError(request.type, requestId)));
|
|
698
|
+
};
|
|
480
699
|
const settle = (fn) => {
|
|
481
|
-
clearTimeout(timer);
|
|
482
|
-
|
|
700
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
701
|
+
signal?.removeEventListener("abort", onAbort);
|
|
483
702
|
this.pending.delete(requestId);
|
|
703
|
+
this.stopWatchdogIfIdle();
|
|
484
704
|
fn();
|
|
485
705
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
settle(() => reject(new NoLeaderError(`follower-rpc:${request.type}`)));
|
|
493
|
-
}
|
|
494
|
-
});
|
|
495
|
-
}, this.livenessProbeMs);
|
|
706
|
+
if (Number.isFinite(timeoutMs)) {
|
|
707
|
+
timer = setTimeout(() => {
|
|
708
|
+
settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, timeoutMs)));
|
|
709
|
+
}, timeoutMs);
|
|
710
|
+
}
|
|
711
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
496
712
|
this.pending.set(requestId, {
|
|
713
|
+
type: request.type,
|
|
497
714
|
resolve: (response) => settle(() => resolve(response)),
|
|
498
715
|
reject: (error) => settle(() => reject(error))
|
|
499
716
|
});
|
|
717
|
+
this.startWatchdog();
|
|
500
718
|
this.channel.postMessage(msg);
|
|
501
719
|
});
|
|
502
720
|
}
|
|
@@ -510,198 +728,63 @@ var FollowerBroadcastBridge = class {
|
|
|
510
728
|
removeEventListener("pagehide", this.onPageHide);
|
|
511
729
|
}
|
|
512
730
|
this.channel.close();
|
|
513
|
-
for (const [, entry] of this.pending) {
|
|
514
|
-
entry.reject(new
|
|
731
|
+
for (const [, entry] of [...this.pending]) {
|
|
732
|
+
entry.reject(new BridgeTerminatedError(entry.type, "follower bridge terminated"));
|
|
515
733
|
}
|
|
516
734
|
this.pending.clear();
|
|
735
|
+
this.stopWatchdog();
|
|
517
736
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
type: CLIENT_LEAVE,
|
|
522
|
-
clientId: this.clientId
|
|
523
|
-
};
|
|
524
|
-
this.channel.postMessage(message);
|
|
525
|
-
} catch {
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
};
|
|
529
|
-
var TransactionSerializingWorkerBridge = class {
|
|
530
|
-
inner;
|
|
531
|
-
leaderClientId = createClientId();
|
|
532
|
-
transactionIdleTimeoutMs;
|
|
533
|
-
queue = [];
|
|
534
|
-
activeTransactionClient = null;
|
|
535
|
-
abortedClients = /* @__PURE__ */ new Set();
|
|
536
|
-
transactionIdleTimer = null;
|
|
537
|
-
reclaiming = false;
|
|
538
|
-
processing = false;
|
|
539
|
-
terminated = false;
|
|
540
|
-
constructor(inner, transactionIdleTimeoutMs = DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS) {
|
|
541
|
-
this.inner = inner;
|
|
542
|
-
this.transactionIdleTimeoutMs = transactionIdleTimeoutMs;
|
|
737
|
+
markHeard() {
|
|
738
|
+
this.lastHeardAt = Date.now();
|
|
739
|
+
this.heardSinceWatch = true;
|
|
543
740
|
}
|
|
544
|
-
|
|
545
|
-
if (this.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
code: "WORKER_TERMINATED"
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
if (this.abortedClients.has(clientId)) {
|
|
554
|
-
if (request.type === "begin") {
|
|
555
|
-
this.abortedClients.delete(clientId);
|
|
556
|
-
} else {
|
|
557
|
-
return Promise.resolve({
|
|
558
|
-
id: request.id,
|
|
559
|
-
type: "error",
|
|
560
|
-
message: "Previous transaction was aborted because the client stopped sending requests.",
|
|
561
|
-
code: "TRANSACTION_ABORTED"
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return new Promise((resolve, reject) => {
|
|
566
|
-
this.queue.push({ clientId, request, resolve, reject });
|
|
567
|
-
void this.processQueue();
|
|
568
|
-
});
|
|
741
|
+
startWatchdog() {
|
|
742
|
+
if (this.watchdog !== void 0) return;
|
|
743
|
+
const now = Date.now();
|
|
744
|
+
this.heardSinceWatch = now - this.lastHeardAt < this.heartbeatMs * 2;
|
|
745
|
+
if (!this.heardSinceWatch) this.lastHeardAt = now;
|
|
746
|
+
this.watchdog = setInterval(() => this.checkLiveness(), this.heartbeatMs);
|
|
569
747
|
}
|
|
570
|
-
|
|
571
|
-
if (this.
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
this.terminated = true;
|
|
575
|
-
this.clearTransactionIdleTimer();
|
|
576
|
-
this.inner.terminate();
|
|
577
|
-
const pending = this.queue.splice(0);
|
|
578
|
-
for (const entry of pending) {
|
|
579
|
-
entry.reject(new Error("Worker terminated"));
|
|
580
|
-
}
|
|
748
|
+
stopWatchdogIfIdle() {
|
|
749
|
+
if (this.pending.size === 0) this.stopWatchdog();
|
|
581
750
|
}
|
|
582
|
-
|
|
583
|
-
if (this.
|
|
584
|
-
|
|
751
|
+
stopWatchdog() {
|
|
752
|
+
if (this.watchdog !== void 0) {
|
|
753
|
+
clearInterval(this.watchdog);
|
|
754
|
+
this.watchdog = void 0;
|
|
585
755
|
}
|
|
586
|
-
void this.reclaimClientNow(clientId, reason);
|
|
587
756
|
}
|
|
588
|
-
|
|
589
|
-
if (this.
|
|
757
|
+
checkLiveness() {
|
|
758
|
+
if (this.pending.size === 0) {
|
|
759
|
+
this.stopWatchdog();
|
|
590
760
|
return;
|
|
591
761
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (!entry) {
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
try {
|
|
604
|
-
const response = await this.inner.send(entry.request);
|
|
605
|
-
this.recordTransactionState(entry, response);
|
|
606
|
-
entry.resolve(response);
|
|
607
|
-
} catch (error) {
|
|
608
|
-
this.resetFailedTransaction(entry);
|
|
609
|
-
entry.reject(error instanceof Error ? error : new Error(String(error)));
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
} finally {
|
|
613
|
-
this.processing = false;
|
|
614
|
-
if (!this.terminated && !this.reclaiming && this.nextRunnableIndex() !== -1) {
|
|
615
|
-
void this.processQueue();
|
|
762
|
+
const silentMs = Date.now() - this.lastHeardAt;
|
|
763
|
+
const heard = this.heardSinceWatch;
|
|
764
|
+
const limit = this.heartbeatMs * (heard ? MISSED_BEATS_UNRESPONSIVE : MISSED_BEATS_ABSENT);
|
|
765
|
+
if (silentMs >= limit) {
|
|
766
|
+
for (const [requestId, entry] of [...this.pending]) {
|
|
767
|
+
entry.reject(
|
|
768
|
+
heard ? new LeaderUnresponsiveError(`follower-rpc:${entry.type}`, silentMs, requestId) : new NoLeaderError(`follower-rpc:${entry.type}`)
|
|
769
|
+
);
|
|
616
770
|
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
nextRunnableIndex() {
|
|
620
|
-
if (this.queue.length === 0) {
|
|
621
|
-
return -1;
|
|
622
|
-
}
|
|
623
|
-
if (this.activeTransactionClient === null) {
|
|
624
|
-
return 0;
|
|
625
|
-
}
|
|
626
|
-
return this.queue.findIndex((entry) => entry.clientId === this.activeTransactionClient);
|
|
627
|
-
}
|
|
628
|
-
recordTransactionState(entry, response) {
|
|
629
|
-
if (response.type === "error") {
|
|
630
|
-
this.resetFailedTransaction(entry);
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
if (entry.request.type === "begin") {
|
|
634
|
-
this.activeTransactionClient = entry.clientId;
|
|
635
|
-
this.armTransactionIdleTimer(entry.clientId);
|
|
636
771
|
return;
|
|
637
772
|
}
|
|
638
|
-
if (
|
|
639
|
-
this.
|
|
640
|
-
}
|
|
641
|
-
if (this.activeTransactionClient === entry.clientId && (entry.request.type === "commit" || entry.request.type === "rollback")) {
|
|
642
|
-
this.activeTransactionClient = null;
|
|
643
|
-
this.clearTransactionIdleTimer();
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
resetFailedTransaction(entry) {
|
|
647
|
-
if (this.activeTransactionClient === entry.clientId) {
|
|
648
|
-
this.activeTransactionClient = null;
|
|
649
|
-
this.clearTransactionIdleTimer();
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
armTransactionIdleTimer(clientId) {
|
|
653
|
-
this.clearTransactionIdleTimer();
|
|
654
|
-
this.transactionIdleTimer = setTimeout(() => {
|
|
655
|
-
void this.reclaimClientNow(clientId, "transaction-idle-timeout");
|
|
656
|
-
}, this.transactionIdleTimeoutMs);
|
|
657
|
-
}
|
|
658
|
-
clearTransactionIdleTimer() {
|
|
659
|
-
if (this.transactionIdleTimer) {
|
|
660
|
-
clearTimeout(this.transactionIdleTimer);
|
|
661
|
-
this.transactionIdleTimer = null;
|
|
773
|
+
if (silentMs >= this.heartbeatMs) {
|
|
774
|
+
this.channel.postMessage({ type: LEADER_PING });
|
|
662
775
|
}
|
|
663
776
|
}
|
|
664
|
-
|
|
665
|
-
if (this.reclaiming || this.activeTransactionClient !== clientId) {
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
this.reclaiming = true;
|
|
669
|
-
this.clearTransactionIdleTimer();
|
|
670
|
-
this.activeTransactionClient = null;
|
|
671
|
-
this.abortedClients.add(clientId);
|
|
672
|
-
this.rejectQueuedClientRequests(clientId, reason);
|
|
777
|
+
leaveLeader() {
|
|
673
778
|
try {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
779
|
+
const message = {
|
|
780
|
+
type: CLIENT_LEAVE,
|
|
781
|
+
clientId: this.clientId
|
|
782
|
+
};
|
|
783
|
+
this.channel.postMessage(message);
|
|
677
784
|
} catch {
|
|
678
|
-
} finally {
|
|
679
|
-
this.reclaiming = false;
|
|
680
|
-
if (!this.terminated && this.nextRunnableIndex() !== -1) {
|
|
681
|
-
void this.processQueue();
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
rejectQueuedClientRequests(clientId, reason) {
|
|
686
|
-
const keep = [];
|
|
687
|
-
for (const entry of this.queue) {
|
|
688
|
-
if (entry.clientId === clientId) {
|
|
689
|
-
entry.resolve({
|
|
690
|
-
id: entry.request.id,
|
|
691
|
-
type: "error",
|
|
692
|
-
message: `Transaction client was reclaimed: ${reason}`,
|
|
693
|
-
code: "TRANSACTION_ABORTED"
|
|
694
|
-
});
|
|
695
|
-
} else {
|
|
696
|
-
keep.push(entry);
|
|
697
|
-
}
|
|
698
785
|
}
|
|
699
|
-
this.queue = keep;
|
|
700
786
|
}
|
|
701
787
|
};
|
|
702
|
-
function hasReclaimClient(bridge) {
|
|
703
|
-
return typeof bridge.reclaimClient === "function";
|
|
704
|
-
}
|
|
705
788
|
function createClientId() {
|
|
706
789
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
707
790
|
return crypto.randomUUID();
|
|
@@ -711,100 +794,510 @@ function createClientId() {
|
|
|
711
794
|
|
|
712
795
|
// src/adapters/sqlite-wasm-adapter.ts
|
|
713
796
|
init_sqlite_wasm_channel();
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
emitter;
|
|
725
|
-
tabSession = null;
|
|
726
|
-
emitNonPersistentDiagnostic;
|
|
727
|
-
storageOpenState = null;
|
|
728
|
-
/** Retained so a follower promoted to leader can re-open its own worker. */
|
|
729
|
-
schema = null;
|
|
730
|
-
promoting = false;
|
|
731
|
-
constructor(options = {}) {
|
|
732
|
-
this.injectedBridge = options.bridge;
|
|
733
|
-
this.workerUrl = options.workerUrl;
|
|
734
|
-
this.sharedWorkerUrl = options.sharedWorkerUrl;
|
|
735
|
-
this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
|
|
736
|
-
this.dbName = options.dbName ?? "kora-db";
|
|
737
|
-
this.emitter = options.emitter;
|
|
738
|
-
this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
|
|
797
|
+
|
|
798
|
+
// src/adapters/sqlite-wasm-persistence.ts
|
|
799
|
+
init_errors();
|
|
800
|
+
var IDB_DATABASE_NAME = "kora-persistence";
|
|
801
|
+
var IDB_STORE_NAME = "databases";
|
|
802
|
+
var IDB_VERSION = 1;
|
|
803
|
+
var DUMP_SUFFIX = "::dump";
|
|
804
|
+
function isIndexedDbQuotaError(error) {
|
|
805
|
+
if (error instanceof DOMException && error.name === "QuotaExceededError") {
|
|
806
|
+
return true;
|
|
739
807
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const ddlStatements = (0, import_core2.generateFullDDL)(schema);
|
|
743
|
-
if (this.injectedBridge) {
|
|
744
|
-
this.bridge = this.injectedBridge;
|
|
745
|
-
const response = await this.openCurrentBridge(ddlStatements);
|
|
746
|
-
this.reportStorageMode(response.data);
|
|
747
|
-
this.opened = true;
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
if (this.workerUrl) {
|
|
751
|
-
this.warnSharedWorkerDeprecated();
|
|
752
|
-
await this.openDurableWorkerBridge(schema);
|
|
753
|
-
const response = await this.openCurrentBridge(ddlStatements);
|
|
754
|
-
this.reportStorageMode(response.data);
|
|
755
|
-
this.opened = true;
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
throw new AdapterError(
|
|
759
|
-
'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
|
|
760
|
-
);
|
|
808
|
+
if (error instanceof PersistenceError && error.context?.quotaExceeded === true) {
|
|
809
|
+
return true;
|
|
761
810
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
)
|
|
785
|
-
|
|
811
|
+
if (error instanceof Error && /quota/i.test(error.message)) {
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
function rejectWithIdbError(reject, message, context, event) {
|
|
817
|
+
const domError = event.target?.error;
|
|
818
|
+
if (domError && isIndexedDbQuotaError(domError)) {
|
|
819
|
+
reject(
|
|
820
|
+
new PersistenceError(message, {
|
|
821
|
+
...context,
|
|
822
|
+
quotaExceeded: true,
|
|
823
|
+
cause: domError.message
|
|
824
|
+
})
|
|
825
|
+
);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
reject(new PersistenceError(message, context));
|
|
829
|
+
}
|
|
830
|
+
function openIdb() {
|
|
831
|
+
return new Promise((resolve, reject) => {
|
|
832
|
+
const request = indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
|
|
833
|
+
request.onupgradeneeded = () => {
|
|
834
|
+
const db = request.result;
|
|
835
|
+
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
|
836
|
+
db.createObjectStore(IDB_STORE_NAME);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
request.onsuccess = () => resolve(request.result);
|
|
840
|
+
request.onerror = () => reject(
|
|
841
|
+
new PersistenceError(
|
|
842
|
+
`Failed to open IndexedDB: ${request.error?.message ?? "unknown error"}`,
|
|
843
|
+
{ database: IDB_DATABASE_NAME }
|
|
844
|
+
)
|
|
845
|
+
);
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
async function saveToIndexedDB(dbName, data) {
|
|
849
|
+
const idb = await openIdb();
|
|
850
|
+
try {
|
|
851
|
+
await new Promise((resolve, reject) => {
|
|
852
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
853
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
854
|
+
store.put(data, dbName);
|
|
855
|
+
tx.oncomplete = () => resolve();
|
|
856
|
+
tx.onerror = (event) => rejectWithIdbError(
|
|
857
|
+
reject,
|
|
858
|
+
`Failed to save database "${dbName}" to IndexedDB`,
|
|
859
|
+
{ dbName },
|
|
860
|
+
event
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
} finally {
|
|
864
|
+
idb.close();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function saveDumpToIndexedDB(dbName, dump) {
|
|
868
|
+
const idb = await openIdb();
|
|
869
|
+
try {
|
|
870
|
+
await new Promise((resolve, reject) => {
|
|
871
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
872
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
873
|
+
store.put(dump, `${dbName}${DUMP_SUFFIX}`);
|
|
874
|
+
tx.oncomplete = () => resolve();
|
|
875
|
+
tx.onerror = (event) => rejectWithIdbError(
|
|
876
|
+
reject,
|
|
877
|
+
`Failed to save dump for database "${dbName}"`,
|
|
878
|
+
{ dbName },
|
|
879
|
+
event
|
|
880
|
+
);
|
|
881
|
+
});
|
|
882
|
+
} finally {
|
|
883
|
+
idb.close();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
async function loadFromIndexedDB(dbName) {
|
|
887
|
+
const idb = await openIdb();
|
|
888
|
+
try {
|
|
889
|
+
return await new Promise((resolve, reject) => {
|
|
890
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
891
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
892
|
+
const request = store.get(dbName);
|
|
893
|
+
request.onsuccess = () => {
|
|
894
|
+
const result = request.result;
|
|
895
|
+
if (result instanceof Uint8Array) {
|
|
896
|
+
resolve(result);
|
|
897
|
+
} else if (result) {
|
|
898
|
+
resolve(new Uint8Array(result));
|
|
899
|
+
} else {
|
|
900
|
+
resolve(null);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
request.onerror = () => reject(
|
|
904
|
+
new PersistenceError(`Failed to load database "${dbName}" from IndexedDB`, { dbName })
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
} finally {
|
|
908
|
+
idb.close();
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
async function loadDumpFromIndexedDB(dbName) {
|
|
912
|
+
const idb = await openIdb();
|
|
913
|
+
try {
|
|
914
|
+
return await new Promise((resolve, reject) => {
|
|
915
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
916
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
917
|
+
const request = store.get(`${dbName}${DUMP_SUFFIX}`);
|
|
918
|
+
request.onsuccess = () => {
|
|
919
|
+
resolve(request.result ?? null);
|
|
920
|
+
};
|
|
921
|
+
request.onerror = () => reject(
|
|
922
|
+
new PersistenceError(`Failed to load dump for database "${dbName}" from IndexedDB`, {
|
|
923
|
+
dbName
|
|
924
|
+
})
|
|
925
|
+
);
|
|
926
|
+
});
|
|
927
|
+
} finally {
|
|
928
|
+
idb.close();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
async function deleteSnapshotFromIndexedDB(dbName) {
|
|
932
|
+
const idb = await openIdb();
|
|
933
|
+
try {
|
|
934
|
+
await new Promise((resolve, reject) => {
|
|
935
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
936
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
937
|
+
store.delete(dbName);
|
|
938
|
+
tx.oncomplete = () => resolve();
|
|
939
|
+
tx.onerror = () => reject(
|
|
940
|
+
new PersistenceError(`Failed to delete database snapshot "${dbName}" from IndexedDB`, {
|
|
941
|
+
dbName
|
|
942
|
+
})
|
|
943
|
+
);
|
|
944
|
+
});
|
|
945
|
+
} finally {
|
|
946
|
+
idb.close();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
async function deleteFromIndexedDB(dbName) {
|
|
950
|
+
const idb = await openIdb();
|
|
951
|
+
try {
|
|
952
|
+
await new Promise((resolve, reject) => {
|
|
953
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
954
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
955
|
+
store.delete(dbName);
|
|
956
|
+
store.delete(`${dbName}${DUMP_SUFFIX}`);
|
|
957
|
+
tx.oncomplete = () => resolve();
|
|
958
|
+
tx.onerror = () => reject(
|
|
959
|
+
new PersistenceError(`Failed to delete database "${dbName}" from IndexedDB`, { dbName })
|
|
960
|
+
);
|
|
961
|
+
});
|
|
962
|
+
} finally {
|
|
963
|
+
idb.close();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// src/adapters/storage-manifest.ts
|
|
968
|
+
var MANIFEST_DB_NAME = "kora-manifest";
|
|
969
|
+
var MANIFEST_STORE = "databases";
|
|
970
|
+
var MANIFEST_VERSION = 1;
|
|
971
|
+
function isManifestAvailable() {
|
|
972
|
+
return typeof indexedDB !== "undefined";
|
|
973
|
+
}
|
|
974
|
+
function openManifest() {
|
|
975
|
+
return new Promise((resolve, reject) => {
|
|
976
|
+
const request = indexedDB.open(MANIFEST_DB_NAME, MANIFEST_VERSION);
|
|
977
|
+
request.onupgradeneeded = () => {
|
|
978
|
+
const db = request.result;
|
|
979
|
+
if (!db.objectStoreNames.contains(MANIFEST_STORE)) {
|
|
980
|
+
db.createObjectStore(MANIFEST_STORE, { keyPath: "dbName" });
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
request.onsuccess = () => resolve(request.result);
|
|
984
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open the Kora storage manifest"));
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
async function withStore(mode, run) {
|
|
988
|
+
const db = await openManifest();
|
|
989
|
+
try {
|
|
990
|
+
return await new Promise((resolve, reject) => {
|
|
991
|
+
const tx = db.transaction(MANIFEST_STORE, mode);
|
|
992
|
+
const request = run(tx.objectStore(MANIFEST_STORE));
|
|
993
|
+
let result;
|
|
994
|
+
if (request) {
|
|
995
|
+
request.onsuccess = () => {
|
|
996
|
+
result = request.result;
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
tx.oncomplete = () => resolve(result);
|
|
1000
|
+
tx.onerror = () => reject(tx.error ?? new Error("Kora storage manifest transaction failed"));
|
|
1001
|
+
tx.onabort = () => reject(tx.error ?? new Error("Kora storage manifest transaction aborted"));
|
|
1002
|
+
});
|
|
1003
|
+
} finally {
|
|
1004
|
+
db.close();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function isRecord(value) {
|
|
1008
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1009
|
+
const record = value;
|
|
1010
|
+
return typeof record.dbName === "string" && (record.backend === "opfs" || record.backend === "indexeddb");
|
|
1011
|
+
}
|
|
1012
|
+
async function readManifestRecord(dbName) {
|
|
1013
|
+
if (!isManifestAvailable()) return null;
|
|
1014
|
+
const value = await withStore("readonly", (store) => store.get(dbName));
|
|
1015
|
+
return isRecord(value) ? value : null;
|
|
1016
|
+
}
|
|
1017
|
+
async function recordDatabase(entry) {
|
|
1018
|
+
if (!isManifestAvailable()) return;
|
|
1019
|
+
const existing = await readManifestRecord(entry.dbName);
|
|
1020
|
+
const now = Date.now();
|
|
1021
|
+
const record = {
|
|
1022
|
+
dbName: entry.dbName,
|
|
1023
|
+
backend: entry.backend,
|
|
1024
|
+
...entry.poolName ? { poolName: entry.poolName } : {},
|
|
1025
|
+
createdAt: existing?.createdAt ?? now,
|
|
1026
|
+
lastOpenedAt: now
|
|
1027
|
+
};
|
|
1028
|
+
await withStore("readwrite", (store) => store.put(record));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// src/adapters/sqlite-wasm-adapter.ts
|
|
1032
|
+
var warnedSharedWorkerDeprecated = false;
|
|
1033
|
+
function createGate() {
|
|
1034
|
+
let open = () => {
|
|
1035
|
+
};
|
|
1036
|
+
const promise = new Promise((resolve) => {
|
|
1037
|
+
open = resolve;
|
|
1038
|
+
});
|
|
1039
|
+
return { promise, open };
|
|
1040
|
+
}
|
|
1041
|
+
var SqliteWasmAdapter = class {
|
|
1042
|
+
bridge = null;
|
|
1043
|
+
/** The dedicated worker this tab runs while it is the database's leader. */
|
|
1044
|
+
ownWorker = null;
|
|
1045
|
+
opened = false;
|
|
1046
|
+
mutex = new Mutex();
|
|
1047
|
+
injectedBridge;
|
|
1048
|
+
workerUrl;
|
|
1049
|
+
sharedWorkerUrl;
|
|
1050
|
+
workerResponseTimeoutMs;
|
|
1051
|
+
dbName;
|
|
1052
|
+
emitter;
|
|
1053
|
+
tabSession = null;
|
|
1054
|
+
emitNonPersistentDiagnostic;
|
|
1055
|
+
storageOpenState = null;
|
|
1056
|
+
/** Retained so a follower promoted to leader can re-open its own worker. */
|
|
1057
|
+
schema = null;
|
|
1058
|
+
promoting = false;
|
|
1059
|
+
closing = false;
|
|
1060
|
+
suspended = false;
|
|
1061
|
+
/** Requests wait here while leadership changes hands (promotion, resume). */
|
|
1062
|
+
gate = null;
|
|
1063
|
+
removeLifecycleListeners = null;
|
|
1064
|
+
allowNonDurable;
|
|
1065
|
+
deferOpenDurabilityCheck;
|
|
1066
|
+
releaseOnFreeze;
|
|
1067
|
+
storage;
|
|
1068
|
+
skipBackendRecord;
|
|
1069
|
+
/** Set when this adapter lost (or never had) durable storage; writes are refused. */
|
|
1070
|
+
durabilityLoss = null;
|
|
1071
|
+
onLeaderWorkerOpened;
|
|
1072
|
+
constructor(options = {}) {
|
|
1073
|
+
this.injectedBridge = options.bridge;
|
|
1074
|
+
this.workerUrl = options.workerUrl;
|
|
1075
|
+
this.sharedWorkerUrl = options.sharedWorkerUrl;
|
|
1076
|
+
this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
|
|
1077
|
+
this.dbName = options.dbName ?? "kora-db";
|
|
1078
|
+
this.emitter = options.emitter;
|
|
1079
|
+
this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
|
|
1080
|
+
this.allowNonDurable = options.allowNonDurable ?? false;
|
|
1081
|
+
this.deferOpenDurabilityCheck = options.deferOpenDurabilityCheck ?? false;
|
|
1082
|
+
this.releaseOnFreeze = options.releaseOnFreeze ?? true;
|
|
1083
|
+
this.storage = options.storage ?? "opfs";
|
|
1084
|
+
this.skipBackendRecord = options.skipBackendRecord ?? false;
|
|
1085
|
+
this.onLeaderWorkerOpened = options.onLeaderWorkerOpened;
|
|
1086
|
+
}
|
|
1087
|
+
async open(schema) {
|
|
1088
|
+
if (this.opened) return;
|
|
1089
|
+
const ddlStatements = (0, import_core3.generateFullDDL)(schema);
|
|
1090
|
+
if (this.injectedBridge) {
|
|
1091
|
+
this.bridge = this.injectedBridge;
|
|
1092
|
+
const response = await this.openCurrentBridge(ddlStatements);
|
|
1093
|
+
this.reportStorageMode(response.data);
|
|
1094
|
+
this.enforceDurability("open");
|
|
1095
|
+
this.opened = true;
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (this.workerUrl) {
|
|
1099
|
+
this.warnSharedWorkerDeprecated();
|
|
1100
|
+
this.schema = schema;
|
|
1101
|
+
try {
|
|
1102
|
+
const response = await this.attachAndOpen(ddlStatements);
|
|
1103
|
+
this.reportStorageMode(response.data);
|
|
1104
|
+
this.enforceDurability("open");
|
|
1105
|
+
await this.afterLeaderOpen(response.data);
|
|
1106
|
+
this.opened = true;
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
await this.teardown();
|
|
1109
|
+
throw error;
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
throw new AdapterError(
|
|
1114
|
+
'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Join the database as leader (own worker) or follower (RPC to the leader's
|
|
1119
|
+
* worker), then open it. The open waits, without a timeout, while another
|
|
1120
|
+
* holder still has the storage; the worker reports that as a blocking state.
|
|
1121
|
+
*/
|
|
1122
|
+
async attachAndOpen(ddlStatements) {
|
|
1123
|
+
const workerUrl = this.workerUrl;
|
|
1124
|
+
if (!workerUrl) {
|
|
1125
|
+
throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
|
|
1126
|
+
}
|
|
1127
|
+
const session = await acquireTabStorageSession(this.dbName, {
|
|
1128
|
+
onPromote: () => {
|
|
1129
|
+
void this.promoteToLeader();
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
this.tabSession = session;
|
|
1133
|
+
if (session.role === "leader") {
|
|
1134
|
+
const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
|
|
1135
|
+
const worker = new WebWorkerBridge2(workerUrl, this.workerResponseTimeoutMs, {
|
|
1136
|
+
onEvent: (event) => this.onWorkerEvent(event)
|
|
1137
|
+
});
|
|
1138
|
+
this.ownWorker = worker;
|
|
1139
|
+
this.bridge = worker;
|
|
1140
|
+
const response = await this.openLeaderWorker(worker, ddlStatements);
|
|
1141
|
+
this.installLifecycleListeners();
|
|
1142
|
+
return response;
|
|
1143
|
+
}
|
|
1144
|
+
const followerBridge = new FollowerBroadcastBridge(
|
|
1145
|
+
session.channelName,
|
|
1146
|
+
this.workerResponseTimeoutMs
|
|
1147
|
+
);
|
|
1148
|
+
this.bridge = followerBridge;
|
|
786
1149
|
this.emitter?.emit({
|
|
787
1150
|
type: "store:db-name-collision",
|
|
788
1151
|
dbName: this.dbName,
|
|
789
1152
|
message: `Another runtime on this origin is already using database "${this.dbName}"; this runtime is sharing it as a follower. If these are separate apps, give each a distinct store name.`
|
|
790
1153
|
});
|
|
791
1154
|
await followerBridge.waitForLeader();
|
|
1155
|
+
this.installLifecycleListeners();
|
|
1156
|
+
return this.checkedOpen(followerBridge, ddlStatements);
|
|
1157
|
+
}
|
|
1158
|
+
/** Open the database in this tab's own worker and start serving followers. */
|
|
1159
|
+
async openLeaderWorker(worker, ddlStatements) {
|
|
1160
|
+
const hook = this.onLeaderWorkerOpened;
|
|
1161
|
+
if (!hook) {
|
|
1162
|
+
const pending = this.checkedOpen(worker, ddlStatements);
|
|
1163
|
+
worker.post({ id: 0, type: "serve", channelName: this.tabSession?.channelName ?? "" });
|
|
1164
|
+
return pending;
|
|
1165
|
+
}
|
|
1166
|
+
const response = await this.checkedOpen(worker, ddlStatements);
|
|
1167
|
+
await runWorkerTransaction(worker, hook);
|
|
1168
|
+
worker.post({ id: 0, type: "serve", channelName: this.tabSession?.channelName ?? "" });
|
|
1169
|
+
return response;
|
|
792
1170
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1171
|
+
/**
|
|
1172
|
+
* Whether this runtime owns the database's worker: the storage leader in a browser,
|
|
1173
|
+
* or any adapter with an injected bridge (tests, Node). A follower tab relays every
|
|
1174
|
+
* request to the leader's worker (STORE-6).
|
|
1175
|
+
*/
|
|
1176
|
+
isLeader() {
|
|
1177
|
+
if (this.injectedBridge) return true;
|
|
1178
|
+
return this.tabSession?.role === "leader";
|
|
1179
|
+
}
|
|
1180
|
+
async checkedOpen(bridge, ddlStatements) {
|
|
1181
|
+
const response = await bridge.send(
|
|
1182
|
+
{
|
|
1183
|
+
id: 0,
|
|
1184
|
+
type: "open",
|
|
1185
|
+
ddlStatements,
|
|
1186
|
+
dbName: this.dbName,
|
|
1187
|
+
...this.storage === "memory" ? { storage: "memory" } : {}
|
|
1188
|
+
},
|
|
1189
|
+
void 0,
|
|
1190
|
+
{ timeoutMs: Number.POSITIVE_INFINITY }
|
|
1191
|
+
);
|
|
800
1192
|
if (response.type === "error") {
|
|
801
1193
|
throw new AdapterError(`Failed to open database: ${response.message}`, {
|
|
802
1194
|
code: response.code,
|
|
803
|
-
dbName: this.dbName
|
|
1195
|
+
dbName: this.dbName,
|
|
1196
|
+
...response.context ?? {}
|
|
804
1197
|
});
|
|
805
1198
|
}
|
|
806
1199
|
return response;
|
|
807
1200
|
}
|
|
1201
|
+
async openCurrentBridge(ddlStatements) {
|
|
1202
|
+
const bridge = this.bridge;
|
|
1203
|
+
if (!bridge) throw new StoreNotOpenError();
|
|
1204
|
+
return this.checkedOpen(bridge, ddlStatements);
|
|
1205
|
+
}
|
|
1206
|
+
onWorkerEvent(event) {
|
|
1207
|
+
if (!this.emitter) return;
|
|
1208
|
+
const holder = event.resource === "legacy-pool" ? "the shared OPFS pool used by older Kora versions (a tab running an older version of this app may still be open)" : "another tab or worker that is still releasing it";
|
|
1209
|
+
if (event.kind === "storage-blocked") {
|
|
1210
|
+
this.emitter.emit({
|
|
1211
|
+
type: "store:storage-blocked",
|
|
1212
|
+
dbName: this.dbName,
|
|
1213
|
+
resource: event.resource,
|
|
1214
|
+
state: "waiting",
|
|
1215
|
+
message: `Database "${this.dbName}" is waiting for ${holder}. Close other tabs of this app if this persists; Kora will not fall back to non-durable storage.`
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
this.emitter.emit({
|
|
1220
|
+
type: "store:storage-blocked",
|
|
1221
|
+
dbName: this.dbName,
|
|
1222
|
+
resource: event.resource,
|
|
1223
|
+
state: "resolved",
|
|
1224
|
+
waitedMs: event.waitedMs,
|
|
1225
|
+
message: `Database "${this.dbName}" obtained its storage after waiting ${event.waitedMs}ms.`
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Leader-only bookkeeping after a durable open: report a legacy-pool
|
|
1230
|
+
* migration, move an IndexedDB-held copy into OPFS explicitly (never two
|
|
1231
|
+
* disjoint copies), and record OPFS as this database's backend.
|
|
1232
|
+
*/
|
|
1233
|
+
async afterLeaderOpen(data) {
|
|
1234
|
+
if (!this.ownWorker || this.skipBackendRecord || this.storageOpenState?.persistent !== true) {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const info = typeof data === "object" && data !== null ? data : {};
|
|
1238
|
+
if (info.migratedFromLegacy === true) {
|
|
1239
|
+
this.emitter?.emit({
|
|
1240
|
+
type: "store:storage-migrated",
|
|
1241
|
+
dbName: this.dbName,
|
|
1242
|
+
from: "legacy-opfs-pool",
|
|
1243
|
+
to: "opfs",
|
|
1244
|
+
message: `Database "${this.dbName}" moved from the shared pre-1.0 OPFS pool into its own pool.`
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
if (!isManifestAvailable()) return;
|
|
1248
|
+
let recordedBackend = null;
|
|
1249
|
+
try {
|
|
1250
|
+
recordedBackend = (await readManifestRecord(this.dbName))?.backend ?? null;
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
console.warn(`[kora] Could not read the storage manifest for "${this.dbName}":`, error);
|
|
1253
|
+
}
|
|
1254
|
+
if (recordedBackend === "indexeddb") {
|
|
1255
|
+
const dump = await loadDumpFromIndexedDB(this.dbName);
|
|
1256
|
+
if (dump) {
|
|
1257
|
+
await this.applyDumpThroughWorker(this.ownWorker, dump);
|
|
1258
|
+
}
|
|
1259
|
+
this.emitter?.emit({
|
|
1260
|
+
type: "store:storage-migrated",
|
|
1261
|
+
dbName: this.dbName,
|
|
1262
|
+
from: "indexeddb",
|
|
1263
|
+
to: "opfs",
|
|
1264
|
+
message: `Database "${this.dbName}" moved from IndexedDB into OPFS.`
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
await recordDatabase({
|
|
1269
|
+
dbName: this.dbName,
|
|
1270
|
+
backend: "opfs",
|
|
1271
|
+
...this.storageOpenState.poolName ? { poolName: this.storageOpenState.poolName } : {}
|
|
1272
|
+
});
|
|
1273
|
+
if (recordedBackend === "indexeddb") {
|
|
1274
|
+
await deleteFromIndexedDB(this.dbName);
|
|
1275
|
+
}
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
console.warn(`[kora] Could not update the storage manifest for "${this.dbName}":`, error);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async applyDumpThroughWorker(worker, dump) {
|
|
1281
|
+
const check = async (request, what) => {
|
|
1282
|
+
const response = await worker.send(request);
|
|
1283
|
+
if (response.type === "error") {
|
|
1284
|
+
throw new AdapterError(`${what} failed: ${response.message}`, { dbName: this.dbName });
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
await check({ id: 0, type: "begin" }, "BEGIN IndexedDB import");
|
|
1288
|
+
try {
|
|
1289
|
+
for (const statement of restoreDumpStatements(dump)) {
|
|
1290
|
+
await check(
|
|
1291
|
+
{ id: 0, type: "execute", sql: statement.sql, params: statement.params },
|
|
1292
|
+
"IndexedDB import"
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
await check({ id: 0, type: "commit" }, "COMMIT IndexedDB import");
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
await worker.send({ id: 0, type: "rollback" }).catch(() => void 0);
|
|
1298
|
+
throw error;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
808
1301
|
/**
|
|
809
1302
|
* Surface a `store:opfs-unavailable` diagnostic when the worker reported that
|
|
810
1303
|
* persistence silently degraded to an in-memory database, so a data-loss
|
|
@@ -816,7 +1309,7 @@ var SqliteWasmAdapter = class {
|
|
|
816
1309
|
if (!this.emitNonPersistentDiagnostic || !this.emitter || !this.storageOpenState) {
|
|
817
1310
|
return;
|
|
818
1311
|
}
|
|
819
|
-
if (this.storageOpenState.persistent === false) {
|
|
1312
|
+
if (this.storageOpenState.persistent === false && this.storage === "opfs") {
|
|
820
1313
|
const reason = this.storageOpenState.fallbackReason ?? "unsupported";
|
|
821
1314
|
this.emitter.emit({
|
|
822
1315
|
type: "store:opfs-unavailable",
|
|
@@ -829,6 +1322,42 @@ var SqliteWasmAdapter = class {
|
|
|
829
1322
|
getStorageOpenState() {
|
|
830
1323
|
return this.storageOpenState;
|
|
831
1324
|
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Durable or loud, never silent (NEW-STORE-6). When the open or a promotion
|
|
1327
|
+
* ended on non-persistent storage and the app did not opt into that, emit the
|
|
1328
|
+
* blocking `store:durability-lost` event and refuse every later write. A
|
|
1329
|
+
* bridge that reports no mode (the Node mock) is not judged.
|
|
1330
|
+
*/
|
|
1331
|
+
enforceDurability(phase, failure) {
|
|
1332
|
+
if (this.allowNonDurable) return;
|
|
1333
|
+
if (phase === "open" && this.deferOpenDurabilityCheck && !failure) return;
|
|
1334
|
+
let reason;
|
|
1335
|
+
if (failure) {
|
|
1336
|
+
reason = failure.reason;
|
|
1337
|
+
} else if (this.storageOpenState?.persistent === false) {
|
|
1338
|
+
reason = this.storageOpenState.fallbackReason ?? "unsupported";
|
|
1339
|
+
} else {
|
|
1340
|
+
this.durabilityLoss = null;
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
this.durabilityLoss = { phase, reason };
|
|
1344
|
+
this.emitter?.emit({
|
|
1345
|
+
type: "store:durability-lost",
|
|
1346
|
+
dbName: this.dbName,
|
|
1347
|
+
phase,
|
|
1348
|
+
reason,
|
|
1349
|
+
message: `Database "${this.dbName}" has no durable storage (${reason} during ${phase}); writes are refused until the app reloads with durable storage.${failure ? ` ${failure.message}` : ""}`
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
guardDurableWrite() {
|
|
1353
|
+
if (this.durabilityLoss) {
|
|
1354
|
+
throw new StorageDurabilityError(
|
|
1355
|
+
this.dbName,
|
|
1356
|
+
this.durabilityLoss.phase,
|
|
1357
|
+
this.durabilityLoss.reason
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
832
1361
|
warnSharedWorkerDeprecated() {
|
|
833
1362
|
if (!this.sharedWorkerUrl || warnedSharedWorkerDeprecated) {
|
|
834
1363
|
return;
|
|
@@ -838,71 +1367,255 @@ var SqliteWasmAdapter = class {
|
|
|
838
1367
|
"[kora] sharedWorkerUrl is deprecated and ignored: SharedWorker-hosted SQLite cannot use OPFS and is never durable. Kora uses the durable dedicated-worker leader/follower path for multi-tab storage."
|
|
839
1368
|
);
|
|
840
1369
|
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Close the database. A leader closes the database file and releases the OPFS
|
|
1372
|
+
* pool inside its worker, terminates the worker, and only then releases the
|
|
1373
|
+
* tab leader lock, so the next leader never races a still-running worker for
|
|
1374
|
+
* the file handles (NEW-STORE-10). A follower just leaves.
|
|
1375
|
+
*/
|
|
841
1376
|
async close() {
|
|
842
|
-
if (!this.bridge) return;
|
|
1377
|
+
if (!this.bridge && !this.tabSession) return;
|
|
1378
|
+
this.closing = true;
|
|
843
1379
|
try {
|
|
844
|
-
|
|
1380
|
+
if (this.injectedBridge && this.bridge) {
|
|
1381
|
+
await this.bridge.send({ id: 0, type: "close" });
|
|
1382
|
+
} else if (this.ownWorker && !this.ownWorker.isTerminated()) {
|
|
1383
|
+
const response = await this.ownWorker.send({ id: 0, type: "close" }, void 0, { timeoutMs: 5e3 }).catch((error) => ({
|
|
1384
|
+
id: 0,
|
|
1385
|
+
type: "error",
|
|
1386
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1387
|
+
code: "CLOSE_FAILED"
|
|
1388
|
+
}));
|
|
1389
|
+
if (response.type === "error") {
|
|
1390
|
+
console.warn(`[kora] Closing "${this.dbName}" in its worker failed:`, response.message);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
845
1393
|
} finally {
|
|
846
|
-
this.
|
|
847
|
-
this.
|
|
848
|
-
|
|
849
|
-
|
|
1394
|
+
await this.teardown();
|
|
1395
|
+
this.closing = false;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Release everything this adapter holds, in ownership order: lifecycle
|
|
1400
|
+
* listeners, then the worker (which holds the OPFS pool and its Web Lock),
|
|
1401
|
+
* then the tab leader lock or queued promotion request.
|
|
1402
|
+
*/
|
|
1403
|
+
async teardown() {
|
|
1404
|
+
this.removeLifecycleListeners?.();
|
|
1405
|
+
this.removeLifecycleListeners = null;
|
|
1406
|
+
const session = this.tabSession;
|
|
1407
|
+
this.tabSession = null;
|
|
1408
|
+
session?.stopRelay?.();
|
|
1409
|
+
this.bridge?.terminate();
|
|
1410
|
+
this.bridge = null;
|
|
1411
|
+
this.ownWorker = null;
|
|
1412
|
+
if (session) {
|
|
1413
|
+
if (session.releaseLock) {
|
|
1414
|
+
await session.releaseLock();
|
|
850
1415
|
}
|
|
851
|
-
|
|
852
|
-
this.bridge.terminate();
|
|
853
|
-
this.bridge = null;
|
|
854
|
-
this.opened = false;
|
|
1416
|
+
session.cancelPromotionWatch?.();
|
|
855
1417
|
}
|
|
1418
|
+
this.opened = false;
|
|
1419
|
+
this.suspended = false;
|
|
1420
|
+
const gate = this.gate;
|
|
1421
|
+
this.gate = null;
|
|
1422
|
+
gate?.open();
|
|
856
1423
|
}
|
|
857
1424
|
/**
|
|
858
1425
|
* Promotes a follower to leader after the previous leader released the storage
|
|
859
|
-
* lock (its tab closed or
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
1426
|
+
* lock (its tab closed, crashed, or suspended on freeze). The new worker waits
|
|
1427
|
+
* on the pool's Web Lock and handles, so it never races the previous worker.
|
|
1428
|
+
* Requests issued during the hand-off wait for it; requests that were in
|
|
1429
|
+
* flight to the old leader fail with a retriable `BridgeTerminatedError`.
|
|
863
1430
|
*/
|
|
864
1431
|
async promoteToLeader() {
|
|
865
|
-
if (this.promoting || !this.opened || this.workerUrl === void 0 || this.schema === null) {
|
|
1432
|
+
if (this.promoting || this.closing || !this.opened || this.workerUrl === void 0 || this.schema === null) {
|
|
866
1433
|
return;
|
|
867
1434
|
}
|
|
868
1435
|
this.promoting = true;
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1436
|
+
const gate = createGate();
|
|
1437
|
+
this.gate = gate;
|
|
1438
|
+
try {
|
|
1439
|
+
const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
|
|
1440
|
+
if (this.closing || !this.opened) return;
|
|
1441
|
+
const worker = new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs, {
|
|
1442
|
+
onEvent: (event) => this.onWorkerEvent(event)
|
|
1443
|
+
});
|
|
1444
|
+
const previousBridge = this.bridge;
|
|
1445
|
+
this.bridge = worker;
|
|
1446
|
+
this.ownWorker = worker;
|
|
1447
|
+
if (this.tabSession) {
|
|
1448
|
+
this.tabSession.role = "leader";
|
|
1449
|
+
}
|
|
1450
|
+
previousBridge?.terminate();
|
|
1451
|
+
const ddlStatements = (0, import_core3.generateFullDDL)(this.schema);
|
|
1452
|
+
let response;
|
|
1453
|
+
try {
|
|
1454
|
+
response = await this.openLeaderWorker(worker, ddlStatements);
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
this.storageOpenState = { persistent: false, mode: "memory" };
|
|
1457
|
+
this.enforceDurability("promotion", {
|
|
1458
|
+
reason: "open-failed",
|
|
1459
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1460
|
+
});
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
this.storageOpenState = parseStorageOpenState(response.data) ?? this.storageOpenState;
|
|
1464
|
+
this.enforceDurability("promotion");
|
|
1465
|
+
try {
|
|
1466
|
+
await this.afterLeaderOpen(response.data);
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
this.enforceDurability("promotion", {
|
|
1469
|
+
reason: "open-failed",
|
|
1470
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
} finally {
|
|
1474
|
+
this.promoting = false;
|
|
1475
|
+
if (this.gate === gate) this.gate = null;
|
|
1476
|
+
gate.open();
|
|
878
1477
|
}
|
|
879
|
-
previousBridge?.terminate();
|
|
880
|
-
const ddlStatements = (0, import_core2.generateFullDDL)(this.schema);
|
|
881
|
-
await this.sendRequest({ id: 0, type: "open", ddlStatements, dbName: this.dbName });
|
|
882
1478
|
}
|
|
883
|
-
|
|
1479
|
+
/**
|
|
1480
|
+
* Page Lifecycle: a frozen or bfcached page cannot run, so it must not keep
|
|
1481
|
+
* other tabs waiting on its storage. Leaders terminate their worker (freeing
|
|
1482
|
+
* the OPFS pool) and then release the leader lock, so a visible tab is
|
|
1483
|
+
* promoted; followers withdraw their queued lock request so a frozen tab is
|
|
1484
|
+
* never granted leadership it cannot use. Never uses Web Locks `steal`.
|
|
1485
|
+
*/
|
|
1486
|
+
installLifecycleListeners() {
|
|
1487
|
+
if (!this.releaseOnFreeze || this.removeLifecycleListeners) return;
|
|
1488
|
+
if (typeof addEventListener !== "function" || typeof document === "undefined") return;
|
|
1489
|
+
const onFreeze = () => this.suspend();
|
|
1490
|
+
const onPageHide = () => this.suspend();
|
|
1491
|
+
const onResume = () => {
|
|
1492
|
+
void this.resume();
|
|
1493
|
+
};
|
|
1494
|
+
const onPageShow = (event) => {
|
|
1495
|
+
if (event.persisted) void this.resume();
|
|
1496
|
+
};
|
|
1497
|
+
document.addEventListener("freeze", onFreeze);
|
|
1498
|
+
document.addEventListener("resume", onResume);
|
|
1499
|
+
addEventListener("pagehide", onPageHide);
|
|
1500
|
+
addEventListener("pageshow", onPageShow);
|
|
1501
|
+
this.removeLifecycleListeners = () => {
|
|
1502
|
+
document.removeEventListener("freeze", onFreeze);
|
|
1503
|
+
document.removeEventListener("resume", onResume);
|
|
1504
|
+
removeEventListener("pagehide", onPageHide);
|
|
1505
|
+
removeEventListener("pageshow", onPageShow);
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
/** Synchronous by necessity: a `freeze` handler gets no further turns. */
|
|
1509
|
+
suspend() {
|
|
1510
|
+
if (!this.opened || this.suspended || this.closing || this.promoting) return;
|
|
1511
|
+
this.suspended = true;
|
|
1512
|
+
this.gate = createGate();
|
|
1513
|
+
const session = this.tabSession;
|
|
1514
|
+
this.tabSession = null;
|
|
1515
|
+
this.bridge?.terminate();
|
|
1516
|
+
this.bridge = null;
|
|
1517
|
+
this.ownWorker = null;
|
|
1518
|
+
if (session) {
|
|
1519
|
+
void session.releaseLock?.();
|
|
1520
|
+
session.cancelPromotionWatch?.();
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async resume() {
|
|
1524
|
+
if (!this.suspended || this.closing || this.schema === null) return;
|
|
1525
|
+
const gate = this.gate;
|
|
1526
|
+
try {
|
|
1527
|
+
const response = await this.attachAndOpen((0, import_core3.generateFullDDL)(this.schema));
|
|
1528
|
+
this.reportStorageMode(response.data);
|
|
1529
|
+
this.enforceDurability("promotion");
|
|
1530
|
+
await this.afterLeaderOpen(response.data);
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
this.enforceDurability("promotion", {
|
|
1533
|
+
reason: "open-failed",
|
|
1534
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1535
|
+
});
|
|
1536
|
+
} finally {
|
|
1537
|
+
this.suspended = false;
|
|
1538
|
+
if (this.gate === gate) this.gate = null;
|
|
1539
|
+
gate?.open();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Non-transactional write. Takes the same mutex as {@link transaction}, so it
|
|
1544
|
+
* never lands inside this tab's open transaction and is never rolled back
|
|
1545
|
+
* with it (STORE-8). Inside a transaction callback, use the `tx` handle.
|
|
1546
|
+
*/
|
|
1547
|
+
async execute(sql, params, options) {
|
|
884
1548
|
this.guardOpen();
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1549
|
+
this.guardDurableWrite();
|
|
1550
|
+
const release = await this.mutex.acquire();
|
|
1551
|
+
try {
|
|
1552
|
+
const response = await this.sendRequest(
|
|
1553
|
+
{
|
|
1554
|
+
id: 0,
|
|
1555
|
+
type: "execute",
|
|
1556
|
+
sql,
|
|
1557
|
+
params,
|
|
1558
|
+
...options?.requestId ? { requestId: options.requestId } : {}
|
|
1559
|
+
},
|
|
1560
|
+
options?.signal ? { signal: options.signal } : void 0
|
|
1561
|
+
);
|
|
1562
|
+
if (response.type === "error") {
|
|
1563
|
+
throw new AdapterError(`Execute failed: ${response.message}`, { sql, params });
|
|
1564
|
+
}
|
|
1565
|
+
} finally {
|
|
1566
|
+
release();
|
|
888
1567
|
}
|
|
889
1568
|
}
|
|
890
|
-
|
|
1569
|
+
/**
|
|
1570
|
+
* Non-transactional read. Waits for this tab's open transaction to finish, so
|
|
1571
|
+
* it never observes uncommitted (possibly rolled-back) rows (STORE-8).
|
|
1572
|
+
*/
|
|
1573
|
+
async query(sql, params, options) {
|
|
891
1574
|
this.guardOpen();
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
1575
|
+
const release = await this.mutex.acquire();
|
|
1576
|
+
try {
|
|
1577
|
+
const response = await this.sendRequest(
|
|
1578
|
+
{
|
|
1579
|
+
id: 0,
|
|
1580
|
+
type: "query",
|
|
1581
|
+
sql,
|
|
1582
|
+
params,
|
|
1583
|
+
...options?.requestId ? { requestId: options.requestId } : {}
|
|
1584
|
+
},
|
|
1585
|
+
options?.signal ? { signal: options.signal } : void 0
|
|
1586
|
+
);
|
|
1587
|
+
if (response.type === "error") {
|
|
1588
|
+
throw new AdapterError(`Query failed: ${response.message}`, { sql, params });
|
|
1589
|
+
}
|
|
1590
|
+
return response.data ?? [];
|
|
1591
|
+
} finally {
|
|
1592
|
+
release();
|
|
895
1593
|
}
|
|
896
|
-
return response.data ?? [];
|
|
897
1594
|
}
|
|
898
1595
|
async transaction(fn) {
|
|
899
1596
|
this.guardOpen();
|
|
1597
|
+
this.guardDurableWrite();
|
|
900
1598
|
const release = await this.mutex.acquire();
|
|
1599
|
+
let txBridge = null;
|
|
1600
|
+
const sendInSpan = async (request) => {
|
|
1601
|
+
if (txBridge === null || this.bridge !== txBridge) {
|
|
1602
|
+
throw new BridgeTerminatedError(request.type, "storage leader changed during transaction");
|
|
1603
|
+
}
|
|
1604
|
+
return txBridge.send(request);
|
|
1605
|
+
};
|
|
901
1606
|
try {
|
|
902
|
-
|
|
1607
|
+
while (this.gate) {
|
|
1608
|
+
await this.gate.promise;
|
|
1609
|
+
}
|
|
1610
|
+
txBridge = this.bridge;
|
|
1611
|
+
if (!txBridge) throw new StoreNotOpenError();
|
|
1612
|
+
const begin = await sendInSpan({ id: 0, type: "begin" });
|
|
1613
|
+
if (begin.type === "error") {
|
|
1614
|
+
throw new AdapterError(`BEGIN transaction failed: ${begin.message}`);
|
|
1615
|
+
}
|
|
903
1616
|
const tx = {
|
|
904
1617
|
execute: async (sql, params) => {
|
|
905
|
-
const response = await
|
|
1618
|
+
const response = await sendInSpan({ id: 0, type: "execute", sql, params });
|
|
906
1619
|
if (response.type === "error") {
|
|
907
1620
|
throw new AdapterError(`Transaction execute failed: ${response.message}`, {
|
|
908
1621
|
sql,
|
|
@@ -911,7 +1624,7 @@ var SqliteWasmAdapter = class {
|
|
|
911
1624
|
}
|
|
912
1625
|
},
|
|
913
1626
|
query: async (sql, params) => {
|
|
914
|
-
const response = await
|
|
1627
|
+
const response = await sendInSpan({ id: 0, type: "query", sql, params });
|
|
915
1628
|
if (response.type === "error") {
|
|
916
1629
|
throw new AdapterError(`Transaction query failed: ${response.message}`, { sql, params });
|
|
917
1630
|
}
|
|
@@ -919,10 +1632,15 @@ var SqliteWasmAdapter = class {
|
|
|
919
1632
|
}
|
|
920
1633
|
};
|
|
921
1634
|
await fn(tx);
|
|
922
|
-
await
|
|
1635
|
+
const commit = await sendInSpan({ id: 0, type: "commit" });
|
|
1636
|
+
if (commit.type === "error") {
|
|
1637
|
+
throw new AdapterError(`COMMIT transaction failed: ${commit.message}`);
|
|
1638
|
+
}
|
|
923
1639
|
} catch (error) {
|
|
924
1640
|
try {
|
|
925
|
-
|
|
1641
|
+
if (txBridge && this.bridge === txBridge) {
|
|
1642
|
+
await txBridge.send({ id: 0, type: "rollback" });
|
|
1643
|
+
}
|
|
926
1644
|
} catch {
|
|
927
1645
|
}
|
|
928
1646
|
throw error;
|
|
@@ -932,6 +1650,7 @@ var SqliteWasmAdapter = class {
|
|
|
932
1650
|
}
|
|
933
1651
|
async migrate(from, to, migration) {
|
|
934
1652
|
this.guardOpen();
|
|
1653
|
+
this.guardDurableWrite();
|
|
935
1654
|
const release = await this.mutex.acquire();
|
|
936
1655
|
try {
|
|
937
1656
|
await this.sendChecked({ id: 0, type: "begin" }, "BEGIN migration");
|
|
@@ -965,36 +1684,50 @@ var SqliteWasmAdapter = class {
|
|
|
965
1684
|
*/
|
|
966
1685
|
async exportDatabase() {
|
|
967
1686
|
this.guardOpen();
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1687
|
+
const release = await this.mutex.acquire();
|
|
1688
|
+
try {
|
|
1689
|
+
const response = await this.sendRequest({ id: 0, type: "export" });
|
|
1690
|
+
if (response.type === "error") {
|
|
1691
|
+
throw new AdapterError(`Export failed: ${response.message}`, {
|
|
1692
|
+
code: response.code,
|
|
1693
|
+
context: response.context
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
return response.data;
|
|
1697
|
+
} finally {
|
|
1698
|
+
release();
|
|
974
1699
|
}
|
|
975
|
-
return response.data;
|
|
976
1700
|
}
|
|
977
1701
|
/**
|
|
978
1702
|
* Import a serialized database snapshot.
|
|
979
1703
|
*/
|
|
980
1704
|
async importDatabase(data) {
|
|
981
1705
|
this.guardOpen();
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1706
|
+
this.guardDurableWrite();
|
|
1707
|
+
const release = await this.mutex.acquire();
|
|
1708
|
+
try {
|
|
1709
|
+
const response = await this.sendRequest({ id: 0, type: "import", data });
|
|
1710
|
+
if (response.type === "error") {
|
|
1711
|
+
throw new AdapterError(`Import failed: ${response.message}`);
|
|
1712
|
+
}
|
|
1713
|
+
} finally {
|
|
1714
|
+
release();
|
|
985
1715
|
}
|
|
986
1716
|
}
|
|
987
1717
|
guardOpen() {
|
|
988
|
-
if (!this.opened || !this.bridge) {
|
|
1718
|
+
if (!this.opened || !this.bridge && !this.gate) {
|
|
989
1719
|
throw new StoreNotOpenError();
|
|
990
1720
|
}
|
|
991
1721
|
}
|
|
992
|
-
async sendRequest(request) {
|
|
1722
|
+
async sendRequest(request, options) {
|
|
1723
|
+
while (this.gate) {
|
|
1724
|
+
await this.gate.promise;
|
|
1725
|
+
}
|
|
993
1726
|
const bridge = this.bridge;
|
|
994
1727
|
if (!bridge) {
|
|
995
1728
|
throw new StoreNotOpenError();
|
|
996
1729
|
}
|
|
997
|
-
return bridge.send(request);
|
|
1730
|
+
return bridge.send(request, void 0, options);
|
|
998
1731
|
}
|
|
999
1732
|
async sendChecked(request, description) {
|
|
1000
1733
|
const response = await this.sendRequest(request);
|
|
@@ -1003,6 +1736,34 @@ var SqliteWasmAdapter = class {
|
|
|
1003
1736
|
}
|
|
1004
1737
|
}
|
|
1005
1738
|
};
|
|
1739
|
+
async function runWorkerTransaction(worker, fn) {
|
|
1740
|
+
const send = async (request, what) => {
|
|
1741
|
+
const response = await worker.send(request);
|
|
1742
|
+
if (response.type === "error") {
|
|
1743
|
+
throw new AdapterError(`${what} failed: ${response.message}`, {
|
|
1744
|
+
code: response.code,
|
|
1745
|
+
...request.type === "execute" || request.type === "query" ? { sql: request.sql } : {}
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
return response;
|
|
1749
|
+
};
|
|
1750
|
+
await send({ id: 0, type: "begin" }, "BEGIN");
|
|
1751
|
+
try {
|
|
1752
|
+
await fn({
|
|
1753
|
+
execute: async (sql, params) => {
|
|
1754
|
+
await send({ id: 0, type: "execute", sql, params }, "Leader open execute");
|
|
1755
|
+
},
|
|
1756
|
+
query: async (sql, params) => {
|
|
1757
|
+
const response = await send({ id: 0, type: "query", sql, params }, "Leader open query");
|
|
1758
|
+
return response.data ?? [];
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
await send({ id: 0, type: "commit" }, "COMMIT");
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
await worker.send({ id: 0, type: "rollback" }).catch(() => void 0);
|
|
1764
|
+
throw error;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1006
1767
|
function parseStorageOpenState(data) {
|
|
1007
1768
|
if (typeof data !== "object" || data === null) {
|
|
1008
1769
|
return null;
|
|
@@ -1014,162 +1775,12 @@ function parseStorageOpenState(data) {
|
|
|
1014
1775
|
return {
|
|
1015
1776
|
persistent: mode.persistent,
|
|
1016
1777
|
mode: mode.persistent ? "opfs" : "memory",
|
|
1017
|
-
...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {}
|
|
1778
|
+
...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {},
|
|
1779
|
+
...typeof mode.poolName === "string" ? { poolName: mode.poolName } : {},
|
|
1780
|
+
...typeof mode.journalMode === "string" ? { journalMode: mode.journalMode } : {}
|
|
1018
1781
|
};
|
|
1019
1782
|
}
|
|
1020
1783
|
|
|
1021
|
-
// src/adapters/sqlite-wasm-persistence.ts
|
|
1022
|
-
init_errors();
|
|
1023
|
-
var IDB_DATABASE_NAME = "kora-persistence";
|
|
1024
|
-
var IDB_STORE_NAME = "databases";
|
|
1025
|
-
var IDB_VERSION = 1;
|
|
1026
|
-
var DUMP_SUFFIX = "::dump";
|
|
1027
|
-
function isIndexedDbQuotaError(error) {
|
|
1028
|
-
if (error instanceof DOMException && error.name === "QuotaExceededError") {
|
|
1029
|
-
return true;
|
|
1030
|
-
}
|
|
1031
|
-
if (error instanceof PersistenceError && error.context?.quotaExceeded === true) {
|
|
1032
|
-
return true;
|
|
1033
|
-
}
|
|
1034
|
-
if (error instanceof Error && /quota/i.test(error.message)) {
|
|
1035
|
-
return true;
|
|
1036
|
-
}
|
|
1037
|
-
return false;
|
|
1038
|
-
}
|
|
1039
|
-
function rejectWithIdbError(reject, message, context, event) {
|
|
1040
|
-
const domError = event.target?.error;
|
|
1041
|
-
if (domError && isIndexedDbQuotaError(domError)) {
|
|
1042
|
-
reject(
|
|
1043
|
-
new PersistenceError(message, {
|
|
1044
|
-
...context,
|
|
1045
|
-
quotaExceeded: true,
|
|
1046
|
-
cause: domError.message
|
|
1047
|
-
})
|
|
1048
|
-
);
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
reject(new PersistenceError(message, context));
|
|
1052
|
-
}
|
|
1053
|
-
function openIdb() {
|
|
1054
|
-
return new Promise((resolve, reject) => {
|
|
1055
|
-
const request = indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
|
|
1056
|
-
request.onupgradeneeded = () => {
|
|
1057
|
-
const db = request.result;
|
|
1058
|
-
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
|
1059
|
-
db.createObjectStore(IDB_STORE_NAME);
|
|
1060
|
-
}
|
|
1061
|
-
};
|
|
1062
|
-
request.onsuccess = () => resolve(request.result);
|
|
1063
|
-
request.onerror = () => reject(
|
|
1064
|
-
new PersistenceError(
|
|
1065
|
-
`Failed to open IndexedDB: ${request.error?.message ?? "unknown error"}`,
|
|
1066
|
-
{ database: IDB_DATABASE_NAME }
|
|
1067
|
-
)
|
|
1068
|
-
);
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
async function saveToIndexedDB(dbName, data) {
|
|
1072
|
-
const idb = await openIdb();
|
|
1073
|
-
try {
|
|
1074
|
-
await new Promise((resolve, reject) => {
|
|
1075
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
1076
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1077
|
-
store.put(data, dbName);
|
|
1078
|
-
tx.oncomplete = () => resolve();
|
|
1079
|
-
tx.onerror = (event) => rejectWithIdbError(
|
|
1080
|
-
reject,
|
|
1081
|
-
`Failed to save database "${dbName}" to IndexedDB`,
|
|
1082
|
-
{ dbName },
|
|
1083
|
-
event
|
|
1084
|
-
);
|
|
1085
|
-
});
|
|
1086
|
-
} finally {
|
|
1087
|
-
idb.close();
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
async function saveDumpToIndexedDB(dbName, dump) {
|
|
1091
|
-
const idb = await openIdb();
|
|
1092
|
-
try {
|
|
1093
|
-
await new Promise((resolve, reject) => {
|
|
1094
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
1095
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1096
|
-
store.put(dump, `${dbName}${DUMP_SUFFIX}`);
|
|
1097
|
-
tx.oncomplete = () => resolve();
|
|
1098
|
-
tx.onerror = (event) => rejectWithIdbError(
|
|
1099
|
-
reject,
|
|
1100
|
-
`Failed to save dump for database "${dbName}"`,
|
|
1101
|
-
{ dbName },
|
|
1102
|
-
event
|
|
1103
|
-
);
|
|
1104
|
-
});
|
|
1105
|
-
} finally {
|
|
1106
|
-
idb.close();
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
async function loadFromIndexedDB(dbName) {
|
|
1110
|
-
const idb = await openIdb();
|
|
1111
|
-
try {
|
|
1112
|
-
return await new Promise((resolve, reject) => {
|
|
1113
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
1114
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1115
|
-
const request = store.get(dbName);
|
|
1116
|
-
request.onsuccess = () => {
|
|
1117
|
-
const result = request.result;
|
|
1118
|
-
if (result instanceof Uint8Array) {
|
|
1119
|
-
resolve(result);
|
|
1120
|
-
} else if (result) {
|
|
1121
|
-
resolve(new Uint8Array(result));
|
|
1122
|
-
} else {
|
|
1123
|
-
resolve(null);
|
|
1124
|
-
}
|
|
1125
|
-
};
|
|
1126
|
-
request.onerror = () => reject(
|
|
1127
|
-
new PersistenceError(`Failed to load database "${dbName}" from IndexedDB`, { dbName })
|
|
1128
|
-
);
|
|
1129
|
-
});
|
|
1130
|
-
} finally {
|
|
1131
|
-
idb.close();
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
async function loadDumpFromIndexedDB(dbName) {
|
|
1135
|
-
const idb = await openIdb();
|
|
1136
|
-
try {
|
|
1137
|
-
return await new Promise((resolve, reject) => {
|
|
1138
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
1139
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1140
|
-
const request = store.get(`${dbName}${DUMP_SUFFIX}`);
|
|
1141
|
-
request.onsuccess = () => {
|
|
1142
|
-
resolve(request.result ?? null);
|
|
1143
|
-
};
|
|
1144
|
-
request.onerror = () => reject(
|
|
1145
|
-
new PersistenceError(`Failed to load dump for database "${dbName}" from IndexedDB`, {
|
|
1146
|
-
dbName
|
|
1147
|
-
})
|
|
1148
|
-
);
|
|
1149
|
-
});
|
|
1150
|
-
} finally {
|
|
1151
|
-
idb.close();
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
async function deleteSnapshotFromIndexedDB(dbName) {
|
|
1155
|
-
const idb = await openIdb();
|
|
1156
|
-
try {
|
|
1157
|
-
await new Promise((resolve, reject) => {
|
|
1158
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
1159
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1160
|
-
store.delete(dbName);
|
|
1161
|
-
tx.oncomplete = () => resolve();
|
|
1162
|
-
tx.onerror = () => reject(
|
|
1163
|
-
new PersistenceError(`Failed to delete database snapshot "${dbName}" from IndexedDB`, {
|
|
1164
|
-
dbName
|
|
1165
|
-
})
|
|
1166
|
-
);
|
|
1167
|
-
});
|
|
1168
|
-
} finally {
|
|
1169
|
-
idb.close();
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
1784
|
// src/adapters/indexeddb-adapter.ts
|
|
1174
1785
|
var IndexedDbAdapter = class {
|
|
1175
1786
|
inner;
|
|
@@ -1177,14 +1788,29 @@ var IndexedDbAdapter = class {
|
|
|
1177
1788
|
emitter;
|
|
1178
1789
|
scheduler;
|
|
1179
1790
|
storageOpenState = null;
|
|
1791
|
+
options;
|
|
1792
|
+
/** True in browsers (a real worker), where the backend choice is recorded. */
|
|
1793
|
+
usesRealWorker;
|
|
1180
1794
|
constructor(options = {}) {
|
|
1795
|
+
this.options = options;
|
|
1181
1796
|
this.dbName = options.dbName ?? "kora-db";
|
|
1182
1797
|
this.emitter = options.emitter;
|
|
1798
|
+
this.usesRealWorker = !options.bridge && options.workerUrl !== void 0;
|
|
1183
1799
|
this.inner = new SqliteWasmAdapter({
|
|
1184
1800
|
bridge: options.bridge,
|
|
1185
1801
|
workerUrl: options.workerUrl,
|
|
1186
1802
|
dbName: this.dbName,
|
|
1187
|
-
workerResponseTimeoutMs: options.workerResponseTimeoutMs
|
|
1803
|
+
workerResponseTimeoutMs: options.workerResponseTimeoutMs,
|
|
1804
|
+
// The inner SQLite database is in memory by design: this adapter makes it
|
|
1805
|
+
// durable by persisting snapshots to IndexedDB, so the inner adapter must
|
|
1806
|
+
// not refuse writes as non-durable, and must never touch OPFS (which would
|
|
1807
|
+
// create a second, disjoint copy of the data).
|
|
1808
|
+
allowNonDurable: true,
|
|
1809
|
+
storage: "memory",
|
|
1810
|
+
skipBackendRecord: true,
|
|
1811
|
+
// A browser leader restores the snapshot into its own fresh worker database
|
|
1812
|
+
// before serving other tabs (first open and promotion, STORE-6).
|
|
1813
|
+
onLeaderWorkerOpened: (tx) => this.restoreDumpIfFresh(tx)
|
|
1188
1814
|
});
|
|
1189
1815
|
this.scheduler = new IndexedDbPersistenceScheduler({
|
|
1190
1816
|
debounceMs: options.persistenceDebounceMs,
|
|
@@ -1193,19 +1819,91 @@ var IndexedDbAdapter = class {
|
|
|
1193
1819
|
});
|
|
1194
1820
|
}
|
|
1195
1821
|
async open(schema) {
|
|
1822
|
+
const tracksBackend = this.usesRealWorker && isManifestAvailable();
|
|
1823
|
+
const recorded = tracksBackend ? await readManifestRecord(this.dbName).catch(() => null) : null;
|
|
1824
|
+
const carried = recorded?.backend === "opfs" ? await this.readOpfsCopy(schema) : null;
|
|
1196
1825
|
await this.inner.open(schema);
|
|
1197
1826
|
this.storageOpenState = { persistent: true, mode: "indexeddb" };
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1827
|
+
if (carried && this.inner.isLeader()) {
|
|
1828
|
+
await this.inner.transaction(async (tx) => {
|
|
1829
|
+
await markRestored(tx);
|
|
1830
|
+
});
|
|
1831
|
+
await this.applyDump(carried);
|
|
1832
|
+
await this.scheduler.flushNow();
|
|
1833
|
+
await recordDatabase({ dbName: this.dbName, backend: "indexeddb" });
|
|
1834
|
+
this.emitter?.emit({
|
|
1835
|
+
type: "store:storage-migrated",
|
|
1836
|
+
dbName: this.dbName,
|
|
1837
|
+
from: "opfs",
|
|
1838
|
+
to: "indexeddb",
|
|
1839
|
+
message: `Database "${this.dbName}" moved from OPFS into IndexedDB because OPFS is not usable in this session.`
|
|
1840
|
+
});
|
|
1201
1841
|
return;
|
|
1202
1842
|
}
|
|
1843
|
+
if (this.inner.isLeader()) {
|
|
1844
|
+
await this.restoreIfFresh();
|
|
1845
|
+
}
|
|
1846
|
+
const stored = storedSchemaVersion(
|
|
1847
|
+
await this.inner.query(STORED_SCHEMA_VERSION_SQL)
|
|
1848
|
+
);
|
|
1849
|
+
if (stored > schema.version) {
|
|
1850
|
+
this.scheduler.dispose();
|
|
1851
|
+
await this.inner.close().catch(() => void 0);
|
|
1852
|
+
throw new SchemaVersionAheadError(this.dbName, stored, schema.version);
|
|
1853
|
+
}
|
|
1854
|
+
if (tracksBackend) {
|
|
1855
|
+
await recordDatabase({ dbName: this.dbName, backend: "indexeddb" }).catch(
|
|
1856
|
+
(error) => console.warn(`[kora] Could not record the storage backend of "${this.dbName}":`, error)
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Read the OPFS copy of this database through a short-lived SQLite WASM
|
|
1862
|
+
* adapter (as leader, or as a follower of a tab that has it open). Refuses
|
|
1863
|
+
* loudly when OPFS cannot be read, instead of starting an empty copy.
|
|
1864
|
+
*/
|
|
1865
|
+
async readOpfsCopy(schema) {
|
|
1866
|
+
const reader = new SqliteWasmAdapter({
|
|
1867
|
+
dbName: this.dbName,
|
|
1868
|
+
workerUrl: this.options.workerUrl,
|
|
1869
|
+
workerResponseTimeoutMs: this.options.workerResponseTimeoutMs,
|
|
1870
|
+
skipBackendRecord: true,
|
|
1871
|
+
releaseOnFreeze: false
|
|
1872
|
+
});
|
|
1203
1873
|
try {
|
|
1204
|
-
await
|
|
1205
|
-
|
|
1206
|
-
|
|
1874
|
+
await reader.open(schema);
|
|
1875
|
+
if (reader.getStorageOpenState()?.persistent !== true) {
|
|
1876
|
+
throw new StorageBackendMismatchError(
|
|
1877
|
+
this.dbName,
|
|
1878
|
+
"OPFS",
|
|
1879
|
+
"IndexedDB",
|
|
1880
|
+
`OPFS reported ${reader.getStorageOpenState()?.fallbackReason ?? "no durable storage"}`
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
let dump = { tables: [] };
|
|
1884
|
+
await reader.transaction(async (tx) => {
|
|
1885
|
+
dump = await exportDump((sql, params) => tx.query(sql, params));
|
|
1886
|
+
});
|
|
1887
|
+
return dump;
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
if (error instanceof StorageBackendMismatchError) throw error;
|
|
1890
|
+
throw new StorageBackendMismatchError(
|
|
1891
|
+
this.dbName,
|
|
1892
|
+
"OPFS",
|
|
1893
|
+
"IndexedDB",
|
|
1894
|
+
error instanceof Error ? error.message : String(error)
|
|
1895
|
+
);
|
|
1896
|
+
} finally {
|
|
1897
|
+
await reader.close().catch(() => void 0);
|
|
1207
1898
|
}
|
|
1208
1899
|
}
|
|
1900
|
+
async applyDump(dump) {
|
|
1901
|
+
await this.inner.transaction(async (tx) => {
|
|
1902
|
+
for (const statement of restoreDumpStatements(dump)) {
|
|
1903
|
+
await tx.execute(statement.sql, statement.params);
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1209
1907
|
async close() {
|
|
1210
1908
|
await this.scheduler.flushNow();
|
|
1211
1909
|
this.scheduler.dispose();
|
|
@@ -1236,6 +1934,15 @@ var IndexedDbAdapter = class {
|
|
|
1236
1934
|
async flushPersistence() {
|
|
1237
1935
|
await this.scheduler.flushNow();
|
|
1238
1936
|
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Durability barrier (RT-35): resolves once every write committed before the call is
|
|
1939
|
+
* in a snapshot persisted to IndexedDB, and rejects when the snapshot cannot be
|
|
1940
|
+
* written. Sync calls it before an operation leaves the device, so the server never
|
|
1941
|
+
* holds an operation this device could lose on reload.
|
|
1942
|
+
*/
|
|
1943
|
+
async ensureDurable() {
|
|
1944
|
+
await this.scheduler.flushBarrier();
|
|
1945
|
+
}
|
|
1239
1946
|
async writeSnapshot() {
|
|
1240
1947
|
const dump = await this.exportDump();
|
|
1241
1948
|
await saveDumpToIndexedDB(this.dbName, dump);
|
|
@@ -1267,59 +1974,58 @@ var IndexedDbAdapter = class {
|
|
|
1267
1974
|
code: quotaExceeded ? "QUOTA_EXCEEDED" : code
|
|
1268
1975
|
});
|
|
1269
1976
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
await this.inner.
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1977
|
+
/**
|
|
1978
|
+
* Restore the persisted snapshot into the worker database unless one was restored
|
|
1979
|
+
* into it already (this adapter or another one sharing the worker). The binary
|
|
1980
|
+
* snapshot (Node / test workers that support export) is imported whole; the JSON dump
|
|
1981
|
+
* (browsers) is restored in one transaction.
|
|
1982
|
+
*/
|
|
1983
|
+
async restoreIfFresh() {
|
|
1984
|
+
const persisted = await loadFromIndexedDB(this.dbName);
|
|
1985
|
+
if (persisted) {
|
|
1986
|
+
const fresh = { value: false };
|
|
1987
|
+
await this.inner.transaction(async (tx) => {
|
|
1988
|
+
fresh.value = !await isRestored(tx);
|
|
1989
|
+
});
|
|
1990
|
+
if (!fresh.value) return;
|
|
1991
|
+
try {
|
|
1992
|
+
await this.inner.importDatabase(persisted);
|
|
1993
|
+
await this.inner.transaction(markRestored);
|
|
1994
|
+
return;
|
|
1995
|
+
} catch {
|
|
1289
1996
|
}
|
|
1290
1997
|
}
|
|
1998
|
+
await this.inner.transaction((tx) => this.restoreDumpIfFresh(tx));
|
|
1291
1999
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
);
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
`PRAGMA table_info(${(0, import_core3.quoteIdent)(tableName)})`
|
|
1301
|
-
);
|
|
1302
|
-
const columnNames = columns.map((column) => column.name);
|
|
1303
|
-
const rows = await this.inner.query(
|
|
1304
|
-
`SELECT * FROM ${(0, import_core3.quoteIdent)(tableName)}`
|
|
1305
|
-
);
|
|
1306
|
-
tables.push({
|
|
1307
|
-
name: tableName,
|
|
1308
|
-
columns: columnNames,
|
|
1309
|
-
rows
|
|
1310
|
-
});
|
|
2000
|
+
/** In `tx`: restore the JSON dump when no snapshot was restored into this database. */
|
|
2001
|
+
async restoreDumpIfFresh(tx) {
|
|
2002
|
+
if (await isRestored(tx)) return;
|
|
2003
|
+
await markRestored(tx);
|
|
2004
|
+
const dump = await loadDumpFromIndexedDB(this.dbName);
|
|
2005
|
+
if (!dump) return;
|
|
2006
|
+
for (const statement of restoreDumpStatements(dump)) {
|
|
2007
|
+
await tx.execute(statement.sql, statement.params);
|
|
1311
2008
|
}
|
|
1312
|
-
return { tables };
|
|
1313
2009
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
identifier
|
|
2010
|
+
/** A consistent snapshot: every table is read inside one transaction (STORE-7). */
|
|
2011
|
+
async exportDump() {
|
|
2012
|
+
let dump = { tables: [] };
|
|
2013
|
+
await this.inner.transaction(async (tx) => {
|
|
2014
|
+
dump = await exportDump((sql, params) => tx.query(sql, params));
|
|
1320
2015
|
});
|
|
2016
|
+
return dump;
|
|
1321
2017
|
}
|
|
1322
|
-
|
|
2018
|
+
};
|
|
2019
|
+
var RESTORED_MARKER = "_kora_idb_restored";
|
|
2020
|
+
async function isRestored(tx) {
|
|
2021
|
+
const rows = await tx.query(
|
|
2022
|
+
"SELECT name FROM sqlite_temp_master WHERE type = 'table' AND name = ?",
|
|
2023
|
+
[RESTORED_MARKER]
|
|
2024
|
+
);
|
|
2025
|
+
return rows.length > 0;
|
|
2026
|
+
}
|
|
2027
|
+
async function markRestored(tx) {
|
|
2028
|
+
await tx.execute(`CREATE TEMP TABLE IF NOT EXISTS ${RESTORED_MARKER} (restored INTEGER)`);
|
|
1323
2029
|
}
|
|
1324
2030
|
function isUnsupportedWorkerExport(error) {
|
|
1325
2031
|
return error instanceof AdapterError && error.context?.code === "EXPORT_NOT_SUPPORTED";
|