Alias & Redirect Cache Implementation Plan (API Router Version - REVISED)

    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! 🚀