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 Model

ScaiDrive's sync protocol is cursor-based. The server maintains a per-tenant change log, and each client tracks an opaque position (the cursor) in that log. Syncing is replaying every change after the cursor. No scanning, no diffing, no snapshots.

The mental model#

flowchart LR subgraph CL["ChangeLog (append-only, per-tenant)"] direction LR E1["file_created"] --> E2["file_updated"] --> E3["folder_created"] --> E4["file_deleted"] --> E5["…"] end A((Client A<br/>cursor)) -.->|reads from| E2 B((Client B<br/>cursor)) -.->|reads from| E4

Every mutation on the tenant appends one row to ChangeLog. Clients advance their cursor as they process changes. Offline for a week? Come back, replay a week of entries, done.

Entities#

Device — a client install. Each client installation registers itself with a unique device_id (typically a UUID generated on first run). The server tracks last-seen time and platform info per device.

Cursor — the client's checkpoint in the change log. Represented as an integer (the changelog row position), but treat it as opaque — future versions may use different representations.

Change — a single operation on a resource. Types: created, updated, deleted, moved, renamed. Changes carry enough metadata for a client to apply them without re-fetching the resource (though fetching the current state is always available).

Conflict — when a client attempts to apply a change based on a stale version. First-class objects that surface through a dedicated conflict-resolution API.

The sync loop#

A typical client does this on a schedule (and on WebSocket nudges):

text
1
2
3
4
5
6
7
1. POST /api/v1/sync/devices                         (first time only)
2. GET  /api/v1/sync/status?share_id=X&device_id=D   (retrieve cursor)
3. GET  /api/v1/sync/changes?cursor=C&share_id=X     (pull remote changes)
4. Apply remote changes to local filesystem
5. POST /api/v1/sync/apply {local changes}           (push local changes)
6. POST /api/v1/sync/cursor?position=...             (advance cursor)
7. WebSocket /api/v1/sync/ws/{device_id}             (wait for nudge)

1. Register the device#

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",
    "platform": "macos",
    "client_version": "1.2.0"
  }'

device_id is client-generated. You can keep it across ScaiDrive installs if you want sync-history continuity.

2. Get the current cursor#

First sync: you don't know where to start. Asking for status creates a cursor at the current head if none exists.

bash
1
2
3
4
curl -G $SCAIDRIVE_URL/api/v1/sync/status \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  --data-urlencode "share_id=shr_01J3K" \
  --data-urlencode "device_id=dev_01J3KX"
json
1
2
3
4
5
6
{
  "cursor_position": 1042387,
  "last_sync_at": "2026-04-23T10:15:00Z",
  "pending_changes": 0,
  "has_conflicts": false
}

3. Pull changes#

bash
1
2
3
4
5
curl -G $SCAIDRIVE_URL/api/v1/sync/changes \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  --data-urlencode "cursor=1042387" \
  --data-urlencode "share_id=shr_01J3K" \
  --data-urlencode "limit=1000"
python
1
2
3
4
5
6
7
8
9
resp = httpx.get(
    f"{url}/api/v1/sync/changes",
    headers={"Authorization": f"Bearer {token}"},
    params={"cursor": cursor, "share_id": share_id, "limit": 1000},
)
body = resp.json()
for change in body["changes"]:
    apply_change(change)
next_cursor = body["cursor"]
typescript
1
2
3
4
5
6
7
const params = new URLSearchParams({ cursor, share_id, limit: "1000" });
const resp = await fetch(`${url}/api/v1/sync/changes?${params}`, {
  headers: { Authorization: `Bearer ${token}` },
});
const data = await resp.json();
for (const change of data.changes) applyChange(change);
const nextCursor = data.cursor;

A change looks like:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "id": "chg_01J3L",
  "resource_type": "file",
  "resource_id": "fil_01J3M",
  "change_type": "updated",
  "changed_by": "usr_01J3N",
  "changed_at": "2026-04-23T10:15:02Z",
  "metadata": {"size": 12345, "mime_type": "application/pdf"},
  "old_path": "/Engineering/spec.pdf",
  "new_path": "/Engineering/spec.pdf",
  "version_id": "ver_01J3P"
}

Response envelope carries cursor (the next cursor to use) and has_more (whether there are more changes past this batch).

4. Apply remote changes locally#

For each change the client sees, it updates its local replica:

Change type Client action
created Create file or folder locally; for files, download content via GET /api/v1/sync/download/{file_id}
updated For files, download the new version; for metadata-only changes (rename), apply the rename
deleted Remove the local copy; if permanent: false, a restore is still possible
moved old_pathnew_path; move the local file/folder accordingly
renamed Same as moved with only the last path component changing

Idempotency: each change has a unique id. If a client re-applies a change it's already seen (for example, after a crash mid-apply), the change is safe to re-apply — the resource is already in the target state, and the second apply is a no-op.

5. Push local changes#

Changes the client made locally (user saved a file, deleted a folder) get pushed as a batch:

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"
      }
    ]
  }'

base_version_id is the version the client was editing against. If the server has moved on, a conflict is raised.

Response:

json
1
2
3
4
5
{
  "applied": 1,
  "conflicts": [],
  "errors": []
}

If anything conflicts:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "applied": 0,
  "conflicts": [
    {
      "id": "cfl_01J4R",
      "resource_type": "file",
      "resource_id": "fil_01J3M",
      "conflict_type": "version_mismatch",
      "client_version_id": "ver_01J4Q",
      "server_version_id": "ver_01J4S",
      "is_resolved": false
    }
  ],
  "errors": []
}

Conflict resolution strategies#

The conflict_resolution parameter on POST /api/v1/sync/apply controls how the server handles conflicts:

Strategy Behavior
LAST_WRITER_WINS (default) Accept whichever change is newest. May overwrite concurrent work
KEEP_BOTH The server version stays; the client version is saved as a copy (filename (conflict).ext)
SERVER_WINS Reject the client change; do not apply
CLIENT_WINS Accept the client change; discard the server's newer version (rare; use with care)
MANUAL Surface as a conflict; sync does not proceed on that resource until resolved

For resolution of conflicts already recorded:

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

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"

6. Advance the cursor#

Once changes through cursor=X are durably applied locally:

bash
1
2
curl -X POST "$SCAIDRIVE_URL/api/v1/sync/cursor?share_id=shr_01J3K&device_id=dev_01J3KX&position=1045000" \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"

Advancing the cursor is idempotent. Calling with the same position twice is harmless. The server never moves a cursor backwards automatically — if you need to re-sync from scratch, pass position=0.

Selective sync — sync preferences#

A client might want to sync only certain folders within a share. DeviceSyncPreference is how that's expressed:

bash
1
2
3
4
5
6
7
8
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"],
    "excluded_folders": null
  }'

selected_folders — if set, only these folders (and descendants) sync. excluded_folders — if set, everything except these syncs.

Preferences don't filter the change stream on the server — they guide which changes the client applies. This keeps the protocol simple and makes cursor advancement universal.

Real-time nudges#

Polling alone works. But clients that want near-real-time updates open a WebSocket:

text
1
wss://scaidrive.scailabs.ai/api/v1/sync/ws/{device_id}?token=<JWT>

Messages from server to client:

json
1
2
3
{"event_type": "change", "share_id": "shr_01J3K", "data": {...change entry...}}
{"event_type": "sync_preference_updated", "share_id": "shr_01J3K", "device_id": "dev_01J3KX"}
{"event_type": "force_sync", "share_id": "shr_01J3K"}

A WebSocket nudge tells the client "there's something to pull" — the client still goes through the cursor/changes flow to fetch the change payload. WebSocket is not the source of truth; the change log is.

See Real-time WebSocket for frame types and the subscribe/unsubscribe protocol.

Heartbeat#

Clients should heartbeat periodically so the server knows they're alive:

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

Missed heartbeats mark a device as idle in the admin UI but don't affect sync correctness — stale cursors work indefinitely.

What's next#

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