TinySync Understand Integrate Decide FAQ Playground ↗
Understand · Chapter 4 of 5

Truth & Conflict

The data model on both sides, the local state machine, and what happens when two devices disagree.

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,
}
Identity vs Path
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.

Item Tree — Example
graph TD Root["📁 Root · item_id: root-uuid · parent: null"] Docs["📁 Documents · item_id: docs-uuid"] Photos["📁 Photos · item_id: photos-uuid"] Report["📄 report.pdf · hash: sha256(...)"] Notes["📄 notes.txt · hash: sha256(...)"] Vacation["📄 vacation.jpg · hash: sha256(...)"] Root --> Docs & Photos Docs --> Report & Notes Photos --> Vacation

Folder Subtree Semantics

Filename Rules (enforced at server boundary)

No path separators No Windows reserved chars No reserved device names (CON, NUL…) No trailing spaces or dots No empty names NFC Unicode normalization Case-insensitive sibling uniqueness Max path depth enforced

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.

Absent
Known to the engine but no local filesystem presence. Starting state and tombstone state.
Folder
A directory exists locally. No content hash — identity and children matter.
Placeholder
File entry visible in Finder/Explorer but bytes not yet downloaded. Lazy adapters only.
Hydrated
File content fully present and matches the server's known hash. No pending upload or conflict.
Modified
Local content differs from last known server version. A pending upload is queued with an idempotent op_id.
Conflicted
Server rejected the upload (stale base) or remote update arrived while upload was pending. Local bytes preserved as conflict copy.
Per-Item LocalKind — State Transitions
stateDiagram-v2 [*] --> Absent : item first learned from server log Absent --> Folder : remote_create_folder / local_create_folder Absent --> Placeholder : remote_create_file (lazy adapter) Absent --> Hydrated : remote_create_file (eager) / local_create_file accepted Placeholder --> Hydrated : OS hydration callback fulfilled Placeholder --> Absent : remote_delete Folder --> Absent : remote_delete / local_delete accepted Hydrated --> Hydrated : remote_update clean / local overwrite accepted Hydrated --> Modified : local_modify detected Hydrated --> Absent : remote_delete / local_delete accepted Modified --> Hydrated : upload_accepted by server Modified --> Conflicted : stale_base_rejected / remote_update during pending upload Conflicted --> Hydrated : conflict_policy_applied

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 the only ordering truth
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

DataRetention
Change log entries90 days
Tombstones (deleted_at)90 days
Idempotency keys7–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 Scenario — Two Concurrent Modifies
sequenceDiagram participant A as Device A participant S as Server participant B as Device B note over A,B: Both have report.pdf at item_version=5 A->>S: ModifyFile { base=5, hash=hashA, op_id=opA } S-->>A: { accepted: true, seq: 101, item_version: 6 } B->>S: ModifyFile { base=5, hash=hashB, op_id=opB } S-->>B: { accepted: false, conflict: StaleBaseItemVersion } note over B: Preserve conflict copy → "report (TinySync conflict dev8b op …).pdf" B->>S: GET /log?after=last_seq S-->>B: event: Updated { item_version: 6, hash: hashA } note over B: Apply server version at original path. Queue conflict copy as new CreateFile. B->>S: CreateFile { name: "report (conflict...)", hash: hashB } S-->>B: { accepted: true } note over A,B: Both versions exist on server. All devices converge.

Conflict Policy (v1)

The policy is pluggable — resolve_conflict returns a list of actions and can be swapped without touching the engine:

  1. Accept the server's current version at the original item path.
  2. Rename local losing content to <name> (TinySync conflict <device> op <op_id>).<ext>.
  3. Reclassify the conflict file as a fresh local create and upload it.
  4. Both versions survive. All devices converge. No data is lost.
Conflict preservation also guards remote-delete-during-modify
If Device B has modified a file and Device A deletes it, the same mechanism fires: B's content is preserved as a conflict copy before the local file is removed.

Invariants the design defends

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.
WebSocket wake payloads are never applied directly.
A wake message is a hint. The client always fetches 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.
Blob must be uploaded before mutation is submitted.
A file mutation references a content hash. The server validates the blob exists. The upload-then-mutate order is enforced in the engine, not assumed.
Local content is preserved before any destructive remote apply.
If a file has dirty local changes and a remote delete or conflicting update arrives, preserve_conflict_copy is called first. Only after bytes are safe does the engine apply the remote change.
Move and rename are never decomposed into delete + create.
A folder rename generating 1,000 fake file-level events is rejected. The engine expresses these as MoveRename mutations and MovedRenamed events — one operation regardless of tree size.
The engine does not own OS callbacks; the adapter does not own sync semantics.
The PresentationAdapter boundary is strict. The engine never imports notify, File Provider, or CFAPI types. Adapters never import conflict policy or change-log sequencing.
Filesystem writes use temp-then-atomic-rename.
Remote apply writes to a .tinysync-tmp-* path, then rename() to the final location. Prevents partially-written files from being visible or triggering false local-change detection.
Panic-resync is a conservative last resort, not a shortcut.
On local invariant violation: fetch fresh snapshot, hash every local file, preserve divergent content as conflict copies, rebuild SQLite from scratch. The rebuild is ordered so it is safe to re-run after a mid-resync crash.