286 lines
6.1 KiB
Markdown
286 lines
6.1 KiB
Markdown
# Sync Protocol v1 Specification
|
|
|
|
## Overview
|
|
|
|
The Native Sync Protocol enables bidirectional filesystem synchronization between a native macOS client and the cairnquire server. The browser client continues to operate via the existing WebSocket + REST API.
|
|
|
|
## Data Model
|
|
|
|
### Snapshot
|
|
|
|
A point-in-time tree of all files in the sync scope:
|
|
|
|
```json
|
|
{
|
|
"id": "snap:device-1:1234567890",
|
|
"deviceId": "device-1",
|
|
"createdAt": "2024-01-15T10:30:00Z",
|
|
"files": [
|
|
{
|
|
"path": "hello.md",
|
|
"hash": "a1b2c3...",
|
|
"size": 42,
|
|
"modified": "2024-01-15T10:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Delta
|
|
|
|
A list of changes since a snapshot:
|
|
|
|
```json
|
|
{
|
|
"changes": [
|
|
{
|
|
"type": "create|update|delete|rename",
|
|
"path": "new-file.md",
|
|
"oldPath": "old-file.md",
|
|
"hash": "a1b2c3...",
|
|
"size": 42,
|
|
"modified": "2024-01-15T10:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Conflict
|
|
|
|
When server and client both changed the same file since the last common snapshot:
|
|
|
|
```json
|
|
{
|
|
"path": "hello.md",
|
|
"serverHash": "a1b2c3...",
|
|
"clientHash": "d4e5f6...",
|
|
"serverModified": "2024-01-15T10:00:00Z",
|
|
"clientModified": "2024-01-15T10:05:00Z",
|
|
"strategy": "last-write-wins"
|
|
}
|
|
```
|
|
|
|
### Resolution Strategies
|
|
|
|
- `last-write-wins` (default): Use the most recently modified version
|
|
- `rename-both`: Keep both versions, renaming one as `file (conflict).md`
|
|
- `manual-merge`: Flag for UI resolution
|
|
- `server-wins`: Always use server version
|
|
- `client-wins`: Always use client version
|
|
|
|
## Endpoints
|
|
|
|
All endpoints require authentication via `X-API-Key` header.
|
|
|
|
### POST /api/sync/init
|
|
|
|
Initialize a new sync session.
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"deviceId": "macbook-pro-1",
|
|
"rootPath": "/Users/alice/Documents/md-hub"
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"snapshotId": "snap:macbook-pro-1:1234567890",
|
|
"serverSnapshot": [
|
|
{
|
|
"path": "hello.md",
|
|
"hash": "a1b2c3...",
|
|
"size": 42,
|
|
"modified": "2024-01-15T10:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### POST /api/sync/delta
|
|
|
|
Upload client changes and receive server changes + conflicts.
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"snapshotId": "snap:macbook-pro-1:1234567890",
|
|
"clientDelta": [
|
|
{
|
|
"type": "update",
|
|
"path": "hello.md",
|
|
"hash": "d4e5f6...",
|
|
"size": 50,
|
|
"modified": "2024-01-15T10:05:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"serverDelta": [
|
|
{
|
|
"type": "create",
|
|
"path": "new-guide.md",
|
|
"hash": "g7h8i9...",
|
|
"size": 100,
|
|
"modified": "2024-01-15T10:02:00Z"
|
|
}
|
|
],
|
|
"conflicts": [
|
|
{
|
|
"path": "hello.md",
|
|
"serverHash": "a1b2c3...",
|
|
"clientHash": "d4e5f6...",
|
|
"serverModified": "2024-01-15T10:00:00Z",
|
|
"clientModified": "2024-01-15T10:05:00Z",
|
|
"strategy": "last-write-wins"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### POST /api/sync/resolve
|
|
|
|
Resolve conflicts and finalize the sync.
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"snapshotId": "snap:macbook-pro-1:1234567890",
|
|
"resolutions": [
|
|
{
|
|
"path": "hello.md",
|
|
"strategy": "server-wins"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"newSnapshotId": "snap:macbook-pro-1:1234567999"
|
|
}
|
|
```
|
|
|
|
### GET /api/content/{hash}
|
|
|
|
Fetch raw file content by content hash (immutable, cache-friendly).
|
|
|
|
**Response:** Raw file bytes with headers:
|
|
```
|
|
Content-Type: application/octet-stream
|
|
Cache-Control: public, max-age=31536000, immutable
|
|
ETag: "a1b2c3..."
|
|
```
|
|
|
|
## Protocol Flow
|
|
|
|
```
|
|
1. Client calls POST /api/sync/init
|
|
→ Receives server snapshot
|
|
|
|
2. Client computes local delta
|
|
(fsevents + local state DB)
|
|
|
|
3. Client POSTs delta to /api/sync/delta
|
|
→ Receives serverDelta + conflicts
|
|
|
|
4. If conflicts exist:
|
|
a. Client resolves conflicts (auto or UI)
|
|
b. Client POSTs resolutions to /api/sync/resolve
|
|
→ Receives newSnapshotId
|
|
|
|
5. Client applies serverDelta to local filesystem
|
|
(downloads content via /api/content/{hash})
|
|
|
|
6. Both sides now have the same snapshot
|
|
```
|
|
|
|
## Authentication
|
|
|
|
Sync endpoints require API key authentication:
|
|
|
|
```
|
|
X-API-Key: <api-key>
|
|
```
|
|
|
|
API keys are long-lived tokens scoped to a user and device. They are stored in the `api_keys` table with SHA-256 hashes.
|
|
|
|
## Implementation Details
|
|
|
|
### Server Architecture
|
|
|
|
```
|
|
internal/sync/
|
|
├── protocol.go # Types and constants
|
|
├── service.go # Business logic (conflict resolution, snapshot management)
|
|
├── store.go # Database operations
|
|
```
|
|
|
|
### Database Schema
|
|
|
|
```sql
|
|
-- API keys for native client authentication
|
|
CREATE TABLE api_keys (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
key_hash TEXT NOT NULL UNIQUE,
|
|
scopes TEXT NOT NULL DEFAULT 'sync:read,sync:write',
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT,
|
|
last_used_at TEXT,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
-- Sync snapshots (point-in-time file trees)
|
|
CREATE TABLE sync_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
device_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
-- Files within a snapshot
|
|
CREATE TABLE sync_files (
|
|
snapshot_id TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
hash TEXT NOT NULL,
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
modified_at TEXT NOT NULL,
|
|
PRIMARY KEY (snapshot_id, path),
|
|
FOREIGN KEY(snapshot_id) REFERENCES sync_snapshots(id) ON DELETE CASCADE
|
|
);
|
|
```
|
|
|
|
### Conflict Detection
|
|
|
|
A conflict occurs when:
|
|
1. The server file existed in the snapshot
|
|
2. The server file has changed since the snapshot (hash changed)
|
|
3. The client also changed the same file (different hash in delta)
|
|
|
|
### Content-Addressed Storage
|
|
|
|
All file content is stored in a content-addressable store using SHA-256 hashes:
|
|
- Storage path: `<store-dir>/<hash[0:2]>/<hash[2:4]>/<hash>`
|
|
- Immutable: once stored, content never changes
|
|
- Deduplication: identical content shares the same storage
|
|
|
|
## Testing
|
|
|
|
The sync protocol includes table-driven tests that simulate client/server scenarios:
|
|
|
|
- `TestInitSyncCreatesSnapshot`: Validates snapshot generation
|
|
- `TestApplyDeltaDetectsServerChanges`: Detects server-side modifications
|
|
- `TestApplyDeltaDetectsConflicts`: Identifies concurrent modifications
|
|
- `TestResolveConflictsCreatesNewSnapshot`: Validates conflict resolution
|
|
- `TestGetContentReturnsFileBytes`: Tests content retrieval
|