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
27 changes: 26 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,33 @@ almost the whole crew surface):
`lib/dexie/prune.ts` — `messages` grew forever at 500 rows a pull. And every
member of the `MutationTable` union must have a retry affordance in
`app/crew/_components/failed-sync-banner.tsx`: a mutation that dead-letters
where no crew member can see it is work silently thrown away. Enforced by
where no crew member can see it is work silently thrown away. BOTH outboxes
(`mutations` and `pending_photo_uploads`) need a dead-letter query AND a
stalled-queue query there — a transport failure never sets `failed`, so the
stalled surface is its only visible one. Enforced by
`unit/guardrails/crew-dead-letter-coverage.test.ts`.
- **The optimistic local write and its outbox row commit in ONE Dexie
transaction.** Use `writeAndQueue()`/`enqueueMutationTx()` (`lib/dexie/
helpers.ts`, `lib/dexie/syncService.ts`) — never a bare `table.update()`
followed by a separate `enqueueMutation()`. As two transactions, a PWA
reclaimed between them left the cache updated with nothing queued to send
it, and no delta pull corrects that because the server row's `updated_at`
never changed. Nothing async-external may go inside the block: an IDB
transaction auto-commits the moment an await leaves it, so the
`processOutbox()` kick stays outside.
- **Abandoning a queued mutation rewinds the cursor that was masking the
server row.** `discardFailedMutation()` and `pruneExpiredDeadLetters()` call
`invalidateCursorsFor()`. While a mutation is queued, `shadow.ts` replays it
over every pull AND `advanceCursor()` moves past the server row it masks —
drop it without rewinding and the delta filter skips that row forever.
`forceFullCrewResync()` is the whole-cache version, for a device that has
already diverged.
- **`failed` is `0 | 1`, never a boolean** (`DeadLetterFlag`). IndexedDB has no
boolean key type, so a boolean `failed` is silently absent from its index and
every dead-letter query degrades to a full scan — three of which are
`useLiveQuery`s live on every crew screen, over a table written on every
checklist tick. Truthiness checks (`!m.failed`) are unaffected; only literal
`true`/`false` writes.

**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
Expand Down
28 changes: 23 additions & 5 deletions app/crew/_components/failed-sync-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,17 @@ export function FailedSyncBanner({ userId }: Readonly<{ userId: string }>) {
const [retrying, setRetrying] = useState(false)
const [confirmDiscard, setConfirmDiscard] = useState<FailedEntry | null>(null)

// Index-backed (`failed` is stored 0/1 — IndexedDB cannot index a boolean).
// These are live queries on tables that are written on every checklist tick
// and every drain step, so as `.filter()` full scans they re-deserialized
// the whole outbox, three times, on each of those writes.
const failedMutations = useLiveQuery(
() => db.mutations.filter((m) => !!m.failed).toArray(),
() => db.mutations.where('failed').equals(1).toArray(),
[],
) ?? []

const failedPhotos = useLiveQuery(
() => db.pending_photo_uploads.filter((p) => !!p.failed).toArray(),
() => db.pending_photo_uploads.where('failed').equals(1).toArray(),
[],
) ?? []

Expand All @@ -83,6 +87,20 @@ export function FailedSyncBanner({ userId }: Readonly<{ userId: string }>) {
[],
) ?? []

// Photos stall the same way and were covered by NEITHER surface: a transport
// failure never sets `failed` (by design — a bad signal must not destroy
// crew work), so they fell out of failedPhotos above, and the stalled notice
// only ever looked at db.mutations. A whole shift of verification photos
// could retry forever against a captive portal with nothing on screen.
const stalledPhotos = useLiveQuery(
() => db.pending_photo_uploads
.filter((p) => !p.failed && (p.network_retry_count ?? 0) >= STALLED_NETWORK_ATTEMPTS)
.toArray(),
[],
) ?? []

const stalledCount = stalledMutations.length + stalledPhotos.length

const entries: FailedEntry[] = [
...failedMutations.map((m) => ({
key: `mutation-${m.id}`,
Expand All @@ -98,12 +116,12 @@ export function FailedSyncBanner({ userId }: Readonly<{ userId: string }>) {
})),
]

if (entries.length === 0 && stalledMutations.length === 0) return null
if (entries.length === 0 && stalledCount === 0) return null

// A stalled queue is NOT a failure — the work is intact and still retrying,
// so it gets its own amber notice with no discard affordance rather than
// being folded into the red "didn't sync" list.
const stalledNotice = stalledMutations.length > 0 && (
const stalledNotice = stalledCount > 0 && (
<div
className="mx-4 mt-3 rounded-xl p-4"
style={{ background: 'var(--accent-amber-dim)', border: '1px solid var(--accent-amber-dim)' }}
Expand All @@ -113,7 +131,7 @@ export function FailedSyncBanner({ userId }: Readonly<{ userId: string }>) {
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" style={{ color: 'var(--accent-amber)' }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-bold" style={{ color: 'var(--accent-amber)' }}>
{stalledMutations.length} change{stalledMutations.length !== 1 ? 's' : ''} still trying to sync
{stalledCount} change{stalledCount !== 1 ? 's' : ''} still trying to sync
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
Your work is saved on this phone and will keep retrying on its own.
Expand Down
17 changes: 16 additions & 1 deletion app/crew/crew-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CalendarCheck, CalendarDays, MessageSquare, LogOut, Bell, X, HelpCircle
import { useLiveQuery } from 'dexie-react-hooks'
import { DexieProvider, useDexieDb } from '@/lib/dexie/context'
import { CrewContext } from '@/lib/crew/crew-context'
import { closeDexieDb, markDexieShutdown, resumeDexieDb } from '@/lib/dexie/schema'
import { closeDexieDb, listenForRemoteShutdown, markDexieShutdown, resumeDexieDb } from '@/lib/dexie/schema'
import { getSyncEngine, disposeSyncEngine } from '@/lib/dexie/syncService'
import { processPendingPhotoUploads } from '@/lib/dexie/photo-sync'
import { countPendingSyncWork } from '@/lib/dexie/prune'
Expand Down Expand Up @@ -236,6 +236,21 @@ export function CrewShell({
}
}

// Logging out in ANOTHER tab has to end this one too. IndexedDB is a
// per-origin resource but the shutdown latch is per-document module state,
// so without this a sibling tab kept draining, kept re-creating the database
// the logging-out tab had just deleted, and kept rendering the signed-out
// crew member's assignments — and its open connection blocked that delete
// outright, which is what left logout hanging with the user still signed in.
useEffect(() => {
if (!userId) return
return listenForRemoteShutdown(userId, () => {
setSignedOut(true)
disposeSyncEngine()
startTransition(() => router.push('/login?next=/crew'))
})
}, [userId, router])

useEffect(() => {
if (!userId || signedOut) return
const supabase = createClient()
Expand Down
16 changes: 12 additions & 4 deletions app/crew/turnovers/[id]/use-turnover-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,15 @@ export function useTurnoverActions(id: string) {

const ext = file.name.split('.').pop() ?? 'jpg'
const slug = sectionName.replace(/\s+/g, '-').toLowerCase()
const path = orgScopedStoragePath(orgId, `turnover-${id}`, `section-${slug}-${Date.now()}.${ext}`)
const blobKey = `photo-section-${sectionName}-${Date.now()}`
// Both keys are UUID-suffixed, not Date.now()-suffixed. Two captures
// landing in the same millisecond (a double-tap, a retry) produced
// identical keys: the second savePendingPhotoBlob() overwrote the first
// blob, both queue rows referenced it, and the first row's
// discardPendingPhoto() deleted it out from under the second — which then
// hit the missing-blob branch. The tracking row's own id has always used
// crypto.randomUUID(); these two just predated the convention.
const path = orgScopedStoragePath(orgId, `turnover-${id}`, `section-${slug}-${crypto.randomUUID()}.${ext}`)
const blobKey = `photo-section-${crypto.randomUUID()}`

try {
const sectionItem = items?.find((i) => i.section_name === sectionName)
Expand Down Expand Up @@ -236,8 +243,9 @@ export function useTurnoverActions(id: string) {
}
try {
const ext = file.name.split('.').pop() ?? 'jpg'
const path = orgScopedStoragePath(orgId, `turnover-${id}`, `${itemId}-${Date.now()}.${ext}`)
const blobKey = `photo-${itemId}-${Date.now()}`
// UUID-suffixed, not Date.now() — see handleSectionPhoto above.
const path = orgScopedStoragePath(orgId, `turnover-${id}`, `${itemId}-${crypto.randomUUID()}.${ext}`)
const blobKey = `photo-${crypto.randomUUID()}`

const compressed = await compressPhotoForQueue(file)
await savePendingPhotoBlob(userId, blobKey, compressed)
Expand Down
43 changes: 35 additions & 8 deletions lib/dexie/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,41 @@ export function DexieProvider({ userId: userIdProp, children }: { userId?: strin
await refreshAssetsSubscription()
}

function resyncSafe(crewMemberId: string): void {
// ── Resync coalescing ─────────────────────────────────────────────────
//
// A phone waking in a parking lot fires `online` and `visibilitychange →
// visible` within the same second, and the 5-minute safety poll can land
// in the same window; on the v1 path a turnover_assignments event can too.
// Each of those used to start its own fullCrewResync with nothing
// serializing them, so three concurrent passes ran on the worst possible
// connection — tripling the query volume, racing advanceCursor()'s
// read-modify-write, and letting one pass's pruneLocalCache() bulkDelete
// from a snapshot another was still mutating (visible as flicker and
// transient empty states).
//
// One in flight, at most one queued follow-up — the same shape
// createSyncSignalHandler() already uses per entity.
let resyncInFlight: Promise<void> | null = null
let resyncQueued = false

function runCoalesced(run: () => Promise<void>, label: string): void {
if (cancelled) return
void resync(crewMemberId).catch((err) =>
console.error('[DexieProvider] resync failed:', err)
)
if (resyncInFlight) {
resyncQueued = true
return
}
resyncInFlight = run()
.catch((err) => console.error(`[DexieProvider] ${label} failed:`, err))
.finally(() => {
resyncInFlight = null
if (cancelled || !resyncQueued) return
resyncQueued = false
runCoalesced(run, label)
})
}

function resyncSafe(crewMemberId: string): void {
runCoalesced(() => resync(crewMemberId), 'resync')
}

// Installed on BOTH paths (see SAFETY_POLL_INTERVAL_MS above) — the
Expand All @@ -312,10 +342,7 @@ export function DexieProvider({ userId: userIdProp, children }: { userId?: strin
}

function resyncV2Safe(crewMemberId: string): void {
if (cancelled) return
void resyncV2(crewMemberId).catch((err) =>
console.error('[DexieProvider] v2 resync failed:', err)
)
runCoalesced(() => resyncV2(crewMemberId), 'v2 resync')
}

// Retry helper for scheduleV2Reconnect's timer callback — kept as its
Expand Down
6 changes: 4 additions & 2 deletions lib/dexie/demo-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export async function checkDemoOfflineReadiness(
workOrders,
assets,
] = await Promise.all([
db.mutations.filter((m) => m.failed !== true).count(),
db.mutations.filter((m) => m.failed === true).count(),
db.mutations.filter((m) => !m.failed).count(),
// `failed` is 0/1, not a boolean (IndexedDB can't index booleans) — so this
// is an index-backed count rather than a full scan of the outbox.
db.mutations.where('failed').equals(1).count(),
db.turnovers.count(),
db.properties.count(),
db.checklist_instances.count(),
Expand Down
Loading
Loading