Database & drizzle-kit
How the data layer works — Drizzle schema, RLS, the Effect DB layer — and the drizzle-kit generate/migrate/push workflow and its production traps.
The data layer has four jobs, each in its own file, and it pays to keep them separate in your head:
- Describe the tables — Drizzle schema in
packages/db/src/schema/. - Decide who may read/write each row — Postgres RLS policies (shipped as
migration SQL) plus the
withRlsruntime helper inpackages/db/src/rls.ts. - Connect to Postgres from Effect — the
PgDrizzlelayer inpackages/db/src/effect/layer.ts, driven byConfig. - Query safely — one Effect repo per table in
packages/db/src/effect/repos/, wrapping Drizzle query builders.
Then there's the operational half: drizzle-kit turns your schema into
migrations and applies them. That's where the sharp edges live, so it gets its
own section at the end.
1. The Drizzle schema
A table is a plain pgTable call. Here is the whole todo table
(packages/db/src/schema/todo.ts):
export const todo = pgTable("todo", {
id: serial("id").primaryKey(),
text: text("text").notNull(),
completed: boolean("completed").default(false).notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Optional trip these todos belong to; the todo outlives the trip (SET NULL).
travelId: integer("travel_id").references(() => travel.id, { onDelete: "set null" }),
});Read it column by column:
serial("id").primaryKey()— an auto-incrementing integer PK..notNull()/.default(false)map straight to the SQL constraints, so the guarantee lives in the database, not in app code..references(() => user.id, { onDelete: "cascade" })is a real foreign key. The() =>thunk is deliberate — it defers resolvinguserso two tables can reference each other without an import cycle.cascadedeletes a user's todos with the user;set nullontravelIdkeeps the todo when its trip is deleted (the comment in the file spells out that intent).
Relations are declared separately with defineRelations, which is what powers
Drizzle's typed relational queries:
export const todoRelations = defineRelations({ todo, travel }, (r) => ({
travel: {
todos: r.many.todo(),
},
todo: {
travel: r.one.travel({
from: r.todo.travelId,
to: r.travel.id,
}),
},
}));from/to name the join columns explicitly — todo.travelId → travel.id — so
a travel row exposes .todos and a todo exposes its .travel.
Derive TypeScript types from the schema, never hand-write them. The table object is the single
source of truth: a Drizzle select() already returns correctly-typed rows, and typeof todo.$inferInsert / $inferSelect give you the insert/row shapes for free. A hand-maintained
duplicate type Todo is a bug waiting to drift.
2. Row-Level Security — two halves
RLS is where this repo's Supabase heritage shows. There are two pieces and they are easy to conflate:
The policies (Postgres SQL, in migrations)
The actual policies are plain SQL, shipped as a migration. This is from
packages/db/src/migrations/20260413170640_rls_anon_and_auth_tables/migration.sql:
-- auth_user_id(): read sub claim set by withRls
CREATE OR REPLACE FUNCTION auth_user_id() RETURNS text AS $$
SELECT current_setting('request.jwt.claims', true)::jsonb ->> 'sub'
$$ LANGUAGE sql STABLE;
CREATE POLICY "order: insert authenticated" ON "order"
FOR INSERT WITH CHECK (
auth_role() = 'authenticated' AND user_id = auth_user_id()
);The policy reads request.jwt.claims — a per-transaction Postgres setting — and
compares the sub (user id) claim against the row's user_id. Postgres itself
enforces this on every query; app code cannot forget to add a WHERE user_id = …. That's the whole point of RLS: the guarantee is in the database.
The runtime helper that sets the claims
For a policy to see a user, something must set request.jwt.claims before the
query runs. That's withRls in packages/db/src/rls.ts:
export async function withRls<T>(
database: NodePgDatabase,
userId: null | string,
callback: (tx: NodePgDatabase) => Promise<T>
): Promise<T> {
return database.transaction(async (tx) => {
const role = resolveRlsRole(userId);
assertRlsRole(role);
const claims = userId
? JSON.stringify({ sub: userId, role: "authenticated" })
: JSON.stringify({ role: "anon" });
await tx.execute(sql`SET LOCAL ROLE ${sql.raw(role)}`);
await tx.execute(sql`SELECT set_config('request.jwt.claims', ${claims}, true)`);
return callback(tx);
});
}Why every line matters:
- Everything runs inside
database.transaction(...)because the settings must be transaction-local — thetruethird argument toset_config(andSET LOCAL ROLE) scopes them to this transaction only, so a pooled connection never leaks one user's identity into the next request's query. resolveRlsRolemaps a nullableuserIdto"authenticated"or"anon", andassertRlsRoleis anasserts role is RlsRoleguard — a type-safe whitelist, sosql.raw(role)(raw string interpolation) can never inject an arbitrary role name.- The JSON claims mirror exactly what the SQL functions above read back:
sub→auth_user_id(),role→auth_role().
rls.ts runs on the classic async NodePgDatabase (the non-Effect db export). The Effect repos
below use a different connection (PgDrizzle) and connect as the schema owner — see the security
note at the end of section 4.
3. The Effect DB layer — Config-driven connection
The Effect side connects through packages/db/src/effect/layer.ts. The
connection string is read via Effect's Config, not process.env:
export const PgLive = PgClient.layerConfig({
url: Config.redacted("DATABASE_URL"),
idleTimeout: Config.succeed(PG_POOL_RESILIENCE.idleTimeout),
connectionTTL: Config.succeed(PG_POOL_RESILIENCE.connectionTTL),
connectTimeout: Config.succeed(PG_POOL_RESILIENCE.connectTimeout),
maxConnections: Config.succeed(PG_POOL_RESILIENCE.maxConnections),
});Config.redacted("DATABASE_URL")reads the env var through Effect's config system and wraps it in aRedactedso the connection string never lands in a log line or error dump by accident. Same pattern as everywhere else in the Effect codebase — no rawprocess.envin service code.- The pool tuning is not decoration. The comment in the file explains it: the
runtime's
PgClientpool is a long-livedglobalThissingleton, so withoutidleTimeout/connectionTTLrecycling it eventually hands a query a socket the Supavisor pooler already dropped, surfacing as "connection terminated". AndmaxConnections: 8is capped so this pool plus the better-auth pool stay under the pooler's session-modepool_sizeof 15 — oversubscribe it and every authed call hitsEMAXCONNSESSION.
The Drizzle surface itself is a canonical Effect service:
export class PgDrizzle extends Context.Service<PgDrizzle>()("@repo/db/PgDrizzle", {
make: makeWithDefaults(),
}) {
static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(PgLive));
}The Context.Service + static layer shape is the house pattern (see the
Effect v4 guide). The payoff is in the doc comment:
because it's backed by drizzle-orm/effect-postgres, the query builders
(db.select()…, db.insert()…) are Effects — they extend
Effect.Effect<Row[], …> directly, so you never wrap a query in
Effect.tryPromise. PgDrizzle.layer wires the Drizzle service on top of
PgLive so consumers get both from one layer.
4. An Effect repo wrapping Drizzle
Each table gets a repo — a Context.Service whose make yields PgDrizzle and
returns a bag of query functions. From packages/db/src/effect/repos/todo-repo.ts:
export class TodoRepo extends Context.Service<TodoRepo>()("@repo/db/TodoRepo", {
make: Effect.gen(function* () {
const db = yield* PgDrizzle;
const findById = Effect.fnUntraced(function* (id: number) {
const rows = yield* db.select().from(todo).where(eq(todo.id, id)).limit(1);
if (rows[0] === undefined) {
return yield* new DbNotFoundError({ table: "todo", id: String(id) });
}
return rows[0];
}, retryOnStaleConnection);
// …create, toggle, deleteById, listForUser …
return { create, deleteById, findById /* … */ } as const;
}),
}) {
static readonly layerWith = pgRepoLayerWith(this);
static readonly layer = this.layerWith(PgDrizzle.layer);
}What each part is doing:
const db = yield* PgDrizzlepulls the Drizzle service out of context once, at construction, and closes over it. Every query function reuses it.yield* db.select()…— noEffect.tryPromise. The query builder is already an Effect, soyield*runs it and hands back typed rows.- A missing row is a typed error, not a thrown exception or a silent
undefined:return yield* new DbNotFoundError({ … }).DbNotFoundErroris aSchema.TaggedErrorClass, so a consumer handles it withEffect.catchTag("DbNotFoundError", …)and TypeScript knows it can occur. - Every function is wrapped in
retryOnStaleConnection(frompackages/db/src/effect/connection-retry.ts). That retries only connection-class failures (a dead pooled socket,EMAXCONN, "connection terminated") with bounded exponential backoff — a deterministic failure like a unique-constraint violation is left to fail immediately, because retrying it would just fail identically. This is the query-time complement to the pool recycling inlayer.ts.
The layerWith boilerplate is factored out once in
packages/db/src/effect/repos/pg-repo-layer.ts — every PgDrizzle-only repo's
layer wiring was byte-identical apart from the class name:
export const pgRepoLayerWith =
<Self, Id extends string, Shape>(
service: Context.ServiceClass<Self, Id, Shape> & {
readonly make: Effect.Effect<Shape, never, PgDrizzle>;
}
) =>
<PE, PR>(pgDrizzleLayer: Layer.Layer<PgDrizzle, PE, PR>): Layer.Layer<Self, PE, PR> =>
Layer.effect(service, service.make).pipe(Layer.provide(pgDrizzleLayer));Passing this as a value (pgRepoLayerWith(this) in the class body) lets
Self/Shape be inferred from the argument, which sidesteps the explicit-type
friction a type-parameter-only helper would hit.
The Effect repos connect as the schema owner via DATABASE_URL and do not go through
withRls. Row ownership in a repo is enforced in the query — note findByIdForUser and
listForUser in todo-repo.ts always add eq(todo.userId, userId). So there are two separate
ownership mechanisms: Postgres RLS (for the withRls path) and explicit userId predicates (for
the Effect-repo path). When you add a repo method, the userId filter is not optional — it is the
access control.
5. The drizzle-kit workflow
drizzle.config.ts tells drizzle-kit where the schema is and where migrations
go (packages/db/drizzle.config.ts):
export default defineConfig({
schema: [
"./src/schema/agent-plan.ts",
// … every schema file listed explicitly …
"./src/schema/todo.ts",
"./src/schema/travel.ts",
],
out: "./src/migrations",
dialect: "postgresql",
dbCredentials: { url: keys().DATABASE_URL },
});Note the schema files are listed one by one, not via a glob. The comment in
the config explains why blog.ts is deliberately absent — it's a pure re-export
module, and listing it would make drizzle-kit re-scan its re-exported tables as
duplicates. When you add a new schema file, add it to this array.
Three commands, three very different jobs (run from the repo root; they proxy to
@repo/db via Turbo):
# 1. Diff schema against the last migration -> write a NEW migration SQL file.
# Reviews as a normal code change. Applies nothing.
pnpm db:generate
# 2. Apply pending migration files to the database in order, recording each in
# drizzle.__drizzle_migrations. This is what prod runs.
pnpm db:migrate
# 3. Diff schema against the LIVE database and mutate it in place. No migration
# file. DEV ONLY — rapid local iteration on a throwaway DB.
pnpm db:pushThe normal loop is: edit a schema file → pnpm db:generate → read the generated
SQL → commit it → pnpm db:migrate applies it. db:push is a shortcut for a
local database you don't mind rewriting; it never produces a reviewable
artifact.
NEVER drizzle-kit push against production, and never use it to "reconcile" a drifted prod
migration state. push diffs the Drizzle schema against the live database and drops anything
the schema does not model — and the Drizzle schema does not model your RLS policies or the
Supabase system views. A single db:push on prod silently DROPs every RLS policy (section 2)
and Supabase-managed view, leaving every table wide open. Reconcile a drifted prod state
additively with hand-written migration SQL, never with push.
drizzle-kit v1 keys drizzle.__drizzle_migrations by the name column. A database that was
built with db:push (or predates v1) has NULL in that name column, so db:migrate cannot
match already-applied migrations and replays every migration from the start — you'll see
relation "X" already exists. The fix is not to push. Reconcile additively, then rebuild the
tracking table so each row has its name, hash = sha256(migration.sql), and created_at set to
the migration folder's YYYYMMDDHHMMSS timestamp. See the "prod migrate fix drizzle-kit v1
name-baseline" vault note for the exact recipe.
See also
- How a request flows — watch a repo call run inside a real end-to-end request.
- Learn Effect v4 — the
Context.Service,Config, and typed-error patterns this layer is built on.