@crvouga/postgres-mem 1.0.1 → 1.1.1

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/AGENTS.md CHANGED
@@ -11,9 +11,9 @@ Guidance for humans and coding agents editing this repository. For install and c
11
11
  1. Custom snapshot codec (`PGMM`), not `pg_dump` / on-disk clusters
12
12
  2. Deterministic `random()` / `gen_random_uuid()` and fixed `now()` by default (injectable)
13
13
  3. Single-session engine: no MVCC across connections, no wire protocol, no aborted-transaction (`25P02`) state
14
- 4. `NOT APPLICABLE` items: roles/auth enforcement, replication, VACUUM internals, storage params, PL/pgSQL
14
+ 4. `NOT APPLICABLE` items: roles/auth enforcement, replication, VACUUM internals, storage params, full PL/pgSQL
15
15
 
16
- **Non-goals:** speaking the wire protocol, matching `pg`/`postgres.js` client APIs, PL/pgSQL, or multi-session concurrency.
16
+ **Non-goals:** speaking the wire protocol, matching `pg`/`postgres.js` client APIs, full PL/pgSQL, or multi-session concurrency.
17
17
 
18
18
  ## SQL pipeline
19
19
 
@@ -39,7 +39,7 @@ Everything is **typed values** (`TypedValue = { t: TypeId, v: Datum }` in [`src/
39
39
  | `ast/` | Discriminated-union AST (`nodes.ts`) |
40
40
  | `lexer/` | Tokenizer (dollar quoting, E-strings, `::`, operators) |
41
41
  | `parser/` | Recursive-descent parser |
42
- | `executor/` | Statement dispatch, SELECT, DML, DDL, session (SET/SHOW/COPY/PREPARE), triggers, window functions |
42
+ | `executor/` | Statement dispatch, SELECT, DML, DDL, session (SET/SHOW/COPY/PREPARE), triggers, plpgsql-lite UDFs, window functions |
43
43
  | `expressions/` | `evalExpr`, operators, pattern matching, `EngineCtx` |
44
44
  | `functions/` | Scalar / aggregate / window / datetime / JSON / array / SRF / tsearch registries |
45
45
  | `types/` | `TypedValue`, casts, comparison, numeric, datetime, jsonb, timezone |
@@ -101,10 +101,10 @@ Hot / large files: `parser/parser.ts`, `executor/select.ts`, `executor/dml.ts`,
101
101
 
102
102
  | Path | Role |
103
103
  | --- | --- |
104
- | `tests/contract/` | Differential SQL vs PGlite (**authoritative**) |
104
+ | `tests/contract/` | Differential SQL vs oracle (**authoritative**; default PGlite, optional native via `test:postgres-native`) |
105
105
  | `tests/fuzz/` | fast-check property tests (seeded); differential, NoREC/TLP metamorphic, stateful DST |
106
106
  | `tests/harness/` | Compare/normalize/classify helpers + harness unit tests |
107
- | `tests/adapters/` | Wrappers for postgres-mem and PGlite (`ContractDb`) |
107
+ | `tests/adapters/` | Wrappers for postgres-mem, PGlite, and native Postgres (`ContractDb`) |
108
108
  | `tests/corpus/` | Fuzz regression corpus |
109
109
  | `tests/meta/` | Canary definitions, skip register |
110
110
 
@@ -124,7 +124,8 @@ POSTGRES_MEM_FUZZ_SEED=12345 POSTGRES_MEM_FUZZ_PATH='0:1' bun test tests/fuzz
124
124
 
125
125
  | Command | Role |
126
126
  | --- | --- |
127
- | `bun run test:postgres-compat` | Requirements + fail-closed gate + construct catalog + smoke ratchet + contract/fuzz/harness |
127
+ | `bun run test:postgres-compat` | Requirements + fail-closed gate + construct catalog + smoke ratchet + contract/fuzz/harness (PGlite oracle) |
128
+ | `bun run test:postgres-native` | Same differential suite vs real PostgreSQL 18.3 (embedded-postgres, or `POSTGRES_MEM_ORACLE_URL`) |
128
129
  | `bun run inventory` | Oracle `pg_proc` / `pg_operator` vs engine registries (+ `--write-register`) |
129
130
  | `bun run scenarios` | Construct-level scenario catalog (`compat/scenarios.ts`) + smoke gate |
130
131
  | `bun run requirements` | Refresh PostgreSQL 18 SQL-commands docs → `compat/requirements.json` + `compat/coverage.json` |
@@ -136,6 +137,8 @@ Statuses: **VERIFIED** / **PARTIALLY VERIFIED** / **UNSUPPORTED** / **NOT APPLIC
136
137
 
137
138
  **PGlite quirk:** PGlite's WASM boot leaks `process.exitCode = 99` under Bun ([pglite#975](https://github.com/electric-sql/pglite/issues/975)); the adapter and scripts reset it — keep that workaround when touching `tests/adapters/pglite.ts`.
138
139
 
140
+ **Native oracle:** `POSTGRES_MEM_ORACLE=server` + `POSTGRES_MEM_ORACLE_URL` selects `PostgresServerAdapter`. Prefer `bun run test:postgres-native` (starts embedded-postgres 18.3). Optional Docker: `docker-compose.oracle.yml`.
141
+
139
142
  Details: [COMPATIBILITY.md](COMPATIBILITY.md), audit: [COMPATIBILITY-AUDIT.md](COMPATIBILITY-AUDIT.md).
140
143
 
141
144
  ## Local gates
@@ -150,10 +153,11 @@ bun run format
150
153
  bun run lint
151
154
  bun run typecheck
152
155
  bun run test:postgres-compat
156
+ bun run test:postgres-native # optional second oracle (real PostgreSQL 18.3)
153
157
  bun test # contract + fuzz + harness
154
158
  bun run build
155
159
  ```
156
160
 
157
161
  ## PR and commits
158
162
 
159
- Use [Conventional Commits](https://www.conventionalcommits.org/) for commits and PR titles (enforced in CI). Prefer squash merges with a conventional title. Releasing is automated via semantic-release see [README.md](README.md#releasing).
163
+ Use [Conventional Commits](https://www.conventionalcommits.org/) for commits and PR titles (enforced on PRs). Prefer squash merges with a conventional title so the npm bump is `feat` minor / `fix` → patch. Direct pushes to `main` with any other subject still publish a patch. See [README.md](README.md#releasing).
@@ -32,8 +32,8 @@ Coverage statuses (SQL_BEHAVIOR):
32
32
 
33
33
  Oracle builtin inventory:
34
34
  pg_catalog functions exposed by oracle: 2787
35
- implemented in engine registries: 301
36
- registered unsupported (with reasons): 2486
35
+ implemented in engine registries: 302
36
+ registered unsupported (with reasons): 2485
37
37
  silently missing: 0 (gate fails otherwise)
38
38
  pg_catalog operators exposed by oracle: 74
39
39
  implemented: 41
@@ -53,7 +53,7 @@ Types / casts / NULL:
53
53
  3VL through operators/aggregates/DISTINCT
54
54
 
55
55
  Functions (oracle surface):
56
- VERIFIED for the implemented 301; every other oracle builtin is an
56
+ VERIFIED for the implemented 302; every other oracle builtin is an
57
57
  explicit register entry (never silent)
58
58
 
59
59
  JSON / JSONB:
@@ -74,10 +74,11 @@ Schemas / search_path / catalogs:
74
74
  Sequences / serial / identity / enums / domains / generated columns:
75
75
  VERIFIED
76
76
 
77
- Triggers / LANGUAGE sql functions:
77
+ Triggers / LANGUAGE sql / plpgsql-lite functions:
78
78
  PARTIALLY VERIFIED — row-level triggers fire in creation order
79
79
  (PostgreSQL: name order), UPDATE OF ignored, INSTEAD OF unsupported —
80
- all pinned divergences
80
+ all pinned divergences. plpgsql-lite UDFs: DECLARE, EXCEPTION WHEN
81
+ others, CASE, FOR-IN-SELECT, RETURN NEXT.
81
82
 
82
83
  Text search:
83
84
  PARTIALLY VERIFIED — tsvector/tsquery/@@/ts_rank with simple-style
package/COMPATIBILITY.md CHANGED
@@ -34,9 +34,9 @@ Anything a PostgreSQL application can invoke through SQL against the PGlite **18
34
34
  1. **Snapshot format** — custom binary codec (`PGMM`), not `pg_dump` / on-disk clusters (logical state still round-trips).
35
35
  2. **Deterministic `random()` / `now()`** — seeded PRNG and fixed clock by default (injectable).
36
36
  3. **Single session** — no MVCC across connections, no isolation levels beyond one session, no `25P02` aborted-transaction state (documented divergence).
37
- 4. **NOT APPLICABLE** rows in `compat/coverage.json` (roles, replication, VACUUM internals, LISTEN/NOTIFY, cursors, PL/pgSQL, extensions).
37
+ 4. **NOT APPLICABLE** rows in `compat/coverage.json` (roles, replication, VACUUM internals, LISTEN/NOTIFY, cursors, full PL/pgSQL, extensions).
38
38
 
39
- The oracle exposes **2787 builtin functions** and **74 operators** in `pg_catalog`; postgres-mem implements **301 functions** and **41 operators**, and every remaining item is an explicit entry in [`compat/unsupported-register.json`](compat/unsupported-register.json) with a reason (trigger/internal plumbing, admin/monitoring, unsupported type families, …). The gate fails closed on silence.
39
+ The oracle exposes **2787 builtin functions** and **74 operators** in `pg_catalog`; postgres-mem implements **302 functions** and **41 operators**, and every remaining item is an explicit entry in [`compat/unsupported-register.json`](compat/unsupported-register.json) with a reason (trigger/internal plumbing, admin/monitoring, unsupported type families, …). The gate fails closed on silence.
40
40
 
41
41
  ## Requirements coverage (PostgreSQL 18 SQL commands)
42
42
 
@@ -60,7 +60,7 @@ The oracle exposes **2787 builtin functions** and **74 operators** in `pg_catalo
60
60
  | Schemas + search_path + pg_catalog / information_schema | VERIFIED | Catalog columns are the commonly-queried subset |
61
61
  | Enums, domains, generated columns | VERIFIED | |
62
62
  | Triggers (row-level, LANGUAGE sql-expressible) | PARTIALLY VERIFIED | Creation-order firing, `UPDATE OF` ignored, no INSTEAD OF (documented) |
63
- | CREATE FUNCTION LANGUAGE sql | PARTIALLY VERIFIED | Scalar + set-returning; no polymorphic/variadic edges |
63
+ | CREATE FUNCTION LANGUAGE sql / plpgsql-lite | PARTIALLY VERIFIED | Scalar + set-returning; plpgsql subset: DECLARE, EXCEPTION WHEN others, CASE, FOR-IN-SELECT, RETURN NEXT |
64
64
  | Text search (tsvector / tsquery / @@ / ts_rank) | PARTIALLY VERIFIED | `simple`-style config; no ispell/synonym dictionaries |
65
65
  | COPY FROM/TO (text, csv) | VERIFIED | Via `copyFrom` API hook / rows out |
66
66
  | PREPARE / EXECUTE / DEALLOCATE, SET / SHOW / RESET | VERIFIED | GUC subset |
@@ -68,7 +68,7 @@ The oracle exposes **2787 builtin functions** and **74 operators** in `pg_catalo
68
68
  | Collation / ordering | PARTIALLY VERIFIED | `C` semantics pinned; locale/ICU out of scope |
69
69
  | Regex (`~`, `~*`, POSIX functions) | PARTIALLY VERIFIED | JS regex flavor mapped to POSIX ERE; documented edges |
70
70
  | EXPLAIN | PARTIALLY VERIFIED | Stub plan shapes |
71
- | MERGE / CALL / cursors / LISTEN / PL/pgSQL | UNSUPPORTED | Fail loud `0A000`, registered |
71
+ | MERGE / CALL / cursors / LISTEN / full PL/pgSQL | UNSUPPORTED | Fail loud `0A000`, registered |
72
72
  | Roles / GRANT / VACUUM / ANALYZE / LOCK | NOT APPLICABLE | Parsed no-ops where harmless |
73
73
  | Wire protocol / multi-session MVCC / on-disk format | NOT APPLICABLE | |
74
74
 
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # postgres-mem
2
2
 
3
+ [npm](https://www.npmjs.com/package/@crvouga/postgres-mem) · [GitHub](https://github.com/crvouga/postgres-mem)
4
+
3
5
  Pure TypeScript, completely in-memory PostgreSQL implementation aiming for **PostgreSQL 18 SQL dialect parity** (same statements → same results).
4
6
 
5
7
  - Runs in modern browsers and Node.js / Bun
6
8
  - **Zero** WASM, native bindings, workers, or filesystem dependencies
7
9
  - Entire database stored in memory
8
10
  - **Synchronous** ESM-only API (no Promises, no `require`)
9
- - **SQL dialect verified** against real PostgreSQL 18.3 (PGlite) via differential contracts + fail-closed gate
11
+ - **SQL dialect verified** against real PostgreSQL 18.3 (PGlite by default; optional native server via `test:postgres-native`) via differential contracts + fail-closed gate
10
12
  - **Not** a drop-in for `pg` / `postgres.js` client APIs, the wire protocol, or on-disk clusters
11
13
  - Intentional differences: deterministic `random()` / `now()` by default, and a custom snapshot format (not `pg_dump`)
12
14
 
@@ -41,7 +43,7 @@ Requires Node.js ≥ 20 or Bun ≥ 1.1. The published package is **ESM only** (`
41
43
  ## Usage
42
44
 
43
45
  ```ts
44
- import { Database } from "@crvouga/postgres-mem";
46
+ import { Database, Snapshot } from "@crvouga/postgres-mem";
45
47
 
46
48
  const db = new Database();
47
49
 
@@ -57,9 +59,10 @@ db.prepare(`INSERT INTO users (name) VALUES ($1)`).run("Alice");
57
59
  const users = db.query<{ id: number; name: string }>(`SELECT * FROM users`);
58
60
  console.log(users);
59
61
 
60
- const snap = db.snapshot();
61
- const db2 = new Database();
62
- db2.restore(snap);
62
+ const seed = db.snapshot();
63
+ const db2 = seed.open();
64
+ const bytes = seed.encode();
65
+ const db3 = Snapshot.decode(bytes).open();
63
66
  ```
64
67
 
65
68
  All methods are **synchronous** — do not `await` them. Browser and Node/Bun share the same in-memory JS surface (no filesystem, no server, no wire protocol).
@@ -79,28 +82,41 @@ From the repo root after that install: `bun run example`.
79
82
  ## API
80
83
 
81
84
  ```ts
82
- import { Database, PostgresError } from "@crvouga/postgres-mem";
85
+ import { Database, Snapshot, PostgresError } from "@crvouga/postgres-mem";
83
86
 
84
87
  interface DatabaseOptions {
85
88
  seed?: number | bigint; // default 1 — ignored when random is "os"
86
89
  random?: "deterministic" | "os"; // default "deterministic"; "os" is CSPRNG like PostgreSQL
87
90
  now?: Date | (() => Date) | "system"; // default 2000-01-01T00:00:00.000Z; "system" is wall clock
91
+ int8?: "bigint" | "number" | "string"; // default "bigint"; "number" is unsafe beyond MAX_SAFE_INTEGER
88
92
  }
89
93
 
90
94
  interface Database {
91
95
  constructor(options?: DatabaseOptions);
92
96
  exec(sql: string): void;
97
+ registerFunction(spec: {
98
+ name: string;
99
+ args: string[];
100
+ returns: string;
101
+ strict?: boolean;
102
+ fn: (...args: JsValue[]) => JsValue;
103
+ }): void;
93
104
  query<T = QueryRow>(sql: string, params?: BindValue[]): T[];
94
105
  prepare(sql: string): Statement;
95
106
  transaction<T>(fn: () => T): T;
96
107
  copyFrom(sql: string, data: string): number; // COPY t FROM STDIN payload (\copy analog)
97
- snapshot(): Uint8Array;
98
- restore(snapshot: Uint8Array): void;
108
+ snapshot(): Snapshot;
99
109
  close(): void;
100
110
  [Symbol.dispose]?(): void; // alias for close() when Symbol.dispose exists
101
111
  readonly changes: number; // rows affected by the most recent INSERT/UPDATE/DELETE
102
112
  }
103
113
 
114
+ class Snapshot {
115
+ open(options?: DatabaseOptions): Database;
116
+ encode(): Uint8Array;
117
+ static decode(bytes: Uint8Array): Snapshot;
118
+ }
119
+
104
120
  interface Statement {
105
121
  run(...params: BindValue[]): RunResult;
106
122
  all<T = QueryRow>(...params: BindValue[]): T[];
@@ -129,18 +145,22 @@ class PostgresError extends Error {
129
145
  }
130
146
  ```
131
147
 
132
- Stick to `Database`, `Statement`, and `PostgresError` for application code. Advanced internals (`parse`, `tokenize`, `executeStatement`, snapshot codec pieces, `Prng`, …) are available only from `@crvouga/postgres-mem/unstable` and are **exempt from semver**.
148
+ Stick to `Database`, `Snapshot`, `Statement`, and `PostgresError` for application code. Advanced internals (`parse`, `tokenize`, `executeStatement`, snapshot codec pieces, `Prng`, …) are available only from `@crvouga/postgres-mem/unstable` and are **exempt from semver**.
133
149
 
134
150
  ### Method semantics
135
151
 
136
152
  | Method | Behavior |
137
153
  | --- | --- |
138
- | `exec(sql)` | Runs all semicolon-separated statements; **discards** row results (`void`). Does **not** accept bind parameters. Read `db.changes` afterward if needed (reflects the **most recent** completed DML statement). |
154
+ | `exec(sql)` | Runs all semicolon-separated statements; **discards** row results (`void`). Does **not** accept bind parameters. Read `db.changes` afterward if needed (reflects the **most recent** completed DML statement). Dump-only `DO` blocks and `ALTER TABLE … SET (` storage parameters are no-ops. |
155
+ | `registerFunction(spec)` | Install a JavaScript scalar. Not stored in PGMM snapshots; `open()` of a live snapshot copies the impl by reference. |
139
156
  | `query(sql, params?)` | **Single statement only** (trailing `;` is fine). Returns all rows. Multi-statement scripts throw `misuse`. |
140
157
  | `prepare(sql)` | **Single statement only**. Parses immediately; AST is reused. Pass binds as rest args to `run` / `all` / `get` / `result` on each call. |
141
158
  | `transaction(fn)` | If idle: `BEGIN` → `fn()` → `COMMIT`, or `ROLLBACK` + rethrow. If already in a transaction: nested savepoint. Nested SQL `BEGIN` inside is a no-op warning like PostgreSQL. `close()` inside `fn` throws `misuse`. |
142
159
  | `copyFrom(sql, data)` | Executes `COPY table [(cols)] FROM STDIN` with `data` as the copy-in payload (text or csv per the COPY options). Returns rows copied. `COPY ... TO STDOUT` output is returned as result rows by `query`. |
143
- | `snapshot` / `restore` | Custom binary format (see below). |
160
+ | `snapshot()` | Freeze a reusable {@link Snapshot} template (no encode). Illegal inside a transaction (`25P01`). |
161
+ | `Snapshot.open()` | Copy-on-write fork from a template. Parent stays open. |
162
+ | `Snapshot.encode()` | Lazy PGMM blob for persistence / worker boot (computed once, cached). |
163
+ | `Snapshot.decode(bytes)` | Decode a blob once per `Uint8Array` (WeakMap); later `open()` calls are CoW. |
144
164
  | `close()` | Idempotent; rolls back an open SQL transaction; further ops throw `misuse`. Also available as `[Symbol.dispose]` when supported. |
145
165
 
146
166
  SQL `BEGIN` / `COMMIT` / `ROLLBACK` / `SAVEPOINT` / `RELEASE` are first-class. Empty / comment-only SQL on `prepare` / `query` / `exec` throws `misuse` (`empty statement`).
@@ -174,12 +194,16 @@ Duplicate column names collapse in row objects (last write wins). Use `stmt.resu
174
194
 
175
195
  ### Snapshots
176
196
 
197
+ - `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
198
  - Format magic `PGMM` followed by an explicit little-endian format-version `u32` — **not** `pg_dump` output and not loadable by real PostgreSQL.
178
- - Round-trips schemas, tables, rows, sequences (counters included), indexes, views, enums, domains, SQL functions, change counters, PRNG state, and clock.
179
- - Cannot `restore()` while a transaction is open (`25P01`).
180
- - `restore()` replaces `now` with a fixed clock from the snapshot (a live `() => Date` is overwritten). `{ now: "system" }` stays live after restore.
181
- - Equivalent databases produce byte-identical snapshots (schema/rows sorted) **within a single library version**.
182
- - **Compatibility policy:** newer library versions can always restore older snapshots; older libraries cannot restore newer format versions (`snapshot_version`). Corrupt magic yields a distinct error.
199
+ - Round-trips schemas, tables, rows, sequences (counters included), indexes, views, enums, domains, SQL functions, change counters, PRNG state, and clock. JavaScript `registerFunction` impls are omitted.
200
+ - Cannot `snapshot()` while a transaction is open (`25P01`).
201
+ - `Snapshot.decode(bytes)` does not mutate the input `Uint8Array`. The same buffer object is decoded once (WeakMap) and later opens are CoW.
202
+ - `open()` shares frozen tables until either side writes; idle `open().snapshot().encode()` is byte-identical to `snapshot().encode()`.
203
+ - `open()` uses a fixed clock from the snapshot unless you pass `{ now: "system" }`, which stays live.
204
+ - Equivalent databases produce byte-identical `encode()` output (schema/rows sorted) **within a single library version**.
205
+ - 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).
206
+ - **Compatibility policy:** newer library versions can always decode older snapshots; older libraries cannot decode newer format versions (`snapshot_version`). Corrupt magic yields a distinct error.
183
207
 
184
208
  ## Determinism
185
209
 
@@ -188,7 +212,7 @@ The engine is deterministic by default. Invariants:
188
212
  | Source | Default | Override / notes |
189
213
  | --- | --- | --- |
190
214
  | `random()` / `gen_random_uuid()` | Seeded xorshift64* (`seed: 1`) | `new Database({ seed })` or `{ random: "os" }` for CSPRNG (not rolled back / not restored) |
191
- | `now()` / `current_timestamp` / friends | Fixed `2000-01-01T00:00:00.000Z` | `new Database({ now: Date \| (() => Date) \| "system" })` — `"system"` is wall clock and is **not** frozen by `restore()` |
215
+ | `now()` / `current_timestamp` / friends | Fixed `2000-01-01T00:00:00.000Z` | `new Database({ now: Date \| (() => Date) \| "system" })` — `"system"` is wall clock and is **not** frozen by `open()` |
192
216
  | `setseed()` / `random()` | Deterministic stream | Matches the engine PRNG, repeatable |
193
217
  | Table scans | Insertion order | Same order after `snapshot`/`restore` |
194
218
  | Snapshots | Sorted schema/rows + PRNG state + clock | Restored into PRNG and `now` |
@@ -228,11 +252,11 @@ This is **not** a drop-in replacement for `pg`, `postgres.js`, or PGlite's clien
228
252
  - `COMMENT ON` parses but comments are not stored
229
253
  - `round(float8)` rounds ties away from zero (PostgreSQL: half-to-even); numeric `round()` has full parity
230
254
  - `'1e400'::float8` saturates to `Infinity` instead of raising `22003`
231
- - `MERGE`, `CALL`/procedures, cursors (`DECLARE`/`FETCH`), `LISTEN`/`NOTIFY`, PL/pgSQL bodies fail loud (`0A000`)
255
+ - `MERGE`, `CALL`/procedures, cursors (`DECLARE`/`FETCH`), `LISTEN`/`NOTIFY`, and full PL/pgSQL (packages, NOTICE, cursors) fail loud (`0A000`)
232
256
  - `VACUUM` / `ANALYZE` / `CLUSTER` / `REINDEX` / `CHECKPOINT` / `GRANT` / `REVOKE` / `LOCK` are parsed no-ops
233
257
  - Collation is `C` semantics (byte order); locale/ICU-dependent ordering is out of scope
234
258
 
235
- **Also supported (oracle-parity):** schemas + `search_path`, `pg_catalog` / `information_schema` introspection, sequences (`serial`, identity, `nextval`/`currval`/`setval`), enums, domains, `LANGUAGE sql` functions, row-level triggers, recursive + data-modifying CTEs, window functions with full frame specs, `GROUPING SETS`/`ROLLUP`/`CUBE`, `DISTINCT ON`, `LATERAL`, arrays + `unnest` + subscripting, JSON/JSONB operator + function surface, `tsvector` text search, `ON CONFLICT DO NOTHING/UPDATE`, `RETURNING`, `PREPARE`/`EXECUTE`/`DEALLOCATE`, `SET`/`SHOW`/`RESET` GUCs, `COPY` text and csv.
259
+ **Also supported (oracle-parity):** schemas + `search_path`, `pg_catalog` / `information_schema` introspection, sequences (`serial`, identity, `nextval`/`currval`/`setval`), enums, domains, `LANGUAGE sql` functions, plpgsql-lite UDFs (`DECLARE`, `EXCEPTION WHEN others`, `RETURN NEXT`), row-level triggers, recursive + data-modifying CTEs, window functions with full frame specs, `GROUPING SETS`/`ROLLUP`/`CUBE`, `DISTINCT ON`, `LATERAL`, arrays + `unnest` + subscripting, JSON/JSONB operator + function surface including `jsonb_path_query_first`, `tsvector` text search, `ON CONFLICT DO NOTHING/UPDATE`, `RETURNING`, `PREPARE`/`EXECUTE`/`DEALLOCATE`, `SET`/`SHOW`/`RESET` GUCs, `COPY` text and csv.
236
260
 
237
261
  ## Common pitfalls
238
262
 
@@ -240,10 +264,10 @@ This is **not** a drop-in replacement for `pg`, `postgres.js`, or PGlite's clien
240
264
  2. **Parameters are `$1..$n` only** — no `?` placeholders, no named parameters, no sticky `bind()`.
241
265
  3. **`query` / `prepare` are single-statement only** — multi-statement scripts belong in `exec()` (which does not take bind parameters).
242
266
  4. **`exec` returns `void` and takes no params** — use `db.prepare(…).run(…)` or `db.query(…)` for binds; use `db.changes` / `stmt.run().rowCount` for counters.
243
- 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"`.
267
+ 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"`.
244
268
  6. **`random()` is seeded**, not OS entropy, unless you pass `{ random: "os" }`. Snapshots restore the seeded PRNG; OS entropy is not rewound.
245
269
  7. **Snapshots are not `pg_dump` output** and cannot be loaded into real PostgreSQL.
246
- 8. **`int8` comes back as `bigint`**, `numeric`/dates/json come back as **text** — parse them explicitly if you need JS numbers/objects.
270
+ 8. **`int8` comes back as `bigint` by default** (`{ int8: "number" | "string" }` opts in). `numeric`/dates/json come back as **text** — parse them explicitly if you need JS numbers/objects.
247
271
  9. **A failed statement does not abort the transaction** — real PostgreSQL rejects everything after an error inside `BEGIN` until `ROLLBACK`; postgres-mem keeps executing (documented divergence).
248
272
  10. **Unquoted identifiers fold to lowercase** (PostgreSQL rule — not uppercase like the SQL standard).
249
273
  11. **Do not import `@crvouga/postgres-mem/unstable` in application code** unless you accept breakage in any release.
@@ -254,7 +278,7 @@ Working examples beyond this README: `examples/react-vite`, `tests/contract/api/
254
278
 
255
279
  Requires [Bun](https://bun.sh). For architecture, change checklists, and how to add contract tests, see **[AGENTS.md](AGENTS.md)**.
256
280
 
257
- Parity is proven only by differential contracts against real PostgreSQL (PGlite, Postgres 18.3 in WASM). Isolated internal unit tests are not PostgreSQL compatibility proof.
281
+ Parity is proven by differential contracts against real PostgreSQL — default oracle PGlite (18.3 in WASM), plus optional native PostgreSQL 18.3 via `bun run test:postgres-native`. Isolated internal unit tests are not PostgreSQL compatibility proof.
258
282
 
259
283
  ```bash
260
284
  bun install
@@ -263,7 +287,8 @@ bun run check # format + lint + typecheck + postgres-compat sui
263
287
  bun run format # write Biome formatting
264
288
  bun run lint # Biome lint
265
289
  bun run typecheck
266
- bun run test:postgres-compat # requirements + inventory gate + differential suite
290
+ bun run test:postgres-compat # requirements + inventory gate + differential suite (PGlite)
291
+ bun run test:postgres-native # same differential suite vs real PostgreSQL 18.3
267
292
  bun test # contract + fuzz + harness
268
293
  bun run build
269
294
  ```
@@ -276,16 +301,16 @@ Publishing is fully automated. You never bump `version` or run `npm publish` by
276
301
 
277
302
  ### How a release happens
278
303
 
279
- 1. Push or merge to `main` with [Conventional Commits](https://www.conventionalcommits.org/).
304
+ 1. Push or merge to `main`. Prefer [Conventional Commits](https://www.conventionalcommits.org/) so the bump is `feat` → minor / `fix` → patch / `BREAKING` → major; any other subject still publishes a patch.
280
305
  2. CI runs commitlint, format/lint/typecheck, build, package verification, tests, browser smoke, and benchmarks.
281
306
  3. If every gate is green, [semantic-release](https://semantic-release.gitbook.io/) analyzes commits since the last git tag, bumps semver, publishes to npm, and creates a GitHub Release.
282
307
 
283
308
  | Commit | Version bump |
284
309
  | --- | --- |
285
- | `fix: …` | patch (`0.1.0` → `0.1.1`) |
286
- | `feat: …` | minor (`0.1.0` → `0.2.0`) |
287
- | `feat!: …` or `BREAKING CHANGE:` footer | major (`0.2.0` → `1.0.0`) |
288
- | `docs:`, `chore:`, `refactor:`, `test:`, | no release |
310
+ | `fix: …` / `perf: …` | patch (`1.0.1` → `1.0.2`) |
311
+ | `feat: …` | minor (`1.0.1` → `1.1.0`) |
312
+ | `feat!: …` or `BREAKING CHANGE:` footer | major (`1.1.0` → `2.0.0`) |
313
+ | any other message on `main` (including Cursor-style subjects) | patch |
289
314
 
290
315
  PR titles must also follow Conventional Commits (enforced in CI). Prefer squash merges with a conventional title.
291
316
 
@@ -318,7 +343,7 @@ Do this once so CI can publish. Full checklist: **[docs/SECRETS.md](./docs/SECRE
318
343
 
319
344
  Validate the checklist anytime with `bun run secrets:doctor`.
320
345
 
321
- After that, every green push to `main` with releasable commits updates npm automatically.
346
+ After that, every green push to `main` updates npm automatically (`feat`/`fix`/`BREAKING` pick the bump; anything else is a patch).
322
347
 
323
348
  ## License
324
349
 
@@ -375,7 +375,7 @@
375
375
  "evidence": [
376
376
  "tests/contract/functions/"
377
377
  ],
378
- "notes": "LANGUAGE sql bodies only; PL/pgSQL fails loud"
378
+ "notes": "LANGUAGE sql plus plpgsql-lite (DECLARE, EXCEPTION WHEN others, CASE, FOR-IN-SELECT, RETURN NEXT); no cursors/NOTICE/packages"
379
379
  },
380
380
  "sql-creategroup": {
381
381
  "status": "NOT_APPLICABLE",
@@ -4,9 +4,9 @@
4
4
  {
5
5
  "id": "oracle-pglite-version",
6
6
  "scope": "oracle",
7
- "predicate": "The differential oracle is PGlite (embedded PostgreSQL 18.x WASM); server_version must be 18.3 or 18.1",
8
- "specifiedBehavior": "Harness bootstrap asserts current_setting('server_version') is in {18.3, 18.1}. Differential results are only meaningful against these lines.",
9
- "pinnedBy": ["scripts/postgres-compat-gate.ts", "tests/harness/oracle-versions.ts"]
7
+ "predicate": "The default differential oracle is PGlite (embedded PostgreSQL 18.x WASM); a secondary native-server oracle is available via POSTGRES_MEM_ORACLE=server. server_version must be 18.3 or 18.1 for either path.",
8
+ "specifiedBehavior": "Harness bootstrap asserts current_setting('server_version') is in {18.3, 18.1}. Default CI uses PGlite; `bun run test:postgres-native` (and CI job test-native-oracle) runs the same suite against a real PostgreSQL 18.3 process.",
9
+ "pinnedBy": ["scripts/postgres-compat-gate.ts", "tests/harness/oracle-versions.ts", "scripts/run-postgres-native-tests.ts"]
10
10
  },
11
11
  {
12
12
  "id": "deterministic-runtime",
@@ -30,7 +30,7 @@
30
30
  {
31
31
  "id": "pgmm-snapshot-codec",
32
32
  "scope": "engine",
33
- "predicate": "snapshot()/restore() use the custom PGMM binary codec (magic 'PGMM' + LE u32 version), not pg_dump or the on-disk PostgreSQL format",
33
+ "predicate": "snapshot()/encode()/decode() use the custom PGMM binary codec (magic 'PGMM' + LE u32 version), not pg_dump or the on-disk PostgreSQL format",
34
34
  "specifiedBehavior": "Snapshots are byte-identical for equivalent logical state, refuse corrupt/truncated/future-version blobs, and cannot run inside a transaction. No PostgreSQL equivalent exists; tests assert memory behavior only.",
35
35
  "pinnedBy": [
36
36
  "SNP-rt-01",
@@ -44,7 +44,8 @@
44
44
  "SNP-hdr-03",
45
45
  "SNP-hdr-04",
46
46
  "SNP-txn-01",
47
- "SNP-rep-01",
47
+ "SNP-open-01",
48
+ "SNP-open-02",
48
49
  "tests/contract/snapshots/basic.test.ts"
49
50
  ]
50
51
  },
@@ -93,9 +94,19 @@
93
94
  "API-txn-02",
94
95
  "API-sync-01",
95
96
  "API-copy-01",
97
+ "API-int8-01",
98
+ "API-int8-02",
99
+ "API-fn-01",
96
100
  "tests/contract/api/basic.test.ts"
97
101
  ]
98
102
  },
103
+ {
104
+ "id": "dump-compat-noop",
105
+ "scope": "sql",
106
+ "predicate": "DO blocks and ALTER TABLE SET (storage/reloptions) execute as no-ops so schema dumps can load without intercepts; PL/pgSQL and storage parameters are NOT APPLICABLE",
107
+ "specifiedBehavior": "exec() loads schema dumps. DO $$ … $$ and ALTER TABLE … SET (fillfactor=…) succeed and change nothing. PGlite executes plpgsql; these cases are memory-only.",
108
+ "pinnedBy": ["API-dump-01", "API-do-01", "API-set-01"]
109
+ },
99
110
  {
100
111
  "id": "empty-script-rejected",
101
112
  "scope": "sql",
@@ -25,4 +25,10 @@ export const API_SECTION: CatalogSection = section("API", "JavaScript surface co
25
25
  ["txn-02", "nested transaction(fn) uses savepoints", D, "JS API surface, no oracle analog", undefined, DIV],
26
26
  ["sync-01", "methods return values, not Promises", D, "JS API surface, no oracle analog", undefined, DIV],
27
27
  ["copy-01", "copyFrom API loads COPY FROM STDIN data", D, "API-level COPY hook", ["tests/contract/copy/"], DIV],
28
+ ["int8-01", "int8 mode number surfaces IEEE number", D, "JS API overlay, no oracle analog", undefined, DIV],
29
+ ["int8-02", "int8 mode string surfaces decimal text", D, "JS API overlay, no oracle analog", undefined, DIV],
30
+ ["fn-01", "registerFunction installs a JS scalar", D, "JS API overlay, not in PGMM", undefined, DIV],
31
+ ["dump-01", "exec loads dump no-ops", D, "dump-compat overlay", undefined, "dump-compat-noop"],
32
+ ["do-01", "DO blocks are no-ops", D, "PL/pgSQL is NOT APPLICABLE", undefined, "dump-compat-noop"],
33
+ ["set-01", "ALTER TABLE SET storage parameters are no-ops", D, "storage params are NOT APPLICABLE", undefined, "dump-compat-noop"],
28
34
  ]);
@@ -26,8 +26,13 @@ const rows: ScenarioRow[] = [
26
26
  ["comb-01", "feature combination fuzz", F, undefined, ["tests/fuzz/combinations.test.ts"]],
27
27
  ["dst-01", "stateful DST dump-after-each engine", F, undefined, ["tests/fuzz/stateful.test.ts"]],
28
28
  ["mix-01", "mixed DDL/DML stateful simulation", F, undefined, ["tests/fuzz/mixed-stateful.test.ts"]],
29
+ ["snap-01", "snapshot restore probe fuzz", F, undefined, ["tests/fuzz/snapshot.test.ts"]],
30
+ ["snap-02", "snapshot object round-trip contracts", F, undefined, ["tests/contract/snapshots/objects.test.ts"]],
31
+ ["snap-03", "extended logical dump (views/matviews/sequences)", F, undefined, ["tests/harness/state-dump.ts"]],
29
32
  ["robust-01", "PostgresError-only / snapshot bit-flip robustness", F, undefined, ["tests/fuzz/robustness.test.ts"]],
30
33
  ["corpus-01", "corpus regression replay", F, undefined, ["tests/fuzz/corpus.test.ts"]],
34
+ ["num-01", "numeric arithmetic differential fuzz", F, undefined, ["tests/fuzz/numeric.test.ts"]],
35
+ ["cpy-01", "COPY FROM STDIN differential fuzz", F, undefined, ["tests/fuzz/copy.test.ts"]],
31
36
  ["seed-01", "default seed 0x5a17e0e1", P, undefined, ["tests/fuzz/config.ts"]],
32
37
  ["replay-01", "POSTGRES_MEM_FUZZ_SEED / PATH replay", P, undefined, ["tests/fuzz/config.ts"]],
33
38
  ["min-01", "DST minimizer and repro script", P, undefined, ["tests/fuzz/dst/minimize.ts"]],
@@ -16,6 +16,7 @@ export const SNP_SECTION: CatalogSection = section("SNP", "Snapshot / restore",
16
16
  ["hdr-02", "corrupt magic errors distinctly", D, SNAP_NOTE, EXTRA, DIV],
17
17
  ["hdr-03", "truncated blob errors", D, SNAP_NOTE, EXTRA, DIV],
18
18
  ["hdr-04", "future version raises snapshot_version", D, SNAP_NOTE, EXTRA, DIV],
19
- ["txn-01", "restore during transaction errors", D, SNAP_NOTE, EXTRA, DIV],
20
- ["rep-01", "restore replaces entire state", D, SNAP_NOTE, EXTRA, DIV],
19
+ ["txn-01", "snapshot during transaction errors", D, SNAP_NOTE, EXTRA, DIV],
20
+ ["open-01", "Snapshot.open CoW fork", D, SNAP_NOTE, EXTRA, DIV],
21
+ ["open-02", "encode does not mutate; decode reuses buffer", D, SNAP_NOTE, EXTRA, DIV],
21
22
  ]);
@@ -1116,7 +1116,6 @@
1116
1116
  "jsonb_path_query": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
1117
1117
  "jsonb_path_query_array": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
1118
1118
  "jsonb_path_query_array_tz": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
1119
- "jsonb_path_query_first": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
1120
1119
  "jsonb_path_query_first_tz": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
1121
1120
  "jsonb_path_query_tz": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
1122
1121
  "jsonb_populate_record": "not implemented in this milestone; calls fail loud with SQLSTATE 42883",
@@ -1,3 +1,4 @@
1
+ import type { Int8Mode } from "../runtime/options.js";
1
2
  import { type Datum, type OutputCtx, type TypedValue, type TypeId } from "../types/value.js";
2
3
  /** JavaScript values accepted as query parameters (`$1..$n`). */
3
4
  export type BindValue = null | undefined | boolean | number | bigint | string | Uint8Array | Date;
@@ -7,4 +8,4 @@ export type JsValue = null | boolean | number | bigint | string | Uint8Array;
7
8
  export type QueryRow = Record<string, JsValue>;
8
9
  export declare function bindValueToTyped(value: BindValue, index: number): TypedValue;
9
10
  /** Convert an engine datum to the public JS value for result rows. */
10
- export declare function datumToJs(t: TypeId, v: Datum, ctx: OutputCtx): JsValue;
11
+ export declare function datumToJs(t: TypeId, v: Datum, ctx: OutputCtx, int8?: Int8Mode): JsValue;
@@ -1,5 +1,6 @@
1
- import { type DatabaseOptions, type RandomMode } from "../runtime/index.js";
2
- import type { BindValue, QueryRow } from "./bind.js";
1
+ import { type DatabaseOptions, type Int8Mode, type RandomMode } from "../runtime/index.js";
2
+ import type { BindValue, JsValue, QueryRow } from "./bind.js";
3
+ import { type Snapshot } from "./snapshot.js";
3
4
  import { Statement } from "./statement.js";
4
5
  /**
5
6
  * Pure TypeScript in-memory PostgreSQL database.
@@ -24,6 +25,8 @@ export declare class Database {
24
25
  readonly seed: number | bigint;
25
26
  /** Entropy mode for `random()` / `gen_random_uuid()`. */
26
27
  readonly randomMode: RandomMode;
28
+ /** How `int8` columns surface in query rows. */
29
+ readonly int8Mode: Int8Mode;
27
30
  private closed;
28
31
  private transactionSequence;
29
32
  private apiTransactionDepth;
@@ -33,6 +36,13 @@ export declare class Database {
33
36
  * are allowed. Does not accept bind parameters — use {@link prepare} / {@link query}.
34
37
  */
35
38
  exec(sql: string): void;
39
+ /**
40
+ * Register a JavaScript function callable from SQL. Not encoded in PGMM
41
+ * snapshots — re-register after {@link Snapshot.decode} / {@link Snapshot.open}.
42
+ * {@link Snapshot.open} copies the implementation by reference when opening
43
+ * a live in-memory snapshot.
44
+ */
45
+ registerFunction(spec: RegisterFunctionOptions): void;
36
46
  /** Execute a single-statement query and return all rows keyed by column name. */
37
47
  query<T = QueryRow>(sql: string, params?: readonly BindValue[]): T[];
38
48
  /** Compile a single SQL statement into a reusable {@link Statement}. */
@@ -43,12 +53,11 @@ export declare class Database {
43
53
  */
44
54
  transaction<T>(fn: () => T): T;
45
55
  /**
46
- * Serialize schema, rows, PRNG state, and clock into a `PGMM` snapshot blob.
47
- * Restore with {@link restore}. Not an on-disk PostgreSQL format.
56
+ * Freeze this database into a reusable {@link Snapshot} template.
57
+ * Does not encode PGMM bytes. Call {@link Snapshot.encode} to persist, or
58
+ * {@link Snapshot.open} for a copy-on-write fork.
48
59
  */
49
- snapshot(): Uint8Array;
50
- /** Replace this database's contents with a blob from {@link snapshot}. */
51
- restore(snapshot: Uint8Array): void;
60
+ snapshot(): Snapshot;
52
61
  /** Close the database. Further SQL throws. Idempotent. */
53
62
  close(): void;
54
63
  /**
@@ -66,4 +75,13 @@ export declare class Database {
66
75
  get changes(): number;
67
76
  private prepareSingle;
68
77
  }
78
+ export { Snapshot } from "./snapshot.js";
79
+ /** Options for {@link Database.registerFunction}. */
80
+ export interface RegisterFunctionOptions {
81
+ name: string;
82
+ args: string[];
83
+ returns: string;
84
+ strict?: boolean;
85
+ fn: (...args: JsValue[]) => JsValue;
86
+ }
69
87
  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 PGMM bytes are produced lazily via {@link encode}.
6
+ */
7
+ export declare class Snapshot {
8
+ private cachedBytes;
9
+ /** Encoded PGMM 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
+ }
@@ -680,6 +680,8 @@ export type AlterTableAction = {
680
680
  } | {
681
681
  kind: "validate_constraint";
682
682
  name: string;
683
+ } | {
684
+ kind: "reloptions";
683
685
  };
684
686
  export interface AlterTableStmt {
685
687
  readonly type: "alter_table";
@@ -18,6 +18,7 @@ interface UniqueSpec {
18
18
  isPrimary: boolean;
19
19
  }
20
20
  export declare function uniqueSpecsFor(env: ExecEnv, table: TableData): UniqueSpec[];
21
+ export declare function uniqueKeyOf(env: ExecEnv, table: TableData, spec: UniqueSpec, row: Datum[]): string | null;
21
22
  /**
22
23
  * Unique / primary key enforcement (23505). `selfIdx` is the row's own index
23
24
  * in table.rows (already inserted / updated in place).