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):
- Each
NodeChangedevent appended to the journal increments the counter for that tenant. - Delta pull:
WHERE tenant_id = ? AND seq > :cursor ORDER BY seq LIMIT :batch. - Devices store their last-seen
seqasdevice_cursors.sync_cursor. - The journal is the projection target of
NodeChangedevents — it is derived, but treated as durable (rebuilt from events if needed).
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 |
- If remote == base: fast-forward — no conflict, local change wins.
- If local == base: fast-forward — remote change already applied, nothing to push.
- If remote ≠ base and local ≠ base: conflict — both sides diverged from the common ancestor.
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:
- Cycle detection: a move that would make a folder its own ancestor is rejected.
- Concurrent push detection: two simultaneous pushes for the same node are serialized; the second sees the updated base.
- Sequence monotonicity:
seqis strictly increasing per tenant; gaps are not permitted in the journal.
Local sync database
ADR-0023: each sync client maintains a SQLite database on device containing:
- The local file state (node IDs, version hashes, cursor).
- An offline queue of pending changes to push when connectivity is restored.
- Conflict records awaiting user resolution.
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:
- Linux: inotify
- macOS: FSEvents
- Windows: ReadDirectoryChangesW
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. :::