agents.md holds the instructions, claude.md points at it
opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the content living in CLAUDE.md, every doc here was invisible to opencode. Swaps all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the workspace-level officer-dev/CLAUDE.md check is untouched, out of scope.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function JobsLibrary() {
|
||||
const { jobs } = useJobs();
|
||||
const dataController = useDataControl<Job>(jobs || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Job>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'type', label: 'Type', sortKey: 'type' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell job={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `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
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/workspaces/hooks/src/useJobs.ts
|
||||
const { jobs, isLoading } = useJobs();
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useJobs`, `useModels`, …) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useJobs>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `../../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
|
||||
+3
-268
@@ -1,271 +1,6 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function JobsLibrary() {
|
||||
const { jobs } = useJobs();
|
||||
const dataController = useDataControl<Job>(jobs || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Job>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'type', label: 'Type', sortKey: 'type' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell job={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `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
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/workspaces/hooks/src/useJobs.ts
|
||||
const { jobs, isLoading } = useJobs();
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useJobs`, `useModels`, …) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useJobs>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `../../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
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**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.
|
||||
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## 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
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
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.
|
||||
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
|
||||
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
|
||||
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
|
||||
longer version and re-creates the constraint on every push.
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
|
||||
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
|
||||
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
The six, and where each lives:
|
||||
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
|
||||
nothing — whatever is being re-created gets re-created next push regardless.
|
||||
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
|
||||
uniqueness rule would break the upserts that depend on it.
|
||||
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
|
||||
branch it takes at a truncate prompt.
|
||||
4. **Read the plan before applying it:**
|
||||
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
|
||||
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
|
||||
all. Anything else is your change.
|
||||
|
||||
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
|
||||
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
|
||||
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
|
||||
|
||||
## 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 & {
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ 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';
|
||||
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `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.
|
||||
+3
-228
@@ -1,231 +1,6 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**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.
|
||||
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## 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
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
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.
|
||||
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
|
||||
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
|
||||
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
|
||||
longer version and re-creates the constraint on every push.
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
|
||||
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
|
||||
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
The six, and where each lives:
|
||||
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
|
||||
nothing — whatever is being re-created gets re-created next push regardless.
|
||||
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
|
||||
uniqueness rule would break the upserts that depend on it.
|
||||
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
|
||||
branch it takes at a truncate prompt.
|
||||
4. **Read the plan before applying it:**
|
||||
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
|
||||
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
|
||||
all. Anything else is your change.
|
||||
|
||||
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
|
||||
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
|
||||
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
|
||||
|
||||
## 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 & {
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ 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';
|
||||
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `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.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
Reference in New Issue
Block a user