From ed257fe4e607d8a3d9f99977fbd067edb2831733 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Sat, 25 Apr 2026 14:38:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=BA=94=E7=94=A8=20Oracle=20round?= =?UTF-8?q?-4=20=E5=A4=8D=E6=A0=B8=EF=BC=8C=E7=A1=AC=E5=8C=96=20migrator?= =?UTF-8?q?=20=E4=B8=8E=E9=BB=98=E8=AE=A4=E5=AE=89=E5=85=A8=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrate: 校验已应用 migration 的 SHA-256,拒绝 schema drift; split 后 trim + skip empty,避免空 statement 触发 SQL 错误 - todo.contract: update 拒绝空 patch - env: DATABASE_URL 限定 postgres(ql):// scheme,配置错误更早失败 - compile: autoloadDotenv: false,二进制部署不再吞 cwd 的 .env - Error.tsx: 生产环境隐藏 error.message,避免内部错误泄露 - AGENTS: 同步 generatedFieldKeys / migrator 行为新描述 --- AGENTS.md | 4 +-- compile.ts | 3 ++- src/cli/migrate.ts | 32 +++++++++++++++++------ src/components/Error.tsx | 2 +- src/env.ts | 2 +- src/server/api/contracts/todo.contract.ts | 4 ++- 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47554f7..f8063ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,10 +45,10 @@ Before committing: `bun run fix && bun run typecheck && bun run test`. No CI, no ``` Do NOT use the v2 object form (`orderBy: { createdAt: 'desc' }`, `where: { id }`) — it won't type-check. - To add relations later: declare per-table with `relations()` from `drizzle-orm` and export them from the same file as the table; they get picked up automatically because `index.ts` does `drizzle({ schema })` via `import *`. -- 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(...)`. +- Every table must spread `...generatedFields` from `src/server/db/fields.ts` (`id` UUIDv7 via `$defaultFn(uuidv7)`, `createdAt`, `updatedAt` with `$onUpdateFn`). `generatedFieldKeys` is hand-written and uses `satisfies Record` so any field-key drift fails typecheck; it feeds `createInsertSchema(...).omit(...)` / `createUpdateSchema(...).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. -- **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/`. +- **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`, **validates SHA-256 hash of every already-applied migration against the embedded SQL** (rejects schema drift if anyone edited an applied migration), then 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`). Each migration is split on `--> statement-breakpoint`; empty fragments are trimmed and skipped. Dev helpers `db:push` / `drizzle-kit migrate` still read `./drizzle/`. ## CLI & single-binary deploy diff --git a/compile.ts b/compile.ts index 7a9471b..60bc990 100644 --- a/compile.ts +++ b/compile.ts @@ -49,7 +49,8 @@ const main = async () => { const result = await Bun.build({ entrypoints: [ENTRYPOINT], outdir: OUTDIR, - compile: { outfile, target }, + // autoloadDotenv: false — produce a deterministic binary; it must not silently consume a .env from cwd. + compile: { outfile, target, autoloadDotenv: false }, minify: true, bytecode: true, sourcemap: 'inline', diff --git a/src/cli/migrate.ts b/src/cli/migrate.ts index 869abd1..194a380 100644 --- a/src/cli/migrate.ts +++ b/src/cli/migrate.ts @@ -19,6 +19,8 @@ export default defineCommand({ return } + const sha256 = (s: string) => createHash('sha256').update(s).digest('hex') + const db = drizzle({ connection: { url: env.DATABASE_URL, max: 1, onnotice: () => {} } }) try { await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "drizzle"`) @@ -29,12 +31,25 @@ export default defineCommand({ 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) + const applied = await db.execute<{ hash: string; created_at: string | null }>( + sql`SELECT hash, created_at FROM "drizzle"."__drizzle_migrations" ORDER BY created_at ASC`, + ) + + // Reject schema drift: any applied migration whose embedded SQL has changed (or is missing) is fatal. + for (const row of applied) { + const when = Number(row.created_at) + const m = embeddedMigrations.find((e) => e.when === when) + if (!m) { + throw new Error(`Applied migration when=${when} is not in this binary; do not roll back applied migrations.`) + } + if (sha256(m.sql) !== row.hash) { + throw new Error(`Migration hash mismatch at when=${when}; do not edit migrations after they are applied.`) + } + } + + const appliedWhens = new Set(applied.map((r) => Number(r.created_at))) + const pending = embeddedMigrations.filter((m) => !appliedWhens.has(m.when)) if (pending.length === 0) { console.log('Database is up to date.') return @@ -43,12 +58,13 @@ export default defineCommand({ 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')) { + for (const rawStmt of m.sql.split('--> statement-breakpoint')) { + const stmt = rawStmt.trim() + if (!stmt) continue 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})`, + sql`INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at") VALUES (${sha256(m.sql)}, ${m.when})`, ) } }) diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 193d098..789c714 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -20,7 +20,7 @@ export const ErrorComponent = ({ error, reset }: { error: Error; reset: () => vo

出错了

-

{error.message}

+

{import.meta.env.DEV ? error.message : '请求失败,请稍后重试'}