Architecture
July 19, 2026 · View on GitHub
How Jync moves bytes between an Obsidian vault and a JMAP FileNode backend.
The data model
A FileNode is an inode-like object: a name + parentId (folder hierarchy) plus a
blobId pointing at content (or null for a directory). File content is uploaded
separately as a blob (JMAP core, RFC 8620) and then linked to a node. Stalwart
also exposes an explicit nodeType ("file" | "directory") and allows in-place
blobId updates, so edits keep a stable node id.
vault/<syncRoot>/a/b.md <-> FileNode: name=b.md, parentId=<a>, blobId=<blob>
vault/<syncRoot>/a/ <-> FileNode: name=a, parentId=<root>, blobId=null
The sync loop
Each sync() runs two directions against a persisted SyncState
({ rootNodeId, changesState, folders{}, files{} }):
0. Reconcile (first run only)
When there is no changesState yet (a fresh device), sync() runs a full two-way
reconcile instead of an incremental pull: it lists the entire remote tree and, for
each remote file, compares content hashes with the local side —
- remote-only → download
- both present, identical → adopt (record state, no transfer)
- both present, differ → conflict copy (honor the conflict strategy)
- local-only → left for push to create
This is what makes onboarding a new device safe: an empty device downloads the vault, and a
device with a divergent copy keeps both versions rather than clobbering the server. After the
first run, changesState is set and subsequent syncs use the incremental pull below.
1. Pull (remote → local)
FileNode/changessincechangesStatereturns{created, updated, destroyed}id sets plus a new state token — a true incremental delta, no full re-scan.- Changed file nodes are downloaded and written locally; destroyed nodes are removed (only if local deletes are enabled).
2. Push (local → remote)
- Walk the sync-root subtree, SHA-256 each file, compare to
files[path].hash. - New → upload blob +
FileNode/set create. Changed → in-placeblobIdupdate. Missing locally →FileNode/set destroy. - After push, the pull baseline (
changesState) is advanced to the current server state, so the engine never re-pulls its own writes (no ping-pong).
Conflict handling
A file changed on both sides since the last sync is detected during pull (remote
hash ≠ stored hash and local hash ≠ stored hash). Jync writes the remote version to
a sibling … (remote conflict).md copy and keeps the local edit (which then pushes up).
No branch is ever silently overwritten.
Safety properties
- Scoped — only the configured sync-root subtree is ever touched.
- Local deletes gated — remote deletions do not remove local files unless explicitly enabled.
- Idempotent — a no-op sync is a no-op; re-attaching to a populated backend adopts
existing nodes (
alreadyExists→ overwrite) instead of erroring. - Single-flight — overlapping syncs are rejected, not interleaved.
Running inside browser Obsidian (ignis)
The plugin is browser-safe: it uses Obsidian's requestUrl (not fetch) and web-crypto
(not Node Buffer). Under ignis, requestUrl is
proxied server-side, so reaching a self-hosted backend requires allow-listing it past
ignis's SSRF guard. See CAPABILITIES.md for the ignis setup notes.
Two deployment shapes
- Community plugin — runs in the Obsidian client (desktop, mobile, or ignis browser).
- Server-side ignis plugin — a headless variant modeled on ignis's
headless-sync, syncing without an open tab. See the ignis section ofFINDINGS.md.