Playbook: Supabase Migrations — applying SQL migrations to mwkqmgadqnkkihjdeqsi
Project (shared DB): mwkqmgadqnkkihjdeqsi (db.mwkqmgadqnkkihjdeqsi.supabase.co)
Secrets: SUPABASE_ACCESS_TOKEN — secrets/administration.env.sops (role-scoped copy:
ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN in secrets/role-secret-manager.env.sops) ·
SUPABASE_DB_PASSWORD — secrets/monitoring.env.sops (SOPS+age).
SUPABASE_ACCESS_TOKEN is not in monitoring.env.sops (moved out 2026-07-05, 2620 — see §Token source below)
Servers with DB access: bms-4, vps-i1 (socat IPv4→IPv6 proxy on :15432)
Related: supabase-management-api-sql-windows.md (Windows-specific Management API recipe)
Several projects share this single Supabase Postgres instance (
et-operational-platform,whatsup-android-chat-puller, BrandPilot, monitoring exporters). A migration authored in one repo’ssupabase/migrations/must be applied here manually — there is no CI step that auto-pushes migrations from app repos to this DB.
When to use
You have a .sql migration file (typically supabase/migrations/<timestamp>_<name>.sql in an app
repo) that must be applied to the shared p24 Supabase DB. This playbook covers where to run it
from and which transport works — because the obvious paths fail silently from the wrong host.
Decision matrix — pick the transport for your environment
| You are on… | Recommended method | Why |
|---|---|---|
| bms-4 / vps-i1 as an agent worker | Method B — psql via socat :15432 | Method A is not available: SUPABASE_ACCESS_TOKEN lives in administration.env.sops, which the worker age key is not a recipient of (no identity matched any of the recipients) |
| bms-4 / vps-i1 interactive SSH with the developer age key | Method A — Management API (curl) | No DB password needed (uses SUPABASE_ACCESS_TOKEN) |
bms-4 / vps-i1, need raw psql | Method B — psql via socat :15432 | Needs a current SUPABASE_DB_PASSWORD (verified working 2026-07-30) |
| Windows dev machine | Method A — Management API (PowerShell) | See supabase-management-api-sql-windows.md |
Anywhere with supabase CLI installed | Method C — supabase db push | Needs CLI install + current DB password |
Default to Method A on a developer machine, Method B on a worker. Method A returns the Postgres
error body on failure and needs only the access token — but that token is in the developer-only
administration.env.sops, so an agent worker cannot decrypt it and must use psql instead.
Method A — Supabase Management API (recommended, host-agnostic)
Works from any machine that can reach api.supabase.com and has SUPABASE_ACCESS_TOKEN.
This is the method used to apply migration 20260629200000_wa_hist_tables.sql (issue #2029).
Endpoint: POST https://api.supabase.com/v1/projects/{ref}/database/query
with JSON body {"query": "<full SQL>"}. {ref} = mwkqmgadqnkkihjdeqsi.
Token location (verified 2026-07-06):
SUPABASE_ACCESS_TOKENis insecrets/administration.env.sops, notsecrets/monitoring.env.sops. The playbook previously referenced monitoring.env.sops — that was wrong.
Linux (bms-4 / vps-i1) — curl
export SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt # worker default
cd /opt/p24-infra # or your repo checkout
# 1. Read the access token silently — NEVER echo it.
TOKEN=$(sops --decrypt --input-type dotenv --output-type dotenv \
secrets/administration.env.sops | grep "^SUPABASE_ACCESS_TOKEN=" | cut -d= -f2-)
REF="mwkqmgadqnkkihjdeqsi"
URL="https://api.supabase.com/v1/projects/$REF/database/query"
# 2. Fetch the migration from the source repo (or use a local path).
gh api repos/radieu/<app-repo>/contents/supabase/migrations/<file>.sql \
--jq '.content' | base64 -d > /tmp/migration.sql
# 3. JSON-encode the SQL safely (handles quotes, dollar-quotes, newlines) and POST.
BODY=$(python3 -c 'import json,sys;print(json.dumps({"query":open("/tmp/migration.sql").read()}))')
HTTP=$(curl -s -o /tmp/resp.json -w "%{http_code}" -X POST "$URL" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$BODY")
echo "HTTP $HTTP"; cat /tmp/resp.json
unset TOKEN- HTTP 201 +
[](or a result set) = success. - HTTP 4xx →
/tmp/resp.jsoncontains the Postgres error message. Read it; the API does not swallow the error body the way some clients do.
Token source:
secrets/administration.env.sops(per the verified note above), notsecrets/monitoring.env.sops— that file contains noSUPABASE_ACCESS_TOKENat all — and not.env.local(which may be stale → 401). If the token itself is expired, rotate persupabase-access-token-rotation.md.Agent workers cannot use this method.
administration.env.sopsis developer-only (excluded fromsecrets-sync.yml) and the worker age key is not one of its recipients, sosops --decryptfails withno identity matched any of the recipients. Use Method B.
Windows dev machine — PowerShell
See supabase-management-api-sql-windows.md for the full
recipe. Do not use Invoke-RestMethod (its error handling hides the response body, and the
auto-mode classifier may block a Bearer-token call) — use the System.Net.WebRequest +
JavaScriptSerializer pattern documented there.
Method B — psql via the bms-4 / vps-i1 socat proxy
bms-4 runs socat-supabase.service, forwarding TCP4-LISTEN:15432 → aws-1-eu-central-1.pooler.supabase.com:5432 (the Supavisor session pooler for this tenant — see
pg-stats-exporter-pooler-tenant-identifier.md).
vps-i1 has no socat unit — from there, dial bms-4’s proxy at 54.36.123.110:15432, which is
exactly what SUPABASE_DB_HOST/SUPABASE_DB_PORT in monitoring/.env already point at.
Two non-obvious requirements:
-U postgres.<project_ref>, never bare-U postgres. The IP-based socat hop strips SNI, so Supavisor can only resolve the tenant from the username suffix; a barepostgresis rejected withFATAL: (ENOIDENTIFIER) no tenant identifier provided(orTenant or user not found).PGSSLMODE=require. TLS terminates at the pooler;requiredoes not verify the hostname, so connecting by IP is fine.
export SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt
# On a worker, prefer the already-deployed plaintext env over a SOPS decrypt:
export PGPASSWORD=$(grep -m1 '^SUPABASE_DB_PASSWORD=' /opt/p24-infra/monitoring/.env | cut -d= -f2- | tr -d '"')
export PGSSLMODE=require
psql -h 54.36.123.110 -p 15432 -U postgres.mwkqmgadqnkkihjdeqsi -d postgres \
-v ON_ERROR_STOP=1 -f /tmp/migration.sql # use 127.0.0.1 when on bms-4 itself
unset PGPASSWORDRead-only queries can also go in as grafana_readonly.mwkqmgadqnkkihjdeqsi with
SUPABASE_GRAFANA_PASSWORD — useful for verifying RLS from a role that does not bypass it.
Password status (verified 2026-07-30, from vps-i1 against the live pooler):
postgresauth succeeds withSUPABASE_DB_PASSWORDas stored in/opt/p24-infra/monitoring/.env,secrets/monitoring.env.sopsandsecrets/n8n-bms4.env.sops(the last is the copy/opt/p24-infra/bms-4/.env— and thereforeapply-supabase-migrations.yml— is built from), andgrafana_readonlyauth succeeds withSUPABASE_GRAFANA_PASSWORD. This supersedes the 2026-06-29 caveat, and the “both copies have drifted, neither authenticates” note in.github/workflows/apply-supabase-migrations.yml(#4387 / #4110). Re-test before concluding the password is stale — that claim has outlived its accuracy at least once.
Method C — supabase CLI (supabase db push)
Useful when applying a whole supabase/migrations/ directory at once. Not installed on bms-4 by
default (supabase not in PATH as of 2026-06-29) — install first:
npm install -g supabase # one-time
export SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt
SUPABASE_DB_PASSWORD=$(sops --decrypt --input-type dotenv --output-type dotenv \
secrets/monitoring.env.sops | grep "^SUPABASE_DB_PASSWORD=" | cut -d= -f2-)
supabase link --project-ref mwkqmgadqnkkihjdeqsi --password "$SUPABASE_DB_PASSWORD"
supabase db push
unset SUPABASE_DB_PASSWORDSame stale-password caveat as Method B applies — the CLI needs the live DB password.
What does NOT work from the Windows dev machine
| Method | Status |
|---|---|
Direct Postgres port 15432 (54.36.123.110:15432 socat) | ETIMEDOUT — TLS/IPv6 negotiation fails from Windows psql; reachable only from on-VPS clients |
supabase CLI | Not installed by default (npm install -g supabase required) |
Management API via Invoke-RestMethod | Can be blocked by the auto-mode classifier + hides error body — use System.Net.WebRequest instead |
The reliable Windows path is Method A via PowerShell — see the cross-referenced playbook.
Authoring rules — check the relation kind before ALTER TABLE
Only supabase/migrations/ is ever applied — monitoring/supabase/migrations/ is dead
CI (.github/workflows/apply-supabase-migrations.yml) applies supabase/migrations/** only.
monitoring/supabase/migrations/ is a frozen historical record, kept that way after a migration
that lived only there caused a 3-hour outage (incident #2249). No CI job ever runs anything in
that folder.
The dangerous part is that writing a migration into the dead folder fails silently: the file
looks applied (it is committed, reviewed and merged), it is never recorded in
_p24_applied_migrations, and nothing errors. The bug only surfaces much later as “the fix we
shipped isn’t in production”.
This has already happened once, for a security fix:
monitoring/supabase/migrations/028_security_definer_revoke_anon.sql carried the header comment
“Applied: 2026-06-22” and revoked anon EXECUTE on ~30 SECURITY DEFINER functions. It never ran.
Five weeks later the ACL still read anon=X/postgres on admin_update_user_password, and an
unauthenticated caller could still read all 52 user emails via
/rest/v1/rpc/get_users_with_emails — re-fixed by
#4663.
A header comment claiming a migration was applied is not evidence. The only evidence is
_p24_applied_migrationsplus a catalog query confirming the change is live.
-- Was it really applied?
SELECT filename, applied_at FROM public._p24_applied_migrations WHERE filename LIKE '%<slug>%';dev_r_services is a view
dev_r_services is a VIEW, not a table. ALTER TABLE dev_r_services … is therefore never
valid in a migration and fails with:
ERROR: ALTER action ADD COLUMN cannot be performed on relation "dev_r_services"
DETAIL: This operation is not supported for views.
IF NOT EXISTS does not rescue this. Postgres rejects the relation kind before the
IF NOT EXISTS guard is evaluated, so the usual idempotency idiom gives no protection here.
This matters more than a normal migration bug because the runner
(scripts/apply-supabase-migrations.sh) stops at the first failure and applies nothing after
it — so one bad ALTER TABLE on a view blocks the entire queue and every later migration
silently goes unapplied. This has now caused three separate incidents on this one relation:
#4293 → #4442 → #4447.
To change the shape of dev_r_services, target the underlying relation and then recreate the
view if the column should be exposed through it.
The underlying relation is devops.dev_r_services (a real table, relkind='r').
public.dev_r_services is a thin SELECT … FROM devops.dev_r_services view — nothing in the
repo does CREATE VIEW dev_r_services, which is why the target was non-obvious and made
#4447 hard to close (#4450).
So a rotation_type / compliance / SLA column change belongs on devops.dev_r_services, e.g.:
ALTER TABLE devops.dev_r_services ADD COLUMN IF NOT EXISTS <col> <type>;
-- the public view auto-exposes it only if the view is recreated to SELECT the new columnConfirm the target any time with:
SELECT definition FROM pg_views WHERE schemaname='public' AND viewname='dev_r_services';Check the relation kind first — cheap, and catches this class of bug at authoring time:
-- 'r' = ordinary table, 'v' = view, 'm' = materialized view
SELECT relname, relkind FROM pg_class
WHERE relnamespace = 'public'::regnamespace AND relname = 'dev_r_services';Fixing a security_definer_view advisor finding
A view without security_invoker runs with its owner’s privileges, so it bypasses RLS on
whatever it selects from. The advisor reports this as an ERROR-level security_definer_view.
Prefer ALTER VIEW … SET, not CREATE OR REPLACE VIEW:
ALTER VIEW public.<view> SET (security_invoker = true); -- PG15+; this DB is PG 17.6CREATE OR REPLACE VIEW … WITH (security_invoker = true) AS <definition> reaches the same end
state but forces you to restate the entire column list, and it aborts on any column name/order
change — so the definition can drift from the underlying relation or lose logic. ALTER VIEW SET
touches only the option. It is also not ALTER TABLE, so the trap above does not apply.
Guard it with a relation-kind assertion so a future relkind change fails loudly instead of
silently doing the wrong thing (see
supabase/migrations/20260730073959_rls_hardening_definer_view_4645.sql for the full pattern):
DO $$
DECLARE kind "char";
BEGIN
SELECT relkind INTO kind FROM pg_class
WHERE relnamespace = 'public'::regnamespace AND relname = '<view>';
IF kind IS DISTINCT FROM 'v' THEN
RAISE EXCEPTION 'expected a view, got relkind=%', kind;
END IF;
EXECUTE 'ALTER VIEW public.<view> SET (security_invoker = true)';
END;
$$;After the change the invoker’s own grants and the RLS policies on the underlying relation apply. Check both before shipping: a role holding a grant on the view but no policy on the base table will start getting 0 rows, with no error.
Fixing an anon_security_definer_function_executable advisor finding
A SECURITY DEFINER function runs as its owner, so an anon EXECUTE grant makes it callable by
anyone holding only the publishable/anon key, via POST /rest/v1/rpc/<name>. Unlike the view case
this is not read-only — an anon-executable definer function can write.
Enumerate the real target set from the catalog; the advisor’s count is a summary, not a list:
SELECT p.proname, pg_get_function_identity_arguments(p.oid) AS args, p.proacl
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public' AND p.prokind = 'f' AND p.prosecdef
AND (p.proacl::text LIKE '%anon=X%' OR p.proacl::text LIKE '{=X/%');Two traps in that query:
- Filter on
prosecdef. On this project 285 public functions are anon-executable but only 55 are SECURITY DEFINER. The other 230 are SECURITY INVOKER — they run with the caller’s own privileges under RLS, so they are a different (much smaller) problem. Revoking all 285 would be a large, unnecessary blast radius. proacl::text LIKE '{=X/%'catches the implicit PUBLIC grant, which prints as a leading=X/with no role name. 25 of the 55 had it. Revoking onlyanonleaves those still executable viaPUBLIC, so always writeREVOKE … FROM PUBLIC, anon[, authenticated].
Decide what is safe to revoke from measured usage, never a code grep — grep misses call sites in
n8n workflows, mobile bundles and other repos. pg_stat_statements records the executing role and
does capture PostgREST RPC traffic as rolname = 'anon':
SELECT r.rolname, sum(s.calls)
FROM pg_stat_statements s JOIN pg_roles r ON r.oid = s.userid
WHERE s.query ~ '\m<function_name>\M'
GROUP BY 1;Before trusting a zero result, confirm nothing was evicted — otherwise “no calls” may just mean “aged out”:
SELECT stats_reset, dealloc FROM pg_stat_statements_info; -- dealloc = 0 -> complete record
SELECT count(*) FROM pg_stat_statements; -- vs SHOW pg_stat_statements.maxPreserve every non-anon grant you find in proacl. On this project get_pg_stat_statements,
grafana_slow_queries and grafana_table_write_stats hold a live grafana_readonly=X used by
pg-stats-exporter; dropping it would break the Grafana dashboards.
Leave genuine pre-auth functions alone (login-flow checks that run before a session exists) and
document each exception in the migration header. Close with an assertion block so a missed
signature fails the migration rather than shipping a half-closed hole — the runner uses
psql --single-transaction, so a RAISE EXCEPTION at the end rolls back every statement in the
file. Full worked pattern:
supabase/migrations/20260730115455_revoke_anon_execute_definer_functions_4663.sql.
Finally: edit these functions with CREATE OR REPLACE FUNCTION, never DROP + CREATE.
DROP+CREATE resets the ACL to the Postgres default (PUBLIC-executable) and silently re-opens every
hole — the likeliest way this fix regresses.
Enabling RLS on an existing public table — check rolbypassrls first
ALTER TABLE … ENABLE ROW LEVEL SECURITY is idempotent and safe to re-run, but RLS with no
matching policy returns 0 rows silently rather than raising. So before enabling it, list every
role that holds a grant and check which of them bypass RLS:
-- who is granted anything on the table
SELECT grantee, string_agg(privilege_type, ',' ORDER BY privilege_type) AS privs
FROM information_schema.role_table_grants
WHERE table_schema = 'public' AND table_name = '<table>'
GROUP BY grantee ORDER BY grantee;
-- which of those roles ignore RLS entirely
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles
WHERE rolname IN ('anon','authenticated','service_role','grafana_readonly','supabase_backup','postgres');On this project (verified 2026-07-30):
| Role | rolbypassrls | Effect of enabling RLS |
|---|---|---|
postgres, service_role, supabase_backup | true | unaffected — migration runner, PostgREST service key, PITR/backup COPY all keep working |
grafana_readonly, anon, authenticated | false | RLS applies — needs an explicit policy or it silently reads 0 rows |
Two consequences that have to be handled every time:
grafana_readonlyneeds a policy on any table it is grantedSELECTon, or the monitoring dashboards go blank without erroring. The house pattern isCREATE POLICY grafana_readonly_select ON public.<t> FOR SELECT TO grafana_readonly USING (true);paired withCREATE POLICY service_role_all ON public.<t> FOR ALL TO service_role USING (true) WITH CHECK (true);— already in use onwa_hist_chats,wa_hist_messages,dev_r_worker_queue,devops.dev_r_services._p24_applied_migrationsis safe to lock down because the runner connects aspostgres.<project_ref>→ rolepostgres→rolbypassrls = true. Confirm this before enabling RLS on the ledger; getting it wrong locks the migration runner out of its own bookkeeping.
Distinguish a deliberate narrow grant from Supabase’s defaults before choosing deny-all. Supabase
grants anon + authenticated full DML on new public tables by default; a table with only
authenticated: SELECT was hand-narrowed on purpose and probably has a real client, so it wants a
permissive SELECT policy rather than flat deny (p24_wap_plan_limits in #4645). Either way the
rls_disabled_in_public finding clears — the advisor only requires RLS to be enabled.
To find out whether a role actually uses a relation before you change its access, read
pg_stat_statements instead of guessing:
SELECT r.rolname, sum(s.calls) AS calls
FROM extensions.pg_stat_statements s JOIN pg_roles r ON r.oid = s.userid
WHERE s.query ILIKE '%<relation>%'
GROUP BY r.rolname ORDER BY calls DESC;Dry-run a migration against prod without committing
DDL is transactional in Postgres, so you can prove a migration applies before it reaches the
queue. This is the cheapest guard against the “one bad migration blocks every later one” failure
mode. lock_timeout keeps the ACCESS EXCLUSIVE locks from ever waiting behind a long query:
SET lock_timeout = '5s';
BEGIN;
\i supabase/migrations/<file>.sql
\i supabase/migrations/<file>.sql -- second pass = idempotency check
-- inspect the resulting catalog state here
ROLLBACK;Note psql cannot SET ROLE to grafana_readonly / anon as postgres on this DB
(permission denied to set role) — an unguarded SET LOCAL ROLE aborts the whole script under
ON_ERROR_STOP=1. Verify per-role behaviour after applying: log in directly as
grafana_readonly.<project_ref> (password SUPABASE_GRAFANA_PASSWORD), and probe the anon path
over PostgREST with the publishable key.
Do not hand-backfill _p24_applied_migrations without applying the SQL
Inserting a filename into _p24_applied_migrations marks it applied forever — the runner
skips it on every subsequent run. If the SQL never actually executed, the schema silently lacks
whatever that migration was supposed to create, and nothing will ever retry it.
As of 2026-07-21, 41 of 61 ledger rows carry an exactly-00:00:00 timestamp, i.e. they were
backfilled rather than observed to run. Treat a midnight applied_at as “recorded, not
verified” — it is not evidence the objects exist. When you need to know, check the catalog
directly (see §Verification) rather than trusting the ledger.
#4450 audited all 41 backfilled rows against
pg_catalog: every table, column, function and trigger they claimed to create exists except
those from 20260627_dev_r_services_rotation_type.sql (the CHECK on devops.dev_r_services and
the whole rotation_type column+CHECK on public.dev_r_rotation_log), repaired by
20260721_dev_r_services_rotation_type_check.sql. So the blast radius of the backfill was one
migration — but it took a full catalog sweep to prove that, which is the point: don’t infer, check.
Verification — always confirm objects after applying
Run these with Method A (q() = the curl helper above), or via psql. Adjust the LIKE filter
to your migration’s object names.
-- tables created + RLS state
SELECT tablename, rowsecurity FROM pg_tables
WHERE schemaname='public' AND tablename LIKE 'wa_hist%' ORDER BY tablename;
-- column counts (sanity-check the schema applied fully)
SELECT table_name, count(*) AS cols FROM information_schema.columns
WHERE table_schema='public' AND table_name LIKE 'wa_hist%' GROUP BY table_name;
-- indexes
SELECT indexname FROM pg_indexes
WHERE schemaname='public' AND tablename LIKE 'wa_hist%' ORDER BY indexname;
-- RLS policies (FK roles like grafana_readonly / service_role must already exist)
SELECT tablename, policyname, roles FROM pg_policies
WHERE schemaname='public' AND tablename LIKE 'wa_hist%' ORDER BY tablename, policyname;Pre-flight for migrations that
CREATE POLICY ... TO <role>: confirm the role exists first (SELECT rolname FROM pg_roles WHERE rolname='grafana_readonly';). A policy referencing a missing role aborts the whole migration.grafana_readonlyandservice_roleboth exist on this DB.
Worked example — migration 042 wa_hist_tables (issue #2029)
Applied 2026-06-29 from bms-4 via Method A:
- Fetched
20260629200000_wa_hist_tables.sqlfromradieu/whatsup-android-chat-puller(PR #55, merged to dev) withgh api … | base64 -d. - Pre-flight: confirmed
wa_hist_*tables absent andgrafana_readonly+service_roleroles present. - POSTed the SQL to the Management API → HTTP 201.
- Verified: 3 tables (
wa_hist_chats9 cols,wa_hist_messages10 cols,wa_hist_threads12 cols), RLS enabled on all three, 8 indexes, and 2 policies per table (service_role_all,grafana_readonly_select).
This unblocked et-operational-platform PR #1105 (chat-history dashboard).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized from Management API | Stale/expired SUPABASE_ACCESS_TOKEN | Re-read from secrets/administration.env.sops (workers: ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN in secrets/role-secret-manager.env.sops) — not monitoring.env.sops; if still 401, rotate per supabase-access-token-rotation.md |
FATAL: password authentication failed (psql/CLI) | Stale SUPABASE_DB_PASSWORD in SOPS | Use Method A, or refresh the password from the dashboard and update SOPS |
ETIMEDOUT to :15432 from Windows | Direct DB port not reachable off-VPS | Use Method A (Management API) instead |
role "<x>" does not exist mid-migration | Policy references a missing role | Create the role first, or split the migration |
| Migration partially applied then errored | A statement after a non-idempotent one failed | Re-run is safe only if statements use IF NOT EXISTS; otherwise clean up partial objects first |
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="migration",
resource="supabase-schema",
result="success", # "success" | "failed" | "skipped"
detail="Supabase schema migration applied via Management API",
env="vps-i1",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'migration', 'supabase-schema', 'success', 'Supabase schema migration applied via Management API', 'vps-i1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''