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:
2026-07-31 16:42:47 +00:00
co-authored by Claude Opus 5
parent 04c0d89057
commit db9d17d6fe
3 changed files with 116 additions and 94 deletions
+22 -16
View File
@@ -38,9 +38,9 @@ A complete data table solution with sorting, filtering, and pagination.
```tsx
import { DataTable, useDataControl } from '@/components/DataTable';
function ExperimentsLibrary() {
const { experiments } = useExperimentsList();
const dataController = useDataControl<Experiment>(experiments || []);
function JobsLibrary() {
const { jobs } = useJobs();
const dataController = useDataControl<Job>(jobs || []);
return (
<>
@@ -50,12 +50,12 @@ function ExperimentsLibrary() {
handleSearch={dataController.setSearchQuery}
/>
<DataTable<Experiment>
<DataTable<Job>
dataController={dataController}
pageSize={10}
columns={[
{ field: 'id', label: 'ID', sortKey: 'id' },
{ field: 'name', label: 'Name', sortKey: 'name' },
{ field: 'type', label: 'Type', sortKey: 'type' },
{
field: 'status',
label: 'Status',
@@ -64,7 +64,7 @@ function ExperimentsLibrary() {
},
{
label: 'Actions',
format: ({ item }) => <ActionsCell experiment={item} />
format: ({ item }) => <ActionsCell job={item} />
},
]}
/>
@@ -143,7 +143,10 @@ Features:
- `usePopover` - Popover state management
- `useTimeout` - Timeout management
- `useTimer` - Interval-based timer
- `useWebsockets` - WebSocket connection
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
- `useJobs` - background jobs
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
## State Management
@@ -187,15 +190,15 @@ Features:
Domain-specific hooks in `state/` directories wrap React Query:
```tsx
// src/apps/dashboard/state/experiments/useExperiment.ts
const { data, isLoading } = useExperiment(experimentId);
// src/workspaces/hooks/src/useJobs.ts
const { jobs, isLoading } = useJobs();
```
### When to Use What
| Scenario | Hook |
|----------|------|
| API data | Domain hooks (`useExperiment`, etc.) |
| API data | Domain hooks (`useJobs`, `useModels`, …) |
| Shared UI state | `useGlobal` |
| URL-driven state (filters, pagination) | `useQueryState` |
| Component-only state | `useState` |
@@ -239,10 +242,10 @@ type PanelProps = {
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
```tsx
import { useExperimentsList } from '@/state/experiments/useExperimentsList';
import { useJobs } from 'hooks/useJobs';
type TopHeaderProps = {
manager: ReturnType<typeof useExperimentsList>;
manager: ReturnType<typeof useJobs>;
};
```
@@ -258,8 +261,11 @@ toast.success("Saved successfully");
API errors automatically trigger via `useClient.config.onError`.
## App-Specific Docs
## Further Reading
- [Dashboard](./dashboard/CLAUDE.md) - Admin UI specifics
- [Editor](./editor/CLAUDE.md) - Visual editor specifics
- [Runtime](./runtime/CLAUDE.md) - Injected scripts specifics
- `../../CLAUDE.md` — architecture and the frontend route conventions
- `../../docs/navigation-audit.md`**authoritative** on routing and selection. The short version:
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
- `../../CONVENTIONS.md` — component organisation and React patterns
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
+73 -69
View File
@@ -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.