Supabase rules
This profile targets a Supabase backend: Postgres schema + RLS in supabase/migrations/, seed data in supabase/seed/, and Deno Edge Functions in supabase/functions/. The runtime is Deno, not Node — there’s no package.json/npm here. This runs on GitHub-hosted runners (no Apple toolchain).
Tooling — Deno, not npm
Section titled “Tooling — Deno, not npm”- Format / lint / type-check / test with the Deno toolchain, never npm:
deno fmt,deno lint,deno check,deno test --allow-all. A synceddeno.jsoncholds the fmt/lint config anddeno taskshortcuts. - Pin every remote import to an exact version —
https://deno.land/std@0.168.0/…,https://esm.sh/@supabase/supabase-js@2.39.0. Never import an unpinned URL; a moving dependency breaks reproducibility and is a supply-chain risk. - Prefer the
supabaseCLI for everything local:supabase start(boots Postgres + Studio + the functions runtime),supabase db reset(recreate + migrate + seed),supabase functions serve,supabase gen types typescript.
Migrations (supabase/migrations/)
Section titled “Migrations (supabase/migrations/)”- Idempotent:
create table if not exists,create or replace function,drop … if exists. A migration must be safe to re-run. enable row level securityimmediately on every new table — in the same migration that creates it. A table without RLS is a data leak.- Explicit foreign-key
on deletebehavior (cascade/set null/restrict) — never rely on the default. - Seed data lives in
supabase/seed/, never in a migration. Migrations are schema; seeds are data. - Migrations are forward-only and append-only once applied to a shared/remote DB — never edit a migration that has shipped; write a new one.
Row-Level Security (the security boundary)
Section titled “Row-Level Security (the security boundary)”- Every table has RLS enabled with explicit policies. Default-deny: no policy means no access.
- Published/public rows are readable by
anon; all user data is owner-scoped (auth.uid() = user_id). Writes are owner-scoped too. - The service-role key bypasses RLS — it’s server-only (Edge Functions, CI). It must never reach a client or a committed file. Clients use the
anonkey + the user’s JWT.
Edge Functions (supabase/functions/, Deno)
Section titled “Edge Functions (supabase/functions/, Deno)”- Route shared logic through
_shared/(e.g.r2.ts,errors.ts) — reuse the helpers, don’t reinvent CORS / error shapes / storage signing per function. - Auth on every endpoint: verify the caller’s JWT before doing work; gate premium/subscription features explicitly. Return the shared error shapes.
- Standard CORS headers on every response (including
OPTIONSpreflight). - Signed, expiring URLs for all object storage (R2 / Supabase Storage) — never hand out a public or long-lived URL. Apply per-user rate limits where relevant.
- Validate and narrow every input (auth header, body, params) at the top of the handler before touching the DB or storage.
Documentation
Section titled “Documentation”Documentation is a requirement — see core documentation rules. Two surfaces here, checked differently.
_shared/ — the code other functions import. Every export in
supabase/functions/_shared/ carries a JSDoc comment, checked by the local
pre-commit hook — there is no CI-side publishing step; this is a local,
pre-commit-enforced requirement only. Run it locally:
deno doc --lint supabase/functions/_shared/*.tsThe check is scoped to _shared/ deliberately: a function’s own index.ts is an
HTTP entry point, not an API other code imports, so an export-documentation rule
there produces ceremony rather than understanding. Document that in the handler’s
header comment — the method, the auth it requires, the request and response
shapes, and the failure codes.
The schema — comment on, not a wiki. Postgres stores documentation in the
database; use it, in the same migration that creates the object, so the comment
ships and versions with the schema:
comment on table public.sessions is 'One recorded session per user. RLS: owner-scoped.';comment on column public.sessions.ended_at is 'Null while the session is still running.';This is the only documentation a client library, supabase gen types, or someone
reading the schema in Studio will actually see. A column whose meaning isn’t
obvious from its name — a nullable flag, a unit, an enum-like text field, a
denormalized counter — needs one.
Secrets
Section titled “Secrets”- Server secrets (service-role key,
R2_*, third-party keys) live insupabase secrets set …for deployed functions and in GitHub Actions secrets for CI — and in a gitignored.env.localfor local dev. Commit.env.example. - Never log a secret or return it in a response.
Local checks vs CI
Section titled “Local checks vs CI”Every CI gate and where it runs before push. See core “Local checks match CI” — a new CI job adds a row here, and a hook never runs weaker than its CI twin.
| CI job / step | Local |
|---|---|
check → deno fmt --check |
pre-commit fmt |
check → deno lint |
pre-commit lint |
check → deno check |
pre-push typecheck |
check → deno test |
pre-push test |
deno doc --lint |
pre-commit docs; local only, no CI counterpart |
DB Lint (Splinter), DB Test (pgTAP) |
CI-only: both need a live Postgres. Run supabase db lint / supabase test db against supabase start when touching the schema. |
Deploy migrations |
CI-only, and only on merge to main — it is the step that touches production. |
No lacquer drift |
lacquer audit (exit 3) |
deno fmt --check and deno lint are not scoped to staged files — they check
everything in deno.jsonc’s include (supabase/functions/). So the first
commit after these hooks start running reports every unformatted file in the
tree, not just the one you touched. That’s the gate working, not a
misconfiguration: run deno fmt once to bring the tree into line, commit that on
its own, and it stays quiet after.
Deploying migrations
Section titled “Deploying migrations”supabase db push runs on merge to main, behind the schema jobs. A
migration can’t reach production without DB Lint (Splinter) and DB Tests (pgTAP) having passed on it, which is why the deploy job lives in ci.yml
rather than a workflow of its own — needs: can’t reach across files.
Configure either a direct connection string, or the link route. The job skips with a warning rather than failing when neither is present, so a project that hasn’t wired deployment up isn’t permanently red:
# Preferred: one secret, no linking stepgh secret set SUPABASE_DB_URL -R <owner>/<repo> # Dashboard → Settings → Database
# Or the link route, which needs all threegh secret set SUPABASE_ACCESS_TOKEN -R <owner>/<repo> # supabase.com/dashboard/account/tokensgh secret set SUPABASE_DB_PASSWORD -R <owner>/<repo> # the database passwordgh variable set SUPABASE_PROJECT_REF -R <owner>/<repo> # the ref in your project URLThe project ref is a variable, not a secret — it’s already public in your project URL, and a variable is readable in the workflow log, which is what you want when diagnosing a deploy that went to the wrong project.
--project-ref is not a flag on db push; it belongs to link, and push
names an already-linked project with --linked. Older CLI versions accepted it
on push, so a workflow copied from an older project fails with Unrecognized flag on its first merge.
When the push refuses: diverged history
Section titled “When the push refuses: diverged history”db push refuses, rather than guessing, when it finds a local migration that
sorts before the last one already applied remotely:
Found local migration files to be inserted before the last migration on remote database.Rerun the command with --include-all flag to apply these migrations: …That almost always means someone applied a migration by hand in the SQL
editor — the objects exist, but no row was written to
supabase_migrations.schema_migrations, so the CLI can’t tell the difference
between “already applied” and “skipped”.
Watch for this when onboarding an existing project onto the lacquer. One
project had this exact job hand-rolled inside its own ci.yml, and onboarding
retired that file wholesale — the deploy went with it while its secret stayed
configured, so nothing looked broken and migrations silently stopped shipping.
That is the failure this job in the profile exists to prevent: if it’s here, a
future sync restores it instead of dropping it.
Edge Functions are deliberately not deployed by CI. db push applies a
reviewed, ordered, append-only migration set; functions deploy swaps running
code. A project may reasonably want the first automatic and the second
deliberate, so run supabase functions deploy <name> yourself, or add the step
if you want it on merge.
Git hooks & commits
Section titled “Git hooks & commits”lefthook.yml is synced — install once with pnpm exec lefthook install (or brew install lefthook). It runs deno fmt --check + deno lint (scoped to the component via lefthook’s root:) and a secrets scan pre-commit, and enforces Conventional Commits via the shared scripts/check-commit-msg.sh.
Testing & CI
Section titled “Testing & CI”deno test --allow-allfor Edge Function logic; keep_shared/helpers unit-tested. The syncedsupabase-ci.ymlrunsdeno fmt --check,deno lint,deno check, anddeno testonubuntu-latest.- CI also checks the schema, not just the functions:
supabase db lint --level warning(Splinter — flags missing-RLS / security-definer issues) andsupabase test db(pgTAP againstsupabase/tests/*.sql). Write pgTAP tests that assert RLS actually denies cross-user access — the lint catches a missing policy, a test catches a wrong one. - An empty
supabase/tests/fails theDB Tests (pgTAP)job.supabase test dbexits 0 over a matchless glob, so without that check the job boots a full Postgres stack and reports green having asserted nothing. A project with no tests yet takes the standard time-boxed escape hatch, and the expiry is enforced:pgtap = { until = "YYYY-MM-DD", reason = "..." }under[baseline.relax]. - Remember that
TRUNCATEis not filtered by RLS. A table with RLS on and no policies still permitsTRUNCATEfrom any role holding the default grant, so lock the grant down as well as the policy — and assert it, since no policy can express it. - See the
supabase-postgres-best-practicesskill (in the skills catalog) for schema design, indexing, RLS performance, and query patterns.