Skip to content

Postgres parity from 250 real project migrations: transactional DDL, type checking, RLS fixes - #12

Merged
RiyaSharma03 merged 5 commits into
mainfrom
feat/corpus-diff
Oct 7, 2026
Merged

RiyaSharma03 merged 5 commits into
mainfrom
feat/corpus-diff

Conversation

@sanketsahu

Copy link
Copy Markdown
Collaborator

What

tools/corpus-diff runs real RapidNative project migrations and seeds through pg-mem and PGlite side by side. It compares migration and seed outcomes, the catalogue reads the agent validator makes, data, RLS as anon/user/stranger (including primary-key lookups), triggers, cascades, and generated probes that Postgres must reject. PGlite (real Postgres) is the oracle. The corpus is user project code and is never committed; the README covers sampling it.

Set of 50 projects before after
A 50/50 diverge 0/50
B 50/50 0/50
C 50/50 0/50
D (fresh, scored once: 3/50 before its fixes) — 0/50
E (fresh, scored once: 2/50 before its fix) — 0/50

The five sets add up to about 46k checks. Every set after A was also used to fix things, so the honest held-out signal is the score each one got the first time it was run (C 3/50, D 3/50, E 2/50).

Validator strictness (pg-mem accepted what Postgres rejects)

  • Typed values no longer cross type categories implicitly: uuid = text, the bug that sent the agent validator to PGlite. bool = text, IN, COALESCE, LIKE and function arguments are covered too. Untyped literals and parameters still coerce. date/timestamptz and int/bigint now widen.
  • CREATE POLICY binds its predicates: unknown columns, bad operators, non-boolean predicates, USING on INSERT, WITH CHECK on SELECT/DELETE.
  • ALTER COLUMN TYPE and DROP COLUMN refuse columns that a policy (on any table, scope-aware) or an FK depends on.
  • CREATE TRIGGER resolves its function. GRANT and CREATE POLICY ... TO check that the role exists.
  • int2/int4/int8 range checks; strict text-to-number parsing.
  • "infinite recursion detected in policy for relation", following Postgres' expansion rules.

Correctness

  • DDL is transactional. A failed call, ROLLBACK, or ROLLBACK TO SAVEPOINT undoes schema changes. schema-snapshot.ts captures schema metadata before a transaction's first DDL; row data already rolled back with the transaction.
  • BEGIN … ROLLBACK across separate query calls actually rolls back, and aborted blocks behave like Postgres.
  • Referential actions run breadth-first, like Postgres RI triggers (ri-queue.ts).
  • Security: RLS was skipped on index lookups. select … where id = 2 returned rows no policy allowed, and UPDATE/DELETE … WHERE <pk> reached them.
  • SECURITY DEFINER runs as the owner.
  • plpgsql: ->> inside bodies, %ROWTYPE/%TYPE.
  • now()/current_date are transaction-stable.
  • Postgres constraint and index names: create table if not exists on an existing table, NULL IN (...), and CREATE OR REPLACE TRIGGER.
  • information_schema.columns reports Postgres type names and pg_get_expr-style defaults.
  • Timestamp/date arithmetic, interval::text, record::text, jsonb ? ?| ?&.
  • text → numeric stores the canonical decimal (this fixes "text.trim is not a function").

Footprint (Node 24, real projects; the agent validator's workload)

PGlite pg-mem
first ready instance ~670 ms, +590–830 MB RSS ~33 ms, +40 MB
each extra concurrent session ~+255 MB ~+7 MB
browser download (gz) ~6.7 MB ~0.19 MB

Before merging

  • Depends on @tinbase/pgsql-ast-parser 12.2.0 (Supabase DDL found in real project migrations (12.2.0) sanketsahu/pgsql-ast-parser#1). package.json already points at ^12.2.0, so install fails until that's published. bun.lockb needs regenerating after.
  • Behaviour changes that existing users of pg-mem may notice: typed text no longer silently compares to uuid/bool/numbers; numeric/bigint read back as strings regardless of insert path (as node-postgres returns them); a ROLLBACK in a multi-statement call now also undoes DDL from that call; a failed DDL statement leaves no partial schema.

Tests: 1320 passing. src/tests/corpus-parity.spec.ts has a minimal repro for each gap, with expected values checked against PGlite.

🤖 Generated with Claude Code

sanketsahu and others added 5 commits October 6, 2026 12:48
…s against PGlite

Adds tools/corpus-diff, which runs real migrations + seeds through pg-mem and PGlite side by
side (catalogue, data, RLS, triggers, cascades, and generated reject/DDL probes). Across 150
projects, divergence went from every project to 1-4 per 50 (remaining: cosmetic default text,
FK action ordering, non-transactional DDL).

Validator strictness (pg-mem accepted what Postgres rejects):
- typed values no longer cross type categories implicitly (uuid = text, bool = text, ...);
  untyped literals and parameters still coerce; date/timestamptz and int/bigint now widen
- CREATE POLICY binds its predicates (unknown columns, bad operators, non-boolean,
  USING on INSERT / WITH CHECK on SELECT|DELETE)
- ALTER COLUMN TYPE / DROP COLUMN refuse columns a policy (on any table) or FK depends on
- CREATE TRIGGER resolves its function; GRANT / CREATE POLICY ... TO check the role exists
- int4/int2/int8 range checks; strict text-to-number parsing

Things pg-mem rejected or got wrong:
- BEGIN ... ROLLBACK across query calls actually rolls back; aborted blocks behave like Postgres
- CREATE TABLE IF NOT EXISTS on an existing table no longer "Not supported"
- unnamed constraints/indexes get Postgres names (t_col_check, t_a_b_key, t_a_b_idx)
- NULL IN (...) is NULL, so CHECK (col IN (...)) accepts NULL
- now()/current_timestamp/current_date are transaction-stable; current_date is a date
- SECURITY DEFINER functions run as their owner
- plpgsql: multi-char operators (->>) in bodies, %ROWTYPE / %TYPE variables
- timestamp - timestamp, date - date (integer), interval::text, record::text, jsonb ? ?| ?&
- text->numeric stores the canonical decimal string (fixes "text.trim is not a function")
- information_schema.columns reports Postgres type names; DROP TABLE frees index names and
  removes dependent FKs from the catalogues; DROP VIEW; Postgres FK error messages

Needs @tinbase/pgsql-ast-parser with the matching grammar changes (SET search_path, DROP VIEW).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…vergence on 250 projects

Second round from the corpus diff (tools/corpus-diff). All five sets of 50 real projects (250,
~46k checks) now match PGlite.

- DDL is transactional: a failed migration or ROLLBACK / ROLLBACK TO undoes created, altered and
  dropped tables, columns, constraints, indexes, policies, triggers and functions
  (schema-snapshot.ts captures the schema metadata before a transaction's first DDL; row data
  already rolls back with the transaction). DDL no longer force-commits mid-transaction, and
  commit rebases onto a parent that moved (CREATE SCHEMA registers catalogues in root).
- referential actions run breadth-first like postgres RI triggers (ri-queue.ts): a direct
  NO ACTION reference is checked before a grandchild cascade
- security: RLS was skipped on index lookups - `select ... where id = 2` returned rows no policy
  allowed, and UPDATE/DELETE ... WHERE <pk> reached them. Filters on a policed table now apply
  the policies on top.
- "infinite recursion detected in policy for relation", following postgres' expansion rules
  (only relations whose applicable policies have subqueries; UPDATE/DELETE that read columns
  also expand SELECT policies)
- CREATE OR REPLACE TRIGGER; replacing a trigger function no longer "changes its return type"
- information_schema.columns.column_default reads like pg_get_expr (0.30, ARRAY[...],
  (now() + '2 days'::interval), '-1')
- fix: count(*) with a filter on information_schema.columns / pg_constraint crashed

Requires @tinbase/pgsql-ast-parser 12.2.0 (CREATE OR REPLACE TRIGGER, numeric literal text).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…pe check

- CREATE POLICY's predicate check left its build in the expression cache,
  keyed on (selection, AST); enforcement then reused a subquery compiled
  under the other table's policies as they were at CREATE POLICY time
  (empty results in normal migration order, or rows a narrowed policy
  hides). Policy create/drop and RLS enable/disable now count as schema
  changes, and a schema change clears the build cache.
- That also keys the recursion cache correctly: replacing a recursive
  policy no longer keeps reporting "infinite recursion", and adding one
  after a clean query reports it.
- With the cache no longer short-circuiting it, compiling a policy that
  reads its own table recursed until the stack overflowed. A re-entrant
  compile now defers to the run-time recursion check (42P17 for the
  roles the policy applies to, as in postgres).
- `x IN / NOT IN (select col ...)` checks x against the column's type
  category: `auth.uid() in (select <text col>)` is rejected like `=` is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- INSERT ... ON CONFLICT DO UPDATE checks the conflicting row against the
  UPDATE and SELECT USING policies (42501, as in postgres) and the
  updated row against UPDATE WITH CHECK; it updated other users' rows.
- ON CONFLICT (cols) DO NOTHING no longer returns the existing row from
  RETURNING (the untargeted form already returned nothing).
- ROLLBACK TO SAVEPOINT in an aborted block rewinds to the savepoint and
  keeps the block, instead of being taken as a full ROLLBACK that ended
  it - after any error, a savepoint-based retry lost the whole block.
- A top-level statement that fails to compile (unknown column,
  uuid = text, ...) inside a BEGIN block aborts it, as one failing at
  run time does, so COMMIT no longer keeps the block's earlier writes.
- SAVEPOINT no longer copies the schema: the first DDL after it does.
  RELEASE / rollback to an earlier savepoint drop the copies. 200
  savepoints on a 150-table schema: 645 ms / +323 MB -> 69 ms / +5 MB.
  A re-declared savepoint name becomes the newest savepoint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@RiyaSharma03
RiyaSharma03 merged commit 0e1ec5f into main Oct 7, 2026
1 check passed
@RiyaSharma03 RiyaSharma03 mentioned this pull request Oct 7, 2026
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.

2 participants