# Postgres schema rollout rehearsal

Standalone Node + Postgres 16 example that rehearses a compatible column
rename (`notes.display_name` → `notes.title`) while **old (v1)** and **new (v2)**
application images coexist. Use disposable local Postgres only — never a
customer database.

## Stages

| Stage | What changes | v1 | v2 |
| --- | --- | --- | --- |
| `initial` | `notes(display_name, body)` | yes | no |
| `add_title` | add nullable `title` | yes | partial |
| `backfill_title` | copy + NOT NULL | yes | yes |
| `cleanup_display_name` | drop `display_name` | no | yes |

During `add_title` / `backfill_title`, both apps **dual-write** so reads stay
consistent. Retire every v1 replica before `cleanup_display_name`.

## Quick start

```bash
cd examples/postgres-schema-rollout
npm install
export DATABASE_URL=postgres://rollout:rollout@127.0.0.1:55432/rollout
npm run setup          # docker run postgres:16-alpine (named, disposable)
npm run migrate -- --to initial
npm run seed
npm run start:v1       # APP reads display_name
# other terminal:
npm run migrate -- --to backfill_title
npm run start:v2       # APP reads title
npm test
npm run teardown -- --container
```

`scripts/migrate.mjs` takes a Postgres advisory lock, sets finite
`lock_timeout` / `statement_timeout`, and stays under Bex's 10-minute
pre-deploy deadline. Competing migrators cannot apply conflicting DDL.

## Bex wiring (sketch)

```yaml
services:
  - type: web
    name: notes-api
    runtime: node
    preDeployCommand: npm run migrate
    startCommand: npm run start:v2
    healthCheckPath: /healthz
```

Image rollback restores the previous Deployment; it does **not** undo schema
or data. A failed migrate blocks promotion of that revision.

## Safety

`lib/db.mjs` refuses non-local hosts and database names that do not look like
rehearsal/tmp/test/fixture/rollout names.
