Compare commits

..

2 Commits

Author SHA1 Message Date
imbytecat 7e27640a26 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
2026-04-25 14:05:58 +08:00
imbytecat e28fe9dc7b perf(compile): 启用 bytecode + minify + inline sourcemap
- Bun 官方 bytecode caching:中型应用 startup ~2x(docs.bun.sh/docs/bundler/bytecode)
- minify:减小 bytecode 体积,二进制仅 +2MB sourcemap
- sourcemap inline:嵌入二进制,保证错误堆栈可读,并在 compile.ts 清理 bundler 残留的 *.js.map
2026-04-25 14:05:35 +08:00
14 changed files with 214 additions and 33 deletions
+20 -8
View File
@@ -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,15 @@ 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
`compile.ts` builds with `--minify --bytecode --sourcemap=inline`:
- **`bytecode`** — pre-compiles JS to bytecode and embeds it in the binary; ~2x startup on app-sized binaries (Bun docs benchmark). Requires `--compile`; top-level `await` must live inside `async` functions (it already does in this repo).
- **`minify`** — shrinks the binary and the bytecode it derives from.
- **`sourcemap: 'inline'`** — embeds the source map in the binary so error stack traces stay decodable. Bun also writes a residual `out/bin.js.map` next to the output; `compile.ts` removes it so the binary is the only artifact.
## ORPC
@@ -116,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`.
@@ -151,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)
@@ -160,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`.
-1
View File
@@ -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
+7 -6
View File
@@ -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
View File
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/routeTree.gen.ts"]
"includes": ["**", "!**/routeTree.gen.ts", "!**/migrations.gen.ts"]
},
"formatter": {
"enabled": true,
+6
View File
@@ -49,12 +49,18 @@ const main = async () => {
entrypoints: [ENTRYPOINT],
outdir: OUTDIR,
compile: { outfile, target },
minify: true,
bytecode: true,
sourcemap: 'inline',
})
if (!result.success) {
throw new Error(result.logs.map(String).join('\n'))
}
// Bun bundler still writes *.js.map next to the binary even with inline sourcemap.
await rm(`${OUTDIR}/${ENTRYPOINT.replace(/\.ts$/, '')}.js.map`, { force: true })
console.log(`${target}${OUTDIR}/${outfile}`)
}
View File
+7
View File
@@ -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
);
+65
View File
@@ -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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1777096386609,
"tag": "0000_loving_thunderbird",
"breakpoints": true
}
]
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+8
View File
@@ -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 },
]
+4
View File
@@ -0,0 +1,4 @@
declare module '*.sql' {
const content: string
export default content
}