113 lines
4.4 KiB
Markdown
113 lines
4.4 KiB
Markdown
# 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:
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
await updateData((data) => {
|
|
data.beds.push(newBed);
|
|
});
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```ts
|
|
const database = getDatabase();
|
|
await database.insert(beds).values({
|
|
organizationId,
|
|
roomId,
|
|
code,
|
|
status: "available",
|
|
});
|
|
```
|
|
|
|
#### Wrong
|
|
|
|
```ts
|
|
await database.insert(admissions).values({ organizationId, elderId, bedId });
|
|
await database.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId));
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```ts
|
|
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));
|
|
});
|
|
```
|