The migration step and the package-owned-table exception — NOT the normal collection flow. For app collections use the crouton CLI/skill (schema JSON → `crouton config`); reach here when you then need to generate/apply the Drizzle migration and hit the schema.mjs-only-after-build gotcha ("No schema files found"), or when adding a package-owned infra table (the crouton-flow pattern) that does NOT go through `crouton config`.
Scanned 9/12/2026
Install to Claude Code
npx -y skills add FriendlyInternet/nuxt-crouton --skill db-migrations --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Db Migrations?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/friendlyinternet-db-migrations)More formats (shields.io, HTML) on the badges page.
---
name: db-migrations
layer: stack
description: The migration step and the package-owned-table exception — NOT the normal collection flow. For app collections use the crouton CLI/skill (schema JSON → `crouton config`); reach here when you then need to generate/apply the Drizzle migration and hit the schema.mjs-only-after-build gotcha ("No schema files found"), or when adding a package-owned infra table (the crouton-flow pattern) that does NOT go through `crouton config`.
allowed-tools: Bash, Read, Grep, Glob, Edit, Write
---
# DB migrations & package-owned tables (Drizzle + NuxtHub + D1)
> **Most tables are app collections — use the `crouton` skill/CLI, not this one.**
> Edit `apps/<app>/schemas/*.json`, ensure it's in `crouton.config.js`, run
> `crouton config`. That generates the layer + Drizzle schema. This skill is for
> the **two things the CLI does *not* do**:
>
> 1. **The migrate step** — turning a schema change (even a CLI-generated one)
> into a committed Drizzle migration, including the gotcha that makes
> `pnpm db:generate` fail on a clean tree.
> 2. **Package-owned infra tables** — non-collection tables shipped by a package
> (`flow_configs`, `yjs_collab_states`, `sales_sync_outbox`) that are
> hand-defined and never touch `crouton config`.
>
> If you're adding a user-facing CRUD collection, stop and use the crouton CLI.
## The pipeline (how a table reaches the DB)
**Since #1445 WS2 there are two shapes.** Which one an app is on is decided by its
`drizzle.config.ts` — check that file first, the rest follows from it.
**Current (CLI-owned resolver) — no Nuxt in the loop.** New scaffolds, `apps/velo`,
`apps/kassa`:
```
schema.ts (app layer OR package) ← Drizzle table definitions
└─ resolveSchemaSourcesSync(process.cwd()) ← walks the layer graph directly
(the same set NuxtHub globs, resolved at drizzle-kit runtime — NO build)
└─ drizzle-kit generate → server/db/migrations/sqlite/NNNN_*.sql
+ meta/_journal.json + meta/NNNN_snapshot.json
└─ wrangler d1 migrations apply (db:migrate*) → applies the .sql to D1 (local/remote/staging)
```
Measured on a cold scaffold (#1457): `crouton config` 0.8s, `pnpm db:generate` 1.8s,
`pnpm dev` boots in ~6s. No `nuxt build`, so the build-first gotcha below cannot occur.
**Legacy (bundled `.mjs`) — no app in this repo is on it any more (#1456 was the last).**
Kept because an EXTERNAL consumer on an older scaffold still hits it. This is the
world the build-first recipe further down exists for:
```
schema.ts → NuxtHub scans layers → writes the bundle at BUILD time:
.nuxt/hub/db/schema.entry.ts (after `nuxt prepare`)
…/.nuxt/hub/db/schema.mjs (ONLY after `nuxt build`)
└─ drizzle-kit generate (reads the .mjs bundle) → …/NNNN_*.sql + meta/
└─ wrangler d1 migrations apply (db:migrate*) → applies to D1
```
Both runtimes are SQLite (local better-sqlite3/libSQL via `node-server`, D1 on
Cloudflare), so **one schema + one set of migrations serves both** — never write
D1-only or node-only SQL in a migration.
Key paths (per app, e.g. `apps/kassa`):
- `drizzle.config.ts` — **the tell for which shape you're on.** Current apps call
`resolveSchemaSourcesSync(process.cwd())` (no `.nuxt`, no build). Legacy apps point
`schema` at the bundled file, trying `.nuxt/hub/db/schema.mjs` then
`node_modules/.cache/nuxt/.nuxt/hub/db/schema.mjs`. Either way
`out: server/db/migrations/sqlite`.
- `server/db/migrations/sqlite/` — generated `NNNN_*.sql` + `meta/` (journal +
per-migration snapshots). **All committed.** wrangler applies the `.sql`;
drizzle-kit needs `meta/` to diff the next migration.
- `package.json` scripts: `db:generate` (drizzle-kit), `db:migrate` (wrangler,
`--local`), `db:migrate:prod` / `db:migrate:staging` (`--remote`),
`db:seed*` (crouton-seed). NB: never run `npx nuxt db generate` from repo root.
## Squashing a migration history — run the schema diff FIRST (required)
A squash regenerates the baseline **from the schema source**. A hand-written corrective
migration that fixed the *database* but was never reflected back into that source is
therefore **silently reverted**.
This is not hypothetical. kassa's `0019_nullable_printer_location` made
`sales_printers.locationId` nullable, healing a `0016` regression. The schema source
still said `.notNull()`, so the #1455 squash brought the bug straight back — caught only
when the production restore died on real rows:
```
NOT NULL constraint failed: sales_printers.locationId
```
**A table-NAME diff cannot see this** — the table exists on both sides. Compare columns:
```bash
# concatenate the OLD history in order, then diff against the regenerated baseline
for f in $(git show HEAD:apps/<app>/server/db/migrations/sqlite | grep '\.sql$'); do
git show HEAD:apps/<app>/server/db/migrations/sqlite/$f
done > /tmp/old_history.sql
node scripts/schema-diff.mjs /tmp/old_history.sql \
apps/<app>/server/db/migrations/sqlite/0000_*.sql \
--live <the wrangler d1 export dump>
```
Exit 0 = a data-only restore should load cleanly. Exit 1 = it will fail; fix the schema
source and regenerate **before** touching any database.
**Always pass `--live`.** The migration files can have drifted from the database that
actually exists; the live dump is what governs the restore. Without it the script can
only flag the *shape* of the problem, not confirm it.
**Restoring afterwards:** data only — never the old `d1_migrations` rows (that silently
un-does the squash) — and insert in **foreign-key order**, because D1's export is
alphabetical, so `account` precedes `user` and a naive reload dies on a constraint.
## Adding an APP collection (the common case)
Use the **crouton** skill / CLI — don't hand-write the table. Edit the schema
JSON (`apps/<app>/schemas/*.json`), make sure it's in `crouton.config.js`
(`collections` + `targets`), run `crouton config` to regenerate the layer, then
generate + apply the migration (see "Generate the migration" below).
## Adding a PACKAGE-OWNED infra table (the crouton-flow pattern)
For a table that ships with a package and should appear in every consuming app
(an outbox, a config table, a CRDT store) — NOT a user-facing CRUD collection.
Mirror `packages/crouton-flow` exactly:
1. **`packages/<pkg>/server/database/schema.ts`** — define the table with
`sqliteTable(...)` from `drizzle-orm/sqlite-core`. This is the source of truth.
2. **`packages/<pkg>/server/db/schema.ts`** — one-line re-export:
`export { myTable } from '../database/schema'`. **NuxtHub scans `server/db/schema.ts`
across extended layers** to build the bundle, so this is what makes consuming
apps auto-discover the table (confirm: `.nuxt/hub/db/schema.entry.ts` lists
your package after a `nuxt prepare`).
3. **`packages/<pkg>/server/database/migrations/NNNN_*.sql`** — hand-write the
`CREATE TABLE IF NOT EXISTS …` + indexes. This is the package's own copy
(for direct apply / docs); the consuming app still generates its own numbered
migration in step "Generate the migration".
4. Editing `packages/` is behind a **hard gate** — get user approval, then
`echo '<pkg-name>' >> .claude/.package-edit-approved`, edit, and
`rm .claude/.package-edit-approved` when done. (See root CLAUDE.md.)
5. Document it in `packages/<pkg>/CLAUDE.md` (Key Files + a table/section).
Naming: package tables use the package's prefix (`flow_*`, `sales_*`); column
names in package tables follow that table's own convention (flow uses snake_case).
**The consuming app needs NO CLI step for a package table.** The crouton CLI
(`crouton config`) has zero migration logic and is *not* involved here — don't
add the table to the app's `crouton.config.js`. NuxtHub auto-discovers it from
the package's `server/db/schema.ts` re-export; the app's only job is the normal
migrate step below (`db:generate` auto-includes it, then `db:migrate`) — the same
step any schema change needs. (A package may also ship its own `.sql` for direct
apply via `wrangler d1 execute --file=node_modules/@fyit/<pkg>/server/database/migrations/<file>.sql`.)
## Generate the migration
**On a current app (resolver `drizzle.config.ts`) there is no gotcha** — the whole
section below does not apply. `pnpm --filter <app> db:generate` resolves the layer
graph itself and writes the migration in ~2s:
```bash
pnpm --filter <app> db:generate # → server/db/migrations/sqlite/NNNN_*.sql + meta/
pnpm --filter <app> db:migrate # apply locally
```
One behaviour worth knowing (#1457): on a **cold scaffold** `crouton config` does not
produce the migration in the same run — it prints
`↻ Migration deferred — resolve the app's layers first, then re-run: pnpm db:generate`
and exits 0. That is the designed soft-defer (`reason: 'deferred'`, see
`packages/crouton-cli/CLAUDE.md`), not a failure. Run `db:generate` and carry on.
### The build-first gotcha — LEGACY scaffolds only (no app in this repo, since #1456)
Applies **only** where `drizzle.config.ts` still points at the bundled `.mjs`.
`pnpm --filter <app> db:generate` reads `.nuxt/hub/db/schema.mjs`. On a freshly
cloned / prepared tree **that file does not exist yet** — `nuxt prepare` only
writes `schema.entry.ts`; the `.mjs` bundle is emitted during **`nuxt build`**.
Two failure modes you'll hit:
- `Error No schema files found for path config ['.nuxt/hub/db/schema.mjs']`
→ the bundle hasn't been built.
- Pointing drizzle-kit at `schema.entry.ts` directly fails with
`ERR_PACKAGE_PATH_NOT_EXPORTED … not defined by "exports"` — drizzle-kit can't
resolve the cross-package aliases (e.g. crouton-auth subpaths) that only Nuxt's
bundler rewrites. **Don't go down this path.**
**The fix — build once to emit the bundle, then generate:**
```bash
cd apps/<app>
# Emit the schema bundle. node-server preset is fine; the bundle is written
# early (before the slow Nitro stage), so you can stop the build once it exists.
NITRO_PRESET=node-server nuxt build &
# wait until the bundle appears, then stop the build:
until [ -f .nuxt/hub/db/schema.mjs ] \
|| [ -f node_modules/.cache/nuxt/.nuxt/hub/db/schema.mjs ]; do sleep 2; done
pkill -f "nuxt build"
# Sanity: confirm your new table is in the bundle
grep -l "<yourTableName>" node_modules/.cache/nuxt/.nuxt/hub/db/schema.mjs .nuxt/hub/db/schema.mjs 2>/dev/null
pnpm db:generate # → server/db/migrations/sqlite/NNNN_*.sql (+ meta updates)
```
Then **inspect the generated `.sql`** — it should contain ONLY your change. If it
includes unrelated drift, the committed `meta/` snapshots were stale; reconcile
before committing (don't blindly ship the drift).
Apply locally: `pnpm db:migrate` (wrangler `--local`). Commit the `.sql`, the new
`meta/NNNN_snapshot.json`, and the updated `meta/_journal.json` together with the
schema change.
## Validate without booting Nuxt (fast check)
To prove a migration's SQL is valid and a table accepts the rows your code
writes, run the migration against an in-memory DB with the repo's `better-sqlite3`
(run the script from inside the repo so `node_modules` resolves):
```bash
cd /path/to/repo-root # so imports resolve
node ./.check.mjs # import Database from 'better-sqlite3'; db.exec(readFileSync(<migration.sql>)); insert + query
rm ./.check.mjs
```
## Gotchas / rules
- **`hub: { db: 'sqlite' }`** in nuxt.config — never `database: true` (breaks the
schema entry + local migrations). See root CLAUDE.md.
- Migrations are **append-only and committed** — never edit an applied `NNNN_*.sql`;
add a new one.
- `meta/` is required for correct future diffs — always commit it with the `.sql`.
- Drizzle `$default`/`$onUpdate` are **app-level** defaults; they do NOT emit SQL
`DEFAULT` clauses. If a column must have a value when written outside your code,
set the SQL default in the migration (the package's hand-written `.sql` can; the
drizzle-generated app `.sql` won't). Keep your insert code setting the values
explicitly so both paths agree.
- Typecheck after: `pnpm -r --filter './apps/*' typecheck` (or `--filter <app>`).
```
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!