Add conflict resolution diff UI and app branding

This commit is contained in:
2026-05-13 16:25:28 -04:00
parent 780ff3a02c
commit d359baa010
83 changed files with 4523 additions and 3735 deletions

View File

@@ -0,0 +1,285 @@
# 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