Skip to content

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.

  • useQuery calls a packages/data service that wraps supabase.rpc('get_…').
  • supabase.rpc() appears only in services, never in components, screens or JSX.
  • RPCs are SECURITY DEFINER + SET search_path = public, then revoked from public, anon and authenticated BY NAME before an explicit GRANT EXECUTE. Revoking only from PUBLIC does not work — Supabase grants those two roles explicitly, which is the root cause of QRS-214. Gated by npm run check:sql.
  • Standard hook shape: { data, isLoading, error, refetch }, per-feature queryKey, staleTime by 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/domain as pure functions unit-tested with node --test and no database.

Writes / secrets / external HTTP / multi-step → Edge Function ​

  • useMutation calls the packages/data service → 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_FN map exported by the service — never an inline string at a call site.
  • Mutations take a REQUIRED idempotencyKey, not an optional one. Generate it with crypto.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 ​

BenefitHow the rule delivers it
SecurityTable names and schema never appear on the wire; RLS + EF enforce access twice.
Write-once data logicServices 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.
PerformanceComposite RPCs avoid ≥3 parallel read RPCs saturating the connection pool on multi-section screens.
Refactor safetyrow_to_json projections are forward-compatible; callers don't break on a column change.
TestabilityA 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 is Array.from/Buffer.from); the real ones live only in legacy/, nefoxx-reference-docs/ and the archived EFs. But nothing checks it. tooling/eslint-config/guardrails.js still lists the ban under "Deliberately NOT included yet … Activate WITH the code they guard: no supabase.from() in app/feature code → lands with packages/data". That trigger condition has been met — packages/data exists with 14 real Supabase services (ls packages/data/src/*/service.supabase.ts) and apps/web already 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, omitting orders and setuCardEditor). 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 useQuery in a feature hook, calling the service, which wraps supabase.rpc('get_…'). RPC only in services, never components/JSX. RPCs are SECURITY DEFINER + SET search_path = public + REVOKE ALL FROM PUBLIC / GRANT EXECUTE (ADR-0014, gated by check:sql). Standard hook shape { data, isLoading, error, refetch }, per-feature queryKey, staleTime by 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_FN map 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 with crypto.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/domain as pure, unit-tested functions — that is what lets behaviour be tested with node --test and no database. See ADR-0016.
  • Zod schemas in packages/schemas use 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 list now returns both qr-setu-dev (dyhjofjjuazhyqcvlrkx, Mumbai) and qr-setu-prod (ygmqxyrbnemhwkiyoboc, Sydney).
  • tools/deploy-functions.js was broken on Windows regardless — it called execFileSync('supabase', …) with no shell, and the CLI is an npm shim (supabase.cmd/.ps1), so every run died spawnSync supabase ENOENT. The file's own header claimed it was "Cross-platform (Windows/macOS/Linux)". Fixed with shell: process.platform === 'win32' (QRS-433). ⚠ ENOENT reads 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.