@crvouga/sqlite-mem 1.8.0 → 1.9.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/README.md CHANGED
@@ -41,7 +41,7 @@ Requires Node.js ≥ 20 or Bun ≥ 1.1. The published package is **ESM only** (`
41
41
  ## Usage
42
42
 
43
43
  ```ts
44
- import { Database } from "@crvouga/sqlite-mem";
44
+ import { Database, Snapshot } from "@crvouga/sqlite-mem";
45
45
 
46
46
  const db = new Database();
47
47
 
@@ -57,9 +57,10 @@ db.prepare(`INSERT INTO users (name) VALUES (?)`).run("Alice");
57
57
  const users = db.query<{ id: number; name: string }>(`SELECT * FROM users`);
58
58
  console.log(users);
59
59
 
60
- const snap = db.snapshot();
61
- const db2 = new Database();
62
- db2.restore(snap);
60
+ const seed = db.snapshot();
61
+ const db2 = seed.open();
62
+ const bytes = seed.encode();
63
+ const db3 = Snapshot.decode(bytes).open();
63
64
  ```
64
65
 
65
66
  All methods are **synchronous** — do not `await` them. Browser and Node/Bun share the same in-memory JS surface (no filesystem; `ATTACH` opens a new empty in-memory schema, not a file).
@@ -79,7 +80,7 @@ From the repo root after that install: `bun run example`.
79
80
  ## API
80
81
 
81
82
  ```ts
82
- import { Database, SqliteError } from "@crvouga/sqlite-mem";
83
+ import { Database, Snapshot, SqliteError } from "@crvouga/sqlite-mem";
83
84
 
84
85
  interface DatabaseOptions {
85
86
  seed?: number | bigint; // default 1 — ignored when random is "os"
@@ -93,8 +94,7 @@ interface Database {
93
94
  query<T = QueryRow>(sql: string, params?: BindValue[]): T[];
94
95
  prepare(sql: string): Statement;
95
96
  transaction<T>(fn: () => T): T;
96
- snapshot(): Uint8Array;
97
- restore(snapshot: Uint8Array): void;
97
+ snapshot(): Snapshot;
98
98
  close(): void;
99
99
  [Symbol.dispose]?(): void; // alias for close() when Symbol.dispose exists
100
100
  readonly changes: number;
@@ -121,6 +121,12 @@ interface ResultSet {
121
121
  lastInsertRowid: number | bigint;
122
122
  }
123
123
 
124
+ class Snapshot {
125
+ open(options?: DatabaseOptions): Database;
126
+ encode(): Uint8Array;
127
+ static decode(bytes: Uint8Array): Snapshot;
128
+ }
129
+
124
130
  class SqliteError extends Error {
125
131
  readonly category: ErrorCategory; // syntax, no_such_table, constraint_unique, misuse, …
126
132
  readonly sqliteCode: string; // always set; default "SQLITE_ERROR"
@@ -128,7 +134,7 @@ class SqliteError extends Error {
128
134
  }
129
135
  ```
130
136
 
131
- Stick to `Database`, `Statement`, and `SqliteError` for application code. Advanced internals (`parse`, `tokenize`, `evalExpr`, snapshot codec pieces, `SqlValue` utilities, `Prng`, …) are available only from `@crvouga/sqlite-mem/unstable` and are **exempt from semver**.
137
+ Stick to `Database`, `Snapshot`, `Statement`, and `SqliteError` for application code. Advanced internals (`parse`, `tokenize`, `evalExpr`, snapshot codec pieces, `SqlValue` utilities, `Prng`, …) are available only from `@crvouga/sqlite-mem/unstable` and are **exempt from semver**.
132
138
 
133
139
  ### Method semantics
134
140
 
@@ -138,7 +144,10 @@ Stick to `Database`, `Statement`, and `SqliteError` for application code. Advanc
138
144
  | `query(sql, params?)` | **Single statement only** (trailing `;` is fine). Returns all rows. Multi-statement scripts throw `misuse`. |
139
145
  | `prepare(sql)` | **Single statement only**. Parses immediately; AST is reused. Pass binds as rest args to `run` / `all` / `get` / `result` on each call. |
140
146
  | `transaction(fn)` | If idle: `BEGIN` → `fn()` → `COMMIT`, or `ROLLBACK` + rethrow. If already in a transaction: nested savepoint. Nested SQL `BEGIN` still errors. `close()` inside `fn` throws `misuse`. |
141
- | `snapshot` / `restore` | Custom binary format (see below). |
147
+ | `snapshot()` | Freeze a reusable {@link Snapshot} template (no encode). Illegal inside a transaction. |
148
+ | `Snapshot.open()` | Copy-on-write fork from a template. Parent stays open. |
149
+ | `Snapshot.encode()` | Lazy SQLM blob for persistence / worker boot (computed once, cached). |
150
+ | `Snapshot.decode(bytes)` | Decode a blob once per `Uint8Array` (WeakMap); later `open()` calls are CoW. |
142
151
  | `close()` | Idempotent; rolls back an open SQL transaction; further ops throw `misuse`. Also available as `[Symbol.dispose]` when supported. |
143
152
 
144
153
  SQL `BEGIN` / `COMMIT` / `ROLLBACK` / `SAVEPOINT` / `RELEASE` are first-class. Empty / comment-only SQL on `prepare` / `query` throws `misuse` (`empty statement`), matching SQLite prepare failure.
@@ -174,13 +183,17 @@ Duplicate column names collapse in row objects (last write wins). Use `stmt.resu
174
183
 
175
184
  ### Snapshots
176
185
 
186
+ - `db.snapshot()` returns a frozen in-memory {@link Snapshot}. Per-test isolation should `seed.open()` (copy-on-write, ~µs). Encoded bytes are **lazy** via `snapshot.encode()`.
177
187
  - Format magic `SQLM` followed by an explicit little-endian format-version `u32` — **not** a portable `.sqlite` file and not loadable by the SQLite CLI.
178
- - Round-trips ordinary tables, views, indexes, change counters, PRNG state, and clock.
188
+ - Round-trips ordinary tables, views, indexes (SQLM v4 compact index keys; v3 persisted full `IndexStore`; v1/v2 blobs rebuild indexes on hydrate), change counters, PRNG state, and clock.
179
189
  - **Not** encoded: triggers, ATTACH’d schemas, virtual tables (FTS / RTREE / …), `userVersion`.
180
- - Cannot `restore()` while a transaction is open.
181
- - `restore()` replaces `now` with a fixed clock from the snapshot (a live `() => Date` is overwritten). `{ now: "system" }` stays live after restore.
182
- - Equivalent databases produce byte-identical snapshots (schema/rows sorted) **within a single library version**.
183
- - **Compatibility policy:** newer library versions can always restore older snapshots; older libraries cannot restore newer format versions (`snapshot_version` / `SQLITE_FORMAT`). Corrupt magic yields a distinct error.
190
+ - Cannot `snapshot()` while a transaction is open.
191
+ - `Snapshot.decode(bytes)` does not mutate the input `Uint8Array`. The same buffer object is decoded once (WeakMap) and later opens are CoW.
192
+ - `open()` shares frozen tables until either side writes; idle `open().snapshot().encode()` is byte-identical to `snapshot().encode()`.
193
+ - `open()` uses a fixed clock from the snapshot unless you pass `{ now: "system" }`, which stays live.
194
+ - Equivalent databases produce byte-identical `encode()` output (schema/rows sorted) **within a single library version**.
195
+ - Per-test isolation (CI-tier, 200 users + 800 items): `Snapshot.open` ~µs; `encode()` / `decode().open()` is the persistence path. See [benchmarks/PERFORMANCE.md](benchmarks/PERFORMANCE.md).
196
+ - **Compatibility policy:** newer library versions can always decode older snapshots; older libraries cannot decode newer format versions (`snapshot_version` / `SQLITE_FORMAT`). Corrupt magic yields a distinct error.
184
197
 
185
198
  ## Determinism
186
199
 
@@ -189,9 +202,9 @@ The engine is deterministic by default. Invariants:
189
202
  | Source | Default | Override / notes |
190
203
  | --- | --- | --- |
191
204
  | `random()` / `randomblob()` | Seeded xorshift64* (`seed: 1`) | `new Database({ seed })` or `{ random: "os" }` for CSPRNG (not rolled back / not restored) |
192
- | `date('now')` / friends | Fixed `2000-01-01T00:00:00.000Z` | `new Database({ now: Date \| (() => Date) \| "system" })` — `"system"` is wall clock and is **not** frozen by `restore()` |
193
- | Table scans | Rowid order | Same order after `snapshot`/`restore` |
194
- | Snapshots | Sorted schema/rows + PRNG state + clock | Restored into PRNG and `now` |
205
+ | `date('now')` / friends | Fixed `2000-01-01T00:00:00.000Z` | `new Database({ now: Date \| (() => Date) \| "system" })` — `"system"` is wall clock and is **not** frozen by `open()` |
206
+ | Table scans | Rowid order | Same order after `snapshot`/`open` |
207
+ | Snapshots | Sorted schema/rows + PRNG state + clock | Applied by `open()` into PRNG and `now` |
195
208
  | Transactions | PRNG rolls back with `ROLLBACK`/`SAVEPOINT` | Matches data rollback |
196
209
  | Numbers | IEEE `-0` canonicalized to `+0` | Bind, affinity, and arithmetic |
197
210
 
@@ -238,7 +251,7 @@ This is **not** a drop-in replacement for `sql.js`, `@sqlite.org/sqlite-wasm`, o
238
251
  2. **No named-object binds and no sticky `bind()`** — pass positional rest args / arrays in declaration order to `query` / `run` / `all` / `get` / `result`.
239
252
  3. **`query` / `prepare` are single-statement only** — multi-statement scripts belong in `exec()` (which does not take bind parameters).
240
253
  4. **`exec` returns `void` and takes no params** — use `db.prepare(…).run(…)` or `db.query(…)` for binds; use `db.changes` / `stmt.run()` for counters.
241
- 5. **`'now'` is not wall-clock** unless you pass `{ now: "system" }` or `{ now: () => new Date() }`. Default is year 2000. `restore()` freezes a snapshot clock except when constructed with `"system"`.
254
+ 5. **`'now'` is not wall-clock** unless you pass `{ now: "system" }` or `{ now: () => new Date() }`. Default is year 2000. `open()` freezes a snapshot clock except when constructed with `"system"`.
242
255
  6. **`random()` is seeded**, not OS entropy, unless you pass `{ random: "os" }`. Snapshots restore the seeded PRNG; OS entropy is not rewound.
243
256
  7. **Snapshots are not `.sqlite` files** and do not round-trip FTS / triggers / ATTACH.
244
257
  8. **No better-sqlite3 extras** — no `iterate`, `pluck`/`raw`, `safeIntegers` option, `pragma()` helper, `loadExtension`, or SQLite-file `serialize()`.
@@ -11,8 +11,8 @@
11
11
  {
12
12
  "id": "snapshot-sqlm",
13
13
  "scope": "snapshot",
14
- "predicate": "snapshot() bytes are SQLM, not a .sqlite file",
15
- "specifiedBehavior": "Custom codec; logical Dump after restore matches pre-snapshot Dump.",
14
+ "predicate": "snapshot().encode() bytes are SQLM, not a .sqlite file",
15
+ "specifiedBehavior": "Custom codec; logical Dump after Snapshot.open matches pre-snapshot Dump.",
16
16
  "pinnedBy": ["SNP-hdr-01", "SNP-rt-01"]
17
17
  },
18
18
  {
@@ -82,7 +82,7 @@
82
82
  "id": "snapshot-exclusions",
83
83
  "scope": "snapshot",
84
84
  "predicate": "triggers, ATTACH, virtual tables, and user_version are not in SQLM",
85
- "specifiedBehavior": "restore() outcome is pinned per omitted feature.",
85
+ "specifiedBehavior": "decode().open() outcome is pinned per omitted feature.",
86
86
  "pinnedBy": ["SNP-omit-01", "SNP-omit-02", "SNP-omit-03", "SNP-omit-04", "TRG-snap-01", "ATT-snap-01", "FTS-snap-01"]
87
87
  },
88
88
  {
@@ -116,8 +116,8 @@
116
116
  {
117
117
  "id": "user-version-snapshot",
118
118
  "scope": "pragma",
119
- "predicate": "user_version is not restored from SQLM",
120
- "specifiedBehavior": "PRAGMA user_version after restore is the default unless re-set.",
119
+ "predicate": "user_version is not encoded in SQLM",
120
+ "specifiedBehavior": "PRAGMA user_version after decode().open() is the default unless re-set.",
121
121
  "pinnedBy": ["PRG-beh-05", "SNP-omit-04"]
122
122
  },
123
123
  {
@@ -371,7 +371,7 @@ const TAIL: CatalogSection[] = [
371
371
  ["now-03", "column DEFAULT CURRENT_TIMESTAMP", D, "README"],
372
372
  ["now-04", "now system tracks wall clock", D, "README"],
373
373
  ["now-05", "now fn called per statement", D, "README"],
374
- ["now-06", "restore freezes clock", D, "README"],
374
+ ["now-06", "open freezes clock", D, "README"],
375
375
  ]),
376
376
  section("JSN", "JSON", true, [
377
377
  ["json-01", "json() minify/validate"],
@@ -756,15 +756,16 @@ const TAIL: CatalogSection[] = [
756
756
  ["hdr-02", "corrupt magic error"],
757
757
  ["hdr-03", "truncated blob error"],
758
758
  ["hdr-04", "future version snapshot_version"],
759
- ["txn-01", "restore during txn errors"],
760
- ["rep-01", "restore replaces entire state"],
761
- ["now-01", "restore keeps system clock live", D],
762
- ["now-02", "restore overwrites Date fn", D],
763
- ["rng-01", "restore with random os does not rewind", D],
759
+ ["txn-01", "snapshot during txn errors"],
760
+ ["now-01", "open with system clock stays live", D],
761
+ ["now-02", "open overwrites Date fn with snapshot clock", D],
762
+ ["rng-01", "open with random os does not rewind", D],
764
763
  ["omit-01", "triggers not encoded", D],
765
764
  ["omit-02", "ATTACH not encoded", D],
766
765
  ["omit-03", "virtual tables not encoded", D],
767
766
  ["omit-04", "user_version not encoded", D],
767
+ ["open-01", "Snapshot.open CoW fork"],
768
+ ["open-02", "encode does not mutate; decode reuses buffer"],
768
769
  ]),
769
770
  section("DET", "Determinism invariants", true, [
770
771
  ["seed-01", "same seed identical random streams", D],
@@ -1,5 +1,6 @@
1
1
  import { type DatabaseOptions, type RandomMode } from "../runtime/index.js";
2
2
  import type { BindValue, QueryRow } from "../types/value.js";
3
+ import { type Snapshot } from "./snapshot.js";
3
4
  import { Statement } from "./statement.js";
4
5
  /**
5
6
  * Pure TypeScript in-memory SQLite database.
@@ -72,24 +73,14 @@ export declare class Database {
72
73
  */
73
74
  transaction<T>(fn: () => T): T;
74
75
  /**
75
- * Serialize schema, rows, PRNG state, and clock into a custom snapshot blob.
76
+ * Freeze this database into a reusable {@link Snapshot} template.
76
77
  *
77
- * This is not a `.sqlite` file. Restore it with {@link restore}.
78
+ * Does not encode SQLM bytes. Call {@link Snapshot.encode} to persist, or
79
+ * {@link Snapshot.open} for a copy-on-write fork.
78
80
  *
79
- * @throws {SqliteError} If the database is closed.
80
- */
81
- snapshot(): Uint8Array;
82
- /**
83
- * Replace this database's contents with a blob from {@link snapshot}.
84
- *
85
- * Restores PRNG state and the clock when the snapshot includes them (v2).
86
- * Newer library versions can restore older snapshots; older libraries cannot
87
- * restore newer format versions.
88
- *
89
- * @param snapshot - Bytes previously returned by {@link snapshot}.
90
- * @throws {SqliteError} If the database is closed, a transaction is open, or the blob is invalid.
81
+ * @throws {SqliteError} If the database is closed or a transaction is open.
91
82
  */
92
- restore(snapshot: Uint8Array): void;
83
+ snapshot(): Snapshot;
93
84
  /**
94
85
  * Close the database. Further SQL throws {@link SqliteError}. Idempotent.
95
86
  *
@@ -117,4 +108,5 @@ export declare class Database {
117
108
  get totalChanges(): number;
118
109
  private prepareSingle;
119
110
  }
111
+ export { Snapshot } from "./snapshot.js";
120
112
  export type { DatabaseOptions };
@@ -0,0 +1,21 @@
1
+ import { type DatabaseOptions } from "../runtime/index.js";
2
+ import { type Database } from "./database.js";
3
+ /**
4
+ * Frozen in-memory database template. {@link open} is a copy-on-write fork.
5
+ * Encoded SQLM bytes are produced lazily via {@link encode}.
6
+ */
7
+ export declare class Snapshot {
8
+ private cachedBytes;
9
+ /** Encoded SQLM blob. Computed once; never mutates a buffer passed to {@link decode}. */
10
+ encode(): Uint8Array;
11
+ /**
12
+ * Copy-on-write database from this frozen template. Does not re-encode or re-decode.
13
+ */
14
+ open(options?: DatabaseOptions): Database;
15
+ /**
16
+ * Decode `bytes` once and freeze the result. The same `Uint8Array` object
17
+ * returns the same {@link Snapshot} (WeakMap), so later {@link open} calls
18
+ * are copy-on-write forks after the first hydrate.
19
+ */
20
+ static decode(bytes: Uint8Array): Snapshot;
21
+ }
@@ -1,11 +1,11 @@
1
1
  import type { Expr } from "../ast/nodes.js";
2
2
  import type { IndexStore } from "../indexes/index.js";
3
3
  import type { Row, Rowid } from "../storage/row.js";
4
- import type { ColumnInfo, Table } from "../storage/table.js";
4
+ import type { Table } from "../storage/table.js";
5
5
  import type { SqlValue } from "../types/value.js";
6
6
  export type CheckEvaluator = (expr: Expr, row: Row) => SqlValue;
7
- export declare function checkNotNull(columns: readonly ColumnInfo[], row: Row, tableName?: string): void;
8
- export declare function checkPrimaryKey(columns: readonly ColumnInfo[], row: Row, tableName?: string): void;
7
+ export declare function checkNotNull(table: Table, row: Row): void;
8
+ export declare function checkPrimaryKey(table: Table, row: Row): void;
9
9
  export declare function checkUnique(index: IndexStore, values: readonly SqlValue[], rowid?: Rowid): void;
10
10
  export declare function checkExpressions(expressions: readonly Expr[], row: Row, evaluate: CheckEvaluator, constraintName?: string): void;
11
11
  export declare function checkTableConstraints(table: Table, row: Row, evaluate: CheckEvaluator): void;
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * @module
23
23
  */
24
- export { Database, type DatabaseOptions } from "./api/database.js";
24
+ export { Database, Snapshot, type DatabaseOptions } from "./api/database.js";
25
25
  export { type RunResult, Statement } from "./api/statement.js";
26
26
  export { type ErrorCategory, SqliteError } from "./errors/index.js";
27
27
  export type { ResultSet } from "./executor/result.js";