From 7e27640a26d7c835dbeab994a087a9685d991792 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Sat, 25 Apr 2026 14:05:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20migrations=20=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6=EF=BC=8C=E5=AE=9E=E7=8E=B0=E7=9C=9F?= =?UTF-8?q?=E5=8D=95=E6=96=87=E4=BB=B6=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - embed-migrations.ts:扫 ./drizzle/meta/_journal.json,生成 src/server/db/migrations.gen.ts,每条 SQL 通过 `import sql_ from '../../../drizzle/.sql' with { type: 'text' }` 在 bun build --compile 时被静态嵌入二进制 - migrate.ts 重写:runtime 用 createHash('sha256') 计算迁移哈希,仅用 db.execute(sql) + db.transaction() 公开 API 写入 drizzle.__drizzle_migrations 簿记表(不依赖 @internal 的 db.dialect/db.session) - db:generate 链 db:embed,保证 SQL 改动总是同步到 migrations.gen.ts - Dockerfile 删 COPY drizzle/,binary 是部署唯一 artifact - 同步 README / AGENTS / biome.json --- AGENTS.md | 20 +++++---- Dockerfile | 1 - README.md | 13 +++--- biome.json | 2 +- drizzle/.gitkeep | 0 drizzle/0000_loving_thunderbird.sql | 7 ++++ drizzle/meta/0000_snapshot.json | 65 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 13 ++++++ embed-migrations.ts | 38 +++++++++++++++++ package.json | 3 +- src/cli/migrate.ts | 59 +++++++++++++++++++------- src/server/db/migrations.gen.ts | 8 ++++ src/sql.d.ts | 4 ++ 13 files changed, 200 insertions(+), 33 deletions(-) delete mode 100644 drizzle/.gitkeep create mode 100644 drizzle/0000_loving_thunderbird.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 embed-migrations.ts create mode 100644 src/server/db/migrations.gen.ts create mode 100644 src/sql.d.ts diff --git a/AGENTS.md b/AGENTS.md index b54c1e3..af27f75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ bun run typecheck # tsc --noEmit bun run test # bun test — runs all *.test.ts files (colocated with source) bun run fix # biome check --write (lint + format + organize imports) bun run db:push # dev only — push schema to DB, no migration file -bun run db:generate # produce SQL migration files in ./drizzle -bun run db:migrate # apply migrations via drizzle-kit (local convenience) +bun run db:generate # drizzle-kit generate && embed-migrations.ts (regenerates migrations.gen.ts) +bun run db:embed # embed-migrations.ts only — regenerate migrations.gen.ts from ./drizzle/ +bun run db:migrate # apply migrations via drizzle-kit (local dev convenience; prod uses ./server migrate) bun run db:studio # Drizzle Studio ``` @@ -47,7 +48,7 @@ Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no - Every table must spread `...generatedFields` from `src/server/db/fields.ts` (`id` UUIDv7 via `$defaultFn(uuidv7)`, `createdAt`, `updatedAt` with `$onUpdateFn`). `generatedFieldKeys` (hand-written `as const`) feeds `createInsertSchema(...).omit(...)`. - `src/server/db/index.ts` exports a module-level `const db = drizzle(...)` — not a lazy singleton. On Bun this is a long-lived process, so top-level side effects are fine and requested. Don't reintroduce `getDB/closeDB` ceremony; the Nitro shutdown plugin calls `db.$client.end()` directly. (Cloudflare Workers would need per-request init — we don't support that deployment target.) - `drizzle.config.ts` runs outside Vite — `@/*` path aliases do NOT resolve there. It currently does `import { env } from './src/env'` (relative). Preserve that. -- The `./drizzle/` migrations directory ships empty with a `.gitkeep`. Migrations are applied via the CLI (`./server migrate`, see "CLI & single-binary deploy" below), NOT at server startup. Dev uses `db:push`. Don't mix `push` and `migrate` on the same DB. +- **Migrations are embedded in the binary, not read from disk.** `bun run db:generate` chains `drizzle-kit generate && bun embed-migrations.ts`, which regenerates `src/server/db/migrations.gen.ts` (committed, AUTO-GENERATED header) by `import sql_ from '../../../drizzle/.sql' with { type: 'text' }`. `src/cli/migrate.ts` reads `embeddedMigrations`, computes SHA-256 hashes at runtime, and applies pending entries via `db.execute(sql\`...\`)` + `db.transaction(...)` against the `drizzle.__drizzle_migrations` book-keeping table — public APIs only, no `db.dialect`/`db.session` (those are `@internal`). Dev helpers `db:push` / `drizzle-kit migrate` still read `./drizzle/`. ## CLI & single-binary deploy @@ -65,7 +66,7 @@ Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no Add a subcommand: drop a file in `src/cli/` that default-exports `defineCommand({...})`, then register it in `bin.ts`'s `subCommands` with a `() => import(...)` thunk. Keep top-level imports limited to `citty` + Node built-ins; pull env / db / etc. via `await import(...)` inside `run()`. -**Deploy flow is always migrate-then-serve.** The compiled binary bundles neither the `./drizzle/` SQL files nor the app schema migrations at runtime — they're read from disk next to the binary. Dockerfile copies `drizzle/` alongside `./server`, and `compose.yaml` models this with a one-shot `migrate` service that `app` `depends_on: service_completed_successfully`. On k8s, run `./server migrate` as an initContainer or a Helm `pre-upgrade` Job; run `./server` (= `./server serve`) as the main container. +**Deploy flow is always migrate-then-serve.** Migrations are embedded in the binary (see "Drizzle" section), so the binary is the only artifact — no `./drizzle/` directory at runtime. Dockerfile copies just `./server`. `compose.yaml` models the pattern with a one-shot `migrate` service that `app` `depends_on: service_completed_successfully`. On k8s, run `./server migrate` as an initContainer or a Helm `pre-upgrade` Job; run `./server` (= `./server serve`) as the main container. ## Compile flags @@ -124,7 +125,6 @@ Path alias: `@/* → src/*`. For files outside `src/` use `@/../` (example ## Docker / deploy - Multi-stage: `oven/bun:1.3.13` builds and runs `bun compile.ts`, then `gcr.io/distroless/cc-debian13:nonroot` runs the single `./server` binary. The `cc` (glibc) distroless variant is required because Bun's compiled binary links glibc. -- `drizzle/` folder is copied into the runtime image so `./server migrate` can find migrations at runtime. - `compose.yaml`: one-shot `migrate` service runs `./server migrate` with `restart: "no"`, then `app` starts (`depends_on: migrate: service_completed_successfully`). `DATABASE_URL=postgres://postgres:postgres@db:5432/postgres` for both. - Distroless has no shell, so any init-then-serve pattern must use exec-form `command: [...]`, not `sh -c`. @@ -159,6 +159,7 @@ src/ │ ├── db/ │ │ ├── index.ts # module-level `export const db = drizzle({...})` │ │ ├── fields.ts # generatedFields (id/createdAt/updatedAt) + generatedFieldKeys +│ │ ├── migrations.gen.ts # AUTO-GENERATED by `bun run db:embed`; embeds ./drizzle/*.sql via `with { type: 'text' }` │ │ └── schema/ # pgTable definitions; also put `relations()` here when adding │ └── plugins/ │ └── shutdown.ts # SIGINT/SIGTERM → db.$client.end() with 500ms delay (prod only) @@ -168,17 +169,20 @@ src/ ├── styles.css # Tailwind v4 entry └── routeTree.gen.ts # auto-generated, do not edit bin.ts # citty entry (root) — keep imports minimal (see "CLI" section) -compile.ts # `bun build --compile` driver; resolves --target -drizzle/ # SQL migrations (.gitkeep until first `bun run db:generate`) +compile.ts # `bun build --compile` driver; resolves --target; sets minify/bytecode/sourcemap +embed-migrations.ts # codegen: scans ./drizzle/meta/_journal.json → src/server/db/migrations.gen.ts +drizzle/ # SQL migrations (source of truth for `db:generate`; not shipped in binary) ``` Nitro plugins are wired in `vite.config.ts` (`nitro({ plugins: [...] })`), not via a Nitro config file. ## Don'ts (specific, non-obvious) -- Don't edit `routeTree.gen.ts`. +- Don't edit `routeTree.gen.ts` or `src/server/db/migrations.gen.ts`. - Don't eager-import anything from `.output/` in `bin.ts` or any module it statically imports — it starts the HTTP server as a side effect. Subcommands must be lazy via citty's `() => import(...)` thunks. - Don't re-add an auto-migrate Nitro plugin. Migrations are an explicit deploy step via `./server migrate`. +- Don't reach into `db.dialect`/`db.session` from `migrate.ts` — they're `@internal`. The current implementation uses public `db.execute(sql)` + `db.transaction(...)` against the documented `drizzle.__drizzle_migrations` schema. +- Don't add `./drizzle/` back to the runtime image — migrations are embedded into the binary. - Don't reintroduce `getDB/closeDB` or any "lazy DB init" pattern — that's a Cloudflare Workers shape; we deploy on Bun processes. - Don't import `os` from `@orpc/server` in middleware/routers — always `@/server/api/server`. - Don't import from `drizzle-orm/zod` (1.0 beta only). Use `drizzle-zod`. diff --git a/Dockerfile b/Dockerfile index d615b1e..21568d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,6 @@ FROM gcr.io/distroless/cc-debian13:nonroot WORKDIR /app COPY --from=build --chown=nonroot:nonroot /app/out/server ./server -COPY --from=build --chown=nonroot:nonroot /app/drizzle ./drizzle ENV HOST=0.0.0.0 EXPOSE 3000 diff --git a/README.md b/README.md index f02e2c0..9895afa 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ RPC endpoint: `/api/rpc` · OpenAPI docs: `/api/docs` · Spec: `/api/spec.json` | `bun run test` | `bun test` (colocated `*.test.ts`) | | `bun run fix` | Biome lint + format + organize imports | | `bun run db:push` | Dev-only schema sync (no migration files) | -| `bun run db:generate` | Write SQL migrations to `./drizzle` | -| `bun run db:migrate` | Apply migrations locally via drizzle-kit | +| `bun run db:generate` | Write SQL migrations to `./drizzle` and regenerate `migrations.gen.ts` | +| `bun run db:embed` | Regenerate `src/server/db/migrations.gen.ts` from `./drizzle` (run if you hand-edit migrations) | +| `bun run db:migrate` | Apply migrations locally via drizzle-kit (dev convenience) | | `bun run db:studio` | Drizzle Studio | Cross-compile: `bun run compile:{linux,darwin,windows}[:arch]`. @@ -45,18 +46,18 @@ Contract-first, additive. Create or touch files in this order: 6. `src/server/api/routers/index.ts` — add `post` to the `router` object. 7. `src/client/queries/post.ts` — export `useInvalidatePosts` (or finer-grained helpers) for affected list keys. 8. `src/routes/.tsx` — UI with `useSuspenseQuery` + loader `ensureQueryData`; call the helper from mutation `onSuccess`. -9. `bun run db:generate` — emit SQL migrations to `./drizzle`. +9. `bun run db:generate` — emit SQL migrations to `./drizzle` and embed them into `src/server/db/migrations.gen.ts`. ## Deploy -Always **migrate-then-serve**. Migrations are an explicit deploy step, never a server startup hook. +Always **migrate-then-serve**. Migrations are embedded in the binary; the binary is the only artifact you ship. ```bash -./server migrate # applies ./drizzle against $DATABASE_URL +./server migrate # applies embedded migrations against $DATABASE_URL ./server # starts HTTP server (default subcommand) ``` -`Dockerfile` copies `./drizzle/` next to the binary. `compose.yaml` models the pattern with a one-shot `migrate` service that `app` depends on (`service_completed_successfully`). On Kubernetes: run `./server migrate` as an initContainer or Helm `pre-upgrade` Job. +`compose.yaml` models the pattern with a one-shot `migrate` service that `app` depends on (`service_completed_successfully`). On Kubernetes: run `./server migrate` as an initContainer or Helm `pre-upgrade` Job. ```bash docker compose up --build diff --git a/biome.json b/biome.json index 18ab6e3..927d7d8 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/routeTree.gen.ts"] + "includes": ["**", "!**/routeTree.gen.ts", "!**/migrations.gen.ts"] }, "formatter": { "enabled": true, diff --git a/drizzle/.gitkeep b/drizzle/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/drizzle/0000_loving_thunderbird.sql b/drizzle/0000_loving_thunderbird.sql new file mode 100644 index 0000000..0ac5d21 --- /dev/null +++ b/drizzle/0000_loving_thunderbird.sql @@ -0,0 +1,7 @@ +CREATE TABLE "todo" ( + "id" uuid PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "title" text NOT NULL, + "completed" boolean DEFAULT false NOT NULL +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..ac2b514 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,65 @@ +{ + "id": "4ece5479-57bf-473d-b806-c1176c972e7f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.todo": { + "name": "todo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed": { + "name": "completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..82b1e07 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1777096386609, + "tag": "0000_loving_thunderbird", + "breakpoints": true + } + ] +} diff --git a/embed-migrations.ts b/embed-migrations.ts new file mode 100644 index 0000000..8285ba2 --- /dev/null +++ b/embed-migrations.ts @@ -0,0 +1,38 @@ +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' + +const JOURNAL = './drizzle/meta/_journal.json' +const OUTPUT = './src/server/db/migrations.gen.ts' +const SQL_RELATIVE_FROM_OUTPUT = '../../../drizzle' + +type JournalEntry = { idx: number; tag: string; when: number; breakpoints: boolean } +type Journal = { entries: JournalEntry[] } + +const main = async () => { + const entries: JournalEntry[] = existsSync(JOURNAL) + ? ((JSON.parse(await readFile(JOURNAL, 'utf-8')) as Journal).entries ?? []).sort((a, b) => a.idx - b.idx) + : [] + + const imports = entries + .map((e) => `import sql_${e.idx} from '${SQL_RELATIVE_FROM_OUTPUT}/${e.tag}.sql' with { type: 'text' }`) + .join('\n') + + const arrayBody = entries.length + ? `[\n${entries.map((e) => ` { tag: '${e.tag}', sql: sql_${e.idx}, when: ${e.when}, breakpoints: ${e.breakpoints} },`).join('\n')}\n]` + : '[]' + + const out = `// AUTO-GENERATED by \`bun run db:embed\`. Do not edit. +${imports ? `${imports}\n` : ''} +export type EmbeddedMigration = { tag: string; sql: string; when: number; breakpoints: boolean } + +export const embeddedMigrations: readonly EmbeddedMigration[] = ${arrayBody} +` + + await writeFile(OUTPUT, out) + console.log(`✓ ${OUTPUT} (${entries.length} migration${entries.length === 1 ? '' : 's'})`) +} + +main().catch((err) => { + console.error('❌', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/package.json b/package.json index 5b8ccd8..f2065c0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "compile:linux:x64": "bun compile.ts --target bun-linux-x64", "compile:windows": "bun run compile:windows:x64", "compile:windows:x64": "bun compile.ts --target bun-windows-x64", - "db:generate": "drizzle-kit generate", + "db:generate": "drizzle-kit generate && bun embed-migrations.ts", + "db:embed": "bun embed-migrations.ts", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", diff --git a/src/cli/migrate.ts b/src/cli/migrate.ts index a79e870..869abd1 100644 --- a/src/cli/migrate.ts +++ b/src/cli/migrate.ts @@ -1,30 +1,57 @@ -import { existsSync } from 'node:fs' import { defineCommand } from 'citty' -const MIGRATIONS_FOLDER = './drizzle' -const JOURNAL = `${MIGRATIONS_FOLDER}/meta/_journal.json` - export default defineCommand({ meta: { name: 'migrate', - description: 'Apply pending database migrations from ./drizzle', + description: 'Apply pending database migrations', }, async run() { - if (!existsSync(JOURNAL)) { - console.log(`No migrations found at ${MIGRATIONS_FOLDER} (run \`bun run db:generate\` to create some).`) + const [{ env }, { drizzle }, { sql }, { embeddedMigrations }, { createHash }] = await Promise.all([ + import('@/env'), + import('drizzle-orm/postgres-js'), + import('drizzle-orm'), + import('@/server/db/migrations.gen'), + import('node:crypto'), + ]) + + if (embeddedMigrations.length === 0) { + console.log('No migrations bundled into this binary.') return } - const [{ env }, { drizzle }, { migrate }] = await Promise.all([ - import('@/env'), - import('drizzle-orm/postgres-js'), - import('drizzle-orm/postgres-js/migrator'), - ]) - - const db = drizzle({ connection: { url: env.DATABASE_URL, max: 1 } }) + const db = drizzle({ connection: { url: env.DATABASE_URL, max: 1, onnotice: () => {} } }) try { - console.log('Applying migrations...') - await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }) + await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "drizzle"`) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `) + const last = await db.execute<{ created_at: string | null }>( + sql`SELECT created_at FROM "drizzle"."__drizzle_migrations" ORDER BY created_at DESC LIMIT 1`, + ) + const lastMillis = Number(last[0]?.created_at ?? 0) + + const pending = embeddedMigrations.filter((m) => m.when > lastMillis) + if (pending.length === 0) { + console.log('Database is up to date.') + return + } + + console.log(`Applying ${pending.length} migration(s)...`) + await db.transaction(async (tx) => { + for (const m of pending) { + for (const stmt of m.sql.split('--> statement-breakpoint')) { + await tx.execute(sql.raw(stmt)) + } + const hash = createHash('sha256').update(m.sql).digest('hex') + await tx.execute( + sql`INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at") VALUES (${hash}, ${m.when})`, + ) + } + }) console.log('Migrations applied.') } finally { await db.$client.end() diff --git a/src/server/db/migrations.gen.ts b/src/server/db/migrations.gen.ts new file mode 100644 index 0000000..da9d10a --- /dev/null +++ b/src/server/db/migrations.gen.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED by `bun run db:embed`. Do not edit. +import sql_0 from '../../../drizzle/0000_loving_thunderbird.sql' with { type: 'text' } + +export type EmbeddedMigration = { tag: string; sql: string; when: number; breakpoints: boolean } + +export const embeddedMigrations: readonly EmbeddedMigration[] = [ + { tag: '0000_loving_thunderbird', sql: sql_0, when: 1777096386609, breakpoints: true }, +] diff --git a/src/sql.d.ts b/src/sql.d.ts new file mode 100644 index 0000000..5a29d00 --- /dev/null +++ b/src/sql.d.ts @@ -0,0 +1,4 @@ +declare module '*.sql' { + const content: string + export default content +}