Files
platform/src/databases/AGENTS.md
T
pastilhas 2360a3ef60 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.
2026-08-27 06:35:31 +00:00

12 KiB
Raw Blame History

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:

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.

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

// 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:

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

// ✅ 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.