Internal: Drizzle ORM rules (repositories, transactions, keyset pages, migrations); read by work's executors before Drizzle code.
Resources
1Install
npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/typescript-drizzle-orm Install via the SkillsCat registry.
Codex runtime. Before using this skill, read
../executor-catalog/references/codex-runtime.md. It defines tool mappings, named-agent dispatch and stage-chat orchestration; use it for inherited Claude Code terms below.
Drizzle ORM
Which layout applies
The cases (architecture document, Framework-first, existing repo) are intypescript/references/which-layout-governs.md, shared by the backend and test skills of both stacks — read it
first. Drizzle's placement in them:
- An architecture document exists: table definitions and records live in
adapters/persistence/; the
repository adapter implements the port the use case owns and maps records to entities explicitly. The domain
imports nothing fromdrizzle-orm, HTTP, or validation libraries. - Framework-first: a route or a small service queries the tables directly.
- Existing repo: its layout (for example
src/db/schema.ts,src/<feature>/<feature>.repository.ts), its
return types (rows or entities) and its error classes; no mappers introduced unasked.
Rules
- Tables follow the schema document.
db-schema-designowns tables, columns, and constraints
(documentation/db/schema.mdand the migration files indocumentation/db/migrations/);
transcribe them:timestamptzinstants (a timestamp column
with time zone), the CHECK / UNIQUE / FK constraints it declares, an enum or CHECK for a closed
set of values. A table the task needs but the document lacks is a question, not an invention.
Brownfield: extendschema.tsthe way it is already written. - Records are inferred, not retyped.
typeof table.$inferSelect/$inferInsert(or theInferSelectModelhelpers) next to the table definition. A hand-written interface mirroring
columns drifts at the first rename. - Mapping lives in the repository adapter. A mapper module beside the repository has
toEntity(record)andtoRecord(entity); the entity has nofromRow/toRowand never sees a
record type. Reconstitute through the entity's restore factory, not its create factory, so
loading an old row does not re-run creation rules. One round-trip test per mapper
(typescript/references/shared-test-rules.md→ 1). - A repository answers in domain terms. It returns entities or read models, never records or
HTTP bodies. "Not found" isnullor a named error, as the port signature says. A constraint
violation it can expect (duplicate email, dangling reference) becomes a named domain or
application error; the HTTP status is mapped later by the error handler. It never throws an
HTTP error class. Brownfield: reuse the error class the repo already throws for that case, but
keep the classification in the repository or a db-error helper it calls, not in a route. - The database enforces what races, and a race test proves it (
typescript/references/shared-test-rules.md
→ 2, written byvitest). A unique index, FK, or CHECK holds under concurrent writes; an app-side "check,
then insert" does not. Keep the pre-check if it gives a nicer error, and
also classify the constraint violation, because two instances can pass the check together. - Classify driver errors by SQLSTATE read from the wrapped error (walk the
causechain), not
from the message: unique23505, FK23503, check23514, serialization40001, deadlock40P01. Never put the driver message into a client-facing detail; it carries SQL and parameters. - One query per read. Load a list with its related rows through one join or relational query,
never one query per item in a loop. - Keyset pagination, when the contract pages a collection:
ORDER BYthe sort columns plus the
id as tie-breaker, the compound predicate(a < x) OR (a = x AND id < y),limit + 1rows to
know whether a next page exists, never.offset(. The cursor carries the stored value at full
precision. Encoding and validating the cursor is the HTTP layer's job (typescript-fastify). - Transactions. A single-aggregate save is one transaction inside
repository.save; a write
spanning aggregates goes through aUnitOfWorkport only when the architecture names one.
Idempotent create: a unique constraint on the key plus an insert that does nothing on conflict
(or catches23505) and re-reads the winner. Never an in-process map as the source of truth. A
serialization failure or deadlock is rerun around the use case's whole run by the one shared
helperbootstrap/wires (references/repository-adapter.md→ Reading the SQLSTATE). - Migrations are the SQL files in
documentation/db/migrations/, written bydb-schema-design;schema.tsfollows them, not the other way round. Do notdrizzle-kit generatethem — that
writes a second migration history fromschema.ts. Apply them with the commandrepo-scaffold
wired. Drizzle'smigrate()reads only drizzle-kit's own folder format, which plain numbered
files are not, so the default is a plain SQL runner; pointmigrationsFolderthere only after
checking the installed version reads such files. Never edit a committed file — a change is the
next file. - A query applies a business rule only by its owner's constant. A filter by status or role
imports the list from the domain module (the domain model's «Кто ещё применяет»), never a
literal in SQL; an adapter writes what the domain decided and never re-derives the rule, an
upsert's conflict branch included. - Options, not a checklist. Relations, optimistic locking (a version column in every
update'sWHERE), and pool tuning only for a present need. The driver owns its pool: close it
in the existing shutdown path, no second tracker.
Shape of a repository adapter
// adapters/persistence/user.mapper.ts — the only place that knows both shapes
export const toEntity = (r: UserRecord): User =>
User.restore({ id: UserId.from(r.id), email: Email.from(r.email), createdAt: r.createdAt })
export const toRecord = (u: User): NewUserRecord =>
({ id: u.id.value, email: u.email.value, createdAt: u.createdAt })
// adapters/persistence/user.repository.ts — implements application's UserRepository port
async save(user: User): Promise<void> {
try { await this.db.insert(users).values(toRecord(user)) }
catch (e) { if (sqlState(e) === '23505') throw new EmailAlreadyTaken(user.email, { cause: e }); throw e }
}Error helper, pagination, idempotent insert, optimistic lock, tenant predicate, and the migration
cases: references/repository-adapter.md.
Version-sensitive points — references/version-checks.md. In a work packet
their answers come in CURRENT LIB DOCS; where it says none, probe the installed version in the project instead
of fetching docs. Outside work, read the file and check each point you rely on against the installed version.
The reviewer checks
- No
drizzle-kit generate, nometa/journal, no edited committed migration file; a schema change arrives as
the next SQL file fromdb-schema-design, andschema.tsfollows it. - A paged read is keyset: sort columns plus the id, the compound predicate,
limit + 1, never.offset(. - One query per read — no query per item in a loop.
- An expected constraint violation is classified by SQLSTATE read through the
causechain (and the
constraint name when a table has several) into a named error; the driver message never reaches a client. - A guard against a double action has a race test against the real database (
vitest), not only a
pre-check. - A repository returns entities or read models through the mapper, which has a round-trip test; a business
rule in a query uses its owner's constant, never a literal.