Conflict-free, end-to-end encrypted, infrastructure-agnostic synchronization for the local-first era.
VaultSync is an embedded local-first synchronization and replication runtime with CRDT-native conflict resolution, end-to-end encryption, multi-tab/process safety, and zero-trust coordinator architecture. It makes your application work instantly offline, sync automatically when online, and feel 0ms fast β because every read and write hits a local database, never a remote server.
- Why VaultSync?
- The Business Case β Real Benefits
- How It Works β High-Level
- Core Architecture
- CRDT Document Model
- End-to-End Encryption (E2EE)
- Multi-Tab & Multi-Process Safety
- Offline Support & Durability
- Synchronization Flow
- Reactive Subscriptions
- Coordinator Abstraction Layer
- Performance Model
- Deployment Modes
- SDK Reference β Quickstart
- Schema System & Migrations
- Observability & Debuggability
- Security Model
- Real-World Use Cases
- VaultSync vs The World
- Repository Structure
- Contributing
- Production Checklist
The traditional web architecture forces every user interaction through a server:
User clicks button
β
HTTP Request β API Server β Remote Database
β
Wait 100β2000ms for round-trip
β
UI updates
This creates a cascade of real problems:
- Your app is unusable offline. No server = no data. A 2-second underground subway trip breaks your product.
- Every interaction has latency. Every click waits for a network round-trip. On mobile under 4G, that's 200β2000ms.
- High server costs. Every read and write is an API call. 1 million users = 1 million round-trips per page load.
- Realtime collaboration is complex. You need WebSockets, event buses, conflict resolution logic β all custom.
- Server outages break everything. When your backend goes down, every user sees an error screen.
- Data breaches expose everything. If your server is compromised, all user data is exposed in plaintext.
VaultSync flips the model: data lives on the device first, syncs to the coordinator in the background.
Every read and write goes to a local embedded database (SQLite on native, OPFS or IndexedDB in-browser). There is no network round-trip on the critical path.
User clicks button
β
Local CRDT write β SQLite/OPFS β sub-millisecond
β
UI updates instantly
β (background, async)
Encrypted mutation uploads to coordinator
β
Other devices receive & merge
Perceived latency: 0ms. Users feel no lag even on slow networks, because the UI updates before the network is involved.
Users can read and write all day on an airplane with no internet. Every mutation is stored locally as an encrypted CRDT entry. When connectivity returns, VaultSync automatically uploads everything and downloads everything they missed β no manual conflict resolution, no data loss.
A user who makes 47 changes offline lands, reconnects, and every change syncs without them lifting a finger.
With VaultSync, reads no longer touch your API servers. A user browsing through 1000 records creates 0 server requests β all data is served from local storage. Server load drops to only sync operations (uploads and downloads of CRDT mutations), which are batched and efficient.
In real applications, this reduces API call volume by 60β95%, cutting infrastructure costs proportionally.
When two users edit the same record at the same time, VaultSync's CRDT merge algorithm handles it automatically and deterministically. There are no conflicts to resolve, no "someone else changed this" dialogs, no last-write-wins data loss. Both changes are preserved.
The sync server (coordinator) never sees your data. Every CRDT mutation is encrypted on-device with X25519 + ChaCha20-Poly1305 before leaving. The coordinator stores only encrypted blobs. Even if your sync infrastructure is breached, no user data is exposed.
This means VaultSync is HIPAA-ready by default β the coordinator literally cannot read patient records.
Open the same app in 3 browser tabs. Close the one that was writing. VaultSync automatically elects a new leader, replays any in-flight mutations, and continues β with no data loss, no corruption, no stale reads.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Application Process β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Application Layer β β
β β vaultsync.db.todos.insert({ text: "buy milk" }) β β
β β vaultsync.db.todos.subscribe(callback) β β
β βββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ β
β β VaultSync Core Engine (Rust + WASM) β β
β β β β
β β ββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ β β
β β β CRDT Layer (Yrs) β β E2EE Encryption β β β
β β β Conflict-free β β X25519 + ChaCha20Poly1305 β β β
β β β merge of all ops β β Coordinator sees 0 plaintextβ β β
β β ββββββββββββ¬ββββββββββ ββββββββββββββββββ¬ββββββββββββββ β β
β β β β β β
β β ββββββββββββΌβββββββββββββββββββββββββββββββΌββββββββββββββ β β
β β β Multi-Tab IPC Layer β β β
β β β Leader Election + Shared Memory + Crash Recovery β β β
β β ββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β β
β β β β β
β β ββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ β β
β β β Oplog & Sync Engine β β β
β β β Append-only encrypted CRDT mutation log β β β
β β β Upload queue + retry + batching β β β
β β β Download queue + replay + CRDT merge β β β
β β ββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β β
β β β β β
β β ββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ β β
β β β Storage Abstraction β β β
β β β SQLite / OPFS / IndexedDB / RocksDB / In-Memory β β β
β β ββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β β
β β β β β
β β ββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ β β
β β β Transport Abstraction β β β
β β β BC (BroadcastChannel) β WS (WebSocket) β P2P (WebRTC) β β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββ΄βββββββββββββββββββββββββ
β Zero-Trust Coordinator (your choice) β
β Postgres β Redis β Cloudflare DO β Custom β
β (only sees encrypted blobs + routing metadata)β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
VaultSync is built on three non-negotiable design principles:
Concurrent writes never conflict. There is no V1 (last-write-wins) β V2 (field merge) β V3 (CRDT) upgrade path. Every operation from the first line of code is a Yrs CRDT mutation. All replicas converge deterministically regardless of write order.
| CRDT Type | Merge Behavior | Use Cases |
|---|---|---|
| LWW-Register | Last-write-wins by hybrid logical clock | Text fields, status flags, scalars |
| PN-Counter | Increment/decrement sums across all replicas | Likes, votes, inventory counts |
| OR-Set | Add/remove with per-element tombstones | Tags, labels, member lists |
| Yrs Text | Sequence CRDT (OT-compatible) | Rich text, collaborative documents |
| Yrs Array | Ordered sequence with insert/delete/move | To-do lists, ranked items |
The sync server is cryptographically blind to your data. The coordinator stores:
{
"id": "mut_abc123",
"namespace": "workspace:core",
"replica_id": "laptop-abc",
"encrypted": "<base64 ciphertext>",
"timestamp": 1740000000,
"sequence": 42
}No field names. No field values. No schema info. Even if your coordinator is fully compromised, attackers see only encrypted bytes. Decryption keys never leave the device.
The Coordinator is a Rust trait, not a specific service. Choose or build:
pub trait Coordinator: Send + Sync + Debug {
async fn push(mutations: Vec<EncryptedMutation>) -> Result<Vec<SequenceId>>;
async fn pull(after: SequenceId, limit: usize) -> Result<Vec<PendingMutation>>;
async fn subscribe(from_sequence: SequenceId) -> Result<Box<dyn Stream<...>>>;
async fn register(namespace: &str, info: ReplicaInfo) -> Result<()>;
}Swap from Postgres to Redis to Cloudflare DO without changing a single line of application code.
Traditional sync engines treat conflicts as exceptional β something to detect and resolve after they happen. This leads to:
- Complex conflict detection logic
- Data loss on last-write-wins
- User-facing "merge conflict" dialogs
- Brittle upgrade paths
CRDTs eliminate the concept of conflict entirely. Every concurrent write produces a deterministic merge.
Replica A edits: doc.set("text", "buy organic milk") β Yrs update binary diff
Replica B edits: doc.set("completed", true) β Yrs update binary diff
Both updates applied to either replica:
β { text: "buy organic milk", completed: true }
Both replicas converge. Both changes preserved. No conflicts.
When two replicas edit the same field concurrently:
Replica A (timestamp T1): doc.set("text", "version A")
Replica B (timestamp T2): doc.set("text", "version B")
LWW-Register: latest timestamp wins β { text: "version B" }
Deterministic. No data loss for the later write.
Every table in VaultSync is a Yrs CRDT Map:
Document: "todos" β record: "todo:123" (Yrs Map)
βββ field: "text" β LWW-Register("build vaultsync")
βββ field: "completed" β LWW-Register(false)
βββ field: "priority" β PN-Counter(3)
βββ field: "tags" β OR-Set(["backend", "rust"])
βββ field: "assignee" β LWW-Register("alice")
Every write produces a Yrs binary diff β a small, self-contained update that can be applied to any replica's document to produce the same result, regardless of order.
Because every operation has always been a CRDT mutation:
- No data format migration when adding features
- No conflict handler rewrite ever
- No schema redefinition as CRDT types are declared at definition time
- Additive-only schema changes β new fields can be added at any time without affecting existing data
Every other sync engine (ElectricSQL, Zero, PowerSync, Replicache) stores plaintext data on the coordinator. This means:
- The coordinator operator can read all synced user data
- A coordinator breach exposes every user's data
- HIPAA, GDPR, SOC2 compliance requires additional layers
VaultSync's E2EE ensures the coordinator is zero-trust. It stores only encrypted blobs and routing metadata. It cannot read any application data β ever.
Local CRDT write
β
βΌ
Yrs binary diff produced (the mutation delta)
β
βΌ
Encrypt with namespace symmetric key:
ciphertext = ChaCha20Poly1305_encrypt(yrs_update, namespace_key)
β
βΌ
Local oplog stores:
yrs_update: plaintext Yrs diff β for local fast merge
encrypted_blob: AEAD ciphertext β for upload to coordinator
β
βΌ
Upload to coordinator:
{ id, replicaId, namespace, encrypted_blob, timestamp }
β coordinator never sees yrs_update plaintext
β
βΌ
Other replica downloads encrypted_blob
β
βΌ
Decrypt with namespace symmetric key
β
βΌ
Merge Yrs diff into local CRDT document
β
βΌ
UI updates automatically via subscription
- X25519 keypair per replica β public key registered with coordinator, private key never leaves device
- Symmetric namespace key β shared by all authorized replicas, never sent to coordinator
- New device onboarding β trusted replica wraps the namespace key with the new device's public key and delivers it securely
- Key rotation β supported at any time; old mutations remain decryptable with old key version stored in local keychain
- Replay attack prevention β each mutation has a unique globally-scoped ID; coordinator deduplicates idempotently
| Information | Coordinator Sees? |
|---|---|
| Encrypted mutation blob | β (opaque bytes) |
| Replica ID (who sent it) | β (routing) |
| Namespace (which dataset) | β (routing) |
| Sequence number | β (ordering) |
| Field names | β (encrypted) |
| Field values | β (encrypted) |
| Document structure | β (encrypted) |
| CRDT type information | β (encrypted) |
When multiple browser tabs, Electron windows, or OS processes share the same local database (one SQLite file or OPFS directory), concurrent writes cause:
- SQLite locking errors (
SQLITE_BUSY) - CRDT document corruption (concurrent Yrs mutations outside a lock)
- Oplog ordering violations
- Subscription double-firing
VaultSync uses leader election to coordinate write access. One process writes; all others read via shared memory.
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ
β Tab A (Leader) β β Tab B (Reader) β β Tab C (Reader) β
β β β β β β
β Write access: β
β β Write access: β β β Write access: β β
β Lock: acquired β β Lock: waiting β β Lock: waiting β
β Heartbeat: 1s β β β β β
ββββββββββ¬ββββββββββ ββββββββββ¬βββββββββββ ββββββββββ¬βββββββββββ
β β β
βββββββββββββββββββββββββΌβββββββββββββββββββββββββββ
β
ββββββββββββββΌββββββββββββββββ
β Shared Memory Region β
β CRDT document snapshots β
β Oplog tail (ring buffer) β
β Subscription notifications β
βββββββββββββββββββββββββββββββ
β
ββββββββββββββΌββββββββββββββββ
β Local Storage (SQLite/OPFS) β
βββββββββββββββββββββββββββββββ
| Platform | Mechanism |
|---|---|
| Browser (tabs) | BroadcastChannel + navigator.locks.request on vaultsync-leader lock |
| Native (Linux/macOS/Windows) | flock() on SQLite WAL file or CreateMutex |
| Electron | Browser mechanism + net.Server on local socket for cross-window IPC |
Leader writes heartbeat every 1 second:
{ leaderId: "tab-A", timestamp: ..., sequence: 42 }
Reader checks every 500ms:
if current_time - leader.heartbeat > 3000ms:
β leader considered dead
β reader acquires lock β becomes new leader
β replays uncommitted ops from shared memory ring buffer
β notifies other readers via BroadcastChannel
No data is lost β shared memory ring buffer preserves last N in-flight mutations.
New leader continues sync from last confirmed sequence.
When a CRDT merge happens:
- Leader merges mutation, writes to storage, updates shared memory
- Leader fires subscription callbacks in its own process
- Leader sends
{ changedDocs: ["todos/789"] }via BroadcastChannel - Reader tabs detect change, read from shared memory
- Reader tabs fire their own subscription callbacks
All tabs see the update in < 5ms β even the ones that didn't initiate the write.
Network disconnects
β
βΌ
VaultSync detects disconnect (WebSocket close / heartbeat timeout)
β
βΌ
Connection status β "disconnected"
β
βΌ
Application continues working fully:
β
Reads served from local CRDT materialized state (instant)
β
Writes produce CRDT mutations β persist to local DB + oplog (status: pending)
β
All mutations E2EE-encrypted before storage
β
Subscriptions continue firing on local CRDT merges
β
UI remains fully interactive
β
Multi-tab: leader continues serving all tabs
Pending mutations accumulate in the local oplog. The coordinator is not involved.
Network reconnects
β
βΌ
VaultSync reconnects to coordinator (exponential backoff: 1s β 2s β 4s β ... β 30s)
β
βΌ
Step 1: Upload backlog
β Read all pending mutations from oplog
β Sort by local timestamp
β Upload in batches (encrypted)
β
βΌ
Step 2: Download missed mutations
β Request all mutations with sequence > last_synced_sequence
β Decrypt each
β Merge into local CRDT documents
β Fire subscriptions for affected documents
β
βΌ
Connection status β "connected"
β
βΌ
Resume real-time sync
| Scenario | Result |
|---|---|
| Process crash | Mutations in oplog survive β re-uploaded on restart |
| Tab close | Shared memory ring buffer preserves in-flight mutations; new leader replays them |
| Power failure | Oplog on persistent storage survives; re-uploaded on restart |
| Local storage theft | E2EE oplog encrypted at rest β attacker sees only ciphertext |
CRDT uploads are idempotent by mutation ID. Re-uploading a mutation the coordinator has already seen causes a silent deduplicated no-op. Zero duplicates.
A complete, annotated trace of a local write propagating to a remote replica:
Step 1: Application Write
vaultsync.db.todos.insert({ id: "todo:123", text: "build vaultsync" })
β
βΌ CRDT Engine
Yrs document "todos/123" receives mutation
Yrs produces binary diff (yrs_update)
β
βΌ E2EE Layer
encrypted_blob = ChaCha20Poly1305_encrypt(yrs_update, namespace_key)
β
βΌ Storage (atomic transaction, Leader only)
BEGIN TRANSACTION
INSERT vaultsync_oplog (yrs_update, encrypted_blob, status: "pending")
UPDATE vaultsync_documents (new Yrs snapshot for fast queries)
COMMIT
β
βΌ UI
Subscription fires immediately β React re-renders <0.1ms after write
Step 2: Background Upload
Upload worker reads pending oplog entries
β
Batches multiple pending mutations if present
β
Sends encrypted_blob to coordinator via WebSocket PUSH frame
(coordinator never sees yrs_update plaintext)
Step 3: Coordinator Processing
Validates: replica authorized? schema version compatible?
β
Assigns monotonic sequence number (e.g., 44)
β
Persists { sequence: 44, replica_id, encrypted_blob, timestamp }
β
Fans out MUTATION_PUSH to all other connected replicas in namespace
Step 4: ACK Back to Sender
Coordinator sends PUSH_ACK { request_id, sequences: [44] }
β
Local oplog: sync_status β "synced"
vaultsync_sync_state: last_synced_sequence = 44
Step 5: Remote Replica Receives Mutation
Replica B receives MUTATION_PUSH (sequence 44, encrypted_blob)
β
Decrypts: yrs_update = decrypt(encrypted_blob, namespace_key)
β
CRDT engine: Yrs.merge(yrs_update) into local "todos/123" document
β
Subscription fires in Replica B β UI updates
Total end-to-end: 50β500ms on typical connections
optimistic β applied locally as optimistic CRDT write, enqueued for upload (OPFS-backed)
pending β written locally, enqueued for upload
uploading β currently being sent to coordinator
synced β coordinator confirmed receipt and assigned sequence
failed β upload failed; in retry queue (exponential backoff)
Note: There is no "conflict" state. CRDT mutations never conflict.
All client timestamps are generated by a CAS-based HybridLogicalClock that ensures monotonic ordering across clock drift and concurrent writes:
now():
if system_time > stored_wall β jump forward, reset logical counter
if system_time == stored_wall β increment logical counter
if system_time < stored_wall (clock went backwards) β increment logical counter
update_with_received(received_hlc):
local_wall = max(local_wall, received_wall, system_time)
local_logical = max(local_logical, received_logical) + 1
Key properties:
- Thread-safe via atomics β
AtomicU64for wall clock,AtomicU32for logical counter, CAS-based update loop. now().wallreplaces all previous clientside timestamp generation in the engine.update_with_received()ensures causal ordering when merging mutations from remote peers.- Wrapping detection β a
wrappedflag is set when the logical counter overflows (exposed viahlc_logical_wrapsmetric).
The clock is stored on VaultSyncClient and drives every local mutation timestamp, including optimistic writes.
All coordinator communication uses binary WebSocket frames with a 5-byte header:
ββββββββββββ¬βββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β type: u8 β length: u32β payload: JSON bytes (length bytes) β
β (1 byte) β (4 bytes) β β
ββββββββββββ΄βββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββ
Key message types: AUTH, REGISTER, PUSH, PUSH_ACK, PULL, PULL_RESPONSE, MUTATION_PUSH, HEARTBEAT, KEY_FETCH, SCHEMA_SYNC.
REGISTER_ACK payload now includes a generation_id field (#[serde(default)] for backward compatibility). On register, the client compares this with its stored SyncState.generation_id. A mismatch triggers an eager cursor reset to 0 and full re-download β this handles server restart scenarios where the mutation store has been replaced.
VaultSync subscriptions fire on both local writes and incoming remote CRDT merges:
// Table subscription β fires on any CRDT merge in the collection
const unsub = vaultsync.db.todos.subscribe((todos) => {
renderTodoList(todos)
})
// Filtered subscription
const unsub = vaultsync.db.todos.subscribe(
(todos) => renderTodos(todos),
{ where: { assignee: "alice", completed: false } }
)
// Single record subscription
const unsub = vaultsync.db.todos.subscribeOne("todo:123", (todo) => {
if (!todo) renderDeleted()
else renderTodo(todo)
})
// Sync status subscription
vaultsync.subscribe("sync:status", (status) => {
updateConnectionIndicator(status)
// status: { connected, pendingUploads, lastSyncAt, leaderStatus, optimisticWrites, pushMutationsReceived, snapshotsApplied, activePeers, hlcLogicalWraps }
})import { useQuery, useVaultSyncOne, useSyncStatus, useVaultSyncMutations } from "@vaultsync/react"
function TodoList() {
// Reactive live query β re-renders whenever any todo changes locally or remotely
const { data: todos, loading } = useQuery("todos", {
where: { completed: false },
orderBy: { field: "priority", direction: "desc" }
})
const { insert } = useVaultSyncMutations("todos")
return (
<div>
<button onClick={() => insert({ text: "new task", completed: false })}>
Add
</button>
{loading ? <Spinner /> : <ul>{todos.map(t => <TodoItem key={t.id} todo={t} />)}</ul>}
</div>
)
}
function SyncIndicator() {
const { connected, pendingUploads } = useSyncStatus()
const { peerCount } = useSyncStatus({ showPeers: true })
return (
<span className={connected ? "synced" : "pending"}>
{connected ? `β Synced (${peerCount} peer${peerCount !== 1 ? 's' : ''})` : `β³ ${pendingUploads} pending`}
</span>
)
}Subscription callbacks fire once per CRDT merge β not once per field. A remote mutation that changes 10 fields fires one subscription callback with the full post-merge document state.
Access engine telemetry directly from JavaScript:
// Snapshot of all metrics counters and gauges
const snapshot = vaultsync.metrics()
// { mutationsUploaded, mutationsDownloaded, syncErrors, pendingMutations,
// pushMutationsReceived, snapshotsApplied, optimisticWrites,
// hlcLogicalWraps, activePeers, ... }
// Presence awareness β active peers in the same namespace
const pc = vaultsync.presence()
pc.peerCount() // number of visible peers
pc.activePeers() // JSON string of { replicaId: lastSeenMs }The PresenceManager class is exported as a #[wasm_bindgen] constructor, making it directly instantiable from JavaScript as new PresenceManager(namespace, replicaId) for standalone use.
Every other sync engine locks you into a specific backend. VaultSync defines Coordinator as a Rust trait. You choose the implementation. You can swap backends without changing application code.
| Backend | Package | Real-Time | Best For |
|---|---|---|---|
| PostgreSQL | vaultsync-coordinator-postgres |
LISTEN/NOTIFY (5β50ms) |
Production self-hosted |
| Redis | vaultsync-coordinator-redis |
Pub/Sub (1β10ms) | High throughput, in-memory |
| SQLite | vaultsync-coordinator-sqlite |
Polling (250ms) | Local dev, single-server |
| Cloudflare DO | vaultsync-coordinator-cloudflare |
DO WebSocket (10β50ms) | Edge-deployed, global |
| Supabase | vaultsync-coordinator-supabase |
Supabase Realtime (10β100ms) | Hosted Postgres |
| In-Memory | vaultsync-coordinator-memory |
Channel (< 1ms) | Testing |
// PostgreSQL
const vaultsync = new VaultSync({
namespace: "workspace:core",
storage: "opfs",
coordinator: new PostgresCoordinator({
connectionString: "postgres://user:pass@host/db"
})
})
// Cloudflare Durable Objects
const vaultsync = new VaultSync({
namespace: "workspace:core",
storage: "opfs",
coordinator: new CloudflareCoordinator({
accountId: "...",
durableObjectId: "..."
})
})
// Your own implementation β implement the trait
class MyCoordinator implements Coordinator {
async push(mutations) { /* ... */ }
async pull(after, limit) { /* ... */ }
async subscribe(fromSequence) { /* ... */ }
}VaultSync ships a production-ready coordinator server you can self-host:
# Docker with PostgreSQL backend
docker run -p 8080:8080 \
-e VAULTSYNC_DB_URL=postgres://user:pass@db:5432/vaultsync \
-e VAULTSYNC_AUTH_JWKS_URL=https://your-auth.example.com/.well-known/jwks.json \
vaultsyncsync/coordinator:latest
# Or run the binary
vaultsync-server \
--backend postgres \
--db-url postgres://user:pass@localhost/vaultsync \
--port 8080When a new device joins an existing namespace:
1. New device sends REGISTER with last_sequence = 0
2. Coordinator checks: snapshot available?
3a. YES: returns snapshot_url (compressed batch of encrypted mutations)
β New device downloads, decompresses, decrypts each mutation
β Applies all Yrs diffs in sequence order to build local CRDT state
β Fetches remaining mutations since snapshot
3b. NO: Paginates through full mutation history via PULL
4. Normal real-time sync begins
Coordinator snapshots are encrypted compressed mutation batches β the coordinator never decrypts to create them.
VaultSync's Transport trait enables multiple communication channels for mutation delivery:
pub trait Transport: Send + Sync + Debug {
async fn send(&self, namespace: &str, mutation: &EncryptedMutation) -> Result<(), TransportError>;
async fn incoming(&self, namespace: &str) -> Result<Box<dyn Stream<Item = InboundMutation>>, TransportError>;
fn is_available(&self) -> bool;
}Two built-in implementations:
BroadcastChannelTransport(BC) β same-origin, plaintext, tab-to-tab viaBroadcastChannel. Used for local multi-tab peer delivery without coordinator round-trip.WasmWsTransport(WS) β cross-device, encrypted via WebSocket. All mutations are E2EE before transmission.
Both are merged into a single self-filtering deduplication stream via TransportRouter:
- Uses
remotes: Vec<Arc<dyn Transport>>(notOption) to support multiple remote transports simultaneously. - Each incoming mutation carries a
TransportSourcetag (BC or WS) for metrics and debugging. - Mutations from the local replica are filtered out to prevent echo loops.
- The merged stream is bridged through internal
futures::channel::mpsc::unbounded()channels andwasm_bindgen_futures::spawn_localfor WASM-compatible stream merging.
This architecture keeps transport logic decoupled from the sync engine and allows adding future transports (WebRTC, libp2p) without engine changes.
MutationStore provides a crash-recovery queue for pending mutations in the WASM environment:
push(entry) β persist to OPFS as `<seq>-<uuid>.json` file
pop_batch(limit) β load next N mutations for upload
ack(id) β remove mutation file (synced)
nack(id) β leave in place for retry
Each pending mutation is a JSON-serialized OplogEntry stored under a _mutations/ directory in OPFS. On page load, the store scans existing files to recover any mutations that were in-flight during a crash. All local writes flow through MutationStore.push() before upload.
The coordinator subscription stream is bridged into the download worker's select loop through a futures::channel::mpsc::unbounded() channel. When a push mutation arrives:
- Coordinator fans out
MUTATION_PUSHto connected replicas. - WasmWsCoordinator deserializes the incoming frame, wraps it as a
PendingMutation, and sends it viaSyncEvents.download_notify. - DownloadQueue.process_push_mutation() decrypts, CRDT-merges, and advances the cursor immediately β no poll cycle needed.
- The
subscribe()function (in clientinitialize()) spawns a WASM-compatible listener task that forwards each inbound mutation into the mpsc channel.
This gives sub-second end-to-end delivery for real-time collaboration without polling.
PresenceManager tracks active replicas in the same namespace via a dedicated BroadcastChannel (vaultsync-presence-{namespace}):
join: { type: "join", replica_id, namespace, timestamp }
heartbeat: { type: "heartbeat", replica_id, namespace, timestamp }
leave: { type: "leave", replica_id, namespace }beforeunloadhook sends an immediateleavemessage when a tab closes.- **
Peer count** exposed viaPresenceManager.peer_count()β reflected in theactive_peers` metric. - Exported via
#[wasm_bindgen]β callable from JavaScript asnew PresenceManager(namespace, replicaId). - Peers that disappear without a
leave(e.g., tab crash) are eventually detected by heartbeat timeout, though the BroadcastChannel API provides immediate notification on tab close.
| Operation | Rust Native (SQLite WAL) | Browser WASM (OPFS) |
|---|---|---|
| Single record read (from materialized CRDT state) | 1β5 Β΅s | 0.5β2 ms |
| Single record write (CRDT mutation + WAL commit) | 5β20 Β΅s | 1β5 ms |
| CRDT merge (single Yrs update into document) | 1β5 Β΅s | 10β50 Β΅s |
| E2EE encrypt (ChaCha20-Poly1305) | 5β15 Β΅s | 50β200 Β΅s |
| E2EE decrypt | 5β15 Β΅s | 50β200 Β΅s |
| Full operation path (write β encrypt β oplog β subscription fire) | 10β40 Β΅s | 2β10 ms |
| Cross-tab notification (shared memory / BroadcastChannel) | 2β10 Β΅s | 1β5 ms |
| Snapshot load (1MB Yrs document from mmap) | < 1 ms | 10β50 ms |
All benchmarks run via Criterion.rs. A > 10% regression fails the CI build.
| Operation | Perceived Latency |
|---|---|
| Write β visible on same device | < 1ms (local write) |
| Write β coordinator ACK | 50β200ms (fast network) |
| Write β visible on another device | 100β500ms (fast network) |
| Reconnect + sync 1000 pending operations | 1β5 seconds |
| New replica bootstrap (1MB snapshot) | 500β2000ms |
The UI updates before the network is involved. The write is committed to local SQLite/OPFS synchronously. The subscription fires synchronously after the local CRDT merge. The upload to the coordinator happens asynchronously in a background worker thread. Users see their action reflected immediately β network latency is invisible.
# Browser / React
npm install @vaultsync/web @vaultsync/react
# Node.js server
npm install @vaultsync/node
# Next.js
npm install @vaultsync/web @vaultsync/react @vaultsync/next[dependencies]
vaultsync-core = "0.1"import { VaultSync } from "@vaultsync/web"
import { VaultSyncProvider } from "@vaultsync/react"
import { PostgresCoordinator } from "@vaultsync/web/coordinator"
// Create the client with CRDT schema
const vaultsync = new VaultSync({
storage: "opfs", // OPFS (SQLite via WASM) for browsers
namespace: "workspace:core",
replicaId: getDeviceId(), // stable, unique device identifier
coordinator: new PostgresCoordinator({
connectionString: process.env.COORDINATOR_URL
}),
auth: {
getToken: () => clerk.session?.getToken() // your JWT provider
},
sync: {
reconnect: { attempts: Infinity, maxDelay: "30s" },
uploadRetry: { attempts: 10, backoff: "exponential" }
},
multiTab: {
enabled: true,
electionTimeout: 3000,
heartbeatInterval: 1000
}
})
// Define CRDT-typed schema
vaultsync.schema.define("todos", {
fields: {
id: { type: "string", crdtType: "lww", primaryKey: true },
text: { type: "string", crdtType: "lww", indexed: true },
completed: { type: "boolean", crdtType: "lww" },
priority: { type: "number", crdtType: "counter" },
tags: { type: "array", crdtType: "orset", indexed: true },
assignee: { type: "string", crdtType: "lww", indexed: true },
localNote: { type: "string", crdtType: "lww", sync: false } // never synced
},
softDelete: true // DELETE adds tombstone, not hard delete β required for correct sync
})
// Register migrations
vaultsync.migration("v1", async (db) => {
await db.defineDocument("todos", { /* initial schema */ })
})
vaultsync.migration("v2", async (db) => {
await db.addField("todos", "priority", { type: "number", crdtType: "counter" })
})
// Initialize β runs migrations, sets up E2EE keys, connects to coordinator
await vaultsync.initialize()
// Wrap your app
export default function App() {
return (
<VaultSyncProvider client={vaultsync}>
<MyApp />
</VaultSyncProvider>
)
}const db = vaultsync.db
// INSERT β creates a new CRDT document (instant local write)
await db.todos.insert({
id: "todo:123",
text: "build vaultsync",
completed: false,
tags: ["backend", "rust"],
priority: 3
})
// READ β from local CRDT materialized state (no network, instant)
const todos = await db.todos.findAll()
const todo = await db.todos.findById("todo:123")
const filtered = await db.todos.findAll({
where: { completed: false, tags: { contains: "backend" } },
orderBy: { field: "priority", direction: "desc" },
limit: 50
})
// UPDATE β produces a Yrs binary CRDT diff, instantly visible in UI
await db.todos.update("todo:123", { text: "build vaultsync runtime" })
// DELETE β soft delete (tombstone) if softDelete: true in schema
await db.todos.delete("todo:123")
// BATCH β multiple CRDT mutations in one atomic transaction
await db.batch([
db.todos.insert({ id: "todo:456", text: "write docs" }),
db.todos.update("todo:123", { completed: true })
])// Keys are generated automatically on initialize()
// Manual management for advanced use cases:
await vaultsync.keys.rotate() // Rotate namespace key
const pubKey = await vaultsync.keys.publicKey() // Export public key
const keyInfo = await vaultsync.keys.status()
// { version: 2, createdAt: ..., algorithm: "X25519+ChaCha20-Poly1305" }vaultsync.schema.define("projects", {
fields: {
id: { type: "string", crdtType: "lww", primaryKey: true },
name: { type: "string", crdtType: "lww", indexed: true },
description: { type: "string", crdtType: "text" }, // Rich text CRDT
memberCount: { type: "number", crdtType: "counter" }, // PN-Counter
tags: { type: "array", crdtType: "orset" }, // OR-Set
ownerId: { type: "string", crdtType: "lww", indexed: true },
draft: { type: "boolean", crdtType: "lww", sync: false } // Local only
},
indexes: [
{ fields: ["ownerId"] },
{ fields: ["name", "memberCount"] }
],
softDelete: true
})crdtType |
Merge Behavior | Real-World Use |
|---|---|---|
lww |
Last-write-wins by hybrid logical clock | Title, status, any scalar |
counter |
All increments/decrements sum globally | Likes, votes, inventory |
orset |
Add/remove with tombstone tracking | Tags, members, feature flags |
text |
Sequence CRDT (position-based insert/delete) | Rich text bodies |
custom |
Developer-defined Yrs extension | Complex domain logic |
Migrations are additive only. Existing CRDT fields can never be removed (tombstones handle deletion). CRDT type of a field cannot be changed (use a new field name). This ensures all replicas on any schema version can accept mutations from other versions without data loss.
vaultsync.migration("v1", async (db) => {
await db.defineDocument("todos", {
fields: { id: { type: "string", crdtType: "lww", primaryKey: true },
text: { type: "string", crdtType: "lww" } }
})
})
vaultsync.migration("v2", async (db) => {
await db.addField("todos", "priority", { type: "number", crdtType: "counter" })
})
vaultsync.migration("v3", async (db) => {
await db.addField("todos", "tags", { type: "array", crdtType: "orset" })
})Schema migration checksums are stored in vaultsync_migrations and verified on every apply. Tampered migrations are rejected.
Trace: "vaultsync.write" (trace_id: abc123)
βββ Span: "crdt.merge" β 2.1 Β΅s β Yrs merge into document
βββ Span: "e2ee.encrypt" β 7.3 Β΅s β ChaCha20-Poly1305 encrypt
βββ Span: "oplog.append" β 1.5 Β΅s β Write to local oplog
βββ Span: "subscription.fire" β 0.8 Β΅s β Fire local subscription
βββ Span: "transport.send" β 45 ms β Upload to coordinator
βββ Span: "coord.validate" β 2 ms
βββ Span: "coord.store" β 5 ms
βββ Span: "coord.fanout" β 20 ms β Push to other replicas
Exportable to Jaeger, Datadog, Grafana Tempo, or any OTLP-compatible backend.
| Metric | Description |
|---|---|
vaultsync_mutations_total |
Total mutations processed (by status, namespace) |
vaultsync_mutations_pending |
Current upload queue depth |
vaultsync_sync_lag_ms |
Time from local write to coordinator ACK |
vaultsync_download_lag_ms |
Time from coordinator sequence to replica apply |
vaultsync_encryption_time_us |
E2EE encrypt/decrypt duration |
vaultsync_crdt_merge_time_us |
CRDT merge duration per document |
vaultsync_connection_status |
1=connected, 0=disconnected |
vaultsync_leader_status |
1=leader, 0=reader |
vaultsync_push_mutations_received |
Counter β mutations delivered via push (DownloadQueue) |
vaultsync_snapshots_applied |
Counter β snapshot catch-ups applied (DownloadQueue) |
vaultsync_optimistic_writes |
Counter β optimistic local writes (client.rs insert/update/delete) |
vaultsync_hlc_logical_wraps |
Counter β HLC logical counter overflow events (HybridLogicalClock) |
vaultsync_active_peers |
Gauge β active replicas in namespace (PresenceManager) |
GET /debug/vaultsync/state β Full internal state as JSON
GET /debug/vaultsync/state/oplog β Last 1000 oplog entries
GET /debug/vaultsync/state/documents β CRDT document snapshot metadata
GET /debug/vaultsync/leader β Current leader status
GET /debug/vaultsync/metrics β Prometheus endpoint
POST /debug/vaultsync/force-sync β Trigger immediate sync
POST /debug/vaultsync/force-election β Trigger leader re-election# Attach to a running VaultSync process
vaultsync inspect --pid 1234
# Watch live operation stream
vaultsync inspect --pid 1234 --stream
# Dump current CRDT document state
vaultsync inspect --pid 1234 --state
# Export and replay a trace file for deterministic debugging
vaultsync inspect --pid 1234 --export-trace > trace.json
vaultsync replay trace.json| Layer | Mechanism |
|---|---|
| Transport | All sync traffic over TLS (WSS). Even if TLS is compromised, coordinator cannot read data (E2EE). |
| Application data | E2EE via X25519 + ChaCha20-Poly1305. Coordinator stores only encrypted blobs. |
| Authentication | Every coordinator connection requires a JWT from your auth provider (Clerk, Auth0, etc.). Expired tokens close the connection. |
| Namespace isolation | A replica authorized for workspace:alpha cannot receive or push mutations for workspace:beta. Enforced at the coordinator. |
| Local storage | OS-level sandboxing (OPFS is origin-private). Optional SQLCipher full-database encryption. Private keys stored in OS keychain (macOS Keychain, Windows Credential Manager). |
| Key pinning | TOFU (Trust-On-First-Use) by default. Strict mode available: any new replica key requires manual operator approval before sync proceeds. |
const vaultsync = new VaultSync({
namespace: "healthcare:patient-records",
e2ee: {
keyValidation: "strict", // Manual approval for new replica keys
onNewKeyDetected: async (replicaId, newKey) => {
await notifySecurityTeam(replicaId, newKey)
return "reject" // "accept" | "reject"
}
},
storageEncryption: {
enabled: true,
mode: "sqlcipher", // Full database encryption
keyDerivation: "os-keychain" // AES-256-CBC, key in OS keychain
}
})Alice (laptop) adds todo:123 β instant UI update, CRDT mutation queued
β (async, encrypted)
Coordinator sequences and distributes encrypted mutation
β (50β200ms on good connection)
Bob (phone) receives encrypted mutation, decrypts, CRDT merges
Bob sees todo:123 appear without refreshing
Both users edit todo:123 simultaneously:
Alice: UPDATE { text: "updated title" }
Bob: UPDATE { completed: true }
CRDT merge: { text: "updated title", completed: true }
Both changes preserved β no data loss, no conflict dialog.
User opens app on airplane (no connectivity):
β All reads served from local CRDT state β instant, zero latency
β User makes 47 changes during the flight
β 47 encrypted CRDT mutations queued in oplog, status: pending
β Device lost? Oplog encrypted at rest β attacker sees only ciphertext
Plane lands, connectivity returns:
β VaultSync reconnects, uploads 47 mutations in batches
β Downloads 12 mutations from other devices
β CRDT merges all 12 β no data lost, no conflicts
β App fully synchronized β coordinator never saw plaintext
User opens same app in 3 browser tabs:
Tab A (Leader): handles all writes
Tab B (Reader): reads via shared memory, < 5ms
Tab C (Reader): reads via shared memory, < 5ms
User closes Tab A (leader crash):
Tab B detects heartbeat timeout (> 3s)
Tab B acquires lock β becomes Leader
Tab B replays in-flight mutations from shared memory
Tab C detects new leader via BroadcastChannel
Zero data loss. Zero interruption. Users notice nothing.
Patient data synced across clinic devices:
β E2EE: coordinator (even if breached) cannot read PHI
β HIPAA compliance: no plaintext in coordinator logs
β Audit trail: OpenTelemetry traces every CRDT mutation
β On-prem coordinator: all data stays in clinic's network
β Key rotation: staff departures trigger namespace key rotation
AI assistant stores context locally as CRDT documents:
β Reads and writes instant β no latency on AI completions
β Context syncs across devices when online
β Works fully offline β context available on airplane
β No server reads required for inference β cost drops by 90%
Two developers on the same office WiFi:
β Coordinator: Postgres in cloud (persistence, third-party access)
β P2P transport: WebRTC enabled for same-LAN peers
β Low-latency edits: P2P path (< 10ms round-trip)
β Developer leaves office: P2P disconnects, coordinator takes over
β Transparent failover. No data loss.
| Feature | VaultSync | ElectricSQL | Zero (Rocicorp) | PowerSync | REST + WebSocket |
|---|---|---|---|---|---|
| Offline first | β Full | β | β | β | β |
| Conflict resolution | β CRDT (no loss) | β Server wins | |||
| E2EE | β Default, zero-trust | β | β | β | β |
| Multi-tab safe | β Leader election | β | β | β | β |
| Coordinator choice | β Any via trait | β Postgres only | β Proprietary | β MongoDB | N/A |
| Perceived 0ms writes | β Local-first | β | β | β | β Network required |
| Reduce API costs | β Reads never hit server | β | β | β | β All reads hit server |
| Open source | β Apache 2.0 | β | β | β | N/A |
| Rust core + WASM | β | β | β | β | N/A |
| Observability | β OpenTelemetry | Partial | Partial | β | N/A |
const vaultsync = new VaultSync({
storage: "sqlite",
namespace: "local",
sync: false // no coordinator β offline-only
})const vaultsync = new VaultSync({
storage: "opfs",
namespace: `user:${userId}`,
coordinator: new PostgresCoordinator({ connectionString: process.env.DATABASE_URL })
})const vaultsync = new VaultSync({
storage: "sqlite",
namespace: "workspace:core",
coordinator: new CustomCoordinator({ url: "wss://your-coordinator.example.com/vaultsync" })
})App Server A (VaultSync embedded, namespace: workspace:core)
App Server B (VaultSync embedded, namespace: workspace:core)
App Server C (VaultSync embedded, namespace: workspace:core)
β
Shared Coordinator (Postgres)
const vaultsync = new VaultSync({
namespace: "workspace:core",
coordinator: new PostgresCoordinator({ ... }),
transports: {
coordinator: true,
p2p: { enabled: true }, // WebRTC for same-LAN peers
mesh: { enabled: false } // libp2p mesh (future)
}
})We welcome contributions from the community! See CONTRIBUTING.md for:
- Local development setup
- Coding standards (Clippy +
rustfmtrequired, zero warnings policy) - Conventional Commits format
- Branch naming conventions
- How to add a new coordinator backend
- How to add a new storage backend
- Fuzzing guide
# Get started
git clone https://github.com/parv68/VaultSync.git
cd vaultsync
./scripts/setup-dev.sh
# Run all tests
./scripts/run-all-tests.sh
# Run Rust tests only
cargo test --workspace --exclude vaultsync-fuzz
# Run SDK tests
npm test -w @vaultsync/web
npm test -w @vaultsync/node
npm test -w @vaultsync/react
npm test -w @vaultsync/nextBefore deploying VaultSync to production:
- E2EE keys generated β verify keys are backed up; export public keys for recovery
- Coordinator deployed β Postgres (recommended), Redis, or self-hosted server
- Replica JWT auth configured β
auth.getTokenreturns valid tokens for all sessions - Namespace authorization β limit which replicas can push/pull per namespace
- Soft delete enabled on all synced tables β prevents sync gaps on delete operations
- Schema migrations tracked β every field addition has a versioned migration
- CRDT types chosen per field β LWW / Counter / OR-Set / Text selected intentionally
- Oplog compaction configured β snapshot interval + tombstone GC threshold set
- Multi-tab election tested β verified 3+ tab crash recovery scenario
- Coordinator backups running β snapshot + mutation log backup to durable storage
- OpenTelemetry configured β traces, metrics, logs exported to your observability backend
- Sync lag alerting set up β alert if
vaultsync_sync_lag_ms> threshold - Offline replica alerts β alert if a replica hasn't synced within tombstone retention
- Graceful shutdown β server replicas flush pending uploads before process exit
- Chaos tests passed β network partition, leader crash, clock skew, key rotation mid-sync
- Benchmark regression gates green β no > 10% performance regressions vs main branch
VaultSync is licensed under the Apache License, Version 2.0. See LICENSE for the full text.
- Complete Technical Specification β 158KB deep dive into every design decision
- Testing Specification β Conformance tests, chaos scenarios, property tests
- Implementation Plan β Internal build reference
VaultSync β Conflict-free, encrypted, infrastructure-agnostic synchronization for the local-first era.