Skip to content

Commit 6a9c924

Browse files
InfantLabclaude
andcommitted
chore(db): unify dev+prod migrations on migrate.js, fix fresh-install bootstrap
Pre-Android-work housekeeping. Two migration runners coexisted in the repo — `migrate.js` (used by the Docker CMD in production) and `drizzle-kit migrate` (used by `bun run db:migrate` in dev) — with different semantics. They drifted: production worked, dev was flaky, and a fresh local DB couldn't be bootstrapped because 0022 fails on a clean install. Changes: - migrate.js: tolerate "no such table" alongside "duplicate column name" and "already exists". Needed for legacy rename migrations (0022 renames weekly_messages -> system_messages, but on fresh installs the source table never exists because the runtime plugin's ensureTables now creates the target directly). Verified fresh install now completes end-to-end. - migrate.js: fix dev default DB path from app/data/db.sqlite to ../data/db.sqlite so it matches the DATABASE_URL convention used by every other npm script. - package.json: db:migrate now invokes migrate.js (same as production). The old drizzle-kit migrate path used timestamp comparison that drifted from the filename-keyed __drizzle_migrations table and could leave dev DBs in inconsistent states. - meta/_journal.json: added the 10 missing entries (0003_add_performance_indexes, 0011_ontology_rename, 0015_ontology_v040, 0016, 0017, 0018, 0019, 0022, 0023, 0024). Reordered idx so timestamps are monotonic. Also corrected 0014's out-of-band 2025 timestamp. The journal is now only used by drizzle-kit generate, not the runner — but it should still reflect reality. - README: documents the runner, tolerated-error patterns, fresh-install test recipe, and known tech-debt (duplicate-numbered files, plugin-managed tables). Verified: - Fresh DB bootstrap (`rm /tmp/x.sqlite && DATABASE_URL=file:/tmp/x.sqlite bun /workspaces/tada/app/migrate.js`) completes cleanly, 28 migrations applied, 31 tables created. - Existing local dev DB re-run is idempotent. - Production migrate.js Docker path unchanged in semantics (it already used the filename-keyed runner). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80e2119 commit 6a9c924

4 files changed

Lines changed: 161 additions & 22 deletions

File tree

app/migrate.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ const __dirname = dirname(__filename);
1010

1111
// Default database path
1212
// Production: /data/db.sqlite (CapRover persistent volume at /data)
13-
// Dev: /workspaces/tada/app/data/db.sqlite (workspace-relative)
13+
// Dev: <repo>/data/db.sqlite — matches DATABASE_URL=file:../data/db.sqlite
14+
// convention used by package.json scripts (dev, db:migrate, etc.)
1415
const isDockerProduction = __dirname === "/app";
1516
const defaultDbPath = isDockerProduction
1617
? "/data/db.sqlite"
17-
: join(__dirname, "data", "db.sqlite");
18+
: join(__dirname, "..", "data", "db.sqlite");
1819

1920
const DB_PATH = process.env.DATABASE_URL?.replace("file:", "") || defaultDbPath;
2021

@@ -144,14 +145,23 @@ async function runMigrations() {
144145
try {
145146
runSQL(statement);
146147
} catch (err) {
147-
// Handle idempotent migrations: duplicate columns/tables are not fatal
148+
// Tolerated patterns for idempotent re-runs and fresh-install no-ops:
149+
// - "duplicate column name" — ALTER TABLE ADD COLUMN on a column that
150+
// was already added by an earlier overlapping migration.
151+
// - "already exists" — CREATE TABLE/INDEX without IF NOT EXISTS that
152+
// was already created.
153+
// - "no such table" — legacy rename migrations (e.g. 0022 renames
154+
// weekly_messages→system_messages) where the source table never
155+
// existed on a fresh install because the plugin's ensureTables now
156+
// creates the target table directly. Safe to skip.
148157
const msg = err.message || "";
149158
if (
150159
msg.includes("duplicate column name") ||
151-
msg.includes("already exists")
160+
msg.includes("already exists") ||
161+
msg.includes("no such table")
152162
) {
153163
const short = statement.substring(0, 60).replace(/\n/g, " ");
154-
console.log(` ⚠ Skipped (already applied): ${short}...`);
164+
console.log(` ⚠ Skipped (already applied or N/A): ${short}...`);
155165
continue;
156166
}
157167
throw err;

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"test:e2e": "playwright test",
2222
"test:e2e:ui": "playwright test --ui",
2323
"db:generate": "drizzle-kit generate",
24-
"db:migrate": "DATABASE_URL=file:../data/db.sqlite drizzle-kit migrate",
24+
"db:migrate": "DATABASE_URL=file:../data/db.sqlite bun run migrate.js",
2525
"db:studio": "DATABASE_URL=file:../data/db.sqlite drizzle-kit studio",
2626
"admin": "bun run scripts/admin.ts"
2727
},

app/server/db/migrations/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Database migrations
2+
3+
SQL migration files for the Drizzle ORM schema.
4+
5+
## How migrations run
6+
7+
Tada uses **one runner** for both dev and production: [`app/migrate.js`](../../../migrate.js).
8+
9+
| Context | Command |
10+
| --- | --- |
11+
| Local dev | `bun run db:migrate` (in `app/`) — points at `../data/db.sqlite` |
12+
| Production (Docker) | `CMD` in [`Dockerfile`](../../../../Dockerfile)`bun run migrate.js && bun run .output/server/index.mjs` |
13+
14+
The runner reads every `*.sql` file in this folder in **alphabetical order**, checks `__drizzle_migrations` for the **filename** (e.g. `0014_backronyms.sql`), and applies anything new. Already-applied migrations are skipped by name.
15+
16+
To keep migrations safe to re-run, the runner tolerates these errors as no-ops:
17+
18+
- `duplicate column name``ALTER TABLE ADD COLUMN` for a column that's already there.
19+
- `already exists``CREATE TABLE` / `CREATE INDEX` without `IF NOT EXISTS` that's already there.
20+
- `no such table` — legacy `RENAME` migrations where the source table never existed (e.g. fresh installs where `0022_system_messages_rename` has nothing to rename because the runtime plugin's `ensureTables` now creates the target table directly).
21+
22+
This means **migrations should be written defensively** — prefer `CREATE TABLE IF NOT EXISTS`, accept that bare `ALTER TABLE ADD COLUMN` will silently re-skip on re-runs.
23+
24+
## `meta/_journal.json`
25+
26+
Auto-managed by `drizzle-kit generate`. The runner does **not** read it. It exists so `drizzle-kit generate` knows which idx/timestamp to assign to the next migration, and for any developer who wants to use `drizzle-kit migrate` (not recommended — its semantics don't match production).
27+
28+
When adding a migration via `drizzle-kit generate`, the journal is updated automatically. If you create a SQL file by hand, also add a corresponding entry to `_journal.json`.
29+
30+
## Adding a new migration
31+
32+
Preferred:
33+
34+
```bash
35+
cd app && bun run db:generate # drizzle-kit generates 0025_*.sql + journal entry
36+
# review the SQL, edit if needed (add IF NOT EXISTS, idempotency)
37+
bun run db:migrate # applies it locally
38+
```
39+
40+
For hand-written migrations:
41+
42+
1. Create `00NN_descriptive_name.sql` here.
43+
2. Add a matching entry to `meta/_journal.json` with the next `idx` and a sensible `when` (ms timestamp).
44+
3. Run `bun run db:migrate` to apply.
45+
46+
## Testing a fresh install
47+
48+
```bash
49+
rm -f /tmp/fresh.sqlite
50+
DATABASE_URL=file:/tmp/fresh.sqlite bun /workspaces/tada/app/migrate.js
51+
```
52+
53+
Should complete without errors, producing the canonical schema (28 migrations as of 2026-05).
54+
55+
## Known tech-debt
56+
57+
- **Duplicate-numbered files** (`0003`, `0011`, `0015` each have two). Both files in each pair are applied to the live DB by filename, so renaming/deleting them would orphan live's `__drizzle_migrations` entries. Left as-is.
58+
- **`drizzle-kit migrate` semantics drift**. Drizzle's migrator decides what to apply by timestamp, not by file hash; this can leave it out of sync with `__drizzle_migrations` (which the runner populates by filename). That's why we don't use it. Don't run `drizzle-kit migrate` on a live DB.
59+
- **Plugin-managed tables.** `system_messages`, `system_message_deliveries`, `weekly_rhythm_settings`, `weekly_stats_snapshots`, and `push_subscriptions` are created by the runtime plugin [`server/plugins/weekly-rhythms.ts`](../../plugins/weekly-rhythms.ts) (`ensureTables`), not by a migration. Schema drift between the plugin's DDL and the Drizzle schema is possible — keep them in sync.

app/server/db/migrations/meta/_journal.json

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,107 +26,177 @@
2626
{
2727
"idx": 3,
2828
"version": "6",
29+
"when": 1768300000000,
30+
"tag": "0003_add_performance_indexes",
31+
"breakpoints": true
32+
},
33+
{
34+
"idx": 4,
35+
"version": "6",
2936
"when": 1768355787798,
3037
"tag": "0003_fast_brood",
3138
"breakpoints": true
3239
},
3340
{
34-
"idx": 4,
41+
"idx": 5,
3542
"version": "6",
3643
"when": 1768369276605,
3744
"tag": "0004_funny_piledriver",
3845
"breakpoints": true
3946
},
4047
{
41-
"idx": 5,
48+
"idx": 6,
4249
"version": "6",
4350
"when": 1768396329229,
4451
"tag": "0005_eager_vance_astro",
4552
"breakpoints": true
4653
},
4754
{
48-
"idx": 6,
55+
"idx": 7,
4956
"version": "6",
5057
"when": 1768416030417,
5158
"tag": "0006_brainy_cerebro",
5259
"breakpoints": true
5360
},
5461
{
55-
"idx": 7,
62+
"idx": 8,
5663
"version": "6",
5764
"when": 1768657918560,
5865
"tag": "0007_hot_stick",
5966
"breakpoints": true
6067
},
6168
{
62-
"idx": 8,
69+
"idx": 9,
6370
"version": "6",
6471
"when": 1768938180076,
6572
"tag": "0008_superb_ronan",
6673
"breakpoints": true
6774
},
6875
{
69-
"idx": 9,
76+
"idx": 10,
7077
"version": "6",
7178
"when": 1768941558950,
7279
"tag": "0009_wandering_exodus",
7380
"breakpoints": true
7481
},
7582
{
76-
"idx": 10,
83+
"idx": 11,
7784
"version": "6",
7885
"when": 1769338044624,
7986
"tag": "0010_careless_meltdown",
8087
"breakpoints": true
8188
},
8289
{
83-
"idx": 11,
90+
"idx": 12,
91+
"version": "6",
92+
"when": 1769400000000,
93+
"tag": "0011_ontology_rename",
94+
"breakpoints": true
95+
},
96+
{
97+
"idx": 13,
8498
"version": "6",
8599
"when": 1769462196199,
86100
"tag": "0011_steep_tyrannus",
87101
"breakpoints": true
88102
},
89103
{
90-
"idx": 12,
104+
"idx": 14,
91105
"version": "6",
92106
"when": 1769506388680,
93107
"tag": "0012_rainy_paladin",
94108
"breakpoints": true
95109
},
96110
{
97-
"idx": 13,
111+
"idx": 15,
98112
"version": "6",
99113
"when": 1769980622156,
100114
"tag": "0013_tired_ultimatum",
101115
"breakpoints": true
102116
},
103117
{
104-
"idx": 14,
118+
"idx": 16,
105119
"version": "6",
106-
"when": 1738519200000,
120+
"when": 1770000000000,
107121
"tag": "0014_backronyms",
108122
"breakpoints": true
109123
},
110124
{
111-
"idx": 15,
125+
"idx": 17,
112126
"version": "6",
113127
"when": 1770571474962,
114128
"tag": "0015_curious_robbie_robertson",
115129
"breakpoints": true
116130
},
117131
{
118-
"idx": 16,
132+
"idx": 18,
133+
"version": "6",
134+
"when": 1770600000000,
135+
"tag": "0015_ontology_v040",
136+
"breakpoints": true
137+
},
138+
{
139+
"idx": 19,
140+
"version": "6",
141+
"when": 1770700000000,
142+
"tag": "0016_cloud_subscriptions",
143+
"breakpoints": true
144+
},
145+
{
146+
"idx": 20,
147+
"version": "6",
148+
"when": 1770800000000,
149+
"tag": "0017_feedback",
150+
"breakpoints": true
151+
},
152+
{
153+
"idx": 21,
154+
"version": "6",
155+
"when": 1770900000000,
156+
"tag": "0018_newsletter_subscribers",
157+
"breakpoints": true
158+
},
159+
{
160+
"idx": 22,
161+
"version": "6",
162+
"when": 1771000000000,
163+
"tag": "0019_journey_stage_rename",
164+
"breakpoints": true
165+
},
166+
{
167+
"idx": 23,
119168
"version": "6",
120169
"when": 1771340400000,
121170
"tag": "0020_journey_thresholds",
122171
"breakpoints": true
123172
},
124173
{
125-
"idx": 17,
174+
"idx": 24,
126175
"version": "6",
127176
"when": 1773504000000,
128177
"tag": "0021_rate_limits",
129178
"breakpoints": true
179+
},
180+
{
181+
"idx": 25,
182+
"version": "6",
183+
"when": 1774000000000,
184+
"tag": "0022_system_messages_rename",
185+
"breakpoints": true
186+
},
187+
{
188+
"idx": 26,
189+
"version": "6",
190+
"when": 1774100000000,
191+
"tag": "0023_ourmoji_module",
192+
"breakpoints": true
193+
},
194+
{
195+
"idx": 27,
196+
"version": "6",
197+
"when": 1774200000000,
198+
"tag": "0024_ourmoji_invites",
199+
"breakpoints": true
130200
}
131201
]
132-
}
202+
}

0 commit comments

Comments
 (0)