Target Branch: feat/api-router
Context
The feat/api-router branch introduces a unified API architecture:
Single client.request<RequestType>(key, input) method for all API calls
API implementations in APIRouter (shared between desktop/web)
Alias resolution happens in API layer (api-account.ts, api-batch-accounts.ts)
Query keys unchanged: [queryKeys.ACCOUNT, id]
Key Changes from Original Plan
What Changed:
❌ No more fetchAccount function to modify
❌ No more createBatchAccountsResolver function
✅ API implementations (Account.getData, BatchAccounts.getData) already resolve aliases
✅ BatchAccounts already returns same data for both alias and target
✅ Query hooks use unified client.request() pattern
What Stayed the Same:
✅ Query keys: [queryKeys.ACCOUNT, id]
✅ Hook names: useAccount, useAccountsMetadata
✅ Goal: preserve hops, traverse in select, track relationships
✅ Use query cache meta to store relationships
Strategy: Hook-Layer Metadata + Select Functions
Single-Layer Approach with Query Cache as Source of Truth:
API Layer: Returns raw data with aliasAccount field intact
Hook Layer:
Detects aliases in returned data
Stores relationship metadata in query cache meta
Uses select to traverse chains by reading cache
Query Cache: Single source of truth for both data AND relationships
Why This Works:
✅ No separate state management
✅ Metadata GC'd with queries (no stale data)
✅ Inspectable in React Query DevTools
✅ Respects existing cache lifecycle
Phase 1: Account Aliases
Step 1: Create Utility Functions for Metadata
New file: frontend/packages/shared/src/models/alias-utils.ts
import { QueryClient } from '@tanstack/react-query'
import { queryKeys } from './query-keys'
/**
* Get the alias target for an account from cache metadata
*/
export function getAliasTarget(
queryClient: QueryClient,
accountId: string
): string | undefined {
const queryState = queryClient.getQueryState([queryKeys.ACCOUNT, accountId])
return queryState?.meta?.targetId as string | undefined
}
/**
* Get all accounts that alias to this target from cache metadata
*/
export function getAliasesForTarget(
queryClient: QueryClient,
targetId: string
): string[] {
const queryState = queryClient.getQueryState([queryKeys.ACCOUNT, targetId])
const aliasedFrom = queryState?.meta?.aliasedFrom as string[] | undefined
return aliasedFrom || []
}
/**
* Follow alias chain by reading from cache until reaching final target.
* Returns the final target ID, or the original ID if not an alias.
*/
export function followAliasChain(
queryClient: QueryClient,
startId: string,
maxDepth = 10
): string {
const visited = new Set<string>()
let currentId = startId
let depth = 0
while (depth < maxDepth && !visited.has(currentId)) {
visited.add(currentId)
const targetId = getAliasTarget(queryClient, currentId)
if (!targetId) {
return currentId // No alias, this is the final target
}
currentId = targetId
depth++
}
return currentId // Return last valid ID (handles cycles)
}
/**
* Get all accounts affected by changes to targetId (target + all its aliases)
*/
export function getAffectedAccounts(
queryClient: QueryClient,
targetId: string
): string[] {
return [targetId, ...getAliasesForTarget(queryClient, targetId)]
}
/**
* Register an alias relationship in cache metadata.
* Updates both source (isAlias: true, targetId) and target (aliasedFrom: [...])
*/
export function registerAliasInCache(
queryClient: QueryClient,
sourceId: string,
targetId: string,
sourceData?: any
) {
// Update source with alias metadata
if (sourceData) {
queryClient.setQueryData(
[queryKeys.ACCOUNT, sourceId],
sourceData,
{ meta: { isAlias: true, targetId } }
)
}
// Update target with reverse mapping
const targetState = queryClient.getQueryState([queryKeys.ACCOUNT, targetId])
const existingAliases = (targetState?.meta?.aliasedFrom || []) as string[]
const targetData = queryClient.getQueryData([queryKeys.ACCOUNT, targetId])
if (targetData) {
queryClient.setQueryData(
[queryKeys.ACCOUNT, targetId],
targetData,
{
meta: {
aliasedFrom: [...new Set([...existingAliases, sourceId])]
}
}
)
}
}
Why: Pure functions that use query cache as the source of truth for relationships.
Step 2: Modify Account API to Return Raw Data with Alias Field
Location: frontend/packages/shared/src/api-account.ts
Critical insight: The current API Router implementation already resolves aliases recursively in Account.getData. We need to change this to preserve the hop data.
Current behavior (resolves immediately):
if (serverAccount.aliasAccount) {
return await Account.getData(grpcClient, serverAccount.aliasAccount, queryDaemon)
}
New behavior (preserve hop, return indicator):
export const Account: HMRequestImplementation<HMAccountRequest> = {
async getData(
grpcClient: GRPCClient,
input: string,
queryDaemon: QueryDaemonFn,
): Promise<HMMetadataPayload> {
const grpcAccount = await grpcClient.documents.getAccount({ id: input })
const serverAccount = toPlainMessage(grpcAccount)
// CHANGED: Return the account with aliasAccount field intact
// Don't resolve recursively here - let the hook layer handle it
const metadata = prepareHMDocumentMetadata(grpcAccount.metadata)
const result: HMMetadataPayload = {
id: hmId(input, { version: serverAccount.homeDocumentInfo?.version }),
metadata,
}
// NEW: Add aliasAccount to result if present
if (serverAccount.aliasAccount) {
return {
...result,
aliasAccount: serverAccount.aliasAccount,
} as HMMetadataPayload & { aliasAccount: string }
}
return result
},
}
Why: API returns raw hop data, hook layer handles resolution and caching.
Step 3: Modify BatchAccounts API Similarly
Location: frontend/packages/shared/src/api-batch-accounts.ts
Keep the recursive resolution but ensure each hop is returned:
export const BatchAccounts: HMRequestImplementation<HMBatchAccountsRequest> = {
async getData(
grpcClient: GRPCClient,
accountUids: string[],
queryDaemon: QueryDaemonFn,
): Promise<Record<string, HMMetadataPayload>> {
const _accounts = await grpcClient.documents.batchGetAccounts({
ids: accountUids,
})
const result: Record<string, HMMetadataPayload> = {}
const aliasesToResolve: string[] = []
const aliasMapping: Record<string, string[]> = {}
// First pass: process all accounts
Object.entries(_accounts.accounts).forEach(([id, account]) => {
const serverAccount = toPlainMessage(account)
const metadata = prepareHMDocumentMetadata(account.metadata)
const accountData: HMMetadataPayload = {
id: hmId(id, { version: serverAccount.homeDocumentInfo?.version }),
metadata,
}
if (serverAccount.aliasAccount) {
// Store the alias hop with aliasAccount field
result[id] = {
...accountData,
aliasAccount: serverAccount.aliasAccount,
} as HMMetadataPayload & { aliasAccount: string }
// Track for resolution
if (!aliasMapping[serverAccount.aliasAccount]) {
aliasMapping[serverAccount.aliasAccount] = []
aliasesToResolve.push(serverAccount.aliasAccount)
}
aliasMapping[serverAccount.aliasAccount].push(id)
} else {
// Not an alias, store directly
result[id] = accountData
}
})
// Second pass: resolve aliases recursively
if (aliasesToResolve.length > 0) {
const resolvedAliases = await BatchAccounts.getData(
grpcClient,
aliasesToResolve,
queryDaemon,
)
// Add resolved accounts to result
Object.entries(resolvedAliases).forEach(([resolvedId, resolvedAccount]) => {
if (!result[resolvedId]) {
result[resolvedId] = resolvedAccount
}
})
}
return result
},
}
Why: Returns both alias hops and their targets in a single batch response.
Step 4: Update useAccount Hook with Metadata Tracking
Location: frontend/packages/shared/src/models/entity.ts
import { followAliasChain, registerAliasInCache } from './alias-utils'
export function useAccount(
id: string | null | undefined,
options?: UseAccountOptions,
) {
const client = useUniversalClient()
const queryClient = useQueryClient()
return useQuery({
enabled: options?.enabled ?? !!id,
queryKey: [queryKeys.ACCOUNT, id],
queryFn: async (): Promise<HMMetadataPayload | null> => {
if (!id) return null
return await client.request<HMAccountRequest>('Account', id)
},
// NEW: Track alias relationships after fetch
onSuccess: (data) => {
if (!data || !id) return
// Check if this account is an alias
const aliasAccount = (data as any).aliasAccount
if (aliasAccount) {
// Register the relationship in cache metadata
registerAliasInCache(queryClient, id, aliasAccount, data)
// Eagerly fetch the target if not in cache
const targetInCache = queryClient.getQueryData([queryKeys.ACCOUNT, aliasAccount])
if (!targetInCache) {
queryClient.fetchQuery({
queryKey: [queryKeys.ACCOUNT, aliasAccount],
queryFn: () => client.request<HMAccountRequest>('Account', aliasAccount),
})
}
}
},
// NEW: Resolve aliases in select
select: (data) => {
if (!data || !id) return data
// Check if this account is an alias
const targetId = followAliasChain(queryClient, id)
if (targetId === id) {
// Not an alias or end of chain, return as-is
return data
}
// Get the resolved target data from cache
const targetData = queryClient.getQueryData<HMMetadataPayload>([
queryKeys.ACCOUNT,
targetId,
])
return targetData || data // Fallback to hop data if target not cached yet
},
...options,
})
}
Why:
onSuccess detects aliases and stores metadata
select traverses chain by reading metadata
Eager fetching ensures complete chains
Step 5: Update useAccountsMetadata with Metadata Tracking
Location: frontend/packages/shared/src/models/entity.ts
import { registerAliasInCache } from './alias-utils'
export function useAccountsMetadata(
uids: string[],
): HMAccountsMetadataResult {
const client = useUniversalClient()
const queryClient = useQueryClient()
const result = useQuery({
enabled: uids.length > 0,
queryKey: [queryKeys.BATCH_ACCOUNTS, ...uids.slice().sort()],
queryFn: async (): Promise<HMAccountsMetadata> => {
if (uids.length === 0) return {}
return await client.request<HMBatchAccountsRequest>('BatchAccounts', uids)
},
// NEW: Populate individual caches with metadata
onSuccess: (data) => {
Object.entries(data).forEach(([accountId, accountData]) => {
// Store in individual cache
queryClient.setQueryData(
[queryKeys.ACCOUNT, accountId],
accountData
)
// Track alias relationships if present
const aliasAccount = (accountData as any).aliasAccount
if (aliasAccount) {
registerAliasInCache(queryClient, accountId, aliasAccount, accountData)
}
})
},
})
return {
data: result.data || {},
isLoading: result.isLoading,
}
}
Why: Batch fetches populate both individual caches AND metadata.
Step 6: Add Smart Invalidation Helper
Location: frontend/packages/shared/src/models/alias-utils.ts
Add function:
/**
* Invalidate an account and all accounts that alias to it.
* Use this in mutations that update account data.
*/
export function invalidateAccountAndAliases(
queryClient: QueryClient,
accountId: string
) {
// Get all affected accounts (target + aliases)
const affectedAccounts = getAffectedAccounts(queryClient, accountId)
// Invalidate each individual account query
affectedAccounts.forEach(id => {
queryClient.invalidateQueries({
queryKey: [queryKeys.ACCOUNT, id]
})
})
// Invalidate any batch queries containing these accounts
queryClient.invalidateQueries({
predicate: (query) => {
if (query.queryKey[0] !== queryKeys.BATCH_ACCOUNTS) return false
const batchIds = query.queryKey.slice(1) as string[]
return affectedAccounts.some(id => batchIds.includes(id))
}
})
}
Step 7: Use Smart Invalidation in Mutations
Pattern for account mutations:
import { invalidateAccountAndAliases } from './alias-utils'
// In any mutation that updates account data
const updateProfileMutation = useMutation({
mutationFn: async (input) => {
// ... update logic
},
onSuccess: (updatedAccount, variables) => {
const accountId = variables.accountId || updatedAccount.id
// This handles both the account and all its aliases
invalidateAccountAndAliases(queryClient, accountId)
// OPTIONAL: Optimistic update
getAffectedAccounts(queryClient, accountId).forEach(id => {
queryClient.setQueryData(
[queryKeys.ACCOUNT, id],
updatedAccount,
// Preserve existing metadata
{ meta: queryClient.getQueryState([queryKeys.ACCOUNT, id])?.meta }
)
})
}
})
Where to apply:
Profile metadata updates
Account settings changes
Any mutation that modifies account data visible to users
Step 8: Add Utility Hooks
Location: frontend/packages/shared/src/models/entity.ts
import { followAliasChain, getAffectedAccounts } from './alias-utils'
/**
* Returns the final target account ID for an account that might be an alias.
*/
export function useResolvedAccountId(accountId: string | undefined): string | undefined {
const queryClient = useQueryClient()
return useMemo(() => {
if (!accountId) return undefined
return followAliasChain(queryClient, accountId)
}, [accountId, queryClient])
}
/**
* Returns all accounts in this alias group (target + all aliases).
*/
export function useAccountAliasGroup(accountId: string | undefined): string[] {
const queryClient = useQueryClient()
return useMemo(() => {
if (!accountId) return []
const target = followAliasChain(queryClient, accountId)
return getAffectedAccounts(queryClient, target)
}, [accountId, queryClient])
}
Phase 2: Document Redirects (Later)
Step 9: Apply Pattern to Documents
Similar approach:
Modify api-resource.ts to return redirectInfo field intact
Create redirect utilities in alias-utils.ts
Update useResource with metadata tracking and select
Use smart invalidation in document mutations
Key difference: Include version in keys:
queryKey: [queryKeys.ENTITY, docId, version]
meta: { redirectTarget: `${targetId}@${targetVersion}` }
Why This Approach is Better
Advantages Over Separate Class
✅ Single source of truth: Query cache holds both data and relationships ✅ Automatic GC: Metadata removed when query is garbage collected ✅ DevTools visibility: Can inspect metadata in React Query DevTools ✅ No stale data: Metadata lifecycle tied to query lifecycle ✅ Type-safe: Uses React Query's built-in metadata system ✅ No memory leaks: No separate state to manage
How It Works
User requests Account A
↓
1. Hook fetches A → API returns { id: 'A', aliasAccount: 'B', metadata: {...} }
↓
2. onSuccess: Stores in cache with meta: { isAlias: true, targetId: 'B' }
↓
3. onSuccess: Triggers fetch of B if not cached
↓
4. Hook fetches B → API returns { id: 'B', metadata: {...} }
↓
5. onSuccess: Stores in cache with meta: { aliasedFrom: ['A'] }
↓
6. select: Reads A's metadata, sees targetId: 'B'
↓
7. select: Reads B from cache, returns B's data
↓
Component receives B's data when querying A
Testing Strategy
Unit Tests
For utility functions:
describe('alias-utils', () => {
describe('followAliasChain', () => {
it('returns same ID if not an alias', () => {})
it('resolves single hop (A -> B)', () => {})
it('resolves multi-hop (A -> B -> C)', () => {})
it('handles cycles gracefully', () => {})
it('respects maxDepth', () => {})
})
describe('getAffectedAccounts', () => {
it('returns target + all aliases', () => {})
it('handles target with no aliases', () => {})
})
describe('registerAliasInCache', () => {
it('sets metadata on source', () => {})
it('appends to aliasedFrom on target', () => {})
it('deduplicates aliasedFrom array', () => {})
})
})
Integration Tests
For hooks with metadata:
describe('useAccount with aliases', () => {
it('stores alias metadata on fetch', async () => {
// Mock A -> B alias
// Fetch A
// Check queryClient.getQueryState(['account', 'A']).meta
expect(meta).toEqual({ isAlias: true, targetId: 'B' })
})
it('returns resolved data in select', async () => {
// Mock A -> B, ensure B is cached
// useAccount('A')
// Expect to receive B's data
})
it('eagerly fetches target', async () => {
// Mock A -> B, B not cached
// Fetch A
// Verify B is fetched automatically
})
})
Manual Testing
Check DevTools:
Open React Query DevTools
Query an aliased account
Verify both queries exist with metadata
Test updates:
Query aliased account A (→ B)
Update B's profile
Verify A's view updates automatically
Test chains:
Create A → B → C chain
Query A, verify C's data shown
Check all three queries in cache
Migration Path
No Breaking Changes
✅ Query keys unchanged
✅ Hook signatures unchanged
✅ Components work without changes
✅ Additive only
Implementation Order
✅ Create alias-utils.ts with utility functions
✅ Modify api-account.ts to return aliasAccount field
✅ Modify api-batch-accounts.ts to return all hops
✅ Update useAccount with metadata + select
✅ Update useAccountsMetadata with metadata
✅ Add smart invalidation to mutations
✅ Add utility hooks
✅ Test thoroughly
⏳ Extend to documents (Phase 2)
Summary
Key Differences from Original API Router Plan
Before: Separate AliasTracker class After: Query cache metadata as source of truth
What This Achieves
✅ Preserves hops: Each alias stored in cache with aliasAccount field ✅ Tracks relationships: Via query cache meta, not separate state ✅ Transparent resolution: Select functions traverse metadata ✅ Automatic updates: Smart invalidation uses metadata ✅ No stale data: Metadata GC'd with queries ✅ DevTools visibility: Can inspect in React Query DevTools ✅ Platform agnostic: Works on desktop and web ✅ Zero breaking changes: Completely additive
Memory & Performance
Query Cache:
Each alias: ~150 bytes (data + meta)
1000 aliases: ~150KB
Standard React Query GC applies
No Separate State:
Zero additional memory overhead
No risk of stale relationships
No manual cleanup needed
Ready to implement with confidence! 🚀