Syncline Protocol Documentation
Syncline uses a custom binary protocol over WebSockets for bidirectional real-time synchronization. It relies on the Yjs framework (and its Rust port, yrs) to handle Conflict-free Replicated Data Types (CRDTs).
This document outlines everything necessary to implement a client that communicates with the Syncline server.
Connection
The client must connect to the server via WebSocket:
ws://<server-host>:<port>/sync
The WebSocket must be configured to process binary data. In Javascript/Typescript, this means setting:
websocket.binaryType = "arraybuffer";
Binary Message Format
Every message exchanged between the client and the server has the following binary structure:
| Field | Size | Details |
|---|---|---|
msg_type |
1 byte | The type of the message (see below). |
doc_id_len |
2 bytes | The length of the doc_id string, encoded as an unsigned 16-bit big-endian integer. |
doc_id |
doc_id_len bytes |
The Document ID encoded as a UTF-8 string. |
payload |
Remaining bytes | The message payload, typically a Yjs standard v1 encoded State Vector or Update. |
Message Types
There are five types of messages defined in the protocol:
MSG_SYNC_STEP_1(0x00)- Direction: Client ➞ Server
- Purpose: Sent by the client to request missing updates from the server and subscribe to future updates for this document.
- Payload: The client's local Yjs Document encoded as a State Vector (
Y.encodeStateVector(doc)). -
Side effect: The server creates a broadcast channel for this
doc_id(if one does not already exist) and subscribes the client to it. If thedoc_idis not"__index__", the server also registers it in the__index__document. -
MSG_SYNC_STEP_2(0x01) - Direction: Server ➞ Client
- Purpose: Sent by the server in response to
MSG_SYNC_STEP_1, containing any updates the client is missing. -
Payload: A Yjs Document update (
Y.encodeStateAsUpdate(doc, stateVector)equivalent). -
MSG_UPDATE(0x02) - Direction: Bidirectional (Client ➞ Server, Server ➞ Client)
- Purpose: Disseminates newly applied changes to the document.
- Payload: A Yjs Document update.
-
Server behavior: When the server receives a
MSG_UPDATE, it (1) registers thedoc_idin__index__if new, (2) persists the update to the database, and (3) broadcasts the update to all subscribers of the document's broadcast channel except the sender. -
MSG_BLOB_UPDATE(0x04) - Direction: Bidirectional (Client ➞ Server, Server ➞ Client)
- Purpose: Transfers raw binary file content (blobs). Used for files that cannot be represented as Yjs Text (images, PDFs, etc.).
- Payload: The raw binary content of the file.
-
Server behavior: When the server receives a
MSG_BLOB_UPDATE, it (1) computes the SHA-256 hash of the payload, (2) stores the blob in theblobstable (content-addressable by hash), and (3) broadcasts the blob to all subscribers of the document's broadcast channel except the sender. -
MSG_BLOB_REQUEST(0x05) - Direction: Client ➞ Server
- Purpose: Requests a specific blob by its SHA-256 hash. Used when a client receives a metadata update indicating a new
blob_hashbut does not have the corresponding binary content. - Payload: The SHA-256 hash of the requested blob, encoded as a UTF-8 hex string.
- Server behavior: The server looks up the blob by hash in the
blobstable and responds with aMSG_BLOB_UPDATEcontaining the blob data.
Document Identification
Syncline uses UUIDs as document identifiers. Each file in the vault is assigned a unique UUID when it is first created or discovered. The UUID is stable across renames — renaming a file changes its metadata, not its UUID.
The mapping between a file's path and its UUID is maintained through two mechanisms:
meta.path— a CRDT field inside each document (see File Documents below) that stores the canonical file path. This is the source of truth that all clients use to determine where a file should exist on disk.- Client-local path map — each client maintains a local lookup table (e.g.,
.syncline/data/path_map.jsonin the CLI client) mappingrelative_path → UUIDfor fast lookups. This is not synced and is rebuilt frommeta.pathfields as needed.
Client Implementation Guidelines
To correctly synchronize documents with the server, a client should follow this lifecycle.
1. Initialization and Connection
- Maintain an individual Yjs Document (
Y.Doc) for eachdoc_idyou want to sync. - Wait for the WebSocket connection to establish (
onopenevent).
2. Initial Synchronization
Once connected, the client should synchronize its local state with the server:
-
Subscribe to
__index__: Send aMSG_SYNC_STEP_1fordoc_id = "__index__"with an empty (or local) State Vector. The server will respond with aMSG_SYNC_STEP_2containing the full index. -
Subscribe to known documents: For each UUID the client already knows about (e.g., from persisted
.binstate files), send aMSG_SYNC_STEP_1with the local document's State Vector. -
Broadcast offline changes (first connection only): If the client accumulated changes while offline, send them as
MSG_UPDATEmessages for each affected UUID. This ensures the server integrates any offline edits.
3. Discovering New Documents
When the client receives an update for __index__, it should:
- Parse the index content (newline-separated UUIDs).
- Identify any UUIDs not already tracked locally.
- For each newly discovered UUID, send a
MSG_SYNC_STEP_1to subscribe and receive the document's content.
When then client receives the new document's content via MSG_SYNC_STEP_2, it should:
- Read the
meta.pathvalue from the document'smetaY.Map. - Write the document's text content to the corresponding file path on disk.
4. Handling Remote Updates
The client needs to listen for incoming WebSocket messages (onmessage).
- Parse the incoming binary buffer into
msg_type,doc_id, andpayload. - If the
doc_idmatches a locally tracked document: - If the
msg_typeisMSG_SYNC_STEP_2orMSG_UPDATE:- Apply the
payloadto the local Yjs Document usingY.applyUpdate(doc, payload). - Read
meta.pathfrom the document — if it has changed, handle the rename (see Rename Propagation). - Write the updated content to disk.
- Note: You must ensure you do not re-broadcast this applied update back to the server (e.g., pause local update observers or check flags before transmitting).
- Apply the
5. Broadcasting Local Changes
When the local document is modified by the user (or the application layer):
- The Yjs Document will emit an
updateevent (doc.on('update', (update, origin) => { ... })). - If the update originated locally (not from the WebSocket), dispatch a
MSG_UPDATEwith the event'supdatebuffer to the server.
6. Creating New Files
When a new file is created locally:
- Generate a new UUID for the file.
- Create a new Y.Doc. Set
meta.pathto the relative file path. Setcontentto the file's text. - Send
MSG_SYNC_STEP_1for the new UUID to subscribe to its broadcast channel. - Send
MSG_UPDATEwith the document's full state. - Insert the UUID into the
__index__document and broadcast the index update.
7. Deleting Files
When a file is deleted locally:
- Clear the document's
contenttext (set to empty string via CRDT operations). - Remove the UUID from the
__index__document. - Broadcast both updates.
- Remote clients receiving the index update will detect the UUID removal and delete the corresponding local file.
Schema & Special Documents
Syncline defines standard structures for its documents over Yjs.
The Index Document (__index__)
The server and clients use a special reserved document ID "__index__" to track the list of all synchronized files in the workspace.
doc_id:"__index__"- Schema: Contains a single Yjs Text named
"content"(doc.getText('content')/doc.get_or_insert_text("content")). - Data format: A plain text string containing one UUID per line (newline-terminated). For example:
a3b8d1b6-0b3b-4b1a-9c1a-1a2b3c4d5e6f
f81d4fae-7dec-11d0-a765-00a0c91e6bf6
4192bff0-e1e0-43ce-a4db-912808c32493
- Operations:
- Insert: Append
"{uuid}\n"at the end of the text. - Remove: Find and delete the
"{uuid}\n"substring from the text. -
List: Split the text by newlines and filter out empty strings.
-
Server behavior: The server automatically registers new
doc_ids in the index when it receives aMSG_SYNC_STEP_1orMSG_UPDATEfor a UUID that it hasn't seen before. The server maintains aknown_doc_idsset in memory (rebuilt from the index on startup) to ensure each UUID is inserted exactly once.
File Documents
Each file in the vault is represented by a Yjs Document identified by a UUID.
Text File Documents
Text files (.md, .txt) use the full Yjs CRDT for content synchronization:
| Yrs Type | Name | Purpose |
|---|---|---|
Y.Text |
"content" |
The file's text content. |
Y.Map |
"meta" |
Metadata about the file (path, type). |
The content Text
- Type:
doc.getText('content')/doc.get_or_insert_text("content") - Data: The entire text content of the file. Syncing this Yjs Text guarantees real-time collaborative text editing with conflict-free merging.
The meta Map
- Type:
doc.getMap('meta')/doc.get_or_insert_map("meta") - Keys:
"path"(string) — The relative file path within the vault (e.g.,"notes/idea.md")."type"(string) —"text"for text files (may be omitted for backward compatibility).
Binary File Documents
Binary files (images, PDFs, etc.) use a metadata-only CRDT document plus separate blob messages for content:
| Yrs Type | Name | Purpose |
|---|---|---|
Y.Map |
"meta" |
Metadata about the file (path, type, blob hash). |
Note: Binary documents do not have a
Y.Text("content"). The binary data is transferred viaMSG_BLOB_UPDATEmessages, not through the CRDT.
The meta Map (Binary)
- Keys:
"path"(string) — The relative file path within the vault (e.g.,"images/photo.png")."type"(string) — Always"binary"for binary files."blob_hash"(string) — The SHA-256 hex hash of the file's binary content. Used for change detection and content-addressable storage.
Binary File Synchronization Flow
- Upload: Client computes SHA-256 hash of the file, sets
meta.blob_hashandmeta.typein the CRDT, then sends aMSG_BLOB_UPDATEwith the raw bytes. - Metadata broadcast: The CRDT update (containing the new
blob_hash) is broadcast to all clients via standardMSG_UPDATE. - Download: Remote clients receive the metadata update, compare
blob_hashwith their local file, and send aMSG_BLOB_REQUESTif the hashes differ. - Conflict resolution: Binary files use Last-Write-Wins (LWW) based on the CRDT timestamp of the
blob_hashfield. The latest writer's content wins.
The meta.path field is critical for:
- File location: Clients use
meta.pathto determine where to write the file on disk. - Rename propagation: When a file is renamed, only
meta.pathis updated. The UUID stays the same, so the CRDT history is preserved. - Path conflict resolution: When two clients independently create files at the same path, they will have different UUIDs. The conflict is detected and resolved using
meta.path.
Rename Propagation
Renames are propagated through the CRDT by updating the meta.path field:
- The renaming client updates
meta.pathin the document'smetaY.Map to the new path. - This generates a CRDT update that is broadcast to all subscribers.
- Remote clients receive the update, read the new
meta.pathvalue, detect that it differs from the previously known path, and rename the file on disk.
Because the UUID remains constant, the full edit history and CRDT state are preserved across renames.
Client-specific rename detection
The two client types detect renames differently:
- Obsidian plugin: Receives atomic
vault.on('rename')events from the Obsidian API. - CLI/Folder client: Uses content-hash matching across batched file-watcher events. When a delete and a create appear in the same event batch with matching content, the client treats it as a rename rather than a delete-then-create.
Path Conflict Resolution
When two clients independently create files at the same path while offline, they will generate different UUIDs for the same path. Upon reconnection, a path collision is detected:
- The server's UUID (the one that was in the
__index__before the client connected) is treated as canonical. - The locally-created UUID's file is renamed to a conflict filename:
"notes/idea (client-name).md". - Both documents are synced to the server. The user can manually reconcile the conflict.