Sync & Real-Time Architecture
Sync is the heart of Tasuku’s offline-first design. The rule never changes: D1 is the single source of truth and the client is a cache plus an outbox (constitution §7.14). Everything below is about when the client reads, never about giving the client a second source of truth. The read/reconcile code is identical whether a refresh is triggered by a WebSocket signal or by the polling fallback — only the trigger differs.
The two halves: write and read
Section titled “The two halves: write and read”A mutation and its propagation are two independent flows joined only by D1:
flowchart LR
subgraph write["Write path (this device)"]
direction TB
M["optimistic mutation<br/>src/lib/offline/sync.ts"]
OB[("outbox<br/>Dexie")]
RP["replay when online<br/>+ Idempotency-Key"]
M --> OB --> RP
end
API["Worker API"]
D1[("D1")]
subgraph read["Read path (every device)"]
direction TB
SIG["change signal / poll tick"]
DR["delta read ?since"]
RC["reconcile into Dexie<br/>sync-core.ts (pure)"]
SIG --> DR --> RC
end
RP --> API --> D1
API -. "fan-out signal (waitUntil)" .-> SIG
DR --> API
- Write —
src/lib/offline/sync.tsapplies the change to the Dexie cache, shows it immediately, enqueues anoutboxentry with a stable Idempotency-Key, and replays to the Worker when online. See Offline Outbox & Optimistic Writes. - Read — on any trigger the client runs a
?sincedelta read per surface and folds the result into Dexie through the pure reconcilers insrc/lib/offline/sync-core.ts. See Sync-Core Reconcilers.
Transport: signal-only push, polling fallback
Section titled “Transport: signal-only push, polling fallback”Real-time propagation started as periodic polling (ADR-006) and was upgraded to signal-only WebSocket push over the per-user SyncInbox Durable Object (ADR-011). The socket carries only a tiny signal (“surface X changed”) — never row data — so the upgrade replaced the polling timer without touching the delta-read data path.
sequenceDiagram
autonumber
participant Writer as Writer (device A)
participant API as Worker API
participant D1 as D1
participant DO as SyncInbox DO (per user)
participant A2 as Device A — tab 2
participant B as Device B
Writer->>API: replay mutation (+ Idempotency-Key)
API->>D1: authoritative write
API-->>Writer: 200 (ack)
Note over API,DO: off the request path (waitUntil)
API->>API: affectedUsers() = owner ∪ active members
API-)DO: notify(SyncSignal{surface})
DO-)A2: SyncSignal
DO-)B: SyncSignal
A2->>API: delta read ?since (only changed surface)
B->>API: delta read ?since
API-->>A2: changed rows
API-->>B: changed rows
Note over A2,B: reconcile into Dexie (LWW per field)
The writer suppresses the echo of its own change (noteLocalMutation / takeUnsuppressedTags in src/lib/offline/live.ts) so it does not redundantly re-read what it just wrote.
Connection lifecycle
Section titled “Connection lifecycle”createLiveConnection() (src/lib/offline/live.ts) owns the socket; PollController (src/lib/offline/poll.ts) is the fallback. Exactly one transport drives refreshes at a time — when the socket is Live, the fast poll is suspended; when it drops, polling resumes and every reconnect performs a catch-up ?since read, so convergence holds regardless of socket health.
stateDiagram-v2
[*] --> Offline
Offline --> Connecting: online + foreground + signed-in
Connecting --> Live: socket open + catch-up read
Live --> ReconnectingFallback: socket closed / error
ReconnectingFallback --> Connecting: backoff + jitter (cap 30s)
Live --> Suspended: tab hidden / offline
ReconnectingFallback --> Suspended: tab hidden / offline
Suspended --> Connecting: foreground + online
Suspended --> Offline: signed out
note right of Live
poll suspended;
signals drive reads
end note
note right of ReconnectingFallback
PollController active
(POLL_INTERVAL_MS = 10s)
end note
Coalescing a burst of signals
Section titled “Coalescing a burst of signals”A single user action can affect several surfaces, and several actions can land in a short window. createSignalCoalescer(...) debounces signals per surface (~500 ms) so a burst collapses into one delta read per surface, and mapSurfaceTags(...) resolves which surfaces to refresh.
flowchart TD S["incoming SyncSignal(tags)"] --> SUP["takeUnsuppressedTags()<br/>drop own writes"] SUP --> MAP["mapSurfaceTags(tags, active)"] MAP --> CO["createSignalCoalescer<br/>debounce ~500ms per surface"] CO --> DR["one delta read ?since per surface"] DR --> RC["reconcile (pure) into Dexie"] RC --> UI["liveQuery → islands re-render"]
Why this stays offline-first
Section titled “Why this stays offline-first”- The socket and the poll are interchangeable triggers over one pure read path — neither is a source of truth.
- While offline, reads serve from Dexie and writes queue in the outbox; on reconnect the outbox replays (idempotently) and a catch-up read reconciles anything missed.
- The Durable Object holds ephemeral connection state only — no business data lives there (constitution §7.10).
Fan-out scope is privacy-shaped. A write signals only the users who may see it. Shared task-children (steps, attachments) fan out to the Task owner ∪ active members (
me-attachments); per-user private tags (spec 036) fan out to the owner’s own connections only (me-tags) — a member is never told about another member’s tags, which keeps the privacy boundary intact even at the signal layer.
Binary attachments on the same model (spec 037). File uploads ride the existing outbox, but a blob can’t sit in the outbox entry (
OutboxEntry.bodyis string-only), so the bytes are held in a separate DexiependingBlobsstore referenced by anattachment.createentry; the optimistic tile shows “pending upload” until the entry drains, then the blob is dropped. Attachment metadata syncs as an ordinary shared task-child surface (me-attachments, fanned out to owner ∪ members like steps). Downloads are online-only — bytes are streamed from R2 through the Worker on demand and never cached in Dexie or the service worker.
Related modules: LiveConnection WS Client, Poll Controller, SyncInbox Durable Object, Sync-Core Reconcilers, DB Schema & Notify-Affected.