RippleDB
RippleDB
Reference

@rippledb/core

HLC timestamps, Change types, and pure merge logic

@rippledb/core

The foundation package containing HLC timestamps, Change types, and pure merge logic. Has zero dependencies.

Installation

pnpm add @rippledb/core
npm install @rippledb/core
yarn add @rippledb/core

Hybrid Logical Clocks (HLC)

Types

// HLC string format: "wallMs:counter:nodeId"
type Hlc = `${number}:${number}:${string}`;

type ParsedHlc = {
  wallMs: number;
  counter: number;
  nodeId: string;
};

type HlcState = {
  lastWallMs: number;
  counter: number;
  nodeId: string;
};

Functions

createHlcState(nodeId: string): HlcState

Create a new HLC state for a node.

import { createHlcState } from "@rippledb/core";

const state = createHlcState("server-abc123");
// { lastWallMs: 0, counter: 0, nodeId: 'server-abc123' }

tickHlc(state: HlcState, nowMs: number): Hlc

Generate a new HLC timestamp. Mutates state to ensure monotonicity.

import { createHlcState, tickHlc } from "@rippledb/core";

const state = createHlcState("node-1");
const ts1 = tickHlc(state, Date.now());
const ts2 = tickHlc(state, Date.now());
// ts2 > ts1 (always increasing)

observeHlc(state: HlcState, remote: Hlc, nowMs: number): Hlc

Update local state after observing a remote HLC. Ensures local clock stays ahead.

import { createHlcState, observeHlc } from "@rippledb/core";

const state = createHlcState("node-1");
const remoteTs = "1706123456789:5:node-2";
const localTs = observeHlc(state, remoteTs, Date.now());
// localTs >= remoteTs

parseHlc(hlc: Hlc): ParsedHlc

Parse an HLC string into its components.

import { parseHlc } from "@rippledb/core";

const parsed = parseHlc("1706123456789:5:node-1");
// { wallMs: 1706123456789, counter: 5, nodeId: 'node-1' }

formatHlc(p: ParsedHlc): Hlc

Format ParsedHlc back to string.

import { formatHlc } from "@rippledb/core";

const hlc = formatHlc({ wallMs: 1706123456789, counter: 5, nodeId: "node-1" });
// '1706123456789:5:node-1'

compareHlc(a: Hlc, b: Hlc): number

Compare two HLCs. Returns -1, 0, or 1.

import { compareHlc } from "@rippledb/core";

compareHlc("1000:0:a", "1001:0:b"); // -1 (a < b)
compareHlc("1000:0:a", "1000:0:a"); // 0 (equal)
compareHlc("1000:1:a", "1000:0:a"); // 1 (a > b)

Changes

Types

type ChangeKind = "upsert" | "delete";

type RippleSchema = Record<string, Record<string, unknown>>;

type Change<S extends RippleSchema, E extends EntityName<S>> = {
  stream: string;
  entity: E;
  entityId: string;
  kind: ChangeKind;
  patch: Partial<S[E]>;
  tags: Partial<Record<keyof S[E], Hlc>>;
  hlc: Hlc;
};

Functions

makeUpsert<S, E>(input): Change<S, E>

Create an upsert change. Auto-generates tags if not provided.

import { makeUpsert } from "@rippledb/core";

type MySchema = { todos: { id: string; title: string; done: boolean } };

const change = makeUpsert<MySchema>({
  stream: "user-123",
  entity: "todos",
  entityId: "todo-1",
  patch: { title: "Buy milk", done: false },
  hlc: "1706123456789:0:server-1",
});
// {
//   stream: 'user-123',
//   entity: 'todos',
//   entityId: 'todo-1',
//   kind: 'upsert',
//   patch: { title: 'Buy milk', done: false },
//   tags: { title: '1706123456789:0:server-1', done: '1706123456789:0:server-1' },
//   hlc: '1706123456789:0:server-1',
// }

Input type:

type UpsertChangeInput<S, E> = {
  stream: string;
  entity: E;
  entityId: string;
  patch: Partial<S[E]>;
  hlc: Hlc;
  tags?: Partial<Record<keyof S[E], Hlc>>; // Optional, auto-generated if omitted
};

makeDelete<S, E>(input): Change<S, E>

Create a delete change (tombstone).

import { makeDelete } from "@rippledb/core";

const change = makeDelete<MySchema>({
  stream: "user-123",
  entity: "todos",
  entityId: "todo-1",
  hlc: "1706123456789:0:server-1",
});
// {
//   stream: 'user-123',
//   entity: 'todos',
//   entityId: 'todo-1',
//   kind: 'delete',
//   patch: {},
//   tags: {},
//   hlc: '1706123456789:0:server-1',
// }

Input type:

type DeleteChangeInput<S, E> = {
  stream: string;
  entity: E;
  entityId: string;
  hlc: Hlc;
};

Schema Definition

RippleDB uses typed field descriptors to define schemas with full type inference and runtime metadata.

defineSchema() and Field Descriptors

Use defineSchema() with s.* field builders to define your schema:

import { defineSchema, s, InferSchema } from "@rippledb/core";

const status = ["pending", "active", "done"] as const;

const schema = defineSchema({
  todos: {
    id: s.string(),
    title: s.string(),
    done: s.boolean(),
    status: s.enum(status),
    notes: s.string().optional(),
  },
  users: {
    id: s.string(),
    name: s.string(),
    email: s.string(),
  },
});

// Infer the TypeScript type from the schema
type MySchema = InferSchema<typeof schema>;
// = {
//   todos: { id: string; title: string; done: boolean; status: 'pending' | 'active' | 'done'; notes: string | undefined }
//   users: { id: string; name: string; email: string }
// }

// Use the inferred type with other RippleDB APIs
const store = new MemoryStore<MySchema>();

Field Descriptor Builders

The s object provides builders for all supported field types:

BuilderTypeScript TypeDescription
s.string()stringString field
s.number()numberNumber field
s.boolean()booleanBoolean field
s.enum(['a', 'b'])'a' | 'b'String literal union (use as const for inference)

All builders support the .optional() modifier:

s.string().optional(); // string | undefined
s.number().optional(); // number | undefined

Tip: Use a const assertion on your enum array for literal type inference. Without it, TypeScript infers string[] instead of the literal union.

InferSchema<D>

Type helper to extract the data type from a schema descriptor:

import { defineSchema, s, InferSchema } from "@rippledb/core";

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

type MySchema = InferSchema<typeof schema>;
// { todos: { id: string; title: string; done: boolean } }

Runtime Schema Descriptor

The defineSchema() function returns a SchemaDescriptor that provides runtime entity discovery and metadata attachment.

Runtime Discovery

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

// Entity discovery
schema.entities; // ['todos', 'users']
schema.entityMap.has("todos"); // true

// Field discovery
schema.getFields("todos"); // ['id', 'title', 'done']
schema.hasField("todos", "title"); // true
schema.hasField("todos", "missing"); // false

// Field type access
schema.getFieldDescriptor("todos", "done");
// { _type: 'boolean', _optional: false }

Schema Descriptor API

type SchemaDescriptor<S extends DescriptorSchema> = {
  // Original schema with field descriptors
  readonly schema: S;

  // Array of entity names for runtime discovery
  readonly entities: readonly (keyof S & string)[];

  // Map for O(1) entity lookup
  readonly entityMap: ReadonlyMap<keyof S & string, true>;

  // Map of entity names to their field names
  readonly entityFields: ReadonlyMap<keyof S & string, readonly string[]>;

  // Get field names for a specific entity
  getFields<E extends keyof S & string>(entity: E): readonly string[];

  // Check if an entity has a specific field
  hasField<E extends keyof S & string>(entity: E, field: string): boolean;

  // Get field descriptor for runtime type access
  getFieldDescriptor<E, F>(entity: E, field: F): FieldDescriptor | undefined;

  // Extensible metadata (Zod schemas, Drizzle tables, etc.)
  readonly extensions: ReadonlyMap<string, SchemaExtension>;

  // Attach metadata extensions
  extend<K extends string, E extends SchemaExtension>(
    key: K,
    extension: E,
  ): SchemaDescriptor<S>;
};

Extensions

Extensions allow adapters to attach metadata to schema descriptors:

import { withZod } from "@rippledb/zod";
import { z } from "zod";

// Using the withZod() wrapper for auto-generated Zod schemas
const schemaWithZod = withZod(schema);
schemaWithZod.zod.todos.parse(data); // Auto-generated from field descriptors

// With custom refinements
const schemaWithRefinements = withZod(schema, {
  users: {
    email: z.string().email(),
  },
});

// Manual extension attachment
const schemaWithDrizzle = schema.extend("drizzle", {
  tableMap: { todos: todosTable, users: usersTable },
});

Use Cases

  • Controllers / client-query: Build api.todos, api.users dynamically from descriptor.entities
  • Zod validation: Use withZod() for auto-generated or custom validation (see @rippledb/zod)
  • Drizzle adapters: Attach table definitions as single source of truth
  • Materializers: Read tableMap from descriptor instead of separate config

On this page