RippleDB
RippleDB
Getting Started

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:nodeId

Example: 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

  1. stream is opaque — You decide what it means (like org, user, project, or a combination of those)
  2. kind is restricted — Only upsert and delete, no custom operations
  3. Patches are partial — Only include changed fields
  4. 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

PatternStream KeyUse Case
Per-useruser-{userId}Personal data, settings
Per-projectproject-{projectId}Collaborative documents
Per-tenanttenant-{tenantId}Multi-tenant SaaS

Why Streams?

  1. Isolation — Users only pull changes they care about
  2. Scalability — Different streams can live on different servers
  3. 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 AUser B
Edittitle: "A's title"done: true
Tag1000:0:A1001: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

  1. Change arrives via db.append()
  2. Materializer reads existing tags from ripple_tags
  3. For each field, compare tags and apply LWW
  4. Update the todos table with winning values
  5. Update ripple_tags with 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 pull

Canonical Sync Order

Pull → Apply → Push

  1. Pull remote changes first
  2. Apply them to local state
  3. 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.

core(pure logic)server(Db interface)client(Store/Sync)materialize-core(projectors)db-*(adapters)store-*(adapters)client-controllers(CRUD)bind-tanstack-query(UI Cache)materialize-*(adapters)client-query(Final DX)

Next Steps

On this page