# Writing Migrations

> Writing Migrations (deploy-time round)

- Skill: `marmelab/writing-migrations` (Agent Skill)
- Install (CLI): `npx skillmds@latest add marmelab/writing-migrations`
- Raw SKILL.md: https://api.skillmd.com/api/skills/marmelab/writing-migrations/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: marmelab (https://skillmd.com/u/marmelab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/marmelab/writing-migrations

---


# Writing Migrations (deploy-time round)

## Overview

A workflow that turns a session's _code_ changes into the _minimal_ SQL that
makes the real Supabase schema match what the session's app now expects —
nothing more.
You (the `developer`) have been asked to generate the deploy-time migration. The
"never write migrations / no schema" rule of normal ticket work does NOT apply
here: writing SQL under `supabase/migrations/` is the whole job. Do not edit any
TS/TSX/CSS — the schema diff already comes from the session branch; you only
translate it to SQL.
Exit criterion: either a committed, idempotent migration whose delta is provably the net schema change, or an explicit `NO_MIGRATION_NEEDED` when there is no schema impact.

Your worktree is `<WORKTREE_BASE>/simple` on `<SESSION_SHORT_ID>/simple`, forked from `session/<SESSION_SHORT_ID>` (`<WORKTREE_BASE>` =
`/tmp/<$CLAUDE_PROJECT_DIR with every "/" replaced by "_">/<SESSION_ID>`). Every Bash call must `cd <WORKTREE_PATH> && …` (stateless shells).

## When to Use

- The deploy-time migration round, after a session's app changes have merged to `session/<SESSION_SHORT_ID>` and need their schema counterpart.
- Triggered only by the orchestrator dispatching a `developer` that loads this skill, never invoked ad hoc by a feature developer (per-ticket developers never write SQL).

Skip (write nothing, report `NO_MIGRATION_NEEDED`) when the net diff has no
schema impact: CSS, layout, copy, or test-only changes.

## Process

Each step ends in a checkpoint, do not advance until its evidence holds.

### 1. Compute the session's net change

```bash
cd <WORKTREE_PATH>
git diff session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID>
```

This is the branch's full diff since creation. Do NOT use `git merge-base`
(it collapses after the first promotion). Do NOT diff against the base branch
(other sessions pollute it).

**Checkpoint:** you have the full session diff, produced from the two-dot range above (not merge-base, not the base branch).

### 2. Identify schema-relevant changes

From that diff, keep only changes that imply a database schema change:

- Entity TypeScript types (e.g. `src/**/types.ts`, resource type defs).
- Fake-data generators that add/remove fields.
- dataProvider resource registrations (new resource = new table).
  Ignore CSS, component layout, copy, tests.

**Checkpoint:** you have an explicit list of schema-relevant changes (possibly
empty). If empty, jump to step 3's no-op path.

### 3. Regenerate ONE net migration against what is ALREADY DEPLOYED (session-base)

"Deployed" = the migrations present at `session-base/<SESSION_SHORT_ID>` (the fork
anchor, i.e. the last deployed state). A migration YOU generated in an EARLIER
iteration of this same session is NOT deployed: it sits on the session branch but was
never pushed. Treating it as deployed is exactly what makes iterations STACK (add
column `foo`, then a second migration to rename it to `bar`, ...) instead of one clean
migration. The user does not want to push N migrations per change.

So, before authoring any SQL:

1. Find this session's own prior migration(s):
   `git diff --diff-filter=A session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID> -- supabase/migrations/`
   (files ADDED since the fork = generated by an earlier iteration of this session).
2. **Delete them.** You will regenerate ONE fresh file that is the whole net change.
3. Author SQL for the NET delta between the desired schema (from the TS types) and the
   schema as of `session-base` ONLY. Never layer an incremental delta on top of your
   own prior migration.

Result: however many times the user iterates before deploy, the session carries
exactly ONE migration = `net(session-base -> final schema)`. If the net diff has no
schema impact, write **nothing** and report `NO_MIGRATION_NEEDED`.

**Hard boundary:** overwrite-and-regenerate is safe ONLY because the migration is not
yet deployed. Once it is pushed / promoted it is immutable; a later change is a NEW
forward migration, never an edit of the pushed one.

**Surface note.** Technical-harness writes to `supabase/migrations/`. On the web-chat
surface (CRM Builder) the launcher stages migrations in `supabase/migrations-pending/`
and promotes them to `supabase/migrations/` after the user confirms: follow the
launcher's staging, do not invent your own pending/promoted handling. The
regenerate-one-net-migration rule above applies either way (it is about NOT stacking,
independent of which directory the launcher stages into).

**Checkpoint:** at most ONE session-owned migration file exists, and its SQL is the
full net delta from `session-base` (not an increment on a prior session migration).

### 4. Write idempotent SQL

Write to `supabase/migrations/<YYYYMMDDHHMMSS>_<SESSION_SHORT_ID>_migration_<slug>.sql`
(timestamp via `date -u +%Y%m%d%H%M%S`). Use `IF NOT EXISTS` / `IF EXISTS`,
correct column types matching the TS types, FKs, and RLS for new tables (RLS
enabled + policies, never `USING (true)`).

**Checkpoint:** re-running the migration on an already-migrated database is a
no-op (every statement is guarded), and every new table has RLS enabled with at least one non-`USING (true)` policy.

### 5. View-recreation rule (BLOCKING correctness)

When a migration adds a column on a table referenced by a view in
`supabase/schemas/03_views.sql`, recreate the view with **`CREATE OR REPLACE
VIEW`** and place the new column as the **very last item** in the SELECT
list — after every existing column AND after every existing computed `AS`
alias (e.g. `count(...) as nb_deals`).

PostgreSQL forbids any ordinal shift in an existing view's SELECT list
(error 42P16). "Between the raw columns and the computed aggregates" is
**still a shift** — the aggregate's position increases by one. There are no
exceptions. The mechanical rule is: positions 1..N of the new view must be
identical to positions 1..N of the old view; the new column is position N+1.

Why not `DROP VIEW … CASCADE; CREATE VIEW …`? It silently drops dependent
views/materialized views/rules, loses any explicit `REVOKE`s on the view
(re-granted by default privileges at the next deploy without warning), and
reads as a destructive operation in audit. Reserve it for cases `CREATE OR
REPLACE` genuinely can't handle (column **removal** or **rename**). If you
use it, enumerate dropped dependents in a SQL comment and re-create them in
the same migration.

PostgREST queries the view, not the table — a missing update makes the
column invisible to the app. The view's column order doesn't matter to API
consumers (they address by name), so "append at end" has no API cost.

Example — adding `importance` to `companies_summary` (the view ends with
`count(...) as nb_deals, count(...) as nb_contacts`):

```sql
create or replace view public.companies_summary with (security_invoker = on) as
select
    c.id, c.created_at, c.name, …, c.logo,
    count(distinct d.id) as nb_deals,
    count(distinct co.id) as nb_contacts,
    c.importance                              -- LAST, after every existing item
from public.companies c
    left join public.deals d on c.id = d.company_id
    left join public.contacts co on c.id = co.company_id
group by c.id;
```

Then update `supabase/schemas/03_views.sql` to the **same order** — the
declarative schema must mirror the deployed view, including the chronological
"append at end" placement of newer columns. Don't reorder the schema file to
look prettier — schema drift breaks future `supabase db diff` generations.

**Checkpoint:** for every table that gained a column AND feeds a view,
positions 1..N of the recreated view match the old view exactly, the new column is position N+1, and `03_views.sql` mirrors that order.

### 6. Commit and hand off

Commit the SQL on `<SESSION_SHORT_ID>/simple`:

```bash
cd <WORKTREE_PATH> && git add supabase/migrations && git commit -m "migration(<SESSION_SHORT_ID>): <slug>"
```

Then stop and emit the output contract as your very last line — one of:

- `DONE: branch=<SESSION_SHORT_ID>/simple migration=<filename> summary=<what the SQL does>`
- `NO_MIGRATION_NEEDED` — only after computing the diff (step 1) and confirming no schema impact.
- `FAILED: <one-line reason>`

SubagentStop hooks (typecheck/prettier/unit/e2e) run automatically; they should
pass since you only touched SQL. The orchestrator then sends you to
quality-reviewer (`MODE: migration-review`) and the merger.

For Postgres correctness you may load `Skill({skill: "supabase-postgres-best-practices"})`.

**Checkpoint:** the migration is committed on `<SESSION_SHORT_ID>/simple` and the SubagentStop validation chain is green.

## Rationalizations

| Rationalization                                                         | Reality                                                                                                            |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| "I'll diff against the base branch, it's close enough."                 | Other sessions pollute the base branch. Only the two-dot `session-base..session` range is the true net change.     |
| "This column is probably already deployed, I'll re-emit it to be safe." | Re-emitting a deployed change is drift, not safety. Guard with `IF NOT EXISTS` and emit only the delta.            |
| "The view still selects all the right columns, order doesn't matter."   | Postgres rejects any ordinal shift (42P16). Order is a hard correctness constraint, not cosmetics.                 |
| "DROP VIEW CASCADE then CREATE is simpler."                             | It silently drops dependents and loses REVOKEs. Use `CREATE OR REPLACE` unless you are removing/renaming a column. |
| "No schema change, but I'll write an empty migration anyway."           | A no-op deploy is valid. Write nothing and report `NO_MIGRATION_NEEDED`.                                           |

## Red Flags

- Diff computed via `git merge-base` or against the base branch.
- A migration statement without an `IF [NOT] EXISTS` guard.
- A new table with no RLS, or an RLS policy using `USING (true)`.
- A view recreated by `DROP … CASCADE` for a mere column _addition_.
- A new column inserted anywhere but the last position of an existing view.
- `03_views.sql` and the migration's view definition disagreeing on column order.
- Re-emitting SQL already present in `supabase/migrations/`.

## Verification

- [ ] Diff taken from `session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID>`.
- [ ] Every emitted statement is an incremental delta not already in `supabase/migrations/`.
- [ ] All statements are idempotent (`IF [NOT] EXISTS`).
- [ ] New tables have RLS enabled with real policies (never `USING (true)`).
- [ ] Affected views recreated via `CREATE OR REPLACE`, new column last, matching `03_views.sql`.
- [ ] SQL committed on `<SESSION_SHORT_ID>/simple`; SubagentStop validation green — OR `NO_MIGRATION_NEEDED` reported with no file written.

