@zukhruf/sqlite-snapshot 0.0.0-stage → 0.3.32

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezzabuzaid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # @zukhruf/sqlite-snapshot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Some programs keep their data in a SQLite database, and write it while they run. Another program can read that database, but the reader must not see half of a change. For example, it must not read a row from one table before a commit and a row from another table after it. Also, the newest rows can be in the WAL file, not in the database file. This package opens such a database read-only, and holds one moment with a read transaction. Thus each query of the snapshot sees the same moment, and the tables agree. This is also known as snapshot isolation of a WAL reader.
4
+
5
+ The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
6
+
7
+ ## Use it
8
+
9
+ This project is an experiment. It uses only `node:sqlite`.
10
+
11
+ ```sh
12
+ npm install @zukhruf/sqlite-snapshot
13
+ ```
14
+
15
+ ```ts
16
+ import { mkdtempDisposable } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { DatabaseSync } from 'node:sqlite';
20
+
21
+ import { Snapshot } from '@zukhruf/sqlite-snapshot';
22
+
23
+ class Unavailable extends Error {
24
+ constructor(path: string, cause: unknown) {
25
+ super(`Cannot read ${path}`, { cause });
26
+ }
27
+ }
28
+
29
+ class MissingColumns extends Error {
30
+ constructor(path: string, missing: readonly string[]) {
31
+ super(`${path} has no ${missing.join(', ')}`);
32
+ }
33
+ }
34
+
35
+ await using scratch = await mkdtempDisposable(join(tmpdir(), 'snapshot-'));
36
+ const path = join(scratch.path, 'notes.sqlite');
37
+
38
+ // The owner: another connection that writes the database through a WAL.
39
+ using owner = new DatabaseSync(path);
40
+ owner.exec(`PRAGMA journal_mode = WAL;
41
+ CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT);
42
+ INSERT INTO notes (title) VALUES ('first')`);
43
+
44
+ {
45
+ using snapshot = new Snapshot(path, Unavailable);
46
+ snapshot.requireColumns({ notes: ['id', 'title'] }, MissingColumns);
47
+ owner.exec("INSERT INTO notes (title) VALUES ('second')");
48
+ console.log(snapshot.all('SELECT title FROM notes'));
49
+ }
50
+
51
+ using next = new Snapshot(path, Unavailable);
52
+ console.log(next.missingColumns({ notes: ['title', 'body'] }));
53
+ console.log(next.all('SELECT count(*) AS notes FROM notes'));
54
+
55
+ try {
56
+ new Snapshot(join(scratch.path, 'missing', 'notes.sqlite'), Unavailable);
57
+ } catch (error) {
58
+ console.log(error instanceof Unavailable);
59
+ }
60
+ ```
61
+
62
+ Output:
63
+
64
+ ```
65
+ [ [Object: null prototype] { title: 'first' } ]
66
+ [ 'notes.body' ]
67
+ [ [Object: null prototype] { notes: 2 } ]
68
+ true
69
+ ```
70
+
71
+ ## The rules
72
+
73
+ - `new Snapshot(path, unavailable)` opens the database read-only. It runs `BEGIN` and a first read at once, so the snapshot holds the moment when it opens, not the moment of the first query of the caller.
74
+ - The snapshot never opens the file with `immutable`. The owner keeps a persistent WAL, and the newest rows are in it. [ADR 0001](./docs/adr/0001-never-immutable-and-held-only-while-reading.md) tells why.
75
+ - Hold a snapshot only while you read. An open read transaction stops the checkpoint of the owner, and then the WAL of the owner grows. Dispose the snapshot with `using`, or call `[Symbol.dispose]()`.
76
+ - `all(sql, ...parameters)` gives all rows. `iterate(sql, ...parameters)` gives the rows one at a time, for a table that is too large to hold in memory.
77
+ - `requireColumns(columns, schema)` checks the columns that the reader needs, as `{ table: [column, ...] }`. When one is missing, it disposes the snapshot and throws `new schema(path, missing)`.
78
+ - `missingColumns(columns)` gives the missing columns as `table.column`, and keeps the snapshot open. A reader whose queries use different tables checks each query with it.
79
+ - The caller gives the error classes. Thus each error names what the caller needs, for example the access that the user must give.
80
+ - Dispose commits the read transaction and closes the connection. A second dispose does nothing.
81
+
82
+ ## Errors
83
+
84
+ | Error | When |
85
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `new unavailable(path, cause)` | SQLite cannot open the file: `SQLITE_CANTOPEN` (14), for a missing file or a file that the system denies, a privacy denial too. |
87
+ | `new schema(path, missing)` | `requireColumns` finds a missing column. The snapshot is closed. |
88
+ | The error of SQLite | All other failures, for example a file that is not a database. The constructor closes the connection before it throws. |
89
+
90
+ ## The same concepts in other systems
91
+
92
+ | System | What it does |
93
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
94
+ | SQLite WAL readers | A read transaction sees the database as it was at its first read, also while a writer commits. An open read stops the checkpoint at its end mark. See "Write-Ahead Logging" in the SQLite documentation. |
95
+ | Datasette `immutable` mode | It opens a file with `immutable=1`, for a file that nobody writes. SQLite then skips the locks and the WAL. This package is the opposite case: the owner writes the file. |
96
+ | `sqlite3 -readonly` | It opens a database read-only. Each statement outside a transaction can see a different moment. |
97
+
98
+ ## Documentation
99
+
100
+ - [0001 Never immutable, and held only while reading](./docs/adr/0001-never-immutable-and-held-only-while-reading.md)
101
+ - [Code copied into this package](./docs/copied-code.md)
102
+
103
+ ## Development
104
+
105
+ This package is part of the [zukhruf](../../../README.md) workspace. Run the commands from the workspace root.
106
+
107
+ ```sh
108
+ npm install
109
+ npx nx run sqlite-snapshot:test # builds, then runs the tests in src/
110
+ npx nx run sqlite-snapshot:typecheck # formats, lints, then type checks
111
+ npx nx run sqlite-snapshot:build # compiles src/ to dist/
112
+ ```
113
+
114
+ ```
115
+ src/
116
+ index.ts the exports
117
+ snapshot.ts Snapshot, SnapshotColumns, UnavailableError and SchemaError
118
+ snapshot.test.ts the tests, on real SQLite files
119
+ ```
@@ -0,0 +1,2 @@
1
+ export { type SchemaError, Snapshot, type SnapshotColumns, type UnavailableError, } from './snapshot.ts';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAChB,QAAQ,EACR,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Snapshot, } from "./snapshot.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,QAAQ,GAGT,MAAM,eAAe,CAAC"}
@@ -0,0 +1,15 @@
1
+ import { type SQLInputValue, type SQLOutputValue } from 'node:sqlite';
2
+ export type UnavailableError = new (path: string, cause: unknown) => Error;
3
+ export type SchemaError = new (path: string, missing: readonly string[]) => Error;
4
+ export type SnapshotColumns = Readonly<Record<string, readonly string[]>>;
5
+ export declare class Snapshot implements Disposable {
6
+ #private;
7
+ readonly path: string;
8
+ constructor(path: string, unavailable: UnavailableError);
9
+ requireColumns(columns: SnapshotColumns, schema: SchemaError): void;
10
+ missingColumns(columns: SnapshotColumns): string[];
11
+ all(sql: string, ...parameters: SQLInputValue[]): Record<string, SQLOutputValue>[];
12
+ iterate(sql: string, ...parameters: SQLInputValue[]): IteratorObject<Record<string, SQLOutputValue>, undefined>;
13
+ [Symbol.dispose](): void;
14
+ }
15
+ //# sourceMappingURL=snapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAIrB,MAAM,MAAM,gBAAgB,GAAG,KAAK,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,KAAK,CAAC;AAI3E,MAAM,MAAM,WAAW,GAAG,KACxB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,SAAS,MAAM,EAAE,KACvB,KAAK,CAAC;AAGX,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;AAyB1E,qBAAa,QAAS,YAAW,UAAU;;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAGV,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,gBAAgB;IAgBvD,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI;IAUnE,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,EAAE;IAalD,GAAG,CACD,GAAG,EAAE,MAAM,EACX,GAAG,UAAU,EAAE,aAAa,EAAE,GAC7B,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE;IAKnC,OAAO,CACL,GAAG,EAAE,MAAM,EACX,GAAG,UAAU,EAAE,aAAa,EAAE,GAC7B,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,SAAS,CAAC;IAI5D,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAKzB"}
@@ -0,0 +1,75 @@
1
+ import { DatabaseSync, } from 'node:sqlite';
2
+ function open(path, unavailable) {
3
+ try {
4
+ return new DatabaseSync(path, { readOnly: true });
5
+ }
6
+ catch (cause) {
7
+ if (cause instanceof Error &&
8
+ 'errcode' in cause &&
9
+ // SQLite's CANTOPEN (14). A missing or denied file fails the open()
10
+ // syscall, and SQLite reports that as CANTOPEN, a privacy denial too.
11
+ // SQLite gives AUTH only for an authorizer, and this private connection
12
+ // has none. Any other failure is not one that access can fix.
13
+ cause.errcode === 14)
14
+ throw new unavailable(path, cause);
15
+ throw cause;
16
+ }
17
+ }
18
+ // A SQLite database another process owns and writes while it runs, read
19
+ // read-only and pinned to one moment by a read transaction, so its tables
20
+ // agree. Never opened immutable: the owner keeps a persistent WAL, where the
21
+ // newest rows live. Hold it only while reading: an open read stops the owner
22
+ // checkpointing its WAL.
23
+ export class Snapshot {
24
+ path;
25
+ #database;
26
+ constructor(path, unavailable) {
27
+ this.path = path;
28
+ this.#database = open(path, unavailable);
29
+ try {
30
+ // The first read pins the snapshot, so it is taken now rather than at
31
+ // the caller's first query; a file that is no database fails here.
32
+ this.#database.exec('BEGIN');
33
+ this.#database.prepare('SELECT 1 FROM sqlite_schema LIMIT 1').get();
34
+ }
35
+ catch (cause) {
36
+ this.#database.close();
37
+ throw cause;
38
+ }
39
+ }
40
+ // Refuses a layout without these columns, and closes the database: the
41
+ // check a reader makes while it opens the snapshot.
42
+ requireColumns(columns, schema) {
43
+ const missing = this.missingColumns(columns);
44
+ if (missing.length === 0)
45
+ return;
46
+ this[Symbol.dispose]();
47
+ throw new schema(this.path, missing);
48
+ }
49
+ // The columns, as table.column, that this layout lacks. A reader whose
50
+ // reads use different tables checks each read with it and keeps the
51
+ // snapshot open for the others.
52
+ missingColumns(columns) {
53
+ return Object.entries(columns).flatMap(([table, names]) => {
54
+ const present = new Set(this.all('SELECT name FROM pragma_table_info(?)', table).map((column) => column.name));
55
+ return names
56
+ .filter((name) => !present.has(name))
57
+ .map((name) => `${table}.${name}`);
58
+ });
59
+ }
60
+ all(sql, ...parameters) {
61
+ return this.#database.prepare(sql).all(...parameters);
62
+ }
63
+ // Rows one at a time, for a table too large to hold whole.
64
+ iterate(sql, ...parameters) {
65
+ return this.#database.prepare(sql).iterate(...parameters);
66
+ }
67
+ [Symbol.dispose]() {
68
+ if (!this.#database.isOpen)
69
+ return;
70
+ if (this.#database.isTransaction)
71
+ this.#database.exec('COMMIT');
72
+ this.#database.close();
73
+ }
74
+ }
75
+ //# sourceMappingURL=snapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@zukhruf/sqlite-snapshot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.32",
4
+ "description": "A read-only snapshot of a SQLite database that another process owns and writes. It pins one moment with a read transaction, so the tables agree, and it never opens the file immutable, so it sees the newest rows in the WAL. It checks the columns that a reader needs, and gives the caller's own errors for a missing file or a missing column.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/JanuaryLabs/zukhruf",
9
+ "directory": "packages/sqlite/snapshot"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/JanuaryLabs/zukhruf/issues"
13
+ },
14
+ "homepage": "https://github.com/JanuaryLabs/zukhruf/tree/main/packages/sqlite/snapshot#readme",
15
+ "keywords": [
16
+ "sqlite",
17
+ "node-sqlite",
18
+ "snapshot",
19
+ "read-transaction",
20
+ "wal",
21
+ "read-only"
22
+ ],
23
+ "type": "module",
24
+ "exports": {
25
+ "./package.json": "./package.json",
26
+ ".": {
27
+ "@zukhruf/source": "./src/index.ts",
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "!dist/spec/**",
36
+ "!**/*.tsbuildinfo",
37
+ "!**/*.test.*"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }