The app uses a three-layer state architecture to avoid denormalization and state drift:
┌─────────────────────────────────────────────────┐
│ React Query │
│ (Server state — in-memory cache) │
│ guilds / roles / memberships / attestations │
├─────────────────────────────────────────────────┤
│ SQLite (DAL) │
│ (Normalized offline persistence — tables) │
│ guilds | roles | memberships | user_roles │
├─────────────────────────────────────────────────┤
│ Zustand Stores │
│ (Client-only UI/device state — no entities) │
│ wallet | session | sync | biometric | history │
└─────────────────────────────────────────────────┘
Golden rule: Server entity data (guilds, roles, memberships) never goes into Zustand. Zustand holds only client-only state like wallet connection status, session tokens, sync progress, and access check history.
- Owns: All entity data fetched from the API (guilds, roles, memberships, attestations)
- Persistence: Encrypted AsyncStorage blob via
@tanstack/react-query-persist-client - Offline reads:
resolveFromDal()inqueryAdapter.tsserves data from SQLite - Staleness: 5-minute staleTime; foreground refetch on app resume
- Sync: On reconnect, the Sync Engine refetches and overwrites cache with server-authoritative data
React Query persistence is encrypted through createEncryptedAsyncStoragePersister.
KeyManager.initialize() only ensures a key exists and reports stale keys; it does
not overwrite an existing key by itself. Rotation is coordinated by the encrypted
persister because that layer owns the serialized cache envelope.
When a key is older than the rotation interval, the persister decrypts the current
gp1: envelope with the old key, re-encrypts the same PersistedClient under the
new key, writes the rotated envelope back to storage, and only then lets
KeyManager.rotateKey() commit the new key. If re-encryption fails or the cache is
unreadable, rotation is deferred and the old key remains active so normal cache
reads are not turned into a silent data-loss event.
- Owns: Relational tables for guilds, roles, memberships, user_roles, guild_configs, access_checks
- Schema: Proper PKs and FKs — see
src/database/schema.ts - Access: Via DAL functions in
src/database/dal.ts(e.g.,getGuildById,getRolesByGuildId) - Not a cache: This is the authoritative offline store; React Query is the in-memory cache on top
- Owns: UI state that has no server equivalent
wallet.store.ts— connection status, wallet addresssession.store.ts— auth token, session statussync.store.ts— sync metadata (entity versions, corrections)accessHistory.store.ts— in-memory access check logreconciliation.store.ts— role change sequence trackingbiometric.store.ts— biometric auth preferenceconnectivityService.ts— online/offline flag
- Persistence: SecureStore (via
migratingSecureStorage) for sensitive data - No entity data: Zustand stores never hold guild, role, or membership objects
- Add SQLite table in
src/database/schema.ts+ migration insrc/database/migrations.ts - Add DAL functions in
src/database/dal.ts(upsert, getById, getByX) - Add query key in
src/lib/queryKeys.ts:events: { all: ["events"] as const, byId: (id: string) => ["events", id] as const, byGuild: (guildId: string) => ["events", "guild", guildId] as const, },
- Add React Query hook in
src/features/events/useEvents.ts:export const useEvent = (eventId: string) => useQuery({ queryKey: queryKeys.events.byId(eventId), queryFn: () => guildPassClient.events.getEvent({ eventId }), networkMode: "offlineFirst", });
- Add DAL-backed resolution in
src/database/queryAdapter.ts:case "events": { const eventId = queryKey[1] as string; const event = await dal.getEventById(db, eventId); return event ? JSON.parse(event.raw_json) : undefined; }
- Register for persistence in
src/lib/offlineCache.ts(re-exports fromqueryKeys.tsautomatically) - Register for sync in
src/features/sync/syncFetchers.ts+ diff logic insrc/features/sync/reconcile.ts
- Create Zustand store in
src/features/theme/theme.store.ts:import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import { migratingSecureStorage } from "../../lib/storage"; type ThemeState = { darkMode: boolean; setDarkMode: (value: boolean) => void; }; export const useThemeStore = create<ThemeState>()( persist( (set) => ({ darkMode: false, setDarkMode: (darkMode) => set({ darkMode }), }), { name: "theme-storage", storage: createJSONStorage(() => migratingSecureStorage), partialize: (state) => ({ darkMode: state.darkMode }), }, ), );
- Keep entity data out — if the state references a guild, store only
guildIdand resolve display data from React Query selectors
- Resource-specific data that has no entity cache (e.g.,
resourceNamein access history) - Server response data that is not a cached entity reference (e.g.,
matchedRolesstring array)
- Entity names that have a dedicated entity cache (e.g., guild names — use
["guild", guildId]instead) - Data from a different entity type (e.g., guild info in a memberships query)
- Store entity
ids, not entity snapshots - Resolve display names at render time using selectors like
useResolvedGuildName(guildId) - Use
useQueriesto batch-resolve multiple entities efficiently
All query keys go through src/lib/queryKeys.ts:
import { queryKeys } from "../../lib/queryKeys";
// Creating keys
queryKeys.guild.byId("abc"); // → ["guild", "abc"]
queryKeys.membership.byWalletAndGuild("0x...", "abc"); // → ["membership", "0x...", "abc"]
// Root-level matching (for invalidations, sync engine)
queryKeys.membership.all; // → ["membership"]
queryKeys.guildRoles.all; // → ["guild-roles"]This ensures consistency across:
- Query hooks (
useGuilds.ts,useMembership.ts) - Cache invalidation (
focusManager.ts) - Cache clearing (
walletScopedCache.ts) - Sync engine reconciliation (
reconcile.ts) - Offline persistence (
offlineCache.ts,queryAdapter.ts)
| Mechanism | Trigger | What Happens |
|---|---|---|
| staleTime | 5 min after last fetch | Queries auto-refetch on next mount |
| Foreground refetch | App comes to foreground | Invalidates ["membership"] and ["user-roles"] |
| Sync Engine | Offline→online transition | Refetches all cached entities, overwrites with server data, generates corrections |
| Wallet disconnect | User disconnects wallet | clearWalletScopedCache() removes all wallet-scoped queries |
| Structural sharing | React Query default | Preserves object identity when data hasn't changed |
If converting an existing screen from denormalized to normalized state:
- Identify embedded entity data. Search for patterns like
guildName,roleNameembedded in query results or store entries. - Strip the embed. Return only IDs from the query/store.
- Add a resolver. Use
useResolvedGuildName(guildId)or create a similar hook. - Update the screen. Compose the base query with resolvers.
- Remove the old import. Delete any now-unused fields from types.
Before:
// useMembership.ts — query embeds guildName from SQLite
return { id: row.guild_id, name: guildName, isActive, roleCount };After:
// useMembership.ts — returns normalized data
return { guildId: row.guild_id, isActive, roleCount };
// guilds.tsx — uses useEnrichedMemberships() which resolves guild names via React Query
<GuildCard name={item.guildName} id={item.guildId} ... />- Store tests (Zustand): Pure function tests — mock nothing, just call
getState().action()and assert state - Query hook tests (React Query): Use
QueryClientProviderwith a freshQueryClientin test setup - Sync engine tests: Inject mock fetchers and query client — see
tests/sync/syncEngine.test.ts - DAL tests: Use an in-memory SQLite database — see
tests/database/
| File | Purpose |
|---|---|
src/lib/queryKeys.ts |
Centralized query key factory |
src/lib/offlineCache.ts |
Stale/GC times, re-exports persistable roots |
src/lib/queryClient.ts |
QueryClient singleton |
src/database/dal.ts |
SQLite data access layer |
src/database/queryAdapter.ts |
DAL-backed offline query resolution |
src/features/sync/syncEngine.ts |
Cache coherence engine |
src/features/sync/reconcile.ts |
Entity diffing + correction generation |