diff --git a/CLAUDE.md b/CLAUDE.md index 3ead54a2..ac347b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -426,6 +426,20 @@ and is **fully gone** — no dependency, no `lib/powersync/` directory, no stopping the drain on first error so later mutations against the same record aren't applied out of order. +**Crew Sync v2 coverage convention** (`docs/CREW_SYNC_V2_PHASES.md` section 5e): +every Supabase-backed table the crew PWA caches in Dexie is covered by the +safety poll (the full `resync()`/`resyncV2()` covers all of them); every such +table must ALSO either have a broadcast trigger in the crew-sync trigger +migration (`supabase/migrations/*crew_sync_broadcast_triggers.sql` — low- +latency entities) or be explicitly listed in the `SAFETY_POLL_ONLY` allowlist +in `unit/guardrails/crew-sync-coverage.test.ts`. This is a union check, not +exclusive-or — a broadcast-triggered table is deliberately covered by both +mechanisms, the poll being the correctness backstop. A new cached table must +be added to `CREW_SYNCED_TABLES` or `LOCAL_ONLY_TABLES` in +`lib/dexie/schema.ts` in the same PR that adds it, and (if synced) placed in +either `TRIGGERED_TABLES` or `SAFETY_POLL_ONLY` in the guardrail test — +`crew-sync-coverage` fails CI otherwise. + ```typescript // Client components read from the local Dexie cache, not Supabase directly import { getDexieDb } from '@/lib/dexie/schema' diff --git a/docs/CREW_SYNC_V2_PHASES.md b/docs/CREW_SYNC_V2_PHASES.md index 3674648a..9c8c0b4e 100644 --- a/docs/CREW_SYNC_V2_PHASES.md +++ b/docs/CREW_SYNC_V2_PHASES.md @@ -36,7 +36,7 @@ anything missed. | 2 | Broadcast infrastructure (DB triggers + RLS on `realtime.messages`) | ✅ Done — migration `20260725191358_crew_sync_broadcast_triggers.sql`, merged via PR #508 (2026-07-26), applied to prod **and** e2e project. Deploys dark per design — no client subscribes yet | | 3 | Client cutover (single private broadcast channel, behind a flag) | ✅ Done — merged via PR #508 (2026-07-26), `lib/dexie/context.tsx`. Ships dormant: `NEXT_PUBLIC_CREW_SYNC_V2` defaults off (unset in `.env.example`) | | 4 | Outbox retry backoff | ✅ Done — merged via PR #508 (2026-07-26), `lib/dexie/syncService.ts`'s `computeNextAttemptAt()` | -| 5 | Rollout, acceptance test, old-code deletion, convention + guardrail | ⬜ **The only phase still open** — see section 5 below | +| 5 | Rollout, acceptance test, old-code deletion, convention + guardrail | 🟡 **In progress** — 5e (convention + `unit/guardrails/crew-sync-coverage.test.ts`) done 2026-07-29, `CREW_SYNCED_TABLES`/`LOCAL_ONLY_TABLES` added to `lib/dexie/schema.ts`. 5a-5d (Realtime quota check, flag flip, acceptance test, soak, deletion) still open — see section 5 below | ### Phase 1 artifacts you will build on (read these before touching code) @@ -677,7 +677,10 @@ tests cover the four behaviors above. - Supabase dashboard → Realtime settings: confirm the concurrent-clients quota comfortably covers the crew fleet (~1,500 was the discussed - target). + target). ✅ Checked 2026-07-29 (production project): Max concurrent + clients = 10,000, well above target. Database connection pool size (used + for private-channel RLS authorization on every join/reconnect — relevant + here since Phase 3 is all-private-channel) was 2, bumped to 15. - Set `NEXT_PUBLIC_CREW_SYNC_V2=true` in Vercel — **Preview environment first**, production only after the acceptance test passes. diff --git a/lib/dexie/schema.ts b/lib/dexie/schema.ts index 83836865..d41ce156 100644 --- a/lib/dexie/schema.ts +++ b/lib/dexie/schema.ts @@ -325,6 +325,31 @@ export class FieldStayDexie extends Dexie { } } +// ── Crew Sync v2 coverage (docs/CREW_SYNC_V2_PHASES.md section 5e) ───────── +// Every Supabase-backed table declared on FieldStayDexie above must appear +// here, mapped to the Supabase table it caches (identical to the Dexie name +// except crew_work_orders, which caches `work_orders`). Checked by +// unit/guardrails/crew-sync-coverage.test.ts against every `Table<...>` +// field on the class above — a newly added cached table fails CI until it's +// placed here (or in LOCAL_ONLY_TABLES below) AND covered by a broadcast +// trigger or the SAFETY_POLL_ONLY allowlist in that same test file. +export const CREW_SYNCED_TABLES: Readonly> = { + turnovers: 'turnovers', + checklist_instances: 'checklist_instances', + checklist_instance_items: 'checklist_instance_items', + inventory_items: 'inventory_items', + properties: 'properties', + crew_availability: 'crew_availability', + messages: 'messages', + crew_work_orders: 'work_orders', + property_assets: 'property_assets', +} + +// Dexie tables with no Supabase counterpart — pure local state (the +// mutation outbox, sync cursors/watermarks, the local photo-upload queue). +// Never subject to the crew-sync trigger/safety-poll coverage check above. +export const LOCAL_ONLY_TABLES = ['pending_photo_uploads', 'mutations', 'sync_meta'] as const + let db: FieldStayDexie | null = null let dbUserId: string | null = null diff --git a/unit/guardrails/crew-sync-coverage.test.ts b/unit/guardrails/crew-sync-coverage.test.ts new file mode 100644 index 00000000..c040975a --- /dev/null +++ b/unit/guardrails/crew-sync-coverage.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync } from 'fs' +import { join } from 'path' +import { CREW_SYNCED_TABLES, LOCAL_ONLY_TABLES } from '../../lib/dexie/schema' + +// Structural backstop for the Crew Sync v2 convention (CLAUDE.md, Dexie/crew +// section; docs/CREW_SYNC_V2_PHASES.md section 5e): every Supabase-backed +// table the crew PWA caches in Dexie is covered by the safety poll (the full +// resync()/resyncV2() always pulls every synced table) and must ALSO either +// have a broadcast trigger in the crew-sync trigger migration (low-latency +// entities) or be explicitly listed in SAFETY_POLL_ONLY below — a union +// check, not exclusive-or, since a triggered table is deliberately covered +// by both mechanisms. A new cached table fails this test until it's placed +// in lib/dexie/schema.ts's CREW_SYNCED_TABLES/LOCAL_ONLY_TABLES AND (if +// synced) classified below. + +const ROOT = join(__dirname, '..', '..') +const MIGRATIONS_DIR = join(ROOT, 'supabase', 'migrations') + +// Supabase tables covered by a broadcast trigger — see +// supabase/migrations/*crew_sync_broadcast_triggers.sql. turnover_assignments +// has no Dexie table of its own (assignment membership is folded into the +// turnovers scope pull), so it doesn't appear in CREW_SYNCED_TABLES even +// though it IS triggered — kept here anyway so the first test below still +// verifies the migration file actually contains that trigger. +const TRIGGERED_TABLES = [ + 'turnover_assignments', + 'turnovers', + 'checklist_instances', + 'checklist_instance_items', + 'work_orders', +] + +// Cached remote tables with NO broadcast trigger — freshness relies on the +// safety poll (≤5 min staleness): property_assets deliberately (low-churn, +// wide property→crew fan-out join — docs/CREW_SYNC_V2_PHASES.md section 1); +// inventory_items/properties are pulled inside the turnovers scope pull +// rather than having their own trigger; crew_availability/messages have no +// trigger at all. +const SAFETY_POLL_ONLY = ['property_assets', 'inventory_items', 'properties', 'crew_availability', 'messages'] + +function findBroadcastMigrationSql(): string { + const file = readdirSync(MIGRATIONS_DIR).find((f) => f.includes('crew_sync_broadcast')) + if (!file) { + throw new Error( + 'No supabase/migrations/*crew_sync_broadcast*.sql file found — did the ' + + 'Crew Sync v2 Phase 2 migration get renamed, moved, or deleted?' + ) + } + return readFileSync(join(MIGRATIONS_DIR, file), 'utf8') +} + +describe('crew-sync-coverage guardrail', () => { + it('every TRIGGERED_TABLES entry actually has a trigger in the broadcast migration', () => { + const sql = findBroadcastMigrationSql() + const missing = TRIGGERED_TABLES.filter((table) => !new RegExp(`ON public\\.${table}\\b`).test(sql)) + + expect(missing, [ + 'TRIGGERED_TABLES claims a broadcast trigger exists for these Supabase', + 'tables, but the migration file has none — the list is stale. Missing:', + ...missing, + ].join('\n')).toEqual([]) + }) + + it('every Supabase-backed Dexie table is covered by a trigger or SAFETY_POLL_ONLY', () => { + const uncovered = Object.entries(CREW_SYNCED_TABLES) + .filter(([, supabaseTable]) => !TRIGGERED_TABLES.includes(supabaseTable) && !SAFETY_POLL_ONLY.includes(supabaseTable)) + .map(([dexieTable, supabaseTable]) => `${dexieTable} (backs Supabase table "${supabaseTable}")`) + + expect(uncovered, [ + 'These CREW_SYNCED_TABLES entries (lib/dexie/schema.ts) are in neither', + 'TRIGGERED_TABLES nor SAFETY_POLL_ONLY in this file — classify them in', + 'one before merging:', + ...uncovered, + ].join('\n')).toEqual([]) + }) + + it('every table declared on FieldStayDexie is classified as synced or local-only', () => { + const schemaSource = readFileSync(join(ROOT, 'lib', 'dexie', 'schema.ts'), 'utf8') + const classStart = schemaSource.indexOf('class FieldStayDexie') + const classEnd = schemaSource.indexOf('constructor(userId: string)') + expect(classStart, 'could not find "class FieldStayDexie" in lib/dexie/schema.ts — has it been renamed?').toBeGreaterThan(-1) + expect(classEnd, 'could not find the FieldStayDexie constructor in lib/dexie/schema.ts').toBeGreaterThan(classStart) + + const classBody = schemaSource.slice(classStart, classEnd) + const declaredTables = [...classBody.matchAll(/^\s*(\w+)!:\s*Table m[1]!) + // Sanity check on the regex itself, not just the tables it finds — an + // empty match list means the extraction broke, not that there are no + // tables (there always are). + expect(declaredTables.length, 'regex matched zero Table<...> fields on FieldStayDexie — did the class field syntax change?').toBeGreaterThan(0) + + const known = new Set([...Object.keys(CREW_SYNCED_TABLES), ...LOCAL_ONLY_TABLES]) + const unclassified = declaredTables.filter((table) => !known.has(table)) + + expect(unclassified, [ + 'These Dexie tables are declared on FieldStayDexie but not classified', + 'in CREW_SYNCED_TABLES or LOCAL_ONLY_TABLES (lib/dexie/schema.ts) — a', + 'new cached table must be placed in one of those two sets in the same', + 'PR that adds it:', + ...unclassified, + ].join('\n')).toEqual([]) + }) +})