Sync & Collaboration
This area covers 35 features. Watch the walkthrough, then use the reference below — each feature links to the exact moment it appears (▶).
Who this is for: Any collaborator. Each feature below lists the role/permission it requires.
Features
Outbox Event Queue (CQRS) ▶ 00:00
As a translator, I want my edits to be reliably persisted even when network is unreliable or I have multiple projects open, so that no work is lost
How it works. When a user commits a cell edit, a CQRS event (cell.update, cell.create, etc.) is enqueued to IndexedDB outbox. Events persist across tab/browser close. Events are stamped with enqueuedAt, attempts count, lastAttemptAt, lastError, and status (pending/failed).
Key files
src/lib/sync/outbox.ts:1-130src/lib/sync/outbox.ts:132-200src/context/OutboxContext.tsx:1-40
Outbox Batch Flusher (5s interval) ▶ 00:00
As a system, I need to automatically drain the outbox queue to the sync-worker API, so that edits reach the backend without user intervention
How it works. Background loop flushes pending outbox records in batches (MAX_BATCH=100) to POST /events every 5s base interval. Uses Web Locks to ensure only one tab per origin runs the loop; falls back to setInterval if locks unavailable. Includes reentrancy guard to prevent overlapping flushes.
Key files
src/hooks/useOutboxFlusher.ts:1-100src/hooks/useOutboxFlusher.ts:100-202src/hooks/useOutboxFlusher.test.tsx:1-100
Exponential Backoff on Flush Failures ▶ 00:00
As a system, I want to reduce load on the auth service and sync-worker when flushes repeatedly fail, so that transient errors don't hammer the server
How it works. On consecutive flush failures (hard fail = posted > 0 AND accepted == 0, OR authError), backoffExp increments (capped at 8 but effective ceiling at 4 due to mult clamping). Delay = 5000ms * max(1, min(12, 2^backoffExp)), max 60s. Reset to 0 on success. flushNow() interrupt clears backoff immediately.
Key files
src/hooks/useOutboxFlusher.ts:130-137src/hooks/useOutboxFlusher.ts:174-188src/hooks/useOutboxFlusher.test.tsx:162-224
Outbox Auto-Requeue on Online Event ▶ 00:00
As a user, I want my queued edits to immediately retry when I regain network connectivity, so I don't have to manually sync or wait for the next interval
How it works. When browser fires 'online' event (navigator.onLine becomes true), hook calls requeueTransientlyFailedOutboxEvents() to revive records that failed due to transient errors (status 0 or 5xx), then calls flushNow() to drain immediately without backoff.
Key files
src/hooks/useOutboxFlusher.ts:204-215src/lib/sync/outbox.ts:406-445
Outbox Auto-Requeue on Auth Change ▶ 00:00
As a user who signs in or switches accounts, I want my queued edits to retry immediately with the new auth context, so previously un-mintable changes now succeed
How it works. When authEpoch prop changes (sign-in, re-auth, account switch), hook detects change via useEffect, calls requeueTransientlyFailedOutboxEvents() to revive transient failures, then flushNow().
Key files
src/hooks/useOutboxFlusher.ts:219-229
Online/Offline Detection (Status Indicator) ▶ 00:00
As a translator, I want to see whether my edits are syncing live or queued offline, so I understand the reliability of my current work
How it works. SyncStatusIndicator renders with status: 'live' (green), 'connecting' (amber/pulse), 'offline' (red), 'idle' (grey when tab hidden), 'disabled' (grey when no file open). Text labels and tooltips describe each state.
Key files
src/components/SyncStatusIndicator.tsx:1-75src/hooks/useFileSync.ts:74-97
Offline Banner ▶ 00:00
As a translator, I want an explicit notification when I go offline, so I'm aware my changes are queued and not live
How it works. Full-width banner appears at top of workspace when navigator.onLine becomes false. Banner disappears automatically (no dismiss button) when online event fires. Uses browser online/offline event listeners.
Key files
src/components/OfflineBanner.tsx:1-46
Outbox Inspector Popover ▶ 00:00
As a translator, I want to inspect pending/failed outbox records and retry them manually, so I can diagnose sync failures
How it works. Clicking the OutboxSyncIndicator chip (status bar) opens a popover showing: list of pending records with attempt count, last error reason, and status badges. Provides 'Retry now' button to reset backoff and force immediate flush. Shows 'All synced' when queue is empty.
Key files
src/components/OutboxSyncIndicator.tsx:1-93src/components/OutboxInspectorPopover.tsxsrc/context/OutboxContext.tsx:30-31
Outbox Record Quarantine (Permanent Failure) ▶ 00:00
As a system, I need to prevent a single broken record (403 no-permission) from head-of-line blocking the entire queue, so other files can continue draining
How it works. When a batch receives 403 (permanent auth failure) OR token-mint fails with 403, quarantineOutboxEvents() moves affected records to status='failed' immediately (no retry budget burn). Failed records are skipped by peekPendingOutboxBatch(). Record is preserved (not deleted) so inspector can show it and user can discard/resolve.
Key files
src/lib/sync/outbox.ts:305-352src/lib/sync/outbox-flush.ts:158-171src/lib/sync/outbox-flush.ts:217-231
Outbox Explicit Retry (Manual Requeue) ▶ 00:00
As a translator, I want to manually retry quarantined/failed records after fixing the root cause (re-auth, permission grant), so my work eventually succeeds
How it works. Inspector popover shows 'Retry' button per record. Clicking calls requeueOutboxEvents(ids) to reset attempts to 0, clear lastError, set status back to 'pending'. Caller should also call flushNow().
Key files
src/lib/sync/outbox.ts:362-394
Stale Sibling Dead-Lettering (Conflict Detection) ▶ 00:00
As a translator, I want to be notified when my edit was accepted but not applied to the projection (due to stale parent-chain), so I know to resolve the conflict
How it works. When POST /events response includes body.stale array, each entry carries { id, fileId, cellId }. flushOutboxBatch() logs to console, calls onStaleSiblings() callback. Records are accepted (deleted from outbox) but flagged. UI surfaces 'stale-sibling' banner with option to 'View in history' (deep-link to cell history drawer).
Key files
src/lib/sync/outbox-flush.ts:40-59src/lib/sync/outbox-flush.ts:271-279src/hooks/useOutboxFlusher.ts:111-118src/context/OutboxContext.tsx:32-34
Stale Source Pin Detection (Source Changed Hint) ▶ 00:00
As a translator, I want to know when the source text changed after I committed my last translation, so I can confirm my translation still matches the new source
How it works. When POST /events response includes body.staleSource array, each entry carries { id, currentSourceEventId }. flushOutboxBatch() calls onStaleSource() callback. UI tracks staleSourceCount and renders 'Source changed' banner suggesting user re-confirm translation.
Key files
src/lib/sync/outbox-flush.ts:49-52src/lib/sync/outbox-flush.ts:281-288src/hooks/useOutboxFlusher.ts:120-122src/context/OutboxContext.tsx:35
Cell Focus Lock (Soft Lease) ▶ 00:00
As a translator, I want to claim a soft lock on a cell while editing, so collaborators see I'm working on it and don't make conflicting edits
How it works. When user focuses a cell, useFocusLock.claim() sends focus.claim message over ProjectSync WS with leaseMs (default 30s). Server broadcasts lock.claimed to all connected clients. Lock is auto-renewed every half-period (15s) via focus.renew messages. Lock auto-expires on server disconnect or lease timeout (triggers lock.released broadcast).
Key files
src/hooks/useFocusLock.ts:1-69src/hooks/useFocusLock.ts:108-120src/lib/sync/ws-reconciler.ts:78-82
Cell Lock Release ▶ 00:00
As a system, I want to release a claimed cell lock when the user blurs or navigates away, so the cell becomes available to other collaborators
How it works. On cell blur or focusedCellId change, useFocusLock.release() clears claimedCellRef and sends focus.release message. Renewal timer is stopped. Server broadcasts lock.released. On component unmount, release() is called to prevent stranded leases.
Key files
src/hooks/useFocusLock.ts:122-150
Cell Lock Holder Display (Presence) ▶ 00:00
As a translator, I want to see who else is editing a specific cell, so I avoid conflicting edits
How it works. When another user holds the lock for a cell, their userId is displayed in a banner above the cell ('Alice is editing this cell'). Cell editor input is disabled. useFocusLock reports { isHeld, heldBy: { userId, ts } }. Banner clears when user releases or lock expires.
Key files
src/hooks/useFocusLock.ts:152-207src/lib/sync/cell-lock-state.ts:1-62src/components/EditorTable.tsx (lockHolderLabel usage)
RACE-5 Commit-Time Lock Guard ▶ 00:00
As a system, I need to prevent race conditions where a commit fires after lock.claimed arrives but before React re-renders with new state, so no one can bypass the lock
How it works. EditorTable maintains cellLockHoldersRef (live ref updated synchronously in WS onMessage). handleEditorCommit calls checkLockHolder(cellId) to read live lock state instead of relying on stale React prop. Lock check returns true/holder label if locked, preventing commit.
Key files
src/lib/sync/lock-commit-guard.test.ts:1-80src/components/EditorTable.tsx (checkLockHolder usage)
Presence Snapshot (Roster Reconciliation) ▶ 00:00
As a system, I need to reconcile the user roster after reconnecting to the WebSocket, so the client always has an accurate view of who is currently focused on which cell
How it works. On WS connect, server sends presence snapshot frame { t: 'presence', users: [{ userId, focusedCell?, ts }] }. useFocusLock feedFrame processes this to extract implicit lock.claimed for any other user's focusedCell. cellLockHoldersRef is updated via applyPresenceFrame().
Key files
src/hooks/useFocusLock.ts:187-204src/lib/sync/cell-lock-state.ts:23-34src/lib/sync/ws-reconciler.ts:42-54
Project-Scoped WebSocket (ProjectSync DO) ▶ 00:00
As a system, I need a persistent realtime channel for broadcasting events and presence updates across all collaborators on a project, so edits propagate without polling
How it works. ProjectWorkspace establishes WebSocket connection to /parties/project-sync/<projectId> on mount. Connection is authenticated with per-project sync-token. Receives event.applied, event.stale, presence, lock.claimed, lock.released frames. Can send outbox.event, focus.claim/renew/release messages.
Key files
src/lib/sync/ws-reconciler.ts:1-150src/components/ProjectWorkspace.tsx (WS setup)
Event Echo Suppression (Own-Write Detection) ▶ 00:00
As a system, I want to avoid redundant refetches when the server echoes back an event I just wrote, so I don't double-fetch and show stale data
How it works. When event.applied frame arrives with { by: currentUserId }, isOwnWriteEcho() returns true. The refetch handler skips redundant GET; the commit handler's targeted refetch already fires, so the echo's refetch would duplicate it.
Key files
src/lib/sync/ws-reconciler.ts:59-76
Comment Events (CQRS) ▶ 00:00
As a translator, I want to create, edit, resolve, and reply to comments on cells, so I can collaborate with reviewers and track issues
How it works. Comment actions (create, edit, delete, resolve) emit CQRS events (comment.create, comment.edit, comment.delete, comment.resolve) to the outbox. Events carry comment ID, text, resolved flag, thread parent, etc. Events are queued and flushed like cell edits.
Key files
src/lib/sync/outbox-types.ts (comment.* event kinds)
Concurrent Cell Edit (Real-time Propagation) ▶ 00:00
As a translator, I want to see another user's cell edits appear in my editor in real-time, so I'm always viewing the latest work
How it works. Alice commits a cell edit → enqueued to outbox → flushed to POST /events → sync-worker writes to D1 + broadcasts event.applied via ProjectSync DO WS → Bob's WS connection receives frame → useCells refetch triggered → D1 re-read → cell re-renders with new text.
Key files
e2e/specs/collab/concurrent-edit.smoke.spec.ts:1-84
File Propagation (Project-Wide Sync) ▶ 00:00
As a collaborator, I want to see a new file imported by another user appear in my project sidebar, so I don't have to manually refresh
How it works. Alice imports a file → file.create event emitted → propagated via outbox/DO → Bob's WS receives event.applied → useCells (or file list) refetch triggered → new file appears in sidebar.
Key files
e2e/specs/collab/file-propagation.smoke.spec.ts
Token Minting (Per-File/Project Scope) ▶ 00:00
As a system, I need to mint fresh sync-tokens scoped to the specific file/project an event belongs to, so permission checks work correctly and a user can't access events they shouldn't
How it works. buildProjectAwareMinter creates a minter function that, given projectId + fileId, fetches a sync-token scoped to that exact project/file. Used by flushOutboxBatch to mint before POST /events. If mint returns null with status 403, the batch is quarantined; other statuses are transient.
Key files
src/lib/sync/cqrs-bridge.ts (buildProjectAwareMinter)src/lib/sync/outbox-flush.ts:147-184
Fetch Timeout (15s Hard Limit) ▶ 00:00
As a system, I want POST /events to time out after 15s, so a hung connection doesn't strand the flusher indefinitely
How it works. flushOutboxBatch uses AbortSignal.timeout(15_000) (feature-detected; older WebKit returns undefined) to cancel slow requests. AbortError is caught and treated as transient (no budget burn).
Key files
src/lib/sync/outbox-flush.ts:189-213src/lib/sync/fetch-timeout.ts
Sync Status Indicator (Workspace Bar) ▶ 00:00
As a translator, I want a persistent indicator in the workspace showing live/connecting/offline status, so I know if my edits are syncing
How it works. SyncStatusIndicator renders in the workspace status bar showing sync status dot (green/amber/red/grey) with label and tooltip. Status updates based on fileSync connection state and network availability.
Key files
src/components/SyncStatusIndicator.tsx:1-75e2e/specs/editor/sync-status-indicator.smoke.spec.ts:1-48
Outbox Pending Count (Real-time Display) ▶ 00:00
As a translator, I want to see how many pending changes are in my outbox, so I know how much work needs to sync
How it works. useOutboxFlusher exposes pendingCount (total records in outbox, pending + failed). Subscribed to outbox listener for reactive updates without polling. OutboxSyncIndicator displays 'Queued N' or 'N failed' based on tone.
Key files
src/hooks/useOutboxFlusher.ts:56-97src/lib/sync/outbox.ts:53-96
Outbox Subscriber Pattern ▶ 00:00
As a system, I want to efficiently notify multiple consumers when the outbox contents change, so UI stays in sync without polling
How it works. subscribeToOutbox() registers a callback that fires whenever enqueueOutboxEvent or removeOutboxEvents is called (notifyOutboxChanged). Multiple subscribers coexist. Allows overlay hooks and the inspector to refresh without polling IDB.
Key files
src/lib/sync/outbox.ts:53-71
Attempt Tracking (Retry Budget) ▶ 00:00
As a system, I want to track how many times we've tried to POST each event, so we don't retry failed events infinitely
How it works. OutboxRecord tracks attempts (count) and lastAttemptAt (timestamp). markOutboxAttempt increments attempts and stamps lastError. When attempts >= OUTBOX_MAX_ATTEMPTS (5), record is moved to status='failed'.
Key files
src/lib/sync/outbox.ts:26-44src/lib/sync/outbox.ts:159-200
Error Stamping (Non-Budget-Burning) ▶ 00:00
As a system, I want to surface the reason a record is stuck (e.g., 'Sync paused: sign in to retry') without burning the retry budget, so token-mint failures don't wedge the queue
How it works. stampOutboxError() sets lastError and lastAttemptAt WITHOUT incrementing attempts. Used for 401 (stale JWT, self-heals on re-auth), 5xx (transient), 0 (network). On re-auth or reconnect, requeueTransientlyFailedOutboxEvents() revives these records.
Key files
src/lib/sync/outbox.ts:213-249src/lib/sync/outbox-flush.ts:173-184src/lib/sync/outbox-flush.ts:244-249
Validation Survey (F5 Notifications) ▶ 00:00
As a translator, I want to know when the source text changed after I last validated a cell, so I can re-validate if needed
How it works. StaleSourceIndicator component renders a small warning badge (amber triangle) when cell's sourceEventId is stale. Tooltip: 'The source has changed since this translation was last revised.' Standalone or parent-managed modes.
Key files
src/components/StaleSourceIndicator.tsx:1-126src/hooks/useStaleSourceCells.ts
Outbox Log Entries (Debug / Inspector) ▶ 00:00
As a developer or power user, I want to see detailed info about each pending/failed outbox record in the inspector, so I can diagnose sync issues
How it works. usePendingOutboxRecords hook fetches all outbox records from IDB. Inspector popover displays list with: record ID, kind, enqueuedAt, attempts, lastError (status + reason), status badge. Each record has action buttons (Retry, Discard, View details).
Key files
src/hooks/usePendingOutboxRecords.tssrc/components/OutboxInspectorPopover.tsx
Session Expiry + Re-Auth Trigger ▶ 00:00
As a system, I want to log out the user and redirect to login when their JWT is rejected, so expired sessions don't produce a cascade of 401s
How it works. When token-mint returns 401 (stale JWT on /sync-token endpoint), OutboxProvider's onUnauthorized callback fires, calling logout() and navigate('/'). User is logged out and redirected.
Key files
src/context/OutboxContext.tsx:54-62
Cross-Project Outbox (Single Queue) ▶ 00:00
As a translator working across multiple projects, I want my edits in all projects to be queued to a single outbox and drained together, so I don't have to manage separate sync states
How it works. Outbox is global (not per-file or per-project). Events from any project/file are enqueued to the same IndexedDB store. flusher batches by file (groupOldestFileFirst) but all files drain from the same queue. Minter scopes token to each event's projectId.
Key files
src/context/OutboxContext.tsx:14-19src/lib/sync/outbox-flush.ts:78-101
App-Shell Outbox Flusher (Global Drain) ▶ 00:00
As a translator on the org dashboard (no project open), I want my queued edits to continue syncing in the background, so I don't lose work by navigating away from a project
How it works. OutboxProvider is mounted at app-shell level (above all routes), independent of which route is active. Flusher runs whenever session exists, draining regardless of current navigation (project/org dashboard/etc.).
Key files
src/context/OutboxContext.tsx:40-114src/hooks/useOutboxFlusher.ts:89-97
Comment Project-Scoping (Sentinel Token) ▶ 00:00
As a system, I need to scope comment events (which have no fileId) to their project for token minting, so permission checks work correctly
How it works. When flushOutboxBatch encounters a comment.* event with no fileId, it uses sentinel fileId '__project__' for token minting (project-scoped token). Server's sync-worker accepts comment auth scoped to projectId only, not fileId.
Key files
src/lib/sync/outbox-flush.ts:130-151