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
ApiMealService: Standard AppSync implementation (refactored to a Class takingclientin constructor).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.isGuestoruserEmailpresence. - Tracker: The
Authenticatorgate is removed;Dashboardis 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.
6.2 Sync Logic (The usePromotion Hook)
- Detection: Check
ChatKcalGuestDBfor data. - User Choice: Prompt: "Sync local data?"
- Batch Upload:
- Serialize local meals and call
importGuestHistory(chunked if > 25 items). - Idempotency: The Lambda uses
BatchWriteItemand ignores duplicate Keys (client-generated UUIDs).
- Serialize local meals and call
- 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,_deletedfields to our clean DynamoDB table. - Logic Blackbox: Incompatible with our custom AppSync JS resolvers (business logic).
- Schema Pollution: Requires adding
Option C: Conditional Hooks ("Smart Query Functions")
- Idea: Handle the data source switch inside
useQueryfunctions (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
LocalMealServiceneeds complex logic (e.g., "4 AM Rule" windowing) which is better encapsulated in a class than inline within a hook.
- Logic Duplication:
9. Impacts & Risks
- Cache Poisoning: When switching from
LocalMealServicetoApiMealService, we MUST callqueryClient.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 (uuidv4) 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
- Refactor Types: Create
IMealServiceandISettingsService. - Refactor ApiServices: Update existing services to implement interfaces.
- Install Dexie: Add
dexiedependency. - Implement LocalServices: Build the IndexedDB logic.
- Backend Implementation: Create
importGuestHistoryLambda and AppSync Resolver. - Create ServiceProvider: Wrap the app and handle
queryClient.resetQueries(). - Update Hooks: Update
useMealsanduseSettingsto use the injected services.