docs: the convention docs were describing a different codebase
Second pass. These three are the ones a new contributor reads first, and all three were teaching things that are not true here. src/databases/CLAUDE.md claimed "Three PostgreSQL databases", listed two, and there is exactly one. Its type examples were Screenshot / Experiment / Company / GanOauth — none of which have ever existed in this repo; it had been carried over from another project wholesale. Rewritten against the real schema, queries and types, and it now carries the two things that actually bite: push-not-migrations, and the rule that the schema is the source of truth for what the database may CONTAIN, not just its shape — with the sql.raw trap in check() written down, since getting it wrong breaks push for the whole schema. src/apps/CLAUDE.md had the same problem in its examples (useExperimentsList, ExperimentCard, a state/ directory layout that does not exist), listed a `useWebsockets` hook that is not there while omitting useChatWebSocket, usePanelChannel and useJobs, and closed with links to three app docs that have never existed. Examples now use real hooks, and it points at the navigation audit — a frontend doc that did not mention the one rule the platform CLAUDE.md calls authoritative was a real gap. CONVENTIONS.md said, in bold, that useMemo and useCallback are "strictly prohibited" because "React 19's compiler handles memoization automatically". Wrong twice: the React Compiler is an opt-in build plugin that is NOT installed here, so React 19 memoizes nothing on its own — and roughly 40 files use each hook regardless, including code added this week. Replaced with guidance that matches both reality and the actual tradeoff, and says plainly what it used to claim. A rule that is false and universally ignored makes every other rule in the file look optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+73
-69
@@ -2,99 +2,103 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Three PostgreSQL databases managed with Drizzle ORM:
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
|
||||
describing a different codebase.
|
||||
|
||||
| Database | Purpose | Package |
|
||||
|----------|---------|---------|
|
||||
| `officer_db` | Main app data | `officerdb` |
|
||||
| `ephemeral_db` | Cache & temporary data | `ephemeraldb` |
|
||||
|
||||
## Type System
|
||||
|
||||
### File Structure
|
||||
|
||||
Each database package has:
|
||||
```
|
||||
db_name/
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── index.ts # DB connection, exports schema + drizzle helpers
|
||||
│ ├── types.ts # All type exports (Select, Insert, extended)
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: re-exports queries, schema and drizzle helpers
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ └── schema/
|
||||
│ ├── index.ts # Re-exports all schema files
|
||||
│ └── *.ts # Table definitions
|
||||
└── package.json # Exports: "." and "./types"
|
||||
│ ├── index.ts # re-exports all schema files
|
||||
│ └── *.ts # table definitions, grouped by domain
|
||||
└── package.json # exports "." and "./types"
|
||||
```
|
||||
|
||||
### Type Naming Convention
|
||||
Schema files are grouped by domain, not by table: `auth`, `chat-events`, `dashboards`, `email`,
|
||||
`headscale`, `music`, `operations`, `pipeline-jobs`, `server`, `soulseek`, `user-data`, `vault`,
|
||||
`wallet`.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
|
||||
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
|
||||
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
|
||||
as the source of truth, never those files.
|
||||
|
||||
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
|
||||
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
|
||||
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
|
||||
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
|
||||
them was gone, because nothing structural said they had become illegal.
|
||||
|
||||
```ts
|
||||
// Pattern 1: Simple table (no relations needed in API)
|
||||
export type Screenshot = typeof Schema.Screenshots.$inferSelect;
|
||||
export type ScreenshotInsert = typeof Schema.Screenshots.$inferInsert;
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
// Pattern 2: Table with relations (for hydrated API responses)
|
||||
export type UserSelect = typeof Schema.Users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.Users.$inferInsert;
|
||||
check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`,
|
||||
)
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
database has drifted, instead of quietly accepting it. Clean the rows, then push.
|
||||
|
||||
## Type naming
|
||||
|
||||
```ts
|
||||
// types.ts — inferred from the schema, never hand-written
|
||||
export type UserSelect = typeof Schema.users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.users.$inferInsert;
|
||||
|
||||
// The bare name is the hydrated shape, when a table has relations worth carrying
|
||||
export type User = UserSelect & {
|
||||
company: Company;
|
||||
passkeys: Passkey[];
|
||||
// computed fields
|
||||
passkeyCount: number;
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
### Type Organization
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
Organize types by domain with comments:
|
||||
## Queries
|
||||
|
||||
Hand-written, one file per domain under `src/queries/`, importing tables from `../schema` and types from
|
||||
`../types`:
|
||||
|
||||
```ts
|
||||
// officerdb/types.ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
// Auth
|
||||
export type PasskeySelect = ...
|
||||
export type Passkey = PasskeySelect & { user: User };
|
||||
|
||||
// Companies & Websites
|
||||
export type CompanySelect = ...
|
||||
export type Company = CompanySelect & { ... };
|
||||
|
||||
// Experiments
|
||||
export type ExperimentSelect = ...
|
||||
export type Experiment = ExperimentSelect & { ... };
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
## Importing Types
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ Good - import from types subpath
|
||||
// ✅ types from the types subpath
|
||||
import type { User } from 'officerdb/types';
|
||||
// ✅ queries, schema and drizzle helpers from the package root
|
||||
import { getUserById, eq } from 'officerdb';
|
||||
|
||||
// ✅ Good - import schema/connection from main
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
|
||||
// ❌ Bad - don't define manual types in schema files
|
||||
// ❌ Bad - don't import types from schema directly
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
## Null Safety
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
Drizzle-inferred types correctly reflect nullable columns. Add guards when needed:
|
||||
## Conventions
|
||||
|
||||
```ts
|
||||
export async function handleOauthAccount(account: GanOauth) {
|
||||
// Guard for nullable fields
|
||||
if (!account.refreshToken || !account.companyId) {
|
||||
return;
|
||||
}
|
||||
// Now TypeScript knows these are non-null
|
||||
const token = await refreshAccessToken(account.refreshToken);
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Best Practices
|
||||
|
||||
- Use `bigserial` with `mode: 'number'` for IDs
|
||||
- Use `bigint` with `mode: 'number'` for foreign keys
|
||||
- Always add indexes for frequently queried columns
|
||||
- Use `varchar` with explicit length limits
|
||||
- Timestamps: `timestamp('...', { withTimezone: true })`
|
||||
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
|
||||
(`chat_session_events` is the example — its id is also the replay cursor).
|
||||
- `timestamp('…', { withTimezone: true })`, always.
|
||||
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
|
||||
than cast.
|
||||
|
||||
Reference in New Issue
Block a user