From ed9956b4d4cf45d40821007655021328b9e7e6c1 Mon Sep 17 00:00:00 2001 From: Goodness Date: Mon, 27 Jul 2026 21:32:22 +0100 Subject: [PATCH 1/2] feat: persist offline guild pass cache --- app/guilds.tsx | 189 +++++++++++------ app/guilds/[guildId].tsx | 114 ++++++++--- src/components/GuildCard.tsx | 74 ++++++- src/features/guilds/useGuilds.ts | 111 ++++++++-- src/features/membership/useMembership.ts | 135 ++++++++---- src/features/passes/passCache.ts | 250 +++++++++++++++++++++++ src/features/sync/reconcile.ts | 16 +- src/features/sync/syncEngine.ts | 19 +- src/lib/queryKeys.ts | 7 + src/lib/walletScopedCache.ts | 7 +- 10 files changed, 753 insertions(+), 169 deletions(-) create mode 100644 src/features/passes/passCache.ts diff --git a/app/guilds.tsx b/app/guilds.tsx index 0e57aeb..3908401 100644 --- a/app/guilds.tsx +++ b/app/guilds.tsx @@ -1,7 +1,15 @@ -import { View, FlatList, TextInput, TouchableOpacity, Text, RefreshControl, useColorScheme } from "react-native"; +import { + View, + FlatList, + TextInput, + TouchableOpacity, + Text, + RefreshControl, + useColorScheme, +} from "react-native"; import { useRouter } from "expo-router"; import { useWallet } from "../src/features/wallet/useWallet"; -import { useGuilds } from "../src/features/guilds/useGuilds"; +import { useGuilds, type GuildListItem } from "../src/features/guilds/useGuilds"; import { AppHeader } from "../src/components/AppHeader"; import { GuildCard } from "../src/components/GuildCard"; import { GuildListSkeleton } from "../src/components/GuildCardSkeleton"; @@ -11,24 +19,116 @@ import { EmptyState } from "../src/components/EmptyState"; import { WalletRequired } from "../src/components/WalletRequired"; import { useDebouncedValue } from "../src/lib/useDebouncedValue"; import React, { useState, useMemo, useCallback } from "react"; -import { useMembership } from "../src/features/membership/useMembership"; +import { useMembership, type EnrichedMembership } from "../src/features/membership/useMembership"; import { StaleDataBanner } from "../src/components/StaleDataBanner"; -import { useStaleQuery } from "../src/features/offline/useStaleQuery"; +import { useCombinedStaleState } from "../src/features/offline/useStaleQuery"; import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "../src/lib/queryKeys"; + +type GuildListRow = { + guildId: string; + guildName: string; + isActive: boolean; + roleCount: number; + status?: EnrichedMembership["status"]; +}; + +function rowsFromWalletGuilds( + guilds: GuildListItem[], + memberships: EnrichedMembership[], +): GuildListRow[] { + const membershipsByGuildId = new Map( + memberships.map((membership) => [membership.guildId, membership]), + ); + + return guilds.map((guild) => { + const membership = membershipsByGuildId.get(guild.id); + return { + guildId: guild.id, + guildName: guild.name, + isActive: guild.isActive, + roleCount: guild.roleCount ?? membership?.roleCount ?? 0, + status: guild.status ?? membership?.status ?? (guild.isActive ? "active" : "inactive"), + }; + }); +} + +function rowsFromMemberships(memberships: EnrichedMembership[]): GuildListRow[] { + return memberships.map((membership) => ({ + guildId: membership.guildId, + guildName: membership.guildName, + isActive: membership.isActive, + roleCount: membership.roleCount, + status: membership.status, + })); +} export default function Guilds() { const router = useRouter(); const colorScheme = useColorScheme(); const { walletAddress, disconnect } = useWallet(); - const { useEnrichedMemberships } = useMembership(walletAddress); - const membershipsQuery = useEnrichedMemberships(); - const { data: memberships, isLoading, error } = membershipsQuery; - const staleState = useStaleQuery(membershipsQuery); - const { walletAddress } = useWallet(); const { useWalletGuilds } = useGuilds(); + const { useEnrichedMemberships } = useMembership(walletAddress); + const queryClient = useQueryClient(); + const guildsQuery = useWalletGuilds(walletAddress); + const membershipsQuery = useEnrichedMemberships(); + const memberships = membershipsQuery.data ?? []; + const staleState = useCombinedStaleState([guildsQuery, membershipsQuery]); + + const [searchQuery, setSearchQuery] = useState(""); + const [isRefetching, setIsRefetching] = useState(false); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + + const listRows = useMemo(() => { + const guilds = guildsQuery.data ?? []; + return guilds.length > 0 + ? rowsFromWalletGuilds(guilds, memberships) + : rowsFromMemberships(memberships); + }, [guildsQuery.data, memberships]); + + const filteredGuilds = useMemo(() => { + const query = debouncedQuery.trim().toLowerCase(); + if (!query) return listRows; + return listRows.filter((guild) => guild.guildName.toLowerCase().includes(query)); + }, [listRows, debouncedQuery]); + + const handleConnectDifferentWallet = async () => { + await disconnect(); + router.replace("/profile"); + }; + + const handleRefresh = useCallback(async () => { + if (!walletAddress) return; + + setIsRefetching(true); + try { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.walletGuilds.byWallet(walletAddress) }), + queryClient.invalidateQueries({ queryKey: queryKeys.memberships.byWallet(walletAddress) }), + guildsQuery.refetch(), + membershipsQuery.refetch(), + ]); + } finally { + setIsRefetching(false); + } + }, [guildsQuery, membershipsQuery, queryClient, walletAddress]); if (!walletAddress) { + return ( + + + + + + + ); + } + + if (guildsQuery.isLoading && membershipsQuery.isLoading && listRows.length === 0) { return ( @@ -36,17 +136,10 @@ export default function Guilds() { - - - - ); } - if (guildsQuery.isLoading) { + if (guildsQuery.isError && membershipsQuery.error && listRows.length === 0) { return ( @@ -54,17 +147,21 @@ export default function Guilds() { {staleState.isOffline ? ( ) : null} - + - - - - ); } - if (guildsQuery.isError) { + if (listRows.length === 0) { return ( @@ -93,7 +190,7 @@ export default function Guilds() { - - void guildsQuery.refetch()} - /> - - ); - } - - return ( - - - item.id} - contentContainerStyle={{ padding: 16 }} - testID="guilds-list" - renderItem={({ item }) => ( - router.push(`/guilds/${item.id}`)} - /> - )} - ListEmptyComponent={ - {searchQuery.length > 0 && ( ); - const isRefreshing = isRefetching || membershipsQuery.isRefetching; + const isRefreshing = isRefetching || guildsQuery.isRefetching || membershipsQuery.isRefetching; + const isShowingOfflineCache = staleState.isOffline && filteredGuilds.length > 0; return ( item.guildId} contentContainerStyle={{ padding: 16 }} testID="guilds-list" @@ -171,6 +234,8 @@ export default function Guilds() { id={item.guildId} isActive={item.isActive} roleCount={item.roleCount} + status={item.status} + offlineCached={isShowingOfflineCache} onPress={() => router.push(`/guilds/${item.guildId}`)} /> )} @@ -184,4 +249,4 @@ export default function Guilds() { ); -} \ No newline at end of file +} diff --git a/app/guilds/[guildId].tsx b/app/guilds/[guildId].tsx index 9d2267a..5a591cc 100644 --- a/app/guilds/[guildId].tsx +++ b/app/guilds/[guildId].tsx @@ -28,6 +28,11 @@ import type { AccessRequirement, PerChainRoleEligibilityResolution, } from "../../src/features/access/roleEligibilityResolver"; +import { + getGuildPassStatusLabel, + resolveGuildPassStatus, + type GuildPassStatus, +} from "../../src/features/passes/passCache"; import React from "react"; type GuildDetailRole = { @@ -37,6 +42,42 @@ type GuildDetailRole = { requirements?: AccessRequirement[]; }; +const MEMBERSHIP_STATUS_STYLES: Record< + GuildPassStatus, + { text: string; border: string; cachePill: string; cacheText: string } +> = { + active: { + text: "text-success dark:text-green-400", + border: "border-success/30 dark:border-green-600/50", + cachePill: "bg-success/10", + cacheText: "text-success", + }, + inactive: { + text: "text-text-muted dark:text-slate-400", + border: "", + cachePill: "bg-text-muted/10", + cacheText: "text-text-muted", + }, + expired: { + text: "text-secondary", + border: "border-secondary/40", + cachePill: "bg-secondary/10", + cacheText: "text-secondary", + }, + revoked: { + text: "text-error", + border: "border-error/40", + cachePill: "bg-error/10", + cacheText: "text-error", + }, + unknown: { + text: "text-text-muted dark:text-slate-400", + border: "", + cachePill: "bg-text-muted/10", + cacheText: "text-text-muted", + }, +}; + function ChainUnavailableState({ chainId, label, @@ -104,6 +145,10 @@ export default function GuildDetail() { const { data: guildConfig } = guildConfigQuery; const staleState = useCombinedStaleState([guildQuery, membershipQuery, rolesQuery]); + const membershipStatus = resolveGuildPassStatus(membership); + const membershipStatusLabel = getGuildPassStatusLabel(membershipStatus); + const membershipStatusStyle = MEMBERSHIP_STATUS_STYLES[membershipStatus]; + const isShowingCachedMembership = staleState.isOffline && membership !== undefined; const fallbackChainId = guild?.chainId ?? 1; const detailRoles = roles as GuildDetailRole[] | undefined; const normalizedRequirements = normalizeRoleRequirements( @@ -125,17 +170,14 @@ export default function GuildDetail() { enabled: !!validGuildId && !!walletAddress && !!detailRoles, }); const availabilityByChain = React.useMemo( - () => - new Map( - chainAvailability.perChain.map((chain) => [chain.chainId, chain] as const), - ), + () => new Map(chainAvailability.perChain.map((chain) => [chain.chainId, chain] as const)), [chainAvailability.perChain], ); const guildChainLabel = groupedRequirements.length === 0 - ? isKnownChainId(guild?.chainId ?? 1) - ? `${getChainDisplayName(guild?.chainId ?? 1)} (${guild?.chainId ?? 1})` - : `Unsupported network (chain: ${guild?.chainId ?? 1})` + ? isKnownChainId(fallbackChainId) + ? `${getChainDisplayName(fallbackChainId)} (${fallbackChainId})` + : `Unsupported network (chain: ${fallbackChainId})` : groupedRequirements.length === 1 ? groupedRequirements[0]?.label : `Multiple networks (${groupedRequirements.map((group) => group.label).join(", ")})`; @@ -202,10 +244,16 @@ export default function GuildDetail() { ) : null} - + {guild.name} - + {guild.description || "No description provided."} @@ -227,41 +275,45 @@ export default function GuildDetail() { - Your Membership + + Your Membership + Status - {membership?.isActive ? "Active Member" : "Not a Member"} + {membershipStatusLabel} + {isShowingCachedMembership ? ( + + + + Cached offline + + + + ) : null} - Available Roles + + Available Roles + {groupedRequirements.length > 0 ? ( - groupedRequirements.map((group) => ( - - - {group.label} - - - {group.requirements.map((role) => ( - { const availability = availabilityByChain.get(group.chainId); const isUnavailable = @@ -315,7 +367,9 @@ export default function GuildDetail() { ); }) ) : ( - No roles defined for this guild. + + No roles defined for this guild. + )} diff --git a/src/components/GuildCard.tsx b/src/components/GuildCard.tsx index 4a9db62..7a5dedb 100644 --- a/src/components/GuildCard.tsx +++ b/src/components/GuildCard.tsx @@ -2,42 +2,98 @@ import { View, Text, TouchableOpacity } from "react-native"; import React from "react"; import { Card } from "./Card"; import { RoleBadge } from "./RoleBadge"; +import type { GuildPassStatus } from "../features/passes/passCache"; type GuildCardProps = { name: string; id: string; isActive: boolean; roleCount: number; + status?: GuildPassStatus; + offlineCached?: boolean; onPress: () => void; }; -export const GuildCard = ({ name, id, isActive, roleCount, onPress }: GuildCardProps) => { +const STATUS_STYLES: Record< + GuildPassStatus, + { label: string; pill: string; text: string; roleTier: "default" | "premium" | "restricted" } +> = { + active: { + label: "ACTIVE", + pill: "bg-success/10", + text: "text-success", + roleTier: "premium", + }, + inactive: { + label: "INACTIVE", + pill: "bg-text-muted/10", + text: "text-text-muted", + roleTier: "default", + }, + expired: { + label: "EXPIRED", + pill: "bg-secondary/10", + text: "text-secondary", + roleTier: "default", + }, + revoked: { + label: "REVOKED", + pill: "bg-error/10", + text: "text-error", + roleTier: "restricted", + }, + unknown: { + label: "UNKNOWN", + pill: "bg-text-muted/10", + text: "text-text-muted", + roleTier: "default", + }, +}; + +export const GuildCard = ({ + name, + id, + isActive, + roleCount, + status, + offlineCached = false, + onPress, +}: GuildCardProps) => { + const resolvedStatus = status ?? (isActive ? "active" : "inactive"); + const statusStyle = STATUS_STYLES[resolvedStatus]; + return ( {name} - - - {isActive ? "ACTIVE" : "INACTIVE"} - + + {statusStyle.label} ID: {id} Tap to view details + {offlineCached ? ( + <> + + + Cached offline + + + ) : null} diff --git a/src/features/guilds/useGuilds.ts b/src/features/guilds/useGuilds.ts index c0c20c9..3339b5c 100644 --- a/src/features/guilds/useGuilds.ts +++ b/src/features/guilds/useGuilds.ts @@ -1,18 +1,27 @@ -import { useQuery } from "@tanstack/react-query"; +import { onlineManager, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { guildPassClient } from "../../lib/guildpassClient"; import { appConfig } from "../../config/appConfig"; +import { queryKeys } from "../../lib/queryKeys"; +import { getCachedMembershipSummaries, type GuildPassStatus } from "../passes/passCache"; export type GuildListItem = { id: string; name: string; isActive: boolean; roleCount?: number; + status?: GuildPassStatus; + lastSyncedAt?: number; }; -export const walletGuildsQueryKey = (walletAddress: string | null | undefined) => [ - "wallet-guilds", - walletAddress ?? "", -]; +export class GuildNotFoundError extends Error { + constructor(guildId: string) { + super(`Guild not found: ${guildId}`); + this.name = "GuildNotFoundError"; + } +} + +export const walletGuildsQueryKey = (walletAddress: string | null | undefined) => + queryKeys.walletGuilds.byWallet(walletAddress ?? ""); export const fetchGuildsByWalletAddress = async ( walletAddress: string, @@ -34,23 +43,70 @@ export const fetchGuildsByWalletAddress = async ( } const data = (await response.json()) as GuildListItem[] | { guilds?: GuildListItem[] }; - return Array.isArray(data) ? data : data.guilds ?? []; + return Array.isArray(data) ? data : (data.guilds ?? []); }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cachedGuildName(queryClient: QueryClient, guildId: string): string { + const guild = queryClient.getQueryData(queryKeys.guild.byId(guildId)); + return isRecord(guild) && typeof guild.name === "string" ? guild.name : guildId; +} + +function getCachedWalletGuilds( + queryClient: QueryClient, + walletAddress: string, +): GuildListItem[] | undefined { + const summaries = getCachedMembershipSummaries(queryClient, walletAddress); + if (!summaries) return undefined; + + return summaries.map((summary) => ({ + id: summary.guildId, + name: cachedGuildName(queryClient, summary.guildId), + isActive: summary.isActive, + roleCount: summary.roleCount, + status: summary.status, + lastSyncedAt: summary.lastSyncedAt, + })); +} + export const useGuilds = () => { + const queryClient = useQueryClient(); + const useWalletGuilds = (walletAddress: string | null | undefined) => { - return useQuery({ - queryKey: walletGuildsQueryKey(walletAddress), - queryFn: () => fetchGuildsByWalletAddress(walletAddress ?? ""), + const queryKey = walletGuildsQueryKey(walletAddress); + + return useQuery({ + queryKey, + queryFn: async () => { + if (!walletAddress) return []; + + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline()) { + return cached ?? getCachedWalletGuilds(queryClient, walletAddress) ?? []; + } + + return fetchGuildsByWalletAddress(walletAddress); + }, enabled: !!walletAddress, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; const useGuild = (guildId: string) => { - return useQuery({ - queryKey: queryKeys.guild.byId(guildId), + const queryKey = queryKeys.guild.byId(guildId); + + return useQuery({ + queryKey, queryFn: async () => { + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline() && cached !== undefined) { + return cached as any; + } + try { return await guildPassClient.guilds.getGuild({ guildId }); } catch (error) { @@ -62,24 +118,45 @@ export const useGuilds = () => { }, enabled: !!guildId, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; const useGuildConfig = (guildId: string) => { - return useQuery({ - queryKey: queryKeys.guildConfig.byId(guildId), - queryFn: () => guildPassClient.guilds.getGuildConfig({ guildId }), + const queryKey = queryKeys.guildConfig.byId(guildId); + + return useQuery({ + queryKey, + queryFn: async () => { + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline() && cached !== undefined) { + return cached as any; + } + + return guildPassClient.guilds.getGuildConfig({ guildId }); + }, enabled: !!guildId, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; const useRoles = (guildId: string) => { - return useQuery({ - queryKey: queryKeys.guildRoles.byId(guildId), - queryFn: () => guildPassClient.roles.getRoles({ guildId }), + const queryKey = queryKeys.guildRoles.byId(guildId); + + return useQuery({ + queryKey, + queryFn: async () => { + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline() && cached !== undefined) { + return cached as any; + } + + return guildPassClient.roles.getRoles({ guildId }); + }, enabled: !!guildId, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; diff --git a/src/features/membership/useMembership.ts b/src/features/membership/useMembership.ts index 497f6ad..765767b 100644 --- a/src/features/membership/useMembership.ts +++ b/src/features/membership/useMembership.ts @@ -1,65 +1,110 @@ -import { useQuery, useQueries } from "@tanstack/react-query"; +import { onlineManager, useQuery, useQueries, useQueryClient } from "@tanstack/react-query"; import { guildPassClient } from "../../lib/guildpassClient"; import { queryKeys } from "../../lib/queryKeys"; +import { + getCachedMembershipSummaries, + normalizeMembershipForPassSummary, + type CachedGuildPassSummary, +} from "../passes/passCache"; -export type NormalizedMembership = { - guildId: string; - isActive: boolean; - roleCount: number; -}; +export type NormalizedMembership = CachedGuildPassSummary; export type EnrichedMembership = NormalizedMembership & { guildName: string; }; export const useMembership = (walletAddress: string | null) => { + const queryClient = useQueryClient(); + const useMembershipQuery = (guildId: string) => { - return useQuery({ - queryKey: queryKeys.membership.byWalletAndGuild(walletAddress ?? "", guildId), - queryFn: () => - guildPassClient.membership.getMembership({ + const queryKey = queryKeys.membership.byWalletAndGuild(walletAddress ?? "", guildId); + + return useQuery({ + queryKey, + queryFn: async () => { + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline() && cached !== undefined) { + return cached as any; + } + + return guildPassClient.membership.getMembership({ walletAddress: walletAddress!, guildId, - }), + }); + }, enabled: !!walletAddress && !!guildId, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; const useUserRoles = (guildId: string) => { - return useQuery({ - queryKey: queryKeys.userRoles.byWalletAndGuild(walletAddress ?? "", guildId), - queryFn: () => - guildPassClient.roles.getUserRoles({ + const queryKey = queryKeys.userRoles.byWalletAndGuild(walletAddress ?? "", guildId); + + return useQuery({ + queryKey, + queryFn: async () => { + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline() && cached !== undefined) { + return cached as any; + } + + return guildPassClient.roles.getUserRoles({ walletAddress: walletAddress!, guildId, - }), + }); + }, enabled: !!walletAddress && !!guildId, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; const useMembershipsQuery = () => { - return useQuery({ - queryKey: queryKeys.memberships.byWallet(walletAddress ?? ""), + const queryKey = queryKeys.memberships.byWallet(walletAddress ?? ""); + + return useQuery({ + queryKey, queryFn: async () => { if (!walletAddress) return []; - const { getDatabase } = await import("../../database/connection"); - const dal = await import("../../database/dal"); - const db = getDatabase(); - const rows = await dal.getMembershipsByWallet(db, walletAddress); - - return rows.map((row) => { - const membership = JSON.parse(row.raw_json); - return { - guildId: row.guild_id, - isActive: row.status === "active", - roleCount: membership.roles?.length || 0, - } satisfies NormalizedMembership; - }); + const cached = getCachedMembershipSummaries(queryClient, walletAddress); + + if (!onlineManager.isOnline() && cached !== undefined) { + return cached; + } + + try { + const { getDatabase } = await import("../../database/connection"); + const dal = await import("../../database/dal"); + const db = getDatabase(); + const rows = await dal.getMembershipsByWallet(db, walletAddress); + + if (rows.length > 0) { + return rows + .map((row) => { + const membership = JSON.parse(row.raw_json); + return normalizeMembershipForPassSummary(membership, { + fallbackGuildId: row.guild_id, + fallbackRoleCount: membership.roles?.length, + lastSyncedAt: new Date(row.updated_at).getTime(), + }); + }) + .filter((entry): entry is NormalizedMembership => entry !== null); + } + } catch (error) { + // The encrypted TanStack cache is the reliable offline source. The + // SQLite layer is optional here until a wallet-wide membership API + // exists and can keep it populated. + if (cached === undefined) { + throw error; + } + } + + return cached ?? []; }, enabled: !!walletAddress, networkMode: "offlineFirst", + refetchOnReconnect: "always", }); }; @@ -68,13 +113,25 @@ export const useMembership = (walletAddress: string | null) => { const memberships = membershipsQuery.data ?? []; const guildNameQueries = useQueries({ - queries: memberships.map((m) => ({ - queryKey: queryKeys.guild.byId(m.guildId), - queryFn: () => guildPassClient.guilds.getGuild({ guildId: m.guildId }), - enabled: !!m.guildId, - staleTime: 1000 * 60 * 5, - networkMode: "offlineFirst" as const, - })), + queries: memberships.map((m) => { + const queryKey = queryKeys.guild.byId(m.guildId); + + return { + queryKey, + queryFn: async () => { + const cached = queryClient.getQueryData(queryKey); + if (!onlineManager.isOnline() && cached !== undefined) { + return cached as any; + } + + return guildPassClient.guilds.getGuild({ guildId: m.guildId }); + }, + enabled: !!m.guildId, + staleTime: 1000 * 60 * 5, + networkMode: "offlineFirst" as const, + refetchOnReconnect: "always" as const, + }; + }), }); const enriched = memberships.map((m, i) => { @@ -87,6 +144,8 @@ export const useMembership = (walletAddress: string | null) => { : undefined) ?? m.guildId, isActive: m.isActive, roleCount: m.roleCount, + status: m.status, + lastSyncedAt: m.lastSyncedAt, } satisfies EnrichedMembership; }); diff --git a/src/features/passes/passCache.ts b/src/features/passes/passCache.ts new file mode 100644 index 0000000..b3cccbf --- /dev/null +++ b/src/features/passes/passCache.ts @@ -0,0 +1,250 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { queryKeys, QUERY_ROOTS } from "../../lib/queryKeys"; + +export type GuildPassStatus = "active" | "inactive" | "expired" | "revoked" | "unknown"; + +export type CachedGuildPassSummary = { + guildId: string; + isActive: boolean; + roleCount: number; + status: GuildPassStatus; + lastSyncedAt?: number; +}; + +export const GUILD_PASS_QUERY_ROOTS = [ + QUERY_ROOTS.MEMBERSHIP, + QUERY_ROOTS.MEMBERSHIPS, + QUERY_ROOTS.WALLET_GUILDS, + QUERY_ROOTS.USER_ROLES, + QUERY_ROOTS.GUILD, + QUERY_ROOTS.GUILD_CONFIG, + QUERY_ROOTS.GUILD_ROLES, +] as const; + +export function isGuildPassQuery(queryKey: readonly unknown[]): boolean { + const root = queryKey[0]; + return typeof root === "string" && (GUILD_PASS_QUERY_ROOTS as readonly string[]).includes(root); +} + +export function getGuildPassStatusLabel(status: GuildPassStatus): string { + switch (status) { + case "active": + return "Active Member"; + case "expired": + return "Expired"; + case "revoked": + return "Revoked"; + case "inactive": + return "Not a Member"; + case "unknown": + default: + return "Unknown"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: Record, keys: string[]): string | undefined { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate; + } + } + return undefined; +} + +function isExpiredTimestamp(value: string | undefined, now: number): boolean { + if (!value) return false; + const time = new Date(value).getTime(); + return Number.isFinite(time) && time <= now; +} + +export function resolveGuildPassStatus( + membership: unknown, + now: number = Date.now(), +): GuildPassStatus { + if (!isRecord(membership)) { + return "unknown"; + } + + const explicitStatus = readString(membership, [ + "status", + "membershipStatus", + "state", + ])?.toLowerCase(); + + if ( + explicitStatus === "active" || + explicitStatus === "inactive" || + explicitStatus === "expired" || + explicitStatus === "revoked" + ) { + return explicitStatus; + } + + if (readString(membership, ["revokedAt", "revocationDate"])) { + return "revoked"; + } + + if (isExpiredTimestamp(readString(membership, ["expiresAt", "expiredAt", "validUntil"]), now)) { + return "expired"; + } + + if (typeof membership.isActive === "boolean") { + return membership.isActive ? "active" : "inactive"; + } + + return "unknown"; +} + +function isActiveStatus(status: GuildPassStatus): boolean { + return status === "active"; +} + +function roleCountFromMembership(membership: Record): number | undefined { + const roles = membership.roles; + if (Array.isArray(roles)) { + return roles.length; + } + + const roleCount = membership.roleCount; + if (typeof roleCount === "number" && Number.isFinite(roleCount)) { + return roleCount; + } + + return undefined; +} + +function roleCountFromQuery( + queryClient: Pick, + walletAddress: string, + guildId: string, +): number | undefined { + const roles = queryClient.getQueryData( + queryKeys.userRoles.byWalletAndGuild(walletAddress, guildId), + ); + return Array.isArray(roles) ? roles.length : undefined; +} + +export function normalizeMembershipForPassSummary( + membership: unknown, + options: { + fallbackGuildId?: string; + fallbackRoleCount?: number; + lastSyncedAt?: number; + now?: number; + } = {}, +): CachedGuildPassSummary | null { + if (!isRecord(membership)) { + return null; + } + + const guildId = readString(membership, ["guildId", "guild_id", "id"]) ?? options.fallbackGuildId; + + if (!guildId) { + return null; + } + + const status = resolveGuildPassStatus(membership, options.now); + const roleCount = roleCountFromMembership(membership) ?? options.fallbackRoleCount ?? 0; + + return { + guildId, + isActive: isActiveStatus(status), + roleCount, + status, + lastSyncedAt: options.lastSyncedAt, + }; +} + +function getQueryUpdatedAt( + queryClient: QueryClient, + queryKey: readonly unknown[], +): number | undefined { + return queryClient.getQueryCache().find({ queryKey })?.state.dataUpdatedAt; +} + +function normalizeAggregateData( + queryClient: QueryClient, + walletAddress: string, +): CachedGuildPassSummary[] | undefined { + const aggregate = queryClient.getQueryData(queryKeys.memberships.byWallet(walletAddress)); + if (!Array.isArray(aggregate)) { + return undefined; + } + + const lastSyncedAt = getQueryUpdatedAt( + queryClient, + queryKeys.memberships.byWallet(walletAddress), + ); + return aggregate + .map((entry) => + normalizeMembershipForPassSummary(entry, { + lastSyncedAt, + }), + ) + .filter((entry): entry is CachedGuildPassSummary => entry !== null); +} + +function deriveMembershipSummariesFromEntities( + queryClient: QueryClient, + walletAddress: string, +): CachedGuildPassSummary[] { + const walletKey = walletAddress.toLowerCase(); + const summaries = new Map(); + + queryClient + .getQueryCache() + .getAll() + .forEach((query) => { + const [root, queryWalletAddress, guildId] = query.queryKey; + if ( + root !== QUERY_ROOTS.MEMBERSHIP || + typeof queryWalletAddress !== "string" || + queryWalletAddress.toLowerCase() !== walletKey || + typeof guildId !== "string" + ) { + return; + } + + const summary = normalizeMembershipForPassSummary(query.state.data, { + fallbackGuildId: guildId, + fallbackRoleCount: roleCountFromQuery(queryClient, queryWalletAddress, guildId), + lastSyncedAt: query.state.dataUpdatedAt, + }); + + if (summary) { + summaries.set(summary.guildId, summary); + } + }); + + return [...summaries.values()].sort((a, b) => a.guildId.localeCompare(b.guildId)); +} + +export function getCachedMembershipSummaries( + queryClient: QueryClient, + walletAddress: string, +): CachedGuildPassSummary[] | undefined { + const derived = deriveMembershipSummariesFromEntities(queryClient, walletAddress); + if (derived.length > 0) { + return derived; + } + + return normalizeAggregateData(queryClient, walletAddress); +} + +export function rebuildMembershipsAggregateFromCache( + queryClient: QueryClient, + walletAddress: string, +): CachedGuildPassSummary[] | undefined { + const summaries = deriveMembershipSummariesFromEntities(queryClient, walletAddress); + if (summaries.length === 0) { + return normalizeAggregateData(queryClient, walletAddress); + } + + queryClient.setQueryData(queryKeys.memberships.byWallet(walletAddress), summaries); + return summaries; +} diff --git a/src/features/sync/reconcile.ts b/src/features/sync/reconcile.ts index b9965b4..e8817c6 100644 --- a/src/features/sync/reconcile.ts +++ b/src/features/sync/reconcile.ts @@ -6,7 +6,6 @@ */ import { isWalletScopedQueryRoot } from "../../lib/queryKeys"; -import { PERSISTABLE_QUERY_KEY_ROOTS } from "../../lib/offlineCache"; import type { SyncCorrection, SyncCorrectionSeverity, @@ -15,11 +14,16 @@ import type { SyncEntityKind, } from "./sync.types"; -/** Query-key roots the reconciliation pass covers: derived from the offline - * cache allowlist so the two never drift. The "access-check" root is a - * mutation namespace, not a cached server entity, so it is excluded. */ -export const RECONCILED_QUERY_KEY_ROOTS: readonly SyncEntityKind[] = - PERSISTABLE_QUERY_KEY_ROOTS.filter((root): root is SyncEntityKind => root !== "access-check"); +/** Query-key roots the reconciliation pass covers. Being persistable and being + * server-reconcilable are separate properties; aggregate/client-only roots + * such as "memberships" are persisted but refreshed indirectly. */ +export const RECONCILED_QUERY_KEY_ROOTS: readonly SyncEntityKind[] = [ + "membership", + "user-roles", + "guild", + "guild-config", + "guild-roles", +]; /** * Parses a React Query key into a sync entity descriptor. diff --git a/src/features/sync/syncEngine.ts b/src/features/sync/syncEngine.ts index 3515ffa..87b44bd 100644 --- a/src/features/sync/syncEngine.ts +++ b/src/features/sync/syncEngine.ts @@ -20,12 +20,8 @@ import { hashKey } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query"; import { computeEntityVersion, describeSyncableQuery, diffEntity } from "./reconcile"; import { prioritizeDescriptors } from "./syncPriority"; -import { - DEFAULT_RETRY_CONFIG, - RetryAborted, - runWithRetry, - type RetryConfig, -} from "./retryPolicy"; +import { rebuildMembershipsAggregateFromCache } from "../passes/passCache"; +import { DEFAULT_RETRY_CONFIG, RetryAborted, runWithRetry, type RetryConfig } from "./retryPolicy"; import type { SyncCorrection, SyncEntityDescriptor, @@ -198,15 +194,23 @@ export function createSyncEngine(deps: SyncEngineDeps): SyncEngine { const corrections: SyncCorrection[] = []; const errors: SyncRunError[] = []; const metaEntries: Record = {}; + const walletsToRefresh = new Set(); let entitiesUpdated = 0; let abortedOffline = false; results.forEach((result, index) => { + const descriptor = descriptors[index]; if (result.status === "fulfilled") { if (result.value === null) return; // entity vanished mid-pass corrections.push(...result.value.corrections); metaEntries[result.value.metaKey] = result.value.meta; if (result.value.updated) entitiesUpdated += 1; + if ( + descriptor.walletAddress && + (descriptor.kind === "membership" || descriptor.kind === "user-roles") + ) { + walletsToRefresh.add(descriptor.walletAddress); + } } else if (result.reason instanceof RetryAborted) { // Connectivity vanished mid-pass. Not an entity error: the entity was // never disproven, so it keeps its existing meta and is left for the @@ -225,6 +229,9 @@ export function createSyncEngine(deps: SyncEngineDeps): SyncEngine { // forward progress instead of being discarded wholesale. syncStore.getState().recordEntityMetaBatch(metaEntries); syncStore.getState().addCorrections(corrections); + walletsToRefresh.forEach((walletAddress) => { + rebuildMembershipsAggregateFromCache(queryClient, walletAddress); + }); const finishedAt = now(); if (errors.length > 0) { diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index e8aa9c4..2a49a9d 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -3,6 +3,7 @@ import { walletScopedQueryRoots } from "./walletScopedCache"; export const QUERY_ROOTS = { MEMBERSHIP: "membership", MEMBERSHIPS: "memberships", + WALLET_GUILDS: "wallet-guilds", USER_ROLES: "user-roles", GUILD: "guild", GUILD_CONFIG: "guild-config", @@ -36,6 +37,10 @@ export const queryKeys = { all: ["memberships"] as const, byWallet: (walletAddress: string) => ["memberships", walletAddress] as const, }, + walletGuilds: { + all: ["wallet-guilds"] as const, + byWallet: (walletAddress: string) => ["wallet-guilds", walletAddress] as const, + }, userRoles: { all: ["user-roles"] as const, byWalletAndGuild: (walletAddress: string, guildId: string) => @@ -55,10 +60,12 @@ export const queryKeys = { export const PERSISTABLE_QUERY_ROOTS: readonly QueryRoot[] = [ QUERY_ROOTS.MEMBERSHIP, QUERY_ROOTS.MEMBERSHIPS, + QUERY_ROOTS.WALLET_GUILDS, QUERY_ROOTS.USER_ROLES, QUERY_ROOTS.GUILD, QUERY_ROOTS.GUILD_CONFIG, QUERY_ROOTS.GUILD_ROLES, + QUERY_ROOTS.ACCESS_CHECK, ]; export function isPersistableQuery(queryKey: readonly unknown[]): boolean { diff --git a/src/lib/walletScopedCache.ts b/src/lib/walletScopedCache.ts index e807061..a5defb6 100644 --- a/src/lib/walletScopedCache.ts +++ b/src/lib/walletScopedCache.ts @@ -1,6 +1,11 @@ import type { QueryClient } from "@tanstack/react-query"; -export const walletScopedQueryRoots = new Set(["membership", "memberships", "user-roles"]); +export const walletScopedQueryRoots = new Set([ + "membership", + "memberships", + "wallet-guilds", + "user-roles", +]); const walletScopedMutationRoots = new Set(["access-check"]); function isWalletScopedQuery(queryKey: readonly unknown[]): boolean { From 0984de685dfdbbffa172ef77b3f5012a6dd0bfe2 Mon Sep 17 00:00:00 2001 From: Goodness Date: Mon, 27 Jul 2026 21:33:20 +0100 Subject: [PATCH 2/2] test: cover offline guild pass persistence --- tests/GuildCard.test.tsx | 33 +++++ tests/passCache.test.ts | 128 ++++++++++++++++++ .../sync/syncCoordinator.integration.test.ts | 19 +-- tests/sync/syncEngine.test.ts | 36 +++++ 4 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 tests/GuildCard.test.tsx create mode 100644 tests/passCache.test.ts diff --git a/tests/GuildCard.test.tsx b/tests/GuildCard.test.tsx new file mode 100644 index 0000000..4998131 --- /dev/null +++ b/tests/GuildCard.test.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { GuildCard } from "../src/components/GuildCard"; + +vi.mock("react-native", () => ({ + View: "View", + Text: "Text", + TouchableOpacity: "TouchableOpacity", +})); + +describe("GuildCard", () => { + it("labels cached revoked passes distinctly while offline", () => { + const renderer = TestRenderer.create( + {}} + />, + ); + + const button = renderer.root.findByProps({ accessibilityRole: "button" }); + expect(button.props.accessibilityLabel).toContain("revoked"); + expect(button.props.accessibilityLabel).toContain("cached offline"); + + expect(JSON.stringify(renderer.toJSON())).toContain("REVOKED"); + expect(renderer.root.findByProps({ testID: "guild-card-offline-cache" })).toBeDefined(); + }); +}); diff --git a/tests/passCache.test.ts b/tests/passCache.test.ts new file mode 100644 index 0000000..b59800f --- /dev/null +++ b/tests/passCache.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { QueryClient, dehydrate, hydrate } from "@tanstack/react-query"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; +import { + getCachedMembershipSummaries, + rebuildMembershipsAggregateFromCache, + resolveGuildPassStatus, +} from "../src/features/passes/passCache"; +import { isPersistableQuery, PERSISTED_QUERY_CACHE_KEY } from "../src/lib/offlineCache"; +import { queryKeys } from "../src/lib/queryKeys"; +import { + MEMBERSHIP_ACTIVE_FIXTURE, + TEST_WALLET_ADDRESS, + USER_ROLES_FIXTURE, +} from "./fixtures/membership.fixtures"; + +function createMemoryStorage() { + const store = new Map(); + + return { + getItem: async (key: string) => store.get(key) ?? null, + setItem: async (key: string, value: string) => { + store.set(key, value); + }, + removeItem: async (key: string) => { + store.delete(key); + }, + }; +} + +describe("pass cache", () => { + it("restores a persisted wallet pass list after an app restart", async () => { + const storage = createMemoryStorage(); + const persister = createAsyncStoragePersister({ + storage, + key: PERSISTED_QUERY_CACHE_KEY, + throttleTime: 0, + }); + const sourceClient = new QueryClient(); + const membershipsKey = queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS); + + sourceClient.setQueryData(membershipsKey, [ + { + guildId: "guild_abc", + isActive: true, + roleCount: 2, + status: "active", + }, + ]); + + await persister.persistClient({ + timestamp: Date.now(), + buster: "", + clientState: dehydrate(sourceClient, { + shouldDehydrateQuery: (query) => + query.state.status === "success" && isPersistableQuery(query.queryKey), + }), + }); + + const restoredClient = new QueryClient(); + const persisted = await persister.restoreClient(); + expect(persisted).toBeDefined(); + hydrate(restoredClient, persisted!.clientState); + + expect(getCachedMembershipSummaries(restoredClient, TEST_WALLET_ADDRESS)).toStrictEqual([ + { + guildId: "guild_abc", + isActive: true, + roleCount: 2, + status: "active", + lastSyncedAt: expect.any(Number), + }, + ]); + }); + + it("rebuilds the aggregate pass list from refreshed membership and role entities", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS), [ + { + guildId: "guild_abc", + isActive: true, + roleCount: 2, + status: "active", + }, + ]); + queryClient.setQueryData( + queryKeys.membership.byWalletAndGuild(TEST_WALLET_ADDRESS, "guild_abc"), + { + ...MEMBERSHIP_ACTIVE_FIXTURE, + status: "revoked", + isActive: false, + }, + ); + queryClient.setQueryData( + queryKeys.userRoles.byWalletAndGuild(TEST_WALLET_ADDRESS, "guild_abc"), + [], + ); + + const summaries = rebuildMembershipsAggregateFromCache(queryClient, TEST_WALLET_ADDRESS); + + expect(summaries).toStrictEqual([ + { + guildId: "guild_abc", + isActive: false, + roleCount: 0, + status: "revoked", + lastSyncedAt: expect.any(Number), + }, + ]); + expect( + queryClient.getQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS)), + ).toStrictEqual(summaries); + }); + + it("classifies expired and revoked cached passes distinctly", () => { + expect(resolveGuildPassStatus({ ...MEMBERSHIP_ACTIVE_FIXTURE, status: "revoked" })).toBe( + "revoked", + ); + expect( + resolveGuildPassStatus({ + ...MEMBERSHIP_ACTIVE_FIXTURE, + expiresAt: "2020-01-01T00:00:00.000Z", + }), + ).toBe("expired"); + expect(resolveGuildPassStatus(MEMBERSHIP_ACTIVE_FIXTURE)).toBe("active"); + expect(USER_ROLES_FIXTURE).toHaveLength(2); + }); +}); diff --git a/tests/sync/syncCoordinator.integration.test.ts b/tests/sync/syncCoordinator.integration.test.ts index e40ad0c..6dcc2f3 100644 --- a/tests/sync/syncCoordinator.integration.test.ts +++ b/tests/sync/syncCoordinator.integration.test.ts @@ -371,12 +371,8 @@ describe("sync under intermittent connectivity", () => { await pass; // And the full order still has every tier-1 entity ahead of every tier-2. - const lastTierOne = order.findLastIndex( - (k) => k === "membership" || k === "user-roles", - ); - const firstTierTwo = order.findIndex( - (k) => k !== "membership" && k !== "user-roles", - ); + const lastTierOne = order.findLastIndex((k) => k === "membership" || k === "user-roles"); + const firstTierTwo = order.findIndex((k) => k !== "membership" && k !== "user-roles"); expect(lastTierOne).toBeLessThan(firstTierTwo); }); @@ -485,10 +481,17 @@ describe("sync under intermittent connectivity", () => { expect(summary?.status).toBe("completed"); expect(summary?.errors).toStrictEqual([]); - // The unreconcilable entry is skipped, not counted and not destroyed. + // The unreconcilable entry is skipped, not counted, and refreshed from + // reconciled membership/role entities rather than left stale. expect(summary?.entitiesChecked).toBe(2); expect(queryClient.getQueryData(["memberships", TEST_WALLET_ADDRESS])).toStrictEqual([ - { guildId: GUILD_ID }, + { + guildId: GUILD_ID, + isActive: true, + roleCount: 2, + status: "active", + lastSyncedAt: expect.any(Number), + }, ]); expect(useSyncStore.getState().status).toBe("idle"); }); diff --git a/tests/sync/syncEngine.test.ts b/tests/sync/syncEngine.test.ts index f53a80b..015bb6e 100644 --- a/tests/sync/syncEngine.test.ts +++ b/tests/sync/syncEngine.test.ts @@ -19,6 +19,7 @@ import { } from "../../src/features/sync/syncEngine"; import { useSyncStore } from "../../src/features/sync/sync.store"; import { computeEntityVersion } from "../../src/features/sync/reconcile"; +import { queryKeys } from "../../src/lib/queryKeys"; import { MEMBERSHIP_ACTIVE_FIXTURE, TEST_WALLET_ADDRESS, @@ -129,6 +130,41 @@ describe("sync engine – acceptance scenario: cached grant revoked server-side" expect(useSyncStore.getState().status).toBe("idle"); expect(useSyncStore.getState().lastSyncCompletedAt).toBe(now); }); + + it("refreshes the wallet pass list aggregate after reconnect reconciliation", async () => { + queryClient.setQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS), [ + { + guildId: "guild_abc", + isActive: true, + roleCount: 2, + status: "active", + }, + ]); + const engine = createSyncEngine({ + queryClient, + fetchers: makeFetchers({ + membership: vi.fn().mockResolvedValue({ ...MEMBERSHIP_REVOKED, status: "revoked" }), + "user-roles": vi.fn().mockResolvedValue([]), + }), + syncStore: useSyncStore, + sleep: async () => {}, + isOnline: () => true, + }); + + await engine.runReconciliation(); + + expect( + queryClient.getQueryData(queryKeys.memberships.byWallet(TEST_WALLET_ADDRESS)), + ).toStrictEqual([ + { + guildId: "guild_abc", + isActive: false, + roleCount: 0, + status: "revoked", + lastSyncedAt: expect.any(Number), + }, + ]); + }); }); describe("sync engine – behaviour", () => {