Authoring the schema and init migration
This is where the invoicing data model becomes real database structure. You will author six tables and their relations in db/schema.ts and db/relations.ts, generate one migration, read the SQL it emits, and apply it to the empty Postgres your Docker container is already running. When you finish, pnpm db:migrate will have created organizations, users, org_members, customers, invoices, and invoice_lines in one reviewed step, and a second pnpm db:generate will report no changes, the signal that Drizzle’s snapshot and your schema agree. The tables stay empty; data fills them in the next lesson.
Your mission
Section titled “Your mission”The schema is the source of truth for the project. Every row type and query in the lessons that follow derives from db/schema.ts, so you never hand-write a row type: declare the table, and $inferSelect returns a row shape that can never drift from the columns.
The starter supplies the tooling. drizzle.config.ts points Drizzle Kit at the unpooled URL, the schema path, and snake-case casing; src/db/index.ts exports the db client and a dbUnpooled alias with schema and relations wired in; and the db:generate / db:migrate / db:studio scripts run through dotenv-cli because Drizzle Kit and tsx do not read .env on their own.
The design decisions carry in from the Drizzle chapters. Every tenant-owned table carries organizationId as a NOT NULL foreign key; the one exception is users, which is global across organizations and reached through the org_members junction table. Primary keys are UUIDv7 generated by Postgres through default(sql`uuidv7()`), so they sort by creation time yet stay unguessable; timestamps are timestamp({ withTimezone: true }), money is numeric({ precision: 12, scale: 2 }) rather than a float, and memberRole and invoiceStatus are pgEnums so the database rejects an invalid status. ON DELETE is a per-edge decision: an owned child that means nothing without its parent cascades, while a referenced entity the schema needs (a customer, an invoice author) restricts so it cannot vanish under the rows pointing at it. Uniqueness and indexes are tenant-aware: an invoice number is unique within an organization, and each composite index’s column order matches the list query’s where plus orderBy direction so the planner can use it.
Reads and seeding come in the next two lessons. Avoid two traps: do not reach for drizzle-kit push, the prototype-only escape hatch this course replaces with the generate-and-commit loop, and do not migrate without reading the emitted SQL first.
pnpm db:migrate runs cleanly on the empty database and leaves exactly one row in __drizzle_migrations.ON DELETE), tenant-scoped uniques, the total >= 0 check, and the three named indexes with their DESC ordering.pnpm db:generate immediately after reports no changes: the snapshot is in sync with the schema.invoices → users edges resolve distinctly, so a nested read of an invoice’s author and its organization’s members does not cross-wire membership with authorship.queries.ts and the inspector consume; nothing downstream hand-types a row.Coding time
Section titled “Coding time”Write db/schema.ts and db/relations.ts against the reference signatures from the project overview and the Lesson 3 tests. Run pnpm db:generate --name init_schema, open the 0000_init_schema.sql it writes and read it end to end, then run pnpm db:migrate. Try the whole loop before opening the walkthrough.
Reference solution and walkthrough
src/db/schema.ts
Section titled “src/db/schema.ts”Build the file in foreign-key order: a table can only reference one that already exists, so organizations and users come first, then the junction and children that point back at them. The two pgEnums sit at the top so the columns that use them have them in scope.
Start with the enums and the two root tables. The named enums ('member_role', 'invoice_status') let the database reject an out-of-domain value. Note the ...timestamps spread from db/columns.ts: it adds the same createdAt column to every table from one definition, so the cursor’s sort key is identical everywhere.
import { sql } from 'drizzle-orm';import { check, index, integer, numeric, pgEnum, pgTable, primaryKey, text, timestamp, unique, uuid,} from 'drizzle-orm/pg-core';
import { timestamps } from '@/db/columns';
export const memberRole = pgEnum('member_role', ['owner', 'admin', 'member']);
export const invoiceStatus = pgEnum('invoice_status', [ 'draft', 'sent', 'paid', 'overdue',]);
export const organizations = pgTable('organizations', { id: uuid().primaryKey().default(sql`uuidv7()`), name: text().notNull(), slug: text().notNull().unique('organizations_slug_unique'), ...timestamps,});
export type Organization = typeof organizations.$inferSelect;export type NewOrganization = typeof organizations.$inferInsert;
export const users = pgTable('users', { id: uuid().primaryKey().default(sql`uuidv7()`), email: text().notNull().unique('users_email_unique'), name: text().notNull(), ...timestamps,});
export type User = typeof users.$inferSelect;export type NewUser = typeof users.$inferInsert;users is a stub. The authentication chapters own the real table through the Better Auth Drizzle adapter; here it gets just enough — id, unique email, name — for org_members.user_id and invoices.created_by to point at. The later table keeps these foreign-key targets, so swapping it in is additive.
Each table is followed by its $inferSelect and $inferInsert exports: the canonical row types. Organization is a row from a select, NewOrganization the shape you pass to an insert. Nothing downstream hand-types a row, so changing a column changes the types and any stale caller stops compiling.
Now the junction. org_members is the many-to-many between organizations and users, and it has no surrogate id — its identity is the pair of foreign keys, declared as a composite primary key in the second-argument callback. Both edges cascade: a membership is meaningless once either its organization or its user is gone.
export const orgMembers = pgTable( 'org_members', { organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), userId: uuid() .notNull() .references(() => users.id, { onDelete: 'cascade' }), role: memberRole().notNull(), ...timestamps, }, (t) => [primaryKey({ columns: [t.organizationId, t.userId] })],);
export type OrgMember = typeof orgMembers.$inferSelect;export type NewOrgMember = typeof orgMembers.$inferInsert;customers carries the tenant rule for the first time. organizationId is a NOT NULL foreign key that cascades, because a customer belongs to exactly one organization. The unique covers (organizationId, email), not email alone: an email is unique within a tenant. Two tenants can each have a billing@acme.test; the same tenant cannot.
export const customers = pgTable( 'customers', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), name: text().notNull(), email: text().notNull(), ...timestamps, }, (t) => [unique('customers_org_email_unique').on(t.organizationId, t.email)],);
export type Customer = typeof customers.$inferSelect;export type NewCustomer = typeof customers.$inferInsert;invoices carries every feature at once: all three kinds of foreign key, the enum-backed status, two money columns, the tenant-scoped unique, a check constraint, and three indexes.
export const invoices = pgTable( 'invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), customerId: uuid() .notNull() .references(() => customers.id, { onDelete: 'restrict' }), createdBy: uuid() .notNull() .references(() => users.id, { onDelete: 'restrict' }), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), currency: text().notNull().default('USD'), issuedAt: timestamp({ withTimezone: true }).notNull(), dueAt: timestamp({ withTimezone: true }).notNull(), ...timestamps, }, (t) => [ unique('invoices_org_number_unique').on(t.organizationId, t.number), check('invoices_total_nonneg', sql`${t.total} >= 0`), index('idx_invoices_org_status_created_at_id').on( t.organizationId, t.status, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_org_created_at_id').on( t.organizationId, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_customer_id').on(t.customerId), ],);
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;Three .references() edges, each with its own onDelete. The org owns the invoice, so organizationId cascades. The customer and the author are entities the invoice only points at, so customerId and createdBy restrict: deleting a customer who still has invoices is refused, because a dangling reference is corruption.
export const invoices = pgTable( 'invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), customerId: uuid() .notNull() .references(() => customers.id, { onDelete: 'restrict' }), createdBy: uuid() .notNull() .references(() => users.id, { onDelete: 'restrict' }), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), currency: text().notNull().default('USD'), issuedAt: timestamp({ withTimezone: true }).notNull(), dueAt: timestamp({ withTimezone: true }).notNull(), ...timestamps, }, (t) => [ unique('invoices_org_number_unique').on(t.organizationId, t.number), check('invoices_total_nonneg', sql`${t.total} >= 0`), index('idx_invoices_org_status_created_at_id').on( t.organizationId, t.status, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_org_created_at_id').on( t.organizationId, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_customer_id').on(t.customerId), ],);
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;The enum makes the database reject any status outside the four allowed values, and a new invoice starts as 'draft'.
export const invoices = pgTable( 'invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), customerId: uuid() .notNull() .references(() => customers.id, { onDelete: 'restrict' }), createdBy: uuid() .notNull() .references(() => users.id, { onDelete: 'restrict' }), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), currency: text().notNull().default('USD'), issuedAt: timestamp({ withTimezone: true }).notNull(), dueAt: timestamp({ withTimezone: true }).notNull(), ...timestamps, }, (t) => [ unique('invoices_org_number_unique').on(t.organizationId, t.number), check('invoices_total_nonneg', sql`${t.total} >= 0`), index('idx_invoices_org_status_created_at_id').on( t.organizationId, t.status, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_org_created_at_id').on( t.organizationId, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_customer_id').on(t.customerId), ],);
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;numeric({ precision: 12, scale: 2 }) for total, a 'USD' default for currency. Fixed-precision decimal, never a float, so cents never drift.
export const invoices = pgTable( 'invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), customerId: uuid() .notNull() .references(() => customers.id, { onDelete: 'restrict' }), createdBy: uuid() .notNull() .references(() => users.id, { onDelete: 'restrict' }), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), currency: text().notNull().default('USD'), issuedAt: timestamp({ withTimezone: true }).notNull(), dueAt: timestamp({ withTimezone: true }).notNull(), ...timestamps, }, (t) => [ unique('invoices_org_number_unique').on(t.organizationId, t.number), check('invoices_total_nonneg', sql`${t.total} >= 0`), index('idx_invoices_org_status_created_at_id').on( t.organizationId, t.status, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_org_created_at_id').on( t.organizationId, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_customer_id').on(t.customerId), ],);
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;unique('invoices_org_number_unique') scopes the number to (organizationId, number) — unique per tenant, like customers.
export const invoices = pgTable( 'invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), customerId: uuid() .notNull() .references(() => customers.id, { onDelete: 'restrict' }), createdBy: uuid() .notNull() .references(() => users.id, { onDelete: 'restrict' }), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), currency: text().notNull().default('USD'), issuedAt: timestamp({ withTimezone: true }).notNull(), dueAt: timestamp({ withTimezone: true }).notNull(), ...timestamps, }, (t) => [ unique('invoices_org_number_unique').on(t.organizationId, t.number), check('invoices_total_nonneg', sql`${t.total} >= 0`), index('idx_invoices_org_status_created_at_id').on( t.organizationId, t.status, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_org_created_at_id').on( t.organizationId, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_customer_id').on(t.customerId), ],);
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;The check refuses a negative total at the database level, so the invariant holds whatever code path writes the row.
export const invoices = pgTable( 'invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), customerId: uuid() .notNull() .references(() => customers.id, { onDelete: 'restrict' }), createdBy: uuid() .notNull() .references(() => users.id, { onDelete: 'restrict' }), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), currency: text().notNull().default('USD'), issuedAt: timestamp({ withTimezone: true }).notNull(), dueAt: timestamp({ withTimezone: true }).notNull(), ...timestamps, }, (t) => [ unique('invoices_org_number_unique').on(t.organizationId, t.number), check('invoices_total_nonneg', sql`${t.total} >= 0`), index('idx_invoices_org_status_created_at_id').on( t.organizationId, t.status, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_org_created_at_id').on( t.organizationId, t.createdAt.desc(), t.id.desc(), ), index('idx_invoices_customer_id').on(t.customerId), ],);
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;The composite indexes mirror the list query’s where + orderBy: tenant first, then the keyset (createdAt, id) descending. idx_invoices_customer_id serves the detail join. Whether the planner uses them is proven against a live EXPLAIN plan next lesson.
Decide each ON DELETE rather than defaulting them all: owned children (the four cascading edges above) cascade, referenced entities (customerId, createdBy) restrict.
The last table, invoice_lines, closes the chain. Each line cascades from its invoice, and (invoiceId, position) is unique, so two lines can never claim the same slot.
export const invoiceLines = pgTable( 'invoice_lines', { id: uuid().primaryKey().default(sql`uuidv7()`), invoiceId: uuid() .notNull() .references(() => invoices.id, { onDelete: 'cascade' }), description: text().notNull(), quantity: numeric({ precision: 12, scale: 2 }).notNull(), unitPrice: numeric({ precision: 12, scale: 2 }).notNull(), position: integer().notNull(), ...timestamps, }, (t) => [ unique('invoice_lines_invoice_position_unique').on(t.invoiceId, t.position), ],);
export type InvoiceLine = typeof invoiceLines.$inferSelect;export type NewInvoiceLine = typeof invoiceLines.$inferInsert;src/db/relations.ts
Section titled “src/db/relations.ts”The constraints above teach Postgres the foreign keys. This file teaches Drizzle’s relational query API the same edges, so a nested read like db.query.invoices.findFirst({ with: { customer: true, lines: true } }) knows how to assemble itself. You declare a relations() per table, pointing each edge’s fields/references at the columns that join.
The one edge that needs care is invoices to users. An invoice touches users twice: through its organization’s members, and through its author in createdBy. Undisambiguated, a nested with cannot tell the two apart. A matching relationName: 'createdByUser' on both sides of the author edge — usersRelations.invoices and invoicesRelations.createdByUser — pins them as one named relationship, separate from membership.
import { relations } from 'drizzle-orm';
import { customers, invoiceLines, invoices, organizations, orgMembers, users,} from '@/db/schema';
export const organizationsRelations = relations(organizations, ({ many }) => ({ members: many(orgMembers), customers: many(customers), invoices: many(invoices),}));
export const usersRelations = relations(users, ({ many }) => ({ members: many(orgMembers), invoices: many(invoices, { relationName: 'createdByUser' }),}));
export const orgMembersRelations = relations(orgMembers, ({ one }) => ({ organization: one(organizations, { fields: [orgMembers.organizationId], references: [organizations.id], }), user: one(users, { fields: [orgMembers.userId], references: [users.id], }),}));
export const customersRelations = relations(customers, ({ one, many }) => ({ organization: one(organizations, { fields: [customers.organizationId], references: [organizations.id], }), invoices: many(invoices),}));
export const invoicesRelations = relations(invoices, ({ one, many }) => ({ organization: one(organizations, { fields: [invoices.organizationId], references: [organizations.id], }), customer: one(customers, { fields: [invoices.customerId], references: [customers.id], }), createdByUser: one(users, { relationName: 'createdByUser', fields: [invoices.createdBy], references: [users.id], }), lines: many(invoiceLines),}));
export const invoiceLinesRelations = relations(invoiceLines, ({ one }) => ({ invoice: one(invoices, { fields: [invoiceLines.invoiceId], references: [invoices.id], }),}));These consts are not a separate drizzle() option. The provided db/index.ts spreads them into the schema object — drizzle(client, { schema: { ...tables, ...relations } }) — which is what wires up db.query.<table> with the relation graph.
Generate, read, then migrate
Section titled “Generate, read, then migrate”Generate the migration with a name so the file reads as intent, not a hash:
pnpm db:generate --name init_schemaThat writes drizzle/0000_init_schema.sql. Open it first. The structure is predictable: two CREATE TYPE ... AS ENUM, six CREATE TABLE, a block of ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY, then three CREATE INDEX. The part worth reading closely is the invoices table with its foreign keys and indexes:
CREATE TABLE "invoices" ( "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, "organization_id" uuid NOT NULL, "customer_id" uuid NOT NULL, "created_by" uuid NOT NULL, "number" text NOT NULL, "status" "invoice_status" DEFAULT 'draft' NOT NULL, "total" numeric(12, 2) NOT NULL, "currency" text DEFAULT 'USD' NOT NULL, "issued_at" timestamp with time zone NOT NULL, "due_at" timestamp with time zone NOT NULL, "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, CONSTRAINT "invoices_org_number_unique" UNIQUE("organization_id","number"), CONSTRAINT "invoices_total_nonneg" CHECK ("invoices"."total" >= 0));--> statement-breakpointALTER TABLE "invoices" ADD CONSTRAINT "invoices_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpointALTER TABLE "invoices" ADD CONSTRAINT "invoices_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpointALTER TABLE "invoices" ADD CONSTRAINT "invoices_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpointCREATE INDEX "idx_invoices_org_status_created_at_id" ON "invoices" USING btree ("organization_id","status","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpointCREATE INDEX "idx_invoices_org_created_at_id" ON "invoices" USING btree ("organization_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpointCREATE INDEX "idx_invoices_customer_id" ON "invoices" USING btree ("customer_id");Your per-edge choices are now DDL: ON DELETE cascade on the organization edge, ON DELETE restrict on customer and author, and DESC NULLS LAST on the index columns so the planner walks them in the direction the list query reads. This file is the diff a teammate reviews in the pull request, the last checkpoint before DDL hits a database.
Once it matches your intent, apply it:
pnpm db:migrateThat runs the file against the database and records it in the drizzle.__drizzle_migrations ledger. One file applied once leaves one row.
Reference for the foreign keys, unique constraints, check, and indexes you declare on these tables.
The relations() API, including the relationName tag that disambiguates the two invoices → users edges.
The generate-and-migrate workflow behind pnpm db:generate and pnpm db:migrate.
Moment of truth
Section titled “Moment of truth”Requirement 1 asserts exactly one row in the migration ledger, which only holds on a database migrated once from empty. So start clean: docker compose down -v && docker compose up -d discards the volume and brings back an empty Postgres, then run pnpm db:migrate once.
With the database freshly migrated, run the tests:
pnpm test:lesson 3The suite introspects the live database through information_schema and pg_catalog, asserting real Postgres state, not your source files. Both requirement groups should pass:
✓ tests/lessons/Lesson 3.test.ts (7) ✓ Requirement 1 — clean migrate leaves exactly one migration row (1) ✓ records exactly one applied migration in __drizzle_migrations ✓ Requirement 2 — six tables with their FKs, uniques, check, and indexes (6) ✓ creates exactly the six tables in the public schema ✓ points every foreign key at the right ON DELETE action ✓ scopes the uniqueness constraints to the tenant where the domain demands ✓ enforces the non-negative invoice total with a check constraint ✓ creates the three query-justified indexes with the right columns and DESC ordering
Test Files 1 passed (1) Tests 7 passed (7)The suite covers requirements 1 and 2; confirm the rest by hand, ticking each as you go:
pnpm db:generate immediately after migrating reports no changes — the schema and the stored snapshot agree (requirement 3).pnpm db:studio shows all six tables, and each invoices foreign key, the tenant-scoped uniques, the total >= 0 check, and the three indexes are present (requirement 2, seen with your own eyes in Studio).drizzle/0000_init_schema.sql, each foreign key carries its intended ON DELETE (cascade for owned children, restrict for the customer and author edges), each tenant-scoped unique covers the right columns, the check guards total >= 0, and the three indexes carry DESC NULLS LAST on their keyset columns.invoices → users edges distinctly — the author edge tagged relationName: 'createdByUser' on both sides — so a nested read of an invoice’s author and its org’s members does not cross-wire (requirement 4).