Hosted onhoraciohdev.dev.hyper.mediavia 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:

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

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

Unsubscribe anytime