@framers/sql-storage-adapter 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +94 -3
  2. package/dist/adapters/betterSqliteAdapter.d.ts +18 -2
  3. package/dist/adapters/betterSqliteAdapter.d.ts.map +1 -1
  4. package/dist/adapters/betterSqliteAdapter.js +13 -5
  5. package/dist/adapters/betterSqliteAdapter.js.map +1 -1
  6. package/dist/adapters/capacitorSqliteAdapter.d.ts +8 -0
  7. package/dist/adapters/capacitorSqliteAdapter.d.ts.map +1 -1
  8. package/dist/adapters/capacitorSqliteAdapter.js +4 -1
  9. package/dist/adapters/capacitorSqliteAdapter.js.map +1 -1
  10. package/dist/adapters/electron/electronMainAdapter.d.ts +10 -0
  11. package/dist/adapters/electron/electronMainAdapter.d.ts.map +1 -1
  12. package/dist/adapters/electron/electronMainAdapter.js +21 -12
  13. package/dist/adapters/electron/electronMainAdapter.js.map +1 -1
  14. package/dist/adapters/indexedDbAdapter.d.ts +198 -54
  15. package/dist/adapters/indexedDbAdapter.d.ts.map +1 -1
  16. package/dist/adapters/indexedDbAdapter.js +549 -129
  17. package/dist/adapters/indexedDbAdapter.js.map +1 -1
  18. package/dist/adapters/postgresAdapter.d.ts +143 -4
  19. package/dist/adapters/postgresAdapter.d.ts.map +1 -1
  20. package/dist/adapters/postgresAdapter.js +612 -107
  21. package/dist/adapters/postgresAdapter.js.map +1 -1
  22. package/dist/adapters/sqlJsAdapter.d.ts +17 -3
  23. package/dist/adapters/sqlJsAdapter.d.ts.map +1 -1
  24. package/dist/adapters/sqlJsAdapter.js +15 -3
  25. package/dist/adapters/sqlJsAdapter.js.map +1 -1
  26. package/dist/core/database.d.ts +28 -8
  27. package/dist/core/database.d.ts.map +1 -1
  28. package/dist/core/database.js +34 -5
  29. package/dist/core/database.js.map +1 -1
  30. package/dist/core/resolver.d.ts +14 -1
  31. package/dist/core/resolver.d.ts.map +1 -1
  32. package/dist/core/resolver.js +28 -8
  33. package/dist/core/resolver.js.map +1 -1
  34. package/dist/features/kv/idb.d.ts +79 -0
  35. package/dist/features/kv/idb.d.ts.map +1 -0
  36. package/dist/features/kv/idb.js +14 -0
  37. package/dist/features/kv/idb.js.map +1 -0
  38. package/dist/features/kv/index.d.ts +14 -0
  39. package/dist/features/kv/index.d.ts.map +1 -0
  40. package/dist/features/kv/index.js +14 -0
  41. package/dist/features/kv/index.js.map +1 -0
  42. package/dist/features/kv/indexedDbKeyValueStore.d.ts +77 -0
  43. package/dist/features/kv/indexedDbKeyValueStore.d.ts.map +1 -0
  44. package/dist/features/kv/indexedDbKeyValueStore.js +127 -0
  45. package/dist/features/kv/indexedDbKeyValueStore.js.map +1 -0
  46. package/dist/features/kv/keyValueStore.d.ts +48 -0
  47. package/dist/features/kv/keyValueStore.d.ts.map +1 -0
  48. package/dist/features/kv/keyValueStore.js +54 -0
  49. package/dist/features/kv/keyValueStore.js.map +1 -0
  50. package/dist/features/kv/sqlKeyValueStore.d.ts +34 -0
  51. package/dist/features/kv/sqlKeyValueStore.d.ts.map +1 -0
  52. package/dist/features/kv/sqlKeyValueStore.js +91 -0
  53. package/dist/features/kv/sqlKeyValueStore.js.map +1 -0
  54. package/dist/features/kv/valueCodec.d.ts +35 -0
  55. package/dist/features/kv/valueCodec.d.ts.map +1 -0
  56. package/dist/features/kv/valueCodec.js +462 -0
  57. package/dist/features/kv/valueCodec.js.map +1 -0
  58. package/dist/features/migrations/dataExport.js +9 -6
  59. package/dist/features/migrations/dataExport.js.map +1 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/shared/postgresTarget.d.ts +4 -3
  65. package/dist/shared/postgresTarget.d.ts.map +1 -1
  66. package/dist/shared/postgresTarget.js +6 -4
  67. package/dist/shared/postgresTarget.js.map +1 -1
  68. package/dist/shared/segment.d.ts +43 -0
  69. package/dist/shared/segment.d.ts.map +1 -0
  70. package/dist/shared/segment.js +77 -0
  71. package/dist/shared/segment.js.map +1 -0
  72. package/package.json +10 -2
@@ -26,12 +26,12 @@
26
26
  *
27
27
  * @example
28
28
  * ```typescript
29
- * import { IndexedDbAdapter } from '@framers/sql-storage-adapter/adapters/indexedDbAdapter';
29
+ * import { IndexedDbAdapter } from '@framers/sql-storage-adapter';
30
30
  *
31
31
  * const adapter = new IndexedDbAdapter({
32
32
  * dbName: 'my-app-db',
33
- * autoSave: true,
34
- * saveIntervalMs: 5000,
33
+ * durability: 'commit', // each write is in IndexedDB before it resolves
34
+ * crossTab: true, // two tabs of the site keep each other's writes
35
35
  * });
36
36
  *
37
37
  * await adapter.open();
@@ -47,8 +47,41 @@ import { expandNamedParameters, normaliseParameters } from '../shared/parameterU
47
47
  import { toSqliteStatement } from '../shared/sqlPlaceholders.js';
48
48
  import { createSerializedTransactionRunner, createSharedConnectionTransactionAdapter, } from '../shared/serializedTransaction.js';
49
49
  import { createConnectionGate, whenAdmitted } from '../shared/connectionGate.js';
50
- /** How long a statement waits for an open transaction before the adapter warns. */
50
+ import { segmentName } from '../shared/segment.js';
51
+ /** How long a statement waits for an open transaction or the cross-tab lock before the adapter warns. */
51
52
  const LONG_WAIT_MS = 10000;
53
+ /** The object store's key of the database image. */
54
+ const IMAGE_KEY = 'db';
55
+ /**
56
+ * The object store's key of the image's version stamp: every save writes a
57
+ * new one beside the image, in the same IndexedDB transaction, so a tab can
58
+ * tell that another has saved since it loaded.
59
+ */
60
+ const STAMP_KEY = 'db:stamp';
61
+ /** How often a statement runs again after another tab saved first, without Web Locks. */
62
+ const MAX_CONFLICT_RETRIES = 50;
63
+ /**
64
+ * Connection settings sql.js's export drops: `export()` closes the
65
+ * connection and opens it again, which resets every per-connection PRAGMA. A
66
+ * save reads these before it exports and sets again the ones a caller changed.
67
+ */
68
+ const CONNECTION_PRAGMAS = [
69
+ 'foreign_keys',
70
+ 'recursive_triggers',
71
+ 'automatic_index',
72
+ 'cache_size',
73
+ 'cache_spill',
74
+ 'cell_size_check',
75
+ 'ignore_check_constraints',
76
+ 'legacy_alter_table',
77
+ 'query_only',
78
+ 'reverse_unordered_selects',
79
+ 'secure_delete',
80
+ 'synchronous',
81
+ 'temp_store',
82
+ 'trusted_schema',
83
+ 'busy_timeout',
84
+ ];
52
85
  // ============================================================================
53
86
  // GLOBAL SQL.JS SINGLETON
54
87
  // ============================================================================
@@ -74,7 +107,23 @@ async function getGlobalSQL(config) {
74
107
  });
75
108
  return globalSQLPromise;
76
109
  }
110
+ /**
111
+ * A `transaction()` another tab saved over: refused, with nothing of it
112
+ * stored. The adapter has loaded the other tab's image, so running the
113
+ * transaction again keeps both tabs' writes.
114
+ */
115
+ export class IndexedDbConflictError extends Error {
116
+ constructor(message) {
117
+ super(message);
118
+ this.name = 'IndexedDbConflictError';
119
+ }
120
+ }
77
121
  const DB_VERSION = 1;
122
+ /** The first keyword of a statement, past its leading comments and whitespace. */
123
+ const firstKeyword = (statement) => {
124
+ const rest = statement.replace(/^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, '');
125
+ return (/^[A-Za-z]+/.exec(rest)?.[0] ?? '').toUpperCase();
126
+ };
78
127
  /**
79
128
  * Storage adapter using IndexedDB + sql.js for client-side SQL persistence.
80
129
  *
@@ -92,51 +141,21 @@ const DB_VERSION = 1;
92
141
  * - Auto-save batching reduces overhead
93
142
  * - Good for: Production PWAs, offline-first apps, persistent client-side storage
94
143
  *
95
- * **Is This Necessary?**
96
- *
97
- * **✅ YES** if you need:
98
- * - Data to survive page refreshes (production apps)
99
- * - Offline-first functionality (PWAs)
100
- * - Privacy-first apps (data never leaves browser)
101
- * - Zero manual save logic (just works)
102
- *
103
- * **❌ NO** if you:
104
- * - Only need temporary/in-memory data (edge functions)
105
- * - Are prototyping and don't care about persistence
106
- * - Want to manually control when data is saved
107
- * - Don't need data to survive refreshes
108
- *
109
- * **Alternative:** You could use sql.js directly and manually save to IndexedDB yourself, but you'd lose:
110
- * - Automatic batched saves (performance optimization)
111
- * - Reliable persistence (easy to forget manual saves = data loss)
112
- * - Consistent API across platforms
113
- * - Production-ready defaults
114
- *
115
- * **Bottom line:** This adapter is **necessary for production web apps** that need persistence. For prototypes or edge functions, sql.js alone is fine.
116
- *
117
144
  * **How It Works:**
118
145
  * 1. IndexedDB stores the SQLite database file (binary blob) - this is just storage
119
146
  * 2. sql.js (WASM) loads the database into memory and executes SQL - this provides SQL capabilities
120
- * 3. After writes, the updated database is automatically saved back to IndexedDB
121
- *
122
- * **Why IndexedDB + sql.js?**
123
- * - IndexedDB alone: NoSQL key-value store, no SQL support
124
- * - sql.js alone: In-memory SQL, but **no automatic persistence** (you must manually save)
125
- * - Combined: Full SQL + **automatic browser-native persistence** = production-ready solution
147
+ * 3. After writes, the updated database is saved back to IndexedDB: on a timer
148
+ * (the default), before each write resolves (`durability: 'commit'`), or
149
+ * under a cross-tab lock (`crossTab`)
126
150
  *
127
151
  * **Capabilities:**
128
152
  * - ✅ Transactions (via sql.js)
129
153
  * - ✅ Persistence (via IndexedDB - automatic!)
130
154
  * - ✅ Full SQL support (via sql.js)
131
155
  * - ✅ Export/import
132
- * - ❌ Concurrent writes (single-threaded)
156
+ * - ✅ Several tabs on one database, with `crossTab`
133
157
  * - ❌ Server-side (browser only)
134
158
  *
135
- * **Performance:**
136
- * - Fast reads (in-memory SQL)
137
- * - Moderate writes (IndexedDB persistence)
138
- * - Auto-save batching reduces IDB overhead
139
- *
140
159
  * @example Client-side application
141
160
  * ```typescript
142
161
  * const adapter = new IndexedDbAdapter({ dbName: 'my-app-db' });
@@ -145,6 +164,7 @@ const DB_VERSION = 1;
145
164
  * ```
146
165
  */
147
166
  export class IndexedDbAdapter {
167
+ /** @throws {Error} When `options.segment` is not a name a segment can take. */
148
168
  constructor(options = {}) {
149
169
  this.kind = 'indexeddb';
150
170
  this.capabilities = new Set(['transactions', 'persistence', 'json', 'prepared']);
@@ -158,13 +178,31 @@ export class IndexedDbAdapter {
158
178
  this.savedGeneration = 0;
159
179
  /** The save in progress, if any. */
160
180
  this.saving = null;
181
+ /** The generation the save in progress exported. */
182
+ this.savingGeneration = 0;
161
183
  /** Whether a failed auto-save has been reported since the last one that succeeded. */
162
184
  this.saveFailureReported = false;
185
+ /** The open() in progress, which a second call waits for. */
186
+ this.opening = null;
163
187
  /** Set while close() runs: no statement or transaction starts. */
164
188
  this.closing = false;
165
189
  /** The close() in progress, which a second call waits for. */
166
190
  this.closeRun = null;
167
191
  this.longWaitReported = false;
192
+ this.lockWaitReported = false;
193
+ /** The stamp of the image the database in memory was loaded from or last saved as. */
194
+ this.loadedStamp = null;
195
+ /** How many stamps this adapter has made, which keeps two of its stamps apart. */
196
+ this.stampCount = 0;
197
+ /** The connection settings a fresh connection has, which a save need not set again. */
198
+ this.pragmaDefaults = new Map();
199
+ /**
200
+ * With `crossTab`, the lock manager, or null for the version stamp alone;
201
+ * undefined without `crossTab`.
202
+ */
203
+ this.locks = undefined;
204
+ /** Without Web Locks, this adapter's coordinated operations run one at a time. */
205
+ this.runCoordinatedInTurn = createSerializedTransactionRunner();
168
206
  /**
169
207
  * The connection is shared, so transaction() runs one callback at a time.
170
208
  */
@@ -181,11 +219,18 @@ export class IndexedDbAdapter {
181
219
  afterMs: LONG_WAIT_MS,
182
220
  onLongWait: () => this.reportLongWait(),
183
221
  };
184
- this.dbName = options.dbName || 'app-db';
222
+ this.dbName = segmentName(options.dbName || 'app-db', options.segment);
185
223
  this.storeName = options.storeName || 'sqliteDb';
186
224
  this.autoSave = options.autoSave ?? true;
187
225
  this.saveIntervalMs = options.saveIntervalMs || 5000;
188
226
  this.sqlJsConfig = options.sqlJsConfig || {};
227
+ this.crossTab =
228
+ options.crossTab === true ? {} : typeof options.crossTab === 'object' && options.crossTab !== null ? options.crossTab : null;
229
+ this.durability = this.crossTab ? 'commit' : options.durability ?? 'interval';
230
+ }
231
+ /** The name of the Web Lock that coordinates the tabs on this database. */
232
+ get lockName() {
233
+ return `@framers/sql-storage-adapter:indexeddb:${this.dbName}:${this.storeName}`;
189
234
  }
190
235
  /**
191
236
  * Opens IndexedDB and initializes sql.js database.
@@ -193,31 +238,57 @@ export class IndexedDbAdapter {
193
238
  *
194
239
  * Uses a global sql.js singleton to prevent race conditions when
195
240
  * multiple adapters (e.g., fabric_codex_db and quarry-codex-local)
196
- * are initialized simultaneously.
241
+ * are initialized simultaneously. Two calls at once open once.
197
242
  */
198
- async open(options) {
243
+ open(options) {
199
244
  if (this.db) {
200
- return; // Already open
245
+ return Promise.resolve(); // Already open
246
+ }
247
+ if (!this.opening) {
248
+ this.foreignKeys = options?.foreignKeys;
249
+ this.opening = this.openNow().finally(() => {
250
+ this.opening = null;
251
+ });
201
252
  }
202
- this.foreignKeys = options?.foreignKeys;
253
+ return this.opening;
254
+ }
255
+ async openNow() {
203
256
  // Initialize sql.js using global singleton (prevents race conditions)
204
257
  this.SQL = await getGlobalSQL(this.sqlJsConfig);
205
258
  // Open IndexedDB with retry logic for race conditions
206
259
  this.idb = await this.openIndexedDbWithRetry();
207
260
  // Load existing database from IndexedDB or create new
208
- const existingData = await this.loadFromIndexedDb();
209
- if (existingData) {
210
- this.db = new this.SQL.Database(existingData);
211
- }
212
- else {
213
- this.db = new this.SQL.Database();
214
- }
261
+ const stored = await this.loadFromIndexedDb();
262
+ const db = stored.image ? new this.SQL.Database(stored.image) : new this.SQL.Database();
263
+ // What a fresh connection has, and export() returns to. Read before the
264
+ // settings open() was asked for, so a save carries those like any other a
265
+ // caller made.
266
+ this.pragmaDefaults = readPragmas(db);
267
+ this.db = db;
268
+ this.loadedStamp = stored.stamp;
215
269
  this.applyConnectionSettings();
216
- // Start auto-save interval
217
- if (this.autoSave) {
270
+ if (this.crossTab) {
271
+ this.locks = this.resolveLocks(this.crossTab);
272
+ }
273
+ else if (this.autoSave) {
274
+ // Start auto-save interval; with crossTab every write is saved under the lock.
218
275
  this.startAutoSave();
219
276
  }
220
277
  }
278
+ /** The lock manager crossTab uses, or null for the version stamp alone. */
279
+ resolveLocks(options) {
280
+ if (options.locks !== undefined) {
281
+ return options.locks;
282
+ }
283
+ const scope = globalThis;
284
+ const locks = scope.navigator?.locks;
285
+ if (locks && typeof locks.request === 'function') {
286
+ return locks;
287
+ }
288
+ console.warn(`[IndexedDbAdapter] Web Locks are not available here, so "${this.dbName}" coordinates tabs by its version stamp alone: ` +
289
+ 'a statement another tab saved over runs again, and a transaction() another tab saved over fails with IndexedDbConflictError.');
290
+ return null;
291
+ }
221
292
  /**
222
293
  * Executes a SQL statement that doesn't return rows (INSERT, UPDATE, DELETE, CREATE, etc.).
223
294
  * @param statement SQL statement with ? or :name placeholders
@@ -225,7 +296,10 @@ export class IndexedDbAdapter {
225
296
  * @returns Result with changes count and last insert row ID
226
297
  */
227
298
  async run(statement, parameters) {
228
- return whenAdmitted(this.gate, () => this.runNow(statement, parameters), undefined, this.longWait);
299
+ if (this.crossTab) {
300
+ return this.coordinatedStatement(() => this.runNow(statement, parameters));
301
+ }
302
+ return whenAdmitted(this.gate, () => this.stored(this.runNow(statement, parameters)), undefined, this.longWait);
229
303
  }
230
304
  runNow(statement, parameters) {
231
305
  ({ statement, parameters } = toSqliteStatement(statement, parameters));
@@ -264,17 +338,32 @@ export class IndexedDbAdapter {
264
338
  return rows.length > 0 ? rows[0] : null;
265
339
  }
266
340
  /**
267
- * Executes a SQL query and returns all matching rows.
341
+ * Executes a SQL query and returns all matching rows. A statement that
342
+ * writes (`INSERT ... RETURNING`) is saved like a `run()`.
268
343
  * @param statement SELECT statement
269
344
  * @param parameters Query parameters
270
345
  * @returns Array of result rows as objects
271
346
  */
272
347
  async all(statement, parameters) {
273
- return whenAdmitted(this.gate, () => this.allNow(statement, parameters), undefined, this.longWait);
348
+ if (this.crossTab) {
349
+ return this.coordinatedStatement(() => this.allNow(statement, parameters));
350
+ }
351
+ return whenAdmitted(this.gate, () => {
352
+ const before = this.writeGeneration;
353
+ const rows = this.allNow(statement, parameters);
354
+ return this.writeGeneration === before ? rows : this.stored(rows);
355
+ }, undefined, this.longWait);
274
356
  }
275
357
  allNow(statement, parameters) {
276
358
  ({ statement, parameters } = toSqliteStatement(statement, parameters));
277
359
  this.ensureOpen();
360
+ // A statement that is not a plain read can write (INSERT ... RETURNING,
361
+ // WITH ... DELETE): it marks the database unsaved when it changed rows,
362
+ // and a statement that can only write marks it whatever it changed.
363
+ const keyword = firstKeyword(statement);
364
+ const reads = keyword === 'SELECT' || keyword === 'EXPLAIN' || keyword === 'VALUES';
365
+ const mayWrite = keyword === 'WITH' || keyword === 'PRAGMA';
366
+ const changesBefore = mayWrite ? this.totalChanges() : 0;
278
367
  const stmt = this.db.prepare(statement);
279
368
  try {
280
369
  const { named, positional } = normaliseParameters(parameters);
@@ -295,17 +384,27 @@ export class IndexedDbAdapter {
295
384
  });
296
385
  results.push(obj);
297
386
  }
387
+ if (!reads && (!mayWrite || this.totalChanges() !== changesBefore)) {
388
+ this.writeGeneration += 1;
389
+ }
298
390
  return results;
299
391
  }
300
392
  finally {
301
393
  stmt.free();
302
394
  }
303
395
  }
396
+ /** The rows changed on this connection since it opened. */
397
+ totalChanges() {
398
+ return Number(this.db.exec('SELECT total_changes()')[0]?.values?.[0]?.[0] ?? 0);
399
+ }
304
400
  /**
305
401
  * Executes a script containing multiple SQL statements.
306
402
  */
307
403
  async exec(script) {
308
- return whenAdmitted(this.gate, () => this.execNow(script), undefined, this.longWait);
404
+ if (this.crossTab) {
405
+ return this.coordinatedStatement(() => this.execNow(script));
406
+ }
407
+ return whenAdmitted(this.gate, () => this.stored(this.execNow(script)), undefined, this.longWait);
309
408
  }
310
409
  execNow(script) {
311
410
  this.ensureOpen();
@@ -317,6 +416,16 @@ export class IndexedDbAdapter {
317
416
  this.writeGeneration += 1;
318
417
  }
319
418
  }
419
+ /**
420
+ * With `durability: 'commit'`, resolves with `result` once the write that
421
+ * produced it is stored; otherwise with `result` at once.
422
+ */
423
+ stored(result) {
424
+ if (this.durability !== 'commit') {
425
+ return result;
426
+ }
427
+ return this.saveThrough(this.writeGeneration).then(() => result);
428
+ }
320
429
  /**
321
430
  * Executes a callback within a database transaction.
322
431
  *
@@ -324,33 +433,46 @@ export class IndexedDbAdapter {
324
433
  * connection. The callback's statements go through the handle it receives;
325
434
  * a statement issued through the adapter itself while the callback runs
326
435
  * waits until the transaction ends, so a callback must not await one. The
327
- * auto-save tick does not save while a transaction is open.
436
+ * auto-save tick does not save while a transaction is open. With
437
+ * `durability: 'commit'` the transaction resolves once it is stored.
328
438
  */
329
439
  async transaction(fn) {
330
440
  this.ensureOpen();
441
+ if (this.crossTab) {
442
+ return this.coordinatedTransaction(fn);
443
+ }
331
444
  const queued = setTimeout(() => this.reportLongWait(), LONG_WAIT_MS);
332
- return this.runSerializedTransaction(() => {
445
+ return this.runSerializedTransaction(async () => {
333
446
  clearTimeout(queued);
334
- return this.gate.hold(async (token) => {
447
+ const result = await this.transactionNow(fn);
448
+ if (this.durability === 'commit') {
449
+ // The gate is open again, and the next transaction waits for this save.
450
+ await this.saveThrough(this.writeGeneration);
451
+ }
452
+ return result;
453
+ });
454
+ }
455
+ /** Runs one transaction: BEGIN, the callback through its handle, COMMIT or ROLLBACK. */
456
+ transactionNow(fn) {
457
+ return this.gate.hold(async (token) => {
458
+ this.ensureOpen();
459
+ // Outside the try: when BEGIN fails, the transaction that is open
460
+ // (a caller's own BEGIN) is not this one to roll back.
461
+ this.db.run('BEGIN TRANSACTION');
462
+ const transactionAdapter = createSharedConnectionTransactionAdapter(this.handleStatements(token));
463
+ try {
464
+ const result = await fn(transactionAdapter);
335
465
  this.ensureOpen();
336
- // Outside the try: when BEGIN fails, the transaction that is open
337
- // (a caller's own BEGIN) is not this one to roll back.
338
- this.db.run('BEGIN TRANSACTION');
339
- const transactionAdapter = createSharedConnectionTransactionAdapter(this.handleStatements(token));
340
- try {
341
- const result = await fn(transactionAdapter);
342
- this.ensureOpen();
343
- this.db.run('COMMIT');
344
- // The callback may have written through all() or get() (INSERT ...
345
- // RETURNING), which mark nothing unsaved; the commit does.
346
- this.writeGeneration += 1;
347
- return result;
348
- }
349
- catch (error) {
350
- this.rollbackQuietly();
351
- throw error;
352
- }
353
- });
466
+ this.db.run('COMMIT');
467
+ // The callback may have written through all() or get() (INSERT ...
468
+ // RETURNING); the commit marks the database unsaved in any case.
469
+ this.writeGeneration += 1;
470
+ return result;
471
+ }
472
+ catch (error) {
473
+ this.rollbackQuietly();
474
+ throw error;
475
+ }
354
476
  });
355
477
  }
356
478
  /**
@@ -387,7 +509,8 @@ export class IndexedDbAdapter {
387
509
  }
388
510
  /**
389
511
  * Begins a transaction the caller ends with commit() or rollback(). It is not
390
- * the callback form: other callers' statements are not held out of it.
512
+ * the callback form: other callers' statements are not held out of it. With
513
+ * `crossTab` it is refused: use transaction().
391
514
  */
392
515
  async beginTransaction() {
393
516
  await this.run('BEGIN');
@@ -404,6 +527,44 @@ export class IndexedDbAdapter {
404
527
  async rollback() {
405
528
  await this.run('ROLLBACK');
406
529
  }
530
+ /**
531
+ * Stores every write made so far in IndexedDB. A `transaction()` that is
532
+ * open is waited for and stored with the rest; a transaction a caller began
533
+ * with `BEGIN` cannot be stored without ending it, so flush() rejects while
534
+ * one is open. Resolves at once when nothing is waiting to be saved.
535
+ */
536
+ async flush() {
537
+ this.ensureOpen();
538
+ if (this.crossTab) {
539
+ // Every write is stored before it resolves; this stores one whose save
540
+ // failed, unless another tab has saved since.
541
+ await this.coordinatedStatement(() => undefined);
542
+ return;
543
+ }
544
+ const target = this.writeGeneration;
545
+ while (this.savedGeneration < target && this.db && this.idb) {
546
+ if (this.saving) {
547
+ const covering = this.savingGeneration >= target;
548
+ try {
549
+ await this.saving;
550
+ }
551
+ catch (error) {
552
+ if (covering)
553
+ throw error;
554
+ }
555
+ continue;
556
+ }
557
+ if (this.gate.openToken() !== null) {
558
+ await this.gate.ended();
559
+ continue;
560
+ }
561
+ if (this.inTransaction()) {
562
+ throw new Error(`IndexedDbAdapter: flush() cannot store "${this.dbName}" while a transaction begun with BEGIN is open: ` +
563
+ "sql.js's export would end it. COMMIT or ROLLBACK it first.");
564
+ }
565
+ await this.startSave();
566
+ }
567
+ }
407
568
  /**
408
569
  * Closes the database and saves its committed state to IndexedDB.
409
570
  *
@@ -420,6 +581,9 @@ export class IndexedDbAdapter {
420
581
  return this.closeRun;
421
582
  }
422
583
  async closeNow() {
584
+ if (this.opening) {
585
+ await this.opening.catch(() => undefined);
586
+ }
423
587
  if (this.saveTimer) {
424
588
  clearInterval(this.saveTimer);
425
589
  this.saveTimer = null;
@@ -433,9 +597,20 @@ export class IndexedDbAdapter {
433
597
  if (this.inTransaction()) {
434
598
  this.rollbackQuietly();
435
599
  }
436
- await this.settleSave();
437
- if (this.hasUnsavedWrites()) {
438
- await this.startSave();
600
+ if (this.crossTab) {
601
+ // Under the lock, once the transaction that holds it has failed: a
602
+ // write whose save failed is stored, unless another tab saved since.
603
+ await this.withCoordination(async () => {
604
+ if (this.db && this.hasUnsavedWrites()) {
605
+ await this.saveIfStampIs(this.loadedStamp);
606
+ }
607
+ });
608
+ }
609
+ else {
610
+ await this.settleSave();
611
+ if (this.hasUnsavedWrites()) {
612
+ await this.startSave();
613
+ }
439
614
  }
440
615
  this.db?.close();
441
616
  this.db = null;
@@ -451,7 +626,8 @@ export class IndexedDbAdapter {
451
626
  }
452
627
  /**
453
628
  * Exports the database as a Uint8Array (SQLite file format).
454
- * Can be downloaded or stored externally.
629
+ * Can be downloaded or stored externally. With `crossTab` it is this tab's
630
+ * image, which another tab's save can have overtaken.
455
631
  *
456
632
  * Throws while a transaction is open: sql.js's export closes and reopens the
457
633
  * connection, which would end the transaction.
@@ -462,12 +638,14 @@ export class IndexedDbAdapter {
462
638
  if (this.inTransaction()) {
463
639
  throw new Error("IndexedDbAdapter: cannot export the database while a transaction is open; sql.js's export would end it.");
464
640
  }
465
- const image = this.db.export();
466
- // export() closed and reopened the connection, which reset its PRAGMAs.
467
- this.applyConnectionSettings();
468
- return image;
641
+ return this.exportImage();
469
642
  }
470
- /** Applies the connection settings open() was asked for: sql.js loses them whenever export() reopens the connection. */
643
+ /**
644
+ * Applies the connection settings open() was asked for. sql.js loses them
645
+ * whenever export() reopens the connection; a save, an export, an import
646
+ * and a reload set them again with the other settings a caller changed
647
+ * (exportImage, replaceDatabase).
648
+ */
471
649
  applyConnectionSettings() {
472
650
  if (this.foreignKeys !== undefined) {
473
651
  this.db.run(`PRAGMA foreign_keys = ${this.foreignKeys ? 'ON' : 'OFF'};`);
@@ -476,25 +654,228 @@ export class IndexedDbAdapter {
476
654
  /**
477
655
  * Imports a database from a Uint8Array (SQLite file).
478
656
  * Replaces the current database once any open transaction() has ended, and
479
- * saves it to IndexedDB before it resolves.
657
+ * saves it to IndexedDB before it resolves; with `crossTab`, in place of
658
+ * what every tab stored. Bytes that are not a SQLite database are refused,
659
+ * and the adapter keeps its database.
480
660
  * @param data SQLite database file
481
661
  */
482
662
  async importDatabase(data) {
483
663
  this.ensureOpen();
664
+ if (this.crossTab) {
665
+ await this.withCoordination(async () => {
666
+ this.swapIn(data);
667
+ await this.saveIfStampIs(undefined);
668
+ });
669
+ return;
670
+ }
484
671
  await whenAdmitted(this.gate, () => {
485
- this.ensureOpen();
486
- // Opened before the current database closes: an image sql.js refuses
487
- // leaves the adapter on the database it had.
488
- const imported = new this.SQL.Database(data);
489
- this.db.close();
490
- this.db = imported;
491
- this.applyConnectionSettings();
492
- this.writeGeneration += 1;
672
+ this.swapIn(data);
493
673
  // The save exports in this turn, so no transaction can open between
494
674
  // the swap and the export and hold the save back.
495
675
  return this.startSave();
496
676
  }, undefined, this.longWait);
497
677
  }
678
+ /** Replaces the database with an image, once sql.js has read it as a SQLite database. */
679
+ swapIn(data) {
680
+ this.ensureOpen();
681
+ const imported = new this.SQL.Database(data);
682
+ try {
683
+ // sqlite3_open reads nothing; the first statement reads the header.
684
+ imported.exec('SELECT count(*) FROM sqlite_master');
685
+ }
686
+ catch (error) {
687
+ imported.close();
688
+ const reason = error instanceof Error ? error.message : String(error);
689
+ throw new Error(`IndexedDbAdapter: the image to import is not a SQLite database that sql.js can read (${reason}).`);
690
+ }
691
+ this.replaceDatabase(imported);
692
+ this.writeGeneration += 1;
693
+ }
694
+ /** Puts `next` in place of the database, with the connection settings the current one has. */
695
+ replaceDatabase(next) {
696
+ const settings = this.changedPragmas();
697
+ this.db.close();
698
+ this.db = next;
699
+ this.applyPragmas(settings);
700
+ }
701
+ // ==========================================================================
702
+ // Across tabs
703
+ // ==========================================================================
704
+ /**
705
+ * Runs `work` with this tab holding the database: under the Web Lock, or,
706
+ * without Web Locks, after this adapter's earlier coordinated work.
707
+ */
708
+ withCoordination(work) {
709
+ const waiting = setTimeout(() => this.reportLockWait(), LONG_WAIT_MS);
710
+ const start = () => {
711
+ clearTimeout(waiting);
712
+ return work();
713
+ };
714
+ const locks = this.locks;
715
+ const held = locks ? locks.request(this.lockName, { mode: 'exclusive' }, start) : this.runCoordinatedInTurn(start);
716
+ return held.finally(() => clearTimeout(waiting));
717
+ }
718
+ /**
719
+ * One statement across tabs: on the newest stored image, and stored before
720
+ * it resolves. Without Web Locks another tab can save between the load and
721
+ * the save; the statement then runs again on that tab's image.
722
+ */
723
+ coordinatedStatement(statement) {
724
+ return this.withCoordination(async () => {
725
+ for (let attempt = 1;; attempt += 1) {
726
+ this.ensureOpen();
727
+ await this.syncFromStore();
728
+ const result = statement();
729
+ if (this.inTransaction()) {
730
+ // A BEGIN cannot hold the database across tabs between two calls.
731
+ this.rollbackQuietly();
732
+ throw new Error(`IndexedDbAdapter: "${this.dbName}" coordinates tabs, so a transaction begun with BEGIN through run() or exec() ` +
733
+ 'cannot be held open; it was rolled back. Use transaction().');
734
+ }
735
+ if (!this.hasUnsavedWrites() || (await this.saveIfStampIs(this.loadedStamp))) {
736
+ return result;
737
+ }
738
+ if (attempt >= MAX_CONFLICT_RETRIES) {
739
+ await this.syncFromStore();
740
+ throw new IndexedDbConflictError(`IndexedDbAdapter: other tabs saved "${this.dbName}" before each of ${attempt} attempts to store a statement; nothing of it is stored.`);
741
+ }
742
+ // Another tab saved first: load its image, and run the statement on it.
743
+ await this.syncFromStore();
744
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 4 * attempt));
745
+ }
746
+ });
747
+ }
748
+ /**
749
+ * One transaction across tabs: on the newest stored image, and stored before
750
+ * it resolves. Without Web Locks another tab can save while the callback
751
+ * runs; the transaction is then refused, nothing of it is stored, and the
752
+ * other tab's image is loaded for the caller to run it again.
753
+ */
754
+ coordinatedTransaction(fn) {
755
+ return this.withCoordination(async () => {
756
+ this.ensureOpen();
757
+ await this.syncFromStore();
758
+ const result = await this.runSerializedTransaction(() => this.transactionNow(fn));
759
+ if (this.hasUnsavedWrites() && !(await this.saveIfStampIs(this.loadedStamp))) {
760
+ await this.syncFromStore();
761
+ throw new IndexedDbConflictError(`IndexedDbAdapter: another tab saved "${this.dbName}" while this transaction ran, so nothing of it is stored. ` +
762
+ "That tab's writes are loaded; run the transaction again.");
763
+ }
764
+ return result;
765
+ });
766
+ }
767
+ /** Loads the stored image when another tab (or adapter) has saved since this one loaded or saved. */
768
+ async syncFromStore() {
769
+ const stored = await this.readIfChanged(this.loadedStamp);
770
+ if (!stored || !this.db) {
771
+ return;
772
+ }
773
+ // The image in memory gives way: what it held that was not stored was
774
+ // refused to its callers when its save failed.
775
+ this.replaceDatabase(stored.image ? new this.SQL.Database(stored.image) : new this.SQL.Database());
776
+ this.loadedStamp = stored.stamp;
777
+ this.savedGeneration = this.writeGeneration;
778
+ }
779
+ /** Reads the stored stamp, and the image with it when the stamp is not `known`; null when it is. */
780
+ readIfChanged(known) {
781
+ return this.withTransactionRetry(() => new Promise((resolve, reject) => {
782
+ const tx = this.idb.transaction(this.storeName, 'readonly');
783
+ const store = tx.objectStore(this.storeName);
784
+ let answer = null;
785
+ const stampRequest = store.get(STAMP_KEY);
786
+ stampRequest.onsuccess = () => {
787
+ const stamp = typeof stampRequest.result === 'string' ? stampRequest.result : null;
788
+ if (stamp === known)
789
+ return;
790
+ const imageRequest = store.get(IMAGE_KEY);
791
+ imageRequest.onsuccess = () => {
792
+ answer = { image: imageRequest.result ?? null, stamp };
793
+ };
794
+ };
795
+ tx.oncomplete = () => resolve(answer);
796
+ tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: reading "${this.dbName}" was aborted.`));
797
+ }));
798
+ }
799
+ /**
800
+ * Stores the image when the stored stamp is still `expected`, in one
801
+ * IndexedDB transaction, so no other tab's save lands between the check and
802
+ * the write. `undefined` stores it whatever the stamp is. Resolves with
803
+ * whether it stored the image.
804
+ */
805
+ async saveIfStampIs(expected) {
806
+ const generation = this.writeGeneration;
807
+ const image = this.exportImage();
808
+ const stamp = this.nextStamp();
809
+ const stored = await this.withTransactionRetry(() => new Promise((resolve, reject) => {
810
+ const tx = this.idb.transaction(this.storeName, 'readwrite');
811
+ const store = tx.objectStore(this.storeName);
812
+ let wrote = false;
813
+ const write = () => {
814
+ store.put(image, IMAGE_KEY);
815
+ store.put(stamp, STAMP_KEY);
816
+ wrote = true;
817
+ };
818
+ if (expected === undefined) {
819
+ write();
820
+ }
821
+ else {
822
+ const current = store.get(STAMP_KEY);
823
+ current.onsuccess = () => {
824
+ const found = typeof current.result === 'string' ? current.result : null;
825
+ if (found === expected)
826
+ write();
827
+ };
828
+ }
829
+ tx.oncomplete = () => resolve(wrote);
830
+ tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: saving "${this.dbName}" to IndexedDB was aborted.`));
831
+ }));
832
+ if (stored) {
833
+ this.loadedStamp = stamp;
834
+ this.savedGeneration = Math.max(this.savedGeneration, generation);
835
+ }
836
+ return stored;
837
+ }
838
+ /** A stamp no other save of this or another tab writes. */
839
+ nextStamp() {
840
+ this.stampCount += 1;
841
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}-${this.stampCount}`;
842
+ }
843
+ // ==========================================================================
844
+ // Saving
845
+ // ==========================================================================
846
+ /**
847
+ * Exports the database for a save, keeping the connection settings a
848
+ * caller made: sql.js's export() closes the connection and opens it again.
849
+ */
850
+ exportImage() {
851
+ const settings = this.changedPragmas();
852
+ const image = this.db.export();
853
+ this.applyPragmas(settings);
854
+ return image;
855
+ }
856
+ /** The connection settings that differ from a fresh connection's. */
857
+ changedPragmas() {
858
+ const changed = new Map();
859
+ if (!this.db)
860
+ return changed;
861
+ for (const [name, value] of readPragmas(this.db)) {
862
+ if (this.pragmaDefaults.get(name) !== value)
863
+ changed.set(name, value);
864
+ }
865
+ return changed;
866
+ }
867
+ /** Sets connection settings on the database, after a save or a reload. */
868
+ applyPragmas(settings) {
869
+ for (const [name, value] of settings) {
870
+ const literal = typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`;
871
+ try {
872
+ this.db.exec(`PRAGMA ${name} = ${literal}`);
873
+ }
874
+ catch {
875
+ // A setting this build of SQLite does not take is left as it is.
876
+ }
877
+ }
878
+ }
498
879
  /**
499
880
  * Opens or creates the IndexedDB database.
500
881
  */
@@ -548,26 +929,28 @@ export class IndexedDbAdapter {
548
929
  throw lastError;
549
930
  }
550
931
  /**
551
- * Loads the SQLite database from IndexedDB.
552
- * @returns Binary database or null if not found
932
+ * Loads the SQLite database and its stamp from IndexedDB, in one
933
+ * transaction, so the two belong together.
553
934
  */
554
935
  async loadFromIndexedDb() {
555
936
  if (!this.idb)
556
- return null;
557
- return this.withTransactionRetry(async () => {
558
- return new Promise((resolve, reject) => {
559
- const tx = this.idb.transaction(this.storeName, 'readonly');
560
- const store = tx.objectStore(this.storeName);
561
- const req = store.get('db');
562
- req.onsuccess = () => resolve(req.result || null);
563
- req.onerror = () => reject(req.error);
937
+ return { image: null, stamp: null };
938
+ return this.withTransactionRetry(() => new Promise((resolve, reject) => {
939
+ const tx = this.idb.transaction(this.storeName, 'readonly');
940
+ const store = tx.objectStore(this.storeName);
941
+ const image = store.get(IMAGE_KEY);
942
+ const stamp = store.get(STAMP_KEY);
943
+ tx.oncomplete = () => resolve({
944
+ image: image.result ?? null,
945
+ stamp: typeof stamp.result === 'string' ? stamp.result : null,
564
946
  });
565
- });
947
+ tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: reading "${this.dbName}" was aborted.`));
948
+ }));
566
949
  }
567
950
  /**
568
- * Writes the current image to IndexedDB. Never called while a transaction is
569
- * open (see saveIfIdle): sql.js's export() closes and reopens the connection,
570
- * which ends an open transaction.
951
+ * Writes the current image to IndexedDB, with a new stamp. Never called
952
+ * while a transaction is open (see saveIfIdle): sql.js's export() closes and
953
+ * reopens the connection, which ends an open transaction.
571
954
  *
572
955
  * Settles when the IndexedDB transaction completes, not when its put request
573
956
  * succeeds: the transaction can still abort after that (a quota error at
@@ -578,18 +961,8 @@ export class IndexedDbAdapter {
578
961
  async saveToIndexedDb() {
579
962
  if (!this.idb || !this.db)
580
963
  return;
581
- const generation = this.writeGeneration;
582
- const data = this.db.export();
583
- // export() closed and reopened the connection, which reset its PRAGMAs.
584
- this.applyConnectionSettings();
585
- await this.withTransactionRetry(() => new Promise((resolve, reject) => {
586
- const tx = this.idb.transaction(this.storeName, 'readwrite');
587
- tx.objectStore(this.storeName).put(data, 'db');
588
- tx.oncomplete = () => resolve();
589
- // A failed request aborts the transaction, so abort covers both.
590
- tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: saving "${this.dbName}" to IndexedDB was aborted.`));
591
- }));
592
- this.savedGeneration = Math.max(this.savedGeneration, generation);
964
+ // exportImage() sets the connection settings again after the export.
965
+ await this.saveIfStampIs(undefined);
593
966
  }
594
967
  /** Whether writes were made since the image the last completed save exported. */
595
968
  hasUnsavedWrites() {
@@ -634,11 +1007,42 @@ export class IndexedDbAdapter {
634
1007
  }
635
1008
  return this.startSave();
636
1009
  }
1010
+ /**
1011
+ * Resolves once the writes up to `generation` are stored. A save in
1012
+ * progress that holds them is waited for; an open transaction() is waited
1013
+ * for, since its own save holds them; while a transaction a caller began
1014
+ * with BEGIN is open, nothing can be stored, and its COMMIT stores them.
1015
+ * Rejects when the save that held them failed.
1016
+ */
1017
+ async saveThrough(generation) {
1018
+ while (this.savedGeneration < generation && this.db && this.idb) {
1019
+ if (this.saving) {
1020
+ const covering = this.savingGeneration >= generation;
1021
+ try {
1022
+ await this.saving;
1023
+ }
1024
+ catch (error) {
1025
+ if (covering)
1026
+ throw error;
1027
+ }
1028
+ continue;
1029
+ }
1030
+ if (this.gate.openToken() !== null) {
1031
+ await this.gate.ended();
1032
+ continue;
1033
+ }
1034
+ if (this.inTransaction()) {
1035
+ return;
1036
+ }
1037
+ await this.startSave();
1038
+ }
1039
+ }
637
1040
  /**
638
1041
  * Starts a save at once and keeps it as the save in progress. The caller has
639
1042
  * made sure no transaction is open: the export would end it.
640
1043
  */
641
1044
  startSave() {
1045
+ this.savingGeneration = this.writeGeneration;
642
1046
  const saving = this.saveToIndexedDb().finally(() => {
643
1047
  if (this.saving === saving)
644
1048
  this.saving = null;
@@ -660,6 +1064,14 @@ export class IndexedDbAdapter {
660
1064
  "Inside a transaction() callback, run statements through the callback's handle: one run through the adapter " +
661
1065
  'itself waits for the transaction, and the transaction waits for it.');
662
1066
  }
1067
+ reportLockWait() {
1068
+ if (this.lockWaitReported)
1069
+ return;
1070
+ this.lockWaitReported = true;
1071
+ console.warn(`[IndexedDbAdapter] A statement on "${this.dbName}" has waited ${LONG_WAIT_MS / 1000} s for its turn across tabs: ` +
1072
+ "another tab's transaction holds the lock, or a transaction() of this tab whose callback runs a statement through " +
1073
+ "the adapter itself instead of the callback's handle waits on itself.");
1074
+ }
663
1075
  /**
664
1076
  * Wraps a transaction operation with retry logic.
665
1077
  * Handles "NotFoundError" by reopening IndexedDB connection.
@@ -735,12 +1147,20 @@ export class IndexedDbAdapter {
735
1147
  }
736
1148
  return null;
737
1149
  }
738
- /**
739
- * Prepares a SQL statement for execution.
740
- */
741
- prepareInternal(statement) {
742
- this.ensureOpen();
743
- return this.db.prepare(statement);
744
- }
745
1150
  }
1151
+ /** The connection settings of `db`, by name. A setting this build does not have is left out. */
1152
+ const readPragmas = (db) => {
1153
+ const values = new Map();
1154
+ for (const name of CONNECTION_PRAGMAS) {
1155
+ try {
1156
+ const value = db.exec(`PRAGMA ${name}`)[0]?.values?.[0]?.[0];
1157
+ if (value !== undefined)
1158
+ values.set(name, value);
1159
+ }
1160
+ catch {
1161
+ // Not in this build of SQLite.
1162
+ }
1163
+ }
1164
+ return values;
1165
+ };
746
1166
  //# sourceMappingURL=indexedDbAdapter.js.map