Skip to content

Stand up Supabase locally, with an empty database - #32

Merged
davidtaing merged 5 commits into
mainfrom
feat/supabase-local-stack
Aug 14, 2026
Merged

Stand up Supabase locally, with an empty database#32
davidtaing merged 5 commits into
mainfrom
feat/supabase-local-stack

Conversation

@davidtaing

@davidtaing davidtaing commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Adds the local Supabase stack and the wiring the app needs to talk to it. The database ships empty — there are no migrations, nothing queries anything, and that is the intended end state of this PR rather than an unfinished one.

Refs #31.

What is here

  • supabase as a devDependency, not a global install, for the same reason the Vercel CLI is one: in the lockfile it comes under the 7-day release floor and each bump is a reviewable diff, and a tool that writes migrations should not be whatever version a machine happens to have. It needs an onlyBuiltDependencies entry — its postinstall downloads the platform binary, and blocked, the install reports success while the failure surfaces much later at pnpm exec.
  • supabase/config.toml and the local stack, with pnpm db:start / db:stop / db:reset / db:types.
  • src/lib/supabase.ts — a lazy getClient() cached at module scope, per AGENTS.md, with persistSession and autoRefreshToken off because it is anonymous and shared across every request in the process.
  • src/lib/database.types.ts, generated by pnpm db:types. It describes no tables, which is the honest output of an empty schema.
  • .env.example, and a README setup section written from what was actually run — including the Docker prerequisite and why it differs by platform.

Why there is no table

An earlier revision of this branch had a connection_check table and a /connection-check page that read from it. Both are gone, and the reasoning is worth recording because it will come up again.

A health-check table would have to live in the migration history permanently, describing a table dropped a fortnight later, to prove something the first real query proves for free. Once it stopped being committed, the page could only ask whether Supabase answered at all — and doing that honestly needed a four-state probe, a raw fetch to sidestep a typed query builder that will not name a table it does not know, and a caveat that the local gateway does not validate the publishable key. A lot of apparatus to prove a URL resolves, all of it deleted by the first real read.

#31's "a Server Component reads something trivial from the local database" was written assuming the fixture would be committed. That assumption is gone, so the requirement goes with it rather than being met by machinery built for the purpose.

Two things found by running it rather than reading about it

A table created in a migration is unreadable through the API until it is granted, and the failure reads as a broken policy. Postgres checks the privilege before it evaluates RLS, so a correct policy with no grant returns 42501 permission denied and the policy is never consulted. The public schema carries two sets of default privileges:

owner supabase_admin  tables -> anon, authenticated get arwdDxtm  (everything)
owner postgres        tables -> anon, authenticated get Dxtm      (no read or write)

Migrations run as postgres. Dashboard-created tables land under the other set, which is why most advice never mentions grants. This is the same privilege layer the verified rule depends on, so it is in AGENTS.md even though the migration that taught it is gone.

NEXT_PUBLIC_* is inlined at build time into the server bundle too, not just the browser one. Confirmed by grepping .next/server after a build and finding the URL and key as string literals — so a built artifact ignores the environment it is later started with. The exception, and the thing that misled an earlier commit on this branch: a variable absent at build time does survive as a runtime lookup, which is why next build with nothing set still produces something that works when given values at run time. Consequence: pointing a build at a different project means rebuilding, and on Vercel the values must exist before vercel build — which vercel pull already ensures.

Checks

  • pnpm lint and pnpm build pass.
  • next build succeeds with no environment variables set — the point of the lazy client.
  • pnpm db:start from clean pulls the stack and comes up; db:reset and db:types work against it.

ESLint now ignores supabase/.temp, where the CLI writes a bundled Deno entrypoint while the stack runs — ESLint does not read nested .gitignore files, and without this pnpm lint fails with ~150 errors in a file nobody wrote.

Not here

The practitioners table, its RLS policies and the verified write protection — #9 owns the model and #14 the submission flow. Nothing has been pushed to the hosted project, so its migration history will start at that first real migration.

Adds the local half of the Supabase stack and proves it end to end: a
migration, a client, generated types, and a page that reads a row and
renders it. A connection nobody has queried is not a connection.

The CLI is a devDependency rather than a global install, for the same
reason the Vercel CLI is: in the lockfile it comes under the 7-day
release floor and each bump is a reviewable diff, and a tool that writes
migrations should not be whatever version a machine happens to have. It
needs an onlyBuiltDependencies entry — its postinstall downloads the
platform binary, and blocked, the install reports success and the failure
surfaces much later at pnpm exec.

Two things found by running it rather than by reading about it:

A table created in a migration is unreadable through the API until it is
granted, and the failure reads as a broken policy: Postgres checks the
privilege before it evaluates RLS, so a correct policy with no grant
returns 42501 and the policy is never consulted. The public schema
carries two sets of default privileges, and migrations run as postgres,
which is the set that grants anon no read at all. Dashboard-created
tables land under the other one, which is why most advice never mentions
grants. This is the same privilege layer the verified rule depends on, so
it is documented rather than just fixed.

NEXT_PUBLIC_ variables are inlined into the client bundle at build time,
but a server read during dynamic rendering still comes from the runtime
environment. So the build stays environment-free — verified by building
with none set — while the deployment genuinely needs them. That stops
being true the moment a client component reads the key, which is where
auth is heading.

/connection-check is dynamic via connection(), which keeps it out of
prerendering and is the case AGENTS.md reserves that call for. Everything
else stays static.

ESLint now ignores supabase/.temp: the CLI writes a bundled Deno
entrypoint there while the stack runs, and ESLint does not read nested
.gitignore files.

Both README.md and AGENTS.md claimed the build needs no environment
variables. Half of that is still true and the other half was load-bearing,
so both are corrected rather than deleted.

The practitioners table, its policies and the verified write protection
are deliberately not here — see #9 and #14. So is the hosted project.

Refs #31
The file asserted the local keys are identical on every machine. They
are, but the reason was worth checking rather than assuming: they are
string constants compiled into the CLI binary, verified by grepping it
for the publishable key, the secret key and the demo JWT secret. All
three are embedded.

That makes the pin in pnpm-lock.yaml the thing keeping it true, which is
another argument for the CLI being a devDependency rather than a global
install.
davidtaing added a commit that referenced this pull request Aug 14, 2026
Adds `supabase` and `supabase-postgres-best-practices` from
[supabase/agent-skills](https://github.com/supabase/agent-skills), so
guidance on RLS, migrations and Postgres schema work is in the repo
rather than in whatever each contributor happens to have installed
locally.

Split out of #32 to keep that PR to the walking skeleton. No dependency
between them; either can merge first.

## Why these two

- **`supabase`** — client libraries and `@supabase/ssr`, auth and
sessions, RLS, migrations, Edge Functions, and debugging workflows for
when something returns a status nobody expected.
- **`supabase-postgres-best-practices`** — Postgres rather than Supabase
specifically: column types, indexes, RLS policies and the tests that
prove them, and migration patterns that avoid taking a lock on a
production table.

The second is the more directly useful of the two here. The `verified`
column rule under Database in `AGENTS.md` — column privileges plus a
`before update` trigger, because RLS has no `OLD` to compare against —
is exactly the class of problem it covers, and #9 and #14 are heading
straight into it.

## How they are stored

Not as ordinary directories, which is the one thing worth a reviewer's
attention.

The real files live in `.agents/skills/<name>/`; `.claude/skills/<name>`
is a **symlink** into it. That lets one copy serve any agent tool that
reads its own directory instead of duplicating ~200 KB per tool. Git
stores the symlinks natively as mode `120000` — worth confirming they
resolve on your machine when you pull.

On Windows this needs `core.symlinks` enabled. The Node section already
points Windows contributors at WSL for unrelated reasons, so this is one
more, and `AGENTS.md` now says so.

`skills-lock.json` pins each skill by **content hash**, not version. Do
not trust the `version` field inside a `SKILL.md`: upstream leaves it
stale — `supabase` declares `0.1.2` in frontmatter against a changelog
whose newest entry is `0.1.7`. The hash is the real pin.

## Review note

44 files, but only two are ours to review: `AGENTS.md` and
`skills-lock.json`. Everything under `.agents/` is vendored upstream
content, and `AGENTS.md` now states that it is not ours to edit — a
local change there is reverted by the next update with no diff left to
explain it.

@davidtaing davidtaing left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — pass run by Codex (codex CLI), verified and posted by Claude Code (Opus 5). Every finding was checked against the source before posting. Caveat worth stating: this PR was authored in the same session, so the areas reported clean are weaker evidence than an independent review would be.

The load-bearing claims hold. I reproduced the environment-free build, the write refusals at the privilege layer, and the generated types matching the pinned CLI, independently of the PR description.

Codex's pass returned no findings. Both below came out of the verification step.

1 major, 1 minor.

Most useful next step: fix the db:types redirect before merge. It is the one thing here that destroys a committed file, and it does it on the ordinary mistake of forgetting pnpm db:start — with an error message that never mentions the file it just overwrote.

Comment thread package.json Outdated
Comment thread src/lib/supabase.ts Outdated
Two findings from the review on #32.

db:types redirected straight into src/lib/database.types.ts, so the
shell truncated the file before the CLI ran. The CLI writes its failure
to stdout, so a run with the stack down did not merely empty the file —
it replaced it with a JSON error object, and the only console output was
ELIFECYCLE with no mention of the file. The next build then failed with
type errors in a file nobody had edited. Forgetting db:start first is
the ordinary case, not an unusual one.

gen types has no --output flag, so the script writes to a sibling and
moves it into place only on success, removing the temporary file
otherwise. Verified both ways: with the stack down the committed file is
byte-identical afterwards and no stray file is left; with it up the
regenerated output matches what is committed. The README now says both
db:reset and db:types need the stack running.

The shared client took auth-js's defaults, which assume a browser where
one client belongs to one person. This one is cached at module scope and
serves every request in the process, so a session stored on it would be
visible to all of them. Nothing signs in through it today; the options
are set now because the next ticket adds auth and the path of least
resistance is to reuse the client that already exists. Signed-in reads
get their own cookie-backed client.
@davidtaing

Copy link
Copy Markdown
Collaborator Author

Review threads resolved — 6ade0a7

Both findings held, both fixed, both threads replied to and resolved. Nothing deferred, nothing rejected.

pnpm db:types destroying the types file (major). The script redirected straight into src/lib/database.types.ts, so the shell truncated it before the CLI ran — and since the CLI writes failures to stdout, a run with the stack down replaced the file with a JSON error object rather than merely emptying it. gen types has no --output <file> flag, so the script now writes to a sibling and moves it into place only on success.

Verified both directions rather than the happy path alone: stack down leaves the committed file byte-identical with no stray .new; stack up regenerates output identical to what is committed. The README scripts table now says db:reset and db:types both need the stack running, which was the gap that made the failure reachable.

Client inheriting auth-js browser defaults (minor). Now explicitly persistSession: false, autoRefreshToken: false. Latent rather than live — nothing signs in through this client today — but it is cached at module scope and shared across every request, and #14 adds auth, where the cheap move is to reuse the getClient() already here.

pnpm lint and pnpm build pass, and /connection-check still renders against the local stack.

Still open on this PR

Unchanged by the above, and not blockers for reviewing the code:

  • The migration has not been pushed to the hosted project, so Stand up the Supabase stack locally #31's "the same read works from a Vercel preview" is still unproven.
  • NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY need setting in Vercel's preview and production environments.

… rule

The connection_check migration is deliberately not pushed to the hosted
project. A health-check table that gets dropped once the practitioners
table lands would sit in the production migration history permanently,
and since nothing has been applied there yet, the migration can simply be
deleted when that happens rather than needing a second migration to undo
it. It stays a local fixture, rebuilt by db:reset.

That makes the page's two states wrong. Against the hosted project
PostgREST returns PGRST205, which means it answered and accepted the key
and only the table is absent — a pass for everything this page tests.
Reporting it as "Not connected" would call a working deployment broken,
which matters now that the Vercel variables are set. Three states now:
read, reachable, unreachable. Verified against all three, including the
real hosted project: local reads the row, hosted reports reachable with
PGRST205, a bad host reports unreachable with fetch failed.

Correcting a claim this PR previously made in AGENTS.md and README, which
was wrong in a way that would mislead. NEXT_PUBLIC_ variables are not
client-only at build time — Next inlines them by literal substitution
wherever they are defined, the server bundle included. Confirmed by
grepping .next/server after a build and finding the URL and key as string
literals. A built artifact therefore ignores the environment it is later
started with.

The earlier claim came from observing that a build made with no variables
does read them at run time. That is true, but it is the exception rather
than the rule: only a variable absent at build time survives as a runtime
lookup. Testing a build against a different project means rebuilding, and
on Vercel the values must exist before the build, which vercel pull
already ensures.
@davidtaing

Copy link
Copy Markdown
Collaborator Author

The migration is deliberately not being pushed — 70c22a3

Raised in review and it holds: a health-check table does not belong in the hosted migration history. It would sit there permanently to describe a table dropped a fortnight later, when the practitioners schema lands.

What makes this cheap: nothing has been applied to the hosted project yetsupabase migration list shows 20260814081052 local, remote empty. So the migration file can simply be deleted when the first real one arrives. No second migration to undo it, no reconciliation. The hosted history starts at the real schema, as if the fixture never existed. connection_check stays a local fixture that db:reset rebuilds.

Which made the page wrong

Against the hosted project PostgREST returns PGRST205 — it answered, accepted the publishable key, and only the table is absent. That is a pass for everything this page tests. The old two-state page called it "Not connected", i.e. reported a working deployment as broken. That matters now the Vercel variables are set.

Three states now, all verified end to end rather than reasoned about:

Scenario Reported
Local stack, table present Connected, row rendered
Hosted project, no schema Reachable — schema not deployed, PGRST205
Bad host Not connected, fetch failed

So #31's hosted half is now provable without putting anything in the production schema: a preview deployment showing "Reachable" is the honest claim that URL and key are right. The full read arrives with the first real table.

Correcting something this PR got wrong

Earlier commits claimed, in both AGENTS.md and README.md, that NEXT_PUBLIC_* is inlined into the client bundle while server reads come from the runtime environment. That is wrong, and wrong in a way that would mislead someone debugging a deployment.

Next inlines these by literal substitution wherever they are defined at build time, server bundle included — confirmed by grepping .next/server after a build and finding the URL and key as string literals. A built artifact ignores the environment it is later started with.

The original claim came from observing that a build made with no variables does read them at run time. True, but that is the exception, not the rule: only a variable absent at build time survives as a runtime lookup. I only caught it because testing the three states above by restarting one build with different variables produced three identical results.

Consequences, now written down: testing a build against a different project means rebuilding, and on Vercel the values must exist before the build — which vercel pull already ensures.

The connection_check table was invented to give the skeleton something to
read. It was never going to survive: the schema starts with
practitioners, and a health-check table would have sat in the migration
history permanently, describing a table dropped a fortnight later, to
prove something the first real query proves for free. It is out before it
was ever pushed, so the hosted history will start at the first real
migration with nothing to undo.

Removing it took the page's justification with it. Without a table, the
check could only ask whether Supabase answered at all, and doing that
honestly needed a probe distinguishing four outcomes, a raw fetch to
sidestep a typed query builder that will not name a table it does not
know, and a caveat that the local gateway does not validate the key. That
is a lot of apparatus to prove a URL resolves, and every line of it would
have been deleted by the first real read.

So the database ships empty. What remains is the part with a life beyond
this PR: the CLI under the release floor, the local stack, the lazy
client and its constraints, type generation, and the environment wiring.
database.types.ts describes no tables, which is the honest output of an
empty schema rather than a placeholder.

#31 asked for a Server Component reading something trivial, on the
assumption the fixture would be committed. That assumption is gone, so
the requirement goes with it rather than being satisfied by machinery
built for the purpose. The first query is the practitioners read.
@davidtaing davidtaing changed the title Stand up Supabase locally with a walking skeleton Stand up Supabase locally, with an empty database Aug 14, 2026
@davidtaing
davidtaing merged commit b28223b into main Aug 14, 2026
1 check passed
@davidtaing
davidtaing deleted the feat/supabase-local-stack branch August 14, 2026 11:02
davidtaing added a commit that referenced this pull request Aug 14, 2026
Two files, salvaged from #38 — which is closed, because the `keystones`
skill belongs in a personal skills repository rather than this one.
These two do not.

## The invariants

`AGENTS.md` gains an **Invariants beat scope** section. It is the
durable output of building the Supabase skeleton, and it holds
regardless of where the skill that produces invariants ends up living.

Scope is hard to set upfront, because the constraints that matter
usually only become visible once the work has started — so "scope harder
next time" is the wrong lesson to draw from work that sprawled.
Invariants do not have that problem: they say what is unacceptable
rather than what to build, so they can be stated before anyone knows the
shape of the answer.

The line carrying the most weight is the last one:

> When a premise is removed, revisit the requirement that rested on it
rather than building machinery to keep satisfying it.

#32 is cited as the worked example — a `connection_check` table added to
give a walking skeleton something to read, and when it was cut for not
belonging in migration history, the page that read it grew a four-state
probe rather than being cut too. Both went in the end. A rule with an
incident attached is easier to apply than one without.

## The ignore rule

Unrelated, but found the same way. `.claude/*` with `!.claude/skills/`.

Only `.claude/skills` is tracked and nothing else in there is shared:
local settings, session state, and worktrees — which carry their own
`.git` file, so `git add -A` stages one as an embedded repository and
would commit a broken pointer with none of the contents. Found by doing
exactly that while preparing #38.

One detail worth not undoing: it is `.claude/*`, **not** `.claude/`. A
trailing-slash ignore stops git descending into the directory at all,
and a negation cannot rescue what git never looked at — so the version
most people write first silently ignores the skills too. Verified with
`git check-ignore` rather than by reading it:

```
.claude/skills/code-tour/SKILL.md     visible
.claude/skills/supabase               visible
.claude/worktrees/ci-skip-draft-prs   IGNORED
.claude/settings.json                 IGNORED
```

Also confirmed nothing already tracked became ignored — the failure that
stays invisible until someone deletes a file and finds git no longer
cares.

Only `.claude/skills` is tracked today, so this changes nothing now and
prevents the accident later. If a settings file with a permissions
allowlist becomes worth sharing, add another `!` exception rather than
loosening the rule.

## Checks

`pnpm lint` passes. No application code touched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant