Skip to content
cccskills
FreePublished on GitHubEnglish overview

drizzle

Use this skill to define Drizzle ORM tables, columns, indexes, relations, and inferred TypeScript types, then write schema-aware queries with explicit joins and aggregations. It also guides choices around typed JSONB fields and raw SQL.

OriginalShow the original description

Use for Drizzle schemas and queries: tables, indexes, relations, joins and inferred types. Rollout belongs to db-migrations.

lobehub/lobehub83,502 stars(GitHub 83,502 + cccskills 0)
Works withPostgreSQL

Run score Not tested

See how to install

Good for

  • Defining a new database table and its indexes
  • Writing typed queries with joins or aggregations
  • Reviewing Drizzle schema and query conventions

Overview

What it does

This skill provides guidance for defining database schemas and queries with Drizzle ORM. It covers table and column naming, primary and foreign keys, indexes, timestamps, and JSONB typing. For new tables, it recommends a single-column primary key and representing business uniqueness with a unique index. Queries generally use the db.select() builder, with examples for joins, aggregations, and CTEs. For recursive CTEs and other cases that are difficult to express in Drizzle, it explains how raw SQL can retain schema references.

When it helps

Use it when adding a database model or repository, matching an existing schema convention, or replacing code that uses db.query.* with explicit selects and joins. Migration procedures are outside its scope; the db-migrations skill covers those.

Example requests

  • “Review this table definition to match the existing naming and primary key conventions.”
  • “Rewrite this Drizzle query with the select builder and explicit joins.”

Notes

New models and repositories need corresponding tests in the same change.

This overview was written by AI (OpenAI Codex) from the public SKILL.md. Check the original below for the exact behavior.

Document check

AI checked the SKILL.md for operations that need care and for setup requirements. The score comes only from actually running the skill (hands-on test).

SafetyAre sending data, deleting files, and running commands disclosed?
AGoodThe supplied material gives no execution, sending, or deletion steps.
Easy setupDoes it need few tools or API keys, and are they documented?
AGoodIt names Drizzle ORM but lists no additional setup requirements.

Not counted in the score. Rated by AI (OpenAI Codex) as of Oct 26, 2026. Treat it as a guide and read the original before installing.

Disagree? Request a re-evaluation

Tell us which point is wrong and why. No login needed. We will check it against the SKILL.md, redo the evaluation, and show the result here.

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

Files included (1)

  • SKILL.md16.2 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

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/** or src/repositories/** needs a matching __tests__/<name>.test.ts. See the testing skill (.agents/skills/testing/references/db-model-test.md) for the getTestDB() integration pattern, user-isolation tests, the BM25 describe.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: postgresql with strict: true

Helper Functions

Location: packages/database/src/schemas/_helpers.ts

  • timestamptz(name): Timestamp with timezone
  • createdAt(), updatedAt(), accessedAt(): Standard timestamp columns
  • timestamps: 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 be workspace_xxx_logs, not workspace_xxx_records or another new synonym.
// ✅ Good: follows the existing user/workspace table family (names are
// illustrative — swap `widget` for the real noun).
export const userWidgetLogs = pgTable('user_widget_logs', { ... });
export const workspaceWidgetLogs = pgTable('workspace_widget_logs', { ... });

// ❌ Bad: introduces a new suffix for the same concept.
export const workspaceWidgetRecords = pgTable('workspace_widget_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 (migrations 0110–0111 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–0111 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 a custom-execution attempt actually runs
// (packages/database/src/schemas/agentIntervention.ts).
export const AGENT_INTERVENTION_CUSTOM_EXECUTION_STATES = ['pending', 'executing', 'completed'] as const;
export type AgentInterventionCustomExecutionState =
  (typeof AGENT_INTERVENTION_CUSTOM_EXECUTION_STATES)[number];

customExecutionState: text('custom_execution_state').$type<AgentInterventionCustomExecutionState>(),

// ❌ Bad: invents a new state that callers now need to handle everywhere.
export type AgentInterventionCustomExecutionState = 'pending' | 'executing' | 'completed' | 'unknown';

customExecutionState: text('custom_execution_state')
  .$type<AgentInterventionCustomExecutionState>()
  .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 fields
// (packages/database/src/schemas/agentIntervention.ts).
/**
 * Private resolution/outbox records. Unlike `agent_interventions`, this table
 * may retain user-edited arguments so a claimed decision can be delivered
 * reliably after the app disconnects. It is never a notification payload.
 */
export const agentInterventionResolutions = pgTable('agent_intervention_resolutions', {
  /** Owner of the durable operation, distinct from the resolving actor. */
  userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),

  /** Private executor result; never selected into Review or notification DTOs. */
  customExecutionResult: jsonb('custom_execution_result').$type<AgentInterventionCustomExecutionResult>(),

  /** Monotonic lease attempt, starting at zero while pending. */
  customExecutionAttempt: integer('custom_execution_attempt'),
});

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

// packages/types/src/agent/agentIntervention.ts +
// packages/database/src/schemas/agentIntervention.ts
interface AgentInterventionCustomExecutionResult {
  content: string;
  pluginState: Record<string, unknown>;
}

customExecutionResult: jsonb('custom_execution_result').$type<AgentInterventionCustomExecutionResult>(),
// ❌ Bad: hides the contract and makes downstream access untyped.
customExecutionResult: jsonb('custom_execution_result').$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(() => knowledgeBases.id, { onDelete: 'cascade' })
      .notNull(),
    userId: text('user_id')
      .references(() => users.id, { onDelete: 'cascade' })
      .notNull(),
    enabled: boolean('enabled').default(true),
    ...timestamps,
  },
  (t) => [
    uniqueIndex('agents_knowledge_bases_agent_id_knowledge_base_id_unique').on(
      t.agentId,
      t.knowledgeBaseId,
    ),
  ],
);

Query Style

Always use db.select() builder API. Never use db.query.* relational API (findMany, findFirst, with:).

The relational API generates complex lateral joins with json_build_array that are fragile and hard to debug.

Select Single Row

// ✅ Good
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
return result;

// ❌ Bad: relational API
return this.db.query.agents.findFirst({
  where: eq(agents.id, id),
});

Select with JOIN

// ✅ Good: explicit select + leftJoin
const rows = await this.db
  .select({
    runId: agentEvalRunTopics.runId,
    score: agentEvalRunTopics.score,
    testCase: agentEvalTestCases,
    topic: topics,
  })
  .from(agentEvalRunTopics)
  .leftJoin(agentEvalTestCases, eq(agentEvalRunTopics.testCaseId, agentEvalTestCases.id))
  .leftJoin(topics, eq(agentEvalRunTopics.topicId, topics.id))
  .where(eq(agentEvalRunTopics.runId, runId))
  .orderBy(asc(agentEvalRunTopics.createdAt));

// ❌ Bad: relational API with `with:`
return this.db.query.agentEvalRunTopics.findMany({
  where: eq(agentEvalRunTopics.runId, runId),
  with: { testCase: true, topic: true },
});

Select with Aggregation

// ✅ Good: select + leftJoin + groupBy
const rows = await this.db
  .select({
    id: agentEvalDatasets.id,
    name: agentEvalDatasets.name,
    testCaseCount: count(agentEvalTestCases.id).as('testCaseCount'),
  })
  .from(agentEvalDatasets)
  .leftJoin(agentEvalTestCases, eq(agentEvalDatasets.id, agentEvalTestCases.datasetId))
  .groupBy(agentEvalDatasets.id);

Raw SQL and Advanced Queries

Prefer Drizzle builders whenever the query reads clearly with select, insert().select(), update().from(), joins, CTEs, and groupBy — this keeps table/column references tied to schema, so changes surface as TypeScript errors. Within a builder, expression-level sql<T> is fine for features lacking a helper (JSON path, casts, aggregates, CASE, NOW()). Row locks are clauses, not expressions — use .for('update'), never raw FOR UPDATE.

Use COALESCE only when null-handling is part of required DB semantics (nullable JSONB append/merge, "keep first non-null"). Don't scatter COALESCE(excluded.col, current.col) across ordinary upsert scalars just to avoid an update object — build set from defined values only, and hide any remaining SQL behind named helpers (appendJsonbArray, mergeJsonbObject, keepFirstValue) so the method reads as business intent, not SQL plumbing.

// ✅ Scalars included only when present; SQL hidden behind a named helper.
// (`userWidgetLogs` is illustrative — swap for the real table.)
const updateValues = compactUndefined({
  email: record.email ?? undefined,
  ip: record.ip ?? undefined,
});
await db.insert(userWidgetLogs).values(values).onConflictDoUpdate({
  set: { ...updateValues, events: appendJsonbArray(userWidgetLogs.events, event), updatedAt: now },
  target: userWidgetLogs.id,
});

// ❌ Every scalar becomes SQL plumbing.
set: {
  email: sql`COALESCE(excluded.email, ${userWidgetLogs.email})`,
  ip: sql`COALESCE(excluded.ip, ${userWidgetLogs.ip})`,
}

When refactoring raw SQL:

  • Preserve query shape on latency-sensitive paths. If raw SQL is one roundtrip, don't split it into multiple depth-based queries just to drop execute.
  • Use $with(...) + insert().select() / update().from() for multi-step single-roundtrip writes Drizzle can express.
  • Don't rely on execute<MyRow>(sql...) for safety — it types rows but doesn't keep selected columns in sync with schema changes.
  • If only a PostgreSQL feature Drizzle can't express works, keep the raw SQL and tighten it: schema refs in interpolations, explicit user scope, a narrow row interface, and regression tests.

Recursive CTEs are the canonical "keep raw" case — there's no clean WITH RECURSIVE builder, and a rewrite would add depth-based roundtrips:

interface TaskTreeRow {
  id: string;
  parent_task_id: string | null;
}

// execute<T> acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the
// interpolations and scope every leg to the user.
const { rows } = await db.execute<TaskTreeRow>(sql`
  WITH RECURSIVE task_tree AS (
    SELECT ${tasks.id}, ${tasks.parentTaskId}
    FROM ${tasks}
    WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId}
    UNION ALL
    SELECT ${tasks.id}, ${tasks.parentTaskId}
    FROM ${tasks}
    JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id
    WHERE ${tasks.createdByUserId} = ${userId}
  )
  SELECT * FROM task_tree
`);

One-to-Many (Separate Queries)

When you need a parent record with its children, use two queries instead of relational with::

// ✅ Good: two simple queries
const [dataset] = await this.db
  .select()
  .from(agentEvalDatasets)
  .where(eq(agentEvalDatasets.id, id))
  .limit(1);

if (!dataset) return undefined;

const testCases = await this.db
  .select()
  .from(agentEvalTestCases)
  .where(eq(agentEvalTestCases.datasetId, id))
  .orderBy(asc(agentEvalTestCases.sortOrder));

return { ...dataset, testCases };

Database Migrations

See the db-migrations skill for the detailed migration guide.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

acceptance

FreeSummary

Use this skill to verify a delivery by exercising the real product through a suitable surface such as a CLI, web app, desktop app, or iOS Simulator. It helps create or find a verification plan, capture evidence, and publish an acceptance report.

  • Verifying a completed product change
  • Collecting screenshots or other evidence
  • Creating a verification plan and report
lobehub/lobehub83.5KUpdated Oct 26, 2026

Use this skill to add an AI model provider to LobeHub across runtime integration, model cards, settings, branding, documentation, and acceptance checks. It also covers provider documentation updates and distinguishes new providers from models added to an existing provider.

  • Integrating a new AI model provider into LobeHub
  • Updating setup or usage documentation for a provider
  • Checking whether a model needs a new provider integration
lobehub/lobehub83.5KUpdated Oct 26, 2026

add-setting-env

FreeSummary

Use this skill to add a server-side environment variable that sets a default for a user setting. It walks through validation, server configuration and store merging, plus updating the example environment file and self-hosting documentation.

  • Adding an environment variable for a setting default
  • Wiring a new setting domain into server config
  • Documenting a new variable for self-hosting
lobehub/lobehub83.5KUpdated Oct 26, 2026

Provides guidance for registering hooks around an agent’s execution steps, tool calls, human approvals, context compression, and sub-agent calls. Hooks can observe events, supply local mock tool results, or use webhooks to allow or deny tool results under configured policies.

  • Observing agent steps and tool errors
  • Mocking tool results during local execution
  • Requiring webhook decisions before or after a tool call
lobehub/lobehub83.5KUpdated Oct 26, 2026

agent-signal

FreeSummary

Build event-driven background workflows for agents by defining sources, interpreting them as signals, and running actions through policies. It also guides asynchronous handoffs, deduplication, scoped execution, completion receipts, and observability.

  • Adding a new event source to an agent workflow
  • Routing interpreted signals to concrete background actions
  • Enqueuing work for asynchronous processing
lobehub/lobehub83.5KUpdated Oct 26, 2026

Use this skill to run end-to-end acceptance checks for bot channels in real desktop chat apps. It drives apps such as Discord, Slack, Telegram, WeChat, Lark, and QQ to send a message, wait for a response, and capture the result.

  • Checking a bot response in a real Discord channel
  • Testing chat behavior through Slack or Telegram
  • Capturing a bot interaction in a native macOS app
lobehub/lobehub83.5KUpdated Oct 26, 2026

See all skills by lobehub

Report a problem with this skill