feat(deploy): migrations 嵌入二进制,实现真单文件部署
- embed-migrations.ts:扫 ./drizzle/meta/_journal.json,生成 src/server/db/migrations.gen.ts,每条 SQL 通过 `import sql_<idx> from '../../../drizzle/<tag>.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
This commit is contained in:
@@ -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_<idx> from '../../../drizzle/<tag>.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 `@/../<file>` (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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<page>.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
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!**/routeTree.gen.ts"]
|
||||
"includes": ["**", "!**/routeTree.gen.ts", "!**/migrations.gen.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777096386609,
|
||||
"tag": "0000_loving_thunderbird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+43
-16
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.sql' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
Reference in New Issue
Block a user