From 29de1c67500386d2ebbaa5c93a421a72438e6c52 Mon Sep 17 00:00:00 2001 From: Jasdeep Date: Sun, 18 Oct 2026 17:07:39 +0000 Subject: [PATCH 1/5] ci: SupaForge between every pair of Supabase and PostgreSQL versions A job per pair of Supabase 15 and 17 and PostgreSQL 14 to 18, each way, on two servers it starts: a first sync, drift with live rows on the target (renames, a retype under a generated column, views and a policy, --prove), and a snapshot restored into a fresh database, each judged by the servers' catalogs. A pull request runs pairs covering each kind of crossing; every pair runs nightly and on demand. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/version-matrix.yml | 86 +++++++++ packages/cli/scripts/version-matrix.sh | 258 +++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 .github/workflows/version-matrix.yml create mode 100755 packages/cli/scripts/version-matrix.sh diff --git a/.github/workflows/version-matrix.yml b/.github/workflows/version-matrix.yml new file mode 100644 index 0000000..5802dd9 --- /dev/null +++ b/.github/workflows/version-matrix.yml @@ -0,0 +1,86 @@ +name: Version matrix + +# SupaForge between every pair of Supabase 15 and 17 and PostgreSQL 14 to 18, +# each way: a first sync, drift with live rows on the target (renames, a +# retype under a generated column and views, --prove), and a snapshot +# restored into a fresh database — see packages/cli/scripts/version-matrix.sh. +# Verdicts are read from the servers' catalogs. +# +# A pull request runs pairs that cover each kind of crossing; every pair runs +# nightly and on demand. + +on: + pull_request: + branches: [main, develop] + schedule: + - cron: '30 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + pairs: + runs-on: ubuntu-latest + outputs: + pairs: ${{ steps.pick.outputs.pairs }} + steps: + - id: pick + run: | + if [ "${{ github.event_name }}" = pull_request ]; then + pairs='["sb15>sb15","sb17>sb17","sb15>sb17","sb17>sb15","sb17>pg17","pg17>sb17","pg14>pg18","pg18>pg14","pg17>pg15","pg18>pg18"]' + else + pairs=$(node -e "const n=['sb15','sb17','pg14','pg15','pg16','pg17','pg18']; console.log(JSON.stringify(n.flatMap(s => n.map(t => s + '>' + t))))") + fi + echo "pairs=$pairs" >> "$GITHUB_OUTPUT" + + pair: + needs: pairs + name: ${{ matrix.pair }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 12 + matrix: + pair: ${{ fromJSON(needs.pairs.outputs.pairs) }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + cache-dependency-path: packages/cli/package-lock.json + + # --prove and snapshot need a pg_dump at least as new as the server. + - name: Install PostgreSQL client 18 + run: | + set -euo pipefail + sudo install -d /usr/share/postgresql-common/pgdg + sudo curl -fsSL -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + https://www.postgresql.org/media/keys/ACCC4CF8.asc + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ + | sudo tee /etc/apt/sources.list.d/pgdg.list > /dev/null + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends postgresql-client-18 + + - name: Build + working-directory: packages/cli + run: npm ci && npm run build + + - name: Run the pair + working-directory: packages/cli + env: + PAIR: ${{ matrix.pair }} + SUPAFORGE_PG_BIN: /usr/lib/postgresql/18/bin + OUT: ${{ runner.temp }}/matrix + run: scripts/version-matrix.sh "${PAIR%%>*}" "${PAIR##*>}" + + - name: Keep the logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: matrix-${{ strategy.job-index }} + path: ${{ runner.temp }}/matrix + retention-days: 7 diff --git a/packages/cli/scripts/version-matrix.sh b/packages/cli/scripts/version-matrix.sh new file mode 100755 index 0000000..530bf47 --- /dev/null +++ b/packages/cli/scripts/version-matrix.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# One pair of the version and platform matrix: SupaForge from SOURCE to +# TARGET, each one of sb15, sb17 (Supabase's Postgres images) or pg14..pg18, +# on two servers this script starts in containers. +# +# p1 a full fixture on the source, an empty target: diff --apply (schema +# and reference data); the target's state must then equal the source's, +# and a re-diff must leave nothing of the fixture. +# p2 rows on the target, then the source moves on (column and table +# renames, a retype under a generated column, views and a policy, an +# enum label, a view widened, a function replaced): diff --check schema +# --apply --prove; the rows must stay and the state must match. +# p3 a plain target: snapshot the source, restore into a fresh database +# on the target's server; the state must equal the source's. +# +# Verdicts are read from the databases. What only one version or platform +# can hold is named mx_v1* and left out of the comparison. +# +# scripts/version-matrix.sh SOURCE TARGET +set -u +CLI=$(cd "$(dirname "$0")/.." && pwd) +SF=${SF:-$CLI} +OUT=${OUT:-$(mktemp -d)}; mkdir -p "$OUT"; RES=$OUT/results.tsv; : > "$RES" +SOURCE=$1 TARGET=$2 +SB15_IMAGE=${SB15_IMAGE:-public.ecr.aws/supabase/postgres:15.8.1.085} +SB17_IMAGE=${SB17_IMAGE:-public.ecr.aws/supabase/postgres:17.6.1.176} + +image(){ case $1 in sb15) echo "$SB15_IMAGE";; sb17) echo "$SB17_IMAGE";; pg1[4-8]) echo "docker.io/library/postgres:${1#pg}";; *) echo "unknown node $1" >&2; exit 2;; esac; } +kind(){ [[ $1 == sb* ]] && echo sb || echo pg; } +version(){ echo "${1:2}"; } +# role → "kind version container port" +info(){ if [ $2 = src ]; then echo "$(kind $1) $(version $1) mx-src 56001"; else echo "$(kind $1) $(version $1) mx-tgt 56002"; fi; } + +start(){ local node=$1 c=$2 port=$3 + docker rm -f $c >/dev/null 2>&1 + if [ $(kind $node) = sb ]; then + docker run -d --name $c -p 127.0.0.1:$port:5432 -e POSTGRES_PASSWORD=smokepass \ + -e JWT_SECRET=ci-only-jwt-secret-at-least-32-characters-long "$(image $node)" postgres -D /etc/postgresql >/dev/null + else + docker run -d --name $c -p 127.0.0.1:$port:5432 -e POSTGRES_PASSWORD=rootpass "$(image $node)" >/dev/null + fi + for _ in $(seq 180); do + q $(kind $node) $c $port postgres -c 'SELECT 1' >/dev/null 2>&1 && return 0 + sleep 1 + done + echo "$c did not start"; docker logs $c 2>&1 | tail -20; exit 1 +} + +# psql as an admin on a node's database: q KIND CONTAINER PORT DB [psql args] +q(){ local kind=$1 c=$2 p=$3 db=$4; shift 4 + if [ $kind = sb ]; then docker exec -i -e PGPASSWORD=smokepass $c psql -h localhost -U supabase_admin -d $db -q -X -At -v ON_ERROR_STOP=1 "$@" + else docker exec -i $c psql -U postgres -d $db -q -X -At -v ON_ERROR_STOP=1 "$@"; fi; } + +# The fixture, for a source of KIND and VERSION. +fixture(){ local kind=$1 v=$2 +cat <<'SQL' +CREATE SCHEMA mx_app; +CREATE TYPE public.mx_status AS ENUM ('new', 'paid', 'shipped'); +CREATE DOMAIN public.mx_email AS text CHECK (VALUE LIKE '%@%'); +CREATE TYPE public.mx_money AS (amount numeric(12,2), currency char(3)); +CREATE TYPE public.mx_span AS RANGE (subtype = int4); +CREATE SEQUENCE public.mx_ticket_seq START 1000; +CREATE TABLE public.mx_customers ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + email public.mx_email NOT NULL UNIQUE, + name text NOT NULL, + status public.mx_status NOT NULL DEFAULT 'new', + created_at timestamptz NOT NULL DEFAULT now(), + tags text[] DEFAULT '{}', + meta jsonb); +CREATE TABLE public.mx_orders ( + id bigserial PRIMARY KEY, + customer_id bigint NOT NULL REFERENCES public.mx_customers (id) ON DELETE CASCADE, + total numeric(12,2) NOT NULL CHECK (total >= 0), + kind varchar(10) CHECK (kind IN ('web', 'shop')), + total_gross numeric(14,2) GENERATED ALWAYS AS (total * 1.2) STORED, + ticket int DEFAULT nextval('public.mx_ticket_seq'), + price public.mx_money, + span public.mx_span); +CREATE INDEX mx_orders_customer ON public.mx_orders (customer_id) WHERE total > 0; +CREATE INDEX mx_customers_lower_email ON public.mx_customers (lower(email::text)); +CREATE TABLE public.mx_events (id bigint, at date NOT NULL, body text) PARTITION BY RANGE (at); +CREATE TABLE public.mx_events_2026 PARTITION OF public.mx_events FOR VALUES FROM ('2026-01-01') TO ('2027-01-01'); +CREATE TABLE public.mx_events_2027 PARTITION OF public.mx_events FOR VALUES FROM ('2027-01-01') TO ('2028-01-01'); +CREATE TABLE public.mx_plans (id int PRIMARY KEY, name text NOT NULL, price numeric(8,2)); +INSERT INTO public.mx_plans VALUES (1, 'free', 0), (2, 'pro', 25), (3, 'team', 99); +CREATE TABLE mx_app.mx_settings (key text PRIMARY KEY, value jsonb NOT NULL); +CREATE VIEW public.mx_order_totals AS + SELECT c.id, c.name, sum(o.total) AS spent FROM public.mx_customers c JOIN public.mx_orders o ON o.customer_id = c.id GROUP BY c.id, c.name; +CREATE VIEW public.mx_big_spenders AS SELECT * FROM public.mx_order_totals WHERE spent > 100; +CREATE MATERIALIZED VIEW public.mx_daily AS SELECT at, count(*) AS n FROM public.mx_events GROUP BY at; +CREATE FUNCTION public.mx_gross(n numeric) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT round(n * 1.2, 2) $$; +CREATE FUNCTION public.mx_touch() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.created_at := now(); RETURN NEW; END $$; +CREATE TRIGGER mx_customers_touch BEFORE INSERT ON public.mx_customers FOR EACH ROW EXECUTE FUNCTION public.mx_touch(); +CREATE FUNCTION public.mx_double(a int) RETURNS int LANGUAGE sql IMMUTABLE BEGIN ATOMIC SELECT a * 2; END; +CREATE AGGREGATE public.mx_concat(text) (SFUNC = textcat, STYPE = text, INITCOND = ''); +CREATE TABLE public.mx_audit (id int, note text); +CREATE RULE mx_audit_no_delete AS ON DELETE TO public.mx_audit DO INSTEAD NOTHING; +COMMENT ON TABLE public.mx_customers IS 'Customers'; +COMMENT ON COLUMN public.mx_orders.total IS 'Net total'; +ALTER TABLE public.mx_orders ENABLE ROW LEVEL SECURITY; +CREATE POLICY mx_orders_read ON public.mx_orders FOR SELECT TO mx_reader USING (total > 0); +GRANT USAGE ON SCHEMA mx_app TO mx_reader; +GRANT SELECT ON public.mx_customers, public.mx_order_totals TO mx_reader; +GRANT SELECT (id, total) ON public.mx_orders TO mx_reader; +ALTER DEFAULT PRIVILEGES IN SCHEMA mx_app GRANT SELECT ON TABLES TO mx_reader; +ALTER TABLE public.mx_customers REPLICA IDENTITY FULL; +ALTER TABLE public.mx_orders ALTER COLUMN kind SET STATISTICS 300; +CREATE STATISTICS public.mx_orders_stats (dependencies) ON customer_id, kind FROM public.mx_orders; +CREATE TEXT SEARCH CONFIGURATION public.mx_simple (COPY = pg_catalog.simple); +SQL + # What only some versions have, named mx_v* so the shared state leaves it out. + [ $v -ge 15 ] && echo "CREATE VIEW public.mx_v15_secure WITH (security_invoker = true) AS SELECT id FROM public.mx_customers; +CREATE TABLE public.mx_v15_codes (code text UNIQUE NULLS NOT DISTINCT);" + [ $v -ge 17 ] && echo "GRANT MAINTAIN ON public.mx_plans TO mx_reader;" + [ $v -ge 18 ] && echo "CREATE TABLE public.mx_v18_virtual (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL);" + [ $kind = sb ] && cat <<'SQL' +ALTER TABLE public.mx_customers ENABLE ROW LEVEL SECURITY; +CREATE POLICY mx_customers_own ON public.mx_customers FOR SELECT TO authenticated USING (auth.uid() IS NOT NULL); +GRANT SELECT ON public.mx_order_totals TO anon; +SQL +} +# The platform's parts of a Supabase fixture, which only its admin may make. +sb_admin_fixture(){ cat <<'SQL' +SELECT cron.schedule('mx-nightly', '0 3 * * *', 'SELECT 1'); +SELECT pgmq.create('mx_jobs'); +ALTER PUBLICATION supabase_realtime ADD TABLE public.mx_orders; +SQL +} + +# The source moving on, once the target has rows of its own. +evolve(){ cat <<'SQL' +ALTER TABLE public.mx_customers RENAME COLUMN name TO full_name; +ALTER TABLE mx_app.mx_settings RENAME TO mx_config; +ALTER TABLE public.mx_orders ADD COLUMN note text; +ALTER TYPE public.mx_status ADD VALUE 'refunded'; +CREATE OR REPLACE FUNCTION public.mx_gross(n numeric) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT round(n * 1.25, 2) $$; +DROP VIEW public.mx_big_spenders; +DROP VIEW public.mx_order_totals; +DROP POLICY mx_orders_read ON public.mx_orders; +ALTER TABLE public.mx_orders DROP COLUMN total_gross; +ALTER TABLE public.mx_orders ALTER COLUMN total TYPE numeric(14,2); +ALTER TABLE public.mx_orders ADD COLUMN total_gross numeric(16,2) GENERATED ALWAYS AS (total * 1.2) STORED; +CREATE VIEW public.mx_order_totals AS + SELECT c.id, c.full_name AS name, sum(o.total) AS spent, count(*) AS orders FROM public.mx_customers c JOIN public.mx_orders o ON o.customer_id = c.id GROUP BY c.id, c.full_name; +CREATE VIEW public.mx_big_spenders AS SELECT * FROM public.mx_order_totals WHERE spent > 100; +GRANT SELECT ON public.mx_order_totals TO mx_reader; +CREATE POLICY mx_orders_read ON public.mx_orders FOR SELECT TO mx_reader USING (total > 0); +SQL +} +TARGET_ROWS="INSERT INTO public.mx_customers (email, name) VALUES ('a@x.io', 'Ann'), ('b@x.io', 'Bo'), ('c@x.io', 'Cy'); +INSERT INTO public.mx_orders (customer_id, total, kind) SELECT id, 150, 'web' FROM public.mx_customers WHERE email IN ('a@x.io', 'b@x.io'); +INSERT INTO mx_app.mx_settings VALUES ('plan', '{\"tier\":\"pro\"}'), ('flags', '{\"beta\":true}');" + +# What both sides must agree on: the fixture, version-specific objects aside. +STATE="SELECT concat_ws(' ', + (SELECT string_agg(relname || ':' || relkind::text, ',' ORDER BY relname) FROM pg_class WHERE relnamespace IN ('public'::regnamespace, to_regnamespace('mx_app')) AND relname LIKE 'mx\_%' AND relname NOT LIKE 'mx\_v1%' AND relkind IN ('r','p','v','m','S','i')), + (SELECT string_agg(proname, ',' ORDER BY proname) FROM pg_proc WHERE pronamespace = 'public'::regnamespace AND proname LIKE 'mx\_%'), + (SELECT string_agg(typname, ',' ORDER BY typname) FROM pg_type WHERE typnamespace = 'public'::regnamespace AND typname LIKE 'mx\_%' AND typname NOT LIKE 'mx\_v1%' AND typtype IN ('e','d','c','r')), + (SELECT string_agg(polname, ',' ORDER BY polname) FROM pg_policy WHERE polname = 'mx_orders_read'), + (SELECT count(*) FROM pg_rewrite WHERE rulename = 'mx_audit_no_delete'), + (SELECT count(*) FROM pg_statistic_ext WHERE stxname = 'mx_orders_stats'), + (SELECT count(*) FROM pg_ts_config WHERE cfgname = 'mx_simple'), + (SELECT relreplident FROM pg_class WHERE oid = to_regclass('public.mx_customers')), + (SELECT attstattarget FROM pg_attribute WHERE attrelid = to_regclass('public.mx_orders') AND attname = 'kind'), + (SELECT count(*) FROM pg_default_acl WHERE defaclnamespace = to_regnamespace('mx_app')), + (SELECT has_table_privilege('mx_reader', 'public.mx_customers', 'SELECT')), + (SELECT CASE WHEN to_regnamespace('mx_app') IS NULL THEN 'no-schema' ELSE has_schema_privilege('mx_reader', 'mx_app', 'USAGE')::text END), + (SELECT has_column_privilege('mx_reader', 'public.mx_orders', 'total', 'SELECT')), + (SELECT obj_description('public.mx_customers'::regclass, 'pg_class')), + (SELECT string_agg(enumlabel, '|' ORDER BY enumsortorder) FROM pg_enum WHERE enumtypid = to_regtype('public.mx_status')), + (SELECT count(*) FROM public.mx_plans))" +P2="SELECT concat_ws(' ', (SELECT count(full_name) FROM public.mx_customers), (SELECT count(*) FROM public.mx_orders), + (SELECT count(*) FROM mx_app.mx_config), (SELECT public.mx_gross(100)), + (SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.mx_orders'::regclass AND attname = 'total'), + (SELECT sum(total_gross) FROM public.mx_orders), (SELECT count(*) FROM public.mx_big_spenders))" + +row(){ printf '%s\t%s\t%s\n' "$1" "$2" "$3" | tee -a $RES; } +sfrun(){ (cd $WS && NO_COLOR=1 timeout 900 node $SF/bin/run.js "$@" 2>&1); } +# fixture issues left after an apply (titles naming mx_ objects), version-specific ones aside +leftover(){ (cd $WS && NO_COLOR=1 node $SF/bin/run.js diff --check schema --json 2>/dev/null) | MX_TARGET_PLAIN=$MX_TARGET_PLAIN node -e " + let j; try { j = JSON.parse(require('fs').readFileSync(0,'utf8')) } catch { console.log('UNREADABLE'); process.exit() } + // What only Supabase can hold (a policy calling auth.uid()) is left out of a plain target by design. + const plain = process.env.MX_TARGET_PLAIN === '1' + const t = j.checks.flatMap(c => c.issues.map(i => i.title)).filter(t => /mx_/.test(t) && !/mx_v1/.test(t) && !(plain && /mx_customers_own/.test(t))) + console.log(t.join(' ; '))"; } + + +# Roles both servers need, and on Supabase what the project's queues and cron +# jobs need: what a Supabase project has once they are enabled. +prep(){ local kind=$1 c=$2 p=$3 + q $kind $c $p postgres -c "DO \$\$ BEGIN CREATE ROLE mx_reader NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END \$\$" >/dev/null + [ $kind = sb ] || return 0 + q sb $c $p postgres -c "CREATE EXTENSION IF NOT EXISTS pg_cron" -c "GRANT USAGE ON SCHEMA cron TO postgres" \ + -c "GRANT ALL ON ALL TABLES IN SCHEMA cron TO postgres" -c "CREATE EXTENSION IF NOT EXISTS pgmq" -c "GRANT mx_reader TO postgres" \ + -c "GRANT USAGE, CREATE ON SCHEMA pgmq TO postgres" -c "GRANT ALL ON ALL TABLES IN SCHEMA pgmq TO postgres" \ + -c "GRANT ALL ON ALL SEQUENCES IN SCHEMA pgmq TO postgres" -c "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgmq TO postgres" >/dev/null +} + +# Fresh servers: a plain database is made, a Supabase one is its postgres. +reset(){ local kind=$1 c=$2 p=$3 db=$4 + [ $kind = sb ] || q pg $c $p postgres -c "DROP DATABASE IF EXISTS $db WITH (FORCE)" -c "CREATE DATABASE $db" >/dev/null; } +pair(){ local s=$1 t=$2 name="$1>$2" + read sk sv sc sp <<< "$(info $s src)"; read tk tv tc tp <<< "$(info $t tgt)" + local sdb=mx_s tdb=mx_t; [ $sk = sb ] && sdb=postgres; [ $tk = sb ] && tdb=postgres + MX_TARGET_PLAIN=$([ $sk = sb ] && [ $tk = pg ] && echo 1 || echo 0) + [ "$sc" = "$tc" ] && [ $sk = pg ] || true + echo "== $name" + WS=$OUT/ws-${s}-${t}; rm -rf $WS; mkdir -p $WS + reset $sk $sc $sp $sdb; reset $tk $tc $tp $tdb + if ! fixture $sk $sv | qu $sk $sc $sp $sdb > $WS/fixture.log 2>&1 \ + || { [ $sk = sb ] && ! sb_admin_fixture | q sb $sc $sp $sdb >> $WS/fixture.log 2>&1; }; then + row "$name" setup "FAIL fixture: $(grep -m1 ERROR $WS/fixture.log)"; return; fi + local rdb=mx_r + printf '{ "environments": { "src": { "dbUrl": "%s" }, "tgt": { "dbUrl": "%s" }%s }, "source": "src", "target": "tgt", "checks": { "data": { "tables": ["mx_plans"] } } }\n' \ + "$(url $sk $sc $sp $sdb)" "$(url $tk $tc $tp $tdb)" "$([ $tk = pg ] && printf ', "rst": { "dbUrl": "%s" }' "$(url pg $tc $tp $rdb)")" > $WS/supaforge.config.json + local want got left t0=$SECONDS + + # p1: first sync + sfrun diff --apply > $WS/p1.apply; local c1=$? + want=$(qu $sk $sc $sp $sdb -c "$STATE" 2>&1); got=$(qu $tk $tc $tp $tdb -c "$STATE" 2>&1) + left=$(leftover) + if [[ "$want" == *mx_customers:r* ]] && [ "$want" = "$got" ] && [ -z "$left" ]; then row "$name" p1 "PASS exit=$c1 $((SECONDS-t0))s" + else row "$name" p1 "FAIL exit=$c1 $( [ "$want" != "$got" ] && echo "state differs: $(diff <(tr ' ' '\n' <<< "$want") <(tr ' ' '\n' <<< "$got") | grep '^[<>]' | tr '\n' ' ' | head -c 400)") left=[$left] err=[$(grep -E '✗|✖|error' $WS/p1.apply | head -3 | tr '\n' ' ' | head -c 400)]"; fi + + # p2: the target has rows; the source moves on + t0=$SECONDS + if qu $tk $tc $tp $tdb -c "$TARGET_ROWS" > $WS/rows.log 2>&1 && evolve | qu $sk $sc $sp $sdb > $WS/evolve.log 2>&1; then + sfrun diff --check schema --apply --prove > $WS/p2.apply; local c2=$? + local proved=yes; grep -q "Not proven\|Not proved:\|does not reproduce" $WS/p2.apply && proved=no + if [ $proved = no ] && grep -q "Nothing was applied" $WS/p2.apply; then + sfrun diff --check schema --apply > $WS/p2b.apply; c2="$c2/then-unproved:$?" + fi + want=$(qu $sk $sc $sp $sdb -c "$STATE" 2>&1); got=$(qu $tk $tc $tp $tdb -c "$STATE" 2>&1) + local rows=$(qu $tk $tc $tp $tdb -c "$P2" 2>&1); left=$(leftover) + if [[ "$want" == *mx_customers:r* ]] && [ "$want" = "$got" ] && [ -z "$left" ] && [ "$rows" = "3 2 2 125.00 numeric(14,2) 360.00 2" ]; then row "$name" p2 "PASS exit=$c2 proved=$proved $((SECONDS-t0))s" + else row "$name" p2 "FAIL exit=$c2 proved=$proved rows=[$rows] $( [ "$want" != "$got" ] && echo "state differs: $(diff <(tr ' ' '\n' <<< "$want") <(tr ' ' '\n' <<< "$got") | grep '^[<>]' | tr '\n' ' ' | head -c 400)") left=[$left] why=[$(grep -E '✗|✖|rows lost|differ|unexpected|missing' $WS/p2.apply | head -4 | tr '\n' ' ' | head -c 500)]"; fi + else row "$name" p2 "FAIL setup: $(grep -m1 ERROR $WS/rows.log $WS/evolve.log)"; fi + + # p3: snapshot the source, restore into a fresh plain database + if [ $tk = pg ]; then + t0=$SECONDS; reset pg $tc $tp $rdb + sfrun snapshot --env src --apply > $WS/p3.snap; local cs=$? + sfrun restore --env rst --from-snapshot=latest --apply > $WS/p3.restore; local cr=$? + want=$(qu $sk $sc $sp $sdb -c "$STATE" 2>&1); got=$(q pg $tc $tp $rdb -c "$STATE" 2>&1) + if [[ "$want" == *mx_customers:r* ]] && [ "$want" = "$got" ]; then row "$name" p3 "PASS snapshot=$cs restore=$cr $((SECONDS-t0))s" + else row "$name" p3 "FAIL snapshot=$cs restore=$cr state differs: $(diff <(tr ' ' '\n' <<< "$want") <(tr ' ' '\n' <<< "$got") | grep '^[<>]' | tr '\n' ' ' | head -c 400) err=[$(grep -E '✖|error' $WS/p3.restore | head -2 | tr '\n' ' ' | head -c 300)]"; fi + fi +} + + +read sk _ sc sp <<< "$(info $SOURCE src)"; read tk _ tc tp <<< "$(info $TARGET tgt)" +start $SOURCE $sc $sp; start $TARGET $tc $tp +prep $sk $sc $sp; prep $tk $tc $tp +pair $SOURCE $TARGET +docker rm -f mx-src mx-tgt >/dev/null 2>&1 +echo; echo "== $SOURCE>$TARGET: $(grep -c PASS "$RES") pass, $(grep -c FAIL "$RES") fail" +! grep -q FAIL "$RES" From 0959d1d3bc7994414adbdb3dacc714e1338c8f54 Mon Sep 17 00:00:00 2001 From: Jasdeep Date: Tue, 20 Oct 2026 23:34:30 +0000 Subject: [PATCH 2/5] feat: DBDiff rc.30, reading as of the scan's own instant @dbdiff/cli 3.0.0-rc.30 reads each side as of one snapshot, and can join one another session holds: the schema and data diffs are now given the scan's, so every check and both diffs see each database at the same instant; --no-snapshot reaches it too. It also orders MySQL's foreign key indexes so they can be dropped, leaves a column only one side has out of a data UPDATE, handles PostgreSQL 18's NOT NULL constraints against older servers, on partitions and through renames, and writes no MAINTAIN for a server before 17. Co-Authored-By: Claude Opus 5.5 --- packages/cli/package-lock.json | 76 ++++++++++---------- packages/cli/package.json | 2 +- packages/cli/src/db.ts | 10 +++ packages/cli/src/dbdiff.ts | 14 ++++ packages/cli/src/scanner.ts | 3 +- packages/cli/test/dbdiff-units.test.ts | 18 ++++- packages/cli/test/e2e/snapshot-reads.test.ts | 4 ++ 7 files changed, 86 insertions(+), 41 deletions(-) diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index a793326..8a68e00 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -1,16 +1,16 @@ { "name": "@akal/supaforge", - "version": "0.0.34", + "version": "0.0.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@akal/supaforge", - "version": "0.0.34", + "version": "0.0.35", "license": "MIT", "dependencies": { "@akal/pg-conformance": "~0.0.16", - "@dbdiff/cli": "3.0.0-rc.29", + "@dbdiff/cli": "3.0.0-rc.30", "@modelcontextprotocol/sdk": "^1.32.1", "@oclif/core": "^4", "@supabase/supabase-js": "^2.103.0", @@ -43,9 +43,9 @@ } }, "node_modules/@dbdiff/cli": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli/-/cli-3.0.0-rc.29.tgz", - "integrity": "sha512-brwSBOJCRwlsHaDF1ECuHQ0m4y0jlGyZFWmjvU4SMYhCAdl8ascr9Qy/ZjYcGhch9gjPqqmNpIjWISWdRrHbsw==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli/-/cli-3.0.0-rc.30.tgz", + "integrity": "sha512-Znal37WvFeDfkzyygDbYS04dWdW/xiP7SS+oZxpGI5NsMx82EncIRz5Q0dEJMsFf8ts3NIu013IWK9N3c/m29g==", "license": "MIT", "bin": { "dbdiff": "bin/dbdiff.js" @@ -54,20 +54,20 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "@dbdiff/cli-darwin-arm64": "3.0.0-rc.29", - "@dbdiff/cli-darwin-x64": "3.0.0-rc.29", - "@dbdiff/cli-linux-arm64": "3.0.0-rc.29", - "@dbdiff/cli-linux-arm64-musl": "3.0.0-rc.29", - "@dbdiff/cli-linux-x64": "3.0.0-rc.29", - "@dbdiff/cli-linux-x64-musl": "3.0.0-rc.29", - "@dbdiff/cli-win32-arm64": "3.0.0-rc.29", - "@dbdiff/cli-win32-x64": "3.0.0-rc.29" + "@dbdiff/cli-darwin-arm64": "3.0.0-rc.30", + "@dbdiff/cli-darwin-x64": "3.0.0-rc.30", + "@dbdiff/cli-linux-arm64": "3.0.0-rc.30", + "@dbdiff/cli-linux-arm64-musl": "3.0.0-rc.30", + "@dbdiff/cli-linux-x64": "3.0.0-rc.30", + "@dbdiff/cli-linux-x64-musl": "3.0.0-rc.30", + "@dbdiff/cli-win32-arm64": "3.0.0-rc.30", + "@dbdiff/cli-win32-x64": "3.0.0-rc.30" } }, "node_modules/@dbdiff/cli-darwin-arm64": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-darwin-arm64/-/cli-darwin-arm64-3.0.0-rc.29.tgz", - "integrity": "sha512-UY9/sPo+HWWmpX/uNr1XoDpQo+MM1Dc7qDxRVLE5/A8/qux21zqJ8KBjqHa5dvPaV3WoZ6ttusYR/qc+noS05g==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-darwin-arm64/-/cli-darwin-arm64-3.0.0-rc.30.tgz", + "integrity": "sha512-uG/jfiK7DDY6bQtXpY4MjvKsHMiTiGQcTrACOK2T4UbN2SWodSEApl194Wa3cVadYzW8gQq6rkJH+QGjbaF5WQ==", "cpu": [ "arm64" ], @@ -78,9 +78,9 @@ ] }, "node_modules/@dbdiff/cli-darwin-x64": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-darwin-x64/-/cli-darwin-x64-3.0.0-rc.29.tgz", - "integrity": "sha512-8gqS2oOJUzp5kKJFxXcgFqCmO8lyisPPFvu/B4FQRhC8h4xtcchpH/FtutcPmLuPbj/JihWvlgoW+fIzZEDadA==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-darwin-x64/-/cli-darwin-x64-3.0.0-rc.30.tgz", + "integrity": "sha512-E4dCSuY6vcyexxDIKSdpNvk24PZN/6Q5os65iK7rpBDQ2ouC0oasPy8el5ouOEh6V2xs7yYtvGuR69TsK3dvWg==", "cpu": [ "x64" ], @@ -91,9 +91,9 @@ ] }, "node_modules/@dbdiff/cli-linux-arm64": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-arm64/-/cli-linux-arm64-3.0.0-rc.29.tgz", - "integrity": "sha512-DGqhm+K1QaYFA5Qd3kB1IejxnSqFwnQZUZ/3Jd9p+0FGGwiekhYDeBoFkvkL907s//MexVv17bJRUWg+xYupXw==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-arm64/-/cli-linux-arm64-3.0.0-rc.30.tgz", + "integrity": "sha512-eLu8iwT8bB4OL8Ur/IPA5XWDveQ5YgFSXzHCcFuBBM9a0b0Rr0lDNcpPXJ/saWQeK8kSSfYsw6WCFL/FJbSIag==", "cpu": [ "arm64" ], @@ -107,9 +107,9 @@ ] }, "node_modules/@dbdiff/cli-linux-arm64-musl": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-arm64-musl/-/cli-linux-arm64-musl-3.0.0-rc.29.tgz", - "integrity": "sha512-WunJz7zOljgYQxlTz8VHPBnIRbs+ndYXBZYsbbHtE6CxJSdzgz72zRP92vG1uBlXGpt8eG18/3cfnvS6wF5sxw==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-arm64-musl/-/cli-linux-arm64-musl-3.0.0-rc.30.tgz", + "integrity": "sha512-9nMU7LbJP+0sMyCKmR49WgVZSfEyquI6ikXDQ8LPYAZkWe4ST2x6sdKuxsPPJSR1iJ6xEzvHysISndWi22KXGQ==", "cpu": [ "arm64" ], @@ -123,9 +123,9 @@ ] }, "node_modules/@dbdiff/cli-linux-x64": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-x64/-/cli-linux-x64-3.0.0-rc.29.tgz", - "integrity": "sha512-0ViMr4UpfL68teSGPevk9vTJlFMHBeMrmMZnszZkM457KSXuh6fnga9EtPO7DkCzwAMk24YeYod0KFjMZVK21w==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-x64/-/cli-linux-x64-3.0.0-rc.30.tgz", + "integrity": "sha512-R4itgc3oBsa+p2ZMxZyStVFa39Gv8IoqvoFSv2kO5mJgUeQRTMnf2191mMWqnQf8uFy6YBr9jW9ea/ScMrSmxw==", "cpu": [ "x64" ], @@ -139,9 +139,9 @@ ] }, "node_modules/@dbdiff/cli-linux-x64-musl": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-x64-musl/-/cli-linux-x64-musl-3.0.0-rc.29.tgz", - "integrity": "sha512-i6AIwEEGOTGILjvsZYRygY715SLow3v5P5x6Twtq+VBoI+zpQo8H70JS+cK2Lua+mdu8m/P3zPAn+LkqxcJ8ig==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-linux-x64-musl/-/cli-linux-x64-musl-3.0.0-rc.30.tgz", + "integrity": "sha512-KKcN/qtl8g8hBq1N1UCJxXwb8DFwJ3AYB0RlX4GYKdLI5hZaH+eEMu3RrfQzttRP2Q1thEcS5vIY6KV3C2vkjQ==", "cpu": [ "x64" ], @@ -155,9 +155,9 @@ ] }, "node_modules/@dbdiff/cli-win32-arm64": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-win32-arm64/-/cli-win32-arm64-3.0.0-rc.29.tgz", - "integrity": "sha512-ad4fcP8LZnzSHPSuYNU79jNQTyIKffUZforQc3bNOzbrt051IGxtAxRnXsvisqffa98wnbGFuPDa5eECJec8hw==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-win32-arm64/-/cli-win32-arm64-3.0.0-rc.30.tgz", + "integrity": "sha512-9jPAcnI7qFAfW3A+vg4Edu4lXPEACDZozgMzYf9kxnt90YhO/CpUOegmLqODBCulDgp42PGqBsecV4vD6cJ0qg==", "cpu": [ "arm64" ], @@ -168,9 +168,9 @@ ] }, "node_modules/@dbdiff/cli-win32-x64": { - "version": "3.0.0-rc.29", - "resolved": "https://registry.npmjs.org/@dbdiff/cli-win32-x64/-/cli-win32-x64-3.0.0-rc.29.tgz", - "integrity": "sha512-zASma8uXzDYMjr6rwYmEr/so4HoHx0P6kYiejC30LU6SQ4fUDtfSrorxHlp6rD/s5SPaeypjiJ5mzQYx/BfvZw==", + "version": "3.0.0-rc.30", + "resolved": "https://registry.npmjs.org/@dbdiff/cli-win32-x64/-/cli-win32-x64-3.0.0-rc.30.tgz", + "integrity": "sha512-VgZ/8dUsjxG+fX1N+uKjQZNH5/mR7Yv2vwtSXo+zXkbqQQ5abDqDFGzcwJ+hwbRwoEIDzCNKCg3LaNt1HgD26A==", "cpu": [ "x64" ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 0a442e8..f80b27b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,7 +56,7 @@ }, "dependencies": { "@akal/pg-conformance": "~0.0.16", - "@dbdiff/cli": "3.0.0-rc.29", + "@dbdiff/cli": "3.0.0-rc.30", "@modelcontextprotocol/sdk": "^1.32.1", "@oclif/core": "^4", "@supabase/supabase-js": "^2.103.0", diff --git a/packages/cli/src/db.ts b/packages/cli/src/db.ts index 9c8fe1d..4e41f5e 100644 --- a/packages/cli/src/db.ts +++ b/packages/cli/src/db.ts @@ -120,6 +120,16 @@ function poolFor(dbUrl: string): pg.Pool { * releaseSnapshots() runs before anything is applied. */ const snapshots = new Map() +let readAlone = false + +/** Read each query on its own, as --no-snapshot asks, here and in @dbdiff/cli. */ +export function setSnapshotsOff(off: boolean): void { + readAlone = off +} + +export function snapshotsOff(): boolean { + return readAlone +} const joined = new WeakMap() /** Read `dbUrl` as of one instant until releaseSnapshots(). False, and nothing held, when the server will not. */ diff --git a/packages/cli/src/dbdiff.ts b/packages/cli/src/dbdiff.ts index a14b1b6..2511f41 100644 --- a/packages/cli/src/dbdiff.ts +++ b/packages/cli/src/dbdiff.ts @@ -8,6 +8,7 @@ import type { DriftIssue } from './types/drift' import { errMsg, friendlyDbError, DiagnosticError } from './utils/error' import { DBDIFF_EXEC_TIMEOUT_MS, DBDIFF_MAX_BUFFER } from './constants' import { schemaDiffIgnores } from './defaults' +import { heldSnapshot, snapshotsOff } from './db.js' import { PLATFORM_ROLES } from './checks/roles' import { parseStatements, names, relationName, type Statement } from './sql-ast.js' import { sqlSkeleton, policiesRemoved } from './sql-deps' @@ -187,6 +188,16 @@ export function stripDbDiffNoise(text: string): string { * Split out of runDbDiff() so the flag surface is one readable unit and can be * asserted directly in tests, rather than only through a spawned process. */ +function snapshotArgs(sourceUrl: string, targetUrl: string): string[] { + if (snapshotsOff()) return ['--no-snapshot'] + const source = heldSnapshot(sourceUrl) + const target = heldSnapshot(targetUrl) + return [ + ...(source ? [`--source-snapshot=${source}`] : []), + ...(target ? [`--target-snapshot=${target}`] : []), + ] +} + export function buildDbDiffArgs(options: DbDiffOptions, outputFile: string): string[] { const args = [ 'diff', @@ -199,6 +210,9 @@ export function buildDbDiffArgs(options: DbDiffOptions, outputFile: string): str // public interface and did nothing. `--include=${options.include}`, '--nocomments', + // The instant the scan reads each side as of, so the schema and data + // diffs see what every other check does; or none, under --no-snapshot. + ...snapshotArgs(options.sourceUrl, options.targetUrl), // @dbdiff/cli >= 3.0.0-rc.3 refuses to emit a migration containing // DROP TABLE or DROP COLUMN unless this is passed, exiting non-zero and // writing no output file. SupaForge is a *detection* tool: an extra table diff --git a/packages/cli/src/scanner.ts b/packages/cli/src/scanner.ts index ea29ede..66adafd 100644 --- a/packages/cli/src/scanner.ts +++ b/packages/cli/src/scanner.ts @@ -1,4 +1,4 @@ -import { holdSnapshot, releaseSnapshots } from './db.js' +import { holdSnapshot, releaseSnapshots, setSnapshotsOff } from './db.js' import { policyOnlyKeys, sqlSkeleton } from './sql-deps.js' import { splitSqlStatements } from './utils/sql-split.js' import type { HookBus } from './hooks' @@ -210,6 +210,7 @@ export async function scan( } // Each side read as of one instant, and let go before anything is applied. + setSnapshotsOff(options.snapshot === false) if (options.snapshot !== false) { await Promise.all([source?.dbUrl, target?.dbUrl].filter(Boolean).map(url => holdSnapshot(url as string))) } diff --git a/packages/cli/test/dbdiff-units.test.ts b/packages/cli/test/dbdiff-units.test.ts index 380a696..2ba0b82 100644 --- a/packages/cli/test/dbdiff-units.test.ts +++ b/packages/cli/test/dbdiff-units.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' +import { setSnapshotsOff } from '../src/db.js' import { pairUnits, parseUnits } from '../src/dbdiff-units.js' import { buildDbDiffArgs, dbdiffNotices, destructiveReason, sqlToIssues, schemaNotices } from '../src/dbdiff.js' @@ -343,3 +344,18 @@ describe('default privileges a platform role holds', () => { } }) }) + +// The scan's instant, or none under --no-snapshot, reaches @dbdiff/cli. +describe('buildDbDiffArgs and snapshots', () => { + const opts = { sourceUrl: 'postgres://s', targetUrl: 'postgres://t', type: 'schema' as const, include: 'both' as const } + afterEach(() => setSnapshotsOff(false)) + + it('names no snapshot when the scan holds none', () => { + expect(buildDbDiffArgs(opts, '/tmp/o.sql').filter(a => /snapshot/.test(a))).toEqual([]) + }) + + it('passes --no-snapshot when the scan reads each query on its own', () => { + setSnapshotsOff(true) + expect(buildDbDiffArgs(opts, '/tmp/o.sql')).toContain('--no-snapshot') + }) +}) diff --git a/packages/cli/test/e2e/snapshot-reads.test.ts b/packages/cli/test/e2e/snapshot-reads.test.ts index 335f268..a128e13 100644 --- a/packages/cli/test/e2e/snapshot-reads.test.ts +++ b/packages/cli/test/e2e/snapshot-reads.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { PgHarness } from '../harness/PgHarness.js' import { describeWithContainers } from '../harness/containers.js' import { holdSnapshot, heldSnapshot, inRolledBackTransaction, pgQuery, releaseSnapshots } from '../../src/db.js' +import { buildDbDiffArgs } from '../../src/dbdiff.js' const describeE2E = describeWithContainers() @@ -30,6 +31,9 @@ describeE2E('e2e: reads as of one instant', () => { const url = h.connectionString('source') expect(await holdSnapshot(url)).toBe(true) expect(heldSnapshot(url)).toBeTruthy() + // @dbdiff/cli reads as of the same instant. + expect(buildDbDiffArgs({ sourceUrl: url, targetUrl: 'postgres://elsewhere', type: 'schema', include: 'both' }, '/tmp/o.sql')) + .toContain(`--source-snapshot=${heldSnapshot(url)}`) await count(url, 'made_meanwhile') await h.applySql('source', 'CREATE TABLE public.made_meanwhile (id int);') From 590f9f8ca65aa497f7c621f5133091232eba9894 Mon Sep 17 00:00:00 2001 From: Jasdeep Date: Tue, 20 Oct 2026 23:47:23 +0000 Subject: [PATCH 3/5] ci: the version matrix waits for a server that has finished starting Two of the script's helpers had been cut out with code that was removed, so every pair failed at its fixture. They are back, and a server now counts as up only once it answers over TCP three times running: on first start the image initialises with a server only its socket reaches, then restarts it. A fixture that fails says why. Co-Authored-By: Claude Opus 5.5 --- packages/cli/scripts/version-matrix.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cli/scripts/version-matrix.sh b/packages/cli/scripts/version-matrix.sh index 530bf47..35062f1 100755 --- a/packages/cli/scripts/version-matrix.sh +++ b/packages/cli/scripts/version-matrix.sh @@ -39,18 +39,30 @@ start(){ local node=$1 c=$2 port=$3 else docker run -d --name $c -p 127.0.0.1:$port:5432 -e POSTGRES_PASSWORD=rootpass "$(image $node)" >/dev/null fi - for _ in $(seq 180); do - q $(kind $node) $c $port postgres -c 'SELECT 1' >/dev/null 2>&1 && return 0 + # Over TCP, three times running: on first start the image initialises the + # database with a server only its socket reaches, then restarts it. + local ok=0 + for _ in $(seq 240); do + if ready $(kind $node) $c; then ok=$((ok + 1)); [ $ok -ge 3 ] && return 0; else ok=0; fi sleep 1 done echo "$c did not start"; docker logs $c 2>&1 | tail -20; exit 1 } +ready(){ if [ $1 = sb ]; then docker exec -e PGPASSWORD=smokepass $2 psql -h localhost -U supabase_admin -d postgres -Atc 'SELECT 1' >/dev/null 2>&1 + else docker exec $2 psql -h 127.0.0.1 -U postgres -d postgres -Atc 'SELECT 1' >/dev/null 2>&1; fi; } + # psql as an admin on a node's database: q KIND CONTAINER PORT DB [psql args] q(){ local kind=$1 c=$2 p=$3 db=$4; shift 4 if [ $kind = sb ]; then docker exec -i -e PGPASSWORD=smokepass $c psql -h localhost -U supabase_admin -d $db -q -X -At -v ON_ERROR_STOP=1 "$@" else docker exec -i $c psql -U postgres -d $db -q -X -At -v ON_ERROR_STOP=1 "$@"; fi; } +# the same as the role SupaForge connects as (postgres on Supabase) +qu(){ local kind=$1 c=$2 p=$3 db=$4; shift 4 + if [ $kind = sb ]; then docker exec -i -e PGPASSWORD=smokepass $c psql -h localhost -U postgres -d $db -q -X -At -v ON_ERROR_STOP=1 "$@" + else q $kind $c $p $db "$@"; fi; } +url(){ [ $1 = sb ] && echo "postgresql://postgres:smokepass@127.0.0.1:$3/postgres" || echo "postgres://postgres:rootpass@127.0.0.1:$3/$4"; } + # The fixture, for a source of KIND and VERSION. fixture(){ local kind=$1 v=$2 cat <<'SQL' @@ -210,7 +222,7 @@ pair(){ local s=$1 t=$2 name="$1>$2" reset $sk $sc $sp $sdb; reset $tk $tc $tp $tdb if ! fixture $sk $sv | qu $sk $sc $sp $sdb > $WS/fixture.log 2>&1 \ || { [ $sk = sb ] && ! sb_admin_fixture | q sb $sc $sp $sdb >> $WS/fixture.log 2>&1; }; then - row "$name" setup "FAIL fixture: $(grep -m1 ERROR $WS/fixture.log)"; return; fi + row "$name" setup "FAIL fixture: $(grep -m1 -i error $WS/fixture.log)"; return; fi local rdb=mx_r printf '{ "environments": { "src": { "dbUrl": "%s" }, "tgt": { "dbUrl": "%s" }%s }, "source": "src", "target": "tgt", "checks": { "data": { "tables": ["mx_plans"] } } }\n' \ "$(url $sk $sc $sp $sdb)" "$(url $tk $tc $tp $tdb)" "$([ $tk = pg ] && printf ', "rst": { "dbUrl": "%s" }' "$(url pg $tc $tp $rdb)")" > $WS/supaforge.config.json From 2a32a08437e97b83d4e2fc718fbe819ca7164dbf Mon Sep 17 00:00:00 2001 From: Jasdeep Date: Wed, 21 Oct 2026 11:48:16 +0000 Subject: [PATCH 4/5] ci: fetch Supabase's images once for the whole version matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every job pulled Supabase's PostgreSQL images anonymously at the same time, and the registry refused with "toomanyrequests: Data limit exceeded", failing every pair with Supabase 17. They are now fetched once — from Docker Hub, or Amazon's public registry failing that — and kept in the Actions cache, from which each job loads them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/version-matrix.yml | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/version-matrix.yml b/.github/workflows/version-matrix.yml index 5802dd9..2c0120c 100644 --- a/.github/workflows/version-matrix.yml +++ b/.github/workflows/version-matrix.yml @@ -19,12 +19,40 @@ on: permissions: contents: read +# Supabase's images, fetched once and shared: pulled anonymously by every job +# at once, the registry refused ("toomanyrequests: Data limit exceeded"). +env: + SUPABASE_TAGS: 15.8.1.085 17.6.1.176 + IMAGES_KEY: supabase-postgres-15.8.1.085-17.6.1.176 + jobs: pairs: runs-on: ubuntu-latest outputs: pairs: ${{ steps.pick.outputs.pairs }} steps: + - name: Cache Supabase's images + id: images + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/images + key: ${{ env.IMAGES_KEY }} + + - name: Fetch Supabase's images + if: steps.images.outputs.cache-hit != 'true' + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/images" + for tag in $SUPABASE_TAGS; do + for registry in docker.io/supabase public.ecr.aws/supabase; do + if docker pull -q "$registry/postgres:$tag"; then + docker tag "$registry/postgres:$tag" "matrix/supabase:$tag" + break + fi + done + docker save "matrix/supabase:$tag" | zstd -q -T0 -o "$RUNNER_TEMP/images/$tag.tar.zst" + done + - id: pick run: | if [ "${{ github.event_name }}" = pull_request ]; then @@ -65,6 +93,16 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends postgresql-client-18 + - name: Supabase's images + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/images + key: ${{ env.IMAGES_KEY }} + fail-on-cache-miss: true + + - name: Load them + run: for f in "$RUNNER_TEMP"/images/*.tar.zst; do zstd -dc "$f" | docker load -q; done + - name: Build working-directory: packages/cli run: npm ci && npm run build @@ -73,6 +111,8 @@ jobs: working-directory: packages/cli env: PAIR: ${{ matrix.pair }} + SB15_IMAGE: matrix/supabase:15.8.1.085 + SB17_IMAGE: matrix/supabase:17.6.1.176 SUPAFORGE_PG_BIN: /usr/lib/postgresql/18/bin OUT: ${{ runner.temp }}/matrix run: scripts/version-matrix.sh "${PAIR%%>*}" "${PAIR##*>}" From fb5a0787aab6b13e37e82f5d954c6dc4af828fdd Mon Sep 17 00:00:00 2001 From: Jasdeep Date: Wed, 21 Oct 2026 12:03:29 +0000 Subject: [PATCH 5/5] fix: a role the target does not have no longer takes a view out of the apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Supabase to plain PostgreSQL, a view dbdiff replaces carries its grants, to anon among them; with no such role on the target the GRANT failed, and the view — and every view reading it — was left out. A policy for such a role also made --prove decline the whole migration. The schema check now leaves out of each fix the grants, revokes and policies naming a role the target does not have, keeping the rest; one left with nothing is held back and says why, its SQL kept so a proof knows it stays different on purpose. The roles check reports grants to such a role on its own. Found by the version matrix on a fresh PostgreSQL server. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/checks/schema.ts | 16 +++++++-- packages/cli/src/dbdiff.ts | 48 ++++++++++++++++++++++++++ packages/cli/test/dbdiff-units.test.ts | 33 +++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/checks/schema.ts b/packages/cli/src/checks/schema.ts index 2bfc3f4..c1acb41 100644 --- a/packages/cli/src/checks/schema.ts +++ b/packages/cli/src/checks/schema.ts @@ -1,7 +1,7 @@ import type { QueryFn } from '../db' import { pgQuery } from '../db' import type { DriftIssue } from '../types/drift' -import { runDbDiff, schemaNotices, sqlToIssues, type DbDiffOptions } from '../dbdiff' +import { runDbDiff, schemaNotices, sqlToIssues, withoutAbsentRoles, type DbDiffOptions } from '../dbdiff' import { Check, CheckSkipped, type CheckContext } from './base' import { DEFAULT_IGNORE_SCHEMAS } from '../defaults' @@ -48,7 +48,9 @@ export class SchemaCheck extends Check { this.fetchIgnoredSchemaTables(ctx.target.dbUrl, ignoreSchemas), ]) for (const notice of schemaNotices(result.notices ?? [])) ctx.onNotice?.(notice) - return sqlToIssues(result, 'schema', ignoreSchemas, ignoredSchemaTables) + const issues = sqlToIssues(result, 'schema', ignoreSchemas, ignoredSchemaTables) + const roles = await this.targetRoles(ctx.target.dbUrl) + return roles?.size ? withoutAbsentRoles(issues, roles) : issues } catch (err) { if (err instanceof Error && err.message.includes('@dbdiff/cli is not installed')) { throw new CheckSkipped('@dbdiff/cli is not installed') @@ -67,6 +69,16 @@ export class SchemaCheck extends Check { * Returns a Set of lowercase table names, bare and schema-qualified. Fails silently so that a transient * DB error here never blocks the main schema diff. */ + /** The target's roles, or undefined when they cannot be read. */ + private async targetRoles(dbUrl: string): Promise | undefined> { + try { + const rows = await this.queryFn(dbUrl, 'SELECT rolname FROM pg_roles') as Array<{ rolname: string }> + return new Set(rows.map(r => r.rolname)) + } catch { + return undefined + } + } + async fetchIgnoredSchemaTables(dbUrl: string, ignoreSchemas: string[]): Promise> { if (ignoreSchemas.length === 0) return new Set() try { diff --git a/packages/cli/src/dbdiff.ts b/packages/cli/src/dbdiff.ts index 2511f41..3b33ca4 100644 --- a/packages/cli/src/dbdiff.ts +++ b/packages/cli/src/dbdiff.ts @@ -1905,3 +1905,51 @@ export function extractRoutineArgs(sql: string): string { } return '' } + +/** The roles a GRANT, REVOKE, ALTER DEFAULT PRIVILEGES or policy statement names, PUBLIC and built-in roles aside. */ +function rolesAddressed(sql: string): string[] { + const names = (specs: Array> = []) => specs + .map(s => s.RoleSpec) + .filter(r => r?.roletype === 'ROLESPEC_CSTRING' && typeof r.rolename === 'string' && !r.rolename.startsWith('pg_')) + .map(r => r.rolename as string) + return (parseStatements(sql) ?? []).flatMap(({ kind, node }) => + kind === 'GrantStmt' ? names(node.grantees) + : kind === 'CreatePolicyStmt' || kind === 'AlterPolicyStmt' ? names(node.roles) + : kind === 'AlterDefaultPrivilegesStmt' + ? [...names(node.action?.grantees), ...names((node.options ?? []) + .filter((o: any) => o.DefElem?.defname === 'roles').flatMap((o: any) => o.DefElem.arg?.List?.items ?? []))] + : []) +} + +/** + * Leave out of each fix the grants, revokes and policies that name a role + * the target does not have — Supabase's `anon` on plain PostgreSQL. A view + * replaced from Supabase carried its grants with it, and the GRANT failing + * took the view, and every view reading it, out of the apply; and --prove + * declined a migration naming such a role. The roles check reports grants to + * one on its own; a fix left with nothing says why. + */ +export function withoutAbsentRoles(issues: DriftIssue[], targetRoles: ReadonlySet): DriftIssue[] { + const absentIn = (sql: string) => rolesAddressed(sql).filter(r => !targetRoles.has(r)) + return issues.map(issue => { + if (!issue.sql) return issue + const missing = new Set() + const keep = (sql: string) => splitStatements(sql).filter(statement => { + const absent = absentIn(statement) + absent.forEach(r => missing.add(r)) + return absent.length === 0 + }) + const up = keep(issue.sql.up) + if (missing.size === 0) return issue + const down = keep(issue.sql.down ?? '') + const roles = [...missing].sort().join(', ') + (missing.size > 1 ? ', roles' : ', a role') + // Nothing left to apply: held back, its SQL kept so a proof can tell + // what stays different on purpose. + if (up.length === 0) return { ...issue, manualOnly: `it names ${roles} the target does not have` } + return { + ...issue, + description: `${issue.description} What it grants to ${roles} the target does not have is left out.`, + sql: { up: up.join('\n'), down: down.join('\n') }, + } + }) +} diff --git a/packages/cli/test/dbdiff-units.test.ts b/packages/cli/test/dbdiff-units.test.ts index 2ba0b82..6a39a40 100644 --- a/packages/cli/test/dbdiff-units.test.ts +++ b/packages/cli/test/dbdiff-units.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest' import { setSnapshotsOff } from '../src/db.js' import { pairUnits, parseUnits } from '../src/dbdiff-units.js' -import { buildDbDiffArgs, dbdiffNotices, destructiveReason, sqlToIssues, schemaNotices } from '../src/dbdiff.js' +import { buildDbDiffArgs, dbdiffNotices, destructiveReason, sqlToIssues, schemaNotices, withoutAbsentRoles } from '../src/dbdiff.js' /** A section as `dbdiff --units` writes it. */ function units(...parts: Array<[kind: string, object: string, sql: string]>): string { @@ -359,3 +359,34 @@ describe('buildDbDiffArgs and snapshots', () => { expect(buildDbDiffArgs(opts, '/tmp/o.sql')).toContain('--no-snapshot') }) }) + +// A view replaced from Supabase keeps its grants, to anon among them; on a +// target without that role the GRANT failed, and the view went with it. +describe('withoutAbsentRoles', () => { + const issue = (up: string, down = '') => ({ id: 'x', check: 'schema' as const, severity: 'warning' as const, title: 't', description: 'd.', sql: { up, down } }) + const roles = new Set(['postgres', 'authenticator']) + + it('leaves a grant to an absent role out of the fix, and keeps the rest', () => { + const [out] = withoutAbsentRoles([issue( + 'DROP VIEW IF EXISTS "v";\nCREATE VIEW "v" AS SELECT 1;\nGRANT SELECT ON "v" TO "anon";\nGRANT SELECT ON "v" TO "postgres";', + 'REVOKE SELECT ON "v" FROM "anon";\nDROP VIEW "v";')], roles) + expect(out.sql?.up).toBe('DROP VIEW IF EXISTS "v";\nCREATE VIEW "v" AS SELECT 1;\nGRANT SELECT ON "v" TO "postgres";') + expect(out.sql?.down).toBe('DROP VIEW "v";') + expect(out.description).toContain('anon, a role the target does not have') + }) + + it('reports a fix left with nothing, and why', () => { + const [out] = withoutAbsentRoles([issue('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO anon, authenticated;')], roles) + expect(out.manualOnly).toBe('it names anon, authenticated, roles the target does not have') + }) + + it('leaves out a policy for an absent role', () => { + const [out] = withoutAbsentRoles([issue('CREATE POLICY "own" ON "t" FOR SELECT TO "authenticated" USING (true);')], roles) + expect(out.manualOnly).toBe('it names authenticated, a role the target does not have') + }) + + it('changes nothing when every role is there', () => { + const fix = issue('GRANT SELECT ON "v" TO "postgres", PUBLIC;') + expect(withoutAbsentRoles([fix], roles)).toEqual([fix]) + }) +})