Data & Hooks Conventions
Status: Active Last reviewed: 2026-06-30
Single source of truth for how TwoMore fetches, mutates, and displays data. Covers formatting utilities, mutation and query hook discipline, the port/adapter/mapper layer, telemetry and performance instrumentation, mock data seeding, API key rotation, tier display, cross-query cache writes, PII/consent/data-collection obligations, and SQL migration footguns (SECDEF discipline,
CREATE OR REPLACEtraps, idempotency-guard silent skips, secret hygiene, and the no-PII-in-JSONB rule). Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (DATA-1..DATA-13, KR-5).
Formatting & display utilities
- Formatting a date →
packages/app/src/utils/date-format.ts. Never inlinegetMonth()/getDay()in components - Formatting an ELO rating →
formatElo(elo)from@twomore/app. Never inlineelo.toLocaleString('ko-KR'). - Formatting a session/match format label →
formatSessionFormat(format)from@twomore/app(reads the long-formt().sessions.format*namespace). Never re-declare a localformatLabel. Space-constrained short-form badges (getFormatLabelinpackages/app/src/presentation/components/sessions/format-label.ts, used bySessionCardV2's chip strip) keep their own혼복-style map — different i18n namespace (t().sessionHeader, retained from the retiredSessionHeadercomponent) by design. - Displaying a session title →
sessionTitle(session)from@twomore/app(generated from format + competitive/friendly style, e.g. "혼합복식 경쟁"). Session titles are always metadata-derived, never custom — never readsession.titlefor display (the create/edit flows no longer collect a custom title; the DB column persists only a generated fallback). Use it at every surface: detail header + identity card,SessionCardV2, scorecard headers, share cards, recap/history. - Grouping list items into "YYYY년 M월" sections →
groupByMonth(items, getDate)from@twomore/app(returns{ title, data }[]for SectionList). Never re-implement the month-bucketing loop. - Filtering RSVPs by status →
filterConfirmed/filterWaitlisted/confirmedUserIds/isConfirmedfrom@twomore/app(domain/utils/rsvp-selectors). Never inlinersvps.filter(r => r.status === 'confirmed'). Bespoke partitions (waitlist sorted by position, confirmed-excluding-self, sessionId extraction) stay inline. - Building an id→name lookup from profiles →
buildNameMap(profiles)(id→displayName) /buildProfileStubMap(profiles)(id→{displayName, avatarUrl}) /getProfileName(map, id)from@twomore/app(presentation/utils/profile-map). Full-Profilemaps stay inlinenew Map(profiles.map(p => [p.id, p])). - Validating a name →
validateName(). Never inline if/else chains - Reading auth state in presentation →
useAuth(). Neversupabase.auth.*outside auth adapter - Reading theme →
useThemeColors()/useTheme(). NeveruseColorScheme()outside theme-provider - UI strings →
t()frompackages/app/src/config/i18n/. Code + comments → English - Option
valuevslabel→ in a{ value, label }option (chips / selects / PillNav / SegmentedTabs / filters),valueis a stable language-invariant key (used for comparison, DB storage, persistence, deep-links, React keys);labelis the display string and the only field that may hold Korean (ideally viat()). NEVER put a Korean/localized string invalue— it's a value/label confusion that breaks the moment the value meets a stored key or another locale. Canonical example: region uses the english SIDO key ('seoul') as value, Korean ('서울') only as label (REGIONS/PICKUP_REGION_OPTIONSfromconfig/regions.ts,SIDO_OPTIONSfor key→label display). Enforced by@twomore/no-localized-option-value. The region filter shipped broken (Korean value compared against the english-keysession.region) until this was fixed (2026-05-22).
Club regular meets & match history
- Club regular meets are RRULE-modeled (iCalendar RFC5545 subset). The
club_regular_meetstable storesrrule TEXT(FREQ/INTERVAL/BYDAY/BYSETPOS + dtstart phase anchor); date-math uses therrulenpm package (nextOccurrence,regularMeetDaysfromdomain/utils/recurrence.ts) — pure-JS, Hermes-safe, OTA-deployable.clubs.primary_day+clubs.primary_time_slotare DENORMALIZED CACHE synced by theprivate.sync_club_primary_scheduletrigger (AFTER INSERT/UPDATE/DELETE onclub_regular_meets) — never write them directly; they exist only for cheap card-rendering queries. The source of truth is alwaysclub_regular_meets.club_schedule_slotsis RETIRED (dormant since the 2026-05 roadmap; data migrated; do not reference). Korean display lives inpresentation/utils/recurrence-format.ts(describeRecurrence/describeRegularMeet— produces "격주 토요일 09:00~11:00") — deliberately i18n-free domain strings (nott()calls), because recurrence descriptions are domain-derived from structured data, not lookup keys. When extending the recurrence editor, stay within the supported RRULE subset (isSupportedRrule); arbitrary RRULE strings from outside callers are not guaranteed to render correctly in Korean. Architecture note: rrule was previously rejected for DST complexity — Korea's no-DST single-timezone neutralizes that entirely; iCalendar strings stored in the DB remain portable should TZ support ever be needed. - Match history is sourced from PARTICIPATION, not current club membership.
useMatchHistoryreadsrsvps.findPlayedSessions(userId)(every session the user confirmed / no-showed for — INCLUDING guest sessions in clubs they aren't a member of, AND pickups) →sessions.findByIds(...)→ matches. NEVER source a player's match history from their current-member clubs (the oldsessions.findPastshape) — that silently DROPS guest + pickup matches and retroactively appears/disappears matches as the user joins/leaves clubs. The "played" gate is the match-level filter (status==='completed' && both scores non-null && user-in-match), NOT a session-status pre-filter — so a scored match in a session later cancelled mid-play still appears. Noteuse-player-stats-detail+use-activity-datastill source fromfindPast(member-club stats are the intended scope there) — only the HISTORY list is participation-sourced. - Every match-history categorization dimension is DURABLY SNAPSHOTTED at write-time, never derived from mutable CURRENT state (the snapshot-at-write principle — a fact computed from current club_members / current friendships / current ELO bands silently drifts as the user/relationships/bands change). The durable sources per dimension:
membership(member/guest/pickup) ←rsvps.joined_as_member(00233 BEFORE INSERT trigger).nature(friendly/competitive/tournament/league) ←matches.nature, snapshotted from the session at the→completedtransition (00234snapshot_match_completion_contexttrigger — fires on completed becausesubmit_match_scoresets completed directly, skippingin_progress);deriveNature(match, session)readsmatch.nature ?? <session fallback>(fallback only for pre-migration rows). friends-vs-strangers ←matches.friend_pairsJSONB (the accepted-friendship[lo,hi]edges among the players at completion, viewer-agnostic); the records-history friends scope readsentry.hadFriendInMatch ?? <current-friends fallback>— NEVER intersect against the live friends list alone (friendships are hard-deleted, so the live list is unreconstructable history). tier-at-time ←elo_history.tier_before(the player's 5-tier label from the row's ownrating_before, set by 00235snapshot_elo_tier_beforeBEFORE INSERT trigger); an opponent's tier-at-time is THEIR ownelo_historyrow, so no per-match tier columns. ELO→tier bands have ONE server-side source:private.tier_index_from_elo/private.tier_from_elo(00235) —rsvps_enforce_tierconsumes it; never re-inline the900/1400/2000/2500thresholds in SQL again (the 3rd inline copy was the 00151/00229 7→5-tier drift). When adding a new dimension: snapshot it at a GUARANTEED transition trigger, forward-only (NULL on old rows = honest "unknown" — never backfill a mutable-derived value, which manufactures a wrong durable fact), and thread it Match-entity → mapper →MatchHistoryEntry.
Mutations & query hooks
- Creating a mutation →
createMutationHookfactory. Never rawuseMutation(except optimistic updates). The factory accepts an optionalmutationKey: QueryKeyconfig — pass it as[<entity>, <action>](e.g.['rsvp', 'upsert']) so TanStack can deduplicate concurrent observers and so the AGENTS.md DATA-2 rule stays satisfied. Required only for mutations whose state needs to be observed cross-component; optional for fire-and-forget admin actions. createMutationHook(...)is MODULE-SCOPE ONLY — never invoke it inside a component/hook body. Alwaysexport const useFoo = createMutationHook({...})at module scope. The factory's returned hook callsuseQueryClient+useMutation; callingcreateMutationHook({...})()during render builds a fresh hook function every render, and the React Compiler (enabled) memoizes/reorders that inline hook-returning call so the inner hooks are skipped between renders → an intermittent, lint-invisible"Should have a queue. You are likely calling Hooks conditionally"crash (the compiler healthcheck +rules-of-hooksboth pass — eslint doesn't recognize the factory's return value as a hook). If you need aqueryClient-dependent side effect (e.g. invalidating a sibling cache after a board-post write), define the factory at module scope and pass the closure via the per-callonSuccessoverride:const useFooBase = createMutationHook({...}); export function useFoo() { const qc = useQueryClient(); return useFooBase({ onSuccess: (r, i) => { /* uses qc */ } }); }. Lesson from 2026-05-25 —useUpdateSession+useCreateSessionboth did the inline-factory pattern and crashedEditSessionSheet/create-session intermittently for days; localized only viarecent_client_errors.component_stack_head(thedev_read_telemetrystream dropscomponent_stack).- Mutations whose target row is already cached → optimistic via
applyOptimisticPatches. Wait-for-server feels broken even on fast networks. - Pure CPU derivations of upstream query data →
useMemo, notuseQuery.useQueryis for I/O — its cache key churns when wrapping pure compute. - Don't fire the same
useQuerykey from two component levels — extra observer subscription cost per render with no caching benefit. Lift state up. - Fetching all open/locked sessions across user's clubs →
useClubUpcomingSessions. RSVP-filtered variant →useRsvpdUpcomingSessions. Never use the olduseUpcomingSessionsname.
Ports, adapters & mappers
- Presentation hook touching Supabase → go through the registered port via
import { <port> } from '@/registry'. Neverimport { supabase } from '@/adapters/supabase/client'orgetSupabase()directly insidepresentation/hooks/**. The hex direction is presentation → registry → port → adapter; bypassing breaks the swap-adapter-for-tests contract and hides the SQL from architecture review. If the existing port doesn't expose the read/write you need, add a method to the port + implement in the adapter in the same commit (canonical example:attendanceRecords.findStrikesByMemberadded in commitd800eb8). - Auth session storage (
adapters/supabase/secure-storage.ts) is expo-secure-store behind an in-memory write-through cache — supabase-js reloads the session fromauth.storageon EVERY request (GoTrueClient.__loadSession), so without the cache each PostgREST/Storage/Functions call paid an Android Keystore decrypt (10–50 ms, serialized natively) before its first byte. Never bypass the cache with a directSecureStoreread of the session key; never give supabase-js another uncached async storage. - Handling adapter error →
handleError(). Never silent catch. Fire-and-forget →logger.warn() - Coercing a drift-tolerant enum in a mapper (Protocol K) →
coerceEnum(schema, value, fallback)fromadapters/supabase/mappers/_assert. Never inlineschema.safeParse(value).data ?? fallbackfor enum fields. (Required enums useassertEnum+safeMapto drop the row; JSONB object schemas keep the inlineSchema.safeParse(value).data ?? DEFAULT— "enum" would mislead there.)
Telemetry, perf & debugging
client_perf_eventswrites are BATCHED (perf-event.supabase.ts, 2026-09-08): reports buffer in memory and flush as one multi-row insert every 5 s / 25 rows / on app background. A perf report is never its own request — every insert used to pay a session read + serialization and queue behind the 6-slot request gate at exactly the moment the slow screen's own queries were waiting (the diagnostic competing with the thing it diagnosed). Keep any new telemetry sink on the same buffer shape.- Stamping platform / app_version / app_dist on a
client_errors/client_perf_eventsinsert →...getAdapterTelemetryContext()fromadapters/supabase/_telemetry-context. Not forlib/sentry(its OTAupdateId+ release/dist source-map semantics differ). - Catching an unrecoverable error →
captureCriticalError(err, { context })from@/lib/sentry. NeverSentry.captureExceptiondirectly. Errors land inclient_errorsSupabase table + Sentry. Seedocs/best-practices/debugging-workflow.md - Tracing user actions →
addBreadcrumb(category, message, data)from@twomore/appat the start of every mutationFn. Stable categories:rsvp.toggle,score.submit,payment.mark-paid,payment.mark-waived,attendance.confirm,manner-tags.submit. When the next error fires, Sentry shows the 10 most-recent breadcrumbs as context. New mutation needs a new category — add it to the docstring inlib/sentry.tsso the enum stays the single source of truth. - Debugging a crash OR lag → run
yarn debug:logs(signs in as dev, reads the unifieddev_read_telemetrystream of errors + slow ops) BEFORE asking the user for screenshots.--since=2h,--kind=perf|error|query|paint|navigation,--route=X,--clear. Falls back toselect * from public.recent_client_errors(20)in the Supabase SQL editor. - Session-start prod-health check — the watchdog only writes to telemetry; NOBODY is paged. At the start of any session that touches prod (deploys, migrations, cron/edge work, or just resuming after days away), run
yarn debug:logs --since=24h --kind=errorFIRST and treat anyCRON HTTP FAILUREwatchdog FATAL as a stop-the-line incident before the planned work. An unread alert IS an ongoing outage: on 2026-06-10→11 the push+weather pipeline was down 19 HOURS with the watchdog faithfully writing hourly FATALs that no one read (cause:app_config.service_role_keyheld a revoked API key → every cronnet.http_post401'd "Unregistered API key" at the gateway). The 00202 watchdog closed the DETECTION gap (the 2026-05-17 outage ran 33h undetected); this rule closes the READING gap. - Capturing lag →
perf.reportSlow(operation, durationMs, { kind })(threshold-gated + throttled, fire-and-forget toclient_perf_events).perf.measure()auto-funnels;query-clientauto-times fetches. NEVER put PII in the perfcontext(counts/ids/query-keys only —redactPIIruns defensively). UX lag is captured at the@twomore/uiprimitive level via a parallel DI sink (perf-sink.tssetPerfSink/reportUiPerf, bridged toperf.reportSlowininitPerfReportersince ui can't importlib/perf):PillNavauto-times tab switches (interaction:tab),FeedList/GroupedFeedListcapture scroll jank when given aperfLabelprop (scroll:<label>), and<PerfProfiler id>(ReactProfiler) captures render storms (render:<id>) on high-churn subtrees. Coverage is now app-wide — everyFeedList/GroupedFeedListsite carries aperfLabel, and the two raw (non-virtualized)ScrollViewscreens (profile,home-feed) wireuseScrollPerf('<label>')+scrollEventThrottle={16}+{...handlers}explicitly. A NEW scrollable surface MUST follow suit:FeedList/GroupedFeedList→ add a kebab-caseperfLabel; a rawScrollView→ spreaduseScrollPerf('<label>'). High-traffic / list-heavy screens additionally wrap their scroll subtree in<PerfProfiler id="<screen-key>">so render storms self-attribute viarender:<id>events alongside thescroll:<label>jank events — use the same kebab key for both (e.g.perfLabel="clubs-mine"+<PerfProfiler id="clubs-mine">) so the two event streams correlate under one screen identifier inyarn debug:logs. Wrap the inner content YStack / FeedList — never the ScrollView/DetailShell chrome (the Profiler must enclose the rendered subtree, not the scroll machinery). Pair the wrap with the Wave-G playbook:React.memoon rows +useCallbackonrenderItem/keyExtractor/separators with stable-reference deps +useMemoon object/array props passed to memoized children + module-level constants for inline styles. Without that, the Profiler will faithfully report storms that are caused by missing memoization. - A rule that exists in BOTH TypeScript and Postgres is a bug waiting to happen — decide which kind it is. TypeScript cannot import a SQL constant and Postgres cannot import a module, so anything stated in both languages is two hand-maintained copies by construction. Three shipped bugs in 2026-09 came from exactly this and none was caught by a type, a test, or a review: the cancellation policy scored members by a window the app never showed them (00639), the push category labels drifted from the app's own vocabulary in production, and the client's pre-match rating preview computed numbers the server would not apply (H2). The rule now:
- A policy NUMBER lives in data. A table the database reads and the client receives through an aggregate it already fetches, with a compiled-in fallback.
private.rate_limit_policies(00641) is the template — one source, no gate needed, and ops can tune it without a deploy. - A policy ALGORITHM is computed in ONE place and read from the other. Prefer the server: expose a
STABLEread-only function built on the SAME helpers the authoritative write path uses, and deliver its result inside an existing bundle so it costs no extra request.00642_match_rating_preview.sqlis the template — it extractedprivate.match_rating_compute_deltassoapply_match_ratingsand the preview share one implementation, and its pgTAP previews a score, really applies it, and asserts the two are equal. That collapse deleted 311 lines of duplicated TypeScript. - A parity gate is the fallback, never the plan. Gates scale linearly with the number of rules and each depends on someone remembering to add one. Where a value genuinely must be written twice (a cold-start seed, a display default), a gate is right — but it must fail when a declaration cannot be FOUND, not only when values differ, or a rename passes it vacuously. Live gates:
check:cancel-policy,check:attendance-policy,check:elo-parity,check:cron-budget. - Never keep a constant "in case" behind a gate. When the ELO algorithm left TypeScript, four constants lost every consumer; they were deleted rather than guarded. A gate protecting values nothing reads is a parking lot with a lock on it.
- A policy NUMBER lives in data. A table the database reads and the client receives through an aggregate it already fetches, with a compiled-in fallback.
- Wall-clock duration measurements that can span an app background/suspend MUST cap implausible values. Any
Date.now() - start(orperf.mark→measure) duration whose start and end can straddle the OS suspending the app — asetIntervaldrift detector, an in-flight fetch timer, anything timing across anawaitthat may be backgrounded — measures the ENTIRE suspend (minutes) as one event on resume, emitting bogus multi-minute "blocks"/"slow queries" that swamp real signal AND (for the monitor) fire an error-level Sentry breadcrumb on every resume. Cap/skip durations beyond the operation's realistic upper bound. Canonical guards (both 2026-06-03 — the SAME bug found in two paths):packages/app/src/lib/js-thread-monitor.tsMAX_PLAUSIBLE_BLOCK_MS(5s — resetlastTickAt+returnwhenactualDeltaexceeds it) andpackages/app/src/presentation/providers/query-client.tsMAX_PLAUSIBLE_QUERY_MS(30s — queries are AbortSignal-timeout-bounded, so anything larger is a suspend artifact → skip thereportSlow). Any NEW wall-clock perf timer must add the same guard; not mechanically lintable — reviewer discipline. A plausibility CAP alone is not enough for a repeating monitor (2026-09-09): a SHORT suspend — notification shade, a glance at another app, screen off/on inside the cap — lands under the bound and is reported as a real multi-second block, which then outranks every genuine block inyarn debug:logs(the unattributed 4187 msvenues/[id]block of 2026-09-08 was exactly this: no GC delta, no interaction near it, sitting between two ordinary home-feed scrolls).js-thread-monitor.tstherefore ALSO gates onAppState: a latch set on any transition away fromactivediscards the next measured window even when the app is back toactivebefore the tick runs (a background round trip completing BETWEEN two ticks is invisible toAppState.currentState). Prefer discarding a real block over reporting a phantom one — phantom events cost more than they reveal. - Marking "screen fully displayed" → render
<Sentry.TimeToFullDisplay record />(importSentryfrom@twomore/app) inside the data-ready content branch of a primary screen — AFTER its Protocol-G readiness gate clears (theQueryBoundarycontent child, theelseof a not-found guard, or below anif (isLoading) return …). It measures the true full-display instant for Sentry Mobile Vitals (TTID is auto via the nav integration; TTFD requires this manual marker). It renders an invisible draw marker and is a no-op when no navigation transaction is active, so a tab-switch re-render won't emit a spurious span. NEVER place it in a skeleton/loading branch (would record skeleton paint as "full display") or unconditionally inside a multi-panerenderTabthat mounts >1 pane (gate to the default landing pane, e.g. home'sslice === 'today'). Wired onhome/profile/records/club-detail/spectator-scorecard; add to a new high-traffic data screen the same way.
Mock data & key rotation
- Mock data comes from the scenario builder, NOT static seeds (2026-06-13).
supabase/seed.sqlis DELETED.config.tomlseeds ONLYseed_public_courts.sql(real reference data — court names, addresses, coordinates; never touched by tests or scenarios). ALL user/club/session/meet/DM/payment/review/availability/challenge/feedback/stats mock data is generated RANDOMLY and IDEMPOTENTLY by theseed-scenarioedge function. Runyarn seed(base chain) oryarn seed:dense(dense chain) afternpx supabase db reset. Adding a new table: (1) add a randomized generator insidesupabase/functions/_shared/scenario-builder.ts; (2) add the table to the seed-scenariotruncatelist so re-seeding is idempotent; (3) wire the new data into the relevant chain. NEVER add a static row toseed.sql— the file is gone; the scenario builder is the single source of truth. - Seeding perf: batch inserts, reconcile once per chain (2026-07-04). Multi-row generators (
matches,match_sets) must accumulate rows and issue ONE.insert(rows).select('id'), mapping returned ids back to their child rows — a per-row insert in a loop is the classic silent-slowdown trap (measured:live_showcase25.6s → 13.9s after batchingmatches).dev_reconcile_seeded_world(full-DB rating replay) is expensive and idempotent-safe to defer — a multi-step chain should passskipReconcile: trueon every step except the last so it runs once over the full dataset, not once per step (mirrors the existingskipTruncateflag). Deliberately serial paths (e.g.dense_compute's scoring, which avoids a real ELO race) are the documented exception. When a generator looks "dead" (zero rows silently seeded), verify its query columns and any CHECK-constrained vocab against the actual migration — three real bugs shipped this way:generateSessionPaymentsqueried a column that only exists on a different table (cost_per_personvs the realparticipation_fee) and wrote a Korean string into a CHECK-constrained enum column (payment_method);guest_applicationsinserts omitted a NOT NULL FK (club_id) with no backfill. None of the three raised an error — they either silently seeded zero rows or failed every insert. Grep the actual migration for the column/constraint before trusting a generator's output. - Rotating/regenerating Supabase API keys →
app_config.service_role_keymust be updated in the SAME operation. The pg_cron jobs build theirAuthorization: Bearerfrompublic.app_config(keyservice_role_key), independent of the edge functions'TWOMORE_SECRET_KEYsecret — rotating the platform key updates neither automatically, and a revoked key inapp_config401s ALL cron HTTP calls (push, weather, receipts) whilecron.job_run_detailsstill shows "succeeded" (dispatch-level). Verify by digest, never by value:select key, length(value), encode(extensions.digest(value,'sha256'),'hex') from public.app_config;and compare againstnpx supabase secrets listdigests. The sanctioned repair shape when the valid value exists only as an edge secret: a ONE-SHOT token-guarded edge function that copies its own env value intoapp_configserver-side and returns only{updatedRows, valueSha256, valueLen}— the secret never transits chat/git/agent context; delete the function (prod + disk) immediately after the digest verifies. Used 2026-06-11 to end the 19h cron outage.
Tier display
- Displaying tier info →
tier-display.tsutilities (TIER_ICONS,getTierColor,TIER_BADGE_VARIANT). Single source of truth for tier visual representation - Tier labels in UI →
tierLabel(tier)frompackages/app/src/utils/tier-label.ts. Never inlinet().ranking.tier[x]. - Formatting a club tier range →
formatTierRange(min, max)from@twomore/app—"실버~골드", a single label when both resolve equal, null when either is absent. Never inlinemin && max ? `${tierLabel(min)}~${tierLabel(max)}` : null. The wizard's "all levels" shortcut stays bespoke; the dash-separated SessionHeader variant this line used to except is gone (SessionHeader was deleted — no replacement dash-format exists). - Tier ordering (promotion/demotion comparisons) →
TIER_ORDERfrom@twomore/app(TIER_ORDER.indexOf(tier)), derived fromTIER_DEFINITIONS. Never hand-declare['bronze','silver',...]. - Fetching session weather →
useSessionWeather({ region, date })frompackages/app/src/presentation/hooks/queries/use-session-weather.ts. Never fetch weather directly from a component.SessionCardV2does NOT call this internally (zero-data-hooks projection, per Conventions › Session Surfaces) — it receives a pre-resolvedweatherLabelprop from the list's bulk model builder (useSessionCardModels);session-detail-screen.tsxis the current direct caller.
Caching & cross-query writes
Persistent Supabase Realtime channels are retired. Live surfaces use focus-gated polling, DM uses native
refetchInterval, signals use push/cache-bust, and ordinary surfaces rely on staleTime + mutation invalidation. If a future realtime hook is introduced, it must live underpresentation/hooks/realtime/**, useuseFocusedEffect, and patch/invalidate only specific keys.Cross-query entity writes → use
writeX/writeXBackfillfrom@/presentation/cache. ManualsetQueryDatascattered across hooks is forbidden.One logical fact lives in ONE consolidation helper, not N hand-synced pipelines. When an entity's rows are cached under multiple keys (e.g. RSVP lives in
bySession+byUser+allByUser+bySessionIds), a mutation/realtime handler MUST NOT hand-enumerate that key set — it WILL forget one and desync (the 2026-05-27 bug: cancel updated the detail screen'sbyUserbut left the summary cards'allByUser/bySessionIdsstale). Instead route through the entity's single consolidation surface inpackages/app/src/presentation/cache/index.ts: an optimistic/realtimewriteX(client, entity)that fans the row across every cache shape (upsert by id-or-natural-key), and anxInvalidationKeys({…}): QueryKey[]builder that returns EVERY shape to reconcile (spread intocreateMutationHook'sinvalidateKeysor a rawonSettledloop). Today:writeRsvp+rsvpInvalidationKeys,sessionPaymentInvalidationKeys,attendanceInvalidationKeys, plus the existingwriteSession/writeProfile/writeClub. Adding a key to an entity → update its helper, and every call site is correct for free. Audit query: an entity with 3+ overlapping keys whose mutations enumerate keys inline is the desync smell.A DB trigger that derives entity X's fields from entity Y's writes needs Y's own mutation hooks to invalidate X's key too — the trigger existing server-side doesn't make the client cache aware of it (PF-04, perf-data-integrity-audit-2026-09-17.md: migration 00650's
trg_sync_club_regionsrecomputes a club'sadm1_slug/adm2_slug/club_play_regionsas a side effect of everycourt_venueswrite, souseCreateVenue/useUpdateVenue/useDeleteVenuealso invalidateclubDetailBundleKeys.detail(clubId)+clubKeys.detail(clubId), not just their ownvenueKeys). This is the cross-entity sibling of the "one logical fact, one consolidation helper" rule above — that rule catches a hand-enumerated key SET for one entity; this one catches a hand-enumerated key set that spans two.Future realtime row events →
applyRealtimeListChange/applyRealtimePaginatedChangefrom@/presentation/cache.invalidateQueriesfallback only on mapping failure.Future realtime invalidation must target the SPECIFIC keys an event affects, not the
*.allroot prefix.Per-event hot-path work must be O(1), never O(total state). Any subscriber to a high-frequency source —
queryCache.subscribe, realtime callbacks, scroll/onScroll, store subscriptions — runs on the JS thread, often inside a React commit. Work there must be constant-cost: keep aggregate state in memory and mutate it incrementally; NEVER re-read + re-serialize the whole collection per event (JSON.parse/JSON.stringifyof a growing structure), and flush persistence on a debounce (ideally off the frame-critical path viarequestIdleCallback). Canonical bug (2026-05-22): the lazy-persister re-readManifest()+writeManifest()(parse+stringify the WHOLE manifest) on every querysuccess; with gcTime=30min the manifest grew all session, so JS-thread blocks grew 79→551ms as the user navigated. Fixed by holding the manifest key-set in memory + a debounced flush that only fires on a genuinely-new key. When you add a cache/realtime subscriber, ask: "does this do work proportional to total cached state per event?" — if yes, it's a progressive-slowdown bug.A hot-path zustand action must bail BEFORE
set()when the write is a no-op.set({...})with unchanged VALUES still creates a new state object and notifies every subscriber — withuseSyncExternalStorethat is a forced re-render (forceStoreRerenderin the stack is this signature). Any render-adjacent caller (a draft-sync wrapper, anonLayoutreporter, an effect) that writes unchanged values then echoes: write → notify → re-render → write → maximum-update-depth (on-device 2026-08-24, wizardupdateFormData). Guard shape: compare incoming keys withObject.isagainst current state andreturn(orreturn statefrom the functional form — zustand bails on identical references) before setting. Applies toupdateFormDatain both wizard stores andsetBottomChromeHeight; apply it to every new store action a render path can reach.
PII, consent & data collection
- New data-collection feature → in the SAME migration, add a
public.purpose_catalogrow for(purpose_key, jurisdiction)withlegal_basis + retention_days + min_age + is_sensitive. Update the privacy policy entrustment table inpackages/app/src/config/legal/documents/privacy.ko.tsif a new vendor is added — any text change there bumps the document'sversionand needs a newpolicy_versionsmigration row carrying the hashyarn check:policy-hashes --printprints (is_material=truewhen material per PIPA §30 → forced re-consent); the gate fails the build until the row exists. Then updategetRequiredConsentsForJurisdiction(j)inconsent-matrix.tsto include the new purpose in the relevant jurisdictions. Thelint:pi-schemascript enforces the migration-side discipline; the policy-text update is reviewer-checked. Never collect personal data without a corresponding active grant inuser_consent. - Reading personal data → before SELECTing a PI column (
birth_year,phone_e164,region,gender, location coordinates), verify the user has an active grant for the relevant purpose. HelpergetActiveConsent(userId, purposeKey)will live in@twomore/appwhen multiple call sites need it; until then, document the consent gate inline in the adapter. - Consent revocation surface → every consent MUST be revocable from
SettingsConsentManagementScreenWITHOUT losing service access. Required consents (terms,privacy,cross_border_transfer) are revocable only via account deletion — that is intentional per PIPA §37; the withdrawal mechanism for required consents is the delete-account flow. - Cross-border vendors → if you add a new US-hosted service (Sentry replacement, payment processor, analytics SaaS), update three places: (1) entrustment table in
documents/privacy.ko.ts, (2) its cross-border transfer section with the vendor's PI manager contact, (3) the newpolicy_versionsrow the hash gate demands (material). The Jan-2025 KakaoPay/Apple KRW 8.3B fine establishes §28-8 disclosure as the top PIPC enforcement target. - No PII in JSONB columns →
signals.payload/signals.context/notification_events.data/client_errors.context/user_consent.evidence/matches.match_rulesare the project's 6 JSONB writeable surfaces. Each is genuinely heterogeneous so JSONB is the right shape (signals have 13 categories with different fields; client_errors carry arbitrary call-site context; ISO/IEC TS 27560:2023 explicitly specifies user_consent.evidence as free-form). But: never write PII values into these columns. PII =name/displayName/display_name/phone*/email/kakao*Id/lat/lng/latitude/longitude/birth_year/birthYear/commenterName/commenter_name. Write the user'suserIdinstead, and resolve the display name at read time viaprofiles.findByIds(...). Migration00192codifies this with: (a) trigger functions rewritten to writeactorUserIdonly, (b) backfill cleanupUPDATEstripping PII keys from existing rows, (c)CHECK constraint signals_payload_no_pii_keysblocking the named PII keys at DB level. The PII key set matchespackages/app/src/lib/redact-pii.ts'sSENSITIVE_KEYSso client + DB stay in sync. New JSONB writers (RPCs / triggers / adapters) must follow the same pattern; if a denormalized name is needed for UX, do read-side enrichment.
SQL: SECDEF & migration footguns
- RETURNS TABLE in plpgsql SECDEF functions → every column reference inside CTE bodies (especially GROUP BY + SELECT lists) MUST be qualified with table aliases when the return column name matches an underlying column name. PostgreSQL raises
42702 "column reference is ambiguous"at function-call time, NOT atCREATE OR REPLACE FUNCTIONtime — so the error only surfaces in production. Codified in migration00183—signals_pick_pushableis the canonical fix shape (all internal CTE refs qualified ass.recipient_user_idetc.). The ESLint plugin cannot catch this; it requires reviewer discipline on SQL migrations. - SECDEF function discipline → every SECURITY DEFINER function MUST (a) set
search_path = ''to prevent search-path injection attacks, (b) REVOKE EXECUTE FROM PUBLIC and explicitly GRANT only to the principals that need it (service_role,authenticated, etc.), (c) gate onauth.uid() IS NOT NULLat the top of the body if the function reads any user-keyed data. Default PUBLIC EXECUTE on a SECDEF function that reads sensitive config (like00101'sget_app_configwhich returned the service-role key) is a catastrophic data-exfil vector — authenticated users can call it via PostgRESTrpc()regardless of RLS. Fixed in00184. Thelookup_club_by_invite_codepattern (migration00146) is the canonical REVOKE + GRANT shape. As of00209,publicno longer auto-grants EXECUTE to PUBLIC for newly-created functions (ALTER DEFAULT PRIVILEGES … REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC), so a new function migration that omits an explicitGRANT EXECUTE … TO authenticated[, service_role]is UN-callable by the client — and silently so (the failure surfaces at runtime, not apply time). Always add the explicit GRANT in the same migration. Cron/edge-only functions GRANTservice_roleonly (cron runs as thepostgressuperuser, bypassing grants; edge functions useservice_role) — neverauthenticated, neveranon. Anonymous EXECUTE is denied except for the intentional public projections and RLS predicates explicitly covered by00026_secdef_acl_hygiene.test.sql. The ownership addition permits only namedanononget_venue_owner_certifications(uuid[]): one to one hundred requested venue IDs return booleans and a check time, without claimant identity or proof. This exact-signature exception does not permit PUBLIC execution, other overloads, raw ownership tables or private helpers. Run the Supabase Security Advisor periodically — the four categories it flags here (rls_policy_always_true,function_search_path_mutable,anon_security_definer_function_executable,public_bucket_allows_listing) were all closed in00207–00210(2026-05-27). CREATE OR REPLACE FUNCTIONis two distinct footguns — both surface only at call time, never at migration-apply time, sosupabase db resetpassing does NOT clear them. (1) Changed arg list spawns a new overload.CREATE OR REPLACEonly replaces a function with the EXACT same signature; adding/removing/retyping a parameter creates a SEPARATE overload and leaves the old one live. When every param is defaulted, the two then collide for the lower-arity call (42725 "function … is not unique"); worse, the orphan can keep a deprecated body reachable. When you change a function's signature, DROP the prior signature explicitly in the same migration (DROP FUNCTION IF EXISTS schema.fn(<old arg types>);). Found 2026-05-24:recent_client_errors(int)orphaned by00191's 2-arg form, andprivate.submit_match_score(uuid,int,int,int)orphaned by00084'sp_outcomeaddition (the orphan still carried the pre-v2 ELO body) — both dropped in00201. Apg_procscan grouping by(nspname, proname) HAVING count(*) > 1overpublic+privateis the audit query; it must return 0 rows. (2) Re-deriving from a stale base silently reverts a later fix. When a hardening/refactor migration re-issuesCREATE OR REPLACEfor a function, it must start from the function's CURRENT (latest-migration) body, not the original.00194's search_path pass re-derivedsignals_enforce_immutabilityfrom the00107original (service_role-only bypass) instead of the00109fix (auth.uid() IS NULL OR service_role), silently re-breaking signal dedup re-emits (signals_emit'sON CONFLICT DO UPDATEruns withauth.uid()NULL — the headline emits are clean INSERTs so it shipped silently; only re-emitted/aggregating signals from cron broke). Restored in00200. Before re-issuing a function,grep -rl "FUNCTION <schema>.<name>" supabase/migrations/and diff against the HIGHEST-numbered prior definition.- Extension functions are schema-qualified in migrations (
extensions.gen_salt(...),extensions.crypt(...),extensions.digest(...),extensions.uuid_generate_v4(...)).supabase db pushapplies each file with a search_path that does NOT includeextensions, whiledb resetandmigration up --localresolve them — so an unqualified call passes every local gate and then fails on the linked project mid-chain, leaving the earlier prefix applied (2026-09-05:00590's fixturegen_salt('bf')failed after00585–00589had landed). Enforced byyarn lint:migration-extension-calls(DATA-14, inyarn check). - An idempotency guard (
IF NOT EXISTS, or a DO-blockIF NOT EXISTS (SELECT FROM pg_constraint WHERE conname=…)) silently SKIPS a constraint/table replacement, not just a re-create. A "fix" migration that intends to TIGHTEN or replace an object whose name already exists becomes a NO-OP under such a guard — it leaves the OLD definition live, andsupabase db reset(fresh DB) PASSES because the guard takes the create-branch there, so the bug only exists in environments that predate the object (i.e. prod). To replace a constraint,ALTER TABLE … DROP CONSTRAINT IF EXISTSthenADDit unconditionally (normalize offending rows FIRST so the new CHECK validates). Found 2026-06-03: 00151 intended to re-pinclubs_min_tier_check/max_tier_checkto the 5-tier enum but itsIF NOT EXISTSguard left the original 00093 7-tier check live in PROD for months (allowedplatinum/diamond, blockedgrandmaster) — only a runtimepg_get_constraintdefprobe caught it; fixed unconditionally in 00229. Same class as theCREATE TABLE IF NOT EXISTSthat silently skipped over a pre-existing v1 table and broke 00124. Verify a constraint/table "fix" actually took by probing the LIVE definition (pg_get_constraintdef(oid)/\d), never by trusting thatdb resetpassed. - Secret-value verification in chat / docs → NEVER paste the literal value. Use
SELECT length(value) FROM <table> WHERE key = '<x>';ordigest(value, 'sha256')to verify identity. Chat transcripts are durable; an exposed secret stays exposed until revoked. Lesson from 2026-05-19 rotation pause: aRETURNING length(value) AS value_lenmismatch triggered a debug paste of an sb_secret in chat, which then required regenerating the key from scratch. The canonical safe pattern isRETURNING key, length(value) AS value_len;and watch for trailing-newline traps that bumpvalue_lenby 1 (some SQL editors append\non paste). - Edge function service-key reads → use
Deno.env.get('TWOMORE_SECRET_KEY'). DirectDeno.env.get('SUPABASE_SERVICE_ROLE_KEY')reads are forbidden by@twomore/no-legacy-service-role-key-in-edge-functions; the legacy reserved name was retired after the Path B rotation. New edge functions should also gate Bearer-token auth guards on the project-prefixed var (seesimulate-activity/index.ts+seed-scenario/index.tsfor the auth-guard pattern).SUPABASE_*names are reserved by Supabase and can't be set via the CLI — always pick a project-prefixed name for custom secrets. - Soft-delete contract → setting
deleted_aton a row is ONLY half the work. Every read of that row's table MUST add.is('deleted_at', null)(Supabase adapter pattern) — omitting it leaks soft-deleted profiles onto every leaderboard, member list, match-history opponent name, and search result. Every write that creates the soft-delete MUST also (a) clear any sensitive columns that drive ongoing behavior (push_token, etc.) and (b) revoke any consent grants if PIPA-relevant. Easy to add the soft-delete and forget either read or write side. Use the canonicaldelete_account_atomicSECDEF RPC pattern (migration00186) for full atomicity:deleted_at+ push_token clear + consent revocations +pi_access_logaudit write in a single transaction.
Sharing
- Building a share-card → use
buildSessionShareCard/buildMatchShareCard(and futurebuildXxxShareCardsiblings) from@/presentation/utils/share-cards.ts. Configuration-driven — the card pulls fields from the entity. Never compose share text inline. - Sharing a session/match →
useShareSession/useShareMatchhook (or futureuseShareXxxsiblings); never callShare.share()directly from a component.
See also
- AGENTS.md — DATA-1..DATA-13, KR-5 (machine-enforced data and consent constraints).
- Best Practices › Security — PIPA compliance, RLS, consent, and SECDEF audit.
- Best Practices › State & Architecture — Zustand, TanStack Query, caching strategy.
- CLAUDE.md — orchestration core + the pointer index back to this doc.