[2026-01-21] Guest Mode: Data Promotion (Sync)
Task Metadata
Date: 2026-01-21
Status: Ready for Review
- Beads Issue: 2512_genai_food_tracking-4r3
Objective
Goal: Implement the "Data Promotion" logic that migrates local guest data (meals and settings) to the cloud when a user signs up or logs in.
- Trigger: Transition from Unauthenticated (Guest) to Authenticated state.
- Constraints:
- Must be idempotent (backend already handles this via client-side UUIDs).
- Must clear local data ONLY after successful cloud confirmation.
- Must follow the pattern defined in Decision 006: Guest Mode Architecture.
Technical Strategy
We will implement a usePromotion hook that monitors the authentication state and triggers the importGuestHistory mutation when guest data is detected in a new session.
- Key Decisions:
- Bypass Abstraction: The promotion hook will read directly from
db(Dexie) infrontend/src/db/GuestDB.tsto ensure it can access local data even after theServiceProviderhas swapped to theApiMealService. - Silent vs Explicit: For the first version, we will perform the sync silently in the background upon login detection. If the sync is large (>50 items), we might consider adding a UI progress indicator later.
- Settings Sync: We will include
GUEST_TARGETS(fromsettingstable) in the sync payload to ensure the user's customized goals are preserved. - Chunking Logic: To avoid AppSync payload limits, meals will be synced in chunks of 25.
- Auth State Monitoring: Use
useAuthenticatorand auseEffectwith a "last known status" ref to reliably detect theunauthenticated -> authenticatedtransition.
- Bypass Abstraction: The promotion hook will read directly from
State Transition Diagram (Hybrid Handover)
stateDiagram-v2
[*] --> GuestMode: Unauthenticated
GuestMode --> Authenticated: User Logs In
state Authenticated {
[*] --> MigrationMode: Auth Success
MigrationMode --> SyncedMode: Sync Complete (isPromoting=false)
}
GuestMode --> LocalService: Reads/Writes
MigrationMode --> LocalService: Reads (to avoid flash of empty)
MigrationMode --> ApiService: Writes (Background Sync)
SyncedMode --> ApiService: Reads/Writes
Testing Strategy
- Must Test:
- Transition trigger: Mock
useAuthenticatorchange and verify mutation call. - Success cleanup: Verify IndexedDB is cleared after successful mutation.
- Failure safety: Verify IndexedDB is NOT cleared if the API returns an error.
- Chunking: Verify multiple mutation calls if meals > 25.
- Skip: UI visual regression (mostly background logic).
Risk Analysis
- Data Duplication: If the sync fails mid-way, we must ensure we don't create duplicates on retry. (Mitigated: Backend uses
BatchWriteItemwith client-generated IDs). - State Inconsistency: React Query cache must be invalidated after sync to show the newly imported cloud data.
- Mutation Payload Size: Even with 25 meals, if
extendedNutrientsare massive, we might hit limits. (Mitigated: 25 is a conservative safe limit for standard meal data).
- Files to Modify:
-
frontend/src/hooks/usePromotion.ts(New Hook: Sync Logic) -
frontend/src/constants/defaults.ts(Shared Default Targets) -
frontend/src/providers/ServiceProvider.tsx(Add MigrationState/Delayed Swap) -
frontend/src/services/ApiMealService.ts(Update Settings Fallback) -
frontend/src/services/LocalSettingsService.ts(Use Shared Defaults) -
frontend/src/graphql/operations.ts(AddimportGuestHistorymutation) -
graphql/schema.graphql(UpdateImportResultfor Dead Letter warnings) -
functions/import_guest_history/app.ts(Implement Dead Letter & Validation)
-
1. Schema: Dead Letter Strategy
We update ImportResult to return partial failures ("warnings") instead of throwing errors.
2. Lambda: Validation & Dead Letter
The backend validates batches but does not fail the whole request on a single bad item.
// functions/import_guest_history/app.ts
const invalidSkList: string[] = [];
const validMeals = [];
guestMeals.forEach(meal => {
const result = MealSchema.safeParse(meal);
if (!result.success) {
console.warn(`Skipping invalid meal ${meal.sk}:`, result.error);
invalidSkList.push(meal.sk); // Mark as "processed" via warning
} else {
validMeals.push(result.data);
}
});
// ... process validMeals ...
return {
success: true,
processedMeals: validMeals.length,
warnings: invalidSkList // Client deletes these locally
};
3. ServiceProvider: Delayed Swap
We introduce an intermediate state to prevent the "Flash of Empty."
// frontend/src/providers/ServiceProvider.tsx
// New State: "Migration Mode"
const [isMigrating, setIsMigrating] = useState(false);
const { isPromoting } = usePromotion({
onStart: () => setIsMigrating(true),
onComplete: () => setIsMigrating(false)
});
const services = useMemo(() => {
// KEY CHANGE: Even if authenticated, if we are migrating, keep serving Local data
if (user && !isMigrating) {
return { mealService: new ApiMealService(apiClient) };
} else {
return { mealService: new LocalMealService() };
}
}, [user, isMigrating]);
Critique & Gaps (Deep Dive)
- Critique 1 (The "Provider Swap" Gap): Simply swapping the service upon auth causes a "Flash of Empty" because the cloud is empty until sync finishes.
- Fix: Delayed Swap. The
ServiceProvidermust maintain aMigrationStateand continue serving local data untilusePromotionreportssyncComplete: true. - Critique 2 (The "Validation Deadlock"): If the backend rejects a batch due to invalid schema, the client retries forever.
- Fix: Dead Letter Strategy. The backend must return
200 OKwithwarningsfor dropped items. The client must treat these as "processed" and delete them locally. - Critique 3 (New vs Returning Distinction): Asking "Sync?" for a brand new account is unnecessary friction.
- Fix: Smart Bifurcation. If
New Registration, force Silent Sync. IfExisting Login, trigger Confirmation Modal.
Refined Execution Plan Additions
- Architecture: Update
ServiceProviderto support "Hybrid/Delayed" state. - Architecture: Centralize
DEFAULT_TARGETSconstant; ensureApiSettingsServicefalls back to defaults if API returns null (New User UX). - Backend: Update
importGuestHistoryto returnwarnings(Dead Letter) instead of throwing on validation error. - Logic: Pass "Is New User" flag to promotion hook to toggle Silent vs Confirm mode.
Execution Notes
-
Unit Testing (Complete): Implemented a comprehensive test suite in
frontend/src/hooks/__tests__/usePromotion.test.tsx.- Verified silent sync for fresh accounts.
- Verified confirmation modal for existing accounts.
- Verified chunking logic (15 items per batch).
- Verified data safety (local data preserved on failure).
- Verified cancellation cleanup.
-
Discovery 1 (Default Settings Gap): We realized that
ApiSettingsServicereturnednullfor new users, causing UI issues. The "Smart Settings Safeguard" required a reliable baseline for "Default".- Adaptation: We extracted
DEFAULT_TARGETSinto@chatkcal/sharedand updatedApiSettingsServiceto return these defaults when the backend returns null. This ensures consistency between Guest and New User states.
- Adaptation: We extracted
-
Discovery 2 (New User Detection): Identifying a "Fresh" account without a dedicated API flag was tricky.
- Adaptation: We implemented a raw GraphQL query (
GET_USER_TARGETS) insideusePromotion. If it returnsnull(before our service layer masks it with defaults), we know the account is truly fresh. This allows us to reliably trigger "Silent Sync" for new users while using "Confirmation" for existing ones.
- Adaptation: We implemented a raw GraphQL query (
-
Discovery 3 (Shared Code in Lambda): Importing
sharedconstants into the Lambda required rebuilding thesharedworkspace and ensuring thevendor:sharedtask was run. Theindex.tsinsharedalso needed fixing to exportdefaults.ts. -
Discovery 4 (The Lambda Entry Point Trap): During testing, the
importGuestHistoryLambda failed withError: Cannot find module 'app'.- Root Cause: A combination of misaligned YAML indentation in the
Metadatablock (causing SAM to skipesbuildand upload raw source files) and an ESM/CJS resolution mismatch in the Node 20 runtime whenpackage.jsonis stripped during the build. - Resolution:
- Renamed Lambda entry point from
app.tstoindex.tsand set Handler toindex.handlerto follow standard conventions. - Fixed
template.yamlindentation to ensureMetadatais a sibling ofProperties. - Standardized on
Format: cjsandTarget: node20to ensure the builtindex.jsis resolvable by the runtime bootstrap without needing apackage.jsonmanifest. - Verified via a new direct invocation script
scripts/test_lambda_direct.py.
- Renamed Lambda entry point from
- Root Cause: A combination of misaligned YAML indentation in the
-
Discovery 5 (Build Standards: Format vs. Target): To prevent future resolution issues, we established the following standards for Node.js 20 Lambdas in this project:
- Recommended Setup (The "Safe" Path):
- Format:
cjs(CommonJS). Reliable because Node.js treats.jsas CJS by default. Does not require apackage.jsonin the build artifact. - Target:
node20. Specifically maps to the Lambda runtime's capabilities. - Handler:
index.handler(pointing toindex.js).
- Format:
- Modern Setup (The ESM Path):
- Format:
esm. Requires either.mjsfile extension OR apackage.jsonwith{"type": "module"}in the ZIP root. - Note: SAM's
esbuildintegration currently makes forcing.mjsdifficult, makingcjsthe more robust choice for now.
- Format:
- Deviations: We are currently using CJS to ensure the Lambda bootstrap can resolve the entry point after SAM strips the source
package.json.
- Recommended Setup (The "Safe" Path):
-
Discovery 6 (The "Base-Dir" Build Context): Even with correct indentation and CJS format,
sam buildcan fail to package the bundledindex.jscorrectly if the build context is restricted to the function folder.- Symptom: ZIP archive contains raw source files and
node_modulesinstead of the single bundledindex.js, leading toCannot find module 'index'. - Fix: Explicitly set
--base-dir .in thesam buildcommand. This ensures SAM evaluates the project root (and itspackage.jsonlogic) but correctly outputs the bundle to the artifact directory. - Permanent Fix: Updated
Taskfile.ymldeploy tasks to include the--base-dir .flag.
- Symptom: ZIP archive contains raw source files and
-
Discovery 7 (The Composite SK Trap): Local guest data uses composite keys (
MEAL#Timestamp#UUID) as the primary key (sk).- Problem: The original
importGuestHistorylogic double-prefixed the keys, and the schema's.uuid()validation rejected the incomingMEAL#...strings. - Fix:
- Relaxed
MealSchemato allow any string forsk. - Updated Lambda to detect and extract the raw UUID from incoming composite keys before generating the final DynamoDB SK.
- Relaxed
- Problem: The original
-
Discovery 8 (The SAM Deploy Template Mismatch): Despite successful builds, deployments were failing because
sam deploywas pointing to the sourcetemplate.yamlrather than the built artifact at.aws-sam/build/template.yaml.- Impact: SAM ignored the
esbuildartifacts and packaged the raw source folder, resulting inindex.tsbeing uploaded instead of the bundledindex.js. - Fix: Updated
Taskfile.ymlto ensuresam deployexplicitly uses the built template:sam deploy -t .aws-sam/build/template.yaml.
- Impact: SAM ignored the
-
Discovery 9 (Manual Testing Failure: Settings Sync): Manual testing revealed that modified settings in Guest Mode were failing to "win" against Cloud settings during promotion.
- Symptom:
settingsUpdatedreturnsfalsein the console summary. - Root Cause: The
guestVersion > cloudVersionsafeguard in the Lambda is too restrictive. If both are at Version 2, the guest changes are discarded even if they represent the user's latest intent. - Critical Failure: Debug logs were prematurely removed before this logic was fully verified, leading to a loss of observability.
- Symptom:
User Approval & Key Learnings
Verification Failed
Manual verification failed on 2026-02-05. Settings synchronization is unreliable, and the system lacks sufficient observability to diagnose race conditions in the Auth state transition.
Dependency: Closure of this task is blocked by Logging & Observability Uplift.
Execution Plan (Hardened)
Stop: User Approval Required
Do not proceed with execution until the user has explicitly approved the Approach and Execution Plan above.
-
Step 1: Backend & Schema
- Add
IMPORT_GUEST_HISTORYmutation tographql/operations.ts. - Update Lambda: Add Zod validation. Crucial: Ensure validation failures return a "Processed with Errors" state, not a hard 500, to allow local deletion of bad data.
- Settings Safeguard: In Lambda, only overwrite cloud settings if guest settings are non-default and newer than cloud settings.
- Add
-
Step 2: Shared Defaults & Types
- Create
frontend/src/constants/defaults.tswithDEFAULT_TARGETS. - Update
LocalSettingsServiceto use shared defaults. - Update
ApiSettingsServiceto return shared defaults if API result is null.
- Create
-
Step 3: The
usePromotionHook (Logic)- Detect transition from
unauthenticatedtoauthenticated. - Logic: Check if account is "Fresh" (New Sign-up) vs "Existing".
- If Fresh: Auto-start sync.
- If Existing: Set
awaitingConfirmationstate.
- Chunking: Read guest meals -> Chunk (15 items) -> Mutate.
- Cleanup: Implement Per-Batch Deletion (delete specific UUIDs from Dexie only after API confirms receipt).
- Detect transition from
-
Step 4: Service Provider Integration (The Anti-Flicker)
- Modify
ServiceProvider: Delay swapping toApiMealServiceuntilusePromotionreturnssyncComplete: true. - Fallback: If sync takes > 5 seconds, swap anyway and show a "Finishing sync..." toast.
- Modify
-
Step 5: UI Implementation
- Dashboard: Add a non-blocking "Syncing guest data..." status indicator (top right pill).
- Login/Register: Pass a flag to
usePromotionto indicate if this was a "Sign Up" (Force Silent) or "Login" (Ask Confirm). - Confirmation Modal: (Only for existing users) "Merge 5 guest meals?"
-
Step 6: Verification
- Test Case: "The Corrupt Meal" (Inject invalid data in Dexie, ensure sync doesn't loop).
- Test Case: "The Network Cut" (Kill network mid-sync, ensure no duplicates on retry).
Scenario A: The Frictionless First Timer (Happy Path)
- Open the app in Incognito/Guest Mode.
- Log 3 meals ("Apple", "Banana", "Burger").
- Click "Sign In" -> "Create Account".
- Complete sign-up.
- Verify:
- No confirmation modal appears (Silent Sync).
- Dashboard immediately shows the 3 meals.
- "Migrating..." indicator might briefly flash (if network is slow).
Scenario B: The Accidental Tourist (Existing Account)
- Open the app in Guest Mode.
- Log 1 meal ("Secret Pizza").
- Click "Sign In" and log into an existing account (that already has history).
- Verify:
- Confirmation Modal appears: "We found unsaved meals..."
- Click "Merge".
- Verify "Secret Pizza" is added to your existing history.
Scenario C: The Settings Conflict
- Pre-condition: Existing Cloud Account has Custom Targets (e.g., 2500 kcal).
- Action: Open Guest Mode. Do NOT change settings (keep default 2000 kcal).
- Log in to Cloud Account.
- Verify:
- Cloud Settings (2500 kcal) are preserved. (Safeguard worked).
Variant:
- Action: Open Guest Mode. Change settings to 3000 kcal.
- Log in.
- Verify:
- Cloud Settings are updated to 3000 kcal (User Intent wins).