Skip to content

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).

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox.ts:1-130
src/lib/sync/outbox.ts:132-200
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useOutboxFlusher.ts:1-100
src/hooks/useOutboxFlusher.ts:100-202
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useOutboxFlusher.ts:130-137
src/hooks/useOutboxFlusher.ts:174-188
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useOutboxFlusher.ts:204-215
src/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().

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/components/SyncStatusIndicator.tsx:1-75
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/components/OutboxSyncIndicator.tsx:1-93
src/components/OutboxInspectorPopover.tsx
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox.ts:305-352
src/lib/sync/outbox-flush.ts:158-171
src/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().

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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).

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox-flush.ts:40-59
src/lib/sync/outbox-flush.ts:271-279
src/hooks/useOutboxFlusher.ts:111-118
src/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.

WhoProject lead (source steward) PermissionsSource content edits: project_lead(500)
Key files

src/lib/sync/outbox-flush.ts:49-52
src/lib/sync/outbox-flush.ts:281-288
src/hooks/useOutboxFlusher.ts:120-122
src/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).

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useFocusLock.ts:1-69
src/hooks/useFocusLock.ts:108-120
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useFocusLock.ts:152-207
src/lib/sync/cell-lock-state.ts:1-62
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/lock-commit-guard.test.ts:1-80
src/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().

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useFocusLock.ts:187-204
src/lib/sync/cell-lock-state.ts:23-34
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/ws-reconciler.ts:1-150
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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).

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox-flush.ts:189-213
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/components/SyncStatusIndicator.tsx:1-75
e2e/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/useOutboxFlusher.ts:56-97
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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'.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox.ts:26-44
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox.ts:213-249
src/lib/sync/outbox-flush.ts:173-184
src/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.

WhoReviewer / translation consultant PermissionsValidate/unvalidate: reviewer(300)
Key files

src/components/StaleSourceIndicator.tsx:1-126
src/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).

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/hooks/usePendingOutboxRecords.ts
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/context/OutboxContext.tsx:14-19
src/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.).

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/context/OutboxContext.tsx:40-114
src/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.

WhoAny collaborator PermissionsPresence/focus-locks: any member; each write re-validated server-side against the per-event role floor
Key files

src/lib/sync/outbox-flush.ts:130-151