Files
teatea-pension/.trellis/spec/backend/drizzle-postgres-current.md

4.4 KiB

Drizzle PostgreSQL Current Persistence Contract

Scenario: Current Project After PostgreSQL Migration

1. Scope / Trigger

  • Trigger: implementing or modifying auth, RBAC, audit, elder, facility, admission, organization, settings, or dashboard data flows in this repository.
  • Applies because this project already has drizzle-orm, postgres, Drizzle migrations under drizzle/, and schema definitions under modules/core/server/schema.ts.
  • This supersedes local JSON persistence for current feature work. Local JSON guidance is only historical or for projects that have not adopted Drizzle yet.

2. Signatures

  • getDatabase(): AppDatabase
  • checkDatabaseConnection(): Promise<{ ok: boolean; reason: string }>
  • readData(): Promise<AppData>
  • recordAuditLog(input: AuditInput): Promise<AuditLog>
  • requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>
  • Route Handlers use standard GET, POST, PATCH, and DELETE exports and return Response.

3. Contracts

  • Environment key: DATABASE_URL is required for database-backed runtime behavior.
  • Drizzle source of truth: modules/core/server/schema.ts.
  • Migration output: drizzle/.
  • Database config: drizzle.config.ts.
  • Session cookie:
    • Name: teatea_session
    • Flags: httpOnly, sameSite: "lax", path: "/"
    • Lifetime: 7 days
  • API response shape:
type ApiResult<T extends Record<string, unknown>> =
  | ({ success: true; reason: string } & T)
  | { success: false; reason: string };
  • modules/core/server/store.ts is a compatibility read model:
    • readData() may aggregate Drizzle rows for existing pages.
    • writeData() and updateData() must remain unavailable after migration.
    • New mutation code must use Drizzle queries or transactions.

4. Validation & Error Matrix

  • Missing DATABASE_URL -> throw during getDatabase() and surface a clear database configuration failure.
  • Missing/expired session -> 401 with { success: false, reason: "未登录或会话已过期" }.
  • Missing permission -> 403 with { success: false, reason: "权限不足" } and a denied audit log.
  • Invalid JSON body -> 400 with a Chinese user-facing reason.
  • Missing record by ID -> 404 with { success: false, reason: "<entity>不存在" }.
  • Duplicate account email -> 409 with { success: false, reason: "账号已存在" }.
  • Bed/admission conflict -> structured failure response and no partial mutation.

5. Good/Base/Bad Cases

  • Good: UI submits to a Route Handler, the handler validates input, calls requirePermission, mutates Drizzle tables in a transaction when multiple tables are involved, records audit, and returns ApiResult.
  • Base: Server Component reads data directly through focused server helpers or the temporary readData() compatibility model.
  • Bad: New mutation code calls writeData() or updateData().
  • Bad: New code treats .data/teatea.json as the active persistence layer.
  • Bad: API auth is enforced only by hidden UI controls.

6. Tests Required

  • pnpm lint
  • pnpm type-check
  • pnpm build
  • When schema changes are made: pnpm db:generate, then review generated SQL and run pnpm db:migrate against the configured development database.
  • Manual or automated integration assertions:
    • first setup creates platform admin, organization, organization roles, membership, and cookie session
    • protected app route redirects without cookie
    • protected app route renders with a valid cookie
    • CRUD mutation persists across reload/API list
    • denied permission returns 403 and writes an audit log
    • admission mutation updates admissions, beds, and elders consistently

7. Wrong vs Correct

Wrong

await updateData((data) => {
  data.beds.push(newBed);
});

Correct

const database = getDatabase();
await database.insert(beds).values({
  organizationId,
  roomId,
  code,
  status: "available",
});

Wrong

await database.insert(admissions).values({ organizationId, elderId, bedId });
await database.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId));

Correct

await database.transaction(async (transaction) => {
  await transaction.insert(admissions).values({ organizationId, elderId, bedId });
  await transaction.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId));
  await transaction.update(elders).set({ status: "active" }).where(eq(elders.id, elderId));
});