- 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>
505 lines
56 KiB
Markdown
505 lines
56 KiB
Markdown
# 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 ~1–2s; 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).
|
||
|
||
---
|
||
|
||
### Pitfall 9: OAuth Callback Through Pangolin/Newt Tunnel — Redirect URI Mismatch and State Cookie Collision
|
||
|
||
**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*
|