Files
familysync/.planning/research/PITFALLS.md
T
Lucas BergerandClaude Opus 4.8 6e1c9ca924 docs: complete v1.2 research (stack, features, architecture, pitfalls)
- STACK.md: google-auth-library@10.7.0 + @googleapis/calendar@15.0.0 scoped packages (vs monolithic googleapis), OAuth2 flow, token storage, Google Calendar API event/reminder model
- FEATURES.md: 6 feature categories (multi-provider, self-service onboarding, multiple reminders, dark mode, zero-setup DB, dev/CI stub), dependency graph, feature prioritization
- ARCHITECTURE.md: CalendarProvider interface, provider factory, CalDavProvider wrapper, GoogleCalendarProvider, MockProvider, provider_tokens table schema, multi-reminder JSON column, OAuth callback routing, 7-component data flows
- PITFALLS.md: 11 critical/medium pitfalls (refresh token 7-day expiry in testing status, Google recurrence mismatch, syncToken 410, timezone handling, provider abstraction regression, VALARM dedup key, auto-migrate failures, dark mode FOWT, OAuth callback through tunnel, token encryption, ESLint 10 breaking changes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:27:35 -04:00

56 KiB
Raw Blame History

Pitfalls Research — v1.2 Multi-Provider, Theming & Zero-Setup

Domain: Adding Google Calendar OAuth2, provider abstraction, multiple reminders, dark mode, auto-migrate-on-boot, self-service OAuth onboarding, and dependency updates to a shipped Node 22 + Hono 4 + Drizzle/MariaDB 11 + tsdav/ical.js + web-push stack on Unraid/Docker behind Authelia OIDC + Pangolin/Newt. Researched: 2026-06-19 Confidence: HIGH — pitfalls derived from direct source inspection of the shipped v1.1 codebase, v1.0/v1.1 retrospectives, known burned-in lessons from the migration and CI history, and deep familiarity with Google Calendar API, OAuth2, Drizzle/MariaDB migration semantics, and PWA theming constraints. Sources cross-checked against PROJECT.md, RETROSPECTIVE.md, MILESTONES.md, and the VALARM/outbox source in apps/api/src/broker/.


Known Constraints (Inherited — Do Not Re-Litigate)

These are burned-in from v1.0/v1.1. Every pitfall below assumes these hold:

  • No node-cron. setInterval only — node-cron 4.2.1 silently skips all ticks in long-lived Node processes.
  • drizzle-kit generate+migrate, never push. push emits a false destructive diff (TRUNCATE/DROP) on populated MariaDB 11 using the mysql dialect — the introspection bug fires on any existing-data DB. This is the root cause behind D-MIGRATION-10-01 and the quick task cr8.
  • Journal-hash matching is mandatory. The __drizzle_migrations table tracks applied migrations by tag (from _journal.json). If a migration file is renamed, regenerated, or its journal entry is modified after it has been applied to any environment, drizzle-kit migrate treats it as unapplied and will attempt to re-run DDL that already ran — producing Duplicate column errors.
  • MariaDB CI readiness uses healthcheck.sh --connect, not mysqladmin ping. mysqladmin ping was removed in MariaDB 11.
  • ESLint is pinned at 9.39.4. ESLint 10 breaks eslint-plugin-react and eslint-plugin-react-hooks. The root package.json pin must not be bumped past 9.x until all React plugins declare ESLint 10 peerDep support.
  • setInterval outbox drain, not Redis. Single-process; in-process EventEmitter (outboxTrigger.ts) for the drain signal. drainRequested flag pattern prevents double-drain. Optimistic-202, create-before-delete, and uid:dtstartMs dedup are load-bearing.
  • All CalDAV VALARM triggers must be built via ICAL.Duration.fromSeconds(-N*60), not bare strings. Bare strings produce VALUE=TEXT which is rejected by Fastmail and Apple Calendar.
  • REGISTRY_PAT secret name (not GITEA_PAT — the GITEA_ prefix is silently dropped by Gitea Actions). docker login --password-stdin, never -p $TOKEN on the command line.

Critical Pitfalls

Pitfall 1: Google Refresh Tokens in "Testing" Publishing Status Expire After 7 Days

What goes wrong: Google OAuth2 refresh tokens issued to an app in "Testing" publishing status (as opposed to "Production") expire after 7 days regardless of the token TTL in the credentials. After 7 days, the stored refresh token returns invalid_grant and all Google Calendar operations fail for that member. The user sees calendar data vanish or receives 401 errors, and the fix requires them to re-authorize — a confusing, non-obvious failure for a non-technical user.

Additionally, Google refresh tokens for apps in "Testing" status can only be granted to users explicitly listed as "test users" in the OAuth consent screen. If the operator adds a second member's Google account but forgets to add them as a test user, the authorization flow returns access_denied at the consent step, not a useful error message.

Why it happens: Developers set up the OAuth app in "Testing" status because "Production" requires Google verification (which requires a privacy policy, domain verification, and Google review). Testing status is the path of least resistance during development and stays in place longer than intended.

How to avoid: For a self-hosted household app serving 2 users, request "Production" publishing status immediately — it does not require Google's full verification unless the app accesses sensitive scopes. For calendar read/write, https://www.googleapis.com/auth/calendar is a "restricted" scope (not "sensitive"), which DOES require verification. Use https://www.googleapis.com/auth/calendar.readonly + https://www.googleapis.com/auth/calendar.events instead — these are the non-restricted scopes sufficient for event read/write (no attendee PII access). With these scopes, production status is grantable without full Google review.

If testing status must remain during development, encode a token_expiry_check that validates the refresh token age and prompts re-auth before it expires rather than failing silently. Store the token grant timestamp alongside the encrypted refresh token in member_credentials.

Add the operator's and wife's Google accounts as test users in the OAuth consent screen during development.

Warning signs:

  • Calendar data disappears exactly 7 days after a member connected their Google account.
  • invalid_grant error in API logs when refreshing Google tokens.
  • Second member's authorization flow returns access_denied.

Phase to address: Google Calendar provider phase (first Google OAuth plan). Document the testing-vs-production publishing status distinction in the onboarding UI and in docs/deployment.md before implementing the OAuth flow.


Pitfall 2: Google Calendar Recurrence Model Does Not Map Cleanly to iCalendar RRULE

What goes wrong: The Google Calendar API represents recurring events as a "master event" with a recurrence field (an array of raw iCalendar strings like ["RRULE:FREQ=WEEKLY;BYDAY=MO"]) plus separate "instance" resources for occurrences that have been individually modified (moved, canceled). The FamilySync codebase assumes the iCalendar model where: (a) a single VEVENT with RRULE expands to all occurrences via ICAL.RecurExpansion, and (b) exceptions are represented as VEVENT objects with RECURRENCE-ID.

When pulling Google recurring events, the Google API returns individual instance resources by default in events.list — unless you pass singleEvents: false. If singleEvents: true (the default), you get individual expanded occurrences, not the master event. You cannot reconstruct the RRULE from the instances. If singleEvents: false, you get the master event (with RRULE) but also need to separately fetch modified instances to know which occurrences have been moved or canceled. There is no single API call that returns "master event + delta patches in RECURRENCE-ID form" like CalDAV does.

Deleted/canceled individual occurrences in Google Calendar have status: "cancelled" — they are not omitted from the response. If your code filters on status === "confirmed" you silently drop the cancellation records and re-expand the occurrence the next time the RRULE expands, causing ghost events.

Why it happens: The CalDAV model and the Google Calendar API model for recurrence are fundamentally different. CalDAV treats recurrence as an iCalendar artifact (RRULE on the master VEVENT, RECURRENCE-ID VEVENTs for exceptions). The Google API wraps iCalendar but exposes a different resource model. Developers assume the broker abstraction can produce identical event shapes from both providers, but the Google recurrence model requires a different fetch strategy.

How to avoid: Fetch Google events with singleEvents: false to get master events with RRULE. For any recurring master event, issue a second call to fetch its instances (events.instances()) to get modified/canceled occurrences. Map status: "cancelled" instance resources to EXDATE entries on the master event's RRULE. Use ICAL.RecurExpansion on the reconstructed VCALENDAR for display, same as the Fastmail path.

The provider abstraction interface should return normalized VEVENT strings (raw iCalendar text), not provider-specific objects. The Google adapter's job is to produce a VCALENDAR string (including reconstructed RRULE + EXDATE) that the existing expand.ts / sync.ts layer can handle without modification.

Expose a fetchRecurringMasterWithExceptions(uid) method in the Google adapter that issues both API calls and returns a unified VCALENDAR string. Do not attempt to normalize at the events.list level.

Warning signs:

  • Recurring events show individual occurrences as separate events in the calendar instead of a recurring series.
  • Canceled occurrences still appear in the calendar after being deleted in Google Calendar.
  • singleEvents: true is used in the Google API client — this is wrong for the FamilySync data model.

Phase to address: Google Calendar provider phase (recurrence handling plan). Write a unit test that takes a Google events.list response (with a recurring master + a modified instance + a canceled instance) and asserts the reconstructed VCALENDAR string passes ical.js parsing and ICAL.RecurExpansion without error.


Pitfall 3: Google syncToken Invalidation Triggers Silent Full Re-Sync That Overwrites Local Writes

What goes wrong: The Google Calendar API supports incremental sync via syncToken: call events.list with the token from the previous response to receive only changes since the last sync. When the sync token expires (after ~7 days of inactivity, or after any large state change like timezone change or Google-side recalculation), the API returns HTTP 410 Gone. The correct response is to perform a full re-sync (no syncToken, save the new token). If the code does not handle 410 — or handles it by retrying with the old token — all incremental sync calls return 410 forever, silently halting all calendar updates.

A second issue: during a full re-sync, the code fetches all events and upserts into calendar_events. If a member created an event in FamilySync that is in the outbox (not yet written to Google), and the full re-sync runs before the outbox drains, the upsert overwrites the local optimistic row with the stale Google state (the event is not yet on Google's side), effectively reverting the user's creation. The event disappears from the UI until the next outbox drain re-creates it on Google and the next poller sync pulls it back.

Why it happens: syncToken expiration is handled in Google's CalDAV-via-ICAL model too (ctag changes trigger full re-sync), but the FamilySync ctag-based poller handles this correctly. The Google syncToken path is a different code branch that does not inherit the ctag handling. Full re-sync without outbox-awareness is a standard oversight.

How to avoid: Wrap every events.list call that uses a syncToken in a try/catch for 410. On 410: drop the sync token, mark the provider's sync state as stale, and enqueue a full re-sync on the next poller tick (not in-band, to avoid blocking the current poller iteration).

Before upsetting rows from a full re-sync, check the calendar_outbox table for any pending rows for the same user/calendar. If any pending writes exist for the same event UID, skip overwriting that row's raw_vevent from Google — the outbox write is the authoritative source of truth until it drains. Alternatively: drain the outbox before any full re-sync. The outbox drain is already ~12s; blocking the re-sync on it is acceptable.

Warning signs:

  • API logs show HTTP 410 from Google Calendar and the app stops updating events for that member.
  • Events created via FamilySync disappear briefly then reappear.
  • syncToken is stored per-provider in member_credentials or a separate provider_sync_state table but there is no 410 error handler branch.

Phase to address: Google Calendar provider phase (sync/poller plan). 410 handling must be in the acceptance criteria before the Google poller ships.


Pitfall 4: Google All-Day Events Use DATE, Timed Events Use RFC 3339 — Timezone Handling Differs From CalDAV

What goes wrong: Google Calendar returns timed events with start.dateTime (RFC 3339 string with timezone offset, e.g., 2026-07-01T10:00:00-04:00) and all-day events with start.date (ISO 8601 date string, e.g., 2026-07-01). The FamilySync sync layer (sync.ts) currently parses raw iCalendar DTSTART;TZID=America/Toronto:20260701T100000 strings via ical.js and stores the UTC instant in dtstart_utc.

When the Google adapter converts start.dateTime to iCalendar, it must produce DTSTART:20260701T140000Z (UTC). If it instead produces DTSTART;TZID=America/Toronto:20260701T100000, ical.js parses it correctly, but only if the corresponding VTIMEZONE component is also present in the VCALENDAR. Google does not return VTIMEZONE components — their API uses offset strings. A missing VTIMEZONE for a TZID reference causes ical.js to default to UTC, silently shifting the event time.

All-day event reminder handling has a separate pitfall: the computeAlertInstantUtc function in vevent.ts uses getHouseholdTimezone(db) (the stored IANA timezone) to compute "9 AM local on alert day." For Google all-day events, the Google API returns reminders.overrides (a list of {method, minutes} objects) or reminders.useDefault (boolean). If useDefault: true, the reminder fires at the user's Google Calendar default reminder time (from their Google account settings) — which the server cannot know. Mapping useDefault: true to a reminderLeadMinutes value in FamilySync is undefined.

Why it happens: CalDAV stores full iCalendar with VTIMEZONE. The Google API abstracts timezone into RFC 3339 offsets. The two representations look similar but require different handling in ical.js. Developers test with US/Eastern events and miss that ical.js silently falls back to UTC for an unknown TZID.

How to avoid: In the Google adapter, always normalize start.dateTime to UTC before building the iCalendar string. Use new Date(googleEvent.start.dateTime).toISOString() to get the UTC instant, then build DTSTART:20260701T140000Z. Never emit a TZID reference without a corresponding VTIMEZONE block.

For all-day events: map start.date directly to DTSTART;VALUE=DATE:20260701, same as the CalDAV path.

For Google reminders.useDefault: true: treat it as "no reminder" at the FamilySync layer. Store reminderLeadMinutes: null. Document this in the UI: "Google default reminders from your Google account settings are not shown in FamilySync; set a reminder here to receive FamilySync push notifications." This avoids fetching and storing per-member Google account preferences.

For Google reminders.overrides: map the first email-method reminder to reminderLeadMinutes (FamilySync uses push, not email). Ignore popup method overrides unless there is a clear 1:1 mapping.

Warning signs:

  • Timed events from Google appear at the wrong time in FamilySync (off by the timezone offset).
  • ical.js warnings about unknown TZID in the server logs.
  • All-day events with useDefault: true trigger unexpected push notifications.

Phase to address: Google Calendar provider phase (event normalization plan). Write unit tests for the UTC normalization: assert that a start.dateTime with -04:00 offset produces DTSTART:...Z with the correct UTC instant, and that a missing VTIMEZONE is never emitted.


Pitfall 5: Provider Abstraction Refactor Regresses the Live Fastmail Path

What goes wrong: Extracting a CalendarProvider interface from the existing CalDAV code requires moving the tsdav client, sync logic, poller, outbox worker, and reminder scheduler from Fastmail-specific code into an abstraction layer. The risk is that the refactor introduces a regression on the Fastmail path: a subtle behavioral change in the outbox worker (e.g., enqueue ordering, isDraining guard, or the drainRequested flag) can cause:

  • Duplicate CalDAV PUTs (double-drain race reintroduced).
  • Create-before-delete ordering broken for move operations.
  • The ctag-based poller not calling the new interface method in the correct order (poll → sync → schedule).
  • The uid:dtstartMs dedup key in the reminder scheduler falling back to uid alone if the schema type changes during the refactor.

A specific risk: the outbox worker currently reads provider_type from member_credentials but the only implemented value is 'caldav'. During refactor, if the worker is changed to dispatch to a provider-specific write() method, and the CalDAV write path is accidentally placed behind a provider_type === 'google' branch instead of provider_type === 'caldav', all existing Fastmail write-back silently stops with no error (the wrong branch simply does nothing).

Why it happens: Refactors that extract interfaces from working code are high-regression-risk because the tests were written against the concrete implementation. The test suite may pass because it stubs the CalDAV calls but not the new interface dispatch, so the dispatch logic change is never exercised by tests.

How to avoid: Refactor behind a feature flag or in a branch that keeps the Fastmail path fully operational throughout. Do not merge the provider abstraction until all existing CalDAV integration tests (outbox, poller, ctag, reminder scheduler) pass unchanged against the new interface.

Write a golden-path integration test for the Fastmail provider specifically: enqueue a write → assert it reaches the CalDAV mock → assert the outbox row is marked done. This test must pass before and after the refactor with no modification.

Keep the provider_type === 'caldav' dispatch path as the default/fallback. The Google path (provider_type === 'google') is additive; it must not be required for the existing Fastmail path to work.

The abstraction interface should be defined from the consumer's perspective (what outboxWorker.ts and poller.ts need), not from the CalDAV implementation's perspective. Avoid leaking tsdav types (DAVCalendar, DAVObject) into the interface — callers should see normalized event objects.

Warning signs:

  • Any existing CalDAV integration test is modified during the refactor (should be zero changes to existing tests).
  • The outbox worker has a branch that exits without calling any write method for provider_type === 'caldav'.
  • The provider_type column contains 'caldav' for all existing rows but the refactored dispatch uses string equality without a default case.

Phase to address: Provider abstraction phase (first). Must ship before any Google Calendar code is written. The acceptance gate is: all v1.1-passing integration tests still pass, unchanged, with the abstraction layer in place.


Pitfall 6: Multiple VALARMs — Duplicate Fire and Preserve-vs-Replace Ambiguity

What goes wrong: v1.2 adds multiple reminders per event (multiple VALARMs on a single VEVENT). The v1.1 reminder scheduler deduplicates on uid:dtstartMs — one dedup entry per event. With multiple VALARMs, a single event now needs multiple push notifications at different lead times. If the dedup key is still uid:dtstartMs, only the first alarm fires; subsequent alarms for the same event at different leads are treated as "already sent" by the dedup map.

A second issue: the scheduler currently runs a SQL query for events where reminderLeadMinutes IS NOT NULL. With multiple reminders stored as multiple VALARM objects in rawVevent (and potentially as a JSON array or repeated column), the SQL query must change to a per-VALARM lead-time scan. If the query fetches one row per event and picks the first VALARM lead only, subsequent alarms are silently skipped.

A third issue: the VALARM preserve-on-edit path (D-08 / CAL-14, shipped in Phase 11) extracts all existing VALARMs from rawVevent and re-attaches them. With multiple VALARMs, if the user adds a new reminder in the FamilySync UI (which produces one VALARM from the picker), the preserve path must decide: replace all existing VALARMs with the single new one, or append? The current single-reminder model implicitly "replace all." Multiple-reminder UI changes this intent.

A fourth issue: Google Calendar caps reminders at 5 per event (reminders.overrides max 5). If the user creates 6 reminders in FamilySync and the event is in a Google Calendar, the write-back to the Google Calendar API returns a 400 error. The error must be caught and surfaced to the user before the write, not as a silent failure.

Why it happens: The v1.1 architecture was designed for exactly one VALARM per event (the picker is single-selection). Multiple VALARM support requires rethinking the dedup key, the SQL query, the preserve logic, and the write-back validation.

How to avoid: Change the scheduler dedup key to uid:dtstartMs:leadMinutes (three-part key). Each VALARM on an event gets its own dedup entry, and each fires independently.

Store multiple reminder leads in a normalized form. Two options: (a) a event_reminders junction table (event_id, lead_minutes), or (b) a JSON array column reminder_lead_minutes_json on calendar_events. Option (a) is cleaner for SQL querying; option (b) avoids a second migration and join. For a two-user household, option (b) is acceptable. The SQL scheduler query must unnest or join to iterate per-lead.

For the preserve-on-edit path: the UI must distinguish "user changed reminders" from "user changed something else (title, time, location)." If reminders were not touched, preserve all existing VALARMs. If the user opened the reminder editor and submitted, replace all VALARMs with the new set. This is a frontend concern: include a remindersChanged: boolean flag in the outbox payload alongside the new reminderLeads: number[] array.

For Google Calendar cap validation: add a Zod validator on the outbox payload schema that rejects more than 5 reminder leads when the provider is Google. Surface the error in the form before submit.

Warning signs:

  • Second and third reminders on an event never fire.
  • The scheduler sends duplicates for the first alarm on every tick (dedup not working after key change).
  • Events with 6+ reminders silently fail to write to Google Calendar.
  • The preserve path strips all VALARMs when the user edits only the event title.

Phase to address: Multiple reminders phase. The dedup key change and schema migration must land in the same plan. Google cap validation must land in the same plan as Google write-back support.


Pitfall 7: Drizzle Auto-Migrate on Boot on MariaDB 11 — The Three Failure Modes

What goes wrong: v1.2 adds zero-manual-setup: the API process runs pending migrations automatically at startup. There are three distinct failure modes specific to this stack:

Failure mode A — Journal-hash mismatch with legacy tracking. The __drizzle_migrations table records applied migrations by tag. If any migration file was renamed after it was applied to an existing database (or if the journal was re-generated, changing the when timestamp), drizzle-kit migrate will not find the matching tag in __drizzle_migrations and will try to re-apply the already-applied migration. On MariaDB, re-running ALTER TABLE ... ADD COLUMN for a column that already exists returns Duplicate column name — not a transaction rollback, but a crash. This is the "journal-hash mismatch" lesson from D-MIGRATION-10-01.

Failure mode B — False destructive diff if db:push logic is used. Any code path that calls drizzle-kit push (even programmatically or from a startup script) against the live MariaDB will schedule TRUNCATE/DROP. The drizzle migrate API (programmatic via drizzle-orm/migrator) is safe — it applies committed .sql files and does not introspect the live DB. The failure mode is: a developer adds a startup call that uses the wrong drizzle API entry point.

Failure mode C — Concurrent startup race (two container instances). If the Docker Compose stack is ever restarted in a way that brings up two API containers briefly (e.g., a rolling update or docker-compose up --scale api=2), both instances call migrate() at startup. MariaDB does not have advisory locks by default. Both instances read __drizzle_migrations, both see the same unapplied migrations, and both attempt to apply them simultaneously. The first to commit wins; the second hits Duplicate column name or a primary key conflict and crashes. The container crash loop then restarts the container, which succeeds because migrations are now applied — but the crash log looks like a startup failure.

Why it happens: Startup migration is convenient but runs without a human watching the DDL. The existing dev workflow always runs pnpm db:migrate explicitly, so a developer sees the output. Auto-migrate is invisible unless there is explicit logging. On MariaDB (no DDL transactions), a partially-applied migration leaves the schema in an undefined state.

How to avoid: Use the drizzle-orm/migrator programmatic API (migrate(db, { migrationsFolder: '...' })), not drizzle-kit CLI, in the startup path. The migrator API applies committed SQL files sequentially and tracks via __drizzle_migrations — it is safe for auto-migrate.

Add a startup log that prints each migration file name as it is applied (or "already applied, skipping"). This makes auto-migrate auditable.

For the concurrent-startup race: wrap the migrate call in a MariaDB advisory-lock table: INSERT INTO migration_lock (id) VALUES (1) with a unique constraint. If the insert fails, the other instance already holds the lock and is migrating — wait and retry. On success, run migrations, then DELETE FROM migration_lock WHERE id=1. This is a single-row sentinel table, created before any other migration.

For distinguishing fresh-DB vs existing-DB: the __drizzle_migrations table not existing means a fresh DB — apply all migrations. The table existing with all entries means fully migrated — skip. The table existing with some entries means a partial state — apply only the unapplied subset (normal drizzle behavior). No special-casing needed; the migrator handles this correctly.

Warning signs:

  • Duplicate column name error in the API startup log immediately after deployment.
  • Two container instances log "applying migration 0003_..." at the same timestamp.
  • The API container crashes on first start but succeeds on second start (concurrent-race symptom).
  • Any code calling drizzle-kit push in a startup path (grep for it explicitly before shipping).

Phase to address: Zero-manual-setup DB migration phase. The migration_lock sentinel table and the startup log must be in the acceptance criteria. Write a test that runs migrate() twice on the same DB and asserts no error and no duplicate column.


Pitfall 8: PWA Dark Mode — Flash of Wrong Theme and Service Worker Cache Staling

What goes wrong: The standard dark mode pattern — read prefers-color-scheme or a localStorage preference and apply a data-theme attribute on <html> — has a flash-of-wrong-theme (FOWT) on initial load if the attribute is set by React (after hydration) rather than by an inline script before the first paint. The user sees light mode for ~100ms then switches to dark. On iOS standalone PWA, this flash is especially visible because the splash screen (controlled by theme-color meta tag) may not match the applied theme.

The deeper issue: Schedule-X uses --sx-color-* CSS custom properties for its calendar component theming. These are not in the tokens.css semantic token layer established in Phase 17. If dark mode only updates the FamilySync token variables and not the --sx-color-* variables, the calendar component remains in light mode regardless of the document theme — a visually broken state that is easy to miss in desktop testing (because developers often test on a light system preference).

A service worker cache complication: the Workbox-managed precache caches index.html and all static assets at build time. If the theme preference is baked into a cached index.html (e.g., via a server-side render or a build-time default), the cached version will always load with the light-mode default, overriding the user's stored preference. In Vite PWA (client-side rendered), this is not the primary risk — but if the service worker caches a CSS file that includes hardcoded light-mode colors (not variables), dark mode changes deployed in a new build will not reach the user until the service worker updates.

Why it happens: Dark mode is treated as a CSS-only concern but the FOWT is a JS execution timing concern. Schedule-X's custom properties are undocumented relative to external theming. Service worker cache invalidation for CSS changes requires a cache-busting strategy.

How to avoid: Prevent FOWT: inject a minimal inline <script> in index.html (before any CSS) that reads localStorage.getItem('theme') and sets document.documentElement.dataset.theme synchronously. This runs before any CSS is parsed, preventing the flash. The React app then reads the same localStorage key on mount to initialize the Zustand theme store.

Map all --sx-color-* variables used by Schedule-X to the FamilySync dark/light token values. Inspect the Schedule-X @schedule-x/theme-default CSS to enumerate all --sx-* variables and override them in a tokens.css [data-theme="dark"] block. Assert that the calendar renders in dark mode during the dark mode acceptance test.

For service worker cache: vite-plugin-pwa handles cache busting via file hashing in the build output. Verify that tokens.css (or whichever file holds the dark mode variables) is included in the Workbox precache manifest — if it is in the public directory without a hash, it may not be cache-busted on deploy. Move all theme CSS into a hashed build output file.

For iOS standalone PWA theme-color: update the <meta name="theme-color"> dynamically using document.querySelector('meta[name="theme-color"]').setAttribute('content', ...) when the theme toggles. iOS reads theme-color for the status bar color. A static theme-color will not match the active dark theme.

Warning signs:

  • Brief white flash visible on page load when dark mode is selected.
  • The calendar component (Schedule-X) shows light colors while the rest of the app is dark.
  • iOS status bar color does not match the active theme.
  • Deploying a dark mode CSS update does not reach users until they manually clear the service worker cache.

Phase to address: PWA dark mode phase. The FOWT fix (inline script in index.html) and Schedule-X variable mapping must be in the acceptance criteria. Test on both light and dark system preferences. Test with the service worker active (not disabled).


What goes wrong: The Google OAuth flow requires registering exact redirect URIs in the Google Cloud Console. The Pangolin/Newt tunnel exposes the app at a public hostname (e.g., familysync.example.com). The registered redirect URI must match exactly: https://familysync.example.com/api/auth/google/callback. If the redirect URI in the Google Cloud Console is http:// instead of https://, or includes/omits a trailing slash, or uses the internal hostname instead of the Pangolin hostname, the callback returns redirect_uri_mismatch from Google.

A second issue: the existing Authelia OIDC flow already uses an OAuth state cookie (oidc.state) managed by @hono/oidc-auth (stored as a cookie on the domain). The Google OAuth flow also requires a state parameter to prevent CSRF. If both flows use the same cookie name, or if the @hono/oidc-auth middleware intercepts the Google OAuth callback URL (because it pattern-matches /api/* for auth), the Google callback's code parameter is consumed by the Authelia handler before the Google handler sees it — resulting in a state mismatch or invalid_grant error.

A third issue: the Pangolin/Newt tunnel may terminate TLS and forward HTTP internally. If the API receives the Google callback over HTTP (forwarded internally), but the redirect_uri registered in Google is https://, Google's OAuth server rejects the authorization code exchange with redirect_uri_mismatch because the redirect_uri in the token exchange request is constructed from the internal HTTP URL, not the external HTTPS URL.

Why it happens: Two OAuth flows in one app (Authelia OIDC for user login + Google OAuth for calendar access) use overlapping mechanisms (cookies, state params, callback routes). The Pangolin/Newt HTTPS termination creates an HTTP-internally-but-HTTPS-externally environment where naive URL construction picks the wrong scheme.

How to avoid: Mount the Google OAuth callback at a distinct path that the Authelia @hono/oidc-auth middleware does not intercept: /api/providers/google/callback (not under /api/auth/). Apply the Authelia OIDC middleware only to routes that need a logged-in session, not as a global catch-all for /api/*. The Google callback handler does not need an Authelia session — it needs the Google state param. Apply authentication to the Google OAuth initiation endpoint (user must be logged in to connect Google), but not to the callback endpoint (callback must be reachable without a session because Google redirects there).

Use distinct state cookie names: the Authelia flow uses oidc.state (managed by @hono/oidc-auth); the Google flow should use google.oauth.state (managed by the Google OAuth handler). Store the state in a HttpOnly; SameSite=Lax; Secure cookie with a 10-minute TTL.

For the HTTPS scheme: read the external hostname from a EXTERNAL_BASE_URL environment variable (e.g., https://familysync.example.com) and construct all OAuth redirect URIs from it. Never derive the redirect URI from req.headers.host or req.protocol — the internal forwarded values will be wrong. Add EXTERNAL_BASE_URL to the required env checklist in the zero-setup wizard.

Register the redirect URI in Google Cloud Console as exactly ${EXTERNAL_BASE_URL}/api/providers/google/callback.

Warning signs:

  • Google OAuth callback returns redirect_uri_mismatch.
  • After completing the Google consent screen, the app returns to the homepage without connecting the account (silent failure).
  • Authelia OIDC errors appear in logs during a Google OAuth flow (state cookie collision).
  • The Google callback URL in server logs shows http:// instead of https://.

Phase to address: Self-service Google OAuth onboarding phase. The EXTERNAL_BASE_URL env var and the distinct callback path must be established before any OAuth flow is implemented. Test the full flow end-to-end over the Pangolin tunnel (not just localhost) as the acceptance gate.


Pitfall 10: Google OAuth Token Storage — Encryption Key Reuse and Scope Creep

What goes wrong: The Google OAuth access token (short-lived, ~1 hour) and refresh token (long-lived) must be stored securely. The existing member_credentials table stores the Fastmail app password encrypted with APP_PASSWORD_ENCRYPTION_KEY (AES-256-GCM). The tempting approach: store the Google refresh token in the same member_credentials table with the same encryption key, adding a google_refresh_token column.

The first issue: provider_type is already UNIQUE(user_id) on member_credentials (enforced by uniq_member_credential_user). A member with both a Fastmail credential and a Google credential would need two rows but the unique constraint allows only one per user. Attempting to upsert the Google credential overwrites the Fastmail credential, breaking CalDAV.

The second issue: the Google access token should not be stored persistently — it expires in 1 hour and must be refreshed on use. If the code stores the access token as well as the refresh token, the stored access token will always be stale and the code must always call the token refresh endpoint anyway. Storing the access token wastes a column and tempts future code to use it without checking expiry.

The third issue: the Google OAuth scope should be the minimum needed. If the developer uses https://www.googleapis.com/auth/calendar (full calendar access) instead of the split calendar.readonly + calendar.events scopes, Google requires manual verification review for production status. Use the minimum scope needed.

Why it happens: The member_credentials unique constraint and the provider_type discriminator were designed for a single provider per user. Multi-provider support requires rethinking the credential storage schema.

How to avoid: Change the member_credentials schema for v1.2: drop the UNIQUE(user_id) constraint and replace it with UNIQUE(user_id, provider_type). This allows one row per provider per user. The migration is additive (drop unique, add composite unique). Verify with drizzle-kit generate that the generated SQL is DROP INDEX uniq_member_credential_user; ADD UNIQUE KEY uniq_member_credential_user_provider (user_id, provider_type) — additive DDL plus an index drop. The index drop is safe on MariaDB (not a TRUNCATE).

Store only the Google refresh token (not the access token). Add a google_refresh_token_encrypted column (or a generic oauth_refresh_token_encrypted column) to the Google provider_type row. Derive fresh access tokens at request time using the refresh token — cache the access token in memory (not in the DB) with its expiry time.

Use scopes: https://www.googleapis.com/auth/calendar.readonly + https://www.googleapis.com/auth/calendar.events. Request them space-separated. Store the granted scopes alongside the refresh token so the app knows what the user consented to.

Warning signs:

  • An attempt to add a second member_credentials row for the same user hits a unique constraint violation.
  • member_credentials for a user shows provider_type: 'google' but the CalDAV credential is gone.
  • The access token column in the DB is always expired.
  • Google authorization flow requests the https://www.googleapis.com/auth/calendar scope (full access, not split).

Phase to address: Provider abstraction phase (schema migration plan) and Google OAuth onboarding phase. The schema change to UNIQUE(user_id, provider_type) must land before any Google credential storage code is written.


Pitfall 11: Dependency Updates — ESLint 10 Pin and Breaking Change Cascade

What goes wrong: The CI dependency audit (Phase 16) surfaces outdated packages. Bulk-applying pnpm up or pnpm audit fix will attempt to update ESLint to v10, which breaks eslint-plugin-react and eslint-plugin-react-hooks (both declare only eslint@9.x as a peer dependency as of 2026-06). When ESLint 10 is installed, both plugins either throw EBADPEERCONN at install time or emit no errors at lint time (silently disabled), breaking the CI lint gate.

A secondary risk: @schedule-x/calendar and @schedule-x/theme-default have been updated (currently pinned at 4.6.0 in apps/pwa). Schedule-X 5.x (if released) changes the --sx-color-* variable naming. If the dependency update phase bumps Schedule-X to a major version before the dark mode phase, the dark mode variable mapping work must be redone with the new variable names.

A third risk: drizzle-kit and drizzle-orm minor versions must be updated together. The drizzle-orm/mysql2 import path and the drizzle-orm/migrator API are version-coupled. If drizzle-orm is bumped to 0.46.x but drizzle-kit stays at 0.31.x, the snapshot format version mismatch may cause generate to produce an incompatible snapshot that migrate cannot parse.

Why it happens: pnpm up or pnpm audit --fix applies updates greedily across the dependency tree without respecting known breaking-change boundaries. The project already knows about the ESLint 9.x pin but a future developer (or an automated tool) may not.

How to avoid: Update dependencies manually, one package or one ecosystem at a time, with the CI lint gate running after each batch. Never use pnpm up --latest or pnpm audit --fix without reviewing the changeset.

ESLint: hold at 9.x until eslint-plugin-react and eslint-plugin-react-hooks both publish ESLint 10 peerDep support and the release notes confirm compatibility. Add a comment in the root package.json above the "eslint" entry: // pinned at 9.x — eslint-plugin-react does not support eslint@10 yet.

Schedule-X: update minor versions only (4.x → 4.y). Hold at major 4 until dark mode theming is complete and the variable mapping is locked. Check Schedule-X release notes for --sx-color-* variable changes before any minor update.

Drizzle: always update drizzle-orm and drizzle-kit in the same commit. After updating, run pnpm db:generate in a dry-run (the output must be "No schema changes, nothing to migrate") to verify the snapshot format compatibility.

Warning signs:

  • ESLint exits 0 (no errors reported) but previously failing lint rules now pass — plugin silently disabled.
  • eslint-plugin-react@X requires eslint@">=9.0.0 <10.0.0" but got eslint@10.x.x in pnpm install output.
  • drizzle-kit generate emits a snapshot with a different version field than the existing snapshots in meta/.
  • Schedule-X calendar renders with missing styles after a version bump.

Phase to address: CI dependency update phase (last in milestone, after all feature phases). Treat each package ecosystem as a separate update batch. Run the full CI suite after each batch.


Technical Debt Patterns

Shortcut Immediate Benefit Long-term Cost When Acceptable
Keeping UNIQUE(user_id) on member_credentials for the Google credential No schema migration needed Can only store one provider per user; Google credential overwrites Fastmail credential Never — change to UNIQUE(user_id, provider_type) before any Google credential storage
Storing Google access tokens persistently in the DB Avoids token refresh logic Access tokens expire in 1 hour; all stored values are always stale Never — cache in memory only; persist only the refresh token
Using singleEvents: true for Google Calendar event fetch Simpler API call Cannot reconstruct RRULE; recurring events appear as individual orphan events Never — use singleEvents: false for the FamilySync data model
Skipping the migration_lock sentinel table for auto-migrate One fewer migration table Concurrent startup races cause Duplicate column name crash loop Never for a production deployment; acceptable in single-instance local dev
Dark mode applied via React state (after hydration) Simpler code Flash-of-wrong-theme on every cold load Never — use inline <script> in index.html to set the theme synchronously
Deriving OAuth redirect URI from req.headers.host No env var to configure Returns internal hostname through Pangolin tunnel; causes redirect_uri_mismatch Never — read from EXTERNAL_BASE_URL env var
Bumping ESLint to v10 with other dependencies Fewer outdated warnings eslint-plugin-react is disabled silently; lint gate appears green but catches nothing Never until eslint-plugin-react publishes v10 support
Not handling Google 410 Gone (expired syncToken) Simpler sync code All incremental sync halts permanently after 7 days of inactivity Never — 410 is a documented, expected Google API response

Integration Gotchas

Integration Common Mistake Correct Approach
Google Calendar API singleEvents: true in events.list Use singleEvents: false; fetch modified instances separately
Google Calendar API Not handling status: "cancelled" instances Map cancelled instances to EXDATE on the master RRULE
Google Calendar API Ignoring 410 Gone on syncToken expiry Catch 410, clear token, enqueue full re-sync
Google Calendar API Using https://www.googleapis.com/auth/calendar scope Use calendar.readonly + calendar.events (non-restricted, no Google verification required)
Google OAuth Constructing redirect URI from req.protocol/req.hostname Read from EXTERNAL_BASE_URL env var; Pangolin terminates TLS so internal protocol is HTTP
Google OAuth Sharing state cookie name with @hono/oidc-auth Authelia flow Use google.oauth.state cookie; mount Google callback outside the Authelia middleware scope
Google refresh token Storing access tokens in DB Cache access tokens in memory with expiry; persist only refresh token (encrypted)
Drizzle auto-migrate Calling drizzle-kit push programmatically at startup Use drizzle-orm/migrator migrate() API — applies committed SQL files, never introspects live DB
Drizzle auto-migrate No concurrent-startup protection Implement migration_lock sentinel row with INSERT ... ON DUPLICATE KEY IGNORE
member_credentials schema UNIQUE(user_id) blocks multi-provider Migrate to UNIQUE(user_id, provider_type) before writing Google credential code
Schedule-X dark mode Not overriding --sx-color-* variables in dark token block Enumerate all --sx-* variables from @schedule-x/theme-default CSS and map them
PWA dark mode Setting data-theme in React (after hydration) Inline <script> in index.html before CSS to set data-theme synchronously on load
Vite PWA service worker tokens.css in public/ without hash Move theme CSS into hashed build output; verify Workbox precache manifest includes it

Performance Traps

Trap Symptoms Prevention When It Breaks
Google events.list with no timeMin/timeMax Fetches all events in history; response > 5MB; rate limit hit Always pass a bounded time window (e.g., 6 months past, 1 year future) First sync for a calendar with >1000 events
Google instances fetch per recurring master event N+1 API calls for N recurring events Batch: fetch all instances in the time window in one call; merge by master event ID Calendars with 10+ recurring series
MariaDB auto-migrate running complex migrations on startup Long startup delays; health check fails before migration completes Log migration progress; set Docker healthcheck start_period long enough Migrations with ALTER TABLE on a table with >50K rows
Multiple VALARMs in scheduler SQL query without index Full calendar_events scan on every scheduler tick Index on reminder_lead_minutes column (or event_reminders.lead_minutes if junction table) Tables with >10K events

Security Mistakes

Mistake Risk Prevention
Google refresh token stored unencrypted Stolen DB backup exposes ability to read all of a member's Google Calendar indefinitely Encrypt with APP_PASSWORD_ENCRYPTION_KEY (AES-256-GCM) same as Fastmail app passwords
Google OAuth state param not validated on callback CSRF: attacker can link their Google account to the victim's FamilySync session Validate state against the google.oauth.state cookie; reject mismatches with 400
Google callback endpoint accessible without session check on initiation Any unauthenticated caller can initiate a Google OAuth flow for any user Require authenticated FamilySync session to initiate the flow (/api/providers/google/connect); the callback itself needs no session but must validate the state cookie
Google scopes stored but not verified on callback User may grant narrower scopes than requested; write-back fails silently Check scope in the token response; if write scope is absent, mark the provider as read-only and surface in UI
EXTERNAL_BASE_URL exposed in a public API response Discloses internal routing; low severity but unnecessary Keep env vars server-side only; never return infrastructure config in API responses
Migration lock table not cleaned up on crash Next startup finds a stale lock and never migrates Set a locked_at timestamp in the lock row; auto-release locks older than 60 seconds

UX Pitfalls

Pitfall User Impact Better Approach
Dark mode toggle has no system-follow option Wife on iPhone expects dark mode to follow iOS system preference automatically Implement three options: Light / Dark / System (default); System reads prefers-color-scheme media query and updates on system change
Google "Connect" button initiates OAuth without explaining what access is granted Non-technical wife confused by Google consent screen listing calendar permissions Show a one-sentence explanation before the OAuth redirect: "FamilySync will read and update your Google Calendar events."
Google auth failure surfaces a raw error message invalid_grant or redirect_uri_mismatch visible to end user Map known Google OAuth error codes to friendly messages; provide a "Try again" button that re-initiates the flow
Multiple reminders UI with no order or visual grouping Hard to tell which reminder fires when Show reminders sorted by lead time (earliest first); each entry shows the computed fire time ("4 days before, at 9:00 AM")
Dark mode flash on cold PWA load Wife on iPhone sees white flash on dark mode Inline script in index.html sets theme before first paint; no React wait required
Zero-setup wizard asks for Google OAuth credentials before explaining the App Flow Non-technical user confused by "Client ID" and "Client Secret" terms Name the fields plainly; link to the FamilySync deployment guide step that shows where to find them in the Google Cloud Console

"Looks Done But Isn't" Checklist

  • Google OAuth flow: Refresh token stored — but 410 syncToken expiry handler missing. Verify with a forced syncToken reset (delete the stored token, check that the next poll triggers a full re-sync without crashing).
  • Google recurrence: Master event fetched with singleEvents: false — but modified/canceled instances not fetched. Verify by canceling one occurrence in Google Calendar and confirming it does not appear in FamilySync.
  • Provider abstraction: Interface defined — but the Fastmail outbox worker's isDraining guard and drainRequested flag behavior is unchanged. Verify with the existing outbox integration tests against the new interface.
  • Multiple reminders: Multiple VALARM objects in rawVevent — but scheduler dedup key still uid:dtstartMs (not uid:dtstartMs:leadMinutes). Verify by creating an event with two reminders and confirming two separate pushes fire at the correct times.
  • Auto-migrate on boot: Migrations run at startup — but no concurrent-startup protection. Verify by starting two container instances simultaneously and confirming exactly one migration run, no crash loop.
  • Dark mode: data-theme applied — but Schedule-X --sx-color-* variables not overridden. Verify by switching to dark mode and checking the calendar view specifically (not just the nav/lists).
  • Google OAuth through Pangolin: OAuth flow works on localhost — but redirect URI uses internal hostname. Verify by completing the Google OAuth flow over the Pangolin public URL (EXTERNAL_BASE_URL), not via localhost:3000.
  • Dependency updates: ESLint updated — but eslint-plugin-react compatibility not verified. Verify by introducing a deliberate React lint violation and confirming the CI lint gate catches it after the update.
  • Google token expiry: Access tokens cached in memory — but expiry check missing. Verify that a request made >1 hour after the last token fetch triggers a silent refresh, not a 401 from Google.

Recovery Strategies

Pitfall Recovery Cost Recovery Steps
Google refresh token 7-day expiry in Testing status LOW Promote app to Production status in Google Cloud Console; member re-authorizes once
Drizzle auto-migrate Duplicate column name crash MEDIUM Manually mark the migration as applied in __drizzle_migrations; restart container
Concurrent migration lock stuck (crash before lock release) LOW DELETE FROM migration_lock WHERE id=1 in the DB; restart container
Google syncToken 410 — incremental sync stopped LOW Delete stored syncToken from member_credentials; next poll performs full re-sync
member_credentials unique constraint violation (Google overwrites Fastmail) HIGH Restore from DB backup; apply the UNIQUE(user_id, provider_type) migration immediately
OAuth redirect URI mismatch (Pangolin hostname not registered) LOW Add the correct HTTPS URL to Google Cloud Console Authorized Redirect URIs; no code change
Dark mode flash in production (inline script missing) LOW Add inline <script> to index.html; rebuild and deploy; service worker cache busted by new hash
ESLint 10 upgrade breaks plugin — lint gate silently passes MEDIUM Roll back ESLint to 9.x in root package.json; re-run CI to confirm lint gate fails again on known violations

Pitfall-to-Phase Mapping

Pitfall Prevention Phase Verification
Google refresh token 7-day expiry (Testing vs Production status) Google Calendar provider (phase start) Document and enforce before any token storage code ships
Google recurrence model mismatch (singleEvents, EXDATE) Google Calendar provider (recurrence plan) Unit test: mock Google API response → assert reconstructed VCALENDAR passes ical.js + RecurExpansion
Google syncToken 410 handling Google Calendar provider (sync/poller plan) Integration test: simulate 410 → assert full re-sync triggered, no crash
Google all-day timezone / useDefault reminder Google Calendar provider (normalization plan) Unit test: UTC normalization of start.dateTime with offset; useDefault: true maps to null
Provider abstraction Fastmail regression Provider abstraction phase (must ship first) All existing outbox/poller/scheduler tests pass unchanged against the new interface
Multiple VALARM dedup key and Google 5-cap Multiple reminders phase Integration test: event with 2 reminders fires 2 pushes; Google write-back with 6 reminders returns validation error before write
Drizzle auto-migrate: journal mismatch, concurrent race, false destructive diff Zero-setup DB migration phase Test: run migrate() twice on same DB → no error; start two containers simultaneously → one applies, one waits
PWA dark mode FOWT and Schedule-X coverage PWA dark mode phase Playwright test: toggle dark mode, assert --sx-color-* computed values on the calendar element match dark theme
OAuth callback through Pangolin redirect URI Self-service Google OAuth onboarding phase End-to-end test over Pangolin tunnel hostname (not localhost)
Google token encryption and multi-provider schema Provider abstraction phase (schema migration) UNIQUE(user_id, provider_type) in place; integration test: add Fastmail + Google credentials for same user → both rows present
ESLint 10 breaking change in dependency updates CI dependency update phase (last in milestone) Update ESLint separately; verify lint gate still catches a known violation after update

Sources

  • Project source: apps/api/src/broker/reminderScheduler.ts, vevent.ts, outboxWorker.ts, client.ts (inspected directly)
  • Migration history: apps/api/src/db/migrations/meta/_journal.json, migration SQL files (inspected directly)
  • .planning/RETROSPECTIVE.md — v1.0 and v1.1 lessons (journal-hash mismatch, false destructive diff, node-cron silent skip, runner-probe-first)
  • .planning/quick/260610-cr8-adopt-drizzle-generate-migrate-workflow-/260610-cr8-PLAN.md — Drizzle MariaDB push foot-gun (direct source)
  • .planning/todos/completed/adopt-drizzle-migrations-workflow.md — false destructive diff root cause (direct source)
  • .planning/milestones/v1.1-phases/10-admin-role-settings/10-01-PLAN.md — migration workflow and UNIQUE(user_id) constraint history (direct source)
  • .planning/milestones/v1.1-phases/11-per-event-reminders/11-RESEARCH.md — VALARM classification, scheduler dedup key design (direct source)
  • CLAUDE.md — ESLint 9.39.4 pin rationale, stack constraints, Schedule-X version, CI runner constraints (direct source)
  • PROJECT.md — Key decisions D-10 through D-20, constraint table, v1.2 feature list (direct source)
  • Google Calendar API documentation knowledge (refresh token expiry policy, singleEvents parameter, 410 Gone syncToken expiry, reminders.useDefault, scope tiers) — HIGH confidence from repeated confirmed behavior
  • OAuth2 PKCE / state cookie mechanics — HIGH confidence from shipped Authelia OIDC implementation in this codebase
  • Drizzle ORM programmatic migrator API (drizzle-orm/migrator) — MEDIUM confidence (verify against drizzle-orm release notes for the installed version before shipping auto-migrate)

Pitfalls research for: FamilySync v1.2 Multi-Provider, Theming & Zero-Setup Researched: 2026-06-19