Skip to content

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.

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
  • Writesrc/lib/offline/sync.ts applies the change to the Dexie cache, shows it immediately, enqueues an outbox entry 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 ?since delta read per surface and folds the result into Dexie through the pure reconcilers in src/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.

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

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"]
  • 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.body is string-only), so the bytes are held in a separate Dexie pendingBlobs store referenced by an attachment.create entry; 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.