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..9e5de96bd 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 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: + +- 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`. 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, 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. + ## 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..aceca3c5a --- /dev/null +++ b/scripts/backend-status.mjs @@ -0,0 +1,310 @@ +#!/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 }; +} + +/** 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); + 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); + // 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 }); + } + } + + 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`; +} + +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) => { + 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}: ${deployedDate(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; 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 + ?? (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; + + 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..eca58bf95 --- /dev/null +++ b/src/test/scripts/backend-status.test.ts @@ -0,0 +1,185 @@ +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[], extraEnv: Record = {}) { + const { SUPABASE_PROJECT_ID: _ignored, ...env } = process.env; + return spawnSync("node", [SCRIPT, ...args], { + cwd: root, + encoding: "utf8", + 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); + 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("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); + + 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.