Plan #5653 — Shared-Supabase migration tracking fix (all 3 phases)
Issue: radieu/p24-infra#5653 · Return-To: radieu/et-operational-platform#1500 · Design/decisions: #5645 (7 decisions confirmed by radieu on #1500).
Canonical standard this plan implements: docs/playbooks/supabase-per-repo-migration-ledger.md.
Problem (recap)
Supabase project mwkqmgadqnkkihjdeqsi is intentionally shared across repos. Supabase’s built-in
schema_migrations ledger is one-per-project, so every repo’s supabase db push perpetually fails
(“Remote migration versions not found in local migrations directory”), and apply_migration stamps
the shared ledger with apply-time versions that don’t match file timestamps. Fix: per-repo ledger +
custom idempotent runner, replacing supabase db push; ban apply_migration project-wide.
Confirmed decisions (from #5645 → #1500)
| # | Decision |
|---|---|
| D1 | Reuse p24-infra’s proven _p24_applied_migrations + custom-runner pattern, generalised per repo. |
| D2 | et-op runner transport = Management API /database/query (et-op CI runs on GitHub-hosted ubuntu-latest, cannot reach bms-4/psql). |
| D3 | Duplicate a small runner per repo. No shared package/submodule. |
| D4 | Verify all migrations against the live schema catalog before seeding the ledger (thorough option; precedent = incident #4450). |
| D5 | Concurrency = GH Actions concurrency: group plus a Postgres advisory lock in the runner (both layers). |
| D6 | Ban mcp__claude_ai_Supabase__apply_migration project-wide; fix p24-infra worker guidance. Leave the ~510-row schema_migrations pollution untouched. |
| D7 | All three repos in this push (Phase 1 et-op, Phase 2 p24-infra, Phase 3 whatsup). |
Phase 1 — et-operational-platform (routed to an et-op session)
289 migrations in supabase/migrations/ (confirmed via GitHub contents API), default branch main,
existing .github/workflows/supabase-migrations.yml uses supabase db push --linked.
1a. Ledger table
CREATE TABLE IF NOT EXISTS public._et_op_applied_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE public._et_op_applied_migrations IS
'Per-repo migration ledger for radieu/et-operational-platform. Managed by scripts/apply-migrations.mjs. See p24-infra docs/playbooks/supabase-per-repo-migration-ledger.md';
-- Lock it down (MANDATORY). This table is in the PostgREST-exposed `public` schema; without RLS +
-- these REVOKEs it comes up with Supabase's default `authenticated=arwdDxtm` grants, so any
-- authenticated session (incl. a driver/technician PIN login) could DELETE ledger rows (→ the
-- runner replays those migrations, including destructive DML) or INSERT a future filename (→
-- permanently suppress that migration). RLS enabled + zero policies denies every non-bypassing
-- role; the runner uses `postgres`/`service_role`, which bypass RLS.
ALTER TABLE public._et_op_applied_migrations ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public._et_op_applied_migrations FROM PUBLIC;
REVOKE ALL ON TABLE public._et_op_applied_migrations FROM anon, authenticated;Shipped as 20260806100000_secure_et_op_migration_ledger.sql (PR #1541, review finding C1).
1b. Runner — scripts/apply-migrations.mjs (Node 20, zero deps, Management API)
Shape (the et-op session finalises; this is the reference design):
#!/usr/bin/env node
// Idempotent migration runner for et-operational-platform against the SHARED Supabase project.
// Transport: Supabase Management API /database/query (no psql; runs on ubuntu-latest).
// Tracks applied files in public._et_op_applied_migrations. Fail-fast. See p24-infra playbook
// docs/playbooks/supabase-per-repo-migration-ledger.md.
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const REF = process.env.SUPABASE_PROJECT_ID; // mwkqmgadqnkkihjdeqsi
const TOKEN = process.env.SUPABASE_ACCESS_TOKEN; // sbp_… Management API PAT
const DIR = process.env.MIGRATIONS_DIR || 'supabase/migrations';
const DRY = process.env.DRY_RUN === 'true';
const LOCK_KEY = 5653001; // constant repo-scoped advisory-lock key
if (!REF || !TOKEN) { console.error('SUPABASE_PROJECT_ID / SUPABASE_ACCESS_TOKEN required'); process.exit(1); }
const URL = `https://api.supabase.com/v1/projects/${REF}/database/query`;
async function q(sql) {
const r = await fetch(URL, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: sql }),
});
const text = await r.text();
if (!r.ok) throw new Error(`query failed (${r.status}): ${text}`);
return text ? JSON.parse(text) : [];
}
const sqlLit = s => `'${s.replace(/'/g, "''")}'`;
// Bootstrap the ledger AND lock it down in one shot, so a fresh repo copying this runner can never
// come up with an unprotected ledger table. All three statements are idempotent / safe to re-run.
await q(`CREATE TABLE IF NOT EXISTS public._et_op_applied_migrations (
filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
ALTER TABLE public._et_op_applied_migrations ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public._et_op_applied_migrations FROM PUBLIC;
REVOKE ALL ON TABLE public._et_op_applied_migrations FROM anon, authenticated;`);
const applied = new Set(
(await q(`SELECT filename FROM public._et_op_applied_migrations;`)).map(r => r.filename));
const files = readdirSync(DIR).filter(f => f.endsWith('.sql')).sort();
const pending = files.filter(f => !applied.has(f));
console.log(`total=${files.length} applied=${applied.size} pending=${pending.length}`);
if (DRY) { pending.forEach(f => console.log('PENDING', f)); process.exit(0); }
let done = 0;
for (const f of pending) {
const body = readFileSync(join(DIR, f), 'utf8');
const noTxn = /^\s*--\s*migrate:no-transaction(\s|$)/im.test(body);
console.log(`APPLY ${f}${noTxn ? ' (no-transaction)' : ''} ...`);
try {
if (noTxn) {
// Cannot take an xact lock; rely on workflow concurrency group + self-idempotent DDL.
await q(body);
await q(`INSERT INTO public._et_op_applied_migrations(filename) VALUES (${sqlLit(f)}) ON CONFLICT DO NOTHING;`);
} else {
// D5 layer 2: transaction-level advisory lock + re-check guard, all in one query/transaction.
await q(
`BEGIN;\n` +
`SELECT pg_advisory_xact_lock(${LOCK_KEY});\n` +
`DO $guard$ BEGIN\n` +
` IF EXISTS (SELECT 1 FROM public._et_op_applied_migrations WHERE filename = ${sqlLit(f)}) THEN\n` +
` RAISE EXCEPTION 'MIGRATION_ALREADY_APPLIED';\n` +
` END IF;\nEND $guard$;\n` +
`${body}\n;\n` +
`INSERT INTO public._et_op_applied_migrations(filename) VALUES (${sqlLit(f)});\n` +
`COMMIT;`
);
}
console.log(`OK ${f}`); done++;
} catch (e) {
if (String(e).includes('MIGRATION_ALREADY_APPLIED')) { console.log(`SKIP ${f} (raced)`); continue; }
console.error(`FAIL ${f} — stopping.\n${e}`); process.exit(1);
}
}
console.log(`Done: applied=${done} skipped=${pending.length - done}`);1c. CI workflow — .github/workflows/supabase-migrations.yml (replaces db push)
name: Supabase Migrations
on:
push:
branches: [main]
paths: ['supabase/migrations/**']
workflow_dispatch:
inputs:
dry_run: { description: 'List pending only', type: boolean, default: false }
concurrency:
group: supabase-migrations-et-op # D5 layer 1 — one migration run at a time
cancel-in-progress: false
jobs:
migrate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Apply migrations
run: node scripts/apply-migrations.mjs
env:
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}Delete the old supabase db push body; keep the same filename so branch protection / required checks
don’t need reconfiguring.
1d. Verified backfill of all 289 (D4) — the deliberate, un-shortcut step
Follow docs/playbooks/supabase-per-repo-migration-ledger.md#first-time-adoption-verified-backfill
exactly for every one of the 289 files:
- Parse each file for created/altered objects.
- Confirm each object in the live catalog via Management API read-only queries
(
information_schema.tables/.columns,pg_proc,pg_policies,pg_indexes,pg_type, views). - Classify verified-present (seed as applied) / effect-absent (leave pending — runner applies it)
/ data-only-or-ambiguous (name-match against shared
schema_migrations, else flag for manual review). - Emit
supabase/migrations/<ts>_backfill_et_op_ledger.sql(INSERT … ON CONFLICT DO NOTHING for the verified set), plus a report listing the pending + flagged sets in the PR body. - Include the 4 known-mismatched et-op migrations from #5645 in the verification (they ARE applied — confirm their objects exist, seed them by filename so future runs skip them).
Do not blanket-seed all 289 as applied (that is the #4450 failure mode).
1e. Order of operations (et-op session)
Ledger table + backfill SQL first (via one Management-API run or a bootstrap migration), then land
the runner + workflow. Verify a DRY_RUN=true dispatch reports pending=0 (or exactly the known-good
pending set) before enabling push-triggered runs.
Phase 2 — p24-infra (this repo, done in this PR)
infra/agent-prompts/worker-issue.md— replaced theapply_migrationrecommendation (was line 821) with: write the migration file only + let the repo’s ledger-backed CI apply it, and an explicit project-wide ban onapply_migration(and on hand-applying viaexecute_sql).docs/playbooks/supabase-per-repo-migration-ledger.md— new playbook formalising the standard (per-repo ledger + runner, the ban, the verified-backfill procedure, transport reference).CLAUDE.md— added a “Do NOT” entry for theapply_migrationban.- This plan doc.
The existing ~510-row schema_migrations pollution is left untouched (D6).
Phase 3 — whatsup-android-chat-puller (routed to a whatsup session)
28 migrations in supabase/migrations/ (confirmed), default branch dev, currently applied ad-hoc
manually via the Management API (ungoverned, not broken). Same pattern as Phase 1, scaled to 28:
- Ledger
public._whatsup_applied_migrations. - Runner
scripts/apply-migrations.mjs(identical shape to Phase 1;LOCK_KEY=5653003, ledger/concurrency-group names swapped). - CI workflow triggering on push to
dev(its default branch) whensupabase/migrations/**changes,concurrency: supabase-migrations-whatsup. - Verified backfill of all 28 per D4 (same procedure; smaller set).
- Requires the repo to have
SUPABASE_PROJECT_ID+SUPABASE_ACCESS_TOKENGitHub secrets (add if absent).
Rollout / verification checklist
- Phase 2 PR merged to p24-infra
main(ban + playbook + plan live before executors start). - Phase 1 issue created in et-op with this plan linked; runner + ledger + workflow + verified 289 backfill;
DRY_RUNclean; olddb pushremoved. - Phase 3 issue created in whatsup; runner + ledger + workflow + verified 28 backfill; secrets present.
- Each repo: first real push-triggered run applies cleanly and records to its own ledger.
- Confirm no repo references
supabase db pushorapply_migrationafterward.
Risks
- Backfill mis-classification (D4) — mitigated by catalog verification + manual-review flag for ambiguous/data-only migrations. Precedent #4450.
- Advisory lock on
no-transactionmigrations — those fall back to the workflowconcurrency:group +IF NOT EXISTSidempotency (documented limitation). - Management API rate/size limits on a 289-migration first run — the backfill seeds “already applied” via SQL, so the first runner execution should have ~0 pending; only genuinely-pending files get applied one query each.