Appearance
Data Access Strategy
This is the single most important rule in the codebase.
supabase.from() is banned in app code. It keeps table names and schema off the network wire. Every data path is one of exactly two shapes.
The seam is packages/data — one directory per domain with a typed interface (service.ts) plus a stub (service.stub.ts), so presentation depends only on the interface and never learns whether Supabase or a stub is behind it. Both apps/mobile and apps/web consume the same services; the data logic is written once.
Which services are actually wired is not obvious — count the barrel, never a doc line
packages/data/src/index.ts decides. Run grep -nE "from './[a-z]+/service" packages/data/src/index.ts; whichever impl the barrel exports is what the app runs. A service.stub.ts sitting beside a service.supabase.ts tells you nothing about which one is live, and a screen backed by a stub has proven nothing about the backend.
The decision rule
Pure SQL read + no secrets + no external HTTP → RPC. Everything else → Edge Function.
Reads → TanStack Query + RPC
⚠ The diagram above never rendered until 2026-08-08. Its link label was
|supabase.rpc('get_*')|, and the(inside a pipe label is a Mermaid parse error — so this page showed an error box where the read path should be. Found by parsing every fence in the portal with the real Mermaid parser rather than by looking at the page. 66 diagrams, this was the only broken one.
useQuerycalls apackages/dataservice that wrapssupabase.rpc('get_…').supabase.rpc()appears only in services, never in components, screens or JSX.- RPCs are
SECURITY DEFINER+SET search_path = public, then revoked frompublic,anonandauthenticatedBY NAME before an explicitGRANT EXECUTE. Revoking only fromPUBLICdoes not work — Supabase grants those two roles explicitly, which is the root cause of QRS-214. Gated bynpm run check:sql. - Standard hook shape:
{ data, isLoading, error, refetch }, per-featurequeryKey,staleTimeby data type. - Use
row_to_json(table.*)in RPCs for forward-compat (don't project bare column lists that leak schema shape). - Domain logic stays out of the data layer. Services return stored rows as-is; derivation (recurrence → due dates, DST, thresholds, scoring) lives in
@qrsetu/domainas pure functions unit-tested withnode --testand no database.
Writes / secrets / external HTTP / multi-step → Edge Function
useMutationcalls thepackages/dataservice →functions.invoke(EDGE_FN.X).supabase.functions.invoke()appears only in services.- Invalidate queries on success so reads refetch.
- EF names come from a per-domain
*_EDGE_FNmap exported by the service — never an inline string at a call site. - Mutations take a REQUIRED
idempotencyKey, not an optional one. Generate it withcrypto.randomUUID()when the user commits the action and reuse the same key across retries — a fresh key per retry defeats the whole mechanism. This is not boilerplate: 6 of 11 rows in the legacy production reminders table were double-tap duplicates (QRS-210).
ts
// packages/data/src/reminders/service.ts
export const REMINDERS_EDGE_FN = { MANAGE_REMINDER: 'manage-reminder' } as const
// the call site
await supabase.functions.invoke(REMINDERS_EDGE_FN.MANAGE_REMINDER, {
body: { ...payload, idempotencyKey },
})Defense in depth
RLS is not the primary write gate — it is defense-in-depth. The Edge Function is the primary write-enforcement layer. Both are always required: an EF validates + authorizes + mutates, and RLS still guards the table underneath.
The [TRANSITIONAL] note is resolved — corrected 2026-08-08
This section previously read "~56 files still call from() directly" and told you to converge two caching layers off src/lib/cacheUtils.js. Both statements are about legacy/. The retired Vite SPA is where those ~56 callers and cacheUtils.js live; it is not linted by the root gate, not built, and not a workspace. In apps/* and packages/* the rule holds with no exceptions, and the guardrail banning from() in app code is live in tooling/eslint-config/guardrails.js.
Why this matters
| Benefit | How the rule delivers it |
|---|---|
| Security | Table names and schema never appear on the wire; RLS + EF enforce access twice. |
| Write-once data logic | Services are DOM-free and platform-agnostic, so apps/mobile and apps/web share them unchanged. This is the whole reason only UI is written twice. |
| Performance | Composite RPCs avoid ≥3 parallel read RPCs saturating the connection pool on multi-section screens. |
| Refactor safety | row_to_json projections are forward-compatible; callers don't break on a column change. |
| Testability | A feature can be built and tested against the stub before any table exists — which is exactly the position the whole app is in while the v2 baseline is unapplied. |
The single hard rule, as the operating manual stated it
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "Data access — the single hard rule" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
Data access — the single hard rule [CONVENTION, currently UNGATED — see the warning]
supabase.from() is banned in app code. It keeps table names off the network wire.
⚠ THIS RULE IS HONOURED BUT NOT ENFORCED, and it was tagged [ENFORCED] until 2026-08-12. Compliance is perfect — a sweep of
apps/**+packages/**finds zero.from('table')calls (every.from(hit isArray.from/Buffer.from); the real ones live only inlegacy/,nefoxx-reference-docs/and the archived EFs. But nothing checks it.tooling/eslint-config/guardrails.jsstill lists the ban under "Deliberately NOT included yet … Activate WITH the code they guard: nosupabase.from()in app/feature code → lands withpackages/data". That trigger condition has been met —packages/dataexists with 14 real Supabase services (ls packages/data/src/*/service.supabase.ts) andapps/webalready consumes one — so the deferral has expired and the rule has not landed. This is exactly the shape of QRS-013 (a lint gate that passed as a green no-op) and QRS-246 (SonarQube documented for months, implemented by nothing): a standard with no gate decays, and the fact that nobody has broken it yet is not evidence that it is enforced. Add the rule; do not read the clean sweep as a passing gate.
- The seam is
packages/data, one directory per domain with a typed interface (service.ts) plus a stub and/or a Supabase impl. Presentation depends only on the interface, so the two are interchangeable. The seam list and count are in the measured inventory at the top of this file (it said "Thirteen" until 2026-08-13, omittingordersandsetuCardEditor). Which of them are actually Supabase-backed is a separate question with a different answer — see "Supabase client" below, and check the barrel rather than this list. - Reads → TanStack Query
useQueryin a feature hook, calling the service, which wrapssupabase.rpc('get_…'). RPC only in services, never components/JSX. RPCs areSECURITY DEFINER+SET search_path = public+REVOKE ALL FROM PUBLIC/GRANT EXECUTE(ADR-0014, gated bycheck:sql). Standard hook shape{ data, isLoading, error, refetch }, per-featurequeryKey,staleTimeby data type. - Writes / secrets / external HTTP / multi-step →
useMutation→ the service →functions.invoke(EDGE_FN.X). Invalidate queries on success. EF names come from a per-domain*_EDGE_FNmap exported by the service (e.g.REMINDERS_EDGE_FN.MANAGE_REMINDER) — never an inline string at a call site. - Mutations take a REQUIRED
idempotencyKey, not an optional one. Generate withcrypto.randomUUID()when the user commits the action and reuse the same key across retries — a fresh key per retry defeats the mechanism. This is not boilerplate: 6 of 11 rows in the legacy production reminders table are double-tap duplicates (QRS-210). - Domain logic stays out of the data layer. Services return stored rows as-is; expansion/derivation (e.g. recurrence → due dates, DST) lives in
@qrsetu/domainas pure, unit-tested functions — that is what lets behaviour be tested withnode --testand no database. See ADR-0016. - Zod schemas in
packages/schemasuse the exact DB column names.
Decision rule: pure SQL read + no secrets + no external HTTP → RPC; everything else → Edge Function. RLS is defense-in-depth; the EF is the primary write-enforcement layer — both always required.
Supabase client wiring and the real-versus-stub split
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "Supabase client — WIRED; the real-vs-stub split is in the measured inventory" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
Supabase client — WIRED; the real-vs-stub split is in the measured inventory [gated 2026-08-13]
The client seam is live (this section previously said "NOT YET WIRED"; that was true until QRS-261/ADR-0018 and is no longer). packages/data/src/supabaseClient.ts owns the Supabase client construction — ⚠ not "the one createClient() call"; there are several in that module (measured 2026-08-28), and the file explains why. It is a DI'd factory, because the session storage adapter is platform-specific (expo-secure-store on native via apps/mobile/src/lib/sessionVault.ts, localStorage on web) while packages/* must stay platform-agnostic. initSupabaseClient() throws on a missing url/key rather than defaulting — no hardcoded production-fallback URL, so a cold CI/test stack can never silently reach a real project.
Which services are real matters far more than "is the client wired", and the split is not obvious.Both lists live in the measured inventory at the top of this file and are re-derived on every push — do not restate them here. A screen backed by a stub-only seam has proven nothing about the backend, which is the rule QRS-636 violated: auth was on the REAL list and still called an RPC that existed only in the archive, because "the impl is Supabase-backed" and "the impl talks to a function that exists" are different claims and only the first was ever checked.
Two of the real ones export no bound singleton, deliberately, and a call site that expects one will not compile: publicSetuCard exposes only createSupabaseSetuCardService(client) because apps/web's SSR loaders have no long-lived process for a module-level instance to be safe in. ⚠ auth is NOT one of them — it DOES export a bound authService singleton (packages/data/src/index.ts), and this sentence listed it as an exception until 2026-08-28. context has no stub at all — workspaces: [] is a valid answer, so a test passes a literal rather than maintaining a second implementation.
THIS HEADING HAS NOW BEEN WRONG FOUR TIMES, WHICH IS WHY IT IS NO LONGER WRITTEN BY HAND. It read "2 of 7" until 2026-08-03 (really 4 of 9), "4 of 9" until 2026-08-12 (really 9 of 13), and "9 of 13" until 2026-08-13 — where the numerator was finally right and the denominator was stale again, because orders and setuCardEditor had arrived as stub-only seams. Every single correction was found by a human reading the file, never by a check.
⚠ AND THE "AUTHORITATIVE COMMAND" THIS SECTION PRESCRIBED WAS ITSELF WRONG, which is how the count kept feeling verified. It said to run grep -nE "from './[a-zA-Z]+/service" packages/data/src/index.ts. That matches the interface re-export (from './account/service') that the barrel emits for every seam, real or stub — so it returns the total, not the real ones. Run on 2026-08-13 it printed 15 names and would have "confirmed" any number you already believed. A grep whose result you do not sanity-check against a second source is a confirmation device, not a measurement. The correct question is which seams have a service.supabase.ts on disk and which of those the barrel actually binds — and it is now npm run check:claims, so nobody has to remember the right incantation. Note that a service.stub.ts sitting next to a service.supabase.ts still does not tell you which one the barrel binds — only the barrel does.
⚠ profileService's eventual real impl has no target any more. Its interface still names manage-profile, which was archived 2026-08-09 along with the profiles and bio_pages tables it wrote (both dropped by the ADR-0020 baseline). Wiring it is a redesign against the v2 schema, not a swap behind the existing interface — see the header of packages/data/src/profile/service.ts, which records this correctly.
Deploying an EF from this machine WORKS via the documented CLI path — corrected 2026-08-08. This section previously said it did not, and that two separate causes had one shared symptom is why it read as a single permanent block:
- The CLI could not see the projects. Fixed by
supabase login --token <PAT>;supabase projects listnow returns bothqr-setu-dev(dyhjofjjuazhyqcvlrkx, Mumbai) andqr-setu-prod(ygmqxyrbnemhwkiyoboc, Sydney). tools/deploy-functions.jswas broken on Windows regardless — it calledexecFileSync('supabase', …)with no shell, and the CLI is an npm shim (supabase.cmd/.ps1), so every run diedspawnSync supabase ENOENT. The file's own header claimed it was "Cross-platform (Windows/macOS/Linux)". Fixed withshell: process.platform === 'win32'(QRS-433). ⚠ENOENTreads as "the CLI is not installed", which sends you looking in the wrong place — that misread is most of why this bullet said what it said.
Prefer the CLI over the MCP tools for EF deploys: supabase functions deploy <name> --project-ref <ref> bundles _shared/*.ts itself, whereas the MCP deploy_edge_function needs them passed explicitly because EFs import them by relative path. MCP remains useful for SQL (apply_migration, execute_sql) — and per QRS-267 call list_migrations immediately after the FIRST apply_migration to catch orphan-version drift. Note MCP tool availability is session-scoped and can drop mid-session; the CLI is the more reliable path now that it is logged in.
supabase functions deploy needs Docker (it pulls supabase/edge-runtime to bundle). If the shell's PATH lacks D:\DevCache\DockerDesktop\resources\bin it prints Failed to load registry credentials … docker-credential-desktop and falls back to an anonymous pull — noisy but harmless. Open a fresh terminal after a Docker install.