Sync

Purpose

Sync ensures correct multi-device synchronization. It consumes NodeChanged events from the event bus, projects them into a per-tenant monotonic change journal, and serves delta pulls and conflict records to devices.

Sync deliberately has a different consistency model (causal / cursor-based) and a different scaling profile (many long-lived streaming / long-poll connections) from the rest of the control plane.

Data owned

Table Purpose
change_journal Monotonic sequence of node changes per tenant (CHANGE.seq is the spine)
device_cursors Each device’s last-seen seq; the bookmark for delta pulls
conflicts Conflict records: conflicted copy references, resolution state

Internal API

Sync.* gRPC methods:

Method Description
Sync.RegisterDevice Register a new device; return initial cursor
Sync.PullDeltas Fetch CHANGE rows WHERE seq > cursor ORDER BY seq; return new cursor
Sync.Push Push a local change; triggers compare-and-commit in Files
Sync.AcknowledgeCursor Persist the device’s cursor after successful application
Sync.ListConflicts Return unresolved conflict records for the device

Change journal

CHANGE.seq is a per-tenant monotonic counter (ADR-0024):

Three-tree sync reconciliation

ADR-0022 defines the merge algorithm used when a device pushes a change:

Tree Meaning
Base Last known common ancestor version (the version the device last synced)
Local The device’s current version (edited offline)
Remote The server’s current version

Conflict resolution

ADR-0026 / ADR-0008: a stale base version never silently overwrites a concurrent server change. Instead, a conflicted copy is created: both histories survive, and the user reconciles manually.

sequenceDiagram
    autonumber
    participant D as Device
    participant GW as API Gateway
    participant SY as Sync
    participant F as File & Metadata

    Note over SY: Sync projects NodeChanged events<br/>into per-tenant change journal
    D->>GW: GET /v1/sync/changes?cursor=N
    GW->>SY: PullDeltas(cursor=N)
    SY-->>GW: changes[N+1..M] + new cursor M
    GW-->>D: deltas (created/updated/moved/deleted)

    loop for each changed file
        D->>GW: GET content URL (download flow)
    end

    Note over D: local edit while offline
    D->>GW: POST /v1/sync/push (node, baseVersion, hash)
    GW->>SY: Push(node, baseVersion)
    SY->>F: CompareAndCommit(baseVersion)
    alt baseVersion is current
        F-->>SY: committed new version
        SY-->>D: ok (no conflict)
    else baseVersion stale (concurrent change)
        F-->>SY: conflict (current ≠ base)
        SY->>F: CreateConflictedCopy(node, device, ts)
        SY-->>D: conflict resolved as copy (both versions kept)
    end

Safety guards

ADR-0027: the sync layer enforces additional invariants:

Local sync database

ADR-0023: each sync client maintains a SQLite database on device containing:

This enables fully offline operation; the device reconciles with the server when reconnected.

File watching

ADR-0025: the desktop sync client watches the local file system for changes:

Changes are debounced (coalesced over a short window) before being queued for push, to avoid a flood of individual write events from editors that save incrementally.

:::note First extraction target: Sync has a distinct consistency model (cursor / causal) and a distinct scaling profile — devices maintain long-poll or streaming connections, unlike the stateless REST/gRPC requests served by the rest of the control plane. It is the first module likely to be extracted into a standalone service as load grows. :::