From 4fcd1b63c308a3257162361caad062bb4c09d524 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 08:51:49 +0000 Subject: [PATCH 1/3] feat(ops): add a read-only backend drift check Vercel ships the frontend on merge, but migrations and Edge Functions are deployed by hand, and nothing in the repo showed when production fell behind. On 2026-09-30 it lacked two migrations and ran pre-#75 functions. `npm run backend:status` runs `supabase migration list` and `supabase functions list` and compares them with the migrations and function folders committed at HEAD. It reports unapplied migrations, undeployed functions, functions deployed before the last commit that touched them or a file they import, and verify_jwt drift from config.toml. `--project-ref ` targets another project, and `--check` exits 1 when a migration is not applied. docs/operations.md makes it the first step of every backend deploy. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- docs/operations.md | 34 ++- package.json | 1 + scripts/backend-status.mjs | 296 ++++++++++++++++++++++++ src/test/scripts/backend-status.test.ts | 146 ++++++++++++ supabase/README.md | 2 +- 6 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 scripts/backend-status.mjs create mode 100644 src/test/scripts/backend-status.test.ts diff --git a/AGENTS.md b/AGENTS.md index 37fa18ff8..f01992aea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ The local Supabase stack still uses shared default ports and container names. In ## Deployment -The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed by that merge; they are separate authorized operations. +The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed by that merge; they are separate authorized operations. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. ## Normal ports diff --git a/docs/operations.md b/docs/operations.md index 3740be747..64eda617a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -63,17 +63,35 @@ The staging browser suite disables trace, screenshot, and video capture. It crea Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass. +## Backend drift check + +`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the linked project; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role unless `SUPABASE_DB_PASSWORD` is set. + +The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports: + +- migrations on this commit that the project has not applied, and applied migrations the commit does not have; +- functions that are not deployed, or that were deployed before the last commit that touched them or a file they import; +- functions whose deployed `verify_jwt` differs from [`supabase/config.toml`](../supabase/config.toml); +- deployed functions that the commit does not have. + +Function change dates come from first-parent git history, which dates a merged change by its merge. A function deployed from a branch before its merge therefore shows as changed until it is redeployed from `main`. A shallow clone skips the function date check. + +`--check` exits 1 when a migration on this commit is not applied, and exit code 2 means the check itself failed. A scheduled CI step could use it against production, but it would need a Supabase access token and database access in GitHub Actions. That is a production credential, so it waits for Adam's decision. + +`workspace-invites` is not deployed to production, because Collaborative Workspace is hidden there. Expect it in the not-deployed list. + ## Supabase changes Before applying a migration or deploying a function: -1. resolve the linked project and confirm whether it is local, staging, or production; -2. inspect the exact migration or function diff; -3. capture the current remote baseline needed for rollback; -4. run the relevant local tests; -5. confirm CORS and authentication behavior; -6. apply one scoped operation; -7. verify the live result before continuing. +1. run `npm run backend:status` on the commit you are deploying and confirm that the pending list matches what you are about to apply; +2. resolve the linked project and confirm whether it is local, staging, or production; +3. inspect the exact migration or function diff; +4. capture the current remote baseline needed for rollback; +5. run the relevant local tests; +6. confirm CORS and authentication behavior; +7. apply one scoped operation; +8. verify the live result before continuing, then rerun `npm run backend:status` to confirm nothing you meant to ship is still pending. Stop when the project target is ambiguous, rollback is unproven, or the operation exceeds authorization. @@ -146,7 +164,7 @@ Do not expose protected data in artifacts. Record the commit, environment, opera - Every Actions job fails in 2 to 5 seconds with no logs: Actions minutes or billing, not the code. Verify locally. - Green local E2E plus staging failure: schema, functions, provider access, demo code, or CORS may differ. -- New frontend errors against Supabase right after a merge: the matching migration or function may not be applied to production yet. +- New frontend errors against Supabase right after a merge: the matching migration or function may not be applied to production yet. Run `npm run backend:status` from the linked checkout. - Function returns unauthorized after deploy: check handler auth and environment propagation before changing gateway settings. - Browser works on one route only: check the `vercel.json` SPA rewrite and cache headers. - Native callback fails while web works: check Capacitor scheme and Supabase redirect allowlist. diff --git a/package.json b/package.json index bb6e16075..4fb982123 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite", "docs:check": "node scripts/check-docs.mjs", + "backend:status": "node scripts/backend-status.mjs", "build": "vite build && node scripts/check-build-boundaries.mjs", "build:dev": "vite build --mode development", "ios:build": "vite build --mode capacitor && cap sync ios", diff --git a/scripts/backend-status.mjs b/scripts/backend-status.mjs new file mode 100644 index 000000000..9d8fedb4f --- /dev/null +++ b/scripts/backend-status.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +// Read-only drift check between the current commit and a Supabase project. +// +// Vercel ships the frontend on merge; migrations and Edge Functions are +// deployed by hand. This script lists what the current commit has that the +// project does not. It only runs `supabase migration list` and +// `supabase functions list`, and it reads the repository from git HEAD, so +// uncommitted files are ignored. +// +// Usage: npm run backend:status [-- --project-ref ] [--check] +// --project-ref inspect this project instead of the linked one +// --check exit 1 when a migration on this commit is not applied + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join, posix } from "node:path"; +import { pathToFileURL } from "node:url"; + +const KNOWN_PROJECTS = { + uziwdtkzofptihahfooo: "production", + brifeyypjauyusmkptkx: "staging", +}; + +const MIGRATION_FILE = /^(\d+)_.+\.sql$/; +const STATIC_IMPORT = /\b(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/g; +const BARE_IMPORT = /\bimport\s*["']([^"']+)["']/g; +const DYNAMIC_IMPORT = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + +class UsageError extends Error {} + +export function parseArgs(argv) { + const options = { check: false, projectRef: null }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--check") { + options.check = true; + } else if (arg === "--project-ref") { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) throw new UsageError("--project-ref needs a value"); + options.projectRef = value; + index += 1; + } else if (arg.startsWith("--project-ref=")) { + options.projectRef = arg.slice("--project-ref=".length); + if (!options.projectRef) throw new UsageError("--project-ref needs a value"); + } else { + throw new UsageError(`Unknown argument: ${arg}`); + } + } + if (options.projectRef && !/^[a-z0-9]{20}$/.test(options.projectRef)) { + throw new UsageError(`Not a Supabase project ref: ${options.projectRef}`); + } + return options; +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw new Error(`${command} ${args[0]} failed: ${result.error.message}`); + return result; +} + +function git(root, args) { + const result = run("git", args, root); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + return result.stdout; +} + +function redact(text) { + const message = text + .split("\n") + .map((line) => { + try { + return JSON.parse(line)?.error?.message; + } catch { + return undefined; + } + }) + .find((value) => typeof value === "string"); + return (message ?? text) + .split("\n") + .filter((line) => !/new version of Supabase CLI|recommend updating regularly/i.test(line)) + .join("\n") + .replace(/(postgres(?:ql)?:\/\/)[^@\s]+@/gi, "$1***@") + .replace(/(password=)\S+/gi, "$1***") + .trim(); +} + +function supabaseJson(root, args) { + const result = run("supabase", [...args, "--output-format", "json"], root); + if (result.status !== 0) { + const detail = redact(result.stderr || result.stdout); + throw new Error(`supabase ${args.slice(0, 2).join(" ")} failed${detail ? `:\n${detail}` : ""}`); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`supabase ${args.slice(0, 2).join(" ")} did not return JSON`); + } +} + +/** Reads the migrations, functions, and function config committed at HEAD. */ +export function readCommit(root) { + const commit = git(root, ["rev-parse", "--short", "HEAD"]).trim(); + const files = git(root, ["ls-tree", "-r", "--name-only", "HEAD", "--", "supabase"]).split("\n").filter(Boolean); + const fileSet = new Set(files); + const show = (path) => git(root, ["show", `HEAD:${path}`]); + + const migrations = files + .filter((path) => posix.dirname(path) === "supabase/migrations") + .map((path) => MIGRATION_FILE.exec(posix.basename(path))?.[1]) + .filter(Boolean) + .sort(); + + const functionNames = [...new Set( + files + .map((path) => /^supabase\/functions\/([^/_][^/]*)\/index\.ts$/.exec(path)?.[1]) + .filter(Boolean), + )].sort(); + + const functions = functionNames.map((name) => { + const dependencies = new Set(); + const queue = [`supabase/functions/${name}/index.ts`]; + while (queue.length > 0) { + const path = queue.pop(); + if (dependencies.has(path) || !fileSet.has(path)) continue; + dependencies.add(path); + const source = show(path); + for (const pattern of [STATIC_IMPORT, BARE_IMPORT, DYNAMIC_IMPORT]) { + for (const match of source.matchAll(pattern)) { + const specifier = match[1]; + if (specifier.startsWith("./") || specifier.startsWith("../")) { + queue.push(posix.normalize(posix.join(posix.dirname(path), specifier))); + } + } + } + } + for (const config of ["supabase/functions/deno.json", `supabase/functions/${name}/deno.json`]) { + if (fileSet.has(config)) dependencies.add(config); + } + return { name, dependencies: [...dependencies].sort() }; + }); + + const verifyJwt = {}; + if (fileSet.has("supabase/config.toml")) { + let section = null; + for (const line of show("supabase/config.toml").split("\n")) { + const header = /^\s*\[functions\.([^\]]+)\]\s*$/.exec(line); + if (header) { + section = header[1].trim().replace(/^"(.*)"$/, "$1"); + continue; + } + if (/^\s*\[/.test(line)) { + section = null; + continue; + } + const setting = /^\s*verify_jwt\s*=\s*(true|false)\b/.exec(line); + if (section && setting) verifyJwt[section] = setting[1] === "true"; + } + } + + return { commit, migrations, functions, verifyJwt }; +} + +/** + * Finds the last commit on HEAD's first-parent line that touched a function's + * files. First-parent history dates a merged change by its merge, which is + * when it reached the branch, not when it was written. + */ +export function lastChange(root, paths) { + const output = git(root, ["log", "-1", "--first-parent", "--format=%H%x00%ct%x00%s", "HEAD", "--", ...paths]).trim(); + if (!output) return null; + const [sha, seconds, subject] = output.split("\0"); + return { sha: sha.slice(0, 7), time: Number(seconds) * 1000, subject }; +} + +export function compareBackend({ local, remoteMigrations, deployedFunctions, changeOf }) { + const applied = new Set(remoteMigrations); + const known = new Set(local.migrations); + const deployed = new Map(deployedFunctions.map((fn) => [fn.slug, fn])); + const inRepo = new Set(local.functions.map((fn) => fn.name)); + + const stale = []; + const verifyJwt = []; + const notDeployed = []; + for (const fn of local.functions) { + const remote = deployed.get(fn.name); + if (!remote) { + notDeployed.push(fn.name); + continue; + } + const change = changeOf(fn); + if (change && change.time > remote.updated_at) stale.push({ name: fn.name, deployed: remote, change }); + const expected = local.verifyJwt[fn.name] ?? true; + if (typeof remote.verify_jwt === "boolean" && remote.verify_jwt !== expected) { + verifyJwt.push({ name: fn.name, deployed: remote.verify_jwt, expected }); + } + } + + return { + pendingMigrations: local.migrations.filter((version) => !applied.has(version)), + unknownMigrations: [...applied].filter((version) => !known.has(version)).sort(), + appliedCount: local.migrations.filter((version) => applied.has(version)).length, + notDeployed, + stale, + verifyJwt, + notInRepo: [...deployed.keys()].filter((slug) => !inRepo.has(slug)).sort(), + }; +} + +function formatTime(milliseconds) { + return `${new Date(milliseconds).toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export function formatReport({ target, local, result, deployedCount, notes }) { + const lines = [`Backend status: ${target} compared with commit ${local.commit}`, ""]; + const list = (title, items) => { + if (items.length === 0) return; + lines.push(` ${title}:`); + for (const item of items) lines.push(` ${item}`); + }; + + lines.push(`Migrations: ${local.migrations.length} on this commit, ${result.appliedCount} of them applied.`); + list("Pending (on this commit, not applied)", result.pendingMigrations); + list("Applied but not on this commit", result.unknownMigrations); + + lines.push("", `Edge Functions: ${local.functions.length} on this commit, ${deployedCount} deployed.`); + list("Not deployed", result.notDeployed); + list( + "Changed on this commit after the last deploy", + result.stale.map(({ name, deployed, change }) => + `${name}: deployed ${formatTime(deployed.updated_at)} (v${deployed.version}); changed ${formatTime(change.time)} in ${change.sha} ${change.subject}`), + ); + list( + "verify_jwt differs from supabase/config.toml", + result.verifyJwt.map(({ name, deployed, expected }) => `${name}: deployed ${deployed}, config ${expected}`), + ); + list("Deployed but not on this commit", result.notInRepo); + + const pending = result.pendingMigrations.length + result.notDeployed.length + result.stale.length + result.verifyJwt.length; + lines.push("", pending === 0 ? "Nothing pending." : `${pending} pending item${pending === 1 ? "" : "s"}.`); + for (const note of notes) lines.push(`Note: ${note}`); + return lines.join("\n"); +} + +export function backendStatus({ root, projectRef }) { + const local = readCommit(root); + const targetArgs = projectRef ? ["--project-ref", projectRef] : []; + const migrationTarget = projectRef ? targetArgs : ["--linked"]; + + const migrationOutput = supabaseJson(root, ["migration", "list", ...migrationTarget]); + const functionOutput = supabaseJson(root, ["functions", "list", ...targetArgs]); + const remoteMigrations = (migrationOutput.migrations ?? []).map((row) => row.remote).filter(Boolean); + const deployedFunctions = Array.isArray(functionOutput) ? functionOutput : (functionOutput.functions ?? []); + + const notes = []; + const shallow = git(root, ["rev-parse", "--is-shallow-repository"]).trim() === "true"; + if (shallow) notes.push("shallow clone, so function change dates were not checked."); + const changeOf = shallow ? () => null : (fn) => lastChange(root, fn.dependencies); + + const dirty = git(root, ["status", "--porcelain", "--", "supabase/migrations", "supabase/functions", "supabase/config.toml"]).trim(); + if (dirty) notes.push("uncommitted changes under supabase/ are not part of this comparison."); + notes.push("function change dates come from git; a function deployed from a branch before its merge shows as changed until it is redeployed from this line."); + + const linkedRefFile = join(root, "supabase/.temp/project-ref"); + const ref = projectRef ?? (existsSync(linkedRefFile) ? readFileSync(linkedRefFile, "utf8").trim() : "linked project"); + const label = KNOWN_PROJECTS[ref]; + const target = label ? `${ref} (${label})` : ref; + + const result = compareBackend({ local, remoteMigrations, deployedFunctions, changeOf }); + return { result, report: formatReport({ target, local, result, deployedCount: deployedFunctions.length, notes }) }; +} + +function main() { + let options; + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + console.error("Usage: npm run backend:status [-- --project-ref ] [--check]"); + process.exit(2); + } + + try { + const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).trim(); + const { result, report } = backendStatus({ root, projectRef: options.projectRef }); + console.log(report); + if (options.check && result.pendingMigrations.length > 0) process.exit(1); + } catch (error) { + console.error(error.message); + if (!options.projectRef && /project ref|supabase link/i.test(error.message)) { + console.error("This checkout is not linked. Pass --project-ref or run from the linked checkout."); + } + process.exit(2); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/src/test/scripts/backend-status.test.ts b/src/test/scripts/backend-status.test.ts new file mode 100644 index 000000000..ad933432e --- /dev/null +++ b/src/test/scripts/backend-status.test.ts @@ -0,0 +1,146 @@ +import { spawnSync } from "node:child_process"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +const SCRIPT = resolve("scripts/backend-status.mjs"); +const STAGING_REF = "brifeyypjauyusmkptkx"; +const temporaryRoots: string[] = []; +let root: string; + +async function write(path: string, content: string): Promise { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); +} + +function commit(message: string, seconds: number): void { + const env = { ...process.env, GIT_AUTHOR_DATE: `@${seconds} +0000`, GIT_COMMITTER_DATE: `@${seconds} +0000` }; + for (const args of [["add", "-A"], ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-q", "-m", message]]) { + const result = spawnSync("git", args, { cwd: root, env, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr); + } +} + +function runStatus(args: string[]) { + return spawnSync("node", [SCRIPT, ...args], { + cwd: root, + encoding: "utf8", + env: { ...process.env, CALL_LOG: join(root, "calls.log"), PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}` }, + }); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "debatelab-backend-status-")); + temporaryRoots.push(root); + spawnSync("git", ["init", "-q"], { cwd: root }); + + await write("supabase/migrations/20260101000000_first.sql", "select 1;\n"); + await write("supabase/migrations/20260102000000_second.sql", "select 2;\n"); + await write("supabase/functions/_shared/util.ts", "export const value = 1;\n"); + await write("supabase/functions/_shared/unused.ts", "export const unused = 1;\n"); + await write("supabase/functions/alpha/index.ts", 'import { value } from "../_shared/util.ts";\nconsole.log(value);\n'); + await write("supabase/functions/alpha/index.test.ts", 'import "../_shared/unused.ts";\n'); + await write("supabase/functions/beta/index.ts", "console.log('beta');\n"); + await write("supabase/functions/gamma/index.ts", "console.log('gamma');\n"); + await write("supabase/config.toml", "[functions.alpha]\nverify_jwt = false\n\n[functions.beta]\nverify_jwt = true\n"); + commit("initial", 1_000); + await write("supabase/functions/_shared/util.ts", "export const value = 2;\n"); + commit("change shared helper", 2_000); + await write("supabase/functions/gamma/index.ts", "console.log('gamma 2');\n"); + await write("supabase/functions/_shared/unused.ts", "export const unused = 2;\n"); + commit("change gamma", 3_000); + + const migrations = { + migrations: [ + { local: "20260101000000", remote: "20260101000000", time: "" }, + { local: "20260102000000", remote: "", time: "" }, + { local: "", remote: "20251231000000", time: "" }, + ], + }; + const functions = { + functions: [ + { slug: "alpha", version: 4, updated_at: 1_500_000, verify_jwt: false }, + { slug: "beta", version: 2, updated_at: 1_500_000, verify_jwt: false }, + { slug: "retired", version: 1, updated_at: 500_000, verify_jwt: true }, + ], + }; + await mkdir(join(root, "bin")); + await writeFile( + join(root, "bin/supabase"), + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$CALL_LOG"', + 'echo "A new version of Supabase CLI is available" >&2', + `if [ "$1 $2" = "migration list" ]; then echo '${JSON.stringify(migrations)}'; exit 0; fi`, + `if [ "$1 $2" = "functions list" ]; then echo '${JSON.stringify(functions)}'; exit 0; fi`, + "exit 99", + "", + ].join("\n"), + ); + await chmod(join(root, "bin/supabase"), 0o755); +}); + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +it("reports what the current commit has that the project lacks", async () => { + const result = runStatus([]); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + const out = result.stdout; + expect(out).toContain("Migrations: 2 on this commit, 1 of them applied."); + expect(out).toMatch(/Pending \(on this commit, not applied\):\n {4}20260102000000\n/); + expect(out).toMatch(/Applied but not on this commit:\n {4}20251231000000\n/); + expect(out).toMatch(/Not deployed:\n {4}gamma\n/); + expect(out).toMatch(/Changed on this commit after the last deploy:\n {4}alpha: deployed 1970-01-01 00:25 UTC \(v4\); changed 1970-01-01 00:33 UTC in [0-9a-f]{7} change shared helper\n {2}verify/); + expect(out).toMatch(/verify_jwt differs from supabase\/config.toml:\n {4}beta: deployed false, config true\n/); + expect(out).toMatch(/Deployed but not on this commit:\n {4}retired\n/); + expect(out).toContain("4 pending items."); +}); + +it("uses only read-only list commands and targets the linked project by default", async () => { + runStatus([]); + + const calls = (await readFile(join(root, "calls.log"), "utf8")).trim().split("\n"); + expect(calls).toEqual(["migration list --linked --output-format json", "functions list --output-format json"]); +}); + +it("passes --project-ref to both commands and names known projects", async () => { + const result = runStatus(["--project-ref", STAGING_REF]); + + expect(result.stdout).toContain(`Backend status: ${STAGING_REF} (staging) compared with commit`); + const calls = (await readFile(join(root, "calls.log"), "utf8")).trim().split("\n"); + expect(calls).toEqual([ + `migration list --project-ref ${STAGING_REF} --output-format json`, + `functions list --project-ref ${STAGING_REF} --output-format json`, + ]); +}); + +it("fails --check only when a migration on this commit is not applied", async () => { + expect(runStatus(["--check"]).status).toBe(1); + + await rm(join(root, "supabase/migrations/20260102000000_second.sql")); + commit("drop pending migration", 4_000); + const result = runStatus(["--check"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Migrations: 1 on this commit, 1 of them applied."); +}); + +it("rejects bad arguments and surfaces CLI failures without a report", async () => { + expect(runStatus(["--project-ref"]).status).toBe(2); + expect(runStatus(["--project-ref", "not a ref"]).status).toBe(2); + expect(runStatus(["--write"]).status).toBe(2); + + await writeFile( + join(root, "bin/supabase"), + '#!/usr/bin/env bash\necho \'{"_tag":"Error","error":{"message":"failed to connect to postgresql://cli_login:hunter2@db.example:5432/postgres"}}\' >&2\nexit 1\n', + ); + const result = runStatus([]); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("supabase migration list failed:\nfailed to connect to postgresql://***@db.example"); + expect(result.stderr).not.toContain("hunter2"); +}); diff --git a/supabase/README.md b/supabase/README.md index 5e2fafc19..2133e3882 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -183,7 +183,7 @@ Read [`../docs/operations.md`](../docs/operations.md) before changing remote sta For an authorized backend change: 1. Confirm the current worktree and commit. -2. Resolve the linked Supabase project and environment. +2. Resolve the linked Supabase project and environment, and run `npm run backend:status` to see what is pending there. See [`../docs/operations.md`](../docs/operations.md#backend-drift-check). 3. Inspect the exact migration or function diff. 4. Capture the remote baseline needed for rollback. 5. Run focused tests, `npm run test:edge`, and database tests when schema or RLS changes. From d6c7dca2d4d4f658cf32df67962581e62d047444 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:10:24 +0000 Subject: [PATCH 2/3] docs(ops): say what the function date check cannot see A function deployed from an out-of-date checkout gets a fresh deploy date, so backend:status reports it as current. Say so in the report and the docs, and deploy functions only from an up-to-date main. Drop the untested claim about SUPABASE_DB_PASSWORD, and note that a CI step would need a production credential that AGENTS.md keeps out of jobs. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/operations.md | 6 +++--- scripts/backend-status.mjs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 64eda617a..581cf77d2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -65,7 +65,7 @@ Do not enable the staging repository variable before every prerequisite is prese ## Backend drift check -`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the linked project; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role unless `SUPABASE_DB_PASSWORD` is set. +`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the linked project; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role. The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports: @@ -74,9 +74,9 @@ The comparison uses the committed tree at `HEAD`, so commit or stash local edits - functions whose deployed `verify_jwt` differs from [`supabase/config.toml`](../supabase/config.toml); - deployed functions that the commit does not have. -Function change dates come from first-parent git history, which dates a merged change by its merge. A function deployed from a branch before its merge therefore shows as changed until it is redeployed from `main`. A shallow clone skips the function date check. +Function change dates come from first-parent git history, which dates a merged change by its merge. A function deployed from a branch before its merge therefore shows as changed until it is redeployed from `main`. The reverse is not caught: a function deployed from an out-of-date checkout gets a fresh deploy date and looks current. Deploy functions only from an up-to-date `main`. A shallow clone skips the function date check. -`--check` exits 1 when a migration on this commit is not applied, and exit code 2 means the check itself failed. A scheduled CI step could use it against production, but it would need a Supabase access token and database access in GitHub Actions. That is a production credential, so it waits for Adam's decision. +`--check` exits 1 when a migration on this commit is not applied, and exit code 2 means the check itself failed. A scheduled CI step could use it against production, but it would need a Supabase access token and database access in GitHub Actions. That is a production credential, which the hard boundaries in [`AGENTS.md`](../AGENTS.md) do not allow in jobs, so it waits for Adam's decision. `workspace-invites` is not deployed to production, because Collaborative Workspace is hidden there. Expect it in the not-deployed list. diff --git a/scripts/backend-status.mjs b/scripts/backend-status.mjs index 9d8fedb4f..2d5db3732 100644 --- a/scripts/backend-status.mjs +++ b/scripts/backend-status.mjs @@ -258,7 +258,7 @@ export function backendStatus({ root, projectRef }) { const dirty = git(root, ["status", "--porcelain", "--", "supabase/migrations", "supabase/functions", "supabase/config.toml"]).trim(); if (dirty) notes.push("uncommitted changes under supabase/ are not part of this comparison."); - notes.push("function change dates come from git; a function deployed from a branch before its merge shows as changed until it is redeployed from this line."); + notes.push("function change dates come from git. A function deployed from a branch before its merge shows as changed; one deployed from an out-of-date checkout looks current."); const linkedRefFile = join(root, "supabase/.temp/project-ref"); const ref = projectRef ?? (existsSync(linkedRefFile) ? readFileSync(linkedRefFile, "utf8").trim() : "linked project"); From eb5ae65967f5e67b62b084dbaf100c55b669b760 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 16:14:39 +0000 Subject: [PATCH 3/3] fix(ops): flag unreadable deploy dates and name the CLI's target A function whose deploy date is missing or unreadable was compared as current, so a stale function could pass unnoticed. It is now listed as changed with "deploy date unknown". The report label read the linked ref file, but the Supabase CLI prefers SUPABASE_PROJECT_ID over it, so the label could name a different project than the one inspected. The label now follows the CLI's order. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/operations.md | 2 +- scripts/backend-status.mjs | 20 ++++++++++-- src/test/scripts/backend-status.test.ts | 43 +++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 581cf77d2..9e5de96bd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -65,7 +65,7 @@ Do not enable the staging repository variable before every prerequisite is prese ## Backend drift check -`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the linked project; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role. +`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role. The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports: diff --git a/scripts/backend-status.mjs b/scripts/backend-status.mjs index 2d5db3732..aceca3c5a 100644 --- a/scripts/backend-status.mjs +++ b/scripts/backend-status.mjs @@ -172,6 +172,11 @@ export function lastChange(root, paths) { return { sha: sha.slice(0, 7), time: Number(seconds) * 1000, subject }; } +/** Deploy time in milliseconds, or NaN when the CLI gave none or an unreadable one. */ +function deployTime(updatedAt) { + return typeof updatedAt === "number" ? updatedAt : Date.parse(updatedAt); +} + export function compareBackend({ local, remoteMigrations, deployedFunctions, changeOf }) { const applied = new Set(remoteMigrations); const known = new Set(local.migrations); @@ -188,7 +193,8 @@ export function compareBackend({ local, remoteMigrations, deployedFunctions, cha continue; } const change = changeOf(fn); - if (change && change.time > remote.updated_at) stale.push({ name: fn.name, deployed: remote, change }); + // A deploy date we cannot read must not make a changed function look current. + if (change && !(change.time <= deployTime(remote.updated_at))) stale.push({ name: fn.name, deployed: remote, change }); const expected = local.verifyJwt[fn.name] ?? true; if (typeof remote.verify_jwt === "boolean" && remote.verify_jwt !== expected) { verifyJwt.push({ name: fn.name, deployed: remote.verify_jwt, expected }); @@ -210,6 +216,11 @@ function formatTime(milliseconds) { return `${new Date(milliseconds).toISOString().slice(0, 16).replace("T", " ")} UTC`; } +function deployedDate(updatedAt) { + const time = deployTime(updatedAt); + return Number.isFinite(time) ? `deployed ${formatTime(time)}` : "deploy date unknown"; +} + export function formatReport({ target, local, result, deployedCount, notes }) { const lines = [`Backend status: ${target} compared with commit ${local.commit}`, ""]; const list = (title, items) => { @@ -227,7 +238,7 @@ export function formatReport({ target, local, result, deployedCount, notes }) { list( "Changed on this commit after the last deploy", result.stale.map(({ name, deployed, change }) => - `${name}: deployed ${formatTime(deployed.updated_at)} (v${deployed.version}); changed ${formatTime(change.time)} in ${change.sha} ${change.subject}`), + `${name}: ${deployedDate(deployed.updated_at)} (v${deployed.version}); changed ${formatTime(change.time)} in ${change.sha} ${change.subject}`), ); list( "verify_jwt differs from supabase/config.toml", @@ -260,8 +271,11 @@ export function backendStatus({ root, projectRef }) { if (dirty) notes.push("uncommitted changes under supabase/ are not part of this comparison."); notes.push("function change dates come from git. A function deployed from a branch before its merge shows as changed; one deployed from an out-of-date checkout looks current."); + // Same order the Supabase CLI uses: the flag, then SUPABASE_PROJECT_ID, then the linked ref. const linkedRefFile = join(root, "supabase/.temp/project-ref"); - const ref = projectRef ?? (existsSync(linkedRefFile) ? readFileSync(linkedRefFile, "utf8").trim() : "linked project"); + const ref = projectRef + ?? (process.env.SUPABASE_PROJECT_ID?.trim() || null) + ?? (existsSync(linkedRefFile) ? readFileSync(linkedRefFile, "utf8").trim() : "linked project"); const label = KNOWN_PROJECTS[ref]; const target = label ? `${ref} (${label})` : ref; diff --git a/src/test/scripts/backend-status.test.ts b/src/test/scripts/backend-status.test.ts index ad933432e..eca58bf95 100644 --- a/src/test/scripts/backend-status.test.ts +++ b/src/test/scripts/backend-status.test.ts @@ -22,14 +22,30 @@ function commit(message: string, seconds: number): void { } } -function runStatus(args: string[]) { +function runStatus(args: string[], extraEnv: Record = {}) { + const { SUPABASE_PROJECT_ID: _ignored, ...env } = process.env; return spawnSync("node", [SCRIPT, ...args], { cwd: root, encoding: "utf8", - env: { ...process.env, CALL_LOG: join(root, "calls.log"), PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}` }, + env: { ...env, CALL_LOG: join(root, "calls.log"), PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, ...extraEnv }, }); } +async function fakeFunctionsList(functions: unknown[]): Promise { + const migrations = { migrations: [{ local: "20260101000000", remote: "20260101000000", time: "" }] }; + await writeFile( + join(root, "bin/supabase"), + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$CALL_LOG"', + `if [ "$1 $2" = "migration list" ]; then echo '${JSON.stringify(migrations)}'; exit 0; fi`, + `if [ "$1 $2" = "functions list" ]; then echo '${JSON.stringify({ functions })}'; exit 0; fi`, + "exit 99", + "", + ].join("\n"), + ); +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "debatelab-backend-status-")); temporaryRoots.push(root); @@ -119,6 +135,29 @@ it("passes --project-ref to both commands and names known projects", async () => ]); }); +it("names the project chosen by SUPABASE_PROJECT_ID, which the CLI prefers over the linked ref", async () => { + await write("supabase/.temp/project-ref", "uziwdtkzofptihahfooo\n"); + + const result = runStatus([], { SUPABASE_PROJECT_ID: STAGING_REF }); + + expect(result.stdout).toContain(`Backend status: ${STAGING_REF} (staging) compared with commit`); +}); + +it("counts a function whose deploy date is missing or unreadable as changed", async () => { + await fakeFunctionsList([ + { slug: "alpha", version: 4, verify_jwt: false }, + { slug: "beta", version: 2, updated_at: "not a date", verify_jwt: true }, + { slug: "gamma", version: 3, updated_at: 9_000_000, verify_jwt: true }, + ]); + + const result = runStatus([]); + + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/Changed on this commit after the last deploy:\n {4}alpha: deploy date unknown \(v4\); changed 1970-01-01 00:33 UTC in [0-9a-f]{7} change shared helper\n {4}beta: deploy date unknown \(v2\); changed 1970-01-01 00:16 UTC in [0-9a-f]{7} initial\n/); + expect(result.stdout).not.toMatch(/ {4}gamma:/); + expect(result.stdout).toContain("3 pending items."); +}); + it("fails --check only when a migration on this commit is not applied", async () => { expect(runStatus(["--check"]).status).toBe(1);