Core Concepts
Understand the mental model behind RippleDB — HLCs, Changes, Streams, and Materialization
Core Concepts
RippleDB is built on a few key ideas. Understanding them will help you use the library effectively.
The Mental Model
Local storage is truth.
Replication is log-based.
Conflicts resolve per field.
Queries read materialized state.
Hybrid Logical Clocks (HLC)
An HLC is a timestamp that combines physical time with a logical counter. It ensures:
- Monotonicity: Timestamps always increase within a node
- Comparability: Any two timestamps can be ordered globally
- Determinism: Tie-breaking is consistent across all replicas
Format
wallMs:counter:nodeIdExample: 1706123456789:0:server-abc123
Usage
import { createHlcState, tickHlc, compareHlc } from "@rippledb/core";
// Create state for this node
const state = createHlcState("my-node-id");
// Generate a new timestamp
const ts1 = tickHlc(state, Date.now());
const ts2 = tickHlc(state, Date.now());
// Compare timestamps
compareHlc(ts1, ts2); // -1 (ts1 < ts2)Each node (server, client device) needs a unique nodeId. Use a random string
or UUID.
Changes
A Change is the atomic unit of replication. It represents a single mutation to an entity.
Structure
type Change = {
stream: string; // Partition key (e.g., user ID, project ID)
entity: string; // Entity type (e.g., 'todos', 'comments')
entityId: string; // Primary key of the entity
kind: "upsert" | "delete";
patch: Record<string, unknown>; // Changed fields only
tags: Record<string, Hlc>; // HLC per field
hlc: Hlc; // Change-level timestamp
};Key Design Decisions
streamis opaque — You decide what it means (like org, user, project, or a combination of those)kindis restricted — Onlyupsertanddelete, no custom operations- Patches are partial — Only include changed fields
- Tags are per-field — Each field has its own timestamp
Creating Changes
import { makeUpsert, makeDelete } from "@rippledb/core";
// Create or update
const change = makeUpsert({
stream: "user-123",
entity: "todos",
entityId: "todo-1",
patch: { title: "Updated title" },
hlc: hlc(),
});
// Delete
const deletion = makeDelete({
stream: "user-123",
entity: "todos",
entityId: "todo-1",
hlc: hlc(),
});Streams
A stream is a partition of changes. All changes in a stream are:
- Ordered (by sequence number)
- Pulled together (cursor-based)
- Typically scoped to a user, project, or tenant
Common Patterns
| Pattern | Stream Key | Use Case |
|---|---|---|
| Per-user | user-{userId} | Personal data, settings |
| Per-project | project-{projectId} | Collaborative documents |
| Per-tenant | tenant-{tenantId} | Multi-tenant SaaS |
Why Streams?
- Isolation — Users only pull changes they care about
- Scalability — Different streams can live on different servers
- Security — Easy to scope access control
Field-Level LWW
RippleDB uses Last-Writer-Wins (LWW) at the field level, not the row level.
How It Works
Each field has:
- A value
- A tag (HLC timestamp)
When merging:
if (incomingTag > localTag) {
// Accept incoming value
} else {
// Keep local value
}Example: Concurrent Edits
User A and User B edit the same todo:
| User A | User B | |
|---|---|---|
| Edit | title: "A's title" | done: true |
| Tag | 1000:0:A | 1001:0:B |
After sync, both see:
title: "A's title"(A's tag wins for title)done: true(B's tag wins for done)
No data loss. Each field resolves independently.
Deletes (Tombstones)
Deletes are modeled as tombstones:
{
deleted: true,
deleted_tag: '<hlc>'
}This ensures:
- Deletes replicate correctly
- Delete/update conflicts resolve cleanly
- Entities don't resurrect after sync
Materialization
Materialization is the process of applying changes to domain tables.
Without Materialization
You'd need to replay all changes to compute current state:
-- Expensive query to get current todo state
SELECT * FROM ripple_changes
WHERE entity = 'todos' AND entity_id = 'todo-1'
ORDER BY seq;
-- Then apply LWW in code...With Materialization
Changes are applied to a todos table as they arrive:
-- Fast lookup of current state
SELECT * FROM todos WHERE id = 'todo-1';How It Works
- Change arrives via
db.append() - Materializer reads existing tags from
ripple_tags - For each field, compare tags and apply LWW
- Update the
todostable with winning values - Update
ripple_tagswith new timestamps
Server-side materialization is optional. It's useful if your server also
serves direct queries (REST APIs, admin dashboards) alongside the sync
endpoints. On the client, the Store handles applying changes to local state.
Sync Model
Append-Only Log
Changes are stored in an immutable, ordered log:
ripple_changes (
seq INTEGER PRIMARY KEY, -- Auto-incrementing
stream TEXT,
change_json TEXT -- Serialized Change
)Cursor-Based Pull
Clients sync by pulling changes after their last cursor:
const { changes, cursor } = await db.pull(stream, lastCursor);
// Process changes...
// Save cursor for next pullCanonical Sync Order
Pull → Apply → Push
- Pull remote changes first
- Apply them to local state
- Then push local changes
This ensures conflicts are resolved deterministically.
Package Relationships
The RippleDB ecosystem is composed of several independent packages that work together. For most applications, @rippledb/client-query is the recommended high-level entry point as it combines CRUD controllers with automatic TanStack Query invalidation.
Next Steps
- Server Setup Guide — Build a production sync server
- Adapters — Choose the right database and materializer
- API Reference — Full
@rippledb/coreAPI