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
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-sqlite | materialize-db |
db-turso | materialize-db |
db-drizzle | materialize-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():
- Insert change to log
- Load existing tags
- Apply LWW
- Update domain table
- Update tags table
- Commit
If any step fails, everything rolls back.
Performance Tips
- Index the tags table on
(entity, id) - Use fieldMap sparingly — only for entities you query directly
- Batch changes — append multiple changes in one call
Related
- materialize-db — SQL-based materializer
- materialize-drizzle — Drizzle materializer
- Core Concepts — Understand LWW