@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/CHANGELOG.md +32 -0
- package/README.md +58 -5
- 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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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 +9 -1
- package/src/storage.ts +335 -176
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.
|
|
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
|
import { randomUUID } from "node:crypto";
|
|
21
24
|
import { resolve } from "node:path";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
export
|
|
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
|
-
/**
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
50
|
+
db.exec(keep ? "COMMIT" : "ROLLBACK");
|
|
57
51
|
return result;
|
|
58
52
|
}
|
|
59
53
|
catch (error) {
|
|
60
|
-
|
|
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
|
-
|
|
90
|
-
saveFile;
|
|
162
|
+
open;
|
|
91
163
|
/** The storages that use the database. */
|
|
92
164
|
users = 0;
|
|
93
165
|
closed = false;
|
|
94
166
|
/**
|
|
95
|
-
*
|
|
96
|
-
* when it opens are not a change: a storage that only reads does not make
|
|
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,
|
|
176
|
+
constructor(path, open) {
|
|
104
177
|
this.path = path;
|
|
105
|
-
this.
|
|
106
|
-
|
|
178
|
+
this.open = open;
|
|
179
|
+
}
|
|
180
|
+
get engine() {
|
|
181
|
+
return this.open.engine;
|
|
107
182
|
}
|
|
108
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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) ===
|
|
254
|
+
if (files.get(key) === own)
|
|
171
255
|
files.delete(key);
|
|
172
256
|
});
|
|
173
257
|
}
|
|
174
|
-
|
|
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.
|
|
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] =
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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] =
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
423
|
-
|
|
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
|
|
536
|
+
const shared = await this.opening;
|
|
445
537
|
this.checkOpen();
|
|
446
|
-
return shared
|
|
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
|
|
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] =
|
|
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
|
|
461
|
-
* the next number of the change log. A write that changes nothing writes
|
|
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
|
|
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
|
|
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
|
|
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
|
|
478
|
-
});
|
|
479
|
-
|
|
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] =
|
|
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
|
|
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. */
|