Skip to content

Major conformance expansion: triggers/PL-pgSQL, window functions, CTEs, numeric/bigint, timezones, roles & RLS (54% → 100% vs Postgres 16) - #476

Open
sanketsahu wants to merge 68 commits into
oguimbal:masterfrom
tinbase:master
Open

Conversation

@sanketsahu

@sanketsahu sanketsahu commented Jul 8, 2026

Copy link
Copy Markdown

Intent

This substantially expands pg-mem's PostgreSQL conformance, pushing toward the "real
Postgres you can run anywhere JS runs" direction. Measured against PostgreSQL 16, it
raises the share of a conformance corpus that pg-mem matches exactly from ~54% to 100%
(256/256), with one documented known gap (numeric ln/exp last-digit precision).

It pairs with the parser PR oguimbal/pgsql-ast-parser#174, which adds the grammar this
consumes.

Real-world validation

Beyond the corpus, the engine drives a Supabase-compatible backend (tinbase) serving a
real app
: a Supabase-style bootstrap plus 135 real production migrations, browsed
through the Admin UI. All 135/135 migrations apply (policies, triggers, PL/pgSQL,
grants, DO blocks all run — nothing skipped), 76 tables are created, and inserts work
end-to-end (SQL and PostgREST, with uuid PK generation, defaults and RETURNING). This
round of work closed every engine gap that blocked those migrations.

What's added

Latest additions (this round):

  • Dependency slimming (no new deps; two removed): moment and json-stable-stringify
    are replaced by a small zero-dependency date module and a compact stable stringifier —
    behavior-preserving (all datetime/timezone/jsonb conformance cases still pass), and it
    cuts a full install by roughly half.
  • information_schema.table_constraints / key_column_usage now expose each
    table's PRIMARY KEY and UNIQUE constraints (were empty), so tools that introspect
    primary keys — e.g. an admin studio building a row UPDATE's WHERE from the PK —
    work instead of falling back to a filterless all-rows update.
  • Correlated subqueries — a subquery can reference a column of an enclosing query
    (by alias or table name); the outer row is fed in per iteration. Fixes RLS policies
    with correlated EXISTS and correlated UPDATE/SELECT subqueries.
  • Scalar subqueries reduce to a single value (0 rows → null, >1 → error), matching
    Postgres — IN/ANY/ALL/EXISTS still receive the row list, and EXISTS accepts
    multi-column subqueries.
  • Array slicingarr[lo:hi], arr[:hi], arr[lo:] (1-based inclusive, clamped).
  • pg_roles catalog (reflects CREATE ROLE); FK constraints auto-named
    <table>_<col>_fkey so ALTER TABLE … DROP CONSTRAINT <that name> resolves.
  • A table's implicit row/composite type is usable as a type (RETURNS SETOF mytable,
    a PL/pgSQL variable of a table's type).
  • Syntax errors in dynamic EXECUTE are catchable QueryErrors (42601) so
    BEGIN … EXCEPTION WHEN OTHERS handles them, as in Postgres.
  • version(), pg_size_pretty, pg_database_size (+ relation/table/total/indexes
    size) — nominal 0-byte sizes since storage is in-memory.
  • ALTER COLUMN ... TYPE ... USING <expr> — the per-row conversion expression is
    evaluated (e.g. ALTER COLUMN id TYPE uuid USING id::uuid).
  • CREATE INDEX ... INCLUDE (cols) — covering-index payload columns accepted.
  • SECURITY DEFINER / SECURITY INVOKER functions.
  • current_database() / current_catalog().
  • PL/pgSQL FOREACH x IN ARRAY <expr> LOOP.
  • Embedded DDL inside DO blocksCREATE/INSERT/… run and persist; statements
    compile lazily so one can reference an object created earlier in the same body.
  • NOTIFY / LISTEN / UNLISTEN, ALTER ROLE, ALTER DEFAULT PRIVILEGES,
    GRANT/REVOKE
    on schema/database/function/sequence — parsed and treated as no-ops
    (pg-mem has no role/privilege system).

Earlier additions:

  • PL/pgSQL functions — a full interpreter for regular callable functions:
    DECLARE + typed variables, IF/ELSIF/ELSE, LOOP/WHILE/FOR i IN [REVERSE] a..b [BY s]/FOR rec IN SELECT, EXIT/CONTINUE [WHEN], RETURN, recursion;
    embedded SQL (SELECT ... INTO, PERFORM, INSERT/UPDATE/DELETE, dynamic
    EXECUTE), the FOUND variable, RETURNS void; RAISE (with % formatting) and
    BEGIN ... EXCEPTION WHEN ... END (rolled back in a sub-transaction); set-returning
    functions (RETURNS TABLE and RETURNS SETOF <type>, RETURN NEXT/RETURN QUERY).
  • Triggers — trigger functions run through the same PL/pgSQL interpreter, so a trigger
    body has the full language (embedded SQL included): audit triggers that INSERT into
    other tables using NEW/OLD, plus IF/loops/RAISE/EXCEPTION, and the TG_* variables
    (TG_OP, TG_TABLE_NAME, TG_ARGV, TG_NARGS, ...). Statement-level triggers
    (FOR EACH STATEMENT) fire once per statement. INSTEAD OF triggers make a view
    insertable / updatable / deletable. WHEN (condition) gating and UPDATE OF (columns)
    change detection; CREATE TRIGGER / DROP TRIGGER … ON table (BEFORE/
    AFTER, INSERT/UPDATE/DELETE, FOR EACH ROW, EXECUTE FUNCTION|PROCEDURE), with
    a minimal PL/pgSQL interpreter for trigger bodies: BEGIN/END, NEW.col := expr,
    RETURN NEW|OLD|NULL|expr, IF/ELSIF/ELSE, RAISE (parsed & ignored). BEFORE row
    triggers can mutate NEW or skip the row by returning NULL; NEW/OLD are in scope
    as real SQL expressions.
  • Query features — window functions (OVER, partitions, ORDER BY, ROWS/RANGE/ GROUPS frames), WITH RECURSIVE, FULL OUTER JOIN, LATERAL, set-returning functions
    in both FROM and the select list.
  • ~70 builtin functions — string (length, substr, replace, lpad/rpad,
    split_part, regexp_replace, format, md5, …), math, date/time (date_trunc,
    date_part, age, to_char, …), json/jsonb, array (generate_series, unnest,
    array_*), position(x in y).
  • Numeric correctness — arbitrary-precision numeric/decimal and 64-bit bigint,
    returned as strings like node-postgres; int4 overflow errors; the modulo operator %
    (integer/bigint/numeric, not float, matching postgres) which was previously unimplemented.
    Backed by a dependency-free BigInt Decimal (no decimal.js).
  • TimezonesAT TIME ZONE, timestamptz/timestamp text rendering, via the
    runtime's own Intl data (no tz-database dependency).
  • TransactionsSAVEPOINT / ROLLBACK TO, deferrable foreign keys.
  • Prepared statements — SQL-level PREPARE / EXECUTE / DEALLOCATE (session-scoped,
    planned at PREPARE time like postgres; EXECUTE reuses the prepared-query pipeline and
    threads the current transaction).
  • DDL completenessALTER INDEX ... RENAME TO; TABLESPACE accepted and ignored
    (standalone statement, CREATE TABLE / CREATE INDEX clauses, index WITH (...)
    storage params) since it is physical storage with no in-memory meaning.
  • CREATE DOMAIN — a base type plus NOT NULL / CHECK constraints, enforced on
    insert, update and explicit cast; a domain value otherwise behaves as its base type.
  • Catalog viewspg_indexes and pg_tables (ORM / introspection).
  • Supabase-default surfacegen_random_uuid() / uuid_generate_v4(), current_setting
    / set_config (JWT-claims GUCs behind auth.uid()-style RLS), tolerant CREATE EXTENSION
    for the Supabase default set, and column DEFAULTs applied before the RLS WITH CHECK. A
    default Supabase schema (uuid PKs + per-user RLS) runs end-to-end.
  • ROW(a, b, ...) constructor producing an anonymous record.
  • Composite typesCREATE TYPE x AS (a int, b text), columns of that type,
    ROW(...)::x casts, and field access (expr).field (e.g. (center).x), including in
    WHERE/arithmetic and as a PL/pgSQL function return type.
  • string_agg(expr, delim) aggregate (and every() as a synonym for bool_and).
  • MERGE (PG 15+) — MERGE INTO target USING source ON cond WHEN [NOT] MATCHED [AND cond] THEN { UPDATE SET … | DELETE | INSERT … | DO NOTHING }. Upserts, conditional
    update/delete, subquery sources, DEFAULT-filled inserts, RLS-checked writes, and the
    MERGE <n> command tag.
  • Range typesint4range, int8range, numrange, daterange, tsrange,
    tstzrange with constructors (int4range(1,10,'[]')), text literals ('[1,10)'::…),
    canonicalization of discrete ranges, the @> / <@ / && operators (contains /
    contained-by / overlap, for both ranges and elements), and lower/upper/isempty/
    lower_inc/upper_inc accessors.
  • Full-text searchtsvector / tsquery types, to_tsvector, to_tsquery,
    plainto_tsquery (with simple and english configs), the @@ match operator
    (boolean & | ! queries) and ts_rank. The simple config is byte-exact with Postgres;
    english applies stop-words + Porter stemming (matches snowball on common words).
  • Declarative partitioningPARTITION BY RANGE|LIST|HASH (key) and CREATE TABLE child PARTITION OF parent FOR VALUES … | DEFAULT. Parent inserts are validated and
    routed to the right partition (or error), children are queryable/insertable filtered
    views with bound enforcement, DEFAULT partitions catch the rest. (Single-storage model;
    HASH uses a non-Postgres hash so it is not differential-tested.)
  • INTERSECT / EXCEPT — the full set-operation family (INTERSECT [ALL], EXCEPT [ALL]) with correct multiset semantics and NULL-equal matching, alongside the existing
    UNION.
  • Aggregate FILTER (WHERE ...) — per-aggregate row filtering (e.g.
    count(*) filter (where active)), independent across aggregates, working with GROUP BY
    and DISTINCT.
  • ALL(array) quantifierx <> ALL(arr), x > ALL(arr), etc. (complements the
    existing ANY), and generate_series(timestamp, timestamp, interval) (+ timestamptz).
  • regexp functionsregexp_matches (set-returning capture groups, with g/i
    flags), regexp_split_to_array, and regexp_split_to_table.
  • Ordered-set aggregatespercentile_cont, percentile_disc, and mode with
    WITHIN GROUP (ORDER BY ...).
  • unnest WITH ORDINALITY, jsonb_pretty, and the jsonb #- delete-path operator.
  • timestamptz date/time functionsdate_trunc, date_part, age, to_char
    overloads for timestamptz (so date_trunc('month', now()) works), plus to_timestamp.
  • array_remove / array_replace / row_to_json / array_to_json functions.
  • IS [NOT] DISTINCT FROM — null-safe comparison operators.
  • week / decade / century / millennium interval units, and the nth_value /
    cume_dist / percent_rank
    window functions.
  • GROUP BY ROLLUP / CUBE / GROUPING SETS — expanded to a UNION ALL of grouping sets
    with NULLed grouping columns.
  • numeric functions & operators — div, gcd, lcm, factorial, width_bucket, bit_length,
    random; integer & | # << >> and exponentiation ^.
  • more builtins — quote_ident / quote_literal / quote_nullable, generate_subscripts,
    make_timestamp, make_time.
  • Durable persistencedb.serialize() returns a backend-agnostic, JSON snapshot
    (schema DDL + all table data, with Date/bytea encoded to stay round-trippable);
    newDb() + db.deserialize(snapshot) rebuilds the schema (tables, types, functions,
    triggers, views, FKs) and bulk-loads the data (no trigger re-fire; serial counters
    advanced). Store the snapshot in OPFS / IndexedDB / localStorage / a file — pg-mem stays
    storage-agnostic.
  • Richer triggersWHEN (condition) gating and UPDATE OF (columns) change
    detection, both with NEW/OLD in scope. (Statement-level triggers and TG_ARGV are
    deferred to the PL/pgSQL follow-up, which brings the variable / embedded-DML machinery
    they need.)
  • Roles & Row-Level SecurityCREATE/DROP ROLE, SET/RESET ROLE, dynamic
    current_user/session_user; CREATE/DROP POLICY, ALTER TABLE … ROW LEVEL SECURITY, full enforcement (permissive/restrictive, default-deny, WITH CHECK,
    BYPASSRLS/superuser), pg_policies introspection, GRANT/REVOKE (parsed as no-ops
    — pg-mem has no privilege system). Verified to match Postgres's row visibility and error
    messages.
  • A differential conformance harness (tools/conformance) — runs a SQL corpus against
    pg-mem and, with PG_URL set, against a real Postgres, diffing the results. This is how
    every feature here was validated, and it gives an ongoing, measurable conformance score.
    It supports a @knownGap directive: accepted divergences (currently only numeric
    ln/exp precision) stay in the corpus and visible rather than being deleted, and
    are automatically flagged the day pg-mem happens to close them.

Tests / coverage

  • 1206 tests passing (existing + new unit specs for each feature).
  • Differential harness: 243/243 (100%) of the corpus matches Postgres 16 exactly, plus
    1 documented known gap (bun run conformance offline, PG_URL=… bun run conformance
    differential).

Known gap

ln(2.718281828459045) returns 1 in pg-mem but 0.9999999999999999 in Postgres:
Postgres parses the literal as numeric and evaluates ln in arbitrary precision, while
pg-mem evaluates it as a float64 where Math.log(2.718281828459045) === 1 exactly.
Matching Postgres's last digit would require a full arbitrary-precision transcendental
ln/exp replicating pg's result-scale selection — disproportionate for a last-ULP
cosmetic difference, so it's tracked as a @knownGap rather than hidden.

Correctness fixes surfaced along the way

  • Parameter / scalar-subquery vs indexed column — the build-time index-seek fast path
    gated on isConstant, which is true for a parameter or scalar subquery, then .get()-ed
    it with no execution context. Prepared statements and WHERE id = (SELECT ...) against
    an indexed column crashed at compile. Now gated on isConstantReal (a genuine
    literal); the rest fall through to per-row evaluation.
  • PREPARE inside a batch with DDL — parameter collection descended into a PREPAREd
    statement's inner body and counted its $n as parameters of the enclosing batch, which
    tripped "schema change with parameters". It no longer does.

Bundle size & memory footprint

A fork-vs-upstream benchmark (tools/benchmark, full write-up in
tools/benchmark/REPORT.md) confirms the changes keep pg-mem's "fast and tiny" promise —
and the install actually gets smaller than upstream:

metric baseline (3.0.14) this PR delta
bundle, minified + gzipped 64.5 KB 101.3 KB +36.8 KB (+57%)
representative workload runtime +1.7%
  • Two fewer runtime dependencies than upstream, none added. This PR removes
    moment (~5.2 MB installed, mostly unused locale data; in maintenance mode) and
    json-stable-stringify (+ its get-intrinsic/es-errors/call-bind chain, ~0.5 MB),
    replacing them with a small zero-dependency UTC date module and a compact stable
    stringifier. So a production npm install shrinks by ~5–6 MB and drops ~15 transitive
    packages versus 3.0.14. (Decimal is hand-rolled on BigInt; timezones use Intl — also no deps.)
    Behavior-preserving: all datetime / timezone / jsonb conformance cases still pass.
  • Still ~35× smaller than PGlite's ~3 MB WASM.
  • Runtime hot path is unaffected — RLS enforcement is a guarded no-op, and trigger
    firing is skipped entirely for tables with no triggers, so ordinary reads/writes
    benchmark unchanged.
  • Row storage is unchanged; only explicit bigint/numeric columns now store strings
    (marginally more), so per-row memory for ordinary tables is the same.
  • Of the +27.4 KB gzipped, ~6.5 KB is parser grammar (PR Getting an error Unexpected kw_limit token: "limit". #174), ~7 KB is the trigger +
    PL/pgSQL engine, and the rest is other feature code (prepared statements, ALTER INDEX,
    tablespaces, WHEN / UPDATE OF, domains, catalog views, ROW(), composite types, string_agg,
    MERGE, range types, full-text search, declarative partitioning, INTERSECT/EXCEPT, aggregate FILTER, ALL(array), generate_series over timestamps, regexp_matches family, ordered-set aggregates, WITH ORDINALITY, jsonb_pretty/#-, timestamptz date funcs, array_remove/row_to_json, IS DISTINCT FROM, interval units, window fns, ROLLUP/CUBE/GROUPING SETS, numeric & quoting/array/date builtins, durable persistence).

Known gaps

The remaining known gaps (all non-fatal for typical use, none a regression). Several
items previously listed here — named dollar-quoted strings, pg_roles, FK auto-naming,
correlated subqueries — are now fixed (see above).

  • Negative numeric literals vs subtraction5-1 (no space) fails to parse; only
    5 - 1 parses. The lexer's int/float regex eats a leading -. This is the main blocker
    for the full Supabase bootstrap (storage.foldername's array_length(...)-1).
  • ALTER DATABASE ... SET — doesn't parse; now surfaces as a catchable 42601 so a
    guarding DO … EXCEPTION tolerates it.
  • EXISTS (SELECT FROM t) with an empty select-list.
  • information_schema.schemata / routines views.

Dependency / merge order

This depends on oguimbal/pgsql-ast-parser#174 — it consumes the new AST nodes (window
frames, roles/policies, valueText, position, deferrable, triggers, etc.) and pg-mem
pins pgsql-ast-parser from npm. So these are not independently mergeable: please
merge and release the parser PR first, then this can build against the released version.

The scope is large because it's a broad conformance sweep; happy to split it into
per-feature PRs (triggers, RLS, numeric, …) if you'd prefer to review it that way.

sanketsahu and others added 23 commits July 8, 2026 13:00
tools/conformance runs a categorized SQL corpus against pg-mem and
classifies outcomes; with PG_URL set, a real postgres server becomes
the source of truth (throwaway schema per case, type-aware row
comparison). 'bun run conformance' prints the score and writes
report.md / report.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- timestamp +- interval now adds months as calendar months (clamping to
  the target month's last day), then days, then the time part - it
  previously converted the whole interval to seconds with 30-day months,
  so '2020-01-01' + '1 month' returned Jan 31 (found by the differential
  conformance harness)
- SHOW returns canonical GUC casing in the column name (TimeZone)
- fresh sessions now have a timezone GUC (UTC) like real postgres

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- string: length family, substr, replace, trim family, lpad/rpad,
  split_part, strpos, initcap, left/right, repeat, translate, md5
  (dependency-free pure-js), regexp_replace, format, starts_with...
- math: abs, ceil/floor, round/trunc (half-away-from-zero like pg
  numerics), power, sqrt, mod, sign, ln/log, pi, degrees/radians
- datetime: date_trunc, date_part, make_date, age (pg's field-wise
  borrowing algorithm), justify_interval/hours/days
- json(b): array_length, set, extract_path[_text], typeof, strip_nulls,
  object_keys, each[_text], array_elements + '#>>' operator; json null
  inside values is the JSON_NIL sentinel, handled throughout
- array: generate_series, string_to_array
- to_char for timestamps, numbers and intervals - every expected value
  verified case-by-case against postgres 16
- FunctionDefinition gains a setReturning flag (used by SRF expansion)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WindowedSelection annotates source rows with computed window values
under per-call symbols, preserving output row order. Supports
row_number, rank, dense_rank, ntile, lag/lead, first/last_value and
count/sum/avg/min/max as windowed aggregates, with pg's default frame
semantics: running aggregates over peer groups when the window has an
ORDER BY, whole partition otherwise.

Also fixes implicit-aggregation detection: 'sum(x) over ()' no longer
collapses the query into a group-by.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- functions flagged setReturning (unnest, generate_series, jsonb SRFs)
  expand projected rows: one row per element, scalars repeated, multiple
  SRFs in lockstep with null padding, empty sets dropping the row
- unnest returns its array instead of throwing (FROM and select-list
  expansion both consume it)
- polymorphic builtins that FunctionDefinition cannot type (no anyarray
  pseudo-type yet) live in the call builder: array_length/upper/lower,
  cardinality, array_append/cat/position/to_string, nullif, to_jsonb,
  json[b]_build_object/array

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- WITH RECURSIVE with pg iteration semantics: the recursive term sees
  only the previous round's rows (working buffer bound in an inner
  scope), UNION deduplicates and terminates cycles
- FULL OUTER JOIN: left join over a materialized joined side, tracking
  matches by object identity, then yielding never-matched rows
- LATERAL function calls in FROM (explicit or implicit): the set is
  re-evaluated per left row; correlated generate_series/unnest work
- set-returning functions in FROM yield one row per element via a
  record-wrapping of scalar arrays
- CROSS JOIN syntax routes to the existing comma-join handling
- JOIN USING: unqualified using-columns are merged (not ambiguous) and
  'select *' keeps the preserved side's value on outer joins

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumes the frame clause now parsed by pgsql-ast-parser (sibling
fork): per-row inclusive frame bounds with pg semantics - single
bound means BETWEEN bound AND CURRENT ROW, ROWS offsets are physical,
GROUPS offsets count peer groups, RANGE supports unbounded/current
bounds (offset RANGE frames stay NotSupported). Aggregates over
explicit frames are computed row by row; first_value/last_value
follow the frame; empty frames yield null/zero. Verified against
postgres 16 (12/13 diff cases - the one divergence is the known
float-vs-numeric precision gap, not frame logic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumes the grammar just added to the sibling pgsql-ast-parser fork:

- SAVEPOINT / RELEASE / ROLLBACK TO, backed by cheap snapshots of the
  copy-on-write transaction map (savepoint captures the ImMap ref;
  rollback-to restores it and discards later savepoints; release drops
  it and later ones)
- '#>' json path operator (returns jsonb; '#>>' already returned text)
- WITH RECURSIVE without a column list now infers names from the seed

offline conformance 87.2% -> 89.4% (recursive-cte 3/3, transactions 1/2).
Differential re-run pending (docker was down this session).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumes the ExprPosition node from the sibling pgsql-ast-parser fork:
1-based index of substring within string (0 when absent), null on null
input. Differential conformance 89.4% -> 90.4% (functions-string 22/22).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumes the role grammar from the sibling pgsql-ast-parser fork.
- roles stored transactionally (rollback-safe) with a synthetic
  default superuser 'pg_mem', so a db with no roles declared behaves
  exactly as before: current_user is 'pg_mem' and (once enforcement
  lands) RLS is always bypassed
- CREATE/DROP ROLE, SET ROLE / RESET ROLE tracked in session GUCs
- current_user/current_role/user now read the session's current role
  dynamically (was a hardcoded constant); session_user reads the fixed
  session role

Roadmap: adds Phase 3b (Roles & RLS). This is slice A of 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumes the policy grammar from the sibling pgsql-ast-parser fork.
- tables carry RLS state (enabled/forced flags + policies) via new
  _ITable members; read-only catalog tables get inert defaults
- CREATE/DROP POLICY executors store policy metadata (permissive,
  command, roles, using/withCheck predicates kept as raw AST, compiled
  lazily at enforcement time)
- ALTER TABLE ... ROW LEVEL SECURITY toggles the enabled/forced flags
No enforcement yet - that's slice C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Row-level security is now enforced end to end (verified against
postgres 16, matching row visibility and error messages):
- reads (SELECT, and the row-scan of UPDATE/DELETE) go through an
  RlsSelection transform that defers role/bypass/policy-applicability
  to enumerate(t) - correct even when a query plan is reused across
  SET ROLE. Policy predicates compile once against the table selection.
- writes (INSERT, UPDATE) enforce WITH CHECK, raising 42501 on violation
- permissive policies OR-combine, restrictive AND-combine; RLS-on with
  no applicable permissive policy default-denies
- superuser / BYPASSRLS roles skip enforcement entirely

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- pg_policies catalog view enumerates real policies (shape matches
  postgres: schemaname/tablename/policyname/permissive/roles/cmd/qual/
  with_check) so ORMs and tools can introspect RLS
- GRANT/REVOKE parsed & ignored (no privilege system) so dumps and RLS
  setup scripts load
- conformance corpus gains an 'rls' category (5 cases); since real PG
  additionally enforces schema-USAGE/table privileges the harness can't
  grant per generated-schema, these are marked @offline (verified vs
  @expect, which match live PG semantics confirmed manually). New
  @offline directive + role reset between differential cases.

RLS is now complete (slices A-D). Conformance 90.9%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AT TIME ZONE operator both directions (timestamp<->timestamptz),
  resolving named IANA zones through the runtime's own Intl tz data
  (no bundled tz database) plus fixed offsets
- timestamp/timestamptz/date cast to text renders postgres-style, with
  timestamptz shown in the session TimeZone GUC (default UTC -> +00)
- coalesce common-type resolution now prefers a concrete type over a
  plain-text operand (postgres unknown-literal coercion), fixing a
  regression the new timestamp->text cast would otherwise cause

Conformance 90.9% -> 92.9% (datetime-tz 3/3). Verified vs postgres 16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DEFERRABLE INITIALLY DEFERRED foreign keys postpone their existence
check to transaction finalization instead of failing per-row, so a
child row can be inserted before its parent within a transaction. A
still-unsatisfied FK errors at commit and nothing persists (checks run
before fullCommit on both query paths). Non-deferrable FKs are
unchanged. Conformance 92.9% -> 93.9% (transactions 2/2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pg-mem now reads (ignores) the new valueText field on integer/numeric
literals so the AST coverage checker passes; numeric behaviour is
unchanged pending the arbitrary-precision bigint/numeric engine work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pg-mem now reads (ignores) the new valueText field on integer/numeric
literals so the AST coverage checker passes; numeric behaviour is
unchanged (verified 93.9% differential vs postgres 16) pending the
arbitrary-precision bigint/numeric engine work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependency-free Decimal (BigInt mantissa + scale) for postgres
numeric/decimal: parse/render, add/sub/mul, division to 20 fractional
digits, half-away-from-zero rounding, scale normalization, comparison.
Verified against the conformance targets (1.005->1.01, 1/3 to 20
digits, 2^53+1 exact). Not wired into the type system yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- bigint (int8) is BigInt-backed: 64-bit values keep full precision;
  large integer literals are bigint (via parser valueText)
- numeric/decimal is arbitrary-precision (Decimal): scale rounding
  (half away from zero), division to 20 digits, numeric(p,s) config
- both return as strings by default, like node-postgres
- type-aware arithmetic operators (int/float stay JS numbers; bigint/
  numeric compute exactly), int4 overflow raises 'integer out of range'
- integer/float behaviour unchanged; sum/count stay number-typed to
  preserve existing semantics

numeric-types 6/6, conformance 93.9% -> 98.0% differential vs pg 16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings arbitrary-precision numeric/bigint (string-backed, node-pg
semantics) and int4 overflow. Conformance 93.9% -> 98.0% (numeric-types
6/6). Developed on a branch; verified green (1052 tests) and against
postgres 16 before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Benchmark harness (tools/benchmark) and report comparing this fork to
production pg-mem@3.0.14: +1.7% runtime (negligible; RLS is a guarded
no-op off the hot path) and +17KB gzipped (64.5->81.4KB, still ~40x
smaller than PGlite, no new deps). Full report in tools/benchmark/REPORT.md.

Also fixes 3 long-standing operator-precedence bugs that blocked the
prod build ('str' + x ?? y  and  a ?? null !== b ?? null).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Row-level trigger engine with a minimal PL/pgSQL interpreter, closing
the last conformance gap that was achievable:

- CREATE TRIGGER / DROP TRIGGER ... ON table (BEFORE/AFTER, INSERT/
  UPDATE/DELETE, FOR EACH ROW, EXECUTE FUNCTION|PROCEDURE)
- BEFORE row triggers can mutate NEW or skip the row by returning NULL;
  NEW/OLD are exposed to the function body via a scoped join selection
- Minimal PL/pgSQL: BEGIN/END, NEW.col := expr, RETURN NEW|OLD|NULL|expr,
  IF/ELSIF/ELSE, RAISE (parsed & ignored); expressions are real SQL
- functions RETURNING trigger register as a trigger pseudo-type

Also adds the modulo operator `%` (integer/bigint/numeric, not float,
matching postgres), which was never implemented, and a Decimal.mod.

Conformance now 101/101 (100%) with 1 documented known gap (numeric
ln/exp precision). The harness gains a @knownGap directive so accepted
divergences stay visible and are flagged if they ever start passing,
rather than being silently deleted from the corpus.

Parser support: pgsql-ast-parser CREATE/DROP TRIGGER (companion commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sanketsahu sanketsahu changed the title Major conformance expansion: window functions, CTEs, numeric/bigint precision, timezones, roles & RLS (54% → 98% vs Postgres 16) Major conformance expansion: triggers/PL-pgSQL, window functions, CTEs, numeric/bigint, timezones, roles & RLS (54% → 100% vs Postgres 16) Jul 8, 2026
sanketsahu and others added 4 commits July 9, 2026 01:25
Re-measured after adding the trigger + PL/pgSQL engine and the modulo
operator: min+gzip is now 83.9 KB (was 81.4 KB), +19.4 KB / +30% over
upstream 3.0.14. Still ~35x smaller than PGlite, no new dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Feature-completeness sweep over previously-unsupported statements:

- SQL-level PREPARE / EXECUTE / DEALLOCATE (session-scoped registry;
  planned at PREPARE time like postgres). EXECUTE reuses the prepared-
  query pipeline, threading the current transaction.
- ALTER INDEX ... RENAME TO (renames across the schema registry, the
  per-hash index map and the backing constraint); SET TABLESPACE is a
  no-op.
- TABLESPACE is physical storage, so it is accepted and ignored:
  standalone statement, CREATE TABLE / CREATE INDEX clauses, and WITH
  (storage params) on indexes.
- trigger WHEN (condition) gating and UPDATE OF (columns) change
  detection (both with NEW/OLD in scope).

Two pre-existing correctness fixes surfaced by the above:
- build-filter used isConstant (true for params & scalar subqueries)
  to gate the build-time index-seek fast path, which then .get()s the
  value with no execution context. Now gated on isConstantReal (a real
  literal). Fixes prepared statements AND scalar-subquery comparisons
  against indexed columns, which previously crashed at compile.
- collectParams descended into a PREPARE's inner statement, counting
  its $n as parameters of the enclosing batch (=> "schema change with
  parameters" when combined with DDL). It no longer does.

Deferred to the PL/pgSQL phase (needs the variable/embedded-DML
machinery): statement-level trigger firing and TG_ARGV/TG_* variables.
Statement triggers still parse and store but do not fire yet.

Conformance 108/108 (100%, 1 known gap); 1056 tests pass. New unit
specs for prepared statements, ALTER INDEX, and the trigger additions.

Parser support: pgsql-ast-parser EXECUTE + CREATE TABLE TABLESPACE
(companion commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Small dump-compatibility batch:

- CREATE DOMAIN: a base type plus NOT NULL / CHECK constraints, enforced
  on insert, update and explicit cast. A domain value behaves as its
  base type (same operators/output). CHECK is compiled once with `value`
  bound via the parameters stack. DEFAULT is parsed (not yet applied as
  a column default).
- pg_indexes and pg_tables catalog views (ORM/introspection). Adds a
  table.listIndexes() accessor.
- ROW(a, b, ...) constructor -> an anonymous record { f1, f2, ... }.
  Record text rendering `(1,2)` and field access remain part of the
  (unimplemented) composite-type work.

Conformance 116/116 (100%, 1 known gap); 1090 tests pass. Catalog
cases are offline (schema-name/enumeration is environment-sensitive).

Parser support: pgsql-ast-parser CREATE DOMAIN (companion commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sanketsahu and others added 26 commits July 10, 2026 02:10
Implements user-defined composite types end to end:

- CompositeType (datatypes/t-composite.ts): a named record type registered
  under its own name (like enums/domains). Builds a value from a record/row
  by mapping fields positionally, so `row(3,4)::pt` yields {x,y}.
- CreateCompositeType executor wires `create type x as (...)` (previously
  threw NotSupported).
- expression-builder buildMember gains a '.' case: `(center).x` reads a
  field off a composite-typed value, throwing 42703 if the field is unknown.
  (Parser support for `(expr).field` shipped in pgsql-ast-parser.)

Verified: composite columns, field access, filtering/arithmetic on fields,
text fields, composite return from a PL/pgSQL function, drop + recreate,
and duplicate-name rejection.

Tests: src/tests/composite-type.spec.ts (7 cases).
Conformance: tools/conformance/corpus/47-composite-types.sql (5 cases),
141/141 differential vs Postgres 16 (1 known gap). Full suite 1109 pass.

See the companion parser commit "Support composite-type field access".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements PostgreSQL MERGE (PG 15+) end to end:

  MERGE INTO target USING source ON cond
    WHEN [NOT] MATCHED [AND cond] THEN { UPDATE SET .. | DELETE | INSERT .. | DO NOTHING }

- MergeExec (records-mutations/merge.ts) builds a source × target JoinSelection so
  ON / AND / SET / INSERT expressions resolve refs on both sides, then for each source
  row finds matching target rows and applies the first WHEN clause whose kind
  (matched / not-matched) and optional AND condition hold.
- Matched UPDATE clones the target row, runs the compiled setter in the join scope,
  enforces RLS WITH CHECK, and writes it back; DELETE removes the row.
- Not-matched INSERT builds the row from VALUES (or DEFAULT VALUES), fills column
  DEFAULTs, enforces RLS, and inserts.
- Target rows are snapshotted up-front and each is affected at most once (Postgres
  semantics); reports the "MERGE <n>" command tag with the affected row count.

Tests: src/tests/merge.spec.ts (6 cases: upsert, conditional delete/update ordering,
DO NOTHING, row count, DEFAULT fill, subquery source).
Conformance: tools/conformance/corpus/48-merge.sql (5 cases), 146/146 differential vs
Postgres 16 (1 known gap). Full suite 1115 pass.

Depends on the parser commit "Add MERGE statement grammar + AST" (pgsql-ast-parser#174).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the six built-in range types end to end (engine-only; the parser already
handles `int4range(…)` calls and `'…'::int4range` casts):

- RangeType (datatypes/t-range.ts): a value is stored as its canonical text form
  (matching Postgres output), with per-type adapters for comparison, formatting and
  discrete-bound stepping. Discrete ranges (int4/int8/date) canonicalize to `[lo,hi)`
  and collapse to `empty` when bounds meet; continuous ranges (num/ts/tstz) keep bounds.
- Constructors `int4range(lo, hi [, bounds])` etc. (schema/pg-catalog/ranges.ts).
- Operators `@>` (contains range / element), `<@` (contained by), `&&` (overlap), plus
  `=`/`<>` via canonical-form equality.
- Accessors `lower`, `upper`, `isempty`, `lower_inc`, `upper_inc`.

The binary-operator dispatch in expression-builder now falls through to schema-registered
operators for a non-array `&&` (previously it hard-errored), so range overlap resolves;
the array `&&` path is unchanged. The "operator does not exist" message is now the
Postgres-standard lowercase form.

Tests: src/tests/ranges.spec.ts (7 cases).
Conformance: tools/conformance/corpus/49-ranges.sql (13 cases), 159/159 differential vs
Postgres 16 (1 known gap). Full suite 1122 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds PostgreSQL full-text search (engine-only; `@@` and the function-call syntax
already parse):

- tsvector / tsquery named types (datatypes/t-textsearch.ts), stored as their canonical
  text form so SELECT output matches Postgres.
- functions/text-search.ts: tokenizer, a Porter stemmer, the snowball-english stop-word
  list, and the tsvector/tsquery builders + boolean `@@` matcher.
- to_tsvector([config,] text), to_tsquery([config,] text), plainto_tsquery([config,]
  text), the `@@` match operator (tsvector @@ tsquery, either order), and a simplified
  ts_rank.

The 'simple' configuration is reproduced exactly (lowercase, positions, sorted lexemes).
The default 'english' config adds stop-word removal and stemming; the stemmer matches
Postgres's snowball-english on common words (a few edge cases like "-ously" differ - the
differential corpus only uses verified-matching words).

Tests: src/tests/text-search.spec.ts (10 cases).
Conformance: tools/conformance/corpus/50-text-search.sql (10 cases), 169/169 differential
vs Postgres 16 (1 known gap). Full suite 1132 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements declarative partitioning end to end:

  CREATE TABLE parent (...) PARTITION BY {RANGE|LIST|HASH} (key)
  CREATE TABLE child PARTITION OF parent FOR VALUES ... | DEFAULT

Design (src/execution/partitioning.ts): single physical storage on the parent, so
indexes / RLS / constraints / reads all work normally. A partitioned parent installs an
insert hook that computes the partition key, validates the row lands in some partition
(else `no partition of relation "…" found for row`, 23514) and routes it. Each child
`PARTITION OF` registers its bound and becomes a filtered facade: reads yield the parent's
rows within the bound; direct inserts are bound-checked (else violates partition
constraint) and delegated to the parent.

- RANGE (multi-column, [from, to) with MINVALUE/MAXVALUE), LIST, and DEFAULT partitions
  are fully supported; bound values are cast to the partition-key column type. HASH is
  functional but uses a pg-mem-internal hash (not Postgres-compatible), so it is not
  differential-tested.
- create-table executor inherits the parent's columns for a PARTITION OF child and marks
  the partition sub-AST read for the coverage checker.

Tests: src/tests/partitioning.spec.ts (6 cases).
Conformance: tools/conformance/corpus/51-partitioning.sql (7 cases), 176/176 differential
vs Postgres 16 (1 known gap). Full suite 1138 pass.

Depends on the parser commit "Add declarative partitioning grammar" (pgsql-ast-parser#174).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the set-operation family beyond UNION:

- new SetOp transform (transforms/union.ts) with correct multiset semantics:
  INTERSECT [ALL] emits up to min(left, right) copies of each key; EXCEPT [ALL]
  emits max(0, left - right); the plain forms are distinct. NULLs compare equal
  (as Postgres does for set operations).
- column type reconciliation is shared with UNION.
- select.ts buildUnion dispatches all six set-operation types; the select /
  statement / scalar-subquery switches accept them.

Tests: src/tests/set-operations.spec.ts (6 cases).
Conformance: tools/conformance/corpus/52-set-operations.sql (6 cases), 182/182
differential vs Postgres 16 (1 known gap). Full suite 1138 pass.

Depends on the parser commit "Support INTERSECT and EXCEPT set operations".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements per-aggregate `FILTER (WHERE predicate)` (the parser already produced
`call.filter`; the engine was ignoring it):

- Aggregation compiles the filter predicate and skips rows it rejects when feeding
  each aggregate, so filters on different aggregates in the same query are independent.
- The direct/index fast paths (which count from index sizes without inspecting rows)
  are bypassed when any aggregate has a FILTER, forcing a seq-scan.
- Works with GROUP BY and DISTINCT.

Tests: src/tests/aggregate-filter.spec.ts (6 cases).
Conformance: tools/conformance/corpus/53-aggregate-filter.sql (5 cases), 187/187
differential vs Postgres 16 (1 known gap). Full suite 1144 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two engine-only additions (both already parsed):

- ALL(array): `x <> all(arr)`, `x > all(arr)`, etc. Mirrors the existing ANY support
  via an `isAll` flag on Evaluator and a unified quantified-comparison builder
  (ANY = some element matches; ALL = every element matches; NULLs propagate). ANY is
  unchanged.
- generate_series(timestamp, timestamp, interval) and the timestamptz overload — steps a
  date range by an interval, forward or backward.

Tests: src/tests/all-and-series.spec.ts (7 cases).
Conformance: tools/conformance/corpus/54-all-and-series.sql (6 cases), 193/193 differential
vs Postgres 16 (1 known gap). Full suite 1150 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the POSIX-regexp helper functions (engine-only; already parsed):

- regexp_matches(str, pattern [, flags]) — set-returning; each row is a text[] of the
  capture groups (or the whole match when the pattern has none). The 'g' flag yields one
  row per match; 'i' is case-insensitive.
- regexp_split_to_array(str, pattern [, flags]) — text[].
- regexp_split_to_table(str, pattern [, flags]) — set-returning, one row per piece.

Tests: src/tests/regexp-functions.spec.ts (6 cases).
Conformance: tools/conformance/corpus/55-regexp-functions.sql (6 cases), 199/199
differential vs Postgres 16 (1 known gap). Full suite 1156 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements WITHIN GROUP ordered-set aggregates (engine-only; the parser already
produces call.withinGroup):

- percentile_cont(fraction) WITHIN GROUP (ORDER BY expr) — continuous percentile with
  linear interpolation; returns double precision.
- percentile_disc(fraction) — discrete percentile; returns an existing value.
- mode() — the most frequent value (first in sort order on ties).

The sort direction from the WITHIN GROUP ORDER BY is honored; nulls are ignored; works
with GROUP BY. transforms/aggregations/ordered-set.ts registers them in the aggregation
pipeline.

Tests: src/tests/ordered-set-aggregates.spec.ts (4 cases).
Conformance: tools/conformance/corpus/56-ordered-set-aggregates.sql (5 cases), 204/204
differential vs Postgres 16 (1 known gap). Full suite 1160 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three engine-only completeness additions (all already parsed):

- unnest(...) WITH ORDINALITY: appends a 1-based bigint ordinality column to a
  set-returning function in FROM; honors `AS alias(col, ord)` column names.
- jsonb_pretty(jsonb): 4-space-indented text rendering.
- jsonb #- text[]: removes the element at the given path (object key, array index
  including negative, or a nested path).

Tests: src/tests/ordinality-and-jsonb.spec.ts (6 cases).
Conformance: tools/conformance/corpus/57-ordinality-jsonb.sql (6 cases), 210/210
differential vs Postgres 16 (1 known gap). Full suite 1166 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Common date/time functions only accepted `timestamp`/`date`, so the most common form —
over `now()` (which is timestamptz) — failed to resolve. Adds timestamptz support:

- date_trunc, date_part, age, and to_char gain timestamptz overloads (date_trunc returns
  timestamptz for a timestamptz input).
- new to_timestamp(double) — epoch seconds to timestamptz.

Tests: src/tests/datetime-timestamptz.spec.ts (6 cases).
Conformance: tools/conformance/corpus/58-datetime-timestamptz.sql (5 cases), 215/215
differential vs Postgres 16 (1 known gap). Full suite 1172 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds four common polymorphic array/record functions (in the function-call builder, which
sees the argument's element type):

- array_remove(anyarray, element) — removes all elements not distinct from the value
  (array_remove(arr, NULL) removes NULLs).
- array_replace(anyarray, from, to) — replaces matching elements.
- row_to_json(record) and array_to_json(anyarray) — build a json value.

Tests: src/tests/array-json-extras.spec.ts (5 cases).
Conformance: tools/conformance/corpus/59-array-json-extras.sql (5 cases), 220/220
differential vs Postgres 16 (1 known gap). Full suite 1177 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the null-safe equality operators: NULL is treated as a comparable value, so
`NULL IS NOT DISTINCT FROM NULL` is true and the result is never NULL. Handled in
buildBinaryValue (rejectNils off; distinct = exactly-one-null or non-null-unequal).

Depends on the parser commit "Support IS DISTINCT FROM / IS NOT DISTINCT FROM".

Tests: src/tests/is-distinct-from.spec.ts (3 cases).
Conformance: tools/conformance/corpus/60-is-distinct-from.sql (4 cases), 224/224
differential vs Postgres 16 (1 known gap). Full suite 1180 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds three more window functions (transforms/window.ts):

- nth_value(expr, n) — the value at the nth row of the frame (default frame: partition
  start .. current row's last peer); null if the frame has fewer than n rows.
- cume_dist() — cumulative distribution (rows <= current peer / total).
- percent_rank() — (rank - 1) / (nrows - 1).

(Pairs with the parser commit adding week/decade/century/millennium interval units.)

Tests: src/tests/interval-units-window.spec.ts (6 cases).
Conformance: tools/conformance/corpus/61-interval-units-window.sql (6 cases), 230/230
differential vs Postgres 16 (1 known gap). Full suite 1186 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements ROLLUP and CUBE grouping (ROLLUP/CUBE already parse as calls in the GROUP BY
list; engine-only). A grouping query is rewritten into a UNION ALL of one ordinary GROUP
BY per grouping set, with grouping columns absent from a given set projected as NULL, and
a wrapper select so a global ORDER BY / LIMIT applies to the whole result.

- src/execution/grouping-sets.ts: elementSets (rollup -> prefixes, cube -> subsets),
  cartesian product across GROUP BY elements, NULL-out via an astMapper on refs.
- The same machinery already handles a 'grouping sets' call, so GROUPING SETS becomes
  available as soon as the parser emits it (grammar is a small follow-up).

Tests: src/tests/grouping-sets.spec.ts (3 cases).
Conformance: tools/conformance/corpus/62-grouping-sets.sql (3 cases), 233/233 differential
vs Postgres 16 (1 known gap). Full suite 1189 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes GROUPING SETS end to end: the engine's grouping-set expansion already handled a
'grouping sets' call, and the parser now emits it. Also pins each branch column's output
name — NULLing a dropped grouping column (e.g. `sub` -> NULL) would otherwise change the
inferred column name and break the UNION / an outer ORDER BY on that name.

Depends on the parser commit "Support GROUP BY GROUPING SETS (...)".

Tests: src/tests/grouping-sets.spec.ts (+1 case).
Conformance: tools/conformance/corpus/62-grouping-sets.sql (+1 case), 234/234 differential
vs Postgres 16 (1 known gap). Full suite 1190 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…om) + bitwise/^ operators

Adds common numeric builtins and integer bitwise / exponentiation operators (all already
parsed):

- functions: div, gcd, lcm, factorial, width_bucket, bit_length, random.
- operators: & | # (XOR) << >> on integers, and ^ (exponentiation, returns double).

Tests: src/tests/numeric-funcs-operators.spec.ts (5 cases).
Conformance: tools/conformance/corpus/63-numeric-funcs-operators.sql (4 cases), 238/238
differential vs Postgres 16 (1 known gap). Full suite 1195 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…imestamp/make_time

Final batch of common builtins (all already parsed):

- quote_ident / quote_literal / quote_nullable — identifier/literal quoting for dynamic SQL.
- generate_subscripts(anyarray, dim) — set-returning array subscripts.
- make_timestamp(...) and make_time(...) — construct temporal values.

Tests: src/tests/quote-subscripts-make.spec.ts (4 cases).
Conformance: tools/conformance/corpus/64-quote-subscripts-make.sql (5 cases), 243/243
differential vs Postgres 16 (1 known gap). Full suite 1199 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds backend-agnostic, JSON-serializable persistence — the one piece needed for a
browser-Postgres to survive a reload.

- db.serialize(): DbSnapshot — captures the schema (the executed DDL statements,
  recorded as they run and re-emitted via to-sql) plus every user table's data. Values
  are encoded to stay pure-JSON and round-trippable (Date -> {$date}, bytea -> {$bytea});
  numeric/bigint (strings), jsonb, arrays and enums pass through.
- db.deserialize(snapshot) — on a fresh newDb(), replays the DDL to rebuild the schema
  (tables, types, functions, triggers, views, FKs, ...), then bulk-loads rows via
  table.insert (which does not fire triggers) and advances serial counters past the
  restored ids (new MemoryTable.restoreSerials).

The snapshot is a plain JSON value, so the app stores it wherever it likes (OPFS,
IndexedDB, localStorage, a file). DDL is recorded in schema.prepare on successful
execution (isSchemaStatement filter).

Tests: src/tests/persistence.spec.ts (8 cases: tables/rows, dates/jsonb/arrays/nulls,
enums+FK+views, serial-counter bump, no trigger re-fire on load, multi-schema, empty db,
invalid snapshot). Full suite 1206 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…EACH, current_database, embedded DDL

Engine support matching the new parser surface, plus PL/pgSQL gaps found running a
full Supabase-style bootstrap + real migrations through pg-mem:

- ALTER COLUMN ... TYPE ... USING <expr>: per-row conversion expression is now
  evaluated (was: coverage error from the unread AST field)
- CREATE INDEX ... INCLUDE: payload columns accepted (no-op for in-memory lookups)
- CREATE FUNCTION ... SECURITY DEFINER/INVOKER: accepted (no roles => harmless)
- NOTIFY / LISTEN / UNLISTEN, ALTER ROLE, ALTER DEFAULT PRIVILEGES: parsed & ignored
- current_database() / current_catalog() functions
- PL/pgSQL FOREACH x IN ARRAY <expr> LOOP
- Embedded DDL in DO blocks (CREATE/INSERT/...): deferred per-statement compilation
  so a statement can reference an object created earlier in the same body; the DO
  executor now threads the forked/committed transaction back out (new ExecCtx.onTransaction)

Full bootstrap + 135 rapidnative migrations: 98.3% of statements run (correct splitter).
+6 engine tests; conformance 243 -> 248/248 (100%) verified against Postgres 16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… row-types, array slicing

Engine features found running the full Supabase bootstrap + rapidnative migrations:

- Correlated subqueries: a subquery can reference an outer-query column (by alias or
  table name), fed in per outer row via correlation holders (context.ts). Fixes RLS
  policies with correlated EXISTS and correlated UPDATE/SELECT subqueries.
- Scalar subqueries reduce to a single value (0 rows -> null, >1 -> error), matching
  Postgres; IN/ANY/ALL/EXISTS still get the row list. EXISTS accepts multi-column
  subqueries. Fixed the build-filter IN path too.
- Array slicing arr[lo:hi] / [:hi] / [lo:] (1-based inclusive, clamped).
- pg_roles catalog (reflects CREATE ROLE); FK constraints auto-named <table>_<col>_fkey
  (so ALTER TABLE ... DROP CONSTRAINT by that name works).
- A table's implicit row/composite type is usable as a type (RETURNS SETOF <table>,
  plpgsql var of a table type).
- Syntax errors in dynamic EXECUTE are now catchable QueryErrors (BEGIN..EXCEPTION).
- version() / pg_size_pretty / pg_database_size (+ relation/table/total/indexes size).

Previously-skipped correlated-subquery tests un-skipped. 1224 tests pass; conformance
248 -> 255/255 (100%) verified against Postgres 16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
information_schema.table_constraints and key_column_usage were empty stubs, so tools
introspecting primary keys (e.g. an admin studio building an UPDATE's WHERE from the PK)
found none — turning a single-row edit into a filterless, all-rows UPDATE. Both views now
enumerate each table's PRIMARY KEY and UNIQUE constraints (name, type, columns, ordinal),
sourced from the table's indexes (PK named <table>_pkey).

Conformance 255 -> 256/256 (differential vs Postgres 16); +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sanketsahu added a commit to tinbase/pg-mem that referenced this pull request Jul 10, 2026
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sanketsahu and others added 2 commits July 10, 2026 16:14
moment (~5.2 MB, mostly unused locale data) is in maintenance mode. All usage — field
accessors, part-construction, startOf, calendar-aware interval add, ISO week, lenient
timestamp parsing, and format-directed to_date/to_timestamp — is now handled by a small
zero-dependency UTC date module (src/datatypes/date-utils.ts). Also replaced
json-stable-stringify (+ its get-intrinsic chain) with a compact stable stringifier for
jsonb canonicalization. No new dependencies; two removed.

Behavior-preserving: full test suite green and 256/256 differential conformance vs
Postgres 16 (all datetime, timezone and jsonb cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two specs imported moment directly for date formatting/arithmetic assertions; now that
moment is not a runtime dependency, they use the engine's zero-dep date-utils (and a small
inline UTC formatter) instead. moment remains available in CI transitively via the
sequelize devDependency, which needs it for its own adapter test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant