Playbook: Supabase Migrations — applying SQL migrations to mwkqmgadqnkkihjdeqsi

Project (shared DB): mwkqmgadqnkkihjdeqsi (db.mwkqmgadqnkkihjdeqsi.supabase.co) Secrets: SUPABASE_ACCESS_TOKENsecrets/administration.env.sops (role-scoped copy: ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN in secrets/role-secret-manager.env.sops) · SUPABASE_DB_PASSWORDsecrets/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’s supabase/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 methodWhy
bms-4 / vps-i1 as an agent workerMethod B — psql via socat :15432Method 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 keyMethod A — Management API (curl)No DB password needed (uses SUPABASE_ACCESS_TOKEN)
bms-4 / vps-i1, need raw psqlMethod B — psql via socat :15432Needs a current SUPABASE_DB_PASSWORD (verified working 2026-07-30)
Windows dev machineMethod A — Management API (PowerShell)See supabase-management-api-sql-windows.md
Anywhere with supabase CLI installedMethod C — supabase db pushNeeds 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.


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_TOKEN is in secrets/administration.env.sops, not secrets/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.json contains 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), not secrets/monitoring.env.sops — that file contains no SUPABASE_ACCESS_TOKEN at all — and not .env.local (which may be stale → 401). If the token itself is expired, rotate per supabase-access-token-rotation.md.

Agent workers cannot use this method. administration.env.sops is developer-only (excluded from secrets-sync.yml) and the worker age key is not one of its recipients, so sops --decrypt fails with no 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 bare postgres is rejected with FATAL: (ENOIDENTIFIER) no tenant identifier provided (or Tenant or user not found).
  • PGSSLMODE=require. TLS terminates at the pooler; require does 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 PGPASSWORD

Read-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): postgres auth succeeds with SUPABASE_DB_PASSWORD as stored in /opt/p24-infra/monitoring/.env, secrets/monitoring.env.sops and secrets/n8n-bms4.env.sops (the last is the copy /opt/p24-infra/bms-4/.env — and therefore apply-supabase-migrations.yml — is built from), and grafana_readonly auth succeeds with SUPABASE_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_PASSWORD

Same stale-password caveat as Method B applies — the CLI needs the live DB password.


What does NOT work from the Windows dev machine

MethodStatus
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 CLINot installed by default (npm install -g supabase required)
Management API via Invoke-RestMethodCan 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_migrations plus 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 column

Confirm 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.6

CREATE 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 only anon leaves those still executable via PUBLIC, so always write REVOKE … 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.max

Preserve 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):

RolerolbypassrlsEffect of enabling RLS
postgres, service_role, supabase_backuptrueunaffected — migration runner, PostgREST service key, PITR/backup COPY all keep working
grafana_readonly, anon, authenticatedfalseRLS applies — needs an explicit policy or it silently reads 0 rows

Two consequences that have to be handled every time:

  • grafana_readonly needs a policy on any table it is granted SELECT on, or the monitoring dashboards go blank without erroring. The house pattern is CREATE POLICY grafana_readonly_select ON public.<t> FOR SELECT TO grafana_readonly USING (true); paired with CREATE POLICY service_role_all ON public.<t> FOR ALL TO service_role USING (true) WITH CHECK (true); — already in use on wa_hist_chats, wa_hist_messages, dev_r_worker_queue, devops.dev_r_services.
  • _p24_applied_migrations is safe to lock down because the runner connects as postgres.<project_ref> → role postgresrolbypassrls = 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_readonly and service_role both exist on this DB.


Worked example — migration 042 wa_hist_tables (issue #2029)

Applied 2026-06-29 from bms-4 via Method A:

  1. Fetched 20260629200000_wa_hist_tables.sql from radieu/whatsup-android-chat-puller (PR #55, merged to dev) with gh api … | base64 -d.
  2. Pre-flight: confirmed wa_hist_* tables absent and grafana_readonly + service_role roles present.
  3. POSTed the SQL to the Management API → HTTP 201.
  4. Verified: 3 tables (wa_hist_chats 9 cols, wa_hist_messages 10 cols, wa_hist_threads 12 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

SymptomCauseFix
401 Unauthorized from Management APIStale/expired SUPABASE_ACCESS_TOKENRe-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 SOPSUse Method A, or refresh the password from the dashboard and update SOPS
ETIMEDOUT to :15432 from WindowsDirect DB port not reachable off-VPSUse Method A (Management API) instead
role "<x>" does not exist mid-migrationPolicy references a missing roleCreate the role first, or split the migration
Migration partially applied then erroredA statement after a non-idempotent one failedRe-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 = ''