01 / Product Shape
TinySync is a cloud-authoritative file sync engine. Think Dropbox — not a database sync library, not a collaborative editing framework, not a peer-to-peer tool.
The product contract: put a file in a cloud drive location on any device, and it appears — on demand — on every other device. The OS presents the drive as if all files exist locally, even when content hasn't been downloaded yet (placeholders). Files hydrate when opened.
Topology
The cloud server is the single source of truth. No peer-to-peer, no CRDT merge, no multi-master replication. Every write from any device must be accepted by the server before becoming canonical. Other devices learn about it by replaying the server's ordered change log.
This eliminates "where is the real version?" ambiguity and makes conflict resolution deterministic: the first write whose precondition still holds wins; all others get conflict copies.
02 / System Context — C4 Level 1
At the highest level, TinySync sits between users on multiple devices and the infrastructure that stores their files.
| System | Role |
|---|---|
| TinySync Server | Accepts mutations, maintains the authoritative change log, serves snapshots and log pages, hosts blob upload/download, pushes WebSocket wake notifications. |
| PostgreSQL | Authoritative store for vault metadata, item tree, change log (ordered by seq), device identities, groups and membership edges, idempotency keys, public link records. |
| Blob Store | Content-addressed binary store keyed by SHA-256(content). Identical files share one blob. Local filesystem in dev; object storage (R2/S3/B2) in production. |
03 / Container Architecture — C4 Level 2
Each device runs several co-operating processes. The engine is separated from the OS integration layer, and a background daemon owns all mutable engine state.
| Container | Owns | Does not own |
|---|---|---|
| TinySync Daemon | SQLite engine state, credentials, server polling, WebSocket, pending ops queue, retry, panic-resync, unified event stream | OS placeholder state, shell callbacks, Finder/Explorer presentation |
| File Provider Ext (macOS) | Placeholder registration, Finder enumeration, hydration callbacks, own-write suppression | Credentials, SQLite, server protocol, conflict policy |
| CFAPI Process (Windows) | Sync root registration, placeholder operations, Explorer callbacks, byte-range hydration | Same as above |
| Tauri App | Setup UI, tray, status display, device management, process supervision | Sync semantics — it is a control plane only |
| CLI | Register, attach, admin, sync-once, status, devices, panic-resync | Owns no state — reads from daemon or engine directly |
LaunchAgent, Windows user-mode service) gives sync one durable, crash-recoverable owner. Quitting the UI does not stop sync.
04 / Sync Engine Components — C4 Level 3
The daemon contains tinysync-engine — the heart of the system. It is isolated behind two traits: one facing the cloud, one facing the OS.
The Two Trait Boundaries
CloudClient — server-facing
trait CloudClient {
get_snapshot(vault_id) → Snapshot
get_log(vault_id, after: Seq)
→ LogPage
upload_blob(vault_id, hash, bytes)
download_blob(vault_id, hash)→Bytes
submit_mutation(vault_id, req)
→ MutationResponse
}
The engine has no HTTP dependency. Swapping in a test double is trivial — and that's how all engine tests run.
PresentationAdapter — OS-facing
trait PresentationAdapter {
hydration_strategy()→Eager|Lazy
apply_create(item, content)
apply_update(item, content)
apply_delete(item)
apply_move_rename(from, to)
enumerate_local()→Vec<LocalEntry>
read_local_content(path, hash)→Bytes
preserve_conflict_copy(path,
op_id, device_id)→Option<VPath>
}
The engine knows nothing about notify events, File Provider callbacks, or CFAPI requests. It speaks in sync intent.
05 / Crate Map
| Crate | Layer | Contents |
|---|---|---|
tinysync-core | Shared | All protocol DTOs, ID types, content hashing, path/name validation, Unicode normalization. |
tinysync-server | Server | Axum HTTP API, Postgres queries (sqlx), blob store trait + FS impl, WebSocket wake hub (in-process + Postgres LISTEN/NOTIFY), token auth. |
tinysync-engine | Client | SyncEngine, LocalStore (SQLite), PresentationAdapter + CloudClient traits, all sync state machine logic, conflict detection, pending ops queue, panic-resync. |
tinysync-adapter-fs | Client | Prototype eager adapter: filesystem watcher (notify), file materialization, content hashing, own-write suppression, inode-based rename detection. |
tinysync-provider-macos-ffi | Client | Thin Swift-Rust FFI shim for macOS File Provider callback bridge. |
tinysync-provider-windows | Client | Windows CFAPI sync root registration, placeholder management, hydration callback handler. |
tinysync-provider-ipc | Shared | Length-prefixed JSON IPC protocol (4-byte big-endian + JSON body, max 1 MiB/frame) between native providers and daemon. |
tinysync-daemon | Client | Per-user background daemon: IPC server, engine orchestration, multi-vault routing, client event stream, wake subscription. |
tinysync-client | Client | HTTP implementation of CloudClient, device identity storage, sync loop primitives. |
tinysync-desktop | UI | Tauri shell, tray, daemon lifecycle management, settings UI. |
tinysync-cli | UI | CLI: register, attach, admin, sync-once, status, devices, panic-resync. |
06 / Core Data Model — The Item Tree
Items
The fundamental entity is an item — a file or a folder. Items form a tree rooted at a synthetic root folder. All items have a parent item ID, except the vault root.
struct ItemView {
item_id: Uuid, // stable — never changes across rename/move
parent_item_id: Option<Uuid>, // None only for vault root
name: String, // filename only, no path separators
path: VPath, // derived from ancestry — not stored as primary key
kind: ItemKind, // File | Folder
version: u64, // incremented on each accepted mutation
metadata: ItemMetadata,
}
struct ItemMetadata {
content_hash: Option<Hash>, // SHA-256 of content, files only
size: u64,
mtime: Option<DisplayTime>, // display only — NOT ordering truth
deleted: bool,
}
item_id is identity. path is a derived, cached string computed by walking parent IDs. If a folder is renamed, descendants' paths change but their item_ids remain stable. This is the only correct foundation for rename, move, conflict resolution, and OS placeholder mapping. Path-as-key breaks at the first folder rename.
Content Addressing
File content is stored separately from metadata. The server's blobs table is keyed by SHA-256(content). A file mutation first PUTs the blob (idempotent — the hash is the key), then submits a metadata mutation referencing that hash. Two files with identical bytes reference the same blob automatically.
Folder Subtree Semantics
- Move/rename folder — one mutation, one change log event (
MovedRenamed). Descendants' IDs don't change. Client rederives paths from the updated ancestry. A folder with 1,000 files is one operation, not 1,000. - Delete folder — one server transaction tombstones the folder and all descendants. The change log emits one
DeleteSubtreeevent rather than one event per descendant.
Filename Rules (enforced at server boundary)
07 / Local Item State Machine
Every item in the local engine has a LocalKind value tracking its state relative to the server and local filesystem. Persisted in SQLite — survives crashes and restarts.
op_id.Move and rename operations do not change LocalKind. They update parent_item_id and name, and the engine rederives all affected path caches.
08 / Server-Side Schema
The server stores all authoritative state in PostgreSQL. The change log is the ordering backbone of the entire system.
-- Vault: root of a synced file tree
CREATE TABLE vaults (
vault_id UUID PRIMARY KEY,
root_item_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Item tree: every file and folder
CREATE TABLE items (
item_id UUID PRIMARY KEY,
vault_id UUID NOT NULL REFERENCES vaults,
parent_item_id UUID,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL, -- casefold for collision checks
kind TEXT NOT NULL, -- File | Folder
item_version BIGINT NOT NULL, -- precondition for mutations
content_hash BYTEA, -- SHA-256 of content, files only
size BIGINT,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- Unique sibling names among live items
CREATE UNIQUE INDEX items_live_sibling_name
ON items(vault_id, parent_item_id, normalized_name)
WHERE deleted_at IS NULL;
-- Content-addressed blob store
CREATE TABLE blobs (
content_hash BYTEA PRIMARY KEY, -- SHA-256 is the key
size BIGINT NOT NULL,
storage_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Authoritative ordered change log
CREATE TABLE change_log (
vault_id UUID NOT NULL,
seq BIGINT NOT NULL, -- monotonically increasing per vault
op_id UUID NOT NULL,
device_id UUID NOT NULL,
item_id UUID NOT NULL,
event_kind TEXT NOT NULL, -- Created|Updated|Deleted|DeleteSubtree|MovedRenamed
event_payload JSONB NOT NULL,
committed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (vault_id, seq)
);
-- Idempotency: replay-safe operation results
CREATE TABLE idempotency_keys (
vault_id UUID NOT NULL,
device_id UUID NOT NULL,
op_id UUID NOT NULL,
request_hash BYTEA NOT NULL,
response_payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (vault_id, device_id, op_id)
);
-- Identity & access (migration 0004): a device is a physical
-- machine — one row, one credential, ever.
CREATE TABLE devices (
device_id UUID PRIMARY KEY,
display_name TEXT NOT NULL,
credential_hash BYTEA NOT NULL CHECK (length(credential_hash) = 32),
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- Opaque to TinySync; meaning lives in the Platform Service.
CREATE TABLE groups (
group_id UUID PRIMARY KEY,
display_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE group_devices (
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, device_id)
);
CREATE TABLE group_vaults (
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
vault_id UUID NOT NULL REFERENCES vaults ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, vault_id)
);
seq is a monotonically increasing integer assigned by the server to every committed vault operation. Wall-clock timestamps are stored for display only. Clients order events by seq, not by clock time. Clocks drift, get spoofed, and produce happens-before ambiguity. seq does not.
Retention
| Data | Retention |
|---|---|
| Change log entries | 90 days |
Tombstones (deleted_at) | 90 days |
| Idempotency keys | 7–30 days |
The server exposes min_retained_seq alongside latest_seq. A client reconnecting after last_seq_processed < min_retained_seq must run a full snapshot resync rather than incremental log replay.
09 / Sync Protocol — Bootstrap
When a device first joins a vault, or when local state is too stale for incremental replay, it runs bootstrap: snapshot + log replay.
10 / Normal Sync Cycle
After bootstrap, the engine runs sync_once on every trigger — WebSocket wake, file watcher notification, or timer. Three phases, always in this order.
latest_seq. The client never applies it directly — it always fetches GET /log?after=N and replays in seq order. A dropped wake message is harmless. Correctness lives in the log.
11 / The Mutation Contract
Every change a client proposes is a typed mutation request that the server may accept or reject.
Mutation Types
enum MutationRequest {
CreateFile {
op_id, parent_item_id, item_id, name,
content_hash, size // blob must already be uploaded
},
CreateFolder { op_id, parent_item_id, item_id, name },
ModifyFile {
op_id, item_id,
base_item_version, // optimistic concurrency precondition
content_hash, size
},
Delete { op_id, item_id, base_item_version },
MoveRename {
op_id, item_id, base_item_version,
to_parent_item_id, new_name // atomic — never decomposed into delete + create
},
}
Server Acceptance Conditions
- Device credential is valid and not revoked.
op_idis new, or exactly repeats a prior operation (idempotent replay).- Name passes cross-platform validation.
- No live sibling with the same normalized name at the target parent.
base_item_versionmatches the current server version for existing-item mutations.- Parent folder exists and is not deleted.
- File content blob is already present in the blob store for file mutations.
Idempotency
Every mutation carries an op_id (UUID generated by the client, persisted to SQLite before sending). If the network fails after the server commits but before the client receives the response, the client retries with the identical op_id. The server returns the original result from the idempotency_keys table.
// Guarantees exactly-once effect despite retries
op_id = OpId::new()
persist_pending_op(op_id, request) // written to SQLite FIRST
upload_blob_if_needed(content)
response = submit_mutation(request) // safe to retry with same op_id
if response.accepted:
complete_pending_op(op_id)
12 / Conflict Resolution
Not "last writer wins"
TinySync's model is more precise: first writer whose base version is still current wins. base_item_version on every existing-item mutation is an optimistic concurrency precondition. If Device A and B both start from version 5, and A's mutation arrives first (incrementing to version 6), B's mutation with base_item_version=5 is rejected — not silently overwriting A's confirmed write.
Conflict Policy (v1)
The policy is pluggable — resolve_conflict returns a list of actions and can be swapped without touching the engine:
- Accept the server's current version at the original item path.
- Rename local losing content to
<name> (TinySync conflict <device> op <op_id>).<ext>. - Reclassify the conflict file as a fresh local create and upload it.
- Both versions survive. All devices converge. No data is lost.
13 / Engine Lifecycle States
The engine has five states, persisted in the engine_state SQLite table. Transitions are explicit and logged.
| State | Description |
|---|---|
| Bootstrapping | Fetches server snapshot, enumerates local adapter state, reconciles, replays log events after snapshot seq. Also the entry state after panic-resync. |
| OnlineIdle | Fully caught up. Listening on WebSocket for wake notifications. |
| OnlineSyncing | Actively processing: replaying remote log, hashing local changes, uploading blobs, submitting mutations, satisfying hydration requests. |
| Offline | Server unreachable. Local changes queue in pending_ops. Remote changes fetched on reconnect via GET /log?after=last_seq_processed. |
| PanicResync | Local state suspected corrupt. Engine halts. Recovery: fetch fresh snapshot, hash local files, preserve divergent content as conflict copies, rebuild SQLite from scratch. Re-runnable if interrupted. |
14 / OS Adapter Layer
The OS native provider layer is where files appear in Finder and Explorer, and where lazy hydration happens. It is the most novel architectural feature relative to a traditional sync system.
notify for filesystem watching. Prototype and CLI only; not the production experience.
Adapter Methods
| Method | Called when | Responsibility |
|---|---|---|
apply_create | Remote creates an item | Write bytes (eager) or register placeholder (lazy); create directory for folders |
apply_update | Remote modifies a file | Replace content (eager) or invalidate placeholder (lazy); suppress own-write echo |
apply_delete | Remote deletes an item | Remove file or directory from local filesystem |
apply_move_rename | Remote moves or renames | Move/rename at OS level; suppress echo |
enumerate_local | Engine reconciling | Return all current local items with kind, hash, size, and optional inode-based identity |
read_local_content | Engine uploading local change | Return bytes at path; verify against expected hash |
preserve_conflict_copy | Destructive remote change over dirty local bytes | Copy local file to conflict-named sibling; return None if source vanished |
~/Library/CloudStorage/TinySync/ (Apple mandates this; Dropbox fought it and lost). Hydration callbacks must complete or be failed within a deadline.
CfRegisterSyncRoot. Manages placeholder states: online-only, partially-hydrated, fully-hydrated, pinned, unpinned. Hydration callbacks may request byte ranges — not always whole-file. Kept as a separate process to isolate shell integration failures. Antivirus and indexers routinely trigger hydration; the adapter handles this gracefully.
15 / Auth & Device Model
TinySync has no traditional user account. Authentication is device-scoped — one identity per physical machine — and authorization is group membership, resolved at request time.
| Concept | Description |
|---|---|
| Vault | Sync scope. Named container of items with its own root and change log. Reachable by the devices whose groups it is granted to. |
| Device credential | Revocable bearer token (tsdev_…) — one per physical device, valid across every vault its groups reach. Hash stored at rest; raw token returned only at registration. |
| Group | Opaque authorization unit. Devices and vaults attach via group_devices/group_vaults edges; access is the union over a device's groups. Meaning lives in the Platform Service. |
| Device revocation | Revoking a device kills that one credential everywhere — every vault at once. Other devices are unaffected. Per-vault removal is a group-membership edit, not a revocation. |
| Public file links | Time-limited, revocable, unauthenticated URLs. Linked to item_id (stable across renames). Server stores hash only; raw URL returned once at creation. |
API surface
| Area | Endpoints | Auth |
|---|---|---|
| Vault lifecycle | POST /v1/vaults | Admin |
| Sync | GET /v1/vaults/{id}/snapshot · GET /v1/vaults/{id}/log · GET /v1/vaults/{id}/history · POST /v1/vaults/{id}/mutations · PUT/GET /v1/vaults/{id}/blobs/{hash} · GET /v1/vaults/{id}/events (WebSocket) | Device (group membership) |
| Sharing | POST/GET /v1/vaults/{id}/public-file-links · POST …/public-file-links/{link_id}/revoke · GET /p/{token} | Device / unauthenticated download |
| Device identity | POST /v1/devices (registration; open by default) · GET /v1/devices · POST /v1/devices/{id}/revoke | Open or admin / admin / admin or the device itself |
| Device session | GET /v1/devices/me/vaults · GET /v1/devices/stream (multiplexed wake stream) · GET /v1/vaults/{id}/devices | Device |
| Groups | PUT/GET /v1/groups/{gid} · PUT/DELETE /v1/groups/{gid}/devices/{did} · PUT/DELETE /v1/groups/{gid}/vaults/{vid} | Admin |
16 / Critical Design Invariants
These are load-bearing. Violating any one breaks correctness, safety, or recoverability.
seq is the only ordering truth. Wall-clock timestamps are display metadata only. Log replay enforces strict sequential application — a gap causes an error, not a silent skip.item_id is identity; path is derived. No code path uses a path as a primary key. Rename, move, and case-only renames update ancestry and rederive path caches. This is what makes folder rename correct.GET /log?after=N and applies events in seq order. Correctness lives in the log, not the socket.op_id is generated and persisted before the request is sent. The only way to guarantee idempotency across crashes and network failures. Generating it after a successful response is too late.preserve_conflict_copy is called first. Only after bytes are safe does the engine apply the remote change.MoveRename mutations and MovedRenamed events — one operation regardless of tree size.PresentationAdapter boundary is strict. The engine never imports notify, File Provider, or CFAPI types. Adapters never import conflict policy or change-log sequencing..tinysync-tmp-* path, then rename() to the final location. Prevents partially-written files from being visible or triggering false local-change detection.17 / Key Design Decisions
The decisions below shaped the architecture in ways that are not obvious from the structure alone. Each entry covers what was rejected and why — so future contributors don't re-litigate settled questions without new information.
Language: Rust, not Go
(1) Swift FFI. macOS File Provider extensions are Swift binaries. Rust compiles to
cdylib and links directly. Go's runtime forces a separate process and an extra IPC layer at the exact boundary where we need the most reliability.(2) Windows CFAPI bindings.
windows-rs is Microsoft-official and idiomatic. Go's COM story is manual and poorly maintained.(3) State machine correctness under concurrency. The sync engine is a state machine where concurrent writes must be caught at compile time. Rust's
Send/Sync and borrow checker do this. This matters more when code is AI-assisted and may not be deeply reviewed.
Topology: Cloud-authoritative, not P2P
Cloud-authoritative gives one source of truth, deterministic conflict ordering (server
seq), and simpler ops. LAN peer-assist can be added later as an optimization on top of a correct cloud-ordered log — not as a topology change.
Build order: Engine-first, OS adapters later
Engine-first with the
PresentationAdapter interface means ~90% of client code is reusable when adapters arrive. The ~10% throwaway is the folder-watcher loop — the price of de-risking the engine first. The folder-watcher adapter still exercises the real engine, server protocol, SQLite state, conflicts, tombstones, and log replay.
Process architecture: Thin extension + daemon, not engine-in-extension
SQLite ownership, server credentials, WebSocket poll loops, retry policy, panic-resync state, and event emission need one durable local owner — shared by the CLI, Tauri app, and native provider. That owner is the per-user daemon (
tinysync-daemon). The extension is a thin callback translator; the daemon is the engine.
Desktop app role: Tauri as control plane only
If Tauri owned sync, quitting the app would stop sync. Files would only appear in a Tauri-managed window, not in Finder or Explorer. The native cloud-drive experience would be impossible. Tauri is the control plane. The daemon is the engine. The OS provider is the filesystem face.
IPC protocol: Bounded JSON over local sockets, not gRPC
JSON is not used for file bytes — hydration and local edits move by daemon-approved file references, so IPC stays bounded and readable. The cap of 1 MiB/frame prevents accidental large-payload abuse while keeping the wire format debuggable with
jq. The protocol is capability-negotiated at connect time so components can be updated independently without silent schema drift.
Write path: Native-provider writes bypass PresentationAdapter
PresentationAdapter models the eager/watcher case: the engine observes local filesystem state through an adapter, derives changes, and queues mutations. Native providers invert that flow. When a user saves a file in Finder, the OS extension is called with a concrete user operation — the daemon doesn't need to discover it by watching the filesystem.Forcing this through the adapter would add an artificial "watch and rediscover" layer for a change the OS already reported. The reuse boundary is enforced differently: daemon writes must share blob upload, mutation submission, idempotency, conflict detection, and accepted-event application helpers with the CLI/eager path. They must not invent provider-only mutation semantics.
18 / v1 Scope
TinySync v1 is deliberately narrow. The bet is that one thing done bulletproof beats ten things done okay. The items below are not forgotten — they are explicitly deferred because they would delay a correct, shippable core.
Explicitly cut for v1
| Feature | Why deferred |
|---|---|
| Shared folders / collaboration | Auth and per-folder permissions alone is months of work. Not on the critical path for a personal cloud drive. |
| Selective sync UI | Subsumed by on-demand hydration. If a file hasn't been opened, it hasn't been downloaded. The OS handles this transparently. |
| Version history / restore | Tombstone retention covers recoverability. A full version UI requires storage policy, API surface, and UI complexity not on the critical path. |
| Block-level dedup / binary diffs / content-defined chunking | Whole-file content-addressing ships first. These are post-beta optimization work — they don't change the protocol shape. |
| LAN sync / peer-assist | Optimization on top of the cloud-ordered log, not a topology change. Add it after the cloud path is proven. |
| E2E encryption | Complicates server-side dedup, conflict diagnostics, and abuse handling. Only adopt if it becomes the core product thesis. |
| Bandwidth limiters / schedules | Configuration knobs, not core correctness. Add when users ask. |
| Mobile clients | Different OS APIs (iOS Files app, Android Storage Access Framework) and different lifecycle. A separate effort. |
| Predictive prefetch | An aesthetic layer above correct lazy hydration. Build after hydration is reliable. |
| Conflict merge UI | Optimistic concurrency with conflict-copy preservation covers v1. A merge UI can layer on the same storage model later. |
| Trash / undelete | Tombstone retention (90 days) covers recoverability. A trash UI with restore requires additional storage policy and API surface. |
| Multi-user accounts on one device | Outside the token model. A device identity is a (user × machine) session; a multi-user system requires a user layer above devices (the Platform Service). |
Open questions — deliberately deferred
These are not decided. They are punted until they materially affect a decision that must be made now.
- Object storage provider (R2 vs B2 vs raw S3) — abstracted behind the
BlobStoretrait. Pick when production hosting matters. The choice has no protocol implications. - Tombstone retention window — likely 90 days; decide before the first migration that touches tombstones. Configuration, not architecture.
- File size cap above 50 MB — fine for v1. Revisit when video or large photos enter scope. The cap is a single constant; raising it has no structural implications.
- E2E encryption — depends on product positioning, not technical readiness. The current architecture does not preclude it, but adding it requires rethinking server-side dedup and conflict diagnostics.
- Sync semantics backlog — folder concurrency races, temp/lock file policy, debounce and write coalescing, large-file transfer strategy, cross-platform path edge cases, and provider retry deduplication are tracked separately. Known hardening work; not current blockers.
- Production credential storage — the CLI and daemon store the device token as plaintext in
identity.jsonunder the state root with owner-only0600permissions. The packaged desktop app should move its copy into the platform keychain / credential manager.
Locked — do not reopen without new information
These were debated and decided. Reopening requires a genuinely new constraint, not just a new mood.
19 / Local Mutations & Device Fanout
This section traces the complete lifecycle of a file change — from the moment a user saves a file on Device A to the moment it appears on Device B and C. There are two directions: local → cloud (mutations) and cloud → local (log replay). They are deliberately decoupled.
The two directions
| Direction | Trigger | Mechanism | Ordering guarantee |
|---|---|---|---|
| Local → Cloud | File change detected by adapter | Hash → upload blob → submit mutation with base_item_version |
Server assigns seq at commit time. First writer whose precondition holds wins. |
| Cloud → Local | WebSocket wake hint, or poll timer | GET /log?after=last_seq → replay events in order |
Strictly sequential by seq. A gap is an error, not a skip. |
Local mutation path — step by step
op_id is written to pending_ops in SQLite before any network call. If the device crashes after the server commits but before the client receives the response, the engine retries on restart with the same op_id. The server looks it up in idempotency_keys and returns the original result — the mutation is not applied twice.
Server commit & fanout
When the server accepts a mutation it does three things in one transaction: updates the item record (bumping item_version), appends to change_log (assigning seq), and records the idempotency result. After the transaction commits, it triggers fanout.
latest_seq — a hint that something changed. Devices never apply wake payloads directly. They always fetch GET /log?after=N and replay in seq order. A dropped or delayed wake message is harmless — the next poll or reconnect catches up. The WebSocket is a latency optimisation, not a correctness path.
Full end-to-end: A modifies, B and C receive
Concurrent writes from multiple devices
Multiple devices can submit mutations for the same item concurrently. The server is the arbiter. The first mutation whose base_item_version matches the current server state is accepted and assigned a seq. All others are rejected with StaleBaseItemVersion. The losing device's local content is preserved as a conflict copy (see §12).
This means the server never merges — it serialises. The seq is the total order across all vaults. Within a vault, item_version is the per-item optimistic lock.
Offline durability
When a device is offline, local changes continue to queue in pending_ops (SQLite). The device's local filesystem reflects the user's changes immediately. On reconnect:
- Check
last_seq_processedagainst server'smin_retained_seq. - If within the retention window → incremental
GET /log?after=Nreplay. - If the cursor has fallen behind
min_retained_seq(device was offline > 90 days) → full snapshot resync. - After pulling remote changes, submit all queued
pending_opsin creation order.
base_item_version on submit. The conflict-copy policy applies exactly as in the online case — A's content is preserved, B's server-committed version wins the canonical path.
20 / Device Authentication & Access
TinySync uses a custom bearer token scheme — not JWT. The design is deliberately minimal: stateless parsing, one database lookup per request, and immediate per-device revocation without token expiry windows.
Why not JWT
JWT is self-contained — the server can validate a token without a database round-trip. This is its main advantage. But TinySync's threat model requires immediate per-device revocation: if a device is lost or compromised, its credential must be dead the moment an admin revokes it. JWT cannot provide this without a revocation list — which is a database lookup anyway. Since TinySync already hits Postgres on every sync API call, the lookup costs nothing extra. Self-contained claims provide no benefit and add unnecessary surface area.
Token anatomy
All tokens follow the same structure: a typed prefix, a stable identifier, and a high-entropy secret — joined by underscores.
Device token: tsdev_{device_id}_{secret}
Public link: {secret} (bare base64url, no prefix)
Admin token: static value from TINYSYNC_ADMIN_TOKEN (env)
The secret in every case is 32 random bytes, base64url-encoded without padding — giving a 43-character string. The server generates this with thread_rng().fill_bytes() (device) or OsRng.fill_bytes() (public links, where the stronger CSPRNG is preferred since the token is the only auth factor).
The server never stores the raw secret. It stores:
SHA-256("tinysync:v1:{kind}:" || secret_bytes)
The domain prefix (tinysync:v1:device:, tinysync:v1:public_link:, etc.) ensures the same raw bytes produce a different hash for different token kinds — preventing cross-kind token confusion even if secrets were ever reused.
Token types & properties
| Token | Format | TTL | Uses | Purpose |
|---|---|---|---|---|
| Device | tsdev_{uuid}_{43-char} |
Until revoked | Unlimited | All sync API calls. One per physical device; vault reach is resolved from group membership per request. |
| Public link | {43-char} |
Configurable or none | Unlimited | Unauthenticated file download. Linked to item_id (stable across renames). |
| Admin | Static env var | ∞ | Unlimited | Server-level: vault creation, group management, device list/revocation, registration when closed. Never distributed to clients. |
Request authentication flow
Device lifecycle
Registration flow — adding a new device
Registration replaces the old join-token exchange. It issues an identity, not access — a freshly registered device reaches nothing until an admin grants it group membership.
- The device calls
POST /v1/devices { display_name }. Open by default; withTINYSYNC_OPEN_DEVICE_REGISTRATION=falsethe admin token is required (the Platform-Service-mediated mode). - The server creates the device record, stores the credential hash, and returns the raw device token once.
- An admin (or the Platform Service) grants access:
PUT /v1/groups/{gid}/devices/{did}andPUT /v1/groups/{gid}/vaults/{vid}. - The raw device credential is stored by the client in
identity.jsonunder the state root with0600permissions. It is never sent to the server again as a raw value — only used to construct request headers.
<state-root>/identity.json with owner-only 0600 permissions. This is acceptable for CLI and prototype use; the packaged desktop app should move its copy into the platform keychain (macOS Keychain, Windows Credential Manager) before beta.
Revocation
Revoking a device sets revoked_at on its record — the row and its credential hash are retained so change-log attribution survives. Every subsequent request from that device returns 403 "device is revoked". Revocation is global: the one credential dies across every vault at once. No other device is affected. Removing a device from a single vault is a group-membership edit (DELETE /v1/groups/{gid}/devices/{did}), not a revocation.
If a device creates public share links, those can be bulk-revoked at the same time: POST /v1/devices/{id}/revoke accepts a flag revoke_public_links_created_by_device: true. This is the intended response to a compromised device.
Security properties
| Property | How it holds |
|---|---|
| Secret confidentiality | Server stores only SHA-256(prefix + secret). A Postgres breach does not expose raw tokens. |
| Immediate revocation | Every request checks revoked_at. No expiry window. No need for revocation lists. |
| Cross-kind confusion prevention | Domain separator (tinysync:v1:{kind}:) in the hash input ensures a device secret cannot be used as a public-link token even if the raw bytes match. |
| Registration grants nothing | POST /v1/devices issues an identity, not access. An unprovisioned device reaches zero vaults until an admin adds it to a group with vault grants. |
| No vault-wide rotation | Device credentials are per-device. Revoking one device has zero impact on others. |
| Public links are bearer secrets | Raw URL returned once at creation. Server stores only the hash. Lost link cannot be recovered — must be revoked and recreated. |
21 / Drive as a Service — Platform Integration
0004): groups and membership edges now live inside TinySync (see §15 and §20), so the join-token orchestration and per-(device × vault) token provisioning sketched below are superseded — access grants are now PUT /v1/groups/* edge writes. The user layer, container policies, and web view layer remain unbuilt proposals.
TinySync is a sync primitive — it understands vaults and devices. It does not understand users, teams, projects, or products. This is intentional. The goal of "Drive as a Service" is to define the thinnest possible interface that lets ecosystem products leverage TinySync without forcing TinySync to absorb identity concerns it should not own.
The three-layer model
Ownership boundary
| Concept | Owned by | Rationale |
|---|---|---|
| Vaults, change log, blobs, sync protocol | TinySync Core | The sync primitive. Must stay stable. |
| Devices, device tokens, revocation | TinySync Core | Sync identity. Device is the right granularity for auth. |
| Users | Drive Platform Service | User identity varies per ecosystem — SSO, OAuth, magic links. TinySync should not care. |
| Groups / Teams / Projects | Drive Platform Service | Organizational constructs that map to sets of users. Not a sync concept. |
| User → Vault access grants | Drive Platform Service | Access policy, not sync mechanics. Expressed to TinySync as device registrations. |
| Group → User fan-out | Drive Platform Service | The Platform resolves group membership to users, then to devices, then provisions tokens. |
| Container tags + policies | Drive Platform Service | Product-level namespacing and configuration. TinySync carries the tag; Platform interprets it. |
| Web views, sharing UI | Drive Platform Service | Read TinySync APIs; render product-specific experiences. |
Data model — which tables live where
Access model — three levels of fan-out
Access can be granted at any of three levels. The Platform Service is responsible for expanding higher-level grants downward to devices.
TinySync sees only the bottom two rows — devices and device tokens. The group and user structure above that is entirely the Platform Service's concern.
Token orchestration — provisioning device × vault access
The fundamental operation: given a (user, vault) grant, ensure every device that user owns has a valid device token for that vault. This is called token provisioning.
Vault creation — product-driven
Ecosystem products create vaults via the Platform Service, not directly against TinySync. The Platform Service calls TinySync's admin API and records metadata on both sides.
Container tags and policy layer
A container is a product's namespace within Drive. Every vault created by a product carries that product's container tag (e.g. tasket:project, notes:personal). The tag is stored on the vault in TinySync and is visible in the change log, enabling per-product behaviour without TinySync understanding product semantics.
Container-level policies are owned by the Platform Service. They are delivered to devices as part of the vault bootstrap configuration — alongside the device token, not inside TinySync's sync protocol.
| Policy type | Where stored | How delivered | Who enforces |
|---|---|---|---|
syncignore rules | Platform: container_policies | In vault bootstrap response from Platform | Client SDK — files matching rules are not uploaded |
| Max file size | Platform (+ TinySync enforces at mutation) | Bootstrap config | Both: client skips; server rejects oversized |
| Allowed file types | Platform: container_policies | Bootstrap config | Client SDK |
| Retention / archival | Platform | Server-side job; not delivered to clients | Platform Service |
| Sharing permissions | Platform: vault grants | Embedded in access grant | Platform (controls who can generate share links) |
Web view layer
The Platform Service hosts web experiences per product. A web session authenticates against the Platform (not TinySync directly) and receives a short-lived session token scoped to the user's accessible vaults. The web app calls the Platform's read-through API, which proxies to TinySync for file tree and history data.
Web sessions use session tokens (tssess_...) — not device tokens. A browser is not a sync device; it gets read access to the vault's current snapshot and history, not the sync protocol.
Access revocation and group membership changes
When a user's access changes (leaves a group, vault access revoked), the Platform Service must revoke their devices' TinySync credentials for the affected vaults. This is a cascade the Platform owns entirely — TinySync just executes the revocations.
| Event | Platform action | TinySync action |
|---|---|---|
| User removed from group | Delete group_members row. Compute vaults user no longer has access to via other grants. | DELETE /vaults/{id}/devices/{device_id} for each affected (device × vault) |
| Vault access revoked for group | Delete group_vault_grants. Expand to all users and devices with no other path to this vault. | Bulk revoke affected devices from vault |
| User account deleted | Delete all user grants. Compute all vaults with no remaining grants. | Revoke all user devices from all vaults |
| Device deregistered by user | Delete user_devices row. | DELETE /devices/{device_id} — revokes from all vaults simultaneously |
Open questions
- Platform admin credential model. The Platform Service needs an admin-level credential to call TinySync (create vaults, generate join tokens, revoke devices). Should this be a static admin token per environment, or a short-lived service token per operation? Static is simpler; short-lived is safer.
- Eager vs lazy provisioning. Lazy handles offline devices but adds latency (device must check in before it can sync a newly granted vault). Eager is instant but burns join tokens. A hybrid — eager for online devices, lazy fallback — may be the right default.
- Group membership change propagation time. When a user is removed from a group, how quickly must their TinySync access be revoked? Real-time revocation requires the Platform to call TinySync synchronously. Eventual (next sync cycle) may be acceptable depending on the product's threat model.
- Multi-vault device bootstrap. A device may have access to many vaults (personal, multiple team projects). Should the device maintain one SQLite engine per vault (current model) or a shared engine with vault partitioning? Current model works but may need UX to manage which vaults are synced on which device.
- Policy versioning. Container policies will evolve. When a policy changes (e.g. syncignore rules tightened), already-synced files that violate the new policy need handling. Define a policy version in bootstrap config; client re-evaluates on policy version bump.
- Web blob access. Should the web app download blobs directly from TinySync's blob endpoint (efficient, requires a valid token) or via a Platform-proxied URL (allows Platform to enforce per-file permissions)? Direct is simpler; proxied gives finer-grained access control.
- Shared devices. If two users share a physical device (family Mac), each user needs separate device registrations per vault. The client must support multiple vault credentials on one device and associate each with the appropriate user session.
What TinySync needs to add to support this
Almost nothing. The design intentionally keeps TinySync unchanged. The only additions that would help:
- Batch join token creation. Provisioning tokens one at a time when a large group is granted access is slow. A single
POST /vaults/{id}/join-tokens/batchendpoint with a count parameter would help the Platform Service. - Batch device revocation. Similarly, revoking all devices for a user across all vaults in one call is cleaner than N individual revocations.
- Container tag filtering on vault listing.
GET /vaults?container_tag=tasket:project— useful for the Platform to enumerate vaults it owns without a full scan. - Webhook on device sync activity. Optional. Lets the Platform Service track last-seen-at per device without polling.
The sync protocol, change log, blob store, and conflict model require zero changes. Drive-as-a-service is a thin API layer above an unchanged sync engine.