@launchsecure/launch-kit 0.0.26 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chart-client/assets/index-CJ4mgRRF.css +1 -0
- package/dist/chart-client/assets/{index-Bk1hawjD.js → index-Ccy-DpI-.js} +46 -42
- package/dist/chart-client/index.html +2 -2
- package/dist/client/assets/index-DI5qSR_w.css +32 -0
- package/dist/client/assets/index-Dp0_okva.js +294 -0
- package/dist/client/index.html +2 -2
- package/dist/council-client/assets/index-C_-vAM9L.css +1 -0
- package/dist/council-client/index.html +2 -2
- package/dist/deck-client/assets/{_baseUniq-C2xT_eYu.js → _baseUniq-W2JQDmje.js} +1 -1
- package/dist/deck-client/assets/{arc-CmVL9pGd.js → arc-DIBWAId9.js} +1 -1
- package/dist/deck-client/assets/{architectureDiagram-Q4EWVU46-BSFgdjve.js → architectureDiagram-Q4EWVU46-CAIRMvJK.js} +1 -1
- package/dist/deck-client/assets/{blockDiagram-DXYQGD6D-DuLzscvP.js → blockDiagram-DXYQGD6D-BeNaNiOi.js} +1 -1
- package/dist/deck-client/assets/{c4Diagram-AHTNJAMY-CfCJB8eY.js → c4Diagram-AHTNJAMY-B9Ozi62h.js} +1 -1
- package/dist/deck-client/assets/channel-CRdozqbp.js +1 -0
- package/dist/deck-client/assets/{chunk-4BX2VUAB-DxmLYTWZ.js → chunk-4BX2VUAB-D7AZ47dt.js} +1 -1
- package/dist/deck-client/assets/{chunk-4TB4RGXK-CCnf7GFE.js → chunk-4TB4RGXK-DnVnNPcI.js} +1 -1
- package/dist/deck-client/assets/{chunk-55IACEB6-Db9DApcj.js → chunk-55IACEB6-UKYs-YNd.js} +1 -1
- package/dist/deck-client/assets/{chunk-EDXVE4YY-DmYDq8ZI.js → chunk-EDXVE4YY-D43b-SKn.js} +1 -1
- package/dist/deck-client/assets/{chunk-FMBD7UC4-BGhUlF20.js → chunk-FMBD7UC4-QzBAoyyW.js} +1 -1
- package/dist/deck-client/assets/{chunk-OYMX7WX6-CpEnicQZ.js → chunk-OYMX7WX6-Cjif4r6W.js} +1 -1
- package/dist/deck-client/assets/{chunk-QZHKN3VN-Doa7LKwf.js → chunk-QZHKN3VN-CqLDirEI.js} +1 -1
- package/dist/deck-client/assets/{chunk-YZCP3GAM-CpkIlH6V.js → chunk-YZCP3GAM-_FQvmMs4.js} +1 -1
- package/dist/deck-client/assets/classDiagram-6PBFFD2Q-lIZMp57W.js +1 -0
- package/dist/deck-client/assets/classDiagram-v2-HSJHXN6E-lIZMp57W.js +1 -0
- package/dist/deck-client/assets/clone-BtWeSTyJ.js +1 -0
- package/dist/deck-client/assets/{cose-bilkent-S5V4N54A-Bkh8Bfcb.js → cose-bilkent-S5V4N54A-rfrocesE.js} +1 -1
- package/dist/deck-client/assets/{dagre-KV5264BT-Bp0XpTgH.js → dagre-KV5264BT-Bv_7DJat.js} +1 -1
- package/dist/deck-client/assets/{diagram-5BDNPKRD-ZHiyGYPQ.js → diagram-5BDNPKRD-4F1414G5.js} +1 -1
- package/dist/deck-client/assets/{diagram-G4DWMVQ6-BW-Q8_H5.js → diagram-G4DWMVQ6-C4-Pszqm.js} +1 -1
- package/dist/deck-client/assets/{diagram-MMDJMWI5-6I3LTafu.js → diagram-MMDJMWI5-B647TIx9.js} +1 -1
- package/dist/deck-client/assets/{diagram-TYMM5635-CyM5YK28.js → diagram-TYMM5635-BFAqpezd.js} +1 -1
- package/dist/deck-client/assets/{erDiagram-SMLLAGMA-CjNxVJHk.js → erDiagram-SMLLAGMA-BfBfrJOC.js} +1 -1
- package/dist/deck-client/assets/{flowDiagram-DWJPFMVM-BDQHuAJR.js → flowDiagram-DWJPFMVM-DX9YAYes.js} +1 -1
- package/dist/deck-client/assets/{ganttDiagram-T4ZO3ILL-B7MnkpbP.js → ganttDiagram-T4ZO3ILL-DCuiy7wF.js} +1 -1
- package/dist/deck-client/assets/{gitGraphDiagram-UUTBAWPF-C9dZAcYD.js → gitGraphDiagram-UUTBAWPF-CGp1IXUh.js} +1 -1
- package/dist/deck-client/assets/{graph-CjdBnzUy.js → graph-B7g8aoxv.js} +1 -1
- package/dist/deck-client/assets/{index-DeIVPW63.js → index-Dg1r-WSN.js} +3 -3
- package/dist/deck-client/assets/index-DsIZ3LqL.css +1 -0
- package/dist/deck-client/assets/{infoDiagram-42DDH7IO-C7d3iRC3.js → infoDiagram-42DDH7IO-L3fahMkF.js} +1 -1
- package/dist/deck-client/assets/{ishikawaDiagram-UXIWVN3A-BcYGKj09.js → ishikawaDiagram-UXIWVN3A-aS_EjWBZ.js} +1 -1
- package/dist/deck-client/assets/{journeyDiagram-VCZTEJTY-DqFlRrOL.js → journeyDiagram-VCZTEJTY-djTSQZF9.js} +1 -1
- package/dist/deck-client/assets/{kanban-definition-6JOO6SKY-BJhPp1NR.js → kanban-definition-6JOO6SKY-CcTHo4CM.js} +1 -1
- package/dist/deck-client/assets/{layout-DIeS6GvK.js → layout-mEJiadb7.js} +1 -1
- package/dist/deck-client/assets/{linear-He_yJy5H.js → linear-XgTKqyRu.js} +1 -1
- package/dist/deck-client/assets/{min-DQ6Kx06t.js → min-Ct9jZdpd.js} +1 -1
- package/dist/deck-client/assets/{mindmap-definition-QFDTVHPH-sQ62L8T2.js → mindmap-definition-QFDTVHPH-BaFxCGNU.js} +1 -1
- package/dist/deck-client/assets/{pieDiagram-DEJITSTG-BqCWmU2K.js → pieDiagram-DEJITSTG-CIbYYjtw.js} +1 -1
- package/dist/deck-client/assets/{quadrantDiagram-34T5L4WZ-rQ1TJOoe.js → quadrantDiagram-34T5L4WZ-D9EtCOvh.js} +1 -1
- package/dist/deck-client/assets/{requirementDiagram-MS252O5E-BO2MPBOM.js → requirementDiagram-MS252O5E-xeni9eVG.js} +1 -1
- package/dist/deck-client/assets/{sankeyDiagram-XADWPNL6-BgsHEVex.js → sankeyDiagram-XADWPNL6-LYeknz9h.js} +1 -1
- package/dist/deck-client/assets/{sequenceDiagram-FGHM5R23-B3j1yMLU.js → sequenceDiagram-FGHM5R23-RDbsKFZf.js} +1 -1
- package/dist/deck-client/assets/{stateDiagram-FHFEXIEX-C8jFlZou.js → stateDiagram-FHFEXIEX-BH1Zjglk.js} +1 -1
- package/dist/deck-client/assets/stateDiagram-v2-QKLJ7IA2-BrV78NDR.js +1 -0
- package/dist/deck-client/assets/{timeline-definition-GMOUNBTQ-tM-qo4Zk.js → timeline-definition-GMOUNBTQ-IFXxKptt.js} +1 -1
- package/dist/deck-client/assets/{vennDiagram-DHZGUBPP-B0-6kOEu.js → vennDiagram-DHZGUBPP-D-sLkQs9.js} +1 -1
- package/dist/deck-client/assets/{wardley-RL74JXVD-HpBk07P-.js → wardley-RL74JXVD-C010F8l4.js} +1 -1
- package/dist/deck-client/assets/{wardleyDiagram-NUSXRM2D-BkA1NLDE.js → wardleyDiagram-NUSXRM2D-BTjjuDU3.js} +1 -1
- package/dist/deck-client/assets/{xychartDiagram-5P7HB3ND-CEKGSuI-.js → xychartDiagram-5P7HB3ND-AYbv92n-.js} +1 -1
- package/dist/deck-client/index.html +2 -2
- package/dist/server/chart-serve.js +3836 -3750
- package/dist/server/cli.js +8746 -8224
- package/dist/server/council-entry.js +17 -5
- package/dist/server/council-serve.js +8 -3
- package/dist/server/deck-mcp-entry.js +24 -12
- package/dist/server/deck-serve.js +11 -8
- package/dist/server/fb-wizard.js +0 -0
- package/dist/server/graph-mcp-entry.js +5005 -4865
- package/dist/server/init-entry.js +609 -0
- package/dist/server/orbit-entry.js +2272 -0
- package/dist/server/parse-worker-entry.js +4721 -0
- package/dist/server/recall-entry.js +356 -18
- package/package.json +29 -23
- package/scaffolds/migrate-safety/.github/workflows/backup-on-migration.yml +72 -0
- package/scaffolds/migrate-safety/docs/migrations-runbook.md +172 -0
- package/scaffolds/migrate-safety/scripts/migrate-with-backup.sh +294 -0
- package/dist/chart-client/assets/index-DpaGa3bY.css +0 -1
- package/dist/client/assets/index-Bfel4OQ5.css +0 -32
- package/dist/client/assets/index-eC-WuUWB.js +0 -291
- package/dist/council-client/assets/index-P5kMsT5a.css +0 -1
- package/dist/deck-client/assets/channel-B4aNO8ZB.js +0 -1
- package/dist/deck-client/assets/classDiagram-6PBFFD2Q-BHTI0yWz.js +0 -1
- package/dist/deck-client/assets/classDiagram-v2-HSJHXN6E-BHTI0yWz.js +0 -1
- package/dist/deck-client/assets/clone-HduFm7qU.js +0 -1
- package/dist/deck-client/assets/index-LKZDAS9S.css +0 -1
- package/dist/deck-client/assets/stateDiagram-v2-QKLJ7IA2-BoqepHW0.js +0 -1
- /package/dist/council-client/assets/{index-Cs_MVXHf.js → index-Dt4zWKSj.js} +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Migrations runbook
|
|
2
|
+
|
|
3
|
+
Operational reference for the three-layer migration safety system. Read this before running a migration in prod, and again when something has gone wrong.
|
|
4
|
+
|
|
5
|
+
## The three layers (recap)
|
|
6
|
+
|
|
7
|
+
| Layer | What | Where defined |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| 1 — Process | Expand-and-contract: never combine "add + backfill + drop" in one migration. Two PRs, two deploys. | `CLAUDE.md` |
|
|
10
|
+
| 2 — Tooling | `scripts/migrate-with-backup.sh` — pg_dump before every `prisma migrate`, abort if dump fails. | `scripts/migrate-with-backup.sh` |
|
|
11
|
+
| 3 — In-migration SQL | Pre-flight count + abort-on-orphan + sidecar backup table before any column drop. | `CLAUDE.md` |
|
|
12
|
+
|
|
13
|
+
Recovery never depends on a single layer. PITR (if your provider has it) is a fourth, continuous layer underneath.
|
|
14
|
+
|
|
15
|
+
## Routine: running a migration
|
|
16
|
+
|
|
17
|
+
### Local (against `.env.testing` or `.env`)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Always use these — they wrap pg_dump around prisma migrate.
|
|
21
|
+
npm run db:migrate # = prisma migrate dev (creates a new migration)
|
|
22
|
+
npm run db:migrate:deploy # = prisma migrate deploy (applies pending migrations)
|
|
23
|
+
npm run db:backup # dump only, no migration (manual snapshot)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Backups land in `.backups/pre-migrate-<mode>-<YYYYMMDD-HHMMSS>-<git-sha>.sql.gz`. The directory is gitignored. Prune old ones manually when convenient.
|
|
27
|
+
|
|
28
|
+
If your local `pg_dump` is older than the server, the wrapper auto-falls-back to a Docker container with `postgres:16`. Override the image if your server is on a different major:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
PG_DUMP_DOCKER_IMAGE=postgres:15 PG_DUMP_VIA_DOCKER=1 npm run db:backup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Production (Vercel build)
|
|
35
|
+
|
|
36
|
+
The Vercel `build` script still runs `prisma migrate deploy` directly — the *prod backup* is decoupled from the Vercel build and runs on GitHub Actions instead (see `.github/workflows/backup-on-migration.yml`). Whenever a commit on `master` or `implementation` touches `prisma/migrations/**` or `prisma/schema.prisma`, the workflow runs the same wrapper in `backup-only` mode against `PROD_DATABASE_URL` and uploads the dump as a GHA artifact.
|
|
37
|
+
|
|
38
|
+
**One-time setup before the workflow can run**:
|
|
39
|
+
|
|
40
|
+
1. Repo → Settings → Secrets and variables → Actions → "New repository secret"
|
|
41
|
+
2. Name: `PROD_DATABASE_URL`. Value: full Postgres URL of prod (with credentials).
|
|
42
|
+
3. Confirm the workflow file is on `master`. The first migration commit afterward will trigger it.
|
|
43
|
+
|
|
44
|
+
## Recovery
|
|
45
|
+
|
|
46
|
+
### Find the right backup
|
|
47
|
+
|
|
48
|
+
**Local backups** — listed by timestamp + commit SHA:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
ls -lt .backups/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Prod backups (GHA artifacts)**:
|
|
55
|
+
|
|
56
|
+
- Web UI: repo → **Actions** tab → "Backup prod DB before migration deploys" workflow → click the run for the relevant commit → scroll to "Artifacts" at the bottom → download the `.zip`.
|
|
57
|
+
- CLI:
|
|
58
|
+
```bash
|
|
59
|
+
gh run list --workflow=backup-on-migration.yml --limit 10
|
|
60
|
+
gh run download <run-id> -n prod-db-backup-<commit-sha>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
GHA artifacts are retained for 90 days by default (configurable in the workflow file).
|
|
64
|
+
|
|
65
|
+
### Restore commands
|
|
66
|
+
|
|
67
|
+
The dump format is identical regardless of where it came from (local or GHA), so the restore commands work for both. Decompress with `gunzip -c` (portable; macOS `zcat` does NOT decompress `.gz`).
|
|
68
|
+
|
|
69
|
+
**1. Inspect a dump without restoring:**
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
gunzip -c .backups/pre-migrate-deploy-<ts>-<sha>.sql.gz | head -50
|
|
73
|
+
gunzip -c .backups/pre-migrate-deploy-<ts>-<sha>.sql.gz | grep -E '^(CREATE TABLE|INSERT INTO "Project")' | head
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**2. Restore to a fresh scratch database (for forensics, dry-run, or recovering a specific row):**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Create a scratch DB (psql must point at a server you control; do NOT do this against prod)
|
|
80
|
+
createdb scratch_restore
|
|
81
|
+
# Or via psql: psql -c 'CREATE DATABASE scratch_restore' "$ADMIN_DB_URL"
|
|
82
|
+
|
|
83
|
+
gunzip -c .backups/pre-migrate-deploy-<ts>-<sha>.sql.gz \
|
|
84
|
+
| psql "postgresql://USER:PASS@HOST:PORT/scratch_restore"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Now you have an exact copy of the pre-migration state. Inspect, copy specific rows back to prod, or run the migration against the scratch DB to verify what *would* happen.
|
|
88
|
+
|
|
89
|
+
**3. Full prod rollback (last resort — see provider section below first):**
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# DESTRUCTIVE — drops the existing schema and replaces it with the dumped contents.
|
|
93
|
+
# Only do this if PITR is unavailable AND you've decided a full rollback is the right call.
|
|
94
|
+
# The dump uses --no-owner --no-privileges, so Postgres roles are NOT modified.
|
|
95
|
+
|
|
96
|
+
# 1. Take a current dump first (in case rollback itself goes wrong)
|
|
97
|
+
DATABASE_URL="$PROD_DATABASE_URL" npm run db:backup
|
|
98
|
+
|
|
99
|
+
# 2. Drop and recreate public schema
|
|
100
|
+
psql "$PROD_DATABASE_URL" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
|
101
|
+
|
|
102
|
+
# 3. Restore from the pre-migration dump
|
|
103
|
+
gunzip -c .backups/pre-migrate-deploy-<ts>-<sha>.sql.gz | psql "$PROD_DATABASE_URL"
|
|
104
|
+
|
|
105
|
+
# 4. Re-run prisma migrate so the migrations table reflects current state
|
|
106
|
+
DATABASE_URL="$PROD_DATABASE_URL" npx prisma migrate resolve --applied <last-good-migration>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Provider-specific recovery (preferred over manual restore when available)
|
|
110
|
+
|
|
111
|
+
| Provider | Recovery feature | Notes |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| Neon | Branch from PITR | Console → Branches → "Create branch from a previous state" → pick a timestamp before the bad migration. Update `DATABASE_URL` to the new branch. Free tier: 7-day retention. |
|
|
114
|
+
| Vercel Postgres | Same as Neon (it's Neon) | Use the Vercel dashboard or Neon console. |
|
|
115
|
+
| Supabase | Daily snapshots (paid tiers) | Dashboard → Database → Backups. |
|
|
116
|
+
| RDS / Aurora | Automated snapshots + PITR | AWS console. PITR up to backup retention period. |
|
|
117
|
+
| Self-hosted | Whatever you set up | Hopefully `pg_basebackup` + WAL archiving. |
|
|
118
|
+
|
|
119
|
+
**Rule of thumb**: provider PITR > GHA artifact > local `.backups/`. Try them in that order — PITR is closest to "the exact moment before things broke," GHA artifacts are the moment before deploy, local backups are whoever ran the last migration on their laptop.
|
|
120
|
+
|
|
121
|
+
## Dry-running a migration before merging
|
|
122
|
+
|
|
123
|
+
Before merging a destructive migration PR, restore a fresh GHA artifact (or a `pg_dump` you took manually against prod) into a scratch DB and run the migration there. Check row counts before/after. If the wrapper aborts on orphan check, the migration is broken — fix the SQL.
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Pull most recent prod backup
|
|
127
|
+
gh run download $(gh run list --workflow=backup-on-migration.yml --limit 1 --json databaseId -q '.[0].databaseId') \
|
|
128
|
+
-n prod-db-backup-<sha> -D /tmp/prod-snapshot
|
|
129
|
+
|
|
130
|
+
# Spin up a scratch DB and restore
|
|
131
|
+
createdb migration_dryrun
|
|
132
|
+
gunzip -c /tmp/prod-snapshot/*.sql.gz | psql "postgresql://localhost/migration_dryrun"
|
|
133
|
+
|
|
134
|
+
# Apply the pending migration
|
|
135
|
+
DATABASE_URL="postgresql://localhost/migration_dryrun" \
|
|
136
|
+
bash scripts/migrate-with-backup.sh deploy
|
|
137
|
+
|
|
138
|
+
# Spot-check: did the rows you expected get backfilled?
|
|
139
|
+
psql "postgresql://localhost/migration_dryrun" -c 'SELECT count(*) FROM "Project" WHERE ...;'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
If the dry-run looks right, merge. If it doesn't, fix the migration and try again.
|
|
143
|
+
|
|
144
|
+
## Security
|
|
145
|
+
|
|
146
|
+
**Treat dump files like prod credentials.** They contain:
|
|
147
|
+
|
|
148
|
+
- Hashed passwords (still attackable offline)
|
|
149
|
+
- OAuth tokens stored in `Integration.metadata` — these are live credentials for Vercel, GitHub, Neon, etc. Anyone with the dump can impersonate the integration.
|
|
150
|
+
- User PII (emails, names, anything stored in user-facing tables)
|
|
151
|
+
|
|
152
|
+
**Therefore:**
|
|
153
|
+
|
|
154
|
+
- `.backups/` is gitignored and must stay that way. Never commit a `.sql.gz` from this directory.
|
|
155
|
+
- GHA artifacts inherit the repo's access — only collaborators on the repo can download them. If you make the repo public or add an external collaborator, they get historical artifacts too. Audit before changing access.
|
|
156
|
+
- When sharing a dump for debugging, scrub the sensitive tables first (`Integration`, `Account`, `Session`, anything with tokens). A quick scrub:
|
|
157
|
+
```bash
|
|
158
|
+
gunzip -c backup.sql.gz | grep -v '^COPY public."Integration"' | gzip > backup-scrubbed.sql.gz
|
|
159
|
+
```
|
|
160
|
+
(This is a blunt instrument — it drops the entire `Integration` COPY block. Inspect before sharing.)
|
|
161
|
+
- Rotate any tokens that may have been exposed if a dump leaks. Vercel/GitHub/Neon integration tokens can all be revoked from their respective dashboards.
|
|
162
|
+
|
|
163
|
+
## Adding support for a new DB engine
|
|
164
|
+
|
|
165
|
+
Today the wrapper supports Postgres only. To add MySQL/MongoDB/etc.:
|
|
166
|
+
|
|
167
|
+
1. Add a case branch in `scripts/migrate-with-backup.sh` matching the URL scheme (`mysql://`, `mongodb://`, etc.).
|
|
168
|
+
2. Use the appropriate dump tool (`mysqldump`, `mongodump`).
|
|
169
|
+
3. Update the sanity check to recognize the new dump format.
|
|
170
|
+
4. Update this runbook with restore commands for the new engine.
|
|
171
|
+
|
|
172
|
+
The orchestration logic (dump → validate → migrate, abort on dump failure) stays identical.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# scripts/migrate-with-backup.sh — single source of truth for "no migration without a backup"
|
|
4
|
+
#
|
|
5
|
+
# Modes:
|
|
6
|
+
# dev → dump DB, then run `prisma migrate dev` (passes extra args through)
|
|
7
|
+
# deploy → dump DB, then run `prisma migrate deploy`
|
|
8
|
+
# backup-only → dump DB, do not migrate (used by GHA pre-deploy workflow)
|
|
9
|
+
#
|
|
10
|
+
# Env:
|
|
11
|
+
# DATABASE_URL (required) — target DB. Wrapper does not load .env files;
|
|
12
|
+
# caller is responsible (matches Prisma's behavior).
|
|
13
|
+
# BACKUP_DIR (default: .backups) — where the dump file is written.
|
|
14
|
+
# MIGRATE_SKIP_BACKUP (default: 0) — set to 1 to skip the dump step entirely.
|
|
15
|
+
# Use sparingly — e.g. fresh shadow DB seeding
|
|
16
|
+
# where there is provably no data to lose.
|
|
17
|
+
# PG_DUMP_VIA_DOCKER (default: auto) — 1=force docker, 0=force local, auto=try
|
|
18
|
+
# local first then fall back to docker on
|
|
19
|
+
# version mismatch or missing pg_dump.
|
|
20
|
+
# PG_DUMP_DOCKER_IMAGE (default: auto-detect from server) — image used for docker
|
|
21
|
+
# fallback. When unset, the wrapper probes
|
|
22
|
+
# the server's major version (via local psql
|
|
23
|
+
# or `docker run postgres:latest psql`) and
|
|
24
|
+
# uses `postgres:<major>`. Set explicitly to
|
|
25
|
+
# override (e.g. internal hardened image).
|
|
26
|
+
# PG_PROBE_DOCKER_IMAGE (default: postgres:latest) — image used only for the
|
|
27
|
+
# version probe; psql is forward/backward
|
|
28
|
+
# compatible for SHOW server_version.
|
|
29
|
+
#
|
|
30
|
+
# Output:
|
|
31
|
+
# $BACKUP_DIR/pre-migrate-<mode>-<timestamp>-<git-sha>.sql.gz
|
|
32
|
+
# The dump uses --no-owner --no-privileges so it restores cleanly across providers.
|
|
33
|
+
#
|
|
34
|
+
# Retention:
|
|
35
|
+
# After a successful backup, the wrapper sweeps $BACKUP_DIR for files matching
|
|
36
|
+
# pre-migrate-*.sql.gz older than 30 days and deletes them. Best-effort —
|
|
37
|
+
# failures are non-fatal. Prod backups are GHA workflow artifacts with their
|
|
38
|
+
# own 90-day retention and are unaffected.
|
|
39
|
+
#
|
|
40
|
+
# Failure model:
|
|
41
|
+
# If the dump fails for any reason, the migration is NOT run, and any partial
|
|
42
|
+
# .sql.gz file is removed. Exit code is non-zero.
|
|
43
|
+
#
|
|
44
|
+
# Engine support:
|
|
45
|
+
# Today: PostgreSQL (URL schemes postgres:// or postgresql://).
|
|
46
|
+
# The dispatcher below makes adding mysql:// or other engines a one-case branch.
|
|
47
|
+
|
|
48
|
+
set -euo pipefail
|
|
49
|
+
|
|
50
|
+
# ─── Argument parsing ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
mode="${1:-}"
|
|
53
|
+
shift || true
|
|
54
|
+
|
|
55
|
+
if [[ -z "$mode" ]]; then
|
|
56
|
+
echo "usage: $0 <dev|deploy|backup-only> [extra prisma args...]" >&2
|
|
57
|
+
exit 2
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
case "$mode" in
|
|
61
|
+
dev|deploy|backup-only) ;;
|
|
62
|
+
*)
|
|
63
|
+
echo "error: mode must be 'dev', 'deploy', or 'backup-only' (got '$mode')" >&2
|
|
64
|
+
exit 2
|
|
65
|
+
;;
|
|
66
|
+
esac
|
|
67
|
+
|
|
68
|
+
if [[ -z "${DATABASE_URL:-}" ]]; then
|
|
69
|
+
echo "error: DATABASE_URL is not set" >&2
|
|
70
|
+
exit 2
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
backup_dir="${BACKUP_DIR:-.backups}"
|
|
74
|
+
skip_backup="${MIGRATE_SKIP_BACKUP:-0}"
|
|
75
|
+
|
|
76
|
+
# ─── Cleanup: remove partial dumps + temp files on any non-zero exit ──────────
|
|
77
|
+
|
|
78
|
+
err_log="$(mktemp)"
|
|
79
|
+
backup_file=""
|
|
80
|
+
dump_validated=""
|
|
81
|
+
|
|
82
|
+
_cleanup() {
|
|
83
|
+
local rc=$?
|
|
84
|
+
rm -f "$err_log"
|
|
85
|
+
if [[ $rc -ne 0 && -n "$backup_file" && -f "$backup_file" && -z "$dump_validated" ]]; then
|
|
86
|
+
rm -f "$backup_file"
|
|
87
|
+
fi
|
|
88
|
+
exit $rc
|
|
89
|
+
}
|
|
90
|
+
trap _cleanup EXIT
|
|
91
|
+
|
|
92
|
+
# ─── 1. Take the backup (unless explicitly skipped) ───────────────────────────
|
|
93
|
+
|
|
94
|
+
if [[ "$skip_backup" == "1" ]]; then
|
|
95
|
+
if [[ "$mode" == "backup-only" ]]; then
|
|
96
|
+
echo "error: MIGRATE_SKIP_BACKUP=1 with mode=backup-only is contradictory" >&2
|
|
97
|
+
exit 2
|
|
98
|
+
fi
|
|
99
|
+
echo "[migrate-with-backup] MIGRATE_SKIP_BACKUP=1 — skipping pre-flight dump"
|
|
100
|
+
else
|
|
101
|
+
mkdir -p "$backup_dir"
|
|
102
|
+
|
|
103
|
+
ts="$(date +%Y%m%d-%H%M%S)"
|
|
104
|
+
sha="$(git rev-parse --short HEAD 2>/dev/null || echo nogit)"
|
|
105
|
+
backup_file="${backup_dir}/pre-migrate-${mode}-${ts}-${sha}.sql.gz"
|
|
106
|
+
|
|
107
|
+
# Dispatch by URL scheme so non-Postgres engines can be added later.
|
|
108
|
+
scheme="${DATABASE_URL%%:*}"
|
|
109
|
+
case "$scheme" in
|
|
110
|
+
postgres|postgresql)
|
|
111
|
+
# ── Strip Prisma-only query params that pg_dump (libpq) rejects ──
|
|
112
|
+
# Keep libpq params (sslmode, sslcert, application_name, etc.) so cloud DBs still connect.
|
|
113
|
+
sanitized_url="$DATABASE_URL"
|
|
114
|
+
query="${sanitized_url#*\?}"
|
|
115
|
+
if [[ "$query" != "$sanitized_url" ]]; then
|
|
116
|
+
base="${sanitized_url%%\?*}"
|
|
117
|
+
kept=""
|
|
118
|
+
IFS='&' read -ra _params <<< "$query"
|
|
119
|
+
for _p in "${_params[@]}"; do
|
|
120
|
+
_key="${_p%%=*}"
|
|
121
|
+
case "$_key" in
|
|
122
|
+
schema|pgbouncer|connection_limit|pool_timeout|socket_timeout|statement_cache_size)
|
|
123
|
+
;; # Prisma-only — drop
|
|
124
|
+
*)
|
|
125
|
+
if [[ -n "$kept" ]]; then kept="${kept}&${_p}"; else kept="$_p"; fi
|
|
126
|
+
;;
|
|
127
|
+
esac
|
|
128
|
+
done
|
|
129
|
+
if [[ -n "$kept" ]]; then
|
|
130
|
+
sanitized_url="${base}?${kept}"
|
|
131
|
+
else
|
|
132
|
+
sanitized_url="$base"
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Docker-mode URL: localhost/127.0.0.1 → host.docker.internal so a containerized
|
|
137
|
+
# client can reach a Postgres running on the host machine.
|
|
138
|
+
docker_url=$(printf '%s' "$sanitized_url" | sed -E 's#@(localhost|127\.0\.0\.1)([:/])#@host.docker.internal\2#')
|
|
139
|
+
|
|
140
|
+
mode_choice="${PG_DUMP_VIA_DOCKER:-auto}"
|
|
141
|
+
probe_image="${PG_PROBE_DOCKER_IMAGE:-postgres:latest}"
|
|
142
|
+
|
|
143
|
+
# ── Server-version detection (sets docker_image when not user-overridden) ──
|
|
144
|
+
_detect_server_major() {
|
|
145
|
+
# Returns Postgres major version (e.g. "17") on stdout, empty if probe fails.
|
|
146
|
+
local result=""
|
|
147
|
+
# Try local psql first (faster, no docker pull).
|
|
148
|
+
if command -v psql >/dev/null 2>&1; then
|
|
149
|
+
result=$(psql "$sanitized_url" -t -A -c "SHOW server_version" 2>/dev/null \
|
|
150
|
+
| head -n 1 | sed -E 's/^[^0-9]*([0-9]+).*/\1/')
|
|
151
|
+
fi
|
|
152
|
+
# Fall back to a containerized psql when local isn't installed or can't connect.
|
|
153
|
+
if [[ -z "$result" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
154
|
+
result=$(docker run --rm \
|
|
155
|
+
--add-host=host.docker.internal:host-gateway \
|
|
156
|
+
"$probe_image" \
|
|
157
|
+
psql "$docker_url" -t -A -c "SHOW server_version" 2>/dev/null \
|
|
158
|
+
| head -n 1 | sed -E 's/^[^0-9]*([0-9]+).*/\1/')
|
|
159
|
+
fi
|
|
160
|
+
printf '%s' "$result"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if [[ -n "${PG_DUMP_DOCKER_IMAGE:-}" ]]; then
|
|
164
|
+
docker_image="$PG_DUMP_DOCKER_IMAGE"
|
|
165
|
+
echo "[migrate-with-backup] using user-pinned docker image: ${docker_image}"
|
|
166
|
+
else
|
|
167
|
+
detected_major="$(_detect_server_major)"
|
|
168
|
+
if [[ -n "$detected_major" ]]; then
|
|
169
|
+
docker_image="postgres:${detected_major}"
|
|
170
|
+
echo "[migrate-with-backup] detected postgres ${detected_major}.x server → docker image: ${docker_image}"
|
|
171
|
+
else
|
|
172
|
+
# Probe failed — fall back to "latest" and let pg_dump's own error guide the user.
|
|
173
|
+
docker_image="postgres:latest"
|
|
174
|
+
echo "[migrate-with-backup] could not detect server version, using ${docker_image} (set PG_DUMP_DOCKER_IMAGE to override)" >&2
|
|
175
|
+
fi
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
# ── Dump strategies ──
|
|
179
|
+
|
|
180
|
+
_try_local() {
|
|
181
|
+
if ! command -v pg_dump >/dev/null 2>&1; then return 127; fi
|
|
182
|
+
echo "[migrate-with-backup] dumping postgres (local pg_dump) → ${backup_file}"
|
|
183
|
+
pg_dump --no-owner --no-privileges "$sanitized_url" 2>"$err_log" | gzip > "$backup_file"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_try_docker() {
|
|
187
|
+
if ! command -v docker >/dev/null 2>&1; then return 127; fi
|
|
188
|
+
if ! docker info >/dev/null 2>&1; then return 126; fi
|
|
189
|
+
echo "[migrate-with-backup] dumping postgres (docker ${docker_image}) → ${backup_file}"
|
|
190
|
+
docker run --rm -i \
|
|
191
|
+
--add-host=host.docker.internal:host-gateway \
|
|
192
|
+
"$docker_image" \
|
|
193
|
+
pg_dump --no-owner --no-privileges "$docker_url" 2>"$err_log" \
|
|
194
|
+
| gzip > "$backup_file"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_hint_install() {
|
|
198
|
+
echo "" >&2
|
|
199
|
+
echo "Fix one of:" >&2
|
|
200
|
+
echo " 1. Install matching pg_dump locally:" >&2
|
|
201
|
+
echo " brew install postgresql@${detected_major:-17} && brew link --force postgresql@${detected_major:-17} # macOS" >&2
|
|
202
|
+
echo " sudo apt-get install postgresql-client-${detected_major:-17} # Debian/Ubuntu" >&2
|
|
203
|
+
echo " 2. Force docker fallback (auto-detects server version):" >&2
|
|
204
|
+
echo " PG_DUMP_VIA_DOCKER=1 npm run db:backup" >&2
|
|
205
|
+
echo " 3. Pin a specific image (overrides auto-detection):" >&2
|
|
206
|
+
echo " PG_DUMP_DOCKER_IMAGE=postgres:<major> npm run db:backup" >&2
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "$mode_choice" in
|
|
210
|
+
1)
|
|
211
|
+
if ! _try_docker; then
|
|
212
|
+
echo "error: PG_DUMP_VIA_DOCKER=1 but docker pg_dump failed" >&2
|
|
213
|
+
cat "$err_log" >&2
|
|
214
|
+
_hint_install
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
;;
|
|
218
|
+
0)
|
|
219
|
+
if ! _try_local; then
|
|
220
|
+
echo "error: local pg_dump failed (PG_DUMP_VIA_DOCKER=0 disables docker fallback)" >&2
|
|
221
|
+
cat "$err_log" >&2
|
|
222
|
+
_hint_install
|
|
223
|
+
exit 1
|
|
224
|
+
fi
|
|
225
|
+
;;
|
|
226
|
+
auto)
|
|
227
|
+
if _try_local; then
|
|
228
|
+
: # success
|
|
229
|
+
elif grep -qE "server version|version mismatch|command not found" "$err_log" 2>/dev/null \
|
|
230
|
+
|| [[ ! -s "$backup_file" ]]; then
|
|
231
|
+
local_err="$(cat "$err_log")"
|
|
232
|
+
echo "[migrate-with-backup] local pg_dump unusable; trying docker fallback..." >&2
|
|
233
|
+
[[ -n "$local_err" ]] && echo " (local error: ${local_err})" >&2
|
|
234
|
+
: > "$backup_file" # reset partial output before retry
|
|
235
|
+
if ! _try_docker; then
|
|
236
|
+
echo "error: docker fallback failed" >&2
|
|
237
|
+
cat "$err_log" >&2
|
|
238
|
+
_hint_install
|
|
239
|
+
exit 1
|
|
240
|
+
fi
|
|
241
|
+
else
|
|
242
|
+
echo "error: pg_dump failed:" >&2
|
|
243
|
+
cat "$err_log" >&2
|
|
244
|
+
exit 1
|
|
245
|
+
fi
|
|
246
|
+
;;
|
|
247
|
+
*)
|
|
248
|
+
echo "error: PG_DUMP_VIA_DOCKER must be 0, 1, or auto (got '$mode_choice')" >&2
|
|
249
|
+
exit 2
|
|
250
|
+
;;
|
|
251
|
+
esac
|
|
252
|
+
|
|
253
|
+
# ── Sanity check ──
|
|
254
|
+
# gzip must produce a non-empty file with a real pg_dump header. Capture the
|
|
255
|
+
# first chunk into a variable rather than chaining `gunzip | head | grep`:
|
|
256
|
+
# under `pipefail`, `head` closing early sends SIGPIPE to gunzip, making the
|
|
257
|
+
# pipeline return non-zero even when grep matched. Capturing breaks the pipe.
|
|
258
|
+
# Use `gunzip -c` (portable) — macOS `zcat` is BSD compress, doesn't decompress gzip.
|
|
259
|
+
header_block="$(gunzip -c "$backup_file" 2>/dev/null | head -10 || true)"
|
|
260
|
+
if [[ ! -s "$backup_file" ]] || ! grep -q "PostgreSQL database dump" <<<"$header_block"; then
|
|
261
|
+
echo "error: dump file is empty or missing PostgreSQL header — refusing to migrate" >&2
|
|
262
|
+
echo " backup_file: $backup_file ($(du -h "$backup_file" 2>/dev/null | cut -f1 || echo 'missing'))" >&2
|
|
263
|
+
exit 1
|
|
264
|
+
fi
|
|
265
|
+
dump_validated=1
|
|
266
|
+
;;
|
|
267
|
+
*)
|
|
268
|
+
echo "error: unsupported DATABASE_URL scheme '${scheme}://' — wrapper currently only supports postgres" >&2
|
|
269
|
+
echo " to add support, extend the case statement in $(basename "$0")" >&2
|
|
270
|
+
exit 2
|
|
271
|
+
;;
|
|
272
|
+
esac
|
|
273
|
+
|
|
274
|
+
size="$(du -h "$backup_file" | cut -f1)"
|
|
275
|
+
echo "[migrate-with-backup] dump complete (${size}) → ${backup_file}"
|
|
276
|
+
|
|
277
|
+
# ─── Prune local backups older than 30 days ────────────────────────────────
|
|
278
|
+
# Best-effort, non-fatal. Prod backups live as GHA artifacts with their own
|
|
279
|
+
# 90-day retention; this only sweeps the local .backups/ dir.
|
|
280
|
+
pruned=$(find "$backup_dir" -maxdepth 1 -type f -name 'pre-migrate-*.sql.gz' -mtime +30 -print -delete 2>/dev/null | wc -l | tr -d ' ' || true)
|
|
281
|
+
if [[ "${pruned:-0}" -gt 0 ]]; then
|
|
282
|
+
echo "[migrate-with-backup] pruned ${pruned} backup(s) older than 30 days"
|
|
283
|
+
fi
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
# ─── 2. Run the migration (skipped in backup-only mode) ───────────────────────
|
|
287
|
+
|
|
288
|
+
if [[ "$mode" == "backup-only" ]]; then
|
|
289
|
+
echo "[migrate-with-backup] backup-only mode — skipping prisma migrate"
|
|
290
|
+
exit 0
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
echo "[migrate-with-backup] running: npx prisma migrate ${mode} $*"
|
|
294
|
+
exec npx prisma migrate "$mode" "$@"
|