@framers/sql-storage-adapter 0.6.13 → 0.6.15

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.
@@ -134,7 +134,32 @@ export declare class IndexedDbAdapter implements StorageAdapter {
134
134
  private db;
135
135
  private idb;
136
136
  private saveTimer;
137
- private dirty;
137
+ /** Bumped by every statement that can change the database. */
138
+ private writeGeneration;
139
+ /** The generation the last completed save exported; the writes after it are unsaved. */
140
+ private savedGeneration;
141
+ /** The save in progress, if any. */
142
+ private saving;
143
+ /** Whether a failed auto-save has been reported since the last one that succeeded. */
144
+ private saveFailureReported;
145
+ /** Set while close() runs: no statement or transaction starts. */
146
+ private closing;
147
+ /** The close() in progress, which a second call waits for. */
148
+ private closeRun;
149
+ private longWaitReported;
150
+ /**
151
+ * The connection is shared, so transaction() runs one callback at a time.
152
+ */
153
+ private readonly runSerializedTransaction;
154
+ /**
155
+ * Holds statements from outside an open transaction until it ends, so they
156
+ * neither join it (and vanish in its rollback) nor read what it has not
157
+ * committed. Browsers have no async context to tell the callback's own
158
+ * statements apart, so the callback's handle is the only way into an open
159
+ * transaction: a statement through the adapter itself waits.
160
+ */
161
+ private readonly gate;
162
+ private readonly longWait;
138
163
  private readonly dbName;
139
164
  private readonly storeName;
140
165
  private readonly autoSave;
@@ -157,6 +182,7 @@ export declare class IndexedDbAdapter implements StorageAdapter {
157
182
  * @returns Result with changes count and last insert row ID
158
183
  */
159
184
  run(statement: string, parameters?: StorageParameters): Promise<StorageRunResult>;
185
+ private runNow;
160
186
  /**
161
187
  * Executes a SQL query and returns the first row.
162
188
  * @param statement SELECT statement
@@ -171,17 +197,32 @@ export declare class IndexedDbAdapter implements StorageAdapter {
171
197
  * @returns Array of result rows as objects
172
198
  */
173
199
  all<T>(statement: string, parameters?: StorageParameters): Promise<T[]>;
200
+ private allNow;
174
201
  /**
175
202
  * Executes a script containing multiple SQL statements.
176
203
  */
177
204
  exec(script: string): Promise<void>;
205
+ private execNow;
178
206
  /**
179
207
  * Executes a callback within a database transaction.
208
+ *
209
+ * Callbacks run one at a time, since every transaction shares the one
210
+ * connection. The callback's statements go through the handle it receives;
211
+ * a statement issued through the adapter itself while the callback runs
212
+ * waits until the transaction ends, so a callback must not await one. The
213
+ * auto-save tick does not save while a transaction is open.
180
214
  */
181
215
  transaction<T>(fn: (trx: StorageAdapter) => Promise<T>): Promise<T>;
182
216
  /**
183
- * Begins a transaction. sql.js executes all statements in implicit transactions,
184
- * so this is a no-op for compatibility.
217
+ * The statements of one transaction's handle: they carry the transaction's
218
+ * token, so the gate admits them while the transaction is open.
219
+ */
220
+ private handleStatements;
221
+ /** Rolls back the open transaction; one that already ended (a failed statement, close()) is left alone. */
222
+ private rollbackQuietly;
223
+ /**
224
+ * Begins a transaction the caller ends with commit() or rollback(). It is not
225
+ * the callback form: other callers' statements are not held out of it.
185
226
  */
186
227
  beginTransaction(): Promise<void>;
187
228
  /**
@@ -193,18 +234,27 @@ export declare class IndexedDbAdapter implements StorageAdapter {
193
234
  */
194
235
  rollback(): Promise<void>;
195
236
  /**
196
- * Closes the database and persists final state to IndexedDB.
237
+ * Closes the database and saves its committed state to IndexedDB.
238
+ *
239
+ * A transaction still open is rolled back, as closing a SQLite connection
240
+ * does, before the save; the save then holds committed writes only. A call
241
+ * made while a close is running waits for that close.
197
242
  */
198
243
  close(): Promise<void>;
244
+ private closeNow;
199
245
  /**
200
246
  * Exports the database as a Uint8Array (SQLite file format).
201
247
  * Can be downloaded or stored externally.
248
+ *
249
+ * Throws while a transaction is open: sql.js's export closes and reopens the
250
+ * connection, which would end the transaction.
202
251
  * @returns SQLite database file as binary
203
252
  */
204
253
  exportDatabase(): Uint8Array;
205
254
  /**
206
255
  * Imports a database from a Uint8Array (SQLite file).
207
- * Replaces the current database.
256
+ * Replaces the current database once any open transaction() has ended, and
257
+ * saves it to IndexedDB before it resolves.
208
258
  * @param data SQLite database file
209
259
  */
210
260
  importDatabase(data: Uint8Array): Promise<void>;
@@ -231,22 +281,51 @@ export declare class IndexedDbAdapter implements StorageAdapter {
231
281
  */
232
282
  private loadFromIndexedDb;
233
283
  /**
234
- * Saves the SQLite database to IndexedDB.
284
+ * Writes the current image to IndexedDB. Never called while a transaction is
285
+ * open (see saveIfIdle): sql.js's export() closes and reopens the connection,
286
+ * which ends an open transaction.
287
+ *
288
+ * Settles when the IndexedDB transaction completes, not when its put request
289
+ * succeeds: the transaction can still abort after that (a quota error at
290
+ * commit), and the image is then not stored. On abort the writes stay
291
+ * unsaved, so the next save writes them. A write made after the export stays
292
+ * unsaved too.
235
293
  */
236
294
  private saveToIndexedDb;
295
+ /** Whether writes were made since the image the last completed save exported. */
296
+ private hasUnsavedWrites;
297
+ /**
298
+ * Whether a transaction is open on the connection: one transaction() is
299
+ * running, or one the caller began with BEGIN or SAVEPOINT. sql.js does not
300
+ * export sqlite3_get_autocommit, so the adapter asks SQLite: BEGIN fails
301
+ * inside a transaction, and outside one it is rolled back at once. A probe
302
+ * that fails for any reason counts as open, so the adapter does not export.
303
+ */
304
+ private inTransaction;
305
+ /**
306
+ * Starts a save of the unsaved writes, unless a save is running or a
307
+ * transaction is open; the auto-save tick tries again later.
308
+ */
309
+ private saveIfIdle;
310
+ /**
311
+ * Starts a save at once and keeps it as the save in progress. The caller has
312
+ * made sure no transaction is open: the export would end it.
313
+ */
314
+ private startSave;
315
+ /** Waits for a save in progress to settle, whether it succeeded or not. */
316
+ private settleSave;
317
+ private reportLongWait;
237
318
  /**
238
319
  * Wraps a transaction operation with retry logic.
239
320
  * Handles "NotFoundError" by reopening IndexedDB connection.
240
321
  */
241
322
  private withTransactionRetry;
242
- /**
243
- * Persists to IndexedDB if auto-save is enabled.
244
- */
245
- private persistIfNeeded;
246
323
  /**
247
324
  * Starts auto-save interval.
248
325
  */
249
326
  private startAutoSave;
327
+ /** One auto-save tick: a failed save is reported once and tried again on the next tick. */
328
+ private autoSaveTick;
250
329
  /**
251
330
  * Ensures the database is open.
252
331
  */
@@ -1 +1 @@
1
- {"version":3,"file":"indexedDbAdapter.d.ts","sourceRoot":"","sources":["../../src/adapters/indexedDbAdapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"indexedDbAdapter.d.ts","sourceRoot":"","sources":["../../src/adapters/indexedDbAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAIH,OAAO,KAAK,EACV,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,mBAAmB,CAAC;AA0C3B;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,kDAAkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uEAAuE;IACvE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uDAAuD;IAEvD,WAAW,CAAC,EAAE,GAAG,CAAC;CACnB;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AACH,qBAAa,gBAAiB,YAAW,cAAc;IACrD,SAAgB,IAAI,eAAe;IACnC,SAAgB,YAAY,EAAE,WAAW,CAAC,iBAAiB,CAAC,CAAgE;IAE5H,OAAO,CAAC,GAAG,CAA4B;IACvC,OAAO,CAAC,EAAE,CAA8B;IACxC,OAAO,CAAC,GAAG,CAA4B;IACvC,OAAO,CAAC,SAAS,CAA+C;IAChE,8DAA8D;IAC9D,OAAO,CAAC,eAAe,CAAK;IAC5B,wFAAwF;IACxF,OAAO,CAAC,eAAe,CAAK;IAC5B,oCAAoC;IACpC,OAAO,CAAC,MAAM,CAA8B;IAC5C,sFAAsF;IACtF,OAAO,CAAC,mBAAmB,CAAS;IACpC,kEAAkE;IAClE,OAAO,CAAC,OAAO,CAAS;IACxB,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,gBAAgB,CAAS;IACjC;;OAEG;IACH,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAuC;IAChF;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0D;IAC/E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAGvB;IAEF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IAExC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAM;gBAEtB,OAAO,GAAE,uBAA4B;IAQjD;;;;;;;OAOG;IACU,IAAI,CAAC,QAAQ,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAyB/D;;;;;OAKG;IACU,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAI9F,OAAO,CAAC,MAAM;IA0Bd;;;;;OAKG;IACU,GAAG,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAKzF;;;;;OAKG;IACU,GAAG,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAIpF,OAAO,CAAC,MAAM;IA8Bd;;OAEG;IACU,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhD,OAAO,CAAC,OAAO;IAUf;;;;;;;;OAQG;IACU,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IA2BhF;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAmBxB,2GAA2G;IAC3G,OAAO,CAAC,eAAe;IASvB;;;OAGG;IACU,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9C;;OAEG;IACU,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAIpC;;OAEG;IACU,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC;;;;;;OAMG;IACI,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YASf,QAAQ;IAgCtB;;;;;;;OAOG;IACI,cAAc,IAAI,UAAU;IAUnC;;;;;OAKG;IACU,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAqB5D;;OAEG;IACH,OAAO,CAAC,aAAa;IAgBrB;;;;;;;;;;;OAWG;YACW,sBAAsB;IAiCpC;;;OAGG;YACW,iBAAiB;IAe/B;;;;;;;;;;OAUG;YACW,eAAe;IAoB7B,iFAAiF;IACjF,OAAO,CAAC,gBAAgB;IAIxB;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAiBrB;;;OAGG;IACH,OAAO,CAAC,UAAU;IAQlB;;;OAGG;IACH,OAAO,CAAC,SAAS;IAQjB,2EAA2E;YAC7D,UAAU;IAMxB,OAAO,CAAC,cAAc;IAUtB;;;OAGG;YACW,oBAAoB;IAyClC;;OAEG;IACH,OAAO,CAAC,aAAa;IAMrB,2FAA2F;YAC7E,YAAY;IAY1B;;OAEG;IACH,OAAO,CAAC,UAAU;IAMlB;;OAEG;IACH,OAAO,CAAC,cAAc;IAUtB;;OAEG;IACH,OAAO,CAAC,eAAe;CAIxB"}
@@ -44,6 +44,10 @@
44
44
  */
45
45
  import initSqlJs from 'sql.js';
46
46
  import { normaliseParameters } from '../shared/parameterUtils.js';
47
+ import { createSerializedTransactionRunner, createSharedConnectionTransactionAdapter, } from '../shared/serializedTransaction.js';
48
+ import { createConnectionGate, whenAdmitted } from '../shared/connectionGate.js';
49
+ /** How long a statement waits for an open transaction before the adapter warns. */
50
+ const LONG_WAIT_MS = 10000;
47
51
  // ============================================================================
48
52
  // GLOBAL SQL.JS SINGLETON
49
53
  // ============================================================================
@@ -147,7 +151,35 @@ export class IndexedDbAdapter {
147
151
  this.db = null;
148
152
  this.idb = null;
149
153
  this.saveTimer = null;
150
- this.dirty = false;
154
+ /** Bumped by every statement that can change the database. */
155
+ this.writeGeneration = 0;
156
+ /** The generation the last completed save exported; the writes after it are unsaved. */
157
+ this.savedGeneration = 0;
158
+ /** The save in progress, if any. */
159
+ this.saving = null;
160
+ /** Whether a failed auto-save has been reported since the last one that succeeded. */
161
+ this.saveFailureReported = false;
162
+ /** Set while close() runs: no statement or transaction starts. */
163
+ this.closing = false;
164
+ /** The close() in progress, which a second call waits for. */
165
+ this.closeRun = null;
166
+ this.longWaitReported = false;
167
+ /**
168
+ * The connection is shared, so transaction() runs one callback at a time.
169
+ */
170
+ this.runSerializedTransaction = createSerializedTransactionRunner();
171
+ /**
172
+ * Holds statements from outside an open transaction until it ends, so they
173
+ * neither join it (and vanish in its rollback) nor read what it has not
174
+ * committed. Browsers have no async context to tell the callback's own
175
+ * statements apart, so the callback's handle is the only way into an open
176
+ * transaction: a statement through the adapter itself waits.
177
+ */
178
+ this.gate = createConnectionGate(null, { withoutContext: 'wait' });
179
+ this.longWait = {
180
+ afterMs: LONG_WAIT_MS,
181
+ onLongWait: () => this.reportLongWait(),
182
+ };
151
183
  this.dbName = options.dbName || 'app-db';
152
184
  this.storeName = options.storeName || 'sqliteDb';
153
185
  this.autoSave = options.autoSave ?? true;
@@ -190,6 +222,9 @@ export class IndexedDbAdapter {
190
222
  * @returns Result with changes count and last insert row ID
191
223
  */
192
224
  async run(statement, parameters) {
225
+ return whenAdmitted(this.gate, () => this.runNow(statement, parameters), undefined, this.longWait);
226
+ }
227
+ runNow(statement, parameters) {
193
228
  this.ensureOpen();
194
229
  const stmt = this.db.prepare(statement);
195
230
  try {
@@ -204,8 +239,7 @@ export class IndexedDbAdapter {
204
239
  const rowIdResult = this.db.exec('SELECT last_insert_rowid() AS id');
205
240
  const rawRowId = rowIdResult[0]?.values?.[0]?.[0];
206
241
  const lastInsertRowid = this.normaliseRowId(rawRowId);
207
- this.dirty = true;
208
- await this.persistIfNeeded();
242
+ this.writeGeneration += 1;
209
243
  return {
210
244
  changes: this.db.getRowsModified(),
211
245
  lastInsertRowid,
@@ -232,6 +266,9 @@ export class IndexedDbAdapter {
232
266
  * @returns Array of result rows as objects
233
267
  */
234
268
  async all(statement, parameters) {
269
+ return whenAdmitted(this.gate, () => this.allNow(statement, parameters), undefined, this.longWait);
270
+ }
271
+ allNow(statement, parameters) {
235
272
  this.ensureOpen();
236
273
  const stmt = this.db.prepare(statement);
237
274
  try {
@@ -263,35 +300,91 @@ export class IndexedDbAdapter {
263
300
  * Executes a script containing multiple SQL statements.
264
301
  */
265
302
  async exec(script) {
303
+ return whenAdmitted(this.gate, () => this.execNow(script), undefined, this.longWait);
304
+ }
305
+ execNow(script) {
266
306
  this.ensureOpen();
267
- this.db.exec(script);
268
- this.dirty = true;
269
- await this.persistIfNeeded();
307
+ try {
308
+ this.db.exec(script);
309
+ }
310
+ finally {
311
+ // A script that fails part way keeps the statements before the failure.
312
+ this.writeGeneration += 1;
313
+ }
270
314
  }
271
315
  /**
272
316
  * Executes a callback within a database transaction.
317
+ *
318
+ * Callbacks run one at a time, since every transaction shares the one
319
+ * connection. The callback's statements go through the handle it receives;
320
+ * a statement issued through the adapter itself while the callback runs
321
+ * waits until the transaction ends, so a callback must not await one. The
322
+ * auto-save tick does not save while a transaction is open.
273
323
  */
274
324
  async transaction(fn) {
275
325
  this.ensureOpen();
276
- await this.run('BEGIN TRANSACTION');
326
+ const queued = setTimeout(() => this.reportLongWait(), LONG_WAIT_MS);
327
+ return this.runSerializedTransaction(() => {
328
+ clearTimeout(queued);
329
+ return this.gate.hold(async (token) => {
330
+ this.ensureOpen();
331
+ // Outside the try: when BEGIN fails, the transaction that is open
332
+ // (a caller's own BEGIN) is not this one to roll back.
333
+ this.db.run('BEGIN TRANSACTION');
334
+ const transactionAdapter = createSharedConnectionTransactionAdapter(this.handleStatements(token));
335
+ try {
336
+ const result = await fn(transactionAdapter);
337
+ this.ensureOpen();
338
+ this.db.run('COMMIT');
339
+ // The callback may have written through all() or get() (INSERT ...
340
+ // RETURNING), which mark nothing unsaved; the commit does.
341
+ this.writeGeneration += 1;
342
+ return result;
343
+ }
344
+ catch (error) {
345
+ this.rollbackQuietly();
346
+ throw error;
347
+ }
348
+ });
349
+ });
350
+ }
351
+ /**
352
+ * The statements of one transaction's handle: they carry the transaction's
353
+ * token, so the gate admits them while the transaction is open.
354
+ */
355
+ handleStatements(token) {
356
+ return {
357
+ kind: this.kind,
358
+ capabilities: this.capabilities,
359
+ open: async () => { },
360
+ close: async () => { },
361
+ run: (statement, parameters) => whenAdmitted(this.gate, () => this.runNow(statement, parameters), token),
362
+ get: async (statement, parameters) => {
363
+ const rows = await whenAdmitted(this.gate, () => this.allNow(statement, parameters), token);
364
+ return rows.length > 0 ? rows[0] : null;
365
+ },
366
+ all: (statement, parameters) => whenAdmitted(this.gate, () => this.allNow(statement, parameters), token),
367
+ exec: (script) => whenAdmitted(this.gate, () => this.execNow(script), token),
368
+ // The handle answers a nested transaction() itself and never calls this.
369
+ transaction: (fn) => fn(this),
370
+ };
371
+ }
372
+ /** Rolls back the open transaction; one that already ended (a failed statement, close()) is left alone. */
373
+ rollbackQuietly() {
374
+ if (!this.db)
375
+ return;
277
376
  try {
278
- const result = await fn(this);
279
- await this.run('COMMIT');
280
- this.dirty = true;
281
- await this.persistIfNeeded();
282
- return result;
377
+ this.db.run('ROLLBACK');
283
378
  }
284
- catch (error) {
285
- await this.run('ROLLBACK');
286
- throw error;
379
+ catch {
380
+ // No transaction is open.
287
381
  }
288
382
  }
289
383
  /**
290
- * Begins a transaction. sql.js executes all statements in implicit transactions,
291
- * so this is a no-op for compatibility.
384
+ * Begins a transaction the caller ends with commit() or rollback(). It is not
385
+ * the callback form: other callers' statements are not held out of it.
292
386
  */
293
387
  async beginTransaction() {
294
- // sql.js uses auto-commit; explicit BEGIN is optional
295
388
  await this.run('BEGIN');
296
389
  }
297
390
  /**
@@ -299,8 +392,6 @@ export class IndexedDbAdapter {
299
392
  */
300
393
  async commit() {
301
394
  await this.run('COMMIT');
302
- this.dirty = true;
303
- await this.persistIfNeeded();
304
395
  }
305
396
  /**
306
397
  * Rolls back the current transaction.
@@ -309,19 +400,44 @@ export class IndexedDbAdapter {
309
400
  await this.run('ROLLBACK');
310
401
  }
311
402
  /**
312
- * Closes the database and persists final state to IndexedDB.
403
+ * Closes the database and saves its committed state to IndexedDB.
404
+ *
405
+ * A transaction still open is rolled back, as closing a SQLite connection
406
+ * does, before the save; the save then holds committed writes only. A call
407
+ * made while a close is running waits for that close.
313
408
  */
314
- async close() {
409
+ close() {
410
+ if (!this.closeRun) {
411
+ this.closeRun = this.closeNow().finally(() => {
412
+ this.closeRun = null;
413
+ });
414
+ }
415
+ return this.closeRun;
416
+ }
417
+ async closeNow() {
315
418
  if (this.saveTimer) {
316
419
  clearInterval(this.saveTimer);
317
420
  this.saveTimer = null;
318
421
  }
319
- if (this.dirty) {
320
- await this.saveToIndexedDb();
321
- }
322
422
  if (this.db) {
323
- this.db.close();
324
- this.db = null;
423
+ // From here no statement or transaction starts: a transaction() still
424
+ // running fails at its next statement, and one queued behind it fails
425
+ // when it starts.
426
+ this.closing = true;
427
+ try {
428
+ if (this.inTransaction()) {
429
+ this.rollbackQuietly();
430
+ }
431
+ await this.settleSave();
432
+ if (this.hasUnsavedWrites()) {
433
+ await this.startSave();
434
+ }
435
+ this.db?.close();
436
+ this.db = null;
437
+ }
438
+ finally {
439
+ this.closing = false;
440
+ }
325
441
  }
326
442
  if (this.idb) {
327
443
  this.idb.close();
@@ -331,23 +447,38 @@ export class IndexedDbAdapter {
331
447
  /**
332
448
  * Exports the database as a Uint8Array (SQLite file format).
333
449
  * Can be downloaded or stored externally.
450
+ *
451
+ * Throws while a transaction is open: sql.js's export closes and reopens the
452
+ * connection, which would end the transaction.
334
453
  * @returns SQLite database file as binary
335
454
  */
336
455
  exportDatabase() {
337
456
  this.ensureOpen();
457
+ if (this.inTransaction()) {
458
+ throw new Error("IndexedDbAdapter: cannot export the database while a transaction is open; sql.js's export would end it.");
459
+ }
338
460
  return this.db.export();
339
461
  }
340
462
  /**
341
463
  * Imports a database from a Uint8Array (SQLite file).
342
- * Replaces the current database.
464
+ * Replaces the current database once any open transaction() has ended, and
465
+ * saves it to IndexedDB before it resolves.
343
466
  * @param data SQLite database file
344
467
  */
345
468
  async importDatabase(data) {
346
469
  this.ensureOpen();
347
- this.db.close();
348
- this.db = new this.SQL.Database(data);
349
- this.dirty = true;
350
- await this.saveToIndexedDb();
470
+ await whenAdmitted(this.gate, () => {
471
+ this.ensureOpen();
472
+ // Opened before the current database closes: an image sql.js refuses
473
+ // leaves the adapter on the database it had.
474
+ const imported = new this.SQL.Database(data);
475
+ this.db.close();
476
+ this.db = imported;
477
+ this.writeGeneration += 1;
478
+ // The save exports in this turn, so no transaction can open between
479
+ // the swap and the export and hold the save back.
480
+ return this.startSave();
481
+ }, undefined, this.longWait);
351
482
  }
352
483
  /**
353
484
  * Opens or creates the IndexedDB database.
@@ -419,24 +550,98 @@ export class IndexedDbAdapter {
419
550
  });
420
551
  }
421
552
  /**
422
- * Saves the SQLite database to IndexedDB.
553
+ * Writes the current image to IndexedDB. Never called while a transaction is
554
+ * open (see saveIfIdle): sql.js's export() closes and reopens the connection,
555
+ * which ends an open transaction.
556
+ *
557
+ * Settles when the IndexedDB transaction completes, not when its put request
558
+ * succeeds: the transaction can still abort after that (a quota error at
559
+ * commit), and the image is then not stored. On abort the writes stay
560
+ * unsaved, so the next save writes them. A write made after the export stays
561
+ * unsaved too.
423
562
  */
424
563
  async saveToIndexedDb() {
425
564
  if (!this.idb || !this.db)
426
565
  return;
566
+ const generation = this.writeGeneration;
427
567
  const data = this.db.export();
428
- return this.withTransactionRetry(async () => {
429
- return new Promise((resolve, reject) => {
430
- const tx = this.idb.transaction(this.storeName, 'readwrite');
431
- const store = tx.objectStore(this.storeName);
432
- const req = store.put(data, 'db');
433
- req.onsuccess = () => {
434
- this.dirty = false;
435
- resolve();
436
- };
437
- req.onerror = () => reject(req.error);
438
- });
568
+ await this.withTransactionRetry(() => new Promise((resolve, reject) => {
569
+ const tx = this.idb.transaction(this.storeName, 'readwrite');
570
+ tx.objectStore(this.storeName).put(data, 'db');
571
+ tx.oncomplete = () => resolve();
572
+ // A failed request aborts the transaction, so abort covers both.
573
+ tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: saving "${this.dbName}" to IndexedDB was aborted.`));
574
+ }));
575
+ this.savedGeneration = Math.max(this.savedGeneration, generation);
576
+ }
577
+ /** Whether writes were made since the image the last completed save exported. */
578
+ hasUnsavedWrites() {
579
+ return this.writeGeneration !== this.savedGeneration;
580
+ }
581
+ /**
582
+ * Whether a transaction is open on the connection: one transaction() is
583
+ * running, or one the caller began with BEGIN or SAVEPOINT. sql.js does not
584
+ * export sqlite3_get_autocommit, so the adapter asks SQLite: BEGIN fails
585
+ * inside a transaction, and outside one it is rolled back at once. A probe
586
+ * that fails for any reason counts as open, so the adapter does not export.
587
+ */
588
+ inTransaction() {
589
+ if (this.gate.openToken() !== null)
590
+ return true;
591
+ if (!this.db)
592
+ return false;
593
+ try {
594
+ this.db.run('BEGIN');
595
+ }
596
+ catch {
597
+ return true;
598
+ }
599
+ try {
600
+ this.db.run('ROLLBACK');
601
+ }
602
+ catch {
603
+ // The probe's own transaction did not roll back; it is open.
604
+ return true;
605
+ }
606
+ return false;
607
+ }
608
+ /**
609
+ * Starts a save of the unsaved writes, unless a save is running or a
610
+ * transaction is open; the auto-save tick tries again later.
611
+ */
612
+ saveIfIdle() {
613
+ if (this.saving)
614
+ return this.saving;
615
+ if (!this.db || !this.idb || !this.hasUnsavedWrites() || this.inTransaction()) {
616
+ return Promise.resolve();
617
+ }
618
+ return this.startSave();
619
+ }
620
+ /**
621
+ * Starts a save at once and keeps it as the save in progress. The caller has
622
+ * made sure no transaction is open: the export would end it.
623
+ */
624
+ startSave() {
625
+ const saving = this.saveToIndexedDb().finally(() => {
626
+ if (this.saving === saving)
627
+ this.saving = null;
439
628
  });
629
+ this.saving = saving;
630
+ return saving;
631
+ }
632
+ /** Waits for a save in progress to settle, whether it succeeded or not. */
633
+ async settleSave() {
634
+ if (this.saving) {
635
+ await this.saving.catch(() => undefined);
636
+ }
637
+ }
638
+ reportLongWait() {
639
+ if (this.longWaitReported)
640
+ return;
641
+ this.longWaitReported = true;
642
+ console.warn(`[IndexedDbAdapter] A statement on "${this.dbName}" has waited ${LONG_WAIT_MS / 1000} s for an open transaction to end. ` +
643
+ "Inside a transaction() callback, run statements through the callback's handle: one run through the adapter " +
644
+ 'itself waits for the transaction, and the transaction waits for it.');
440
645
  }
441
646
  /**
442
647
  * Wraps a transaction operation with retry logic.
@@ -472,30 +677,32 @@ export class IndexedDbAdapter {
472
677
  }
473
678
  throw lastError;
474
679
  }
475
- /**
476
- * Persists to IndexedDB if auto-save is enabled.
477
- */
478
- async persistIfNeeded() {
479
- if (this.autoSave && !this.saveTimer) {
480
- // Immediate save on first write, then batched
481
- await this.saveToIndexedDb();
482
- }
483
- }
484
680
  /**
485
681
  * Starts auto-save interval.
486
682
  */
487
683
  startAutoSave() {
488
- this.saveTimer = setInterval(async () => {
489
- if (this.dirty) {
490
- await this.saveToIndexedDb();
491
- }
684
+ this.saveTimer = setInterval(() => {
685
+ void this.autoSaveTick();
492
686
  }, this.saveIntervalMs);
493
687
  }
688
+ /** One auto-save tick: a failed save is reported once and tried again on the next tick. */
689
+ async autoSaveTick() {
690
+ try {
691
+ await this.saveIfIdle();
692
+ this.saveFailureReported = false;
693
+ }
694
+ catch (error) {
695
+ if (!this.saveFailureReported) {
696
+ this.saveFailureReported = true;
697
+ console.warn(`[IndexedDbAdapter] Saving "${this.dbName}" to IndexedDB failed; the next tick tries again.`, error);
698
+ }
699
+ }
700
+ }
494
701
  /**
495
702
  * Ensures the database is open.
496
703
  */
497
704
  ensureOpen() {
498
- if (!this.db) {
705
+ if (!this.db || this.closing) {
499
706
  throw new Error('IndexedDbAdapter: Database not open. Call open() first.');
500
707
  }
501
708
  }