Approach: Hop-Preserving Cache with Select-Time Resolution
This is what will happen:
Store each hop in cache as returned by API (raw response with aliasAccount/redirectInfo fields)
Use select function to traverse the chain at query time
Eagerly fetch targets when encountering aliases/redirects
Use metadata to track reverse relationships for invalidation
Hybrid updates: invalidate queries (triggers select re-run) + optional optimistic updates
Phase 1: Account Aliases (Start Here)
Step 1: Modify fetchAccount to populate intermediate hops
Location: frontend/packages/shared/src/models/entity.ts:159-181
Changes:
async function fetchAccount(
id: string,
queryClient: QueryClient // Add this parameter
): Promise<HMAccount> {
const account = await client.accounts.getAccount(id)
// NEW: Store this hop in cache immediately
queryClient.setQueryData(['account', id], account)
if (account.aliasAccount) {
// Recursively fetch target
return fetchAccount(account.aliasAccount, queryClient)
}
return account
}
Why: This ensures every hop is stored in cache as we traverse the chain.
Step 2: Create alias chain traversal helper
New file or add to: frontend/packages/shared/src/models/entity.ts
Add function:
function followAccountAliasChain(
startId: string,
queryClient: QueryClient,
maxDepth = 5 // Prevent infinite loops
): HMAccount | undefined {
const visited = new Set<string>()
let currentId = startId
let depth = 0
while (depth < maxDepth && !visited.has(currentId)) {
visited.add(currentId)
const account = queryClient.getQueryData<HMAccount>(['account', currentId])
if (!account) {
return undefined // Not in cache
}
if (!account.aliasAccount) {
return account // Found final target
}
currentId = account.aliasAccount
depth++
}
return undefined // Cycle detected or max depth reached
}
Why: Reusable logic for traversing alias chains by reading from cache.
Step 3: Update useAccount hook with select function
Location: frontend/packages/shared/src/models/entity.ts:186-202
Changes:
export function useAccount(id: string | undefined, options?: UseAccountOptions) {
const { client, queryClient } = useUniversalClient()
return useQuery({
queryKey: [queryKeys.ACCOUNT, id],
queryFn: () => fetchAccount(id!, queryClient), // Pass queryClient
enabled: !!id && (options?.enabled ?? true),
// NEW: Add select to traverse chain
select: (rawAccount) => {
if (!rawAccount.aliasAccount) {
return rawAccount // No alias, return as-is
}
// Follow the chain
const resolved = followAccountAliasChain(id!, queryClient)
return resolved || rawAccount // Fallback to raw if chain incomplete
},
...options,
})
}
Why: The hook now returns the final resolved account data, but cache preserves all hops.
Step 4: Update batch resolver to track relationships
Location: frontend/packages/shared/src/models/entity.ts:80-161
Changes: Modify createBatchAccountsResolver to use setQueryData with metadata:
// After resolving aliases, store with metadata
Object.entries(resolvedMap).forEach(([sourceId, targetAccount]) => {
const targetId = targetAccount.id
// Store source (if it was an alias)
if (sourceId !== targetId) {
queryClient.setQueryData(
['account', sourceId],
{ ...originalAccount, aliasAccount: targetId },
{ meta: { isAlias: true, targetId } }
)
}
// Store target with reverse tracking
const existingMeta = queryClient.getQueryState(['account', targetId])?.meta
const aliasedFrom = (existingMeta?.aliasedFrom || []) as string[]
queryClient.setQueryData(
['account', targetId],
targetAccount,
{ meta: { aliasedFrom: [...new Set([...aliasedFrom, sourceId])] } }
)
})Why: Tracks which accounts point to each target for smart invalidation.
Step 5: Add smart invalidation to account mutations
Locations: Any mutations that update accounts (e.g., updateProfile, profile updates)
Pattern to add:
onSuccess: (updatedAccount, variables) => {
const accountId = variables.account || Why: When target changes, all sources automatically see the update through select re-execution.
Step 6: Handle orphaned references
In fetchAccount: Add eager fetching of missing targets:
if (account.aliasAccount) {
// Check if target is in cache
const targetInCache = queryClient.getQueryData(['account', account.aliasAccount])
if (!targetInCache) {
// Eagerly fetch target
queryClient.fetchQuery({
queryKey: ['account', account.aliasAccount],
queryFn: () => fetchAccount(account.aliasAccount!, queryClient)
})
}
return fetchAccount(account.aliasAccount, queryClient)
}Why: Ensures complete chain is available in cache for select function.
Phase 2: Extend to Documents (Later)
Step 7: Apply pattern to document redirects
Locations:
frontend/packages/shared/src/resource-loader.ts
frontend/packages/shared/src/models/entity.ts (useResource hook)
Similar changes:
Store each hop when fetching resources
Create followRedirectChain helper (similar to account version)
Add select function to useResource that traverses redirects
Update mutations to invalidate redirect chains
Handle redirectInfo field in DocumentInfo responses
Testing Strategy
Unit tests for followAccountAliasChain:
Single hop (A → B)
Multi-hop (A → B → C)
Cycle detection (A → B → A)
Max depth limit
Missing intermediate hops
Integration tests for useAccount:
Fetch aliased account, verify final data returned
Verify all hops stored in cache
Update target, verify source queries update
Test with concurrent queries for same chain
Manual testing:
Profile page showing aliased account
Update target account profile, verify UI updates
Check React DevTools Query tab for cache structure
Summary
What this achieves:
✅ Preserves all hops in cache (A → B → C all stored separately)
✅ Components get resolved data transparently
✅ Updates propagate automatically via invalidation + select re-run
✅ Handles orphaned references by eager fetching
✅ Works with existing batch resolver pattern
✅ Acceptable performance for edge cases (50+ aliases)
✅ No persistent storage needed (rebuilds on next fetch)
What's NOT in scope (by design):
❌ Direct cache updates on mutations (relies on invalidation)
❌ Persistent relationship storage across sessions
❌ Complex garbage collection prevention