Guides
Client Sync
Implement client-side sync with offline support
Client Sync
This guide shows how to set up client-side sync with a RippleDB server.
Architecture
Setup
Install Dependencies
pnpm add @rippledb/core @rippledb/client @rippledb/remote-httpnpm install @rippledb/core @rippledb/client @rippledb/remote-httpyarn add @rippledb/core @rippledb/client @rippledb/remote-httpCreate the Remote
import { createHttpRemote } from "@rippledb/remote-http";
import type { AppSchema } from "./schema";
export const remote = createHttpRemote<AppSchema>({
baseUrl: process.env.API_URL ?? "http://localhost:3000",
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
});Implement a Store
The Store interface manages local state:
import type { Store, DbEvent } from "@rippledb/client";
import type { Change, EntityName } from "@rippledb/core";
import type { AppSchema } from "./schema";
export class InMemoryStore implements Store<AppSchema> {
private data: Map<string, Map<string, unknown>> = new Map();
private listeners: Set<(event: DbEvent<AppSchema>) => void> = new Set();
async applyChanges(changes: Change<AppSchema>[]): Promise<void> {
for (const change of changes) {
const entityMap = this.data.get(change.entity) ?? new Map();
if (change.kind === "delete") {
entityMap.delete(change.entityId);
this.emit({
entity: change.entity,
kind: "delete",
id: change.entityId,
});
} else {
const existing = entityMap.get(change.entityId) ?? {};
const updated = { ...existing, ...change.patch };
entityMap.set(change.entityId, updated);
this.data.set(change.entity, entityMap);
this.emit({
entity: change.entity,
kind: existing ? "update" : "insert",
id: change.entityId,
});
}
}
}
async getRow<E extends EntityName<AppSchema>>(
entity: E,
id: string,
): Promise<AppSchema[E] | null> {
return (this.data.get(entity)?.get(id) as AppSchema[E]) ?? null;
}
async listRows<E extends EntityName<AppSchema>>(
entity: E,
): Promise<AppSchema[E][]> {
const entityMap = this.data.get(entity);
if (!entityMap) return [];
return Array.from(entityMap.values()) as AppSchema[E][];
}
onEvent(cb: (event: DbEvent<AppSchema>) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private emit(event: DbEvent<AppSchema>): void {
for (const listener of this.listeners) {
listener(event);
}
}
}Create the Replicator
import { createReplicator, InMemoryOutbox } from "@rippledb/client";
import {
createHlcState,
tickHlc,
makeUpsert,
makeDelete,
} from "@rippledb/core";
import { remote } from "./remote";
import { InMemoryStore } from "./store";
import type { AppSchema } from "./schema";
// Initialize components
const store = new InMemoryStore();
const outbox = new InMemoryOutbox<AppSchema>();
const hlcState = createHlcState(`client-${crypto.randomUUID().slice(0, 8)}`);
export const replicator = createReplicator<AppSchema>({
stream: `user-${getCurrentUserId()}`,
store,
remote,
outbox,
});
// Helper to generate HLCs
export function hlc() {
return tickHlc(hlcState, Date.now());
}
// Helper to create and push a todo
export async function createTodo(id: string, title: string) {
const change = makeUpsert<AppSchema>({
stream: replicator.stream,
entity: "todos",
entityId: id,
patch: { id, title, done: false, createdAt: new Date().toISOString() },
hlc: hlc(),
});
await replicator.pushLocal(change);
}
// Helper to update a todo
export async function updateTodo(
id: string,
patch: Partial<AppSchema["todos"]>,
) {
const change = makeUpsert<AppSchema>({
stream: replicator.stream,
entity: "todos",
entityId: id,
patch,
hlc: hlc(),
});
await replicator.pushLocal(change);
}
// Helper to delete a todo
export async function deleteTodo(id: string) {
const change = makeDelete<AppSchema>({
stream: replicator.stream,
entity: "todos",
entityId: id,
hlc: hlc(),
});
await replicator.pushLocal(change);
}
export { store };Sync Periodically
import { replicator, store } from "./sync";
// Initial sync
await replicator.sync();
// Periodic sync (every 30 seconds)
setInterval(async () => {
try {
const result = await replicator.sync();
console.log(`Synced: pulled ${result.pulled}, pushed ${result.pushed}`);
} catch (error) {
console.error("Sync failed:", error);
}
}, 30000);
// Subscribe to changes for UI updates
store.onEvent((event) => {
console.log(`${event.kind} ${event.entity} ${event.id}`);
// Trigger UI re-render
});Offline Support
The replicator handles offline gracefully:
- Local writes go to the store immediately
- Outbox queues changes for later push
- Sync retries when connection is restored
// Check if online before syncing
async function syncWhenOnline() {
if (!navigator.onLine) {
console.log("Offline, skipping sync");
return;
}
try {
await replicator.sync();
} catch (error) {
console.error("Sync failed, will retry");
}
}
// Listen for online/offline events
window.addEventListener("online", syncWhenOnline);React Integration
import { useEffect, useState, useCallback } from "react";
import { replicator, store, createTodo, updateTodo } from "./sync";
import type { AppSchema } from "./schema";
function useTodos() {
const [todos, setTodos] = useState<AppSchema["todos"][]>([]);
useEffect(() => {
// Load initial data
store.listRows("todos").then(setTodos);
// Subscribe to changes
return store.onEvent((event) => {
if (event.entity === "todos") {
store.listRows("todos").then(setTodos);
}
});
}, []);
return todos;
}
function TodoApp() {
const todos = useTodos();
const handleAdd = async () => {
await createTodo(crypto.randomUUID(), "New todo");
};
const handleToggle = async (todo: AppSchema["todos"]) => {
await updateTodo(todo.id, { done: !todo.done });
};
return (
<div>
<button onClick={handleAdd}>Add Todo</button>
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => handleToggle(todo)}>
{todo.done ? "✓" : "○"} {todo.title}
</li>
))}
</ul>
</div>
);
}Conflict Resolution
Conflicts are resolved automatically via field-level LWW:
// Client A: Update title
await updateTodo("todo-1", { title: "A title" });
// Client B: Update done (concurrently)
await updateTodo("todo-1", { done: true });
// After sync, both see:
// { title: 'A title', done: true }
// Each field resolves independently based on HLC timestampsNo manual conflict resolution needed. RippleDB's field-level LWW ensures consistent, deterministic merges across all clients.
Next Steps
- Server Setup — Build the server
- Core Concepts — Understand the sync model
- Remote HTTP — HTTP transport options