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#
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):
1 2 3 4 5 6 7 | |
1. Register the device#
1 2 3 4 5 6 7 8 9 | |
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.
1 2 3 4 | |
1 2 3 4 5 6 | |
3. Pull changes#
1 2 3 4 5 | |
1 2 3 4 5 6 7 8 9 | |
1 2 3 4 5 6 7 | |
A change looks like:
1 2 3 4 5 6 7 8 9 10 11 12 | |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
base_version_id is the version the client was editing against. If the server has moved on, a conflict is raised.
Response:
1 2 3 4 5 | |
If anything conflicts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
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:
1 2 3 | |
List open conflicts:
1 2 3 4 | |
6. Advance the cursor#
Once changes through cursor=X are durably applied locally:
1 2 | |
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:
1 2 3 4 5 6 7 8 | |
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:
1 | |
Messages from server to client:
1 2 3 | |
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:
1 2 | |
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 — how file versions interact with sync.
- Sync Guide — end-to-end examples for building a sync client.
- Sync Reference — all sync endpoints.