Apply the db-schema-architect specialist workflow. Design schemas grounded in the factory's data-layer conventions, not generic SQL. Load factory-data-layer and factory-stack through the host's skill capability when needed.
How to think (in order)
What entities are in scope? Restate the request — name every entity, every relationship. If the user asks for "a customer model," check whether they mean (a) just customers, or (b) customers + addresses + contacts + .... Commit to the interpretation; flag the assumption.
Multi-tenant key? Almost always yes. Every domain table gets orgId (or workspaceId / projectId) FK with onDelete: 'cascade'. If this is a shared-reference table (countries, states, vehicle types), call out that it's tenant-agnostic.
Polymorphic? If entities share a base type but diverge significantly (ICE vs BEV vehicles, individual vs business accounts), use the shared-base + variant-tables pattern from factory-data-layer.md. Don't reach for nullable columns or discriminator: 'type'.
JSONB or columns? For each field, ask: "Does anything query / sort / filter on this?"
- Yes → real column
- No, but it's structured → JSONB envelope (
customAttributes, metadata, config)
- No, and it's blob-shaped → external storage (S3) with a pointer column
Partition by domain? Check src/server/db/schemas/:
- If
_shared.ts exists with timestamps and pgTableCreator, reuse them
- If not, create them as part of this work
- Pick / create the domain file (e.g.
fleet.ts, payments.ts)
Soft-delete or hard-delete? Default to hard-delete (with cascade). Use soft-delete when:
- Regulatory requirement (audit history must persist)
- User-facing "trash bin" UX
- References across tenants where hard delete would break referential integrity
Migration shape? drizzle-kit generate from the schema diff. Name the migration file:
- Timestamps:
<unix>_<verb_subject>.sql (preferred)
- Or sequential:
000N_<verb_subject>.sql
- Pick one convention per project and stick. Mixed naming is a
factory-pitfalls.md entry.
ESLint Drizzle rules? If eslint-plugin-drizzle isn't installed, recommend adding it for the WHERE enforcement on UPDATE/DELETE.
Reference: canonical schema file shape
// src/server/db/schemas/customers.ts
import { uuid, text, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { pgTable, timestamps } from './_shared';
import { organizations } from './auth';
export const customerStatus = pgEnum('customer_status', ['active', 'inactive', 'pending']);
export const customers = pgTable('customers', {
id: uuid('id').defaultRandom().primaryKey(),
orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),
name: text('name').notNull(),
email: text('email'),
status: customerStatus('status').notNull().default('pending'),
customAttributes: jsonb('custom_attributes').$type<Record<string, unknown>>().default({}),
...timestamps,
});
export type Customer = typeof customers.$inferSelect;
export type NewCustomer = typeof customers.$inferInsert;
// src/server/db/schemas/_shared.ts (create if missing)
import { pgTableCreator, timestamp } from 'drizzle-orm/pg-core';
export const pgTable = pgTableCreator((name) => `myapp_${name}`);
export const timestamps = {
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull().$onUpdate(() => new Date()),
};
Output format
## Restated request
<one sentence — entities, relationships>
## Entities
- <entity>: <fields, relationships, tenancy key>
## Schema decisions
- Multi-tenant key: <orgId / workspaceId / N/A — why>
- Polymorphic: <yes/no — and shape>
- JSONB fields: <which fields, why>
- Soft-delete: <yes/no — why>
- Migration naming: <timestamps / sequential — flag if pre-existing convention differs>
## Files to create or modify
<bulleted with paths>
## Schema code
<actual Drizzle code, organized by file>
## Migration plan
<drizzle-kit generate command + resulting migration filename>
## ESLint check
- WHERE enforcement on UPDATE/DELETE: <enabled / recommend enabling>
## Open questions
<things the user should confirm>
What you do NOT do
- Don't skip the org FK on domain tables. Every tenant-scoped table has it with
onDelete: 'cascade'.
- Don't put the whole schema in one file. Domain-partitioned modules.
- Don't define entity types separately from the schema. Use
$inferSelect / $inferInsert.
- Don't allow nullable-column proliferation. Polymorphic? Use shared-base + variants.
- Don't mix migration-file naming conventions within a project.
- Don't run migrations at runtime. CI's job — see
factory-deployment.md.
- Don't put queryable data in JSONB. If something filters / sorts on it, it's a column.
- Don't reach for raw SQL. Drizzle handles everything Postgres can do.
- Don't add
updatedAt manually. Use the timestamps spread from _shared.ts.
When the request is too small for this framework
If the user asks to add a single column to an existing table, do it directly with a migration. The framework is for new tables, new entities, or non-trivial schema evolution.
1---2name: factory-db-schema-architect3description: Use when designing or modifying database schemas, migrations, multi-tenant data models, or polymorphic table structures. Carries the factory's data-layer conventions — Drizzle with domain-partitioned schema modules, `_shared.ts` with `timestamps` helper and `pgTableCreator`, org-keyed FKs with cascade delete, JSONB envelope for non-query-driving data, polymorphic table patterns (shared base + variant tables), schema-derived type exports, ESLint Drizzle WHERE-enforcement, soft-delete mixin (Python). Produces schema files that fit the house style — not generic Postgres tables.4---56Apply the **db-schema-architect** specialist workflow. Design schemas grounded in the factory's data-layer conventions, not generic SQL. Load `factory-data-layer` and `factory-stack` through the host's skill capability when needed.78## How to think (in order)9101. **What entities are in scope?** Restate the request — name every entity, every relationship. If the user asks for "a customer model," check whether they mean (a) just `customers`, or (b) `customers + addresses + contacts + ...`. Commit to the interpretation; flag the assumption.11122. **Multi-tenant key?** Almost always yes. Every domain table gets `orgId` (or `workspaceId` / `projectId`) FK with `onDelete: 'cascade'`. If this is a shared-reference table (countries, states, vehicle types), call out that it's tenant-agnostic.13143. **Polymorphic?** If entities share a base type but diverge significantly (ICE vs BEV vehicles, individual vs business accounts), use the shared-base + variant-tables pattern from `factory-data-layer.md`. Don't reach for nullable columns or `discriminator: 'type'`.15164. **JSONB or columns?** For each field, ask: "Does anything query / sort / filter on this?"17 - **Yes** → real column18 - **No, but it's structured** → JSONB envelope (`customAttributes`, `metadata`, `config`)19 - **No, and it's blob-shaped** → external storage (S3) with a pointer column20215. **Partition by domain?** Check `src/server/db/schemas/`:22 - If `_shared.ts` exists with `timestamps` and `pgTableCreator`, reuse them23 - If not, create them as part of this work24 - Pick / create the domain file (e.g. `fleet.ts`, `payments.ts`)25266. **Soft-delete or hard-delete?** Default to hard-delete (with cascade). Use soft-delete when:27 - Regulatory requirement (audit history must persist)28 - User-facing "trash bin" UX29 - References across tenants where hard delete would break referential integrity30317. **Migration shape?** `drizzle-kit generate` from the schema diff. Name the migration file:32 - Timestamps: `<unix>_<verb_subject>.sql` (preferred)33 - Or sequential: `000N_<verb_subject>.sql`34 - **Pick one convention per project and stick.** Mixed naming is a `factory-pitfalls.md` entry.35368. **ESLint Drizzle rules?** If `eslint-plugin-drizzle` isn't installed, recommend adding it for the WHERE enforcement on UPDATE/DELETE.3738## Reference: canonical schema file shape3940```ts41// src/server/db/schemas/customers.ts42import { uuid, text, jsonb, pgEnum } from 'drizzle-orm/pg-core';43import { pgTable, timestamps } from './_shared';44import { organizations } from './auth';4546export const customerStatus = pgEnum('customer_status', ['active', 'inactive', 'pending']);4748export const customers = pgTable('customers', {49 id: uuid('id').defaultRandom().primaryKey(),50 orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'cascade' }).notNull(),51 name: text('name').notNull(),52 email: text('email'),53 status: customerStatus('status').notNull().default('pending'),54 customAttributes: jsonb('custom_attributes').$type<Record<string, unknown>>().default({}),55 ...timestamps,56});5758export type Customer = typeof customers.$inferSelect;59export type NewCustomer = typeof customers.$inferInsert;60```6162```ts63// src/server/db/schemas/_shared.ts (create if missing)64import { pgTableCreator, timestamp } from 'drizzle-orm/pg-core';6566export const pgTable = pgTableCreator((name) => `myapp_${name}`);6768export const timestamps = {69 createdAt: timestamp('created_at').defaultNow().notNull(),70 updatedAt: timestamp('updated_at').defaultNow().notNull().$onUpdate(() => new Date()),71};72```7374## Output format7576```77## Restated request78<one sentence — entities, relationships>7980## Entities81- <entity>: <fields, relationships, tenancy key>8283## Schema decisions84- Multi-tenant key: <orgId / workspaceId / N/A — why>85- Polymorphic: <yes/no — and shape>86- JSONB fields: <which fields, why>87- Soft-delete: <yes/no — why>88- Migration naming: <timestamps / sequential — flag if pre-existing convention differs>8990## Files to create or modify91<bulleted with paths>9293## Schema code94<actual Drizzle code, organized by file>9596## Migration plan97<drizzle-kit generate command + resulting migration filename>9899## ESLint check100- WHERE enforcement on UPDATE/DELETE: <enabled / recommend enabling>101102## Open questions103<things the user should confirm>104```105106## What you do NOT do107108- **Don't skip the org FK on domain tables.** Every tenant-scoped table has it with `onDelete: 'cascade'`.109- **Don't put the whole schema in one file.** Domain-partitioned modules.110- **Don't define entity types separately from the schema.** Use `$inferSelect` / `$inferInsert`.111- **Don't allow nullable-column proliferation.** Polymorphic? Use shared-base + variants.112- **Don't mix migration-file naming conventions** within a project.113- **Don't run migrations at runtime.** CI's job — see `factory-deployment.md`.114- **Don't put queryable data in JSONB.** If something filters / sorts on it, it's a column.115- **Don't reach for raw SQL.** Drizzle handles everything Postgres can do.116- **Don't add `updatedAt` manually.** Use the `timestamps` spread from `_shared.ts`.117118## When the request is too small for this framework119120If the user asks to add a single column to an existing table, do it directly with a migration. The framework is for new tables, new entities, or non-trivial schema evolution.