drizzle
Defines database schemas, migration processes, and query patterns while enforcing consistent naming and unit testing for all models.
Install
mkdir -p .claude/skills/drizzle && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/859" && unzip -o skill.zip -d .claude/skills/drizzle && rm skill.zipInstalls to .claude/skills/drizzle
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
LobeHub Drizzle ORM schema and query style. Use for pgTable schemas, indexes, joins, inferred types, db.select/db.query, schema fields, foreign keys, junction tables, or postgres query patterns.Key capabilities
- →Define PostgreSQL table schemas using pgTable with strict naming conventions
- →Implement application-generated primary keys using text IDs instead of serial sequences
- →Enforce data contracts for JSONB columns using explicit TypeScript interfaces
- →Standardize timestamp management via shared helper functions
- →Execute database operations using the builder API exclusively
- →Manage one-to-many relationships through separate, explicit queries
How it works
The skill enforces a specific Drizzle ORM style guide that mandates builder-based queries, application-level ID generation, and strict schema typing. It requires developers to document non-obvious fields with JSDoc and maintain consistent table naming families.
Inputs & outputs
When to use drizzle
- →Defining new database tables with Drizzle
- →Running database migrations
- →Writing type-safe SQL queries
- →Adding model tests for repositories
About this skill
Drizzle ORM Schema Style Guide
Adding a Model or Repository? Ship a sibling test in the same PR — every new file under
packages/database/src/models/**orsrc/repositories/**needs a matching__tests__/<name>.test.ts. See the testing skill (.agents/skills/testing/references/db-model-test.md) for thegetTestDB()integration pattern, user-isolation tests, the BM25describe.skipIf(!isServerDB)guard, and schema gotchas. CI's coverage patch gate won't reliably catch a brand-new untested file, so this is on you.
Configuration
- Config:
drizzle.config.ts - Schemas:
packages/database/src/schemas/ - Migrations:
packages/database/migrations/ - Dialect:
postgresqlwithstrict: true
Helper Functions
Location: packages/database/src/schemas/_helpers.ts
timestamptz(name): Timestamp with timezonecreatedAt(),updatedAt(),accessedAt(): Standard timestamp columnstimestamps: Object with all three for easy spread
Naming Conventions
- Tables: Plural snake_case (
users,session_groups) - Columns: snake_case (
user_id,created_at) - New tables: Check nearby existing tables before naming a new one. Preserve
the established noun family and suffix. For example, if the user-scoped table
is
user_xxx_logs, the workspace-scoped counterpart should beworkspace_xxx_logs, notworkspace_xxx_recordsor another new synonym.
// ✅ Good: follows the existing user/workspace table family.
export const userSignupLogs = pgTable('user_signup_logs', { ... });
export const workspaceSignupLogs = pgTable('workspace_signup_logs', { ... });
// ❌ Bad: introduces a new suffix for the same concept.
export const workspaceSignupRecords = pgTable('workspace_signup_records', { ... });
Column Definitions
Primary Keys
Do not use auto-incrementing primary keys (serial, bigserial, generated
identity columns). They create sequence-state problems during cross-database
migrations, restores, and data copy jobs. Prefer text IDs from application
generators (idGenerator, createNanoId) or uuid for internal tables.
Keep $defaultFn(...) when a table normally owns ID generation. Callers can
still pass an explicit id; the default only runs when the insert omits it. Do
not remove the default just because one flow needs to supply a request-scoped ID.
// ✅ Good: app-generated text ID; explicit inserts can still override it.
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
// ❌ Bad: sequence state is fragile across DB migrations and restores.
id: serial('id').primaryKey(),
ID prefixes make entity types distinguishable. For internal tables, use uuid.
Do not use composite primary keys on new tables. Give every table a single-column
surrogate PK and carry business uniqueness in a uniqueIndex instead. PK columns
cannot be nullable, so when the uniqueness scope later grows by a nullable
dimension the composite PK must be torn down and rebuilt — exactly what happened
when ai_providers / ai_models were workspace-scoped (migration 0110 replaced
their composite PKs with a surrogate _id plus partial unique indexes). A unique
index still works as the arbiter for onConflictDoUpdate upserts.
// ✅ Good: surrogate PK; uniqueness scope can evolve without a PK rebuild.
export const workspaceUserSettings = pgTable(
'workspace_user_settings',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }).notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
...timestamps,
},
(t) => [uniqueIndex('workspace_user_settings_workspace_id_user_id_unique').on(t.workspaceId, t.userId)],
);
// ❌ Bad: locked to exactly these columns; adding a nullable scope column
// (workspaceId, deviceId, …) later forces a full PK rebuild migration.
(t) => [primaryKey({ columns: [t.workspaceId, t.userId] })],
Existing composite PKs are legacy — leave them alone unless they block a scope change, then migrate them the 0110 way.
Foreign Keys
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
Timestamps
...timestamps, // Spread from _helpers.ts
Optional and Undefined Values
Do not introduce artificial sentinel strings for missing values, such as
unknown, unless the domain already has that explicit state and existing code
uses it consistently. Prefer nullable columns, optional TypeScript fields, or a
separate concrete status enum when the value is genuinely absent.
// ✅ Good: absent until the final stage writes a real decision.
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error';
finalDecision: varchar('final_decision', { length: 32 }).$type<UserSignupLogFinalDecision>(),
// ❌ Bad: invents a new state that callers now need to handle everywhere.
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error' | 'unknown';
finalDecision: varchar('final_decision', { length: 32 })
.$type<UserSignupLogFinalDecision>()
.notNull()
.default('unknown');
Database Enums
Default to not using PostgreSQL/Drizzle pgEnum. Database enums are
expensive to evolve safely: adding members needs migrations, removing or
renaming members is awkward, and deployment order becomes more fragile.
For product/business states, use text() or varchar() with a TypeScript value
type via $type<...>(). Keep those TS-only value types in the domain/shared type
module, then import them into the schema. For cloud DB schemas, that usually
means cloudDB/types.ts.
Do not copy existing DB enums as a pattern. Treat them as legacy or explicitly
reviewed exceptions. If a new pgEnum seems necessary, stop and justify why the
value set is effectively immutable and why the migration cost is acceptable.
Field Descriptions
For columns whose meaning is not obvious from the name alone, add JSDoc on the schema field. Include a concrete example when it clarifies the stored value or the lifecycle moment that writes it. This is especially important for external IDs, lifecycle statuses, denormalized snapshots, JSONB signals, and fields whose name could mean either a request ID or a persisted row ID.
// ✅ Good: explain the table's business object first, then only document
// non-obvious lifecycle or risk-control fields.
/**
* User signup logs - one row per signup flow, collecting stage-level
* risk-control decisions before and after the auth provider creates a user.
*/
export const userSignupLogs = pgTable('user_signup_logs', {
/** Final signup outcome reason, for example user_created, llm_block, or guard_error */
finalReason: text('final_reason'),
/** Aggregated risk level derived from stage decisions, for example block -> high */
riskLevel: varchar('risk_level', { length: 16 }).$type<UserSignupLogRiskLevel>(),
/** Ordered stage-level decisions and metadata grouped by signup review stage */
stageResults: jsonb('stage_results').$type<UserSignupLogStageResults>(),
});
// ❌ Bad: comments restate obvious column names without adding domain meaning.
/** User email */
email: text('email'),
JSONB Types
Avoid Record<string, unknown> or similarly loose JSONB types for schema
columns. Define a concrete interface that describes the expected JSON shape, even
when most properties are optional. This keeps callers, migrations, and review
queries aligned on the same data contract.
interface UserSignupLogMetadata {
payloadPath?: string;
requestPath?: string;
}
metadata: jsonb('metadata').$type<UserSignupLogMetadata>(),
// ❌ Bad: hides the contract and makes downstream access untyped.
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
A loosely-typed JSONB column is often a symptom of a deeper problem: the column
was reserved speculatively ("for future extension") and nothing actually writes
it. Don't add metadata / extra JSONB columns for hypothetical future needs —
a column earns its place only when a concrete writer ships alongside it. When
review finds such a column, the fix is to delete the column, not to invent
an interface for data that doesn't exist; add a properly-typed column once the
real requirement arrives.
Indexes
// Return array (object style deprecated)
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
Type Inference
export const insertAgentSchema = createInsertSchema(agents);
export type NewAgent = typeof agents.$inferInsert;
export type AgentItem = typeof agents.$inferSelect;
Example Pattern
export const agents = pgTable(
'agents',
{
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
slug: varchar('slug', { length: 100 })
.$defaultFn(() => randomSlug(4))
.unique(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: text('client_id'),
chatConfig: jsonb('chat_config').$type<LobeAgentChatConfig>(),
...timestamps,
},
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
);
Common Patterns
Junction Tables (Many-to-Many)
The surrogate-PK rule above applies to junction tables too — pair uniqueness
goes in a uniqueIndex, not a composite PK (many existing junction tables
still use composite PKs; that is legacy, not the template):
export const agentsKnowledgeBases = pgTable(
'agents_knowledge_bases',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
agentId: text('agent_id')
.references(() => agents.id, { onDelete: 'cascade' })
.notNull(),
knowledgeBaseId: text('knowledge_base_id')
.references((
---
*Content truncated.*
When not to use it
- →Using the Drizzle relational API (db.query.*) for complex joins
- →Implementing auto-incrementing primary keys like serial or bigserial
- →Creating PostgreSQL enums (pgEnum) for business states
Prerequisites
Limitations
- →Requires manual implementation of recursive CTEs via raw SQL
- →Prohibits the use of relational API features like with: and findMany
- →Mandates strict adherence to existing table naming families
How it compares
Unlike generic Drizzle usage, this approach forbids relational API joins and auto-incrementing keys to ensure migration stability and query performance.
Compared to similar skills
drizzle side by side with the closest alternatives in the catalog.
| Skill | Installs | Updated | Safety | Difficulty |
|---|---|---|---|---|
| drizzle (this skill) | 238 | 2mo | No flags | Intermediate |
| database-development | 1 | 2mo | Review | Intermediate |
| prisma-expert | 12 | 6mo | Review | Intermediate |
| create-module | 1 | 3mo | Review | Intermediate |
Try saying
Example prompts that trigger this skill in your AI assistant.
More by lobehub
View all by lobehub →You might also like
database-development
vm0-ai
Database migrations and Drizzle ORM guidelines for the vm0 project
prisma-expert
davila7
Prisma ORM expert for schema design, migrations, query optimization, relations modeling, and database operations. Use PROACTIVELY for Prisma schema issues, migration problems, query performance, relation design, or database connection issues.
create-module
mx-space
Create a new NestJS module with controller, service, model, and schema files. Use when adding new feature modules, API endpoints, or business domains.
effect-patterns-streams-sinks
PaulJPhilp
Effect-TS patterns for Streams Sinks. Use when working with streams sinks in Effect-TS applications.
supabase-migration-deep-dive
jeremylongshore
Execute Supabase major re-architecture and migration strategies with strangler fig pattern. Use when migrating to or from Supabase, performing major version upgrades, or re-platforming existing integrations to Supabase. Trigger with phrases like "migrate supabase", "supabase migration", "switch to supabase", "supabase replatform", "supabase upgrade major".
alwib-backend
Tsuev
Develop and maintain the Alwib NestJS backend in `backend/`. Use when implementing or refactoring modules, controllers, services, DTO validation, Swagger docs, authentication/authorization flows, Prisma schema and migrations, and backend lint/test flows.