Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 5 additions & 2 deletions docs/CREW_SYNC_V2_PHASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions lib/dexie/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
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

Expand Down
103 changes: 103 additions & 0 deletions unit/guardrails/crew-sync-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -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</gm)].map((m) => 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<string>([...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([])
})
})
Loading