scribasedocs

Control API /v1

Migrations

The migrations endpoint accepts a set of migrations, lints them, and optionally applies them to an environment. The lint step is always performed first — apply: false is the safe default for reviewing a migration plan before committing it.

Endpoint

POST /v1/organizations/{org}/projects/{project}/environments/{env}/migrations

Request body:

JSON
{
  "migrations": [
    {
      "version": "1",
      "name": "init",
      "up": "CREATE TABLE orders (id uuid primary key);",
      "down": "DROP TABLE orders;"
    },
    {
      "version": "2",
      "name": "add_user_id",
      "up": "ALTER TABLE orders ADD COLUMN user_id uuid NOT NULL;"
    }
  ],
  "apply": false
}

Set apply: true to execute the migrations after lint passes.

Response 200 OK (lint only):

JSON
{
  "plan": [
    {
      "version": "1",
      "name": "init",
      "direction": "up",
      "findings": []
    },
    {
      "version": "2",
      "name": "add_user_id",
      "direction": "up",
      "findings": [
        {
          "severity": "warning",
          "message": "Adding a NOT NULL column without a default to a large table may cause a full rewrite."
        }
      ]
    }
  ],
  "applied": false
}

Response 200 OK (applied):

JSON
{
  "plan": [...],
  "applied": true,
  "applied_at": "2026-01-01T12:00:00Z"
}

Lint findings

Severity Meaning
error Migration will fail or cause data loss. Blocked — apply cannot proceed.
warning Migration may cause problems at scale or violate best practices.
info Informational note.

Lock-safety rules

Four rules catch a migration that would lock a live table — or fail outright — before it ever reaches Postgres. Neither supabase db push nor Neon's branch/merge flow checks for any of this, because it's Postgres's own lock and transaction behavior, not a feature either vendor has opted into.

Rule Severity Blocks on Why
unsafe_column_rewrite Block ALTER COLUMN ... TYPE ... on a table that already existed before this migration set Changing a column's type takes Postgres's ACCESS EXCLUSIVE lock for a full-table rewrite — every read and write on the table stops for as long as the rewrite takes, which on a populated table is real, visible downtime from a routine-looking migration.
unsafe_not_null_scan Warn ALTER COLUMN ... SET NOT NULL on a table that already existed before this migration set Proving every existing row already satisfies NOT NULL takes that same ACCESS EXCLUSIVE lock for the scan — read-only, but the lock it runs under is not. This is a warning, not a block, because whether it actually matters depends on the table's size, which the lint can't know from the SQL alone.
unsafe_check_constraint_scan Warn ALTER TABLE ... ADD [CONSTRAINT name] CHECK (...) with no trailing NOT VALID, on a table that already existed before this migration set Same ACCESS EXCLUSIVE-held scan as unsafe_not_null_scan, just proving a CHECK expression instead of a NOT NULL — and for the same reason a warning, not a block: it's a scan, not a rewrite, so the real risk depends on table size. This is also what makes unsafe_not_null_scan's own recommended fix (ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID) unsafe again if the NOT VALID is dropped — a one-word omission this rule exists specifically to catch.
not_transactional Block CREATE/DROP/REINDEX ... CONCURRENTLY, VACUUM, CREATE/DROP DATABASE, CREATE/DROP TABLESPACE, ALTER SYSTEM, anywhere in the migration Every migration here applies inside one transaction, together with its ledger row. Postgres refuses these specific statements the instant a transaction block is already open (SQLSTATE 25001) — not slowly, not with a warning, refused — so without this rule the migration would reach Postgres and fail there instead of being caught by the lint. This is also the statement the other two scan/rewrite rules' own fix recommends (build the index CONCURRENTLY instead of taking the full-table lock), so this rule exists specifically to stop that fix from being suggested without a safe way to apply it.

A table created earlier in the same migration set is exempt from the first three rules — nothing has read or written it yet, so there's no live traffic for the lock to block. not_transactional has no such exemption: the refusal is Postgres's transaction machinery, not a size- or traffic-dependent risk, so it applies the same way regardless of whether the table is brand new. The fix for not_transactional is different from the other three as well: run the one statement directly against the environment's connection string, outside any migration (the console's SQL editor also runs every script in one transaction, so it refuses the same statement with the same reason — a direct psql session against the connection string is the one place on Scribase that doesn't).

JSON
{
  "plan": [
    {
      "version": "7",
      "name": "widen_order_total",
      "direction": "up",
      "findings": [
        {
          "severity": "error",
          "message": "changing the type of orders.total rewrites every existing row under an ACCESS EXCLUSIVE lock — the table is unreadable and unwritable for the whole rewrite, not just slow. Add a new column in the new type, backfill it in batches, cut over the application, then drop the old column in separate migrations instead."
        }
      ]
    }
  ],
  "applied": false
}

The fix is the same shape for unsafe_column_rewrite, unsafe_not_null_scan, and unsafe_check_constraint_scan: split one migration that locks a live table into several that don't. For a type change, add the new-typed column alongside the old one, backfill it in batches, cut the application over, then drop the old column — four small migrations instead of one blocking one. For SET NOT NULL, add CHECK (col IS NOT NULL) NOT VALID first (instant, no scan), VALIDATE CONSTRAINT it in its own migration (scans, but under the much weaker SHARE UPDATE EXCLUSIVE lock that still allows reads and writes), then SET NOT NULL — which Postgres can prove instantly once the validated check already guarantees it. Any other CHECK constraint added to an existing table gets the same two-step treatment directly: add it NOT VALID first, then VALIDATE CONSTRAINT it in its own migration — leaving off NOT VALID on that very statement is exactly what unsafe_check_constraint_scan flags, including when it's the NOT NULL check from the paragraph above.

Reaching for that same fix — building the index itself CONCURRENTLY instead of taking the full-table lock — is exactly what trips not_transactional, and splitting it into more migrations doesn't help: the problem isn't the migration's size, it's that CONCURRENTLY cannot run inside any transaction, anywhere, and every migration here runs inside one. Run it directly against the connection string instead:

Terminal
psql "$SCRIBASE_CONNECTION_STRING" \
  -c "CREATE INDEX CONCURRENTLY orders_total_idx ON orders (total);"

Row-level security rule

Rule Severity Blocks on Why
missing_rls Block A public-schema table that doesn't both enable RLS and attach at least one policy, within the same migration set that creates it The single most common Supabase data leak is a table left readable and writable by anyone who reaches the Data API, because RLS was never turned on. Scribase refuses to apply the migration at all rather than ship the table first and rely on a follow-up migration (or a human remembering) to lock it down.

Three ways a table can fail this rule, each with its own message so the fix is obvious:

  • Neither RLS nor a policy — the table is wide open. Message: has neither RLS enabled nor any policy.
  • RLS enabled, no policy — the table is locked to everyone, including its own owner, which usually means the policy was forgotten. Message: enables RLS but defines no policy (so every row is denied).
  • A policy exists, but RLS was never turned on — the policy is dead code; without RLS enabled, every row is still public regardless of what the policy says. Message: has a policy but never enables RLS (so the policy is inert).
JSON
{
  "plan": [
    {
      "version": "3",
      "name": "add_orders_table",
      "direction": "up",
      "findings": [
        {
          "severity": "error",
          "message": "table public.orders has neither RLS enabled nor any policy. Add `ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;` and at least one `CREATE POLICY ... ON public.orders` before shipping."
        }
      ]
    }
  ],
  "applied": false
}

supabase db push and a raw psql migration both apply this exact table unchanged — Postgres has no opinion on whether RLS is on, so neither Supabase's CLI nor Neon's branch/merge flow checks for it. Scribase does, at migration time, before any statement runs.

Migration version ordering

Migrations are ordered by their version field parsed as a number. The server applies them in ascending order and skips already-applied versions.

CLI shortcut

Terminal
# Lint only
scribase migrate acme store production --dir ./migrations

# Apply
scribase migrate acme store production --dir ./migrations --apply

See scribase migrate for directory and file formats.