실제 제품을 적절한 조작 환경에서 실행해 변경 사항을 검증하고, 사용자가 확인할 수 있는 증거를 모아 인수 보고서로 게시합니다. 기존 검증 계획을 확인하거나 점검 항목을 만들고, `lh` CLI로 결과를 게시할 수 있습니다.
- 변경된 제품 동작을 실제로 확인하기
- 스크린샷 등 검증 증거 수집하기
- 검증 계획과 보고서 작성하기
Drizzle ORM으로 테이블, 열, 인덱스, 관계와 추론된 TypeScript 타입을 정의하고, 명시적인 조인과 집계를 포함한 스키마 기반 쿼리를 작성하도록 안내합니다. JSONB 타입과 원시 SQL 사용 기준도 다룹니다.
Use for Drizzle schemas and queries: tables, indexes, relations, joins and inferred types. Rollout belongs to db-migrations.
실행 점수 미검증
설치 방법 보기이 스킬은 Drizzle ORM으로 데이터베이스 스키마와 쿼리를 작성하는 방법을 안내합니다. 테이블과 열의 이름, 기본 키와 외래 키, 인덱스, 타임스탬프, JSONB 타입 지정 규칙을 다룹니다. 새 테이블에는 단일 열 기본 키를 사용하고, 업무상 고유성은 고유 인덱스로 표현하도록 권합니다. 쿼리는 주로 db.select() 빌더를 사용하며 조인, 집계, CTE 예시도 제공합니다. 재귀 CTE처럼 Drizzle로 표현하기 어려운 경우에는 스키마 참조를 유지하면서 원시 SQL을 사용하는 방법을 설명합니다.
데이터베이스 모델이나 저장소를 추가할 때, 기존 스키마 규칙에 맞출 때, 또는 db.query.*를 사용하는 코드를 명시적인 select와 조인으로 바꿀 때 참고할 수 있습니다. 마이그레이션 절차는 범위에 포함되지 않으며, 관련 내용은 db-migrations 스킬에서 다룹니다.
새 모델이나 저장소를 추가할 때는 같은 변경에 대응하는 테스트도 포함해야 합니다.
이 소개문은 공개된 SKILL.md를 바탕으로 AI(OpenAI Codex)가 작성했습니다. 정확한 사양은 아래 원문을 확인하세요.
AI가 SKILL.md의 내용에서 주의가 필요한 작업과 도입 전제를 확인했습니다. 점수는 실제로 실행한 결과(실행 검증)만으로 매깁니다.
점수에는 넣지 않았습니다. 2026년 10월 26일 기준으로 AI(OpenAI Codex)가 판정했습니다. 참고용이므로 설치 전에 원문도 확인해 주세요.
설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.
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.
drizzle.config.tspackages/database/src/schemas/packages/database/migrations/postgresql with strict: trueLocation: packages/database/src/schemas/_helpers.ts
timestamptz(name): Timestamp with timezonecreatedAt(), updatedAt(), accessedAt(): Standard timestamp columnstimestamps: Object with all three for easy spreadusers, session_groups)user_id, created_at)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', { ... });
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.
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
...timestamps, // Spread from _helpers.ts
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');
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.
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'),
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.
// Return array (object style deprecated)
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
export const insertAgentSchema = createInsertSchema(agents);
export type NewAgent = typeof agents.$inferInsert;
export type AgentItem = typeof agents.$inferSelect;
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)],
);
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,
),
],
);
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.
// ✅ 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),
});
// ✅ 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 },
});
// ✅ 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);
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:
execute.$with(...) + insert().select() / update().from() for multi-step
single-roundtrip writes Drizzle can express.execute<MyRow>(sql...) for safety — it types rows but doesn't keep
selected columns in sync with schema changes.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
`);
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 };
See the db-migrations skill for the detailed migration guide.
아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.
로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.
실제 제품을 적절한 조작 환경에서 실행해 변경 사항을 검증하고, 사용자가 확인할 수 있는 증거를 모아 인수 보고서로 게시합니다. 기존 검증 계획을 확인하거나 점검 항목을 만들고, `lh` CLI로 결과를 게시할 수 있습니다.
LobeHub에 AI 모델 제공자를 추가하는 과정을 안내합니다. 런타임 연동, 모델 정보와 설정, 브랜드 표시, 문서화 및 인수 확인을 다루며, 기존 제공자에 모델만 추가하는 경우와 새 연동이 필요한 경우를 구분합니다.
서버 환경 변수로 사용자 설정의 기본값을 지정하도록 구현하는 절차를 안내합니다. 변수 검증, 서버 설정 구성, 사용자 저장소 반영부터 예시 환경 파일과 셀프 호스팅 문서 갱신까지 다룹니다.
에이전트 실행 단계, 도구 호출, 사람의 승인, 컨텍스트 압축, 하위 에이전트 호출에 훅을 등록하는 방법을 안내합니다. 이벤트를 관찰하고 로컬 도구 결과를 모의하거나 설정에 따라 웹훅으로 도구 호출을 허용 또는 거부할 수 있습니다.
에이전트의 이벤트 기반 백그라운드 작업을 설계하고 구현하도록 돕습니다. 소스, 시그널, 액션, 정책의 역할을 나누고 비동기 처리, 중복 실행 방지, 완료 기록과 관측 기능을 다룹니다.
실제 데스크톱 채팅 앱에서 봇 채널의 인수 테스트를 진행합니다. Discord, Slack, Telegram, WeChat, Lark, QQ에서 메시지를 보내고 응답을 기다린 뒤 결과 화면을 캡처합니다. iMessage는 BlueBubbles 브리지를 사용합니다.