···11+# Tandem
22+33+A native forge for [Jujutsu](https://github.com/martinvonz/jj) (jj) with real-time multiplayer collaboration. Tandem syncs jj changes, bookmarks, and user presence across multiple clients using CRDTs.
44+55+## Overview
66+77+Tandem provides a server-client architecture where:
88+99+- The **server** maintains the authoritative Y.Doc (Yrs CRDT document) for each repository
1010+- **Clients** run a daemon that syncs local changes to the server over WebSocket
1111+- All data merges automatically without conflicts using Yrs (the Rust port of Yjs)
1212+1313+This enables multiple developers to work on the same repository simultaneously with real-time visibility into who is editing what.
1414+1515+## Architecture
1616+1717+Tandem is organized into three crates:
1818+1919+### tandem-core
2020+2121+The shared library containing:
2222+2323+- **types.rs** - Core data types: `ChangeId`, `TreeHash`, `Change`, `ChangeRecord`, `Bookmark`, `PresenceInfo`
2424+- **sync.rs** - `ForgeDoc` wrapping a Y.Doc with maps for changes, bookmarks, presence, and subdocuments for lazy content loading
2525+2626+### tandem-server
2727+2828+An Axum-based HTTP/WebSocket server:
2929+3030+- **sync.rs** - WebSocket endpoint at `/sync/:repo_id` handling bidirectional CRDT sync
3131+- **docs.rs** - `DocManager` loads and persists Y.Doc state to `.yrs` files
3232+- REST API for repositories, changes, bookmarks, presence, and content
3333+3434+### tandem-cli
3535+3636+The `jjf` command-line tool and background daemon:
3737+3838+- **main.rs** - CLI commands: `init`, `link`, `clone`, `daemon start`
3939+- **daemon.rs** - Background sync daemon connecting to the forge
4040+- **presence.rs** - Tracks who is editing which change
4141+- **offline.rs** - Queues operations when disconnected for later replay
4242+- **content.rs** - Lazy fetching of tree/blob content from the server
4343+4444+## Data Model
4545+4646+### Core Types
4747+4848+**ChangeId** - A stable 32-byte identifier for a change that persists across rebases. This is jj's native change ID.
4949+5050+**Change** - The unit of work containing:
5151+- `id: ChangeId` - Stable identifier
5252+- `tree: TreeHash` - Content-addressed 20-byte hash of the tree
5353+- `parents: Vec<ChangeId>` - Parent changes
5454+- `description: String` - Commit message
5555+- `author: Identity` - Name and email
5656+- `timestamp: DateTime<Utc>`
5757+5858+**ChangeRecord** - The CRDT-friendly wrapper stored in Y.Doc:
5959+- `record_id: Uuid` - Unique key for the Y.Map entry
6060+- `change_id: ChangeId` - The actual change ID
6161+- `visible: bool` - False when abandoned/hidden
6262+- All fields from `Change`
6363+6464+The distinction matters: a single `ChangeId` can have multiple `ChangeRecord` entries when a change diverges across clients before syncing. The CRDT merges them all, and the application layer decides how to present divergence.
6565+6666+**Bookmark** - A named pointer to a change (like a git branch):
6767+- `name: String` - Bookmark name
6868+- `target: ChangeId` - The change it points to
6969+- `protected: bool` - Whether rules apply
7070+- `rules: BookmarkRules` - CI/review requirements
7171+7272+**PresenceInfo** - Real-time editing status:
7373+- `user_id: String` - Username
7474+- `change_id: ChangeId` - Currently edited change
7575+- `device: String` - Device name
7676+- `timestamp: DateTime<Utc>` - Last update time (stale after 5 minutes)
7777+7878+### Y.Doc Structure
7979+8080+Each repository has **one main Y.Doc** for metadata, plus **separate subdocuments** (each its own Y.Doc) for content:
8181+8282+```
8383+ForgeDoc
8484+├── Main Doc (one per repo)
8585+│ ├── Y.Map("changes") → {record_id: JSON(ChangeRecord)}
8686+│ ├── Y.Map("bookmarks") → {name: ChangeId_hex}
8787+│ └── Y.Map("presence") → {user_id: JSON(PresenceInfo)}
8888+│
8989+└── Subdocuments (HashMap<hash, Doc>)
9090+ ├── "abc123" → Y.Doc { Y.Map("data") → {content: base64} }
9191+ ├── "def456" → Y.Doc { Y.Map("data") → {content: base64} }
9292+ └── ...
9393+```
9494+9595+This design enables:
9696+1. **Fast metadata sync** - The main doc is small and syncs quickly
9797+2. **Lazy content loading** - Each content blob is a separate Y.Doc fetched on-demand
9898+3. **Independent sync** - Subdocuments can sync independently, so you only fetch content you need
9999+100100+## Sync Protocol
101101+102102+### State Vector Exchange
103103+104104+Yrs uses state vectors to track what each peer has seen. The sync flow:
105105+106106+1. **Client connects** via WebSocket to `/sync/:repo_id`
107107+2. **Client sends state vector** - A compact encoding of which updates it has
108108+3. **Server computes diff** - Encodes only the updates the client lacks
109109+4. **Server sends update** - Binary Yrs update packet
110110+5. **Client applies update** - Merges into its local Y.Doc
111111+6. **Bidirectional sync continues** - Either side can send updates
112112+113113+```
114114+Client Server
115115+ | |
116116+ |-- Binary(state_vector) ------>|
117117+ | | (compute diff)
118118+ |<----- Binary(update) ---------|
119119+ | |
120120+ |-- Binary(local_update) ------>| (from local edit)
121121+ | | (apply, save, broadcast)
122122+ | |
123123+```
124124+125125+### Server-Side Sync (tandem-server/src/sync.rs)
126126+127127+The server handles each WebSocket message:
128128+129129+1. **Try to apply as update** - If valid Yrs update, apply it
130130+2. **On success** - Save to disk, broadcast to other clients (excluding sender)
131131+3. **On failure** - Assume it's a state vector, compute and send diff
132132+133133+The `SyncManager` maintains broadcast channels per repository so updates propagate to all connected clients.
134134+135135+### Client-Side Sync (tandem-cli/src/daemon.rs)
136136+137137+The daemon:
138138+139139+1. Connects to the forge WebSocket
140140+2. Sends its state vector to request initial sync
141141+3. Receives and applies updates from the server
142142+4. When local changes happen, sends updates to the server
143143+5. On disconnect, enters offline mode and queues operations
144144+145145+## Conflict Resolution
146146+147147+### Why CRDTs Avoid Traditional Conflicts
148148+149149+Yrs implements a CRDT (Conflict-free Replicated Data Type). Every operation is designed to commute - the same set of operations applied in any order produces the same result.
150150+151151+For Tandem:
152152+153153+- **Changes** are keyed by unique `record_id` UUIDs. Two clients creating a record for the same change get two records, not a conflict.
154154+- **Bookmarks** use last-writer-wins semantics on the Y.Map. Concurrent moves to different targets result in one winning.
155155+- **Presence** uses last-writer-wins per user. Stale entries are filtered out by timestamp.
156156+157157+### Divergence Handling
158158+159159+When the same `ChangeId` gets edited on two disconnected clients:
160160+161161+1. Each client creates a new `ChangeRecord` with a unique `record_id`
162162+2. On reconnect, both records sync to all clients
163163+3. `get_change_records(change_id)` returns multiple records
164164+4. The application must decide which is canonical (by timestamp, by user, or by user choice)
165165+166166+Hidden/abandoned changes are marked with `visible: false` rather than deleted, preserving history.
167167+168168+### Edge Cases
169169+170170+- **Bookmark races** - If Alice moves `main` to change A while Bob moves it to B, one wins. The CRDT resolves this deterministically but the "loser" may need to re-move the bookmark.
171171+- **Offline queuing** - Operations made while disconnected are stored in `.jj/forge-queue.json` and replayed on reconnect.
172172+- **Stale presence** - Presence entries older than 5 minutes are filtered out.
173173+174174+## Getting Started
175175+176176+### Starting the Server
177177+178178+```bash
179179+cd crates/tandem-server
180180+DATABASE_URL=sqlite:tandem.db DATA_DIR=./data cargo run
181181+```
182182+183183+The server starts on `http://localhost:3000` with:
184184+- REST API at `/api/*`
185185+- WebSocket sync at `/sync/:repo_id`
186186+- Health check at `/health`
187187+188188+### Linking a Repository
189189+190190+In an existing jj repository:
191191+192192+```bash
193193+jjf link https://forge.example.com/org/myrepo --token <auth_token>
194194+```
195195+196196+This:
197197+1. Tests the connection to the forge
198198+2. Creates `.jj/forge.toml` with the forge URL
199199+3. Prints instructions to start the daemon
200200+201201+### Cloning from Forge
202202+203203+```bash
204204+jjf clone https://forge.example.com/org/myrepo
205205+```
206206+207207+This:
208208+1. Creates the target directory
209209+2. Runs `jj init`
210210+3. Links to the forge
211211+4. Pulls initial state via WebSocket sync
212212+5. Saves the Y.Doc state to `.jj/forge-doc.bin`
213213+214214+### Running the Daemon
215215+216216+```bash
217217+jjf daemon start
218218+```
219219+220220+The daemon:
221221+1. Loads forge config from `.jj/forge.toml`
222222+2. Connects to the forge WebSocket
223223+3. Syncs bidirectionally in real-time
224224+4. Tracks presence (which change you're editing)
225225+5. Warns when someone else is editing the same change
226226+6. Queues operations when offline, replays on reconnect
227227+228228+### Presence Tracking
229229+230230+The daemon uses `whoami` to identify the current user and device. When you edit a change, it broadcasts your presence. Other users see warnings like:
231231+232232+```
233233+Warning: This change is currently being edited by alice@laptop
234234+```
235235+236236+Presence entries expire after 5 minutes of inactivity.
237237+238238+## Configuration
239239+240240+### .jj/forge.toml
241241+242242+Created by `jjf link`, stores the forge URL:
243243+244244+```toml
245245+[forge]
246246+url = "https://forge.example.com/org/myrepo"
247247+```
248248+249249+### .jj/forge-queue.json
250250+251251+Stores queued operations when offline:
252252+253253+```json
254254+{
255255+ "operations": [
256256+ {
257257+ "type": "change_updated",
258258+ "record": { ... },
259259+ "timestamp": "2024-01-01T00:00:00Z"
260260+ }
261261+ ]
262262+}
263263+```
264264+265265+### .jj/forge-offline
266266+267267+Marker file indicating offline mode. Removed when connection is restored.
268268+269269+## API Reference
270270+271271+### REST Endpoints
272272+273273+| Method | Path | Description |
274274+|--------|------|-------------|
275275+| POST | `/api/auth/login` | Authenticate and get token |
276276+| GET | `/api/auth/me` | Get current user |
277277+| GET | `/api/repos` | List repositories |
278278+| POST | `/api/repos` | Create repository |
279279+| GET | `/api/repos/:id` | Get repository details |
280280+| GET | `/api/repos/:id/changes` | List changes |
281281+| GET | `/api/repos/:id/changes/:cid` | Get specific change |
282282+| GET | `/api/repos/:id/bookmarks` | List bookmarks |
283283+| POST | `/api/repos/:id/bookmarks` | Move bookmark |
284284+| GET | `/api/repos/:id/presence` | Get active presence |
285285+| GET | `/api/repos/:id/content/:hash` | Fetch content by hash |
286286+287287+### WebSocket Endpoints
288288+289289+| Path | Description |
290290+|------|-------------|
291291+| `/sync/:repo_id` | Bidirectional Yrs sync |
292292+| `/events/:repo_id` | Server-sent events for notifications |
293293+294294+## License
295295+296296+MIT OR Apache-2.0