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)
Local item states
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.
The server's ledger
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)
);
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.
When devices disagree
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.
Invariants the design defends
These are load-bearing. Violating any one breaks correctness, safety, or recoverability.
seq is the only ordering truth.item_id is identity; path is derived.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.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.