From ccc3572f3d069751d3850046ea1e6ea0c2ed8c7a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:47:54 +0800 Subject: [PATCH] fix(db): read migration history through a service-role RPC so live-drift can pass (#316) The live-drift workflow's "Align migration history for Supabase Preview" step has never been able to pass on this project. It read supabase_migrations.schema_migrations directly through PostgREST with Accept-Profile, and this project has never exposed that schema to the Data API, so the read returns 406 PGRST106 every time. The defect stayed hidden because the drift comparison ran first and always failed, leaving this step skipped. Phase 6.2 cleared the last drift finding on 2026-08-19, the step ran for the first time ever, and it is now the sole reason the job concludes failure - and therefore the sole reason pinned issue #1963 stays open against a database whose drift block is empty (run 32378402265, 2026-08-20: "Compare live schema drift: success", "No unexpected schema drift"). 20260820120000 adds public.migration_history_versions(): stable, security definer, search_path pinned to '', dynamic read guarded by to_regclass, returning {probe, versions} for every history row, revoked from public/anon/ authenticated and granted to service_role alone - the established schema_drift_snapshot() pattern. Exposing supabase_migrations to the Data API would widen the public PostgREST surface of a clinical project for one weekly read; routing through the management API would put an account-scoped token into CI. Both are recorded as rejected in the forensics file. check-migration-history-alignment.ts now prefers the RPC and falls back to the old read only when the function itself is absent. Every other outcome raises, including probe: no_history_table - a check that reports "aligned" because it could not look is worse than the red job it replaces. The migration is NOT deployed: D4 is off, so db push runs from this branch in an approved window BEFORE merge, the order Phase 4 used. Until it is applied, check:drift would report migration_history_versions as a missing function. Co-Authored-By: Claude Opus 5 --- docs/audit/live-drift-forensics-2026-08.md | 73 +++++++++++ docs/database-drift-detection.md | 36 +++++ docs/database-remediation-coordination.md | 17 ++- scripts/check-migration-history-alignment.ts | 110 +++++++++++++--- supabase/drift-manifest.json | 14 +- ...0120000_migration_history_versions_rpc.sql | 71 ++++++++++ supabase/schema.sql | 37 ++++++ tests/migration-history-alignment.test.ts | 123 ++++++++++++++++++ 8 files changed, 457 insertions(+), 24 deletions(-) create mode 100644 supabase/migrations/20260820120000_migration_history_versions_rpc.sql create mode 100644 tests/migration-history-alignment.test.ts diff --git a/docs/audit/live-drift-forensics-2026-08.md b/docs/audit/live-drift-forensics-2026-08.md index 87c6c37d15..166269ae6e 100644 --- a/docs/audit/live-drift-forensics-2026-08.md +++ b/docs/audit/live-drift-forensics-2026-08.md @@ -1950,3 +1950,76 @@ the `SUPABASE_ACCESS_TOKEN` secret of `#183`), or a service-role RPC listing ver with its own window). Queued as its own ledger item from this session; until it is fixed the weekly job will stay red on that step alone and the pinned issue will not self-close — **the drift block, which is what the issue was opened for, is clear.** + +## Alignment-step repair — 2026-08-20 (repo-side; production deploy still owed) + +_Follow-on from Phase 6.2 step 6. Repo-only session: no hosted mutation, no provider gate run. The +three GitHub reads (open-PR list, issue #1963, live-drift run `32378402265`) were owner-requested._ + +### The finding restated, re-measured on `main` + +live-drift run [`32378402265`](https://github.com/BigSimmo/Database/actions/runs/32378402265), +2026-08-20T14:09:03Z, `main`, weekly cron. Step conclusions: + +``` +Compare live schema drift: success +Align migration history for Supabase Preview: failure +``` + +Compare step, decisive lines: + +``` +Compared 6 extensions, 38 tables, 1 views, 93 functions, 210 indexes, 48 policies, 170 constraints, 26 triggers, 2 storage_buckets against live. +No unexpected schema drift between live and supabase/schema.sql. +``` + +Alignment step, decisive line: + +``` +Unable to read remote schema_migrations via Accept-Profile (status 406: {"code":"PGRST106","details":null,"hint":"Only the following schemas are exposed: public, graphql_public","message":"Invalid schema: supabase_migrations"}) +``` + +So the drift block has now been empty for **two consecutive runs** (`32251326536` on the 6.2 branch, +`32378402265` on `main`), and issue #1963 is still open solely because a sibling step cannot read a +table it was never able to read. `#316`'s finding set stays empty. + +### Fix: least-privilege RPC, not a widened API surface and not a new credential + +`20260820120000_migration_history_versions_rpc.sql` adds +`public.migration_history_versions()` — `stable`, `security definer`, `set search_path to ''`, +dynamic read guarded by `to_regclass`, returning `{probe, versions}` for every history row. +`revoke ... from public, anon, authenticated` + `grant ... to service_role`, exactly the +`schema_drift_snapshot()` pattern (`20260706200000` / `20260818090000`). + +Two alternatives were rejected and are recorded so the choice is not re-litigated: + +| Option | Why not | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Expose `supabase_migrations` to the Data API | Widens the public PostgREST surface of a clinical project for one weekly read, and lives in dashboard config, not in git | +| Read via the management API + `SUPABASE_ACCESS_TOKEN` | Puts an account-scoped token into CI secrets — far broader authority than the read needs; also still blocked on `#183` | + +`scripts/check-migration-history-alignment.ts` now tries the RPC first and falls back to the old +Accept-Profile read **only** when the function itself is absent (404 / `PGRST202`). Every other outcome +raises, including `probe: no_history_table` — a check that reports "aligned" because it could not look +is worse than the red job it replaces. When neither path works, the error names the remedy. + +### Repo-side proof + +- `npm run drift:manifest` — full scratch replay of `supabase/schema.sql` into + `supabase/postgres:17.6.1.127`: "Replay complete in 58s". This executes the new function body in a + real Postgres, so the SQL is proven, not merely reviewed. Manifest now carries **94** functions + (was 93) with `public.migration_history_versions()` at + `acl: ["postgres=X/postgres", "service_role=X/postgres"]` — least privilege confirmed by replay, + no `PUBLIC` execute. `schema_sha256` `6fe4883e03fa…`. +- `tests/migration-history-alignment.test.ts` — 7 tests: RPC preferred and Accept-Profile never sent; + fallback only on an absent function; unexpected RPC failure surfaces rather than falling back; + `no_history_table` is an error; migration-vs-`schema.sql` byte parity; read-only + service-role-only + shape. + +### What is still owed + +The migration is **not deployed**. D4 is OFF, so merging does not apply it, and until it is applied +`check:drift` will report `migration_history_versions` as a missing function — i.e. merging before the +window trades one red for another. **Deploy from the branch first, then merge**, which is the order +Phase 4 used (§Phase 4 completion). Staging needs the same migration by the Phase 2 method to hold the +parity Phase 4 restored. diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index 2aabe0baef..5a85212de0 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -143,6 +143,42 @@ entries, which is how a wrong seed is caught; against staging every `migration_history` entry reads stale by design (staging's chain was replayed with statements), so never run `--prune-stale` there. +## Migration-history alignment (`npm run check:migration-history`) + +A second, separate step of `.github/workflows/live-drift.yml`, added by Phase 0 +(PR #1939). It compares the versions in `supabase/migrations` against the versions +recorded in live `supabase_migrations.schema_migrations` and fails when live holds +a version with no local file — the state that makes a hosted Supabase Preview +branch fail with "Remote migration versions not found in local migrations +directory". It is not the probe above: the probe asks whether an applied version +executed its DDL, this asks whether an applied version exists in the repo at all. + +**It could never pass on this project until 2026-08-20.** The original +implementation read the history table straight through PostgREST with +`Accept-Profile: supabase_migrations`, and this project has never exposed that +schema to the Data API, so the read returned `406 PGRST106` every time. The defect +stayed hidden because the drift comparison ran first and always failed, leaving +this step `skipped`; Phase 6.2 cleared the last drift finding on 2026-08-19 and the +step ran for the first time ever, becoming the sole reason the job still concluded +`failure` — and therefore the sole reason pinned issue #1963 stayed open against a +clean database. + +The read now goes through **`public.migration_history_versions()`** (migration +`20260820120000`), a `stable` `security definer` function with `search_path` pinned +to `''`, granted to `service_role` only, that returns `{probe, versions}` for every +row of the history table. It is deliberately the smallest possible authority: +exposing `supabase_migrations` to the Data API would widen the public API surface +of a clinical project for one weekly read, and routing through the management API +would put an account-scoped access token into CI. + +The Accept-Profile read is retained as a fallback for any environment that does +expose the schema, and is tried **only** when the RPC itself is absent. Every other +outcome is an error, including a database with no history table at all +(`probe: no_history_table`) — a check that reports "aligned" because it could not +look would be worse than the red job it replaced. When neither path works the +failure names the remedy: apply `20260820120000` through the normal linked +migration workflow. Pinned by `tests/migration-history-alignment.test.ts`. + ## Guard-migration contract **Rule (also in `AGENTS.md`, "Supabase project safety"): any mark-applied diff --git a/docs/database-remediation-coordination.md b/docs/database-remediation-coordination.md index 01d97bf10c..80d5d4b87b 100644 --- a/docs/database-remediation-coordination.md +++ b/docs/database-remediation-coordination.md @@ -128,7 +128,22 @@ open PR #2130 queues 7 more requests but carries **no** reconcile transaction. O link a dedicated worktree for production reads and `supabase unlink` after; `supabase db query --linked --project-ref ` works read-only via the management API without a DB password. -**Where the programme stands after Phase 6.2 (2026-08-19, later the same day).** Drift is **green on both tiers**: the fifteen `migration_history` rows now carry `validation` guards, live-drift run `32251326536` (dispatched on the 6.2 branch) reports `No unexpected schema drift` with all 20 history rows allowed, and the staging comparison is green with the 20 production-scoped entries reading stale as designed. `#316`'s finding set is empty for the first time since 2026-07-26. **Two things still need the owner, not a worker:** (1) the live-drift job is red on its `Align migration history` step alone — `check:migration-history` cannot read `supabase_migrations` over PostgREST (PGRST106), a Phase 0 step that had never run before because drift always failed first; until it is fixed (expose the schema read-only / wire `SUPABASE_ACCESS_TOKEN` `#183` and use `supabase migration list` / add a versions RPC) the weekly job stays red and pinned issue #1963 will not self-close even though its drift block is empty; (2) PITR is still off on production (Phase 4 escalation). **Next dispatches:** merge the 6.2 PR (its CI `Migration replay` and Supabase Preview are the last chain proofs), then Phase 5 close-out (after-EXPLAIN set, `#231` re-test on healthy latency, `check:production-readiness`), then one serialized `issues:reconcile`. Every future migration still needs its own approved window and its own `db push` (D4 OFF). +**Where the programme stands after Phase 6.2 (2026-08-19, later the same day).** Drift is **green on both tiers**: the fifteen `migration_history` rows now carry `validation` guards, live-drift run `32251326536` (dispatched on the 6.2 branch) reports `No unexpected schema drift` with all 20 history rows allowed, and the staging comparison is green with the 20 production-scoped entries reading stale as designed. `#316`'s finding set is empty for the first time since 2026-07-26. **Two things still need the owner, not a worker:** (1) the live-drift job is red on its `Align migration history` step alone — `check:migration-history` cannot read `supabase_migrations` over PostgREST (PGRST106), a Phase 0 step that had never run before because drift always failed first; until it is fixed (expose the schema read-only / wire `SUPABASE_ACCESS_TOKEN` `#183` and use `supabase migration list` / add a versions RPC) the weekly job stays red and pinned issue #1963 will not self-close even though its drift block is empty; (2) PITR is still off on production (Phase 4 escalation). + +**Update 2026-08-20 — the alignment step is fixed in the repo, not yet on production.** Drift stayed +empty on a second run (`32378402265`, `main`, weekly cron: `Compare live schema drift: success` / +`No unexpected schema drift`), confirming Phase 6.2 held. The PGRST106 defect was repaired by +`20260820120000_migration_history_versions_rpc.sql` — a `security definer`, service-role-only read of +the history version list — plus an RPC-first rewrite of +`scripts/check-migration-history-alignment.ts`, `schema.sql` mirror, regenerated manifest (94 +functions) and `tests/migration-history-alignment.test.ts`. Exposing `supabase_migrations` to the Data +API and adding a management-API token to CI were both considered and rejected (forensics §Alignment-step +repair). **Deploy order matters: `db push` from the branch FIRST, then merge** — D4 is OFF, so merging +alone applies nothing and would make `check:drift` report a missing function in the meantime. Staging +needs the same migration by the Phase 2 method. Until that window runs, the weekly job stays red on +this one step and #1963 will not self-close. + +**Next dispatches:** merge the 6.2 PR (its CI `Migration replay` and Supabase Preview are the last chain proofs), then Phase 5 close-out (after-EXPLAIN set, `#231` re-test on healthy latency, `check:production-readiness`), then one serialized `issues:reconcile`. Every future migration still needs its own approved window and its own `db push` (D4 OFF). **Where the programme stands after Phase 4 (2026-08-19).****Where the programme stands after Phase 4 (2026-08-19).** The index track of `#316` is closed on both tiers and staging is at full parity, so the remaining live-drift findings are exactly one category: diff --git a/scripts/check-migration-history-alignment.ts b/scripts/check-migration-history-alignment.ts index d390276787..4fdd6ab102 100644 --- a/scripts/check-migration-history-alignment.ts +++ b/scripts/check-migration-history-alignment.ts @@ -16,9 +16,24 @@ loadEnvConfig(process.cwd()); * This script prints remote-only / local-only versions and exits 1 when any * remote-only versions remain. It is intended for workflow_dispatch / live * alignment checks (uses service-role secrets). + * + * TRANSPORT: the history table lives in the `supabase_migrations` schema, which + * this project does not expose to the Data API — a direct PostgREST read returns + * 406 PGRST106, so the original Accept-Profile read could never succeed here. It + * went unnoticed until 2026-08-19 because the live-drift workflow's drift + * comparison always failed first and skipped this step; once Phase 6.2 cleared + * the last drift finding, this became the sole reason the job stayed red (and + * pinned issue #1963 stayed open) against a clean database. The read now goes + * through public.migration_history_versions(), a service-role-only security + * definer RPC (migration 20260820120000), with the Accept-Profile read retained + * as a fallback for any environment that does expose the schema. See + * docs/database-drift-detection.md and docs/audit/live-drift-forensics-2026-08.md. */ +export const MIGRATION_HISTORY_VERSIONS_MIGRATION = "20260820120000_migration_history_versions_rpc.sql"; + type RemoteRow = { version: string; name: string | null }; +type RemoteRead = { rows: RemoteRow[]; source: "rpc" | "accept-profile" }; function localMigrationVersions(migrationsDir: string): string[] { return readdirSync(migrationsDir) @@ -30,29 +45,81 @@ function localMigrationVersions(migrationsDir: string): string[] { .sort(); } -async function fetchRemoteVersions(url: string, serviceKey: string): Promise { - // Read supabase_migrations via Accept-Profile when the project exposes that - // schema to the Data API. Preview alignment itself only needs local files to - // cover remote versions; this check is diagnostic for live history drift. - const profileResponse = await fetch(`${url}/rest/v1/schema_migrations?select=version,name&order=version.asc`, { +function authHeaders(serviceKey: string): Record { + return { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` }; +} + +/** + * Preferred path: the service-role-only RPC. Returns null (rather than throwing) + * only when the function itself is absent, so a project that has not yet applied + * the migration can still fall back to Accept-Profile. Every other failure — + * including a live database with no history table — is a real error and throws. + */ +async function fetchViaRpc(url: string, serviceKey: string): Promise { + const response = await fetch(`${url}/rest/v1/rpc/migration_history_versions`, { + method: "POST", signal: AbortSignal.timeout(10_000), - headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - "Accept-Profile": "supabase_migrations", - }, + headers: { ...authHeaders(serviceKey), "Content-Type": "application/json" }, + body: "{}", }); - if (profileResponse.ok) { - return (await profileResponse.json()) as RemoteRow[]; + + if (!response.ok) { + const text = await response.text(); + if (response.status === 404 || /PGRST202|could not find the function|schema cache/i.test(text)) { + return null; + } + throw new Error(`migration_history_versions() RPC failed (status ${response.status}: ${text.slice(0, 240)})`); } - const profileText = await profileResponse.text(); + const payload = (await response.json()) as { probe?: string; versions?: RemoteRow[] } | null; + if (!payload || typeof payload !== "object") { + throw new Error("migration_history_versions() returned an unexpected payload"); + } + if (payload.probe !== "ok") { + throw new Error( + `migration_history_versions() reports probe "${payload.probe ?? "unknown"}": the target database has no ` + + `supabase_migrations.schema_migrations table, so migration history cannot be compared. ` + + `Check that this is the intended project.`, + ); + } + return payload.versions ?? []; +} + +/** Legacy path, kept for any project that exposes supabase_migrations to PostgREST. */ +async function fetchViaAcceptProfile(url: string, serviceKey: string): Promise { + const response = await fetch(`${url}/rest/v1/schema_migrations?select=version,name&order=version.asc`, { + signal: AbortSignal.timeout(10_000), + headers: { ...authHeaders(serviceKey), "Accept-Profile": "supabase_migrations" }, + }); + if (response.ok) { + return (await response.json()) as RemoteRow[]; + } + const text = await response.text(); throw new Error( `Unable to read remote schema_migrations via Accept-Profile ` + - `(status ${profileResponse.status}: ${profileText.slice(0, 240)})`, + `(status ${response.status}: ${text.slice(0, 240)})`, ); } +export async function fetchRemoteVersions(url: string, serviceKey: string): Promise { + const viaRpc = await fetchViaRpc(url, serviceKey); + if (viaRpc) { + return { rows: viaRpc, source: "rpc" }; + } + + try { + return { rows: await fetchViaAcceptProfile(url, serviceKey), source: "accept-profile" }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Migration history is unreadable on this project. public.migration_history_versions() is not ` + + `available — apply supabase/migrations/${MIGRATION_HISTORY_VERSIONS_MIGRATION} through the normal ` + + `linked migration workflow (an approved window plus \`supabase db push\`; the Supabase GitHub ` + + `auto-deploy is off) — and the direct read also failed. ${detail}`, + ); + } +} + async function main() { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -64,12 +131,12 @@ async function main() { const local = new Set(localMigrationVersions(migrationsDir)); console.log(`Local migration versions: ${local.size}`); - const remoteRows = await fetchRemoteVersions(url, serviceKey); + const { rows: remoteRows, source } = await fetchRemoteVersions(url, serviceKey); const remote = new Set(remoteRows.map((row) => row.version)); const remoteOnly = [...remote].filter((version) => !local.has(version)).sort(); const localOnly = [...local].filter((version) => !remote.has(version)).sort(); - console.log(`Remote migration versions: ${remote.size}`); + console.log(`Remote migration versions: ${remote.size} (read via ${source})`); console.log(`Remote-only (Preview blockers): ${remoteOnly.length}`); for (const version of remoteOnly) { const row = remoteRows.find((item) => item.version === version); @@ -94,7 +161,10 @@ async function main() { console.log("Migration history alignment OK: every remote version exists locally."); } -main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -}); +const invokedDirectly = process.argv[1] && /check-migration-history-alignment\.(ts|mts|js)$/.test(process.argv[1]); +if (invokedDirectly) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 1bc9e2e45d..45de942324 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-08-18T18:15:50.121Z", + "generated_at": "2026-08-20T15:53:04.540Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "328677d1c6f3e0136ca108e8117a73051553a2387b078836001e8cdc96f4c2d3", - "replay_seconds": 21, + "schema_sha256": "6fe4883e03fa662dc90e09f3cfd8d35ab9fcf965bfe87bad64db73f1fab5986e", + "replay_seconds": 58, "snapshot": { "views": [ { @@ -7139,6 +7139,14 @@ "def_hash": "8248e8d1aec23bbad35aca0ddc4dc94d", "signature": "public.match_documents_for_query(text,integer,uuid)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "4e8e4f9ee726a5eef77d6d6a646fe787", + "signature": "public.migration_history_versions()" + }, { "acl": [ "postgres=X/postgres", diff --git a/supabase/migrations/20260820120000_migration_history_versions_rpc.sql b/supabase/migrations/20260820120000_migration_history_versions_rpc.sql new file mode 100644 index 0000000000..3d56df78f9 --- /dev/null +++ b/supabase/migrations/20260820120000_migration_history_versions_rpc.sql @@ -0,0 +1,71 @@ +-- migration_history_versions(): a service-role-only read of the applied +-- migration version list, for `npm run check:migration-history`. +-- +-- WHY: the live-drift workflow's "Align migration history for Supabase Preview" +-- step (scripts/check-migration-history-alignment.ts, added by Phase 0 in +-- PR #1939) reads supabase_migrations.schema_migrations directly through +-- PostgREST with `Accept-Profile: supabase_migrations`. This project has never +-- exposed that schema to the Data API, so the read can only ever fail: +-- +-- status 406 {"code":"PGRST106", ... "message":"Invalid schema: supabase_migrations"} +-- +-- The step had never actually run before 2026-08-19: on every earlier run the +-- drift comparison failed first and this step was skipped. Phase 6.2 closed the +-- last drift finding, the comparison went green for the first time since +-- 2026-07-26 (run 32251326536), and this latent defect surfaced as the sole +-- reason the job still concludes `failure` — which keeps pinned issue #1963 +-- open and makes a green database look red. Evidence: +-- docs/audit/live-drift-forensics-2026-08.md, Phase 6.2 step 6. +-- +-- WHY THIS SHAPE, and not the two alternatives: +-- * Exposing `supabase_migrations` to PostgREST would widen the public Data API +-- surface of a clinical project for one weekly CI read, and lives in dashboard +-- configuration rather than in git. +-- * Routing the read through the management API would put an account-scoped +-- Supabase access token into CI secrets — far broader authority than the read +-- requires. +-- A security-definer function granted to service_role alone is least +-- privilege, is already the established pattern here +-- (public.schema_drift_snapshot(), 20260706200000 / 20260818090000), and uses +-- the transport CI already holds a key for. +-- +-- Read-only: one select over the CLI history table. Returns every applied +-- version, unlike schema_drift_snapshot()'s `migration_history`, which returns +-- only the rows recorded WITHOUT executed statements. The two are complementary +-- and neither replaces the other. +-- +-- Read via dynamic SQL because search_path is pinned to '' and the schema does +-- not exist in the drift-manifest replay container (`npm run drift:manifest`) — +-- same pattern as the storage.buckets and history blocks of 20260818090000. +-- There the absence is reported honestly as `no_history_table` rather than as an +-- empty-but-fine result. + +create or replace function public.migration_history_versions() +returns jsonb +language plpgsql +stable +security definer +set search_path to '' +as $$ +declare + versions jsonb := '[]'::jsonb; + probe text := 'no_history_table'; +begin + if to_regclass('supabase_migrations.schema_migrations') is not null then + execute 'select coalesce(jsonb_agg(jsonb_build_object(' + || '''version'', m.version, ''name'', m.name' + || ') order by m.version), ''[]''::jsonb) ' + || 'from supabase_migrations.schema_migrations m' + into versions; + probe := 'ok'; + end if; + + return jsonb_build_object( + 'probe', probe, + 'versions', versions + ); +end; +$$; + +revoke execute on function public.migration_history_versions() from public, anon, authenticated; +grant execute on function public.migration_history_versions() to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 78d6934f9a..a07bff313d 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -3689,6 +3689,43 @@ $$; revoke execute on function public.schema_drift_snapshot() from public, anon, authenticated; grant execute on function public.schema_drift_snapshot() to service_role; +-- Applied migration version list backing the live-drift workflow's +-- "Align migration history for Supabase Preview" step +-- (`npm run check:migration-history`). Service-role only; complements +-- schema_drift_snapshot(), which reports only the rows recorded WITHOUT +-- executed statements. Keep this definition byte-identical to its defining +-- migration, supabase/migrations/20260820120000_migration_history_versions_rpc.sql; +-- tests/drift-detection.test.ts enforces the parity. +create or replace function public.migration_history_versions() +returns jsonb +language plpgsql +stable +security definer +set search_path to '' +as $$ +declare + versions jsonb := '[]'::jsonb; + probe text := 'no_history_table'; +begin + if to_regclass('supabase_migrations.schema_migrations') is not null then + execute 'select coalesce(jsonb_agg(jsonb_build_object(' + || '''version'', m.version, ''name'', m.name' + || ') order by m.version), ''[]''::jsonb) ' + || 'from supabase_migrations.schema_migrations m' + into versions; + probe := 'ok'; + end if; + + return jsonb_build_object( + 'probe', probe, + 'versions', versions + ); +end; +$$; + +revoke execute on function public.migration_history_versions() from public, anon, authenticated; +grant execute on function public.migration_history_versions() to service_role; + create or replace function public.explain_retrieval_rpc( p_rpc text, p_query_text text, diff --git a/tests/migration-history-alignment.test.ts b/tests/migration-history-alignment.test.ts new file mode 100644 index 0000000000..bd141c51cf --- /dev/null +++ b/tests/migration-history-alignment.test.ts @@ -0,0 +1,123 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MIGRATION_HISTORY_VERSIONS_MIGRATION, + fetchRemoteVersions, +} from "../scripts/check-migration-history-alignment"; + +const root = join(__dirname, ".."); +const read = (relative: string) => readFileSync(join(root, relative), "utf8"); + +const URL_ = "https://project.supabase.co"; +const KEY = "service-role-key"; + +type FetchCall = { url: string; init?: RequestInit }; + +/** + * The live-drift workflow's alignment step could never pass on this project: it + * read supabase_migrations through PostgREST, which returns 406 PGRST106 because + * the schema is not exposed to the Data API. The read now prefers a + * service-role-only RPC. These tests pin the preference order, the fallback, and + * the failure message — a check that silently degrades to "fine" would be worse + * than the red job it replaced. + */ +function stubFetch(handler: (call: FetchCall) => Response | Promise) { + const calls: FetchCall[] = []; + vi.stubGlobal("fetch", (url: string, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return Promise.resolve(handler({ url: String(url), init })); + }); + return calls; +} + +const rpcOk = (versions: { version: string; name: string | null }[]) => + new Response(JSON.stringify({ probe: "ok", versions }), { status: 200 }); + +const rpcAbsent = () => + new Response(JSON.stringify({ code: "PGRST202", message: "Could not find the function" }), { status: 404 }); + +const profileBlocked = () => + new Response(JSON.stringify({ code: "PGRST106", message: "Invalid schema: supabase_migrations" }), { status: 406 }); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("migration history remote read", () => { + it("prefers the service-role RPC and never touches the unexposed schema", async () => { + const calls = stubFetch(() => rpcOk([{ version: "20260101000000", name: "init" }])); + + const result = await fetchRemoteVersions(URL_, KEY); + + expect(result.source).toBe("rpc"); + expect(result.rows).toEqual([{ version: "20260101000000", name: "init" }]); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain("/rest/v1/rpc/migration_history_versions"); + expect(calls[0].init?.headers).not.toHaveProperty("Accept-Profile"); + }); + + it("falls back to the direct read only when the RPC does not exist yet", async () => { + const calls = stubFetch(({ url }) => + url.includes("/rpc/") + ? rpcAbsent() + : new Response(JSON.stringify([{ version: "20260101000000", name: "init" }]), { status: 200 }), + ); + + const result = await fetchRemoteVersions(URL_, KEY); + + expect(result.source).toBe("accept-profile"); + expect(result.rows).toHaveLength(1); + expect(calls).toHaveLength(2); + }); + + it("fails with the remedy named when neither path can read history", async () => { + stubFetch(({ url }) => (url.includes("/rpc/") ? rpcAbsent() : profileBlocked())); + + await expect(fetchRemoteVersions(URL_, KEY)).rejects.toThrow(MIGRATION_HISTORY_VERSIONS_MIGRATION); + await expect(fetchRemoteVersions(URL_, KEY)).rejects.toThrow(/PGRST106/); + }); + + it("treats a database with no history table as an error, not an empty history", async () => { + stubFetch(() => new Response(JSON.stringify({ probe: "no_history_table", versions: [] }), { status: 200 })); + + await expect(fetchRemoteVersions(URL_, KEY)).rejects.toThrow(/no_history_table/); + }); + + it("surfaces an unexpected RPC failure instead of silently falling back", async () => { + const calls = stubFetch(() => new Response("boom", { status: 500 })); + + await expect(fetchRemoteVersions(URL_, KEY)).rejects.toThrow(/status 500/); + expect(calls).toHaveLength(1); + }); +}); + +describe("migration_history_versions definition parity (migration vs schema.sql)", () => { + const extract = (text: string) => { + const start = text.indexOf("create or replace function public.migration_history_versions()"); + expect(start, "migration_history_versions definition not found").toBeGreaterThanOrEqual(0); + const end = text.indexOf("grant execute on function public.migration_history_versions() to service_role;", start); + expect(end, "migration_history_versions grants not found").toBeGreaterThan(start); + return text.slice(start, end); + }; + + it(`migration ${MIGRATION_HISTORY_VERSIONS_MIGRATION.slice(0, 14)} and schema.sql carry byte-identical definitions`, () => { + const fromMigration = extract(read(`supabase/migrations/${MIGRATION_HISTORY_VERSIONS_MIGRATION}`)); + expect(extract(read("supabase/schema.sql"))).toBe(fromMigration); + }); + + it("is read-only, service-role only, and guarded for databases without the history schema", () => { + const file = read(`supabase/migrations/${MIGRATION_HISTORY_VERSIONS_MIGRATION}`); + const sql = extract(file); + expect(sql).toContain("security definer"); + expect(sql).toContain("set search_path to ''"); + expect(sql).toContain("stable"); + expect(sql).toContain("to_regclass('supabase_migrations.schema_migrations')"); + expect(sql).toContain("'no_history_table'"); + expect(sql).not.toMatch(/\b(insert|update|delete|drop|alter)\b/i); + expect(file).toContain( + "revoke execute on function public.migration_history_versions() from public, anon, authenticated;", + ); + expect(file).toContain("grant execute on function public.migration_history_versions() to service_role;"); + }); +});