@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/dist/storage.js CHANGED
@@ -3,25 +3,28 @@
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
  import { randomUUID } from "node:crypto";
21
24
  import { resolve } from "node:path";
22
- import { createConnection } from "@mark1russell7/docker-sqlite";
23
- /** The path of a database that is only in memory. */
24
- export const MEMORY_PATH = ":memory:";
25
+ import { optionalMs } from "@procedures/client";
26
+ import { MEMORY_PATH, checkEngine, openEngine, } from "./engines.js";
27
+ export { MEMORY_PATH, isNodeSqliteAvailable } from "./engines.js";
25
28
  /** The pattern of a table name: a letter, then letters, digits and underscores. */
26
29
  const TABLE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
27
30
  /**
@@ -34,33 +37,39 @@ export function checkTableName(name) {
34
37
  }
35
38
  return name;
36
39
  }
37
- /** The rows of a query, as objects. */
38
- function rows(db, sql, params = []) {
39
- const statement = db.prepare(sql);
40
- try {
41
- statement.bind(params);
42
- const out = [];
43
- while (statement.step())
44
- out.push(statement.getAsObject());
45
- return out;
46
- }
47
- finally {
48
- statement.free();
49
- }
50
- }
51
- /** This function runs the work in a transaction. An error rolls back each change of the work. */
52
- function transaction(db, work) {
53
- db.run("BEGIN");
40
+ /**
41
+ * This function runs the work in a transaction. An error rolls back each change of the work.
42
+ * With `keep` false, the transaction always rolls back: the work only tests a change.
43
+ */
44
+ function transaction(db, work, keep = true) {
45
+ // IMMEDIATE takes the write lock at the start, so the busy timeout applies to it. A deferred
46
+ // transaction of the engine node that reads and then writes can fail at once in WAL mode.
47
+ db.exec("BEGIN IMMEDIATE");
54
48
  try {
55
49
  const result = work();
56
- db.run("COMMIT");
50
+ db.exec(keep ? "COMMIT" : "ROLLBACK");
57
51
  return result;
58
52
  }
59
53
  catch (error) {
60
- db.run("ROLLBACK");
54
+ try {
55
+ db.exec("ROLLBACK");
56
+ }
57
+ catch {
58
+ // SQLite rolled back the transaction already
59
+ }
61
60
  throw error;
62
61
  }
63
62
  }
63
+ /** This function runs several reads in one read transaction, so they see one state of the file. */
64
+ function readTransaction(db, work) {
65
+ db.exec("BEGIN");
66
+ try {
67
+ return work();
68
+ }
69
+ finally {
70
+ db.exec("COMMIT");
71
+ }
72
+ }
64
73
  /** The JSON text of a value. A value without JSON text is a `TypeError`. */
65
74
  function toJson(id, value) {
66
75
  let text;
@@ -81,32 +90,107 @@ function checkId(id) {
81
90
  return id;
82
91
  }
83
92
  // ---------------------------------------------------------------------------
93
+ // The schema
94
+ // ---------------------------------------------------------------------------
95
+ const VERSIONS = "_storage_versions";
96
+ const LOG = "_storage_log";
97
+ const LOG_INDEX = `${LOG}_seq`;
98
+ const SCHEMA = `
99
+ CREATE TABLE IF NOT EXISTS ${VERSIONS} (
100
+ collection TEXT PRIMARY KEY,
101
+ epoch TEXT NOT NULL,
102
+ seq INTEGER NOT NULL,
103
+ floor INTEGER NOT NULL
104
+ );
105
+ CREATE TABLE IF NOT EXISTS ${LOG} (
106
+ collection TEXT NOT NULL,
107
+ id TEXT NOT NULL,
108
+ seq INTEGER NOT NULL,
109
+ deleted INTEGER NOT NULL,
110
+ PRIMARY KEY (collection, id)
111
+ );
112
+ CREATE INDEX IF NOT EXISTS ${LOG_INDEX} ON ${LOG} (collection, seq);
113
+ `;
114
+ /** A new epoch: a version of another collection or of another file does not match it. */
115
+ function newEpoch() {
116
+ return randomUUID().replaceAll("-", "").slice(0, 12);
117
+ }
118
+ /** This function throws when the table exists without the columns of a storage. */
119
+ function checkColumns(db, table) {
120
+ const columns = new Set(db.all(`PRAGMA table_info("${table}")`).map((row) => row.name));
121
+ if (!columns.has("id") || !columns.has("value")) {
122
+ throw new Error(`The table ${table} exists, and it does not have the columns id and value of a SqliteStorage`);
123
+ }
124
+ }
125
+ /** True when the database has the tables of the change log, the table and its version. */
126
+ function hasSchema(db, table) {
127
+ const names = db.all(`SELECT name FROM sqlite_master WHERE name IN (?, ?, ?, ?)`, [VERSIONS, LOG, LOG_INDEX, table]);
128
+ return names.length === 4 && db.all(`SELECT 1 FROM ${VERSIONS} WHERE collection = ?`, [table]).length > 0;
129
+ }
130
+ /**
131
+ * This function makes the tables of the change log and of a collection, and the version of the
132
+ * collection. A database that has them already does not change, so a storage that only reads
133
+ * does not write to an existing file.
134
+ */
135
+ function prepareTable(db, table, epoch) {
136
+ if (hasSchema(db, table)) {
137
+ checkColumns(db, table);
138
+ return;
139
+ }
140
+ transaction(db, () => {
141
+ db.exec(SCHEMA);
142
+ db.exec(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL)`);
143
+ checkColumns(db, table);
144
+ db.run(`INSERT OR IGNORE INTO ${VERSIONS} (collection, epoch, seq, floor) VALUES (?, ?, 0, 0)`, [table, epoch]);
145
+ });
146
+ }
147
+ /**
148
+ * This function copies the schema of each collection, with its epoch, to the file that the node
149
+ * engine makes. A version that a storage gave before the file existed stays valid.
150
+ */
151
+ const copySchema = (from, to) => {
152
+ for (const { collection, epoch } of from.all(`SELECT collection, epoch FROM ${VERSIONS}`)) {
153
+ prepareTable(to, checkTableName(collection), epoch);
154
+ }
155
+ };
156
+ // ---------------------------------------------------------------------------
84
157
  // The databases of the files, shared in the process
85
158
  // ---------------------------------------------------------------------------
86
159
  /** One open database and its file writes. */
87
160
  class SharedDatabase {
88
161
  path;
89
- db;
90
- saveFile;
162
+ open;
91
163
  /** The storages that use the database. */
92
164
  users = 0;
93
165
  closed = false;
94
166
  /**
95
- * True when the database has a change that is not in the file. The tables that a storage makes
96
- * when it opens are not a change: a storage that only reads does not make the file.
167
+ * The engine sqljs: true when the database has a change that is not in the file. The tables
168
+ * that a storage makes when it opens are not a change: a storage that only reads does not make
169
+ * the file.
97
170
  */
98
171
  dirty = false;
99
172
  /** The last file write. Its error is in the promise of the write that asked for it. */
100
173
  saving = Promise.resolve();
101
174
  /** A file write that waits for the running one. The writes that come before it starts share it. */
102
175
  next;
103
- constructor(path, db, saveFile) {
176
+ constructor(path, open) {
104
177
  this.path = path;
105
- this.db = db;
106
- this.saveFile = saveFile;
178
+ this.open = open;
179
+ }
180
+ get engine() {
181
+ return this.open.engine;
107
182
  }
108
- /** This method records a change, and gives the promise of the file write that holds it. */
183
+ /** True when the engine writes the whole file after a write: sql.js with a file. */
184
+ get savesFile() {
185
+ return this.open.engine === "sqljs" && this.path !== MEMORY_PATH;
186
+ }
187
+ /**
188
+ * This method records a change, and gives the promise of the file write that holds it. The
189
+ * engine node wrote the change at the commit of its transaction.
190
+ */
109
191
  changed() {
192
+ if (!this.savesFile)
193
+ return Promise.resolve();
110
194
  this.dirty = true;
111
195
  return this.save();
112
196
  }
@@ -115,7 +199,7 @@ class SharedDatabase {
115
199
  * starts after this call, so the file gets each change that came before the call.
116
200
  */
117
201
  save() {
118
- if (this.path === MEMORY_PATH)
202
+ if (!this.savesFile)
119
203
  return Promise.resolve();
120
204
  if (this.next)
121
205
  return this.next;
@@ -126,7 +210,7 @@ class SharedDatabase {
126
210
  return;
127
211
  this.dirty = false;
128
212
  try {
129
- await this.saveFile();
213
+ await this.open.saveFile();
130
214
  }
131
215
  catch (error) {
132
216
  this.dirty = true;
@@ -147,14 +231,13 @@ function fileKey(path) {
147
231
  const absolute = resolve(path);
148
232
  return process.platform === "win32" ? absolute.toLowerCase() : absolute;
149
233
  }
150
- async function openDatabase(path) {
151
- const { db, save } = await createConnection({ dbPath: path });
152
- return new SharedDatabase(path, db, save);
234
+ async function openDatabase(path, engine, busyTimeoutMs) {
235
+ return new SharedDatabase(path, await openEngine(path, engine, busyTimeoutMs));
153
236
  }
154
237
  /** This function opens the database of a path, or gives the open database of the same file. */
155
- async function acquire(path) {
238
+ async function acquire(path, engine, busyTimeoutMs) {
156
239
  if (path === MEMORY_PATH) {
157
- const shared = await openDatabase(path);
240
+ const shared = await openDatabase(path, engine, busyTimeoutMs);
158
241
  shared.users++;
159
242
  return shared;
160
243
  }
@@ -162,19 +245,32 @@ async function acquire(path) {
162
245
  for (;;) {
163
246
  let entry = files.get(key);
164
247
  if (!entry) {
165
- const opening = openDatabase(path);
166
- entry = opening;
167
- files.set(key, opening);
248
+ const opening = openDatabase(path, engine, busyTimeoutMs);
249
+ entry = { engine, database: opening };
250
+ files.set(key, entry);
251
+ const own = entry;
168
252
  opening.then((shared) => opened.set(key, shared), () => {
169
253
  // A failed open does not stay: the next storage tries again
170
- if (files.get(key) === opening)
254
+ if (files.get(key) === own)
171
255
  files.delete(key);
172
256
  });
173
257
  }
174
- const shared = await entry;
258
+ let shared;
259
+ try {
260
+ shared = await entry.database;
261
+ }
262
+ catch (error) {
263
+ // A storage with another engine choice failed to open the file: try with this choice
264
+ if (entry.engine !== engine)
265
+ continue;
266
+ throw error;
267
+ }
175
268
  // The last storage closed the database while this one waited: open the file again
176
269
  if (shared.closed)
177
270
  continue;
271
+ if (engine !== "auto" && shared.engine !== engine) {
272
+ throw new Error(`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}.`);
273
+ }
178
274
  shared.users++;
179
275
  return shared;
180
276
  }
@@ -205,31 +301,10 @@ async function release(shared) {
205
301
  files.delete(key);
206
302
  }
207
303
  }
208
- shared.db.close();
304
+ shared.open.close();
209
305
  if (failure !== undefined)
210
306
  throw failure;
211
307
  }
212
- // ---------------------------------------------------------------------------
213
- // The storage
214
- // ---------------------------------------------------------------------------
215
- const VERSIONS = "_storage_versions";
216
- const LOG = "_storage_log";
217
- const SCHEMA = `
218
- CREATE TABLE IF NOT EXISTS ${VERSIONS} (
219
- collection TEXT PRIMARY KEY,
220
- epoch TEXT NOT NULL,
221
- seq INTEGER NOT NULL,
222
- floor INTEGER NOT NULL
223
- );
224
- CREATE TABLE IF NOT EXISTS ${LOG} (
225
- collection TEXT NOT NULL,
226
- id TEXT NOT NULL,
227
- seq INTEGER NOT NULL,
228
- deleted INTEGER NOT NULL,
229
- PRIMARY KEY (collection, id)
230
- );
231
- CREATE INDEX IF NOT EXISTS ${LOG}_seq ON ${LOG} (collection, seq);
232
- `;
233
308
  /**
234
309
  * A collection storage in a table of a SQLite database (roadmap R45).
235
310
  *
@@ -239,6 +314,7 @@ const SCHEMA = `
239
314
  * await users.set("123", { id: "123", name: "John" });
240
315
  * const user = await users.get("123"); // { id: "123", name: "John" }
241
316
  * const changes = await users.getChangesSince(); // { version, full: true, changed, deleted: [] }
317
+ * await users.getEngine(); // "node" on Node.js 22.13 or later, else "sqljs"
242
318
  * await users.close();
243
319
  * ```
244
320
  */
@@ -247,6 +323,8 @@ export class SqliteStorage {
247
323
  path;
248
324
  /** The table of the items. */
249
325
  table;
326
+ /** The engine choice of the options. `getEngine()` gives the engine that the storage uses. */
327
+ engine;
250
328
  maxTombstones;
251
329
  /** The quoted table name, for the SQL text. */
252
330
  quoted;
@@ -258,9 +336,11 @@ export class SqliteStorage {
258
336
  }
259
337
  this.path = options.path;
260
338
  this.table = checkTableName(options.table);
339
+ this.engine = checkEngine(options.engine ?? "auto");
340
+ const busyTimeoutMs = optionalMs(options.busyTimeout, "busyTimeout") ?? 5000;
261
341
  this.quoted = `"${this.table}"`;
262
342
  this.maxTombstones = Math.max(0, options.maxTombstones ?? 10_000);
263
- this.opening = this.open();
343
+ this.opening = this.open(busyTimeoutMs);
264
344
  // An operation gives the error of the open. This handler only stops an unhandled rejection.
265
345
  this.opening.catch(() => undefined);
266
346
  }
@@ -269,47 +349,58 @@ export class SqliteStorage {
269
349
  //
270
350
  async get(id) {
271
351
  const db = await this.database();
272
- const [row] = rows(db, `SELECT value FROM ${this.quoted} WHERE id = ?`, [checkId(id)]);
352
+ const [row] = db.all(`SELECT value FROM ${this.quoted} WHERE id = ?`, [checkId(id)]);
273
353
  return row === undefined ? undefined : JSON.parse(row.value);
274
354
  }
275
355
  async getAll() {
276
356
  const db = await this.database();
277
- return rows(db, `SELECT value FROM ${this.quoted} ORDER BY rowid`).map((row) => JSON.parse(row.value));
357
+ return db.all(`SELECT value FROM ${this.quoted} ORDER BY rowid`).map((row) => JSON.parse(row.value));
278
358
  }
279
359
  /** Every item with its id, in the order of the first write of each id. */
280
360
  async getEntries() {
281
361
  return this.entries(await this.database());
282
362
  }
363
+ /** The ids of the items, in the order of the entries. The storage does not read the values. */
364
+ async getIds() {
365
+ const db = await this.database();
366
+ return db.all(`SELECT id FROM ${this.quoted} ORDER BY rowid`).map((row) => row.id);
367
+ }
283
368
  /**
284
369
  * The changes after a version (roadmap R28). A version of another storage, of another database
285
370
  * file, or older than the dropped deletes gives all items (`full: true`). A version stays valid
286
- * after a restart, because the change log is in the file.
371
+ * after a restart, because the change log is in the file. The reads run in one read
372
+ * transaction, so a write of another process does not come between them.
287
373
  */
288
374
  async getChangesSince(since) {
289
375
  const db = await this.database();
290
- const { epoch } = await this.opening;
291
- const { seq, floor } = this.counter(db);
292
- const version = `${epoch}:${seq}`;
293
- const from = parseVersion(since, epoch, seq);
294
- if (from === undefined || from < floor) {
295
- return { version, full: true, changed: this.entries(db), deleted: [] };
296
- }
297
- const changed = rows(db, `SELECT l.id AS id, d.value AS value FROM ${LOG} l JOIN ${this.quoted} d ON d.id = l.id
298
- WHERE l.collection = ? AND l.deleted = 0 AND l.seq > ? ORDER BY l.seq`, [this.table, from]).map((row) => [row.id, JSON.parse(row.value)]);
299
- const deleted = rows(db, `SELECT id FROM ${LOG} WHERE collection = ? AND deleted = 1 AND seq > ? ORDER BY seq`, [this.table, from]).map((row) => row.id);
300
- return { version, full: false, changed, deleted };
376
+ return readTransaction(db, () => {
377
+ const { epoch, seq, floor } = this.counter(db);
378
+ const version = `${epoch}:${seq}`;
379
+ const from = parseVersion(since, epoch, seq);
380
+ if (from === undefined || from < floor) {
381
+ return { version, full: true, changed: this.entries(db), deleted: [] };
382
+ }
383
+ const changed = db
384
+ .all(`SELECT l.id AS id, d.value AS value FROM ${LOG} l JOIN ${this.quoted} d ON d.id = l.id
385
+ WHERE l.collection = ? AND l.deleted = 0 AND l.seq > ? ORDER BY l.seq`, [this.table, from])
386
+ .map((row) => [row.id, JSON.parse(row.value)]);
387
+ const deleted = db
388
+ .all(`SELECT id FROM ${LOG} WHERE collection = ? AND deleted = 1 AND seq > ? ORDER BY seq`, [this.table, from])
389
+ .map((row) => row.id);
390
+ return { version, full: false, changed, deleted };
391
+ });
301
392
  }
302
393
  async find(predicate) {
303
394
  return (await this.getAll()).filter((item) => predicate(item));
304
395
  }
305
396
  async has(id) {
306
397
  const db = await this.database();
307
- return rows(db, `SELECT 1 FROM ${this.quoted} WHERE id = ? LIMIT 1`, [checkId(id)]).length > 0;
398
+ return db.all(`SELECT 1 FROM ${this.quoted} WHERE id = ? LIMIT 1`, [checkId(id)]).length > 0;
308
399
  }
309
400
  async size() {
310
401
  const db = await this.database();
311
- const [row] = rows(db, `SELECT COUNT(*) AS n FROM ${this.quoted}`);
312
- return row?.n ?? 0;
402
+ const [row] = db.all(`SELECT COUNT(*) AS n FROM ${this.quoted}`);
403
+ return Number(row?.n ?? 0);
313
404
  }
314
405
  //
315
406
  // ═══ Write Operations ═══
@@ -324,7 +415,7 @@ export class SqliteStorage {
324
415
  }
325
416
  async clear() {
326
417
  await this.write((db, next) => {
327
- for (const { id } of rows(db, `SELECT id FROM ${this.quoted} ORDER BY rowid`))
418
+ for (const { id } of db.all(`SELECT id FROM ${this.quoted} ORDER BY rowid`))
328
419
  this.logChange(db, id, next(), true);
329
420
  db.run(`DELETE FROM ${this.quoted}`);
330
421
  });
@@ -334,6 +425,8 @@ export class SqliteStorage {
334
425
  //
335
426
  /** This method sets several items in one transaction. When one value is not JSON, nothing changes. */
336
427
  async setBatch(items) {
428
+ // After close(), an empty batch rejects too
429
+ this.checkOpen();
337
430
  const texts = items.map(([id, value]) => [checkId(id), toJson(id, value)]);
338
431
  if (texts.length === 0)
339
432
  return;
@@ -344,6 +437,7 @@ export class SqliteStorage {
344
437
  }
345
438
  /** This method deletes several items in one transaction. */
346
439
  async deleteBatch(ids) {
440
+ this.checkOpen();
347
441
  for (const id of ids)
348
442
  checkId(id);
349
443
  if (ids.length === 0)
@@ -358,38 +452,39 @@ export class SqliteStorage {
358
452
  }
359
453
  async getBatch(ids) {
360
454
  const db = await this.database();
455
+ const rows = db.firstOfEach(`SELECT value FROM ${this.quoted} WHERE id = ?`, ids.map((id) => [checkId(id)]));
361
456
  const result = new Map();
362
- const statement = db.prepare(`SELECT value FROM ${this.quoted} WHERE id = ?`);
363
- try {
364
- for (const id of ids) {
365
- statement.bind([checkId(id)]);
366
- if (statement.step())
367
- result.set(id, JSON.parse(statement.getAsObject()["value"]));
368
- statement.reset();
369
- }
370
- }
371
- finally {
372
- statement.free();
373
- }
457
+ rows.forEach((row, index) => {
458
+ if (row !== undefined)
459
+ result.set(ids[index], JSON.parse(row.value));
460
+ });
374
461
  return result;
375
462
  }
376
463
  //
377
464
  // ═══ Lifecycle & Metadata ═══
378
465
  //
466
+ /** The engine that the storage uses: `node` or `sqljs`. With the option `auto`, the open decides it. */
467
+ async getEngine() {
468
+ this.checkOpen();
469
+ const shared = await this.opening;
470
+ this.checkOpen();
471
+ return shared.engine;
472
+ }
379
473
  /**
380
474
  * This method writes the database file. Each write ends after its file write already, so a
381
- * caller needs it only after a failed file write.
475
+ * caller needs it only after a failed file write of the engine `sqljs`. The engine `node`
476
+ * writes each change at its commit.
382
477
  */
383
478
  async flush() {
384
- const { shared } = await this.opening;
385
- if (this.closed)
386
- return;
479
+ this.checkOpen();
480
+ const shared = await this.opening;
481
+ this.checkOpen();
387
482
  await shared.save();
388
483
  }
389
484
  /**
390
485
  * This method writes the file and releases the database. The items stay in the file. A new
391
486
  * storage of the same file and table reads them, and the versions of this storage stay valid.
392
- * After `close()`, each operation throws.
487
+ * After `close()`, each operation rejects, and a second `close()` does nothing.
393
488
  */
394
489
  async close() {
395
490
  if (this.closed)
@@ -397,7 +492,7 @@ export class SqliteStorage {
397
492
  this.closed = true;
398
493
  let shared;
399
494
  try {
400
- ({ shared } = await this.opening);
495
+ shared = await this.opening;
401
496
  }
402
497
  catch {
403
498
  // The open failed: there is nothing to release
@@ -406,81 +501,93 @@ export class SqliteStorage {
406
501
  await release(shared);
407
502
  }
408
503
  async getMetadata() {
504
+ const size = await this.size();
505
+ const engine = await this.getEngine();
409
506
  return {
410
507
  type: "custom",
411
- size: await this.size(),
412
- stats: { backend: "sqlite", table: this.table, memory: this.path === MEMORY_PATH },
508
+ size,
509
+ stats: { backend: "sqlite", engine, table: this.table, memory: this.path === MEMORY_PATH },
413
510
  };
414
511
  }
415
512
  //
416
513
  // ═══ Internal Helpers ═══
417
514
  //
418
515
  /** This method opens the database, and makes the tables of the collection and of the change log. */
419
- async open() {
420
- const shared = await acquire(this.path);
516
+ async open(busyTimeoutMs) {
517
+ const shared = await acquire(this.path, this.engine, busyTimeoutMs);
421
518
  try {
422
- const db = shared.db;
423
- db.exec(SCHEMA);
424
- db.exec(`CREATE TABLE IF NOT EXISTS ${this.quoted} (id TEXT PRIMARY KEY, value TEXT NOT NULL)`);
425
- const columns = new Set(rows(db, `PRAGMA table_info(${this.quoted})`).map((row) => row.name));
426
- if (!columns.has("id") || !columns.has("value")) {
427
- throw new Error(`The table ${this.table} exists, and it does not have the columns id and value of a SqliteStorage`);
428
- }
429
- db.run(`INSERT OR IGNORE INTO ${VERSIONS} (collection, epoch, seq, floor) VALUES (?, ?, 0, 0)`, [
430
- this.table,
431
- randomUUID().replaceAll("-", "").slice(0, 12),
432
- ]);
433
- const [row] = rows(db, `SELECT epoch FROM ${VERSIONS} WHERE collection = ?`, [this.table]);
434
- return { shared, epoch: row.epoch };
519
+ prepareTable(shared.open.db, this.table, newEpoch());
520
+ return shared;
435
521
  }
436
522
  catch (error) {
437
523
  await release(shared).catch(() => undefined);
438
524
  throw error;
439
525
  }
440
526
  }
441
- /** The open database. It throws after `close()`. */
527
+ /** The open database. It throws after `close()`, so the promise of the operation rejects. */
442
528
  async database() {
529
+ const shared = await this.sharedDatabase();
530
+ // The engine node: another process made the file after the open
531
+ shared.open.refresh(copySchema);
532
+ return shared.open.db;
533
+ }
534
+ async sharedDatabase() {
443
535
  this.checkOpen();
444
- const { shared } = await this.opening;
536
+ const shared = await this.opening;
445
537
  this.checkOpen();
446
- return shared.db;
538
+ return shared;
447
539
  }
448
540
  checkOpen() {
449
541
  if (this.closed)
450
542
  throw new Error(`The SQLite storage of the table ${this.table} is closed`);
451
543
  }
452
544
  entries(db) {
453
- return rows(db, `SELECT id, value FROM ${this.quoted} ORDER BY rowid`).map((row) => [row.id, JSON.parse(row.value)]);
545
+ return db.all(`SELECT id, value FROM ${this.quoted} ORDER BY rowid`).map((row) => [row.id, JSON.parse(row.value)]);
454
546
  }
455
547
  counter(db) {
456
- const [row] = rows(db, `SELECT seq, floor FROM ${VERSIONS} WHERE collection = ?`, [this.table]);
457
- return { seq: row?.seq ?? 0, floor: row?.floor ?? 0 };
548
+ const [row] = db.all(`SELECT epoch, seq, floor FROM ${VERSIONS} WHERE collection = ?`, [this.table]);
549
+ return { epoch: row?.epoch ?? "", seq: Number(row?.seq ?? 0), floor: Number(row?.floor ?? 0) };
458
550
  }
459
551
  /**
460
- * One write: the change in a transaction with the change log, then the file write. `next` gives
461
- * the next number of the change log. A write that changes nothing writes no file.
552
+ * One write: the change in a transaction with the change log, then the file write of the engine
553
+ * `sqljs`. `next` gives the next number of the change log. A write that changes nothing writes
554
+ * no file.
555
+ *
556
+ * Before the first write, the engine `node` keeps the database of a missing file in memory, with
557
+ * no item. A write first tests its change there. When it changes an item, the storage makes the
558
+ * file and runs the write in it.
462
559
  */
463
560
  async write(change) {
464
- const db = await this.database();
561
+ const shared = await this.sharedDatabase();
562
+ const { open } = shared;
563
+ open.refresh(copySchema);
564
+ if (open.waiting) {
565
+ const test = this.runWrite(open.db, change, false);
566
+ if (!test.changed)
567
+ return test.value;
568
+ open.makeFile(copySchema);
569
+ }
570
+ const { value, changed } = this.runWrite(open.db, change, true);
571
+ if (changed)
572
+ await shared.changed();
573
+ return value;
574
+ }
575
+ runWrite(db, change, keep) {
465
576
  let changed = false;
466
- const result = transaction(db, () => {
577
+ const value = transaction(db, () => {
467
578
  const counter = this.counter(db);
468
579
  const next = () => {
469
580
  changed = true;
470
581
  return ++counter.seq;
471
582
  };
472
- const value = change(db, next);
583
+ const result = change(db, next);
473
584
  if (changed) {
474
585
  this.trimTombstones(db, counter);
475
586
  db.run(`UPDATE ${VERSIONS} SET seq = ?, floor = ? WHERE collection = ?`, [counter.seq, counter.floor, this.table]);
476
587
  }
477
- return value;
478
- });
479
- if (changed) {
480
- const { shared } = await this.opening;
481
- await shared.changed();
482
- }
483
- return result;
588
+ return result;
589
+ }, keep);
590
+ return { value, changed };
484
591
  }
485
592
  /** This method writes an item. An existing id keeps its place in the order. */
486
593
  put(db, id, text, next) {
@@ -489,8 +596,7 @@ export class SqliteStorage {
489
596
  }
490
597
  /** This method deletes an item. True when it existed. */
491
598
  remove(db, id, next) {
492
- db.run(`DELETE FROM ${this.quoted} WHERE id = ?`, [id]);
493
- if (db.getRowsModified() === 0)
599
+ if (db.run(`DELETE FROM ${this.quoted} WHERE id = ?`, [id]) === 0)
494
600
  return false;
495
601
  this.logChange(db, id, next(), true);
496
602
  return true;
@@ -501,15 +607,16 @@ export class SqliteStorage {
501
607
  }
502
608
  /** This method drops the oldest deletes above the limit, and moves the floor of the change log. */
503
609
  trimTombstones(db, counter) {
504
- const [row] = rows(db, `SELECT COUNT(*) AS n FROM ${LOG} WHERE collection = ? AND deleted = 1`, [this.table]);
505
- const excess = (row?.n ?? 0) - this.maxTombstones;
610
+ const [row] = db.all(`SELECT COUNT(*) AS n FROM ${LOG} WHERE collection = ? AND deleted = 1`, [this.table]);
611
+ const excess = Number(row?.n ?? 0) - this.maxTombstones;
506
612
  if (excess <= 0)
507
613
  return;
508
- const [cut] = rows(db, `SELECT seq FROM ${LOG} WHERE collection = ? AND deleted = 1 ORDER BY seq LIMIT 1 OFFSET ?`, [this.table, excess - 1]);
614
+ const params = [this.table, excess - 1];
615
+ const [cut] = db.all(`SELECT seq FROM ${LOG} WHERE collection = ? AND deleted = 1 ORDER BY seq LIMIT 1 OFFSET ?`, params);
509
616
  if (cut === undefined)
510
617
  return;
511
618
  db.run(`DELETE FROM ${LOG} WHERE collection = ? AND deleted = 1 AND seq <= ?`, [this.table, cut.seq]);
512
- counter.floor = Math.max(counter.floor, cut.seq);
619
+ counter.floor = Math.max(counter.floor, Number(cut.seq));
513
620
  }
514
621
  }
515
622
  /** The number of a version of this collection, or undefined for another or an unknown version. */