Skip to content

ADR 006: Guest Mode Architecture

What is this?

An Architecture Decision Record detailing the "Service Abstraction" pattern and the use of Dexie.js to support unauthenticated logging.

Metadata

  • Status: Accepted
  • Date: 2026-01-17
  • Related Issue: 2512_genai_food_tracking-kzq
  • Type: Architecture Decision Record (ADR-006)

1. Overview

Guest Mode allows users to track meals and configure settings without an AWS Cognito account. Data is stored locally in the browser using IndexedDB. When a user eventually signs up, their local data is "promoted" (synced) to the cloud.

2. Service Abstraction (The Repository Pattern)

Currently, the frontend calls ApiMealService directly, passing an AmplifyClient. We will refactor this into a decoupled service layer.

2.1 Interface Definitions

// frontend/src/types/services.ts

export interface IMealService {
  getMeals(currentDate: Date): Promise<Meal[]>;
  addMeal(input: MealInput): Promise<MutationResult>;
  deleteMeal(input: DeleteMealInput): Promise<MutationResult>;
}

export interface ISettingsService {
  getUserTargets(): Promise<UserTargets>;
  updateUserTargets(targets: UserTargets): Promise<UserTargets>;
}

2.2 Implementations

  1. ApiMealService: Standard AppSync implementation (refactored to a Class taking client in constructor).
  2. LocalMealService: New implementation using Dexie.js to interface with IndexedDB.

3. Local Storage Schema (Dexie.js)

Database Name: ChatKcalGuestDB

Store Primary Key Indexes Description
meals sk userDate, createdAt Stores meal records. sk will be a client-side UUID.
settings key - Key-value store for user targets and preferences.

4. Service Injection

We use a ServiceProvider to inject the correct implementation based on authentication state.

// frontend/src/providers/ServiceProvider.tsx

export function ServiceProvider({ children }) {
  const { user } = useAuthenticator();
  // ...
  const services = useMemo(() => {
    if (user) {
      return {
        mealService: new ApiMealService(apiClient),
        settingsService: new ApiSettingsService(apiClient)
      };
    } else {
      return {
        mealService: new LocalMealService(),
        settingsService: new LocalSettingsService()
      };
    }
  }, [user]);
  // ...
}

5. UI Enablement (Null Object Pattern)

To support Guest Mode without rewriting the entire UI to handle user=null, we adopt the Null Object Pattern.

  • GUEST_USER: A constant object { username: 'guest', isGuest: true } passed to components when unauthenticated.
  • Header: Toggles between "Sign Out" and "Sign In" based on user.isGuest or userEmail presence.
  • Tracker: The Authenticator gate is removed; Dashboard is rendered by default.

6. Data Promotion (Guest -> User)

When user transitions from null to Authenticated, we must perform an atomic and idempotent migration.

6.1 The importGuestHistory Mutation

Instead of naively iterating addMeal (N+1 problem), we will implement a batch mutation backed by a Lambda Resolver.

type Mutation {
  importGuestHistory(meals: [String]!, settings: String): ImportResult
}

6.2 Sync Logic (The usePromotion Hook)

  1. Detection: Check ChatKcalGuestDB for data.
  2. User Choice: Prompt: "Sync local data?"
  3. Batch Upload:
    • Serialize local meals and call importGuestHistory (chunked if > 25 items).
    • Idempotency: The Lambda uses BatchWriteItem and ignores duplicate Keys (client-generated UUIDs).
  4. Cleanup: Only upon success response, clear ChatKcalGuestDB.

7. Conflict Resolution (Smart Merge)

If a user logs into an existing account that already has data:

  • Disjoint Data: New guest meals (unique UUIDs) are appended.
  • Overlapping Data: We adopt a "Trust the Guest" policy. If the user just logged a meal locally, that interaction is the most recent intent. The Lambda will overwrite the server copy for that specific ID.

8. Alternatives Considered

We evaluated and rejected two other strategies (detailed in ACP-002):

Option A: localStorage

  • Idea: Store meals as a JSON string in localStorage.
  • Reason for Rejection:
    • Blocking I/O: Serialization blocks the main thread, causing UI jank as history grows.
    • Capacity: Hard 5MB limit risks data loss for engaged users.

Option B: Amplify DataStore

  • Idea: Use AWS's "batteries-included" offline sync engine.
  • Reason for Rejection:
    • Schema Pollution: Requires adding _version, _deleted fields to our clean DynamoDB table.
    • Logic Blackbox: Incompatible with our custom AppSync JS resolvers (business logic).

Option C: Conditional Hooks ("Smart Query Functions")

  • Idea: Handle the data source switch inside useQuery functions (e.g., queryFn: user ? api.getMeals : local.getMeals).
  • Pros: Simpler (no Provider boilerplate).
  • Reason for Rejection:
    • Logic Duplication: if (user) checks would be scattered across all hooks (useMeals, useSettings, useProfile).
    • Coupling: Hooks would know about specific implementations (AppSync vs Dexie).
    • Testing: Harder to mock the "Mode" for unit tests compared to injecting a Mock Service.
    • Complexity: The LocalMealService needs complex logic (e.g., "4 AM Rule" windowing) which is better encapsulated in a class than inline within a hook.

9. Impacts & Risks

  • Cache Poisoning: When switching from LocalMealService to ApiMealService, we MUST call queryClient.resetQueries() to prevent showing stale guest data as user data.
  • UUID Generation: Local meals must generate strict MEAL#<ISO>#<UUID> keys using a robust library (uuid v4) to ensure global uniqueness during the merge.

10. Senior Critique & Edge Case Handling

10.1 The "Service Swap" Race Condition

Problem: When the user authenticates, ServiceProvider immediately swaps LocalMealService for ApiMealService. If the API hasn't been backfilled yet, the user sees an empty dashboard ("Flash of Empty"). Solution (Revised): Delayed Swap. The ServiceProvider will maintain a "Migration State." It will continue to serve data from LocalMealService (or a proxy) until the usePromotion hook reports syncComplete: true. Only then will it hot-swap to ApiMealService and invalidate queries.

10.2 The "4 AM Rule" Parity

Problem: The Backend uses complex logic to determine "User Day" (4 AM to 4 AM). If LocalMealService naively filters by calendar date, the UI will show different meals when switching between Offline/Online modes. Solution: LocalMealService must explicitly utilize the shared src/utils/dateUtils (getUtcRange, getLogicalDate) to ensure its query logic creates the exact same time boundaries as the AppSync resolvers.

10.3 Validation at the Source

Problem: IndexedDB has no schema enforcement. If a guest writes invalid data (e.g., negative calories) that the API later rejects, the Sync will fail permanently. Solution: We must apply Zod-based validation before writing to Dexie. The UI must prevent invalid data from ever entering the "Guest" stage.

10.4 The "Validation Deadlock" (Dead Letter Strategy)

Problem: If the backend rejects a batch of meals due to schema validation (e.g., one malformed record), the client might retry indefinitely, never clearing the local queue. Solution: The importGuestHistory mutation must implement a "Dead Letter" strategy. It should return 200 OK even for validation failures, but include a warnings or errors array identifying the specific items that were dropped. The client must treat these as "processed" (and delete them locally) to unblock the queue.

10.5 New vs. Returning Users

Problem: Treating all logins equally is inefficient. A new sign-up doesn't need a confirmation modal, whereas an existing user logging into a populated account risks data merging issues. Solution:

  • New Registration: Force Silent Sync. Zero friction.
  • Existing Login: Trigger Confirmation Modal if guest data exists.

11. Execution Steps

  1. Refactor Types: Create IMealService and ISettingsService.
  2. Refactor ApiServices: Update existing services to implement interfaces.
  3. Install Dexie: Add dexie dependency.
  4. Implement LocalServices: Build the IndexedDB logic.
  5. Backend Implementation: Create importGuestHistory Lambda and AppSync Resolver.
  6. Create ServiceProvider: Wrap the app and handle queryClient.resetQueries().
  7. Update Hooks: Update useMeals and useSettings to use the injected services.