@korajs/store 1.0.0-beta.12 → 1.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +29 -37
  2. package/dist/adapters/better-sqlite3.cjs +90 -4
  3. package/dist/adapters/better-sqlite3.cjs.map +1 -1
  4. package/dist/adapters/better-sqlite3.d.cts +21 -12
  5. package/dist/adapters/better-sqlite3.d.ts +21 -12
  6. package/dist/adapters/better-sqlite3.js +69 -5
  7. package/dist/adapters/better-sqlite3.js.map +1 -1
  8. package/dist/adapters/indexeddb.cjs +1316 -610
  9. package/dist/adapters/indexeddb.cjs.map +1 -1
  10. package/dist/adapters/indexeddb.d.cts +29 -3
  11. package/dist/adapters/indexeddb.d.ts +29 -3
  12. package/dist/adapters/indexeddb.js +247 -221
  13. package/dist/adapters/indexeddb.js.map +1 -1
  14. package/dist/adapters/sqlite-wasm-worker.cjs +856 -63
  15. package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
  16. package/dist/adapters/sqlite-wasm-worker.js +529 -63
  17. package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
  18. package/dist/adapters/sqlite-wasm.cjs +1032 -344
  19. package/dist/adapters/sqlite-wasm.cjs.map +1 -1
  20. package/dist/adapters/sqlite-wasm.d.cts +239 -18
  21. package/dist/adapters/sqlite-wasm.d.ts +239 -18
  22. package/dist/adapters/sqlite-wasm.js +111 -5
  23. package/dist/adapters/sqlite-wasm.js.map +1 -1
  24. package/dist/backup-IHKVMUAM.js +25 -0
  25. package/dist/chunk-5AQSGMJV.js +5471 -0
  26. package/dist/chunk-5AQSGMJV.js.map +1 -0
  27. package/dist/chunk-5JMGMPUE.js +591 -0
  28. package/dist/chunk-5JMGMPUE.js.map +1 -0
  29. package/dist/{chunk-CYO3YKFL.js → chunk-5ZRRF6R2.js} +2 -2
  30. package/dist/chunk-A6GFH5IG.js +36 -0
  31. package/dist/chunk-A6GFH5IG.js.map +1 -0
  32. package/dist/chunk-BILSA6XZ.js +608 -0
  33. package/dist/chunk-BILSA6XZ.js.map +1 -0
  34. package/dist/chunk-BQSIL6TT.js +1095 -0
  35. package/dist/chunk-BQSIL6TT.js.map +1 -0
  36. package/dist/{chunk-GSJ7EEUC.js → chunk-J5AGXF3D.js} +58 -9
  37. package/dist/chunk-J5AGXF3D.js.map +1 -0
  38. package/dist/chunk-KK3GBONL.js +112 -0
  39. package/dist/chunk-KK3GBONL.js.map +1 -0
  40. package/dist/chunk-KTVNANUH.js +876 -0
  41. package/dist/chunk-KTVNANUH.js.map +1 -0
  42. package/dist/chunk-QW4UU7NG.js +1003 -0
  43. package/dist/chunk-QW4UU7NG.js.map +1 -0
  44. package/dist/chunk-SRHDJF5B.js +186 -0
  45. package/dist/chunk-SRHDJF5B.js.map +1 -0
  46. package/dist/chunk-UC2LGFPV.js +810 -0
  47. package/dist/chunk-UC2LGFPV.js.map +1 -0
  48. package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
  49. package/dist/index.cjs +9533 -4246
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.d.cts +1143 -110
  52. package/dist/index.d.ts +1143 -110
  53. package/dist/index.js +298 -3191
  54. package/dist/index.js.map +1 -1
  55. package/dist/internal.cjs +581 -264
  56. package/dist/internal.cjs.map +1 -1
  57. package/dist/internal.d.cts +15 -85
  58. package/dist/internal.d.ts +15 -85
  59. package/dist/internal.js +10 -8
  60. package/dist/restore-MNDPR557.js +299 -0
  61. package/dist/restore-MNDPR557.js.map +1 -0
  62. package/dist/sql-builder-D0tC3sv9.d.cts +615 -0
  63. package/dist/sql-builder-D5UMzHEo.d.ts +615 -0
  64. package/dist/sqlite-wasm-channel-DVX93Uiq.d.cts +141 -0
  65. package/dist/sqlite-wasm-channel-DVX93Uiq.d.ts +141 -0
  66. package/dist/sqlite-wasm-channel-QCCNBYCU.js +12 -0
  67. package/dist/store-HNGX4KTT.js +16 -0
  68. package/dist/store-HNGX4KTT.js.map +1 -0
  69. package/dist/{types-BslDOTk0.d.ts → types-Dh-xyUjP.d.cts} +99 -26
  70. package/dist/{types-BslDOTk0.d.cts → types-Dh-xyUjP.d.ts} +99 -26
  71. package/package.json +3 -2
  72. package/dist/backup-5S7K2Z35.js +0 -14
  73. package/dist/chunk-GSJ7EEUC.js.map +0 -1
  74. package/dist/chunk-HS6DBMST.js +0 -383
  75. package/dist/chunk-HS6DBMST.js.map +0 -1
  76. package/dist/chunk-RJIEKEN4.js +0 -751
  77. package/dist/chunk-RJIEKEN4.js.map +0 -1
  78. package/dist/chunk-TUR7RGEP.js +0 -84
  79. package/dist/chunk-TUR7RGEP.js.map +0 -1
  80. package/dist/chunk-VDBJQ75K.js +0 -78
  81. package/dist/chunk-VDBJQ75K.js.map +0 -1
  82. package/dist/chunk-YT7MRFCP.js +0 -687
  83. package/dist/chunk-YT7MRFCP.js.map +0 -1
  84. package/dist/sqlite-wasm-channel-GGHA2BXU.js +0 -12
  85. package/dist/sqlite-wasm-channel-L6o3bWQK.d.cts +0 -92
  86. package/dist/sqlite-wasm-channel-L6o3bWQK.d.ts +0 -92
  87. package/dist/types-BOO9EPt1.d.ts +0 -170
  88. package/dist/types-C_GDavG-.d.cts +0 -170
  89. /package/dist/{backup-5S7K2Z35.js.map → backup-IHKVMUAM.js.map} +0 -0
  90. /package/dist/{chunk-CYO3YKFL.js.map → chunk-5ZRRF6R2.js.map} +0 -0
  91. /package/dist/{export-audit-ODGQTQIK.js.map → export-audit-J2QXRHD2.js.map} +0 -0
  92. /package/dist/{sqlite-wasm-channel-GGHA2BXU.js.map → sqlite-wasm-channel-QCCNBYCU.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
- `No live leader tab is answering multi-tab storage RPC for operation "${operation}"`,
55
- "NO_LEADER",
56
- { operation }
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 = "NoLeaderError";
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 response = event.data;
139
- const entry = this.pending.get(response.id);
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(response.id);
142
- entry.resolve(response);
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
- async send(request) {
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 req = { ...request, id };
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
- reject(new WorkerTimeoutError(req.type, this.timeoutMs));
168
- }, this.timeoutMs);
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
- clearTimeout(timer);
266
+ cleanup();
172
267
  resolve(response);
173
268
  },
174
269
  reject: (error) => {
175
- clearTimeout(timer);
270
+ cleanup();
176
271
  reject(error);
177
272
  }
178
273
  });
179
- this.worker.postMessage(req);
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 Error("Worker terminated"));
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
- /** Cancel any pending debounce and persist immediately. */
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
- if (this.inFlight) {
252
- await this.inFlight;
253
- return;
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
- this.inFlight = this.runFlush();
256
- try {
257
- await this.inFlight;
258
- } finally {
259
- this.inFlight = null;
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 import_core2 = require("@korajs/core");
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 DEFAULT_LIVENESS_PROBE_MS = 2e3;
293
- var DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS = 1e4;
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 = `kora-storage-${dbName}`;
299
- const lockName = `kora-leader-${dbName}`;
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
- livenessProbeMs;
599
+ heartbeatMs;
401
600
  terminated = false;
402
- constructor(channelName, timeoutMs = 3e4, livenessProbeMs = DEFAULT_LIVENESS_PROBE_MS) {
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.livenessProbeMs = Math.min(livenessProbeMs, timeoutMs);
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 and waiting out the full timeout.
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
- clearTimeout(probe);
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
- const timer = setTimeout(() => {
487
- settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs)));
488
- }, this.timeoutMs);
489
- const probe = setTimeout(() => {
490
- void this.pingLeader(this.livenessProbeMs).then((alive) => {
491
- if (!alive && this.pending.has(requestId)) {
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 Error("Follower bridge terminated"));
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
- leaveLeader() {
519
- try {
520
- const message = {
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
- send(request, clientId = this.leaderClientId) {
545
- if (this.terminated) {
546
- return Promise.resolve({
547
- id: request.id,
548
- type: "error",
549
- message: "Worker has been terminated",
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
- terminate() {
571
- if (this.terminated) {
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
- reclaimClient(clientId, reason) {
583
- if (this.terminated) {
584
- return;
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
- async processQueue() {
589
- if (this.processing || this.reclaiming) {
757
+ checkLiveness() {
758
+ if (this.pending.size === 0) {
759
+ this.stopWatchdog();
590
760
  return;
591
761
  }
592
- this.processing = true;
593
- try {
594
- while (!this.terminated) {
595
- const index = this.nextRunnableIndex();
596
- if (index === -1) {
597
- return;
598
- }
599
- const [entry] = this.queue.splice(index, 1);
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 (this.activeTransactionClient === entry.clientId) {
639
- this.armTransactionIdleTimer(entry.clientId);
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
- async reclaimClientNow(clientId, reason) {
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 response = await this.inner.send({ id: 0, type: "rollback" });
675
- if (response.type === "error") {
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
- var warnedSharedWorkerDeprecated = false;
715
- var SqliteWasmAdapter = class {
716
- bridge = null;
717
- opened = false;
718
- mutex = new Mutex();
719
- injectedBridge;
720
- workerUrl;
721
- sharedWorkerUrl;
722
- workerResponseTimeoutMs;
723
- dbName;
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
- async open(schema) {
741
- if (this.opened) return;
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
- async openDurableWorkerBridge(schema) {
763
- if (!this.workerUrl) {
764
- throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
765
- }
766
- this.schema = schema;
767
- this.tabSession = await acquireTabStorageSession(this.dbName, {
768
- onPromote: () => {
769
- void this.promoteToLeader();
770
- }
771
- });
772
- const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
773
- if (this.tabSession.role === "leader") {
774
- const workerBridge = new TransactionSerializingWorkerBridge(
775
- new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
776
- );
777
- this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
778
- this.bridge = workerBridge;
779
- return;
780
- }
781
- const followerBridge = new FollowerBroadcastBridge(
782
- this.tabSession.channelName,
783
- this.workerResponseTimeoutMs
784
- );
785
- this.bridge = followerBridge;
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
- async openCurrentBridge(ddlStatements) {
794
- const response = await this.sendRequest({
795
- id: 0,
796
- type: "open",
797
- ddlStatements,
798
- dbName: this.dbName
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
- await this.sendRequest({ id: 0, type: "close" });
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.tabSession?.stopRelay?.();
847
- this.tabSession?.cancelPromotionWatch?.();
848
- if (this.tabSession?.releaseLock) {
849
- await this.tabSession.releaseLock();
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
- this.tabSession = null;
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 crashed). The lock is only granted once the old leader
860
- * is gone, so opening our own worker here is safe from the OPFS single-writer
861
- * rule. In-flight follower RPCs are rejected and left to the caller / reactive
862
- * layer to retry against the new leader.
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 { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
870
- const workerBridge = new TransactionSerializingWorkerBridge(
871
- new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
872
- );
873
- const previousBridge = this.bridge;
874
- this.bridge = workerBridge;
875
- if (this.tabSession) {
876
- this.tabSession.role = "leader";
877
- this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
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
- async execute(sql, params) {
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
- const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
886
- if (response.type === "error") {
887
- throw new AdapterError(`Execute failed: ${response.message}`, { sql, params });
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
- async query(sql, params) {
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 response = await this.sendRequest({ id: 0, type: "query", sql, params });
893
- if (response.type === "error") {
894
- throw new AdapterError(`Query failed: ${response.message}`, { sql, params });
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
- await this.sendChecked({ id: 0, type: "begin" }, "BEGIN transaction");
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 this.sendRequest({ id: 0, type: "execute", sql, params });
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 this.sendRequest({ id: 0, type: "query", sql, params });
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 this.sendChecked({ id: 0, type: "commit" }, "COMMIT transaction");
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
- await this.sendRequest({ id: 0, type: "rollback" });
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 response = await this.sendRequest({ id: 0, type: "export" });
969
- if (response.type === "error") {
970
- throw new AdapterError(`Export failed: ${response.message}`, {
971
- code: response.code,
972
- context: response.context
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
- const response = await this.sendRequest({ id: 0, type: "import", data });
983
- if (response.type === "error") {
984
- throw new AdapterError(`Import failed: ${response.message}`);
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
- const persisted = await loadFromIndexedDB(this.dbName);
1199
- if (!persisted) {
1200
- await this.restoreFromDumpFallback();
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 this.inner.importDatabase(persisted);
1205
- } catch {
1206
- await this.restoreFromDumpFallback();
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
- async restoreFromDumpFallback() {
1271
- const dump = await loadDumpFromIndexedDB(this.dbName);
1272
- if (!dump) return;
1273
- for (const table of dump.tables) {
1274
- const name = ensureSafeIdentifier(table.name);
1275
- await this.inner.execute(`DELETE FROM ${(0, import_core3.quoteIdent)(name)}`);
1276
- if (table.rows.length === 0) continue;
1277
- for (const row of table.rows) {
1278
- const columns = table.columns.filter(
1279
- (column) => Object.prototype.hasOwnProperty.call(row, column)
1280
- );
1281
- if (columns.length === 0) continue;
1282
- const placeholders = columns.map(() => "?").join(", ");
1283
- const quotedColumns = columns.map((column) => (0, import_core3.quoteIdent)(ensureSafeIdentifier(column))).join(", ");
1284
- const values = columns.map((column) => row[column]);
1285
- await this.inner.execute(
1286
- `INSERT INTO ${(0, import_core3.quoteIdent)(name)} (${quotedColumns}) VALUES (${placeholders})`,
1287
- values
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
- async exportDump() {
1293
- const tableRows = await this.inner.query(
1294
- "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
1295
- );
1296
- const tables = [];
1297
- for (const tableRow of tableRows) {
1298
- const tableName = ensureSafeIdentifier(tableRow.name);
1299
- const columns = await this.inner.query(
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
- function ensureSafeIdentifier(identifier) {
1316
- if (!/^[a-zA-Z0-9_]+$/.test(identifier)) {
1317
- throw new PersistenceError(`Unsafe SQL identifier: ${identifier}`, {
1318
- code: "UNSAFE_IDENTIFIER",
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
- return identifier;
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";