@procedures/sqlite 0.4.0 → 0.6.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/CHANGELOG.md +42 -0
- package/README.md +60 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/engines.d.ts +68 -0
- package/dist/engines.d.ts.map +1 -0
- package/dist/engines.js +268 -0
- package/dist/engines.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +2 -11
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +1 -25
- package/dist/module.js.map +1 -1
- package/dist/storage.d.ts +57 -16
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +255 -148
- package/dist/storage.js.map +1 -1
- package/package.json +6 -5
- package/src/engines.ts +360 -0
- package/src/index.ts +10 -2
- package/src/module.ts +1 -27
- package/src/storage.ts +335 -176
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.
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
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 {
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
109
|
+
db.exec(keep ? "COMMIT" : "ROLLBACK");
|
|
86
110
|
return result;
|
|
87
111
|
} catch (error) {
|
|
88
|
-
|
|
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
|
-
*
|
|
122
|
-
* when it opens are not a change: a storage that only reads does not make
|
|
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
|
|
133
|
-
private readonly saveFile: () => Promise<void>,
|
|
243
|
+
readonly open: OpenDatabase,
|
|
134
244
|
) {}
|
|
135
245
|
|
|
136
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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) ===
|
|
330
|
+
if (files.get(key) === own) files.delete(key);
|
|
202
331
|
},
|
|
203
332
|
);
|
|
204
333
|
}
|
|
205
|
-
|
|
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.
|
|
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<
|
|
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] =
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
db
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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] =
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
478
|
-
|
|
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<
|
|
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
|
|
644
|
+
const shared = await this.opening;
|
|
500
645
|
this.checkOpen();
|
|
501
|
-
return shared
|
|
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:
|
|
509
|
-
return
|
|
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:
|
|
515
|
-
const [row] =
|
|
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
|
|
521
|
-
* the next number of the change log. A write that changes nothing writes
|
|
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:
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
570
|
-
const [row] =
|
|
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 [
|
|
574
|
-
|
|
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
|
|