---
title: Sync Model
path: core-concepts/sync-model
status: published
---

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

```mermaid
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. 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
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
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
{
  "cursor_position": 1042387,
  "last_sync_at": "2026-04-23T10:15:00Z",
  "pending_changes": 0,
  "has_conflicts": false
}
```

## 3. Pull changes

```bash
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
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
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
{
  "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_path` → `new_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
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
{
  "applied": 1,
  "conflicts": [],
  "errors": []
}
```

If anything conflicts:

```json
{
  "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
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
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
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
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
wss://scaidrive.scailabs.ai/api/v1/sync/ws/{device_id}?token=<JWT>
```

Messages from server to client:

```json
{"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](/docs/scaidrive/advanced/realtime-websocket) for frame types and the subscribe/unsubscribe protocol.

## Heartbeat

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

```bash
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

- [Versioning and Deduplication](/docs/scaidrive/core-concepts/versioning-and-deduplication) — how file versions interact with sync.
- [Sync Guide](/docs/scaidrive/api-guides/sync) — end-to-end examples for building a sync client.
- [Sync Reference](/docs/scaidrive/reference/sync) — all sync endpoints.