Skip to content

feat(mobile): Task 9 — core infrastructure (config, logger, supabase, query client, auth store) - #110

Merged
toruiwasa merged 2 commits into
mainfrom
feat/mobile-core-infra
Sep 3, 2026
Merged

feat(mobile): Task 9 — core infrastructure (config, logger, supabase, query client, auth store)#110
toruiwasa merged 2 commits into
mainfrom
feat/mobile-core-infra

Conversation

@toruiwasa

Copy link
Copy Markdown
Owner

Task 9 of REQ-17 — the shared non-UI layer every screen from Task 10 onward depends on. Nothing here renders.

Closes #12

What landed

File Role
app.config.js new — lifts EXPO_PUBLIC_* into expo.extra. Task 7 shipped a static app.json, so nothing did this before
src/lib/config.ts Reads those values and throws at import naming any that are missing
src/lib/logger.ts MobileLogger — the only permitted console caller; no-console is now an error everywhere else
src/lib/supabase.ts expo-secure-store adapter, detectSessionInUrl: false
src/lib/queryClient.ts MMKV-persisted query cache + AppState focus / NetInfo online listeners
src/store/authStore.ts Zustand session store — session, setSession, clearSession, nothing else
src/constants/{colors,thresholds}.ts Design tokens; STALE_WARNING_MS / STALE_DISCONNECTED_MS

config.ts is what turns #95 (EAS cloud builds have no source for three EXPO_PUBLIC_* variables) from a silent undefined endpoint into a launch-time error naming the variable. #95 still has to supply the values; this makes its absence loud.

Deviations from REQ-17 — all four recorded in the plan with reasons

  1. react-native-mmkv v4 renamed the v3 API the spec used. new MMKV({id})createMMKV({id}), delete(key)remove(key). The v3 calls do not exist on a v4 instance, so the spec's adapter would have thrown on the first cache eviction, not at construction. A test asserts remove() specifically.
  2. createSyncStoragePersister carries an @deprecated tag in @tanstack/query-sync-storage-persister@5.102, pointing at createAsyncStoragePersister. Switched. MMKV's synchronous methods satisfy the async persister's AsyncStorage interface unchanged (every field is MaybePromise), and PersistQueryClientProvider restores asynchronously either way — the hydration behaviour REQ-17 describes is unaffected.
  3. EXPO_PUBLIC_APP_ENV, not APP_ENVeas.json and .env.example, both merged in Task 7, already settled on that name.
  4. An absent or unrecognised appEnv falls back to production, not development. CLAUDE.md > Logging Strategy fixes the undefined environment at the quietest level; defaulting the other way means a build that simply forgot the variable ships with debug logging on.

Issue #12's body also said gcTime: 300_000 and src/lib/query-client.ts / src/store/auth.ts. The plan is followed instead: gcTime is 24h (a query evicted after 5 minutes is never written to MMKV, which would put the skeleton state on every launch, not just the first), and the filenames are queryClient.ts / authStore.ts per the folder structure in REQ-17.

Two further changes made while implementing:

  • react-native-nitro-modules added as a direct dependency. It is a required peer of react-native-mmkv@4 and was not resolvable from apps/mobile under pnpm's isolated layout. Same rule as Hoisted pnpm store mixes jest 29.x and 30.x — name-resolved config can bind the wrong major #97: every consumer declares what it resolves by name.
  • userInterfaceStyle pinned to "light". colors.ts ships a light palette only — REQ-17 Phase 1 specifies no dark palette, and a second one would be invented rather than designed — so "automatic" would pair dark system chrome with light screens.

Test boundary

__tests__/{config,logger,supabase,queryClient,authStore,constants}.test.ts — 43 tests, 100% on all seven new files.

Mocked: expo-constants, expo-secure-store, @supabase/supabase-js, react-native-mmkv, @react-native-community/netinfo. Per CLAUDE.md, each mock is asserted to have actually intercepted (createClient call count, captured MMKV/NetInfo/AppState handlers) rather than only that behaviour looked right. sanitize() runs for real — the redaction assertions would pass against a no-op mock.

What the tests pin down, beyond the happy path:

  • config.ts treats an empty string as missing (an unset EAS variable arrives that way) and names only the variables actually absent
  • logger.ts redacts at warn and error too — the levels production still emits
  • queryClient.ts gcTime >= persister.maxAge; no refetchInterval in the defaults; isConnected: null (NetInfo before its first probe) is not read as online; the focus listener's cleanup actually unsubscribes

Verification

pnpm --filter @pulseticker/mobile lint       clean
pnpm --filter @pulseticker/mobile typecheck  clean
pnpm --filter @pulseticker/mobile test:cov   43 passed, 100% on new files
pnpm build                                   6/6
pnpm test                                    6/6 tasks

expo config --type public was run with and without the variables set, confirming app.json is merged and extra is populated — the change is not inferred from the config file alone.

Not verified: nothing here has run on a device. The MMKV native module and the secure-store adapter need an EAS development build, which needs #95 first.

🤖 Generated with Claude Code

…lient, auth store

Task 9 (#12). Every screen from Task 10 onward depends on this layer; none of
it renders anything itself.

- src/lib/config.ts — reads the EXPO_PUBLIC_* values from the manifest and
  throws at import naming any that are missing. Turns issue #95's silent
  `undefined` endpoint into a startup error.
- app.config.js — new: Task 7 shipped a static app.json, so nothing lifted
  those variables into `extra`. Spreads app.json rather than replacing it.
- src/lib/logger.ts — MobileLogger, the only permitted console caller;
  no-console is now an error everywhere else. Data argument is
  Record<string, unknown> so a Session or Error cannot be passed whole.
- src/lib/supabase.ts — expo-secure-store adapter, detectSessionInUrl: false.
- src/lib/queryClient.ts — MMKV-persisted query cache, AppState focus and
  NetInfo online listeners registered once at import.
- src/store/authStore.ts, src/constants/{colors,thresholds}.ts

Deviations from REQ-17, each recorded in the plan with its reason:
react-native-mmkv v4 renamed the v3 API the spec used; the sync persister the
spec named is deprecated in favour of the async one; APP_ENV is
EXPO_PUBLIC_APP_ENV and an unrecognised value falls back to production, not
development.

Coverage: 100% on all seven new files.

Closes #12
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pulseticker Ready Ready Preview Sep 3, 2026 2:08am UTC

…refresh wiring

sanitize() redacted only top-level keys, so the guarantee was depth-dependent:
{ access_token } was redacted while { context: { access_token } } logged the
token verbatim at warn, a level production still emits. Task 9 is what made
this load-bearing — it routes every mobile log through one gateway whose doc
comment sells sanitize() as the second defensive layer — so the fix lands in
packages/logging and applies to apps/web and apps/api too. It now recurses
through plain objects and arrays and guards cycles with a WeakSet. Class
instances are still passed through untouched; that stays the LogData type's
job, and the limitation is recorded rather than left implied.

MobileLogger gains warnWithCause/errorWithCause, which CLAUDE.md > Logging
Strategy §6 requires for Supabase-auth and JWT errors. Without them Task 10's
auth paths had no compliant way to log a cause at all, since LogData
deliberately rejects a raw Error. Its level gate now compares through the
LEVELS table @pulseticker/logging already exports, as apps/web does, instead
of a boolean per method.

supabase.ts wires startAutoRefresh/stopAutoRefresh to AppState. auth-js drives
the refresh from a 30s setInterval and React Native suspends JS timers while
backgrounded, so autoRefreshToken alone left a resumed app issuing its next
request with an expired JWT. The secure-store adapter now logs and rethrows
instead of passing rejections through silently, where a refused write meant an
unexplained sign-out on the next cold launch.

authStore makes clearSession the single teardown path — setSession(null)
delegates to it, so sign-out cleanup added later cannot be skipped by the
onAuthStateChange caller.

The Config fixture duplicated across four test suites moves to one helper, and
the userInterfaceStyle note in colors.ts is corrected: the pin applies on iOS
only, because expo-system-ui is not installed (#113).

Filed rather than fixed here: #112 (single-source the web/mobile palette),
#113 (expo-system-ui, needs a device build), #114 (packages/logging has no
test project, so sanitize() has no measured coverage).

pnpm build 6/6, pnpm test 6/6, mobile 59 tests, 100% on all seven src files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfHB8Q3zSQ5idotURDwkyv
@toruiwasa

Copy link
Copy Markdown
Owner Author

Review round — 9 findings, 6 fixed here, 3 filed

Reviewed the diff for correctness and quality. Five further candidates were raised and refuted on verification, recorded at the bottom so they do not get re-litigated.

Fixed in b1b8c25

# Finding Fix
1 sanitize() redacted top-level keys only, so { context: { access_token } } logged the token verbatim at warn — a level production emits. The new tests asserted redaction for flat objects only, so CI stayed green. Recursion through plain objects and arrays in packages/logging, cycles guarded with a WeakSet. Applies to apps/web and apps/api too. Class instances still pass through — recorded as a limitation, since that is the LogData type's job.
2 warnWithCause / errorWithCause missing, which CLAUDE.md §6 requires for Supabase-auth and JWT errors. LogData rejects a raw Error, so Task 10 would have had no compliant way to log a cause. Added, mirroring apps/web and apps/api: errorName always, errorMessage only in development, stack only for errorWithCause in development.
3 autoRefreshToken: true with no AppState wiring. auth-js drives the refresh from a 30s setInterval and RN suspends JS timers while backgrounded, so an app resumed after token expiry issues its next request with a stale JWT. startAutoRefresh / stopAutoRefresh driven from AppState, registered at module scope so a screen cannot forget it.
4 Secure-store adapter passed rejections straight through: a refused write meant no persisted session and a silent sign-out on the next cold launch, with nothing logged. All three methods wrapped in a guard() that logs through errorWithCause and rethrows (CLAUDE.md §3).
5 Level gate hand-rolled per method in two styles, instead of the LEVELS table @pulseticker/logging exports and apps/web already uses. As written, isProd gated debug and info identically. One appEnvLogLevel map compared through LEVELS.
6 clearSession() byte-for-byte setSession(null), with both documented as live paths — later sign-out cleanup would have run for one caller only. clearSession is the single teardown path; setSession(null) delegates to it.

Also: the Config fixture duplicated across four suites moved to __tests__/helpers/mockConfig.ts, and the userInterfaceStyle note in colors.ts was corrected — the pin applies on iOS only, since Expo's config reference requires expo-system-ui for it to work on Android and that package is not installed. Nothing reads useColorScheme yet, so no screen is affected.

Filed rather than fixed

Refuted on verification

Recorded so they are not raised again: the app.config.js extra spread (app.json has no extra to clobber); the re-declared AppEnv type in a test (the CLAUDE.md rule quoted against it is scoped to Zod-inferred types); the no-console exemption living in eslint.config.js (a rename produces a loud lint error, not a silent unpairing); the import-time throw in config.ts (REQ-17:537 argues for exactly that, and config is reached transitively regardless); and gcTime: 24h (load-bearing, pinned by a test, and negligible at MVP cardinality).

Verification

pnpm --filter @pulseticker/mobile lint       clean
pnpm --filter @pulseticker/mobile typecheck  clean
pnpm --filter @pulseticker/mobile test:cov   59 passed, 100% on all seven src files
pnpm build                                   6/6
pnpm test                                    6/6 (web 164, api, mobile 59)

Still not verified on a device — unchanged from the original PR, and #95 still gates that. The AppState wiring and the secure-store failure paths are asserted against mocks only.

@toruiwasa
toruiwasa merged commit 141b060 into main Sep 3, 2026
4 checks passed
@toruiwasa
toruiwasa deleted the feat/mobile-core-infra branch September 3, 2026 02:13
toruiwasa added a commit that referenced this pull request Sep 4, 2026
…undled versions (#119)

The first native build of apps/mobile failed to compile. One error:

  ExpoWorkletsBridgeProvider.mm:236
  no member named 'executeSync' in 'worklets::WorkletRuntime'

expo/bundledNativeModules.json fixes react-native-worklets at 0.10.1 and
react-native-reanimated at 4.5.1, but neither is a direct dependency —
expo-router declares reanimated as a bare `*` peer and nothing pins worklets —
so pnpm resolved the newest satisfying versions, 0.12.1 and 4.6.0.
expo-modules-core is written against the <=0.10 worklets API, and 0.12 removed
executeSync. Its peer range states the constraint correctly, but the peer is
optional, so pnpm installs the mismatch with a warning rather than an error.

The pins go in pnpm-workspace.yaml overrides, not only in apps/mobile's
dependencies. Declaring them in the app is necessary — it should say what it
links — but not sufficient: expo-modules-autolinking resolves native modules
out of the store, and @expo/ui and expo-router still bound the 0.12.x copy.
With the override the tree holds 0.10.1 and 4.5.1 alone and pnpm peers check
is clean.

Nothing in CI caught this because nothing in CI compiles native code — jest,
tsc and eslint all passed throughout. That gap is filed as #118.

Verified by building and running on the iOS simulator: ExpoWorkletsBridgeProvider.mm
compiles, Build Succeeded, the app installs, Metro bundles 1347 modules, and the
Task 7 smoke screen reports both workspace packages resolved — which also
exercises the sanitize() rewrite from #110 at runtime. pnpm build 6/6,
pnpm test 6/6.

Closes #117


Claude-Session: https://claude.ai/code/session_01NfHB8Q3zSQ5idotURDwkyv

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Task 9 — Mobile core infrastructure (logger, supabase client, query client, auth store, constants, colors)

1 participant