@procedures/sqlite 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/storage.ts CHANGED
@@ -3,34 +3,46 @@
3
3
  *
4
4
  * `SqliteStorage` keeps the items of one collection in one table of a SQLite database: a file,
5
5
  * or `:memory:`. It implements `CollectionStorage` of `@procedures/client`, so a
6
- * `ProcedureServer`, an `ApiStorage` server and a `HybridStorage` can use it. The engine is
7
- * sql.js (SQLite in WebAssembly), through `@mark1russell7/docker-sqlite`: no native build.
6
+ * `ProcedureServer`, an `ApiStorage` server and a `HybridStorage` can use it.
8
7
  *
8
+ * - The storage has two engines (the option `engine`). The engine `node` is `node:sqlite` of
9
+ * Node.js: it writes only the changed pages of the file. The engine `sqljs` is sql.js (SQLite in
10
+ * WebAssembly) through `@mark1russell7/docker-sqlite`: it keeps the database in memory and
11
+ * writes the whole file after a write. The default `auto` uses `node` when Node.js has it.
12
+ * - The two engines use the same file format: a file of one engine opens with the other.
9
13
  * - The table of a collection has the columns `id` (the key) and `value` (the JSON text of the
10
14
  * item). A value goes through `JSON.stringify` and `JSON.parse`.
11
15
  * - A change log gives `getChangesSince()`. The table `_storage_log` has the last change of each
12
16
  * id, and `_storage_versions` has the version of each collection: `<epoch>:<seq>`, as
13
17
  * `InMemoryStorage` has. The epoch stays in the file, so a version stays valid after a restart.
14
18
  * - Each write runs in a transaction. A batch that fails changes nothing.
15
- * - The storage keeps the database in memory and writes the file after each write. A write ends
16
- * after its file write. One file write can hold the changes of several writes.
17
- * - The storages of one file in one process share one database, so the storages of two tables in
18
- * one file do not write over the changes of each other.
19
+ * - The storages of one file in one process share one database. Thus the storages of two tables
20
+ * in one file keep the changes of each other.
21
+ * - A storage that only reads does not make the file.
19
22
  */
20
23
 
21
24
  import { randomUUID } from "node:crypto";
22
25
  import { resolve } from "node:path";
23
- import { createConnection, type Database } from "@mark1russell7/docker-sqlite";
24
- import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/client";
25
-
26
- /** The path of a database that is only in memory. */
27
- export const MEMORY_PATH = ":memory:";
26
+ import { optionalMs, type CollectionStorage, type Duration, type StorageChanges, type StorageMetadata } from "@procedures/client";
27
+ import {
28
+ MEMORY_PATH,
29
+ checkEngine,
30
+ openEngine,
31
+ type CopySchema,
32
+ type EngineDatabase,
33
+ type OpenDatabase,
34
+ type SqliteEngine,
35
+ type SqliteEngineName,
36
+ type SqlValue,
37
+ } from "./engines.js";
38
+
39
+ export { MEMORY_PATH, isNodeSqliteAvailable, type SqliteEngine, type SqliteEngineName } from "./engines.js";
28
40
 
29
41
  /** The options of a `SqliteStorage`. */
30
42
  export interface SqliteStorageOptions {
31
43
  /**
32
44
  * The database file, or `:memory:` for a database that lives as long as the storage. The
33
- * storage makes the file, and its folder, at the first write.
45
+ * storage makes the file, and its folder, at the first write that changes an item.
34
46
  */
35
47
  path: string;
36
48
  /**
@@ -44,6 +56,28 @@ export interface SqliteStorageOptions {
44
56
  * @default 10000
45
57
  */
46
58
  maxTombstones?: number;
59
+ /**
60
+ * The SQLite engine.
61
+ *
62
+ * - `node`: the module `node:sqlite` of Node.js 22.13 or later. It writes only the changes, and
63
+ * the file is in WAL mode. Two processes can use one file.
64
+ * - `sqljs`: sql.js, SQLite in WebAssembly. It keeps the database in memory, and it writes the
65
+ * whole file after a write.
66
+ * - `auto`: `node` when Node.js has `node:sqlite`, and else `sqljs`.
67
+ *
68
+ * The storages of one file in one process share one database, and thus one engine. The first
69
+ * storage of the file sets it. A storage with another engine than `auto` refuses a database
70
+ * of the other engine.
71
+ * @default "auto"
72
+ */
73
+ engine?: SqliteEngine;
74
+ /**
75
+ * The time that a write of the engine `node` waits for the lock of another process, for example
76
+ * `seconds(5)`. After it, the write fails with the error `database is locked`. The first
77
+ * storage of a file sets it.
78
+ * @default 5 seconds
79
+ */
80
+ busyTimeout?: Duration;
47
81
  }
48
82
 
49
83
  /** The pattern of a table name: a letter, then letters, digits and underscores. */
@@ -62,34 +96,38 @@ export function checkTableName(name: unknown): string {
62
96
  return name;
63
97
  }
64
98
 
65
- type SqlValue = string | number | null;
66
-
67
- /** The rows of a query, as objects. */
68
- function rows<R>(db: Database, sql: string, params: SqlValue[] = []): R[] {
69
- const statement = db.prepare(sql);
70
- try {
71
- statement.bind(params);
72
- const out: R[] = [];
73
- while (statement.step()) out.push(statement.getAsObject() as R);
74
- return out;
75
- } finally {
76
- statement.free();
77
- }
78
- }
79
-
80
- /** This function runs the work in a transaction. An error rolls back each change of the work. */
81
- function transaction<R>(db: Database, work: () => R): R {
82
- db.run("BEGIN");
99
+ /**
100
+ * This function runs the work in a transaction. An error rolls back each change of the work.
101
+ * With `keep` false, the transaction always rolls back: the work only tests a change.
102
+ */
103
+ function transaction<R>(db: EngineDatabase, work: () => R, keep = true): R {
104
+ // IMMEDIATE takes the write lock at the start, so the busy timeout applies to it. A deferred
105
+ // transaction of the engine node that reads and then writes can fail at once in WAL mode.
106
+ db.exec("BEGIN IMMEDIATE");
83
107
  try {
84
108
  const result = work();
85
- db.run("COMMIT");
109
+ db.exec(keep ? "COMMIT" : "ROLLBACK");
86
110
  return result;
87
111
  } catch (error) {
88
- db.run("ROLLBACK");
112
+ try {
113
+ db.exec("ROLLBACK");
114
+ } catch {
115
+ // SQLite rolled back the transaction already
116
+ }
89
117
  throw error;
90
118
  }
91
119
  }
92
120
 
121
+ /** This function runs several reads in one read transaction, so they see one state of the file. */
122
+ function readTransaction<R>(db: EngineDatabase, work: () => R): R {
123
+ db.exec("BEGIN");
124
+ try {
125
+ return work();
126
+ } finally {
127
+ db.exec("COMMIT");
128
+ }
129
+ }
130
+
93
131
  /** The JSON text of a value. A value without JSON text is a `TypeError`. */
94
132
  function toJson(id: string, value: unknown): string {
95
133
  let text: string | undefined;
@@ -108,6 +146,78 @@ function checkId(id: unknown): string {
108
146
  return id;
109
147
  }
110
148
 
149
+ // ---------------------------------------------------------------------------
150
+ // The schema
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const VERSIONS = "_storage_versions";
154
+ const LOG = "_storage_log";
155
+ const LOG_INDEX = `${LOG}_seq`;
156
+
157
+ const SCHEMA = `
158
+ CREATE TABLE IF NOT EXISTS ${VERSIONS} (
159
+ collection TEXT PRIMARY KEY,
160
+ epoch TEXT NOT NULL,
161
+ seq INTEGER NOT NULL,
162
+ floor INTEGER NOT NULL
163
+ );
164
+ CREATE TABLE IF NOT EXISTS ${LOG} (
165
+ collection TEXT NOT NULL,
166
+ id TEXT NOT NULL,
167
+ seq INTEGER NOT NULL,
168
+ deleted INTEGER NOT NULL,
169
+ PRIMARY KEY (collection, id)
170
+ );
171
+ CREATE INDEX IF NOT EXISTS ${LOG_INDEX} ON ${LOG} (collection, seq);
172
+ `;
173
+
174
+ /** A new epoch: a version of another collection or of another file does not match it. */
175
+ function newEpoch(): string {
176
+ return randomUUID().replaceAll("-", "").slice(0, 12);
177
+ }
178
+
179
+ /** This function throws when the table exists without the columns of a storage. */
180
+ function checkColumns(db: EngineDatabase, table: string): void {
181
+ const columns = new Set(db.all<{ name: string }>(`PRAGMA table_info("${table}")`).map((row) => row.name));
182
+ if (!columns.has("id") || !columns.has("value")) {
183
+ throw new Error(`The table ${table} exists, and it does not have the columns id and value of a SqliteStorage`);
184
+ }
185
+ }
186
+
187
+ /** True when the database has the tables of the change log, the table and its version. */
188
+ function hasSchema(db: EngineDatabase, table: string): boolean {
189
+ const names = db.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE name IN (?, ?, ?, ?)`, [VERSIONS, LOG, LOG_INDEX, table]);
190
+ return names.length === 4 && db.all(`SELECT 1 FROM ${VERSIONS} WHERE collection = ?`, [table]).length > 0;
191
+ }
192
+
193
+ /**
194
+ * This function makes the tables of the change log and of a collection, and the version of the
195
+ * collection. A database that has them already does not change, so a storage that only reads
196
+ * does not write to an existing file.
197
+ */
198
+ function prepareTable(db: EngineDatabase, table: string, epoch: string): void {
199
+ if (hasSchema(db, table)) {
200
+ checkColumns(db, table);
201
+ return;
202
+ }
203
+ transaction(db, () => {
204
+ db.exec(SCHEMA);
205
+ db.exec(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL)`);
206
+ checkColumns(db, table);
207
+ db.run(`INSERT OR IGNORE INTO ${VERSIONS} (collection, epoch, seq, floor) VALUES (?, ?, 0, 0)`, [table, epoch]);
208
+ });
209
+ }
210
+
211
+ /**
212
+ * This function copies the schema of each collection, with its epoch, to the file that the node
213
+ * engine makes. A version that a storage gave before the file existed stays valid.
214
+ */
215
+ const copySchema: CopySchema = (from, to) => {
216
+ for (const { collection, epoch } of from.all<{ collection: string; epoch: string }>(`SELECT collection, epoch FROM ${VERSIONS}`)) {
217
+ prepareTable(to, checkTableName(collection), epoch);
218
+ }
219
+ };
220
+
111
221
  // ---------------------------------------------------------------------------
112
222
  // The databases of the files, shared in the process
113
223
  // ---------------------------------------------------------------------------
@@ -118,8 +228,9 @@ class SharedDatabase {
118
228
  users = 0;
119
229
  closed = false;
120
230
  /**
121
- * True when the database has a change that is not in the file. The tables that a storage makes
122
- * when it opens are not a change: a storage that only reads does not make the file.
231
+ * The engine sqljs: true when the database has a change that is not in the file. The tables
232
+ * that a storage makes when it opens are not a change: a storage that only reads does not make
233
+ * the file.
123
234
  */
124
235
  dirty = false;
125
236
  /** The last file write. Its error is in the promise of the write that asked for it. */
@@ -129,12 +240,24 @@ class SharedDatabase {
129
240
 
130
241
  constructor(
131
242
  readonly path: string,
132
- readonly db: Database,
133
- private readonly saveFile: () => Promise<void>,
243
+ readonly open: OpenDatabase,
134
244
  ) {}
135
245
 
136
- /** This method records a change, and gives the promise of the file write that holds it. */
246
+ get engine(): SqliteEngineName {
247
+ return this.open.engine;
248
+ }
249
+
250
+ /** True when the engine writes the whole file after a write: sql.js with a file. */
251
+ private get savesFile(): boolean {
252
+ return this.open.engine === "sqljs" && this.path !== MEMORY_PATH;
253
+ }
254
+
255
+ /**
256
+ * This method records a change, and gives the promise of the file write that holds it. The
257
+ * engine node wrote the change at the commit of its transaction.
258
+ */
137
259
  changed(): Promise<void> {
260
+ if (!this.savesFile) return Promise.resolve();
138
261
  this.dirty = true;
139
262
  return this.save();
140
263
  }
@@ -144,7 +267,7 @@ class SharedDatabase {
144
267
  * starts after this call, so the file gets each change that came before the call.
145
268
  */
146
269
  save(): Promise<void> {
147
- if (this.path === MEMORY_PATH) return Promise.resolve();
270
+ if (!this.savesFile) return Promise.resolve();
148
271
  if (this.next) return this.next;
149
272
  const run = this.saving.then(async () => {
150
273
  // The file write reads the database now: a later change needs a new file write
@@ -152,7 +275,7 @@ class SharedDatabase {
152
275
  if (!this.dirty) return;
153
276
  this.dirty = false;
154
277
  try {
155
- await this.saveFile();
278
+ await this.open.saveFile();
156
279
  } catch (error) {
157
280
  this.dirty = true;
158
281
  throw error;
@@ -164,8 +287,14 @@ class SharedDatabase {
164
287
  }
165
288
  }
166
289
 
290
+ /** A database of a file that opens or is open, and the engine choice of the storage that opened it. */
291
+ interface FileEntry {
292
+ engine: SqliteEngine;
293
+ database: Promise<SharedDatabase>;
294
+ }
295
+
167
296
  /** The databases of the files that open or are open, by the key of the file. */
168
- const files = new Map<string, Promise<SharedDatabase>>();
297
+ const files = new Map<string, FileEntry>();
169
298
  /** The open databases of the files, by the key of the file. */
170
299
  const opened = new Map<string, SharedDatabase>();
171
300
 
@@ -175,15 +304,14 @@ function fileKey(path: string): string {
175
304
  return process.platform === "win32" ? absolute.toLowerCase() : absolute;
176
305
  }
177
306
 
178
- async function openDatabase(path: string): Promise<SharedDatabase> {
179
- const { db, save } = await createConnection({ dbPath: path });
180
- return new SharedDatabase(path, db, save);
307
+ async function openDatabase(path: string, engine: SqliteEngine, busyTimeoutMs: number): Promise<SharedDatabase> {
308
+ return new SharedDatabase(path, await openEngine(path, engine, busyTimeoutMs));
181
309
  }
182
310
 
183
311
  /** This function opens the database of a path, or gives the open database of the same file. */
184
- async function acquire(path: string): Promise<SharedDatabase> {
312
+ async function acquire(path: string, engine: SqliteEngine, busyTimeoutMs: number): Promise<SharedDatabase> {
185
313
  if (path === MEMORY_PATH) {
186
- const shared = await openDatabase(path);
314
+ const shared = await openDatabase(path, engine, busyTimeoutMs);
187
315
  shared.users++;
188
316
  return shared;
189
317
  }
@@ -191,20 +319,33 @@ async function acquire(path: string): Promise<SharedDatabase> {
191
319
  for (;;) {
192
320
  let entry = files.get(key);
193
321
  if (!entry) {
194
- const opening = openDatabase(path);
195
- entry = opening;
196
- files.set(key, opening);
322
+ const opening = openDatabase(path, engine, busyTimeoutMs);
323
+ entry = { engine, database: opening };
324
+ files.set(key, entry);
325
+ const own = entry;
197
326
  opening.then(
198
327
  (shared) => opened.set(key, shared),
199
328
  () => {
200
329
  // A failed open does not stay: the next storage tries again
201
- if (files.get(key) === opening) files.delete(key);
330
+ if (files.get(key) === own) files.delete(key);
202
331
  },
203
332
  );
204
333
  }
205
- const shared = await entry;
334
+ let shared: SharedDatabase;
335
+ try {
336
+ shared = await entry.database;
337
+ } catch (error) {
338
+ // A storage with another engine choice failed to open the file: try with this choice
339
+ if (entry.engine !== engine) continue;
340
+ throw error;
341
+ }
206
342
  // The last storage closed the database while this one waited: open the file again
207
343
  if (shared.closed) continue;
344
+ if (engine !== "auto" && shared.engine !== engine) {
345
+ throw new Error(
346
+ `The file ${path} is open with the SQLite engine ${shared.engine} in this process: a storage with the engine ${engine} cannot share it. Give the engine auto or ${shared.engine}.`,
347
+ );
348
+ }
208
349
  shared.users++;
209
350
  return shared;
210
351
  }
@@ -233,7 +374,7 @@ async function release(shared: SharedDatabase): Promise<void> {
233
374
  files.delete(key);
234
375
  }
235
376
  }
236
- shared.db.close();
377
+ shared.open.close();
237
378
  if (failure !== undefined) throw failure;
238
379
  }
239
380
 
@@ -241,31 +382,18 @@ async function release(shared: SharedDatabase): Promise<void> {
241
382
  // The storage
242
383
  // ---------------------------------------------------------------------------
243
384
 
244
- const VERSIONS = "_storage_versions";
245
- const LOG = "_storage_log";
246
-
247
- const SCHEMA = `
248
- CREATE TABLE IF NOT EXISTS ${VERSIONS} (
249
- collection TEXT PRIMARY KEY,
250
- epoch TEXT NOT NULL,
251
- seq INTEGER NOT NULL,
252
- floor INTEGER NOT NULL
253
- );
254
- CREATE TABLE IF NOT EXISTS ${LOG} (
255
- collection TEXT NOT NULL,
256
- id TEXT NOT NULL,
257
- seq INTEGER NOT NULL,
258
- deleted INTEGER NOT NULL,
259
- PRIMARY KEY (collection, id)
260
- );
261
- CREATE INDEX IF NOT EXISTS ${LOG}_seq ON ${LOG} (collection, seq);
262
- `;
263
-
264
385
  interface Counter {
386
+ epoch: string;
265
387
  seq: number;
266
388
  floor: number;
267
389
  }
268
390
 
391
+ /** The result of one write, and true when it changed an item. */
392
+ interface WriteResult<R> {
393
+ value: R;
394
+ changed: boolean;
395
+ }
396
+
269
397
  /**
270
398
  * A collection storage in a table of a SQLite database (roadmap R45).
271
399
  *
@@ -275,6 +403,7 @@ interface Counter {
275
403
  * await users.set("123", { id: "123", name: "John" });
276
404
  * const user = await users.get("123"); // { id: "123", name: "John" }
277
405
  * const changes = await users.getChangesSince(); // { version, full: true, changed, deleted: [] }
406
+ * await users.getEngine(); // "node" on Node.js 22.13 or later, else "sqljs"
278
407
  * await users.close();
279
408
  * ```
280
409
  */
@@ -283,10 +412,12 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
283
412
  readonly path: string;
284
413
  /** The table of the items. */
285
414
  readonly table: string;
415
+ /** The engine choice of the options. `getEngine()` gives the engine that the storage uses. */
416
+ readonly engine: SqliteEngine;
286
417
  private readonly maxTombstones: number;
287
418
  /** The quoted table name, for the SQL text. */
288
419
  private readonly quoted: string;
289
- private readonly opening: Promise<{ shared: SharedDatabase; epoch: string }>;
420
+ private readonly opening: Promise<SharedDatabase>;
290
421
  private closed = false;
291
422
 
292
423
  constructor(options: SqliteStorageOptions) {
@@ -295,9 +426,11 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
295
426
  }
296
427
  this.path = options.path;
297
428
  this.table = checkTableName(options.table);
429
+ this.engine = checkEngine(options.engine ?? "auto");
430
+ const busyTimeoutMs = optionalMs(options.busyTimeout, "busyTimeout") ?? 5000;
298
431
  this.quoted = `"${this.table}"`;
299
432
  this.maxTombstones = Math.max(0, options.maxTombstones ?? 10_000);
300
- this.opening = this.open();
433
+ this.opening = this.open(busyTimeoutMs);
301
434
  // An operation gives the error of the open. This handler only stops an unhandled rejection.
302
435
  this.opening.catch(() => undefined);
303
436
  }
@@ -308,13 +441,13 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
308
441
 
309
442
  async get(id: string): Promise<T | undefined> {
310
443
  const db = await this.database();
311
- const [row] = rows<{ value: string }>(db, `SELECT value FROM ${this.quoted} WHERE id = ?`, [checkId(id)]);
444
+ const [row] = db.all<{ value: string }>(`SELECT value FROM ${this.quoted} WHERE id = ?`, [checkId(id)]);
312
445
  return row === undefined ? undefined : (JSON.parse(row.value) as T);
313
446
  }
314
447
 
315
448
  async getAll(): Promise<T[]> {
316
449
  const db = await this.database();
317
- return rows<{ value: string }>(db, `SELECT value FROM ${this.quoted} ORDER BY rowid`).map((row) => JSON.parse(row.value) as T);
450
+ return db.all<{ value: string }>(`SELECT value FROM ${this.quoted} ORDER BY rowid`).map((row) => JSON.parse(row.value) as T);
318
451
  }
319
452
 
320
453
  /** Every item with its id, in the order of the first write of each id. */
@@ -322,32 +455,39 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
322
455
  return this.entries(await this.database());
323
456
  }
324
457
 
458
+ /** The ids of the items, in the order of the entries. The storage does not read the values. */
459
+ async getIds(): Promise<string[]> {
460
+ const db = await this.database();
461
+ return db.all<{ id: string }>(`SELECT id FROM ${this.quoted} ORDER BY rowid`).map((row) => row.id);
462
+ }
463
+
325
464
  /**
326
465
  * The changes after a version (roadmap R28). A version of another storage, of another database
327
466
  * file, or older than the dropped deletes gives all items (`full: true`). A version stays valid
328
- * after a restart, because the change log is in the file.
467
+ * after a restart, because the change log is in the file. The reads run in one read
468
+ * transaction, so a write of another process does not come between them.
329
469
  */
330
470
  async getChangesSince(since?: string): Promise<StorageChanges<T>> {
331
471
  const db = await this.database();
332
- const { epoch } = await this.opening;
333
- const { seq, floor } = this.counter(db);
334
- const version = `${epoch}:${seq}`;
335
- const from = parseVersion(since, epoch, seq);
336
- if (from === undefined || from < floor) {
337
- return { version, full: true, changed: this.entries(db), deleted: [] };
338
- }
339
- const changed = rows<{ id: string; value: string }>(
340
- db,
341
- `SELECT l.id AS id, d.value AS value FROM ${LOG} l JOIN ${this.quoted} d ON d.id = l.id
342
- WHERE l.collection = ? AND l.deleted = 0 AND l.seq > ? ORDER BY l.seq`,
343
- [this.table, from],
344
- ).map((row): [string, T] => [row.id, JSON.parse(row.value) as T]);
345
- const deleted = rows<{ id: string }>(
346
- db,
347
- `SELECT id FROM ${LOG} WHERE collection = ? AND deleted = 1 AND seq > ? ORDER BY seq`,
348
- [this.table, from],
349
- ).map((row) => row.id);
350
- return { version, full: false, changed, deleted };
472
+ return readTransaction(db, () => {
473
+ const { epoch, seq, floor } = this.counter(db);
474
+ const version = `${epoch}:${seq}`;
475
+ const from = parseVersion(since, epoch, seq);
476
+ if (from === undefined || from < floor) {
477
+ return { version, full: true, changed: this.entries(db), deleted: [] };
478
+ }
479
+ const changed = db
480
+ .all<{ id: string; value: string }>(
481
+ `SELECT l.id AS id, d.value AS value FROM ${LOG} l JOIN ${this.quoted} d ON d.id = l.id
482
+ WHERE l.collection = ? AND l.deleted = 0 AND l.seq > ? ORDER BY l.seq`,
483
+ [this.table, from],
484
+ )
485
+ .map((row): [string, T] => [row.id, JSON.parse(row.value) as T]);
486
+ const deleted = db
487
+ .all<{ id: string }>(`SELECT id FROM ${LOG} WHERE collection = ? AND deleted = 1 AND seq > ? ORDER BY seq`, [this.table, from])
488
+ .map((row) => row.id);
489
+ return { version, full: false, changed, deleted };
490
+ });
351
491
  }
352
492
 
353
493
  async find(predicate: (item: T) => boolean): Promise<T[]> {
@@ -356,13 +496,13 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
356
496
 
357
497
  async has(id: string): Promise<boolean> {
358
498
  const db = await this.database();
359
- return rows(db, `SELECT 1 FROM ${this.quoted} WHERE id = ? LIMIT 1`, [checkId(id)]).length > 0;
499
+ return db.all(`SELECT 1 FROM ${this.quoted} WHERE id = ? LIMIT 1`, [checkId(id)]).length > 0;
360
500
  }
361
501
 
362
502
  async size(): Promise<number> {
363
503
  const db = await this.database();
364
- const [row] = rows<{ n: number }>(db, `SELECT COUNT(*) AS n FROM ${this.quoted}`);
365
- return row?.n ?? 0;
504
+ const [row] = db.all<{ n: number }>(`SELECT COUNT(*) AS n FROM ${this.quoted}`);
505
+ return Number(row?.n ?? 0);
366
506
  }
367
507
 
368
508
  //
@@ -381,7 +521,7 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
381
521
 
382
522
  async clear(): Promise<void> {
383
523
  await this.write((db, next) => {
384
- for (const { id } of rows<{ id: string }>(db, `SELECT id FROM ${this.quoted} ORDER BY rowid`)) this.logChange(db, id, next(), true);
524
+ for (const { id } of db.all<{ id: string }>(`SELECT id FROM ${this.quoted} ORDER BY rowid`)) this.logChange(db, id, next(), true);
385
525
  db.run(`DELETE FROM ${this.quoted}`);
386
526
  });
387
527
  }
@@ -392,6 +532,8 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
392
532
 
393
533
  /** This method sets several items in one transaction. When one value is not JSON, nothing changes. */
394
534
  async setBatch(items: Array<[string, T]>): Promise<void> {
535
+ // After close(), an empty batch rejects too
536
+ this.checkOpen();
395
537
  const texts = items.map(([id, value]): [string, string] => [checkId(id), toJson(id, value)]);
396
538
  if (texts.length === 0) return;
397
539
  await this.write((db, next) => {
@@ -401,6 +543,7 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
401
543
 
402
544
  /** This method deletes several items in one transaction. */
403
545
  async deleteBatch(ids: string[]): Promise<number> {
546
+ this.checkOpen();
404
547
  for (const id of ids) checkId(id);
405
548
  if (ids.length === 0) return 0;
406
549
  return this.write((db, next) => {
@@ -412,17 +555,11 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
412
555
 
413
556
  async getBatch(ids: string[]): Promise<Map<string, T>> {
414
557
  const db = await this.database();
558
+ const rows = db.firstOfEach<{ value: string }>(`SELECT value FROM ${this.quoted} WHERE id = ?`, ids.map((id) => [checkId(id)]));
415
559
  const result = new Map<string, T>();
416
- const statement = db.prepare(`SELECT value FROM ${this.quoted} WHERE id = ?`);
417
- try {
418
- for (const id of ids) {
419
- statement.bind([checkId(id)]);
420
- if (statement.step()) result.set(id, JSON.parse(statement.getAsObject()["value"] as string) as T);
421
- statement.reset();
422
- }
423
- } finally {
424
- statement.free();
425
- }
560
+ rows.forEach((row, index) => {
561
+ if (row !== undefined) result.set(ids[index]!, JSON.parse(row.value) as T);
562
+ });
426
563
  return result;
427
564
  }
428
565
 
@@ -430,27 +567,37 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
430
567
  // ═══ Lifecycle & Metadata ═══
431
568
  //
432
569
 
570
+ /** The engine that the storage uses: `node` or `sqljs`. With the option `auto`, the open decides it. */
571
+ async getEngine(): Promise<SqliteEngineName> {
572
+ this.checkOpen();
573
+ const shared = await this.opening;
574
+ this.checkOpen();
575
+ return shared.engine;
576
+ }
577
+
433
578
  /**
434
579
  * This method writes the database file. Each write ends after its file write already, so a
435
- * caller needs it only after a failed file write.
580
+ * caller needs it only after a failed file write of the engine `sqljs`. The engine `node`
581
+ * writes each change at its commit.
436
582
  */
437
583
  async flush(): Promise<void> {
438
- const { shared } = await this.opening;
439
- if (this.closed) return;
584
+ this.checkOpen();
585
+ const shared = await this.opening;
586
+ this.checkOpen();
440
587
  await shared.save();
441
588
  }
442
589
 
443
590
  /**
444
591
  * This method writes the file and releases the database. The items stay in the file. A new
445
592
  * storage of the same file and table reads them, and the versions of this storage stay valid.
446
- * After `close()`, each operation throws.
593
+ * After `close()`, each operation rejects, and a second `close()` does nothing.
447
594
  */
448
595
  async close(): Promise<void> {
449
596
  if (this.closed) return;
450
597
  this.closed = true;
451
598
  let shared: SharedDatabase;
452
599
  try {
453
- ({ shared } = await this.opening);
600
+ shared = await this.opening;
454
601
  } catch {
455
602
  // The open failed: there is nothing to release
456
603
  return;
@@ -459,10 +606,12 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
459
606
  }
460
607
 
461
608
  async getMetadata(): Promise<StorageMetadata> {
609
+ const size = await this.size();
610
+ const engine = await this.getEngine();
462
611
  return {
463
612
  type: "custom",
464
- size: await this.size(),
465
- stats: { backend: "sqlite", table: this.table, memory: this.path === MEMORY_PATH },
613
+ size,
614
+ stats: { backend: "sqlite", engine, table: this.table, memory: this.path === MEMORY_PATH },
466
615
  };
467
616
  }
468
617
 
@@ -471,93 +620,106 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
471
620
  //
472
621
 
473
622
  /** This method opens the database, and makes the tables of the collection and of the change log. */
474
- private async open(): Promise<{ shared: SharedDatabase; epoch: string }> {
475
- const shared = await acquire(this.path);
623
+ private async open(busyTimeoutMs: number): Promise<SharedDatabase> {
624
+ const shared = await acquire(this.path, this.engine, busyTimeoutMs);
476
625
  try {
477
- const db = shared.db;
478
- db.exec(SCHEMA);
479
- db.exec(`CREATE TABLE IF NOT EXISTS ${this.quoted} (id TEXT PRIMARY KEY, value TEXT NOT NULL)`);
480
- const columns = new Set(rows<{ name: string }>(db, `PRAGMA table_info(${this.quoted})`).map((row) => row.name));
481
- if (!columns.has("id") || !columns.has("value")) {
482
- throw new Error(`The table ${this.table} exists, and it does not have the columns id and value of a SqliteStorage`);
483
- }
484
- db.run(`INSERT OR IGNORE INTO ${VERSIONS} (collection, epoch, seq, floor) VALUES (?, ?, 0, 0)`, [
485
- this.table,
486
- randomUUID().replaceAll("-", "").slice(0, 12),
487
- ]);
488
- const [row] = rows<{ epoch: string }>(db, `SELECT epoch FROM ${VERSIONS} WHERE collection = ?`, [this.table]);
489
- return { shared, epoch: row!.epoch };
626
+ prepareTable(shared.open.db, this.table, newEpoch());
627
+ return shared;
490
628
  } catch (error) {
491
629
  await release(shared).catch(() => undefined);
492
630
  throw error;
493
631
  }
494
632
  }
495
633
 
496
- /** The open database. It throws after `close()`. */
497
- private async database(): Promise<Database> {
634
+ /** The open database. It throws after `close()`, so the promise of the operation rejects. */
635
+ private async database(): Promise<EngineDatabase> {
636
+ const shared = await this.sharedDatabase();
637
+ // The engine node: another process made the file after the open
638
+ shared.open.refresh(copySchema);
639
+ return shared.open.db;
640
+ }
641
+
642
+ private async sharedDatabase(): Promise<SharedDatabase> {
498
643
  this.checkOpen();
499
- const { shared } = await this.opening;
644
+ const shared = await this.opening;
500
645
  this.checkOpen();
501
- return shared.db;
646
+ return shared;
502
647
  }
503
648
 
504
649
  private checkOpen(): void {
505
650
  if (this.closed) throw new Error(`The SQLite storage of the table ${this.table} is closed`);
506
651
  }
507
652
 
508
- private entries(db: Database): Array<[string, T]> {
509
- return rows<{ id: string; value: string }>(db, `SELECT id, value FROM ${this.quoted} ORDER BY rowid`).map(
653
+ private entries(db: EngineDatabase): Array<[string, T]> {
654
+ return db.all<{ id: string; value: string }>(`SELECT id, value FROM ${this.quoted} ORDER BY rowid`).map(
510
655
  (row): [string, T] => [row.id, JSON.parse(row.value) as T],
511
656
  );
512
657
  }
513
658
 
514
- private counter(db: Database): Counter {
515
- const [row] = rows<Counter>(db, `SELECT seq, floor FROM ${VERSIONS} WHERE collection = ?`, [this.table]);
516
- return { seq: row?.seq ?? 0, floor: row?.floor ?? 0 };
659
+ private counter(db: EngineDatabase): Counter {
660
+ const [row] = db.all<Counter>(`SELECT epoch, seq, floor FROM ${VERSIONS} WHERE collection = ?`, [this.table]);
661
+ return { epoch: row?.epoch ?? "", seq: Number(row?.seq ?? 0), floor: Number(row?.floor ?? 0) };
517
662
  }
518
663
 
519
664
  /**
520
- * One write: the change in a transaction with the change log, then the file write. `next` gives
521
- * the next number of the change log. A write that changes nothing writes no file.
665
+ * One write: the change in a transaction with the change log, then the file write of the engine
666
+ * `sqljs`. `next` gives the next number of the change log. A write that changes nothing writes
667
+ * no file.
668
+ *
669
+ * Before the first write, the engine `node` keeps the database of a missing file in memory, with
670
+ * no item. A write first tests its change there. When it changes an item, the storage makes the
671
+ * file and runs the write in it.
522
672
  */
523
- private async write<R>(change: (db: Database, next: () => number) => R): Promise<R> {
524
- const db = await this.database();
525
- let changed = false;
526
- const result = transaction(db, () => {
527
- const counter = this.counter(db);
528
- const next = (): number => {
529
- changed = true;
530
- return ++counter.seq;
531
- };
532
- const value = change(db, next);
533
- if (changed) {
534
- this.trimTombstones(db, counter);
535
- db.run(`UPDATE ${VERSIONS} SET seq = ?, floor = ? WHERE collection = ?`, [counter.seq, counter.floor, this.table]);
536
- }
537
- return value;
538
- });
539
- if (changed) {
540
- const { shared } = await this.opening;
541
- await shared.changed();
673
+ private async write<R>(change: (db: EngineDatabase, next: () => number) => R): Promise<R> {
674
+ const shared = await this.sharedDatabase();
675
+ const { open } = shared;
676
+ open.refresh(copySchema);
677
+ if (open.waiting) {
678
+ const test = this.runWrite(open.db, change, false);
679
+ if (!test.changed) return test.value;
680
+ open.makeFile(copySchema);
542
681
  }
543
- return result;
682
+ const { value, changed } = this.runWrite(open.db, change, true);
683
+ if (changed) await shared.changed();
684
+ return value;
685
+ }
686
+
687
+ private runWrite<R>(db: EngineDatabase, change: (db: EngineDatabase, next: () => number) => R, keep: boolean): WriteResult<R> {
688
+ let changed = false;
689
+ const value = transaction(
690
+ db,
691
+ () => {
692
+ const counter = this.counter(db);
693
+ const next = (): number => {
694
+ changed = true;
695
+ return ++counter.seq;
696
+ };
697
+ const result = change(db, next);
698
+ if (changed) {
699
+ this.trimTombstones(db, counter);
700
+ db.run(`UPDATE ${VERSIONS} SET seq = ?, floor = ? WHERE collection = ?`, [counter.seq, counter.floor, this.table]);
701
+ }
702
+ return result;
703
+ },
704
+ keep,
705
+ );
706
+ return { value, changed };
544
707
  }
545
708
 
546
709
  /** This method writes an item. An existing id keeps its place in the order. */
547
- private put(db: Database, id: string, text: string, next: () => number): void {
710
+ private put(db: EngineDatabase, id: string, text: string, next: () => number): void {
548
711
  db.run(`INSERT INTO ${this.quoted} (id, value) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value`, [id, text]);
549
712
  this.logChange(db, id, next(), false);
550
713
  }
551
714
 
552
715
  /** This method deletes an item. True when it existed. */
553
- private remove(db: Database, id: string, next: () => number): boolean {
554
- db.run(`DELETE FROM ${this.quoted} WHERE id = ?`, [id]);
555
- if (db.getRowsModified() === 0) return false;
716
+ private remove(db: EngineDatabase, id: string, next: () => number): boolean {
717
+ if (db.run(`DELETE FROM ${this.quoted} WHERE id = ?`, [id]) === 0) return false;
556
718
  this.logChange(db, id, next(), true);
557
719
  return true;
558
720
  }
559
721
 
560
- private logChange(db: Database, id: string, seq: number, deleted: boolean): void {
722
+ private logChange(db: EngineDatabase, id: string, seq: number, deleted: boolean): void {
561
723
  db.run(
562
724
  `INSERT INTO ${LOG} (collection, id, seq, deleted) VALUES (?, ?, ?, ?)
563
725
  ON CONFLICT(collection, id) DO UPDATE SET seq = excluded.seq, deleted = excluded.deleted`,
@@ -566,18 +728,15 @@ export class SqliteStorage<T> implements CollectionStorage<T> {
566
728
  }
567
729
 
568
730
  /** This method drops the oldest deletes above the limit, and moves the floor of the change log. */
569
- private trimTombstones(db: Database, counter: Counter): void {
570
- const [row] = rows<{ n: number }>(db, `SELECT COUNT(*) AS n FROM ${LOG} WHERE collection = ? AND deleted = 1`, [this.table]);
571
- const excess = (row?.n ?? 0) - this.maxTombstones;
731
+ private trimTombstones(db: EngineDatabase, counter: Counter): void {
732
+ const [row] = db.all<{ n: number }>(`SELECT COUNT(*) AS n FROM ${LOG} WHERE collection = ? AND deleted = 1`, [this.table]);
733
+ const excess = Number(row?.n ?? 0) - this.maxTombstones;
572
734
  if (excess <= 0) return;
573
- const [cut] = rows<{ seq: number }>(
574
- db,
575
- `SELECT seq FROM ${LOG} WHERE collection = ? AND deleted = 1 ORDER BY seq LIMIT 1 OFFSET ?`,
576
- [this.table, excess - 1],
577
- );
735
+ const params: SqlValue[] = [this.table, excess - 1];
736
+ const [cut] = db.all<{ seq: number }>(`SELECT seq FROM ${LOG} WHERE collection = ? AND deleted = 1 ORDER BY seq LIMIT 1 OFFSET ?`, params);
578
737
  if (cut === undefined) return;
579
738
  db.run(`DELETE FROM ${LOG} WHERE collection = ? AND deleted = 1 AND seq <= ?`, [this.table, cut.seq]);
580
- counter.floor = Math.max(counter.floor, cut.seq);
739
+ counter.floor = Math.max(counter.floor, Number(cut.seq));
581
740
  }
582
741
  }
583
742