RippleDB
RippleDB
Guides

Materialization

Project changes into queryable domain tables

Materialization

Materialization projects your change log into domain tables for fast queries.

This guide covers server-side materialization. On the client, the Store interface handles applying changes to local state. See Client Sync for client patterns.

Why Materialize?

Without materialization, reading current state requires replaying all changes:

// Slow: scan entire change log
const changes = await db.pull({ stream, cursor: null });
const currentState = changes.reduce((acc, change) => {
  // Apply each change with LWW...
}, {});

With materialization, queries are instant:

-- Fast: direct table lookup
SELECT * FROM todos WHERE id = 'todo-1';

How It Works

db.append()1. Load existing tags2. Apply field-level LWW(compare HLC timestamps)3. Update domain table4. Update tags tableripple_tagstodosripple_tags

Configuration

tableMap

Maps entity names to database tables:

{
  tableMap: {
    todos: 'todos',        // Entity 'todos' → table 'todos'
    users: 'app_users',    // Entity 'users' → table 'app_users'
  }
}

fieldMap

Maps schema fields to database columns:

{
  fieldMap: {
    todos: {
      id: 'id',
      title: 'title',
      done: 'is_completed',  // Schema 'done' → column 'is_completed'
    }
  }
}

Without fieldMap, only the ripple_tags table is updated. The domain table is unchanged.

With fieldMap, both ripple_tags and the domain table are updated.

Field-Level LWW in Action

When a change arrives, each field is compared independently:

// Incoming change
{
  entity: 'todos',
  entityId: 'todo-1',
  patch: { title: 'New Title', done: true },
  tags: { title: '1001:0:client-A', done: '1002:0:client-B' }
}

// Existing state (from ripple_tags)
{
  values: { title: 'Old Title', done: false },
  tags: { title: '1000:0:server', done: '1003:0:client-C' }
}

// Result after LWW:
// - title: '1001:0:client-A' > '1000:0:server' → accept 'New Title'
// - done: '1002:0:client-B' < '1003:0:client-C' → keep false

// Final state
{
  values: { title: 'New Title', done: false },
  tags: { title: '1001:0:client-A', done: '1003:0:client-C' }
}

Choosing a Materializer

If you use...Materializer
db-sqlitematerialize-db
db-tursomaterialize-db
db-drizzlematerialize-drizzle

materialize-db Example

import { defineSchema, s } from "@rippledb/core";
import { SqliteDb } from "@rippledb/db-sqlite";
import { createSyncMaterializer } from "@rippledb/materialize-db";

const schema = defineSchema({
  todos: { id: s.string(), title: s.string(), done: s.boolean() },
});

const db = new SqliteDb({
  filename: "./data.db",
  schema,
  materializer: ({ db, schema }) =>
    createSyncMaterializer({
      schema,
      db,
      dialect: "sqlite",
      tableMap: { todos: "todos" },
      fieldMap: { todos: { id: "id", title: "title", done: "done" } },
    }),
});

materialize-drizzle Example

import { defineSchema, s } from "@rippledb/core";
import { SqliteDb } from "@rippledb/db-sqlite";
import { createDrizzleSyncMaterializer } from "@rippledb/materialize-drizzle";
import { drizzle } from "drizzle-orm/better-sqlite3";

const schema = defineSchema({
  todos: { id: s.string(), title: s.string(), done: s.boolean() },
});

const db = new SqliteDb<MySchema>({
  filename: "./data.db",
  schema,
  materializer: ({ db, schema }) => {
    const drizzleDb = drizzle(db);
    return createDrizzleSyncMaterializer({
      schema,
      tableMap: { todos: todosTable },
      tagsTableDef: tagsTable,
      getTableConfig,
      fieldMap: { todos: { id: "id", title: "title", done: "done" } },
    });
  },
});

Tags Table

The materializer maintains a ripple_tags table:

CREATE TABLE ripple_tags (
  entity TEXT NOT NULL,
  id TEXT NOT NULL,
  data TEXT NOT NULL,       -- JSON: current field values
  tags TEXT NOT NULL,       -- JSON: field → HLC timestamp
  deleted INTEGER NOT NULL, -- Tombstone flag
  deleted_tag TEXT,         -- Delete timestamp
  PRIMARY KEY (entity, id)
);

Custom Materializers

For advanced cases, implement a custom executor:

import { createMaterializer } from "@rippledb/materialize-db";

const db = new SqliteDb<MySchema>({
  filename: "./data.db",
  schema,
  materializer: ({ db, schema }) =>
    createMaterializer({
      schema,
      db,
      tableMap: { todos: "todos" },
      fieldMap: { todos: { id: "id", title: "title", done: "done" } },
      executor: {
        ensureTagsTable: async (db) => {
          // Create tags table
        },
        loadTags: async (db, entity, id) => {
          // Return TagsRow or null
        },
        saveTags: async (db, entity, id, dataJson, tagsJson) => {
          // Upsert tags row
        },
        removeTags: async (db, entity, id, dataJson, tagsJson, deletedTag) => {
          // Upsert with deleted flag
        },
        saveEntity: async (db, tableName, id, columns, values, updates) => {
          // Upsert domain table
        },
      },
    }),
});

Atomicity

All materialization happens in the same transaction as db.append():

  1. Insert change to log
  2. Load existing tags
  3. Apply LWW
  4. Update domain table
  5. Update tags table
  6. Commit

If any step fails, everything rolls back.

Performance Tips

  1. Index the tags table on (entity, id)
  2. Use fieldMap sparingly — only for entities you query directly
  3. Batch changes — append multiple changes in one call

On this page