Syncing Data
ursalock syncs your encrypted data between local storage and the server.
How Sync Works
Section titled “How Sync Works”- Local changes → encrypted → pushed to server
- Server changes → pulled → decrypted → merged with local
- Conflict resolution → Last-Write-Wins (LWW) by timestamp
All data is encrypted client-side before leaving your device.
Sync Methods
Section titled “Sync Methods”Full Sync
Section titled “Full Sync”await useStore.vault.sync();Pushes local changes, pulls remote changes, merges using LWW.
Push Only
Section titled “Push Only”await useStore.vault.push();Send local state to server without pulling.
Pull Only
Section titled “Pull Only”const hasChanges = await useStore.vault.pull();// Returns true if server had newer dataGet latest from server without pushing.
Auto-Sync
Section titled “Auto-Sync”By default, vault syncs every 30 seconds:
vault(storeCreator, { name: "my-store", cipherJwk, server: "https://vault.example.com", getToken: () => token, syncInterval: 30000, // 30 seconds (default)});Disable auto-sync:
vault(storeCreator, { // ... syncInterval: 0, // Manual sync only});Sync Status
Section titled “Sync Status”const status = useStore.vault.getSyncStatus();// "idle" | "syncing" | "synced" | "error" | "offline"Status Hook
Section titled “Status Hook”import { useSyncStatus } from "@ursalock/zustand";
function SyncIndicator({ store }) { const status = useSyncStatus(store);
return ( <span> {status === "syncing" && "⏳ Syncing..."} {status === "synced" && "✅ Synced"} {status === "error" && "❌ Sync failed"} {status === "offline" && "📴 Offline"} {status === "idle" && "⏸️ Idle"} </span> );}Custom Sync Status Component
Section titled “Custom Sync Status Component”function SyncStatus() { const [status, setStatus] = useState("idle");
useEffect(() => { const interval = setInterval(() => { setStatus(useStore.vault.getSyncStatus()); }, 5000); // Poll every 5s
return () => clearInterval(interval); }, []);
const handleManualSync = async () => { await useStore.vault.sync(); };
return ( <button onClick={handleManualSync}> {status === "syncing" ? "Syncing..." : "Sync Now"} </button> );}Conflict Resolution
Section titled “Conflict Resolution”ursalock uses Last-Write-Wins (LWW):
Device A: saves { count: 5 } at 10:00:00Device B: saves { count: 8 } at 10:00:05
After sync: { count: 8 } wins (newer timestamp)Partialize for Granular Sync
Section titled “Partialize for Granular Sync”Control which fields are synced:
vault(storeCreator, { name: "my-store", cipherJwk, server: "https://...", getToken: () => token, partialize: (state) => ({ // Sync these fields notes: state.notes, settings: state.settings, // Don't sync UI state // currentNoteId: state.currentNoteId, // excluded }),});Offline Support
Section titled “Offline Support”When offline:
- Changes save to encrypted localStorage
getSyncStatus()returns"offline"- When back online, call
sync()to push pending changes
// Check for pending changesconst pending = useStore.vault.hasPendingChanges();
// Sync when back onlinewindow.addEventListener("online", () => { useStore.vault.sync();});Sync on App Lifecycle
Section titled “Sync on App Lifecycle”Recommended pattern for robust sync:
useEffect(() => { // Sync on app start useStore.vault.sync();
// Sync when tab becomes visible const handleVisibility = () => { if (document.visibilityState === "visible") { useStore.vault.sync(); } }; document.addEventListener("visibilitychange", handleVisibility);
// Sync before close const handleUnload = () => { if (useStore.vault.hasPendingChanges()) { useStore.vault.push(); } }; window.addEventListener("beforeunload", handleUnload);
return () => { document.removeEventListener("visibilitychange", handleVisibility); window.removeEventListener("beforeunload", handleUnload); };}, []);Debounced Sync
Section titled “Debounced Sync”For frequently changing data (like a text editor), debounce syncs:
import { useMemo } from "react";import { debounce } from "lodash-es";
function Editor() { const updateNote = useStore((s) => s.updateNote);
// Debounce sync after edits const debouncedSync = useMemo( () => debounce(() => useStore.vault.sync(), 3000), [] );
const handleChange = (content: string) => { updateNote(noteId, { content }); debouncedSync(); };
return <textarea onChange={(e) => handleChange(e.target.value)} />;}Server Configuration
Section titled “Server Configuration”The server stores encrypted blobs per user:
POST /api/vault/:name/push Body: { encrypted: "...", updatedAt: 1234567890 }
GET /api/vault/:name/pull Response: { encrypted: "...", updatedAt: 1234567890 }See Self-Hosting for server setup.
Debugging
Section titled “Debugging”Enable debug logging:
localStorage.setItem("ursalock:debug", "true");Check sync state:
console.log({ status: useStore.vault.getSyncStatus(), pending: useStore.vault.hasPendingChanges(), hydrated: useStore.vault.hasHydrated(),});Common Issues
Section titled “Common Issues””vault_already_exists” Error
Section titled “”vault_already_exists” Error”This happens when creating a new vault but one already exists on the server. The client should pull first:
// On initial load, try pull before pushawait useStore.vault.pull();Sync Not Working Across Devices
Section titled “Sync Not Working Across Devices”- Check passkey provider syncs credentials (iCloud, Google, Proton Pass)
- Verify same
opaqueIdon both devices (derived from passkey rawId) - Check server URL is the same
- Verify JWT is valid
Data Appears Stale
Section titled “Data Appears Stale”// Force a fresh pull from serverawait useStore.vault.pull();