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;"); + }); +});