Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 26 additions & 8 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
310 changes: 310 additions & 0 deletions scripts/backend-status.mjs
Original file line number Diff line number Diff line change
@@ -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 <ref>] [--check]
// --project-ref <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 <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 <ref> or run from the linked checkout.");
}
process.exit(2);
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
Loading
Loading