Two numbers from Munchable's migration folder, both of which look like mistakes and are not:
$ grep -c "ENABLE ROW LEVEL SECURITY" *.sql | grep -v ":0"
0001_product_database.sql:6
0003_curious_pete_wisdom.sql:4
0005_rich_boomerang.sql:1
0007_shiny_malice.sql:1
0009_smart_serpent_society.sql:2
0014_support_and_email_preferences.sql:3
$ grep -c "CREATE POLICY" *.sql
# nothing
Seventeen tables with Row Level Security enabled, and zero policies across nineteen migrations.
RLS with no policies is the point
On Postgres, a table with RLS enabled and no policies attached denies every row to every role that is subject to RLS. Not "allows reads but not writes". Nothing.
That is precisely the desired state here. Supabase gives a project a REST API over its tables for the anon and authenticated roles, and the honest question for every table is not "what policy does this need" but "should this be reachable from a browser at all". For the schema holding product and curation data, the answer is no. The app does not query it from the client; a request from the phone goes to our own API route, which reads the database with a privileged connection as the table owner, and an owner is not subject to RLS.
So there are two locks. That schema is not exposed through the Data API at all, and every table in it also denies everything to the two roles an API key can reach. Neither lock depends on the other being right.
The reason to bother with the second one is that the first is a project setting, and project settings are edited in a dashboard by a human at 11pm. ALTER TABLE ... ENABLE ROW LEVEL SECURITY is in the migration history, reviewed, and replayable. If someone exposes the schema by accident, the tables still say no.
The anti-pattern this replaces is the tempting one: leave RLS off because "nothing queries it from the client anyway". A table with RLS off in an exposed schema is readable by anybody holding the public anon key, which is in every copy of the app.
Nothing applies these migrations except a person
This repository has three GitHub Actions workflows: a nightly database backup, an EAS build and an EAS update. None of them touches the schema.
pnpm db:generate runs drizzle-kit generate, which writes SQL into the migrations folder and does nothing else. Applying it is a separate command, run by hand, against a database whose connection string is not in CI at all. The project's schema document puts it bluntly: generated as file output only, applied by the founder via CLI, never in CI, never automated.
For a one-person project with a single production database, this is the correct trade and not a confession. An automated migration step buys you deploy-time convenience and costs you the moment where someone reads the generated SQL. Drizzle will happily emit a DROP COLUMN for a rename it did not understand, and the diff between "generated SQL I read" and "generated SQL that ran during a deploy" is the entire difference between a renamed column and a lost one.
The schema document is also prose, kept deliberately: eleven tables described in English, reviewed against schema.ts, with the code treated as authoritative when they disagree. It is the artifact that makes "read the generated SQL" a check rather than a vibe, because you can compare what the migration does to what the document says the shape is supposed to be.
The one file that is hand-edited
Migration 0001 opens with five lines nobody generated:
-- pg_trgm: text similarity for the photo-free consensus matcher
-- (...). Added by hand: drizzle-kit does
-- not model extensions.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
--> statement-breakpoint
CREATE SCHEMA "catalog";
drizzle-kit does not model Postgres extensions, so the CREATE EXTENSION for the trigram module was prepended by hand to a generated file. It works, it is idempotent, and it carries one permanent cost: the snapshot in meta/ does not know the extension exists, so nothing in the tooling will ever tell you it is a dependency. The comment is the only thing standing between a future me and a fresh database that is missing an extension three queries depend on.
If you take one practice from this post, make it that one. When you hand-edit generated output, the edit needs a comment explaining itself, because the tool will not remember and the diff will not either.
A small note on names
drizzle-kit names migrations after Marvel characters. 0003_curious_pete_wisdom, 0008_tranquil_doctor_octopus, 0010_redundant_maria_hill. Delightful, and useless the first time you have to say out loud which migration introduced the support tables.
Four of the nineteen have names a person chose: 0001_product_database, 0011_merge_products, 0014_support_and_email_preferences and one more. Renaming is not free, because the file name is a tag in meta/_journal.json and the two have to agree, so it is worth doing only for the migrations you expect to refer to again. In practice those are exactly the ones that changed the shape of something rather than adding a column.
The part of the product that needs none of this
Everything above serves a small number of routes. The pages a reader can actually browse do not touch the database at all: munchable.app/answers indexes a few hundred generated question pages that are built at build time, and munchable.app/conditions is seven editorial guides with no query behind them.
What the database holds is the short list in munchable.app/privacy: an email address, a subscription record, contributed product labels, and consent rows. No conditions, no verdicts, no photographs. The reason seventeen tables can deny everything to the public roles without breaking the app is that the thing people come for is computed on their own device.
Top comments (0)