Skip to content

[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) in frontend/src/db/GuestDB.ts to ensure it can access local data even after the ServiceProvider has swapped to the ApiMealService.
    • 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 (from settings table) 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 useAuthenticator and a useEffect with a "last known status" ref to reliably detect the unauthenticated -> authenticated transition.

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 useAuthenticator change 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 BatchWriteItem with 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 extendedNutrients are 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 (Add importGuestHistory mutation)
    • graphql/schema.graphql (Update ImportResult for 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.

# graphql/schema.graphql
type ImportResult {
  success: Boolean!
  processedMeals: Int!
  message: String
  # List of SKs that failed validation but were "processed" (Dead Letter)
  warnings: [String] 
}

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 ServiceProvider must maintain a MigrationState and continue serving local data until usePromotion reports syncComplete: 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 OK with warnings for 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. If Existing Login, trigger Confirmation Modal.

Refined Execution Plan Additions

  • Architecture: Update ServiceProvider to support "Hybrid/Delayed" state.
  • Architecture: Centralize DEFAULT_TARGETS constant; ensure ApiSettingsService falls back to defaults if API returns null (New User UX).
  • Backend: Update importGuestHistory to return warnings (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 ApiSettingsService returned null for new users, causing UI issues. The "Smart Settings Safeguard" required a reliable baseline for "Default".

    • Adaptation: We extracted DEFAULT_TARGETS into @chatkcal/shared and updated ApiSettingsService to return these defaults when the backend returns null. This ensures consistency between Guest and New User states.
  • 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) inside usePromotion. If it returns null (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.
  • Discovery 3 (Shared Code in Lambda): Importing shared constants into the Lambda required rebuilding the shared workspace and ensuring the vendor:shared task was run. The index.ts in shared also needed fixing to export defaults.ts.

  • Discovery 4 (The Lambda Entry Point Trap): During testing, the importGuestHistory Lambda failed with Error: Cannot find module 'app'.

    • Root Cause: A combination of misaligned YAML indentation in the Metadata block (causing SAM to skip esbuild and upload raw source files) and an ESM/CJS resolution mismatch in the Node 20 runtime when package.json is stripped during the build.
    • Resolution:
      1. Renamed Lambda entry point from app.ts to index.ts and set Handler to index.handler to follow standard conventions.
      2. Fixed template.yaml indentation to ensure Metadata is a sibling of Properties.
      3. Standardized on Format: cjs and Target: node20 to ensure the built index.js is resolvable by the runtime bootstrap without needing a package.json manifest.
      4. Verified via a new direct invocation script scripts/test_lambda_direct.py.
  • 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 .js as CJS by default. Does not require a package.json in the build artifact.
      • Target: node20. Specifically maps to the Lambda runtime's capabilities.
      • Handler: index.handler (pointing to index.js).
    • Modern Setup (The ESM Path):
      • Format: esm. Requires either .mjs file extension OR a package.json with {"type": "module"} in the ZIP root.
      • Note: SAM's esbuild integration currently makes forcing .mjs difficult, making cjs the more robust choice for now.
    • Deviations: We are currently using CJS to ensure the Lambda bootstrap can resolve the entry point after SAM strips the source package.json.
  • Discovery 6 (The "Base-Dir" Build Context): Even with correct indentation and CJS format, sam build can fail to package the bundled index.js correctly if the build context is restricted to the function folder.

    • Symptom: ZIP archive contains raw source files and node_modules instead of the single bundled index.js, leading to Cannot find module 'index'.
    • Fix: Explicitly set --base-dir . in the sam build command. This ensures SAM evaluates the project root (and its package.json logic) but correctly outputs the bundle to the artifact directory.
    • Permanent Fix: Updated Taskfile.yml deploy tasks to include the --base-dir . flag.
  • Discovery 7 (The Composite SK Trap): Local guest data uses composite keys (MEAL#Timestamp#UUID) as the primary key (sk).

    • Problem: The original importGuestHistory logic double-prefixed the keys, and the schema's .uuid() validation rejected the incoming MEAL#... strings.
    • Fix:
      1. Relaxed MealSchema to allow any string for sk.
      2. Updated Lambda to detect and extract the raw UUID from incoming composite keys before generating the final DynamoDB SK.
  • Discovery 8 (The SAM Deploy Template Mismatch): Despite successful builds, deployments were failing because sam deploy was pointing to the source template.yaml rather than the built artifact at .aws-sam/build/template.yaml.

    • Impact: SAM ignored the esbuild artifacts and packaged the raw source folder, resulting in index.ts being uploaded instead of the bundled index.js.
    • Fix: Updated Taskfile.yml to ensure sam deploy explicitly uses the built template: sam deploy -t .aws-sam/build/template.yaml.
  • 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: settingsUpdated returns false in the console summary.
    • Root Cause: The guestVersion > cloudVersion safeguard 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.

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_HISTORY mutation to graphql/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.
  • Step 2: Shared Defaults & Types

    • Create frontend/src/constants/defaults.ts with DEFAULT_TARGETS.
    • Update LocalSettingsService to use shared defaults.
    • Update ApiSettingsService to return shared defaults if API result is null.
  • Step 3: The usePromotion Hook (Logic)

    • Detect transition from unauthenticated to authenticated.
    • Logic: Check if account is "Fresh" (New Sign-up) vs "Existing".
      • If Fresh: Auto-start sync.
      • If Existing: Set awaitingConfirmation state.
    • Chunking: Read guest meals -> Chunk (15 items) -> Mutate.
    • Cleanup: Implement Per-Batch Deletion (delete specific UUIDs from Dexie only after API confirms receipt).
  • Step 4: Service Provider Integration (The Anti-Flicker)

    • Modify ServiceProvider: Delay swapping to ApiMealService until usePromotion returns syncComplete: true.
    • Fallback: If sync takes > 5 seconds, swap anyway and show a "Finishing sync..." toast.
  • Step 5: UI Implementation

    • Dashboard: Add a non-blocking "Syncing guest data..." status indicator (top right pill).
    • Login/Register: Pass a flag to usePromotion to 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)

  1. Open the app in Incognito/Guest Mode.
  2. Log 3 meals ("Apple", "Banana", "Burger").
  3. Click "Sign In" -> "Create Account".
  4. Complete sign-up.
  5. Verify:
  6. No confirmation modal appears (Silent Sync).
  7. Dashboard immediately shows the 3 meals.
  8. "Migrating..." indicator might briefly flash (if network is slow).

Scenario B: The Accidental Tourist (Existing Account)

  1. Open the app in Guest Mode.
  2. Log 1 meal ("Secret Pizza").
  3. Click "Sign In" and log into an existing account (that already has history).
  4. Verify:
  5. Confirmation Modal appears: "We found unsaved meals..."
  6. Click "Merge".
  7. Verify "Secret Pizza" is added to your existing history.

Scenario C: The Settings Conflict

  1. Pre-condition: Existing Cloud Account has Custom Targets (e.g., 2500 kcal).
  2. Action: Open Guest Mode. Do NOT change settings (keep default 2000 kcal).
  3. Log in to Cloud Account.
  4. Verify:
  5. Cloud Settings (2500 kcal) are preserved. (Safeguard worked).

Variant:

  1. Action: Open Guest Mode. Change settings to 3000 kcal.
  2. Log in.
  3. Verify:
  4. Cloud Settings are updated to 3000 kcal (User Intent wins).