@procedures/sqlite 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +126 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/index.d.ts +3 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +6 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/module.d.ts +2 -1
  9. package/dist/module.d.ts.map +1 -1
  10. package/dist/module.js +93 -9
  11. package/dist/module.js.map +1 -1
  12. package/dist/procedures/db/backup.d.ts +19 -0
  13. package/dist/procedures/db/backup.d.ts.map +1 -0
  14. package/dist/procedures/db/backup.js +61 -0
  15. package/dist/procedures/db/backup.js.map +1 -0
  16. package/dist/procedures/db/index.d.ts +6 -0
  17. package/dist/procedures/db/index.d.ts.map +1 -0
  18. package/dist/procedures/db/index.js +6 -0
  19. package/dist/procedures/db/index.js.map +1 -0
  20. package/dist/procedures/db/migrate.d.ts +24 -0
  21. package/dist/procedures/db/migrate.d.ts.map +1 -0
  22. package/dist/procedures/db/migrate.js +110 -0
  23. package/dist/procedures/db/migrate.js.map +1 -0
  24. package/dist/procedures/db/schema.d.ts +13 -0
  25. package/dist/procedures/db/schema.d.ts.map +1 -0
  26. package/dist/procedures/db/schema.js +33 -0
  27. package/dist/procedures/db/schema.js.map +1 -0
  28. package/dist/procedures/db/shared.d.ts +25 -0
  29. package/dist/procedures/db/shared.d.ts.map +1 -0
  30. package/dist/procedures/db/shared.js +105 -0
  31. package/dist/procedures/db/shared.js.map +1 -0
  32. package/dist/procedures/db/tables.d.ts +12 -0
  33. package/dist/procedures/db/tables.d.ts.map +1 -0
  34. package/dist/procedures/db/tables.js +22 -0
  35. package/dist/procedures/db/tables.js.map +1 -0
  36. package/dist/procedures/db/transaction.d.ts +14 -0
  37. package/dist/procedures/db/transaction.d.ts.map +1 -0
  38. package/dist/procedures/db/transaction.js +44 -0
  39. package/dist/procedures/db/transaction.js.map +1 -0
  40. package/dist/storage.d.ts +123 -0
  41. package/dist/storage.d.ts.map +1 -0
  42. package/dist/storage.js +525 -0
  43. package/dist/storage.js.map +1 -0
  44. package/dist/types.d.ts +131 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +109 -0
  47. package/dist/types.js.map +1 -1
  48. package/package.json +9 -3
  49. package/src/index.ts +26 -0
  50. package/src/module.ts +120 -9
  51. package/src/procedures/db/backup.ts +62 -0
  52. package/src/procedures/db/index.ts +5 -0
  53. package/src/procedures/db/migrate.ts +121 -0
  54. package/src/procedures/db/schema.ts +71 -0
  55. package/src/procedures/db/shared.ts +109 -0
  56. package/src/procedures/db/tables.ts +27 -0
  57. package/src/procedures/db/transaction.ts +43 -0
  58. package/src/storage.ts +591 -0
  59. package/src/types.ts +268 -0
package/src/storage.ts ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * A collection storage in SQLite (roadmap R45).
3
+ *
4
+ * `SqliteStorage` keeps the items of one collection in one table of a SQLite database: a file,
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.
8
+ *
9
+ * - The table of a collection has the columns `id` (the key) and `value` (the JSON text of the
10
+ * item). A value goes through `JSON.stringify` and `JSON.parse`.
11
+ * - A change log gives `getChangesSince()`. The table `_storage_log` has the last change of each
12
+ * id, and `_storage_versions` has the version of each collection: `<epoch>:<seq>`, as
13
+ * `InMemoryStorage` has. The epoch stays in the file, so a version stays valid after a restart.
14
+ * - 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
+ */
20
+
21
+ import { randomUUID } from "node:crypto";
22
+ 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:";
28
+
29
+ /** The options of a `SqliteStorage`. */
30
+ export interface SqliteStorageOptions {
31
+ /**
32
+ * 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.
34
+ */
35
+ path: string;
36
+ /**
37
+ * The table of the collection. The name starts with a letter and has only letters, digits and
38
+ * underscores, 64 characters or fewer. A name that starts with `sqlite_` is not permitted.
39
+ */
40
+ table: string;
41
+ /**
42
+ * The number of deletes that the change log keeps. After more deletes, the log drops the
43
+ * oldest ones, and `getChangesSince` of an older version gives all items.
44
+ * @default 10000
45
+ */
46
+ maxTombstones?: number;
47
+ }
48
+
49
+ /** The pattern of a table name: a letter, then letters, digits and underscores. */
50
+ const TABLE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
51
+
52
+ /**
53
+ * This function gives the name when it is a permitted table name. Else it throws a `TypeError`. The name goes
54
+ * into the SQL text, so the check is strict.
55
+ */
56
+ export function checkTableName(name: unknown): string {
57
+ if (typeof name !== "string" || !TABLE_NAME.test(name) || name.toLowerCase().startsWith("sqlite_")) {
58
+ throw new TypeError(
59
+ `Invalid table name ${JSON.stringify(name)}: use a letter, then letters, digits and underscores (64 characters or fewer), not the prefix sqlite_`,
60
+ );
61
+ }
62
+ return name;
63
+ }
64
+
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");
83
+ try {
84
+ const result = work();
85
+ db.run("COMMIT");
86
+ return result;
87
+ } catch (error) {
88
+ db.run("ROLLBACK");
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ /** The JSON text of a value. A value without JSON text is a `TypeError`. */
94
+ function toJson(id: string, value: unknown): string {
95
+ let text: string | undefined;
96
+ try {
97
+ text = JSON.stringify(value);
98
+ } catch (error) {
99
+ throw new TypeError(`The value of ${JSON.stringify(id)} is not JSON: ${(error as Error).message}`);
100
+ }
101
+ if (text === undefined) throw new TypeError(`The value of ${JSON.stringify(id)} is not JSON: ${typeof value}`);
102
+ return text;
103
+ }
104
+
105
+ /** An id must be a string: the `id` column holds text. */
106
+ function checkId(id: unknown): string {
107
+ if (typeof id !== "string") throw new TypeError(`An id must be a string, not ${typeof id}`);
108
+ return id;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // The databases of the files, shared in the process
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** One open database and its file writes. */
116
+ class SharedDatabase {
117
+ /** The storages that use the database. */
118
+ users = 0;
119
+ closed = false;
120
+ /**
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.
123
+ */
124
+ dirty = false;
125
+ /** The last file write. Its error is in the promise of the write that asked for it. */
126
+ private saving: Promise<void> = Promise.resolve();
127
+ /** A file write that waits for the running one. The writes that come before it starts share it. */
128
+ private next: Promise<void> | undefined;
129
+
130
+ constructor(
131
+ readonly path: string,
132
+ readonly db: Database,
133
+ private readonly saveFile: () => Promise<void>,
134
+ ) {}
135
+
136
+ /** This method records a change, and gives the promise of the file write that holds it. */
137
+ changed(): Promise<void> {
138
+ this.dirty = true;
139
+ return this.save();
140
+ }
141
+
142
+ /**
143
+ * This method writes the file after the running file write. It gives the promise of a file write that
144
+ * starts after this call, so the file gets each change that came before the call.
145
+ */
146
+ save(): Promise<void> {
147
+ if (this.path === MEMORY_PATH) return Promise.resolve();
148
+ if (this.next) return this.next;
149
+ const run = this.saving.then(async () => {
150
+ // The file write reads the database now: a later change needs a new file write
151
+ this.next = undefined;
152
+ if (!this.dirty) return;
153
+ this.dirty = false;
154
+ try {
155
+ await this.saveFile();
156
+ } catch (error) {
157
+ this.dirty = true;
158
+ throw error;
159
+ }
160
+ });
161
+ this.next = run;
162
+ this.saving = run.catch(() => undefined);
163
+ return run;
164
+ }
165
+ }
166
+
167
+ /** The databases of the files that open or are open, by the key of the file. */
168
+ const files = new Map<string, Promise<SharedDatabase>>();
169
+ /** The open databases of the files, by the key of the file. */
170
+ const opened = new Map<string, SharedDatabase>();
171
+
172
+ /** The key of a file. Windows file names do not depend on case. */
173
+ function fileKey(path: string): string {
174
+ const absolute = resolve(path);
175
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
176
+ }
177
+
178
+ async function openDatabase(path: string): Promise<SharedDatabase> {
179
+ const { db, save } = await createConnection({ dbPath: path });
180
+ return new SharedDatabase(path, db, save);
181
+ }
182
+
183
+ /** 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> {
185
+ if (path === MEMORY_PATH) {
186
+ const shared = await openDatabase(path);
187
+ shared.users++;
188
+ return shared;
189
+ }
190
+ const key = fileKey(path);
191
+ for (;;) {
192
+ let entry = files.get(key);
193
+ if (!entry) {
194
+ const opening = openDatabase(path);
195
+ entry = opening;
196
+ files.set(key, opening);
197
+ opening.then(
198
+ (shared) => opened.set(key, shared),
199
+ () => {
200
+ // A failed open does not stay: the next storage tries again
201
+ if (files.get(key) === opening) files.delete(key);
202
+ },
203
+ );
204
+ }
205
+ const shared = await entry;
206
+ // The last storage closed the database while this one waited: open the file again
207
+ if (shared.closed) continue;
208
+ shared.users++;
209
+ return shared;
210
+ }
211
+ }
212
+
213
+ /** This function releases a database. The last storage writes the file and closes the database. */
214
+ async function release(shared: SharedDatabase): Promise<void> {
215
+ shared.users--;
216
+ if (shared.users > 0) return;
217
+ let failure: unknown;
218
+ try {
219
+ await shared.save();
220
+ } catch (error) {
221
+ failure = error;
222
+ }
223
+ // Another storage of the file opened while the file write ran
224
+ if (shared.users > 0) {
225
+ if (failure !== undefined) throw failure;
226
+ return;
227
+ }
228
+ shared.closed = true;
229
+ if (shared.path !== MEMORY_PATH) {
230
+ const key = fileKey(shared.path);
231
+ if (opened.get(key) === shared) {
232
+ opened.delete(key);
233
+ files.delete(key);
234
+ }
235
+ }
236
+ shared.db.close();
237
+ if (failure !== undefined) throw failure;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // The storage
242
+ // ---------------------------------------------------------------------------
243
+
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
+ interface Counter {
265
+ seq: number;
266
+ floor: number;
267
+ }
268
+
269
+ /**
270
+ * A collection storage in a table of a SQLite database (roadmap R45).
271
+ *
272
+ * @example
273
+ * ```typescript
274
+ * const users = new SqliteStorage<User>({ path: "./data/app.db", table: "users" });
275
+ * await users.set("123", { id: "123", name: "John" });
276
+ * const user = await users.get("123"); // { id: "123", name: "John" }
277
+ * const changes = await users.getChangesSince(); // { version, full: true, changed, deleted: [] }
278
+ * await users.close();
279
+ * ```
280
+ */
281
+ export class SqliteStorage<T> implements CollectionStorage<T> {
282
+ /** The database file, or `:memory:`. */
283
+ readonly path: string;
284
+ /** The table of the items. */
285
+ readonly table: string;
286
+ private readonly maxTombstones: number;
287
+ /** The quoted table name, for the SQL text. */
288
+ private readonly quoted: string;
289
+ private readonly opening: Promise<{ shared: SharedDatabase; epoch: string }>;
290
+ private closed = false;
291
+
292
+ constructor(options: SqliteStorageOptions) {
293
+ if (typeof options?.path !== "string" || options.path === "") {
294
+ throw new TypeError("SqliteStorage needs a path: a database file, or :memory:");
295
+ }
296
+ this.path = options.path;
297
+ this.table = checkTableName(options.table);
298
+ this.quoted = `"${this.table}"`;
299
+ this.maxTombstones = Math.max(0, options.maxTombstones ?? 10_000);
300
+ this.opening = this.open();
301
+ // An operation gives the error of the open. This handler only stops an unhandled rejection.
302
+ this.opening.catch(() => undefined);
303
+ }
304
+
305
+ //
306
+ // ═══ Read Operations ═══
307
+ //
308
+
309
+ async get(id: string): Promise<T | undefined> {
310
+ const db = await this.database();
311
+ const [row] = rows<{ value: string }>(db, `SELECT value FROM ${this.quoted} WHERE id = ?`, [checkId(id)]);
312
+ return row === undefined ? undefined : (JSON.parse(row.value) as T);
313
+ }
314
+
315
+ async getAll(): Promise<T[]> {
316
+ 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);
318
+ }
319
+
320
+ /** Every item with its id, in the order of the first write of each id. */
321
+ async getEntries(): Promise<Array<[string, T]>> {
322
+ return this.entries(await this.database());
323
+ }
324
+
325
+ /**
326
+ * The changes after a version (roadmap R28). A version of another storage, of another database
327
+ * 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.
329
+ */
330
+ async getChangesSince(since?: string): Promise<StorageChanges<T>> {
331
+ 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 };
351
+ }
352
+
353
+ async find(predicate: (item: T) => boolean): Promise<T[]> {
354
+ return (await this.getAll()).filter((item) => predicate(item));
355
+ }
356
+
357
+ async has(id: string): Promise<boolean> {
358
+ const db = await this.database();
359
+ return rows(db, `SELECT 1 FROM ${this.quoted} WHERE id = ? LIMIT 1`, [checkId(id)]).length > 0;
360
+ }
361
+
362
+ async size(): Promise<number> {
363
+ const db = await this.database();
364
+ const [row] = rows<{ n: number }>(db, `SELECT COUNT(*) AS n FROM ${this.quoted}`);
365
+ return row?.n ?? 0;
366
+ }
367
+
368
+ //
369
+ // ═══ Write Operations ═══
370
+ //
371
+
372
+ async set(id: string, value: T): Promise<void> {
373
+ const text = toJson(checkId(id), value);
374
+ await this.write((db, next) => this.put(db, id, text, next));
375
+ }
376
+
377
+ async delete(id: string): Promise<boolean> {
378
+ checkId(id);
379
+ return this.write((db, next) => this.remove(db, id, next));
380
+ }
381
+
382
+ async clear(): Promise<void> {
383
+ 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);
385
+ db.run(`DELETE FROM ${this.quoted}`);
386
+ });
387
+ }
388
+
389
+ //
390
+ // ═══ Bulk Operations ═══
391
+ //
392
+
393
+ /** This method sets several items in one transaction. When one value is not JSON, nothing changes. */
394
+ async setBatch(items: Array<[string, T]>): Promise<void> {
395
+ const texts = items.map(([id, value]): [string, string] => [checkId(id), toJson(id, value)]);
396
+ if (texts.length === 0) return;
397
+ await this.write((db, next) => {
398
+ for (const [id, text] of texts) this.put(db, id, text, next);
399
+ });
400
+ }
401
+
402
+ /** This method deletes several items in one transaction. */
403
+ async deleteBatch(ids: string[]): Promise<number> {
404
+ for (const id of ids) checkId(id);
405
+ if (ids.length === 0) return 0;
406
+ return this.write((db, next) => {
407
+ let deleted = 0;
408
+ for (const id of ids) if (this.remove(db, id, next)) deleted++;
409
+ return deleted;
410
+ });
411
+ }
412
+
413
+ async getBatch(ids: string[]): Promise<Map<string, T>> {
414
+ const db = await this.database();
415
+ 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
+ }
426
+ return result;
427
+ }
428
+
429
+ //
430
+ // ═══ Lifecycle & Metadata ═══
431
+ //
432
+
433
+ /**
434
+ * 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.
436
+ */
437
+ async flush(): Promise<void> {
438
+ const { shared } = await this.opening;
439
+ if (this.closed) return;
440
+ await shared.save();
441
+ }
442
+
443
+ /**
444
+ * This method writes the file and releases the database. The items stay in the file. A new
445
+ * storage of the same file and table reads them, and the versions of this storage stay valid.
446
+ * After `close()`, each operation throws.
447
+ */
448
+ async close(): Promise<void> {
449
+ if (this.closed) return;
450
+ this.closed = true;
451
+ let shared: SharedDatabase;
452
+ try {
453
+ ({ shared } = await this.opening);
454
+ } catch {
455
+ // The open failed: there is nothing to release
456
+ return;
457
+ }
458
+ await release(shared);
459
+ }
460
+
461
+ async getMetadata(): Promise<StorageMetadata> {
462
+ return {
463
+ type: "custom",
464
+ size: await this.size(),
465
+ stats: { backend: "sqlite", table: this.table, memory: this.path === MEMORY_PATH },
466
+ };
467
+ }
468
+
469
+ //
470
+ // ═══ Internal Helpers ═══
471
+ //
472
+
473
+ /** 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);
476
+ 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 };
490
+ } catch (error) {
491
+ await release(shared).catch(() => undefined);
492
+ throw error;
493
+ }
494
+ }
495
+
496
+ /** The open database. It throws after `close()`. */
497
+ private async database(): Promise<Database> {
498
+ this.checkOpen();
499
+ const { shared } = await this.opening;
500
+ this.checkOpen();
501
+ return shared.db;
502
+ }
503
+
504
+ private checkOpen(): void {
505
+ if (this.closed) throw new Error(`The SQLite storage of the table ${this.table} is closed`);
506
+ }
507
+
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(
510
+ (row): [string, T] => [row.id, JSON.parse(row.value) as T],
511
+ );
512
+ }
513
+
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 };
517
+ }
518
+
519
+ /**
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.
522
+ */
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();
542
+ }
543
+ return result;
544
+ }
545
+
546
+ /** 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 {
548
+ db.run(`INSERT INTO ${this.quoted} (id, value) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value`, [id, text]);
549
+ this.logChange(db, id, next(), false);
550
+ }
551
+
552
+ /** 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;
556
+ this.logChange(db, id, next(), true);
557
+ return true;
558
+ }
559
+
560
+ private logChange(db: Database, id: string, seq: number, deleted: boolean): void {
561
+ db.run(
562
+ `INSERT INTO ${LOG} (collection, id, seq, deleted) VALUES (?, ?, ?, ?)
563
+ ON CONFLICT(collection, id) DO UPDATE SET seq = excluded.seq, deleted = excluded.deleted`,
564
+ [this.table, id, seq, deleted ? 1 : 0],
565
+ );
566
+ }
567
+
568
+ /** 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;
572
+ 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
+ );
578
+ if (cut === undefined) return;
579
+ 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);
581
+ }
582
+ }
583
+
584
+ /** The number of a version of this collection, or undefined for another or an unknown version. */
585
+ function parseVersion(since: string | undefined, epoch: string, seq: number): number | undefined {
586
+ if (since === undefined) return undefined;
587
+ const separator = since.lastIndexOf(":");
588
+ if (separator < 0 || since.slice(0, separator) !== epoch) return undefined;
589
+ const at = Number(since.slice(separator + 1));
590
+ return Number.isInteger(at) && at >= 0 && at <= seq ? at : undefined;
591
+ }