RippleDB
RippleDB
Adapters

materialize-db

SQL-based state materializer for raw SQL projects

@rippledb/materialize-db

A SQL-based materializer that projects changes into domain tables. Works with db-sqlite and db-turso.

Installation

pnpm add @rippledb/materialize-db
npm install @rippledb/materialize-db
yarn add @rippledb/materialize-db

What Is Materialization?

Materialization is the process of applying changes to your domain tables so you can query them efficiently.

Without materialization:

-- Expensive: replay all changes to compute current state
SELECT * FROM ripple_changes
WHERE entity = 'todos' AND entity_id = 'todo-1'
ORDER BY seq;
-- Then apply LWW in code...

With materialization:

-- Fast: directly query current state
SELECT * FROM todos WHERE id = 'todo-1';

Basic Usage

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() },
  users: { id: s.string(), name: s.string(), email: s.string() },
});

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

How It Works

  1. Change arrives via db.append()
  2. Load existing tags from ripple_tags table
  3. Apply field-level LWW — compare HLC timestamps per field
  4. Update domain table with winning values
  5. Update tags table with new timestamps

All happens atomically in the same transaction.

Configuration

schema

Schema descriptor used for entity/field discovery.

dialect

Built-in SQL dialect. Currently supported:

  • 'sqlite' — For SQLite and Turso
import { createSyncMaterializer } from "@rippledb/materialize-db";

materializer: ({ db, schema }) =>
  createSyncMaterializer({
    schema,
    db,
    dialect: "sqlite",
    tableMap: { todos: "todos" },
    fieldMap: { todos: { id: "id", title: "title", done: "done" } },
  });

tableMap?

Maps entity names (from your schema) to database table names. If omitted, entity names are used as-is.

const sqlConfig = {
  dialect: "sqlite",
  tableMap: {
    todos: "todos", // Entity 'todos' → table 'todos'
    users: "app_users", // Entity 'users' → table 'app_users'
  },
} as const;

materializer: ({ db, schema }) =>
  createSyncMaterializer({
    schema,
    db,
    ...sqlConfig,
  });

fieldMap?

Maps schema field names to database column names. If omitted, field names are used as-is.

const sqlConfig = {
  dialect: "sqlite",
  tableMap: { todos: "todos" },
  fieldMap: {
    todos: {
      id: "id",
      title: "title",
      done: "is_done", // Schema field 'done' → column 'is_done'
    },
  },
} as const;

materializer: ({ db, schema }) =>
  createSyncMaterializer({
    schema,
    db,
    ...sqlConfig,
  });

tagsTable?

Name of the table storing entity tags/metadata. Default: 'ripple_tags'

const sqlConfig = {
  dialect: "sqlite",
  tableMap: { todos: "todos" },
  tagsTable: "my_ripple_tags",
} as const;

materializer: ({ db, schema }) =>
  createSyncMaterializer({
    schema,
    db,
    ...sqlConfig,
  });

Tags Table Schema

The materializer automatically creates 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 DEFAULT 0,
  deleted_tag TEXT,
  PRIMARY KEY (entity, id)
);

Custom Commands

For databases not covered by built-in dialects, provide custom SQL commands:

import {
  createSyncSqlExecutor,
  createSyncMaterializer,
} from "@rippledb/materialize-db";

const sqlConfig = {
  tableMap: { todos: "todos" },
  loadCommand: (tagsTable) => `
    SELECT data, tags, deleted, deleted_tag 
    FROM ${tagsTable} 
    WHERE entity = ? AND id = ?
  `,
  saveCommand: (tagsTable) => `
    INSERT INTO ${tagsTable} (entity, id, data, tags, deleted, deleted_tag)
    VALUES (?, ?, ?, ?, 0, NULL)
    ON CONFLICT (entity, id) DO UPDATE SET
      data = excluded.data,
      tags = excluded.tags,
      deleted = 0,
      deleted_tag = NULL
  `,
  removeCommand: (tagsTable) => `
    INSERT INTO ${tagsTable} (entity, id, data, tags, deleted, deleted_tag)
    VALUES (?, ?, ?, ?, 1, ?)
    ON CONFLICT (entity, id) DO UPDATE SET
      data = excluded.data,
      tags = excluded.tags,
      deleted = 1,
      deleted_tag = excluded.deleted_tag
  `,
} as const;

materializer: ({ db, schema }) =>
  createSyncMaterializer({
    schema,
    db,
    executor: createSyncSqlExecutor(sqlConfig),
    tableMap: sqlConfig.tableMap,
  });

Custom Executor

For full control, provide a custom executor:

materializer: ({ db, schema }) =>
  createMaterializer({
    schema,
    db,
    tableMap: { todos: "todos" },
    fieldMap: { todos: { id: "id", title: "title", done: "done" } },
    executor: {
      ensureTagsTable: async (db) => {
        // Create tags table if needed
      },
      loadTags: async (db, entity, id) => {
        // Load and return TagsRow or null
      },
      saveTags: async (db, entity, id, dataJson, tagsJson) => {
        // Upsert tags row
      },
      removeTags: async (db, entity, id, dataJson, tagsJson, deletedTag) => {
        // Upsert tags row with deleted flag
      },
      saveEntity: async (db, tableName, id, columns, values, updates) => {
        // Upsert domain table row
      },
    },
  });

Sync vs Async

For SQLite with better-sqlite3, use the sync variants:

// Sync adapter for better-sqlite3
materializer: ({ db, schema }) =>
  createSyncMaterializer({
    schema,
    db,
    dialect: "sqlite",
    tagsTable: "ripple_tags",
    tableMap: { todos: "todos" },
    fieldMap: { todos: { id: "id", title: "title", done: "done" } },
  });

For async databases (like Turso), use createMaterializer:

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

materializer: ({ db, schema }) =>
  createMaterializer({
    schema,
    db,
    dialect: "sqlite",
    tableMap: { todos: "todos" },
    fieldMap: { todos: { id: "id", title: "title", done: "done" } },
  });

Domain Table Requirements

Your domain tables must:

  1. Have an id column (or whatever your schema uses as primary key)
  2. Have columns for each field in fieldMap
  3. Already exist (the materializer doesn't create them)
CREATE TABLE todos (
  id TEXT PRIMARY KEY,
  title TEXT,
  done INTEGER  -- SQLite doesn't have boolean, use 0/1
);

On this page