Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Sync

Build a sync client: device registration, cursor-based change fetching, applying changes, conflict resolution. This page shows complete worked examples. For the conceptual model, read Sync Model first.

Base path: /api/v1/sync/

Full sync loop#

A minimal client:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import asyncio
import httpx
import os

class SyncClient:
    def __init__(self, base, token, device_id):
        self.client = httpx.AsyncClient(
            base_url=base,
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        self.device_id = device_id

    async def register(self, name: str, platform: str, version: str):
        r = await self.client.post(
            "/api/v1/sync/devices",
            json={
                "device_id": self.device_id,
                "device_name": name,
                "platform": platform,
                "client_version": version,
            },
        )
        r.raise_for_status()
        return r.json()

    async def cursor(self, share_id: str) -> int:
        r = await self.client.get(
            "/api/v1/sync/status",
            params={"share_id": share_id, "device_id": self.device_id},
        )
        r.raise_for_status()
        return r.json()["cursor_position"]

    async def pull(self, share_id: str, cursor: int, limit: int = 1000):
        r = await self.client.get(
            "/api/v1/sync/changes",
            params={"share_id": share_id, "cursor": cursor, "limit": limit},
        )
        r.raise_for_status()
        return r.json()

    async def advance(self, share_id: str, position: int):
        r = await self.client.post(
            "/api/v1/sync/cursor",
            params={
                "share_id": share_id,
                "device_id": self.device_id,
                "position": position,
            },
        )
        r.raise_for_status()

    async def apply(self, share_id: str, changes: list, resolution="LAST_WRITER_WINS"):
        r = await self.client.post(
            "/api/v1/sync/apply",
            json={
                "device_id": self.device_id,
                "share_id": share_id,
                "changes": changes,
                "conflict_resolution": resolution,
            },
        )
        r.raise_for_status()
        return r.json()


async def sync_share(client: SyncClient, share_id: str):
    cursor = await client.cursor(share_id)
    while True:
        batch = await client.pull(share_id, cursor)
        for change in batch["changes"]:
            apply_change_locally(change)
        cursor = batch["cursor"]
        await client.advance(share_id, cursor)
        if not batch["has_more"]:
            break
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
type Change = {
  id: string;
  resource_type: "file" | "folder" | "share";
  resource_id: string;
  change_type: "created" | "updated" | "deleted" | "moved" | "renamed";
  changed_by: string;
  changed_at: string;
  old_path: string | null;
  new_path: string | null;
  version_id: string | null;
  metadata: Record<string, unknown>;
};

class SyncClient {
  constructor(private base: string, private token: string, private deviceId: string) {}

  private async fetch(path: string, init?: RequestInit) {
    const resp = await fetch(`${this.base}${path}`, {
      ...init,
      headers: { Authorization: `Bearer ${this.token}`, ...(init?.headers ?? {}) },
    });
    if (!resp.ok) throw new Error(`${resp.status}: ${await resp.text()}`);
    return resp.json();
  }

  async cursor(shareId: string): Promise<number> {
    const params = new URLSearchParams({ share_id: shareId, device_id: this.deviceId });
    const body = await this.fetch(`/api/v1/sync/status?${params}`);
    return body.data.cursor_position;
  }

  async pull(shareId: string, cursor: number, limit = 1000) {
    const params = new URLSearchParams({
      share_id: shareId,
      cursor: String(cursor),
      limit: String(limit),
    });
    const body = await this.fetch(`/api/v1/sync/changes?${params}`);
    return body.data as { changes: Change[]; cursor: number; has_more: boolean };
  }

  async advance(shareId: string, position: number) {
    const params = new URLSearchParams({
      share_id: shareId,
      device_id: this.deviceId,
      position: String(position),
    });
    await this.fetch(`/api/v1/sync/cursor?${params}`, { method: "POST" });
  }

  async apply(shareId: string, changes: unknown[], resolution = "LAST_WRITER_WINS") {
    const body = await this.fetch(`/api/v1/sync/apply`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        device_id: this.deviceId,
        share_id: shareId,
        changes,
        conflict_resolution: resolution,
      }),
    });
    return body.data;
  }
}

Device registration#

Every client installation needs a unique device_id. Generate a UUID on first run and persist it:

bash
1
2
3
4
5
6
7
8
9
curl -X POST $SCAIDRIVE_URL/api/v1/sync/devices \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev_01J3KX",
    "device_name": "Alice MacBook Pro",
    "platform": "macos",
    "client_version": "1.2.0"
  }'

Platforms recognized: windows, macos, linux, ios, android, web, cli.

Heartbeat periodically so the server marks the device as active:

bash
1
2
curl -X POST $SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/heartbeat \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"

List registered devices:

bash
1
2
curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
     $SCAIDRIVE_URL/api/v1/sync/devices

Selective sync#

Not every client wants every folder. Set per-device, per-share preferences:

bash
1
2
3
4
5
6
7
curl -X PUT "$SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/preferences/shr_01J3K" \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sync_enabled": true,
    "selected_folders": ["fld_01J4A", "fld_01J4B"]
  }'

Three modes:

  • sync_enabled: false — don't sync this share on this device.
  • selected_folders: [...] — sync only these folders (and descendants).
  • excluded_folders: [...] — sync everything except these.

Clients interpret preferences locally — they still pull the full change stream, but skip applying changes for paths they're not tracking. The cursor advances through all changes either way.

Bulk update all preferences at once:

bash
1
2
3
4
5
6
7
8
9
curl -X PUT $SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/preferences \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "preferences": [
      {"share_id": "shr_01J3K", "sync_enabled": true, "selected_folders": null, "excluded_folders": null},
      {"share_id": "shr_01J4Z", "sync_enabled": false, "selected_folders": null, "excluded_folders": null}
    ]
  }'

Applying client-side changes#

When a user saves a file locally, the client pushes the change back:

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
curl -X POST $SCAIDRIVE_URL/api/v1/sync/apply \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev_01J3KX",
    "share_id": "shr_01J3K",
    "conflict_resolution": "LAST_WRITER_WINS",
    "changes": [
      {
        "resource_type": "file",
        "resource_id": "fil_01J3M",
        "change_type": "updated",
        "version_id": "ver_01J4Q",
        "base_version_id": "ver_01J3P",
        "new_path": "/Engineering/spec.pdf"
      }
    ]
  }'

For new files, the client must first upload the content (multipart to /api/v1/files or a resumable session to /api/v1/uploads), then apply the created change referencing the new file ID.

Handling conflicts#

Strategies in order of how often you'd pick them in a client:

  1. LAST_WRITER_WINS — default for interactive editing. Users see "Alice's version replaced yours" in the history.
  2. KEEP_BOTH — safe default for automated clients. Nothing is ever lost; user reviews duplicates later.
  3. MANUAL — surface the conflict, let the user choose.
  4. SERVER_WINS / CLIENT_WINS — forceful overrides; rare outside admin recovery tooling.

When conflicts are returned, they're also recorded server-side. List open conflicts:

bash
1
2
3
4
curl -G $SCAIDRIVE_URL/api/v1/sync/conflicts \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  --data-urlencode "device_id=dev_01J3KX" \
  --data-urlencode "include_resolved=false"

Resolve:

bash
1
2
curl -X POST "$SCAIDRIVE_URL/api/v1/sync/conflicts/cfl_01J4R/resolve?resolution=KEEP_BOTH" \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"

Downloading file content during sync#

The sync download endpoint supports range requests out of the box — handy for resumable downloads:

bash
1
2
3
curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
     -H "Range: bytes=0-4194303" \
     "$SCAIDRIVE_URL/api/v1/sync/download/fil_01J3M?version=4"

Or with query-param ranges (some clients find this easier than the Range header):

bash
1
2
curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
     "$SCAIDRIVE_URL/api/v1/sync/download/fil_01J3M?range_start=0&range_end=4194303"

Real-time updates#

Polling works; WebSocket reduces latency. Connect to the sync WebSocket for push nudges:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import asyncio, json, websockets

async def watch(base_ws, token, device_id):
    url = f"{base_ws}/api/v1/sync/ws/{device_id}?token={token}"
    async with websockets.connect(url) as ws:
        await ws.send(json.dumps({"type": "subscribe"}))
        async for msg in ws:
            frame = json.loads(msg)
            if frame["event_type"] == "change":
                # trigger a pull for the affected share
                await trigger_sync(frame["share_id"])

See Real-time WebSocket for the full WebSocket protocol.

Putting it together#

A production sync client typically:

  1. Registers once, stores device_id locally.
  2. Opens a WebSocket for low-latency nudges.
  3. On nudge or a 60-second timer, pulls changes for every subscribed share.
  4. Downloads content for new/updated files in parallel.
  5. Watches the local filesystem for changes and pushes them back.
  6. Heartbeats every 5 minutes.
  7. Handles reconnection with exponential backoff.

The official desktop client (client-core + client-desktop) does all of this. Source is under client-core/ in the repository if you want to read reference code.

What's next#

Updated 2026-05-18 15:04:14 View source (.md) rev 2