Skip to content
Chapter 37Lesson 11

Quiz - Schema as source of truth with Drizzle

Quiz progress

0 / 0

Your codebase has a public GET /api/invoices endpoint whose response hides internalNotes and organizationId, and a dashboard InvoiceSummary showing just id, status, and amountDue. Under Principle #2, how should each shape come to exist?

The API response is a hand-written DTO (a deliberately different contract); InvoiceSummary is composed from inferred pieces, e.g. Pick<Invoice, 'id' | 'status' | 'amountDue'>.
Both must be hand-written types — neither is the stored row, so deriving from the schema doesn’t apply to either.
Both must derive directly from $inferSelect with no narrowing — anything else violates the principle.

A new table is declared const tags = pgTable('tags', { ... }) but the author forgets the export. tsc stays green and the migrations run, yet a query against tags fails at runtime. Why?

The migration generator reads only the file’s exports, so the unexported table was never created — but the local const still type-checks, so tsc is happy.
An unexported pgTable is a compile error, so tsc should have failed first — something else is broken.
Migrations scan every pgTable call in the file, so the table was created but with no columns yet.

You’re modeling a webhook_deliveries table. You need to store a third-party webhook body, and you also frequently run WHERE-clause lookups by the event’s type field. What’s the senior choice?

Store the body in a jsonb column, and promote type to its own real column you can index and filter on.
Store the whole body in jsonb and reach into the JSON in every WHERE clause — keeping it in one column is simpler.
Store the body as json (not jsonb) so the raw text is preserved exactly, and filter on type inside it.

Your id column uses .$defaultFn(() => uuidv7()). A data-migration script seeds rows with a raw SQL INSERT that bypasses Drizzle and omits id. What happens, and why?

The insert fails — .$defaultFn runs inside Drizzle’s client, so a raw INSERT gets no id and Postgres rejects the missing required value.
The row inserts fine — .$defaultFn emits a SQL DEFAULT clause, so Postgres fills the id for any writer.
The row inserts with a NULL id — .$defaultFn only applies on reads, not writes.

You’re choosing the primary key for an append-only audit_logs table: very high volume, internal-only, never fetched by id from outside the database, with no sharding planned. Which strategy fits, and what’s the main reason?

bigint generatedAlwaysAsIdentity — 8 bytes instead of 16 keeps the index tighter, and the UUID’s non-enumeration benefit is moot since no outsider ever sees the id.
UUIDv7 — it’s the safe default for every table, and a high-volume table benefits most from time-sortable keys.
UUIDv4 — randomness spreads the writes across the index, which a high-volume insert path needs.

An invoice’s assignedToId references users.id with onDelete: 'set null'. Reviewing the schema, you notice assignedToId is declared .notNull(). What’s wrong?

set null must write a null when the user is deleted, which a .notNull() column forbids — Postgres rejects the contradiction. Drop .notNull(), or pick restrict/cascade if the relationship is truly required.
Nothing — set null overrides .notNull() at delete time, temporarily allowing the null.
Nothing — .notNull() just means the column can’t be null on insert; set null is free to null it later.

You add UNIQUE (organization_id, slug) to a pages table so each org’s slugs are unique. A bug report shows one org has three rows with slug = NULL. The constraint accepted all three. Why?

In SQL NULL is never equal to NULL, so a unique constraint treats every NULL as distinct and waves the duplicate rows through. The fix is usually upstream — make slug .notNull().
A composite unique only checks the first column, so the NULL slugs were never compared at all.
The unique constraint silently disables itself whenever any column in the tuple is NULL.

A memberships table started as a pure user-to-organization junction and then grew a role column. The team promotes it to an entity: it gains a surrogate id primary key, and the old composite primary key on (userId, organizationId) is replaced by a named unique(...) on the same pair. Why keep that unique after adding the id?

The composite PK was also enforcing “a user can’t join the same org twice”; the id doesn’t preserve that rule, so it survives as a unique to keep the invariant true.
It’s redundant belt-and-suspenders — the surrogate id already guarantees no duplicate (userId, organizationId) pairs.
A junction-turned-entity is required to have at least two unique constraints for Drizzle to traverse it.

You declare the forward relation invoices.organization with from/to, but leave organizations without an invoices relation, assuming Drizzle infers the reverse. Later org.invoices in a with returns nothing. What’s the lesson?

You must still declare both ends (the reverse can be a bare r.many.invoices()); an undeclared direction is a silent gap, not an inferred relation.
The reverse failed because the bare side also needs its own from/to — Drizzle never infers a join direction.
org.invoices returned nothing because the foreign key on invoices.organizationId needs an index before with can follow it.

For a users table with id (defaulted UUIDv7), email (.notNull(), no default), and emailLowercased (a .generatedAlwaysAs(...) column), how does each column appear on typeof users.$inferInsert?

id? optional (has a default), email required (NOT NULL, no default), and emailLowercased absent entirely (generated).
All three are optional, since the database can fill in any column it knows how to compute or default.
id and emailLowercased are both optional, and email is required — generated columns are optional, not omitted.

Quiz complete

Score by topic