ZenoZENO

Existing schema

Describe a Supabase database you already have, and prove the description matches

The rest of this guide is codebase-first: you declare tables in TypeScript, generate a migration, and apply it to Postgres. Many projects start the other way round, because the Supabase dashboard and SQL editor encourage it. If you already have a database, your schema files describe it rather than create it, and that changes how you write and check them.

Drizzle's Supabase with an existing database guide covers the drizzle-kit pull side. This page covers what Zeno adds on top: which helpers describe what Supabase creates, what must stay out of your schema barrel, and how to prove the description still matches.

Start from the database

pnpm exec drizzle-kit pull

pull introspects the live database and writes schema files from it. Treat the result as a starting point, and replace a pulled column with a Zeno helper wherever the helper emits the same SQL:

{project}-db/src/schemas/posts.ts
export const posts = table("posts", {
  id: sequentialPrimaryId(),
  ...timestamps(),
  authorId: userId(() => profiles.id),
})

Each helper emits the SQL Supabase itself produces, so the swap usually works. Where it does not, keep the column longhand rather than changing the database to suit the helper. Tables lists the options each helper takes.

Two defaults are worth checking against what you already have. sequentialPrimaryId() is bigint generated by default as identity, matching the table editor; if your ids came from somewhere else they may be integer or generated always, which the type and generated options cover. Author columns are nullable with on delete set null, and createdBy() defaults to referencing auth.users. Pass reference to point at your own profiles mirror, or reference: null if the column has no foreign key at all.

Keep Supabase-owned objects out of the barrel

drizzle-kit generate applies no entity filter. It passes () => true, and its config accepts neither schemaFilter nor entities, so nothing you put in drizzle.config.ts stops it emitting a Supabase-owned table. Neither does pgSchema("auth").existing(), which binds push and pull, not generate.

Such a table stays out of your migrations only if it never reaches the files your schema glob reads. So auth.users ships from its own entrypoint rather than from @zeno-lib/db/schema:

{project}-db/src/schemas/profiles.ts
import { authUsers } from "@zeno-lib/db/auth"

export const profiles = table("profiles", {
  id: uuidPrimaryId().references(() => authUsers.id, { onDelete: "cascade" }),
})

Referencing it is safe, because Drizzle Kit never collects a table reached only through a references() thunk. Re-exporting it is not. If src/schema.ts re-exports a module that exports authUsers, the next generated migration contains CREATE TABLE "auth"."users".

The same applies to a schema("auth") of your own. It emits CREATE SCHEMA by itself, independently of any table in it.

Relations live in a separate file, so check them too. defineRelations takes any record, so merge authUsers in there rather than through the barrel: defineRelations({ ...schema, users: authUsers }, ...). relations.ts is outside the schema glob, so Drizzle Kit never reads it.

Prove the description matches

Two commands answer two different questions, and for an existing database you want both.

push --explain checks the schema against the database

pnpm exec drizzle-kit push --explain

It introspects the live database, diffs your schema against it, prints the statements it would run, and returns before executing any of them. A clean description prints:

[i] No changes detected

This is the check that proves the description matches. push also reconciles implicit constraint names. An inline .references() produces a Drizzle-style posts_author_id_profiles_id_fk, where an existing database usually has posts_author_id_fkey, and push matches them by shape rather than reporting a difference.

Run it against a local database restored from your migrations, never a deployed one.

export --sql checks whether a refactor changed anything

pnpm exec drizzle-kit export --sql > before.sql
# make the change
pnpm exec drizzle-kit export --sql > after.sql
diff before.sql after.sql

export reads no database and writes no files. It prints the SQL for your schema as it stands, which makes it the right tool for a relative check: move a column onto a helper, then confirm the emitted SQL is byte-identical. It also catches things push deliberately hides, such as a table that silently lost its columns.

export compares text, so it reports differences Postgres does not see. functionPolicies emits (select "can_select_posts"("posts"."id")) where drizzle-kit pull writes ( SELECT can_select_posts(posts.id) AS can_select_posts). They are the same expression, because the second is how Postgres renders the first once stored. export shows a diff; push --explain correctly shows none. When the two disagree about policies, believe push.

Triggers are invisible to both

Drizzle Kit emits no trigger DDL and triggers are not in its snapshot format, so neither command can see them. If your database maintains updated_at with a trigger, adopting timestamps() does not replace it and no diff will tell you whether one exists. @zeno-lib/db/triggers prints the SQL, and Migrations covers getting it into a tracked migration.

When the helpers and the database disagree

Sometimes the helpers do not fit, and the database is right. The common case is a column that ends in a digit. Drizzle derives address_line1 from addressLine1, but the column is address_line_1, so it keeps an explicit name. Write the column longhand, and keep a note of every such place. A short document listing where your schema and the defaults diverge, and why, stops the next person from "fixing" a table into a migration against production.