Hosted ongabo.esvia theHypermedia Protocol

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:

  1. API Layer: Returns raw data with aliasAccount field intact

  2. Hook Layer:

    • Detects aliases in returned data

    • Stores relationship metadata in query cache meta

    • Uses select to traverse chains by reading cache

  3. 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:

  1. Modify api-resource.ts to return redirectInfo field intact

  2. Create redirect utilities in alias-utils.ts

  3. Update useResource with metadata tracking and select

  4. 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

  1. Check DevTools:

    • Open React Query DevTools

    • Query an aliased account

    • Verify both queries exist with metadata

  2. Test updates:

    • Query aliased account A (→ B)

    • Update B's profile

    • Verify A's view updates automatically

  3. 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

  1. ✅ Create alias-utils.ts with utility functions

  2. ✅ Modify api-account.ts to return aliasAccount field

  3. ✅ Modify api-batch-accounts.ts to return all hops

  4. ✅ Update useAccount with metadata + select

  5. ✅ Update useAccountsMetadata with metadata

  6. ✅ Add smart invalidation to mutations

  7. ✅ Add utility hooks

  8. ✅ Test thoroughly

  9. ⏳ 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! 🚀

Do you like what you are reading?. Subscribe to receive updates.

Unsubscribe anytime