@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/corenpm install @rippledb/coreyarn add @rippledb/coreHybrid 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 >= remoteTsparseHlc(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:
| Builder | TypeScript Type | Description |
|---|---|---|
s.string() | string | String field |
s.number() | number | Number field |
s.boolean() | boolean | Boolean 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 | undefinedTip: 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.usersdynamically fromdescriptor.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
tableMapfrom descriptor instead of separate config
Related
- client-query — Primary consumer of schema descriptors
- client-controllers — Uses core change types
- Core Concepts — Understand HLCs and Changes
- Server Reference — Db interface for persistence
- Client Reference — Store interface for local state