pi-mono/packages/chord
Armin Ronacher 48c8f32284 feat(chord): exclusive SQLite operations and a replicated connection wrapper
NodeSqliteDatabase.exclusive() runs an operation alone on the connection, outside a transaction, and rolls back a transaction the operation left open.

The internal replicated connection wrapper opens a database file with automatic checkpoints off, reports every operation, and runs a hook before SQLite checkpoints and deletes the WAL on close. It is not exported from the package yet.
2026-10-20 15:09:25 +02:00
..
docs feat(chord): references hydrate their state before use (protocol 4) 2026-10-14 14:29:58 +00:00
src feat(chord): exclusive SQLite operations and a replicated connection wrapper 2026-10-20 15:09:25 +02:00
test feat(chord): exclusive SQLite operations and a replicated connection wrapper 2026-10-20 15:09:25 +02:00
CHANGELOG.md feat(chord): exclusive SQLite operations and a replicated connection wrapper 2026-10-20 15:09:25 +02:00
package.json feat: SQLite telemetry store in Chord, shared TelemetryStore and trace renderer 2026-10-19 16:50:11 +00:00
README.md feat(chord): exclusive SQLite operations and a replicated connection wrapper 2026-10-20 15:09:25 +02:00
tsconfig.build.json feat: add Chord runtime foundation 2026-08-29 14:54:19 +02:00

@earendil-works/chord

Chord builds applications out of objects that live in several processes (a server, workers, browser tabs) and can be hot reloaded while they run.

  • An object is state plus code at an address. It is created on demand and lives while someone references it.
  • Everyone gets a reference, never the object. References work the same locally and across processes, and survive reloads.
  • Every object knows its principal: an owner and a guest get different objects, with different methods.
  • State is observable JSON. Remote viewers get a snapshot followed by deltas.
  • Hot reload swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.

Contracts are runtime values described with TypeBox schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.

Entry points

Import Contents Runs in
@earendil-works/chord Contracts (local, remote, method, opaque), declarations (through the factory's self: state, derive, resource, on, task), hosts (createHost, defineFacet, defineRegistry), links (memoryLinkPair, ipcLink, webSocketLink, acceptWebSockets), errors (ChordError, isChordError, forbidden, invalid). Also the shared base: Context, JsonValue, JsonRepresentation, Draft, copyJson, isJsonValue, and replicatedState with its types. Node and browsers
@earendil-works/chord/node Child host processes: createHostManager, forkDriver, childControl, processLock; the facet module loader nodeLoader. Node
@earendil-works/chord/context Explicit cancellation, telemetry, and invocation values: BACKGROUND_CONTEXT, withCancel, withAbortSignal, withoutAbortSignal, withTelemetry, traceIn, withContextValue, awaitWithContext. Node and browsers
@earendil-works/chord/delta The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. Node and browsers
@earendil-works/chord/output Bounded output streams and paced progress: OutputBuffer (head or tail window of a stream, OutputLimits, OutputSkip), boundOutput, sanitizeOutput, utf8ByteLength, and Progress (adaptive commits of a changing value). Node and browsers
@earendil-works/chord/telemetry-sqlite createSqliteTelemetry(db, { retentionMs?, maxQueuedSpans? }): buffered recording and backend-independent TelemetryStore queries. Any SQLite adapter
@earendil-works/chord/sqlite Portable SqliteDatabase, SqliteExecutor, and SqliteValue, atomic applySqliteMigrations(database, migrations, { table }), and validateSqliteSchemaPrefix. The metadata table must be a valid, non-reserved SQLite identifier; migration versions start at 1 and are contiguous. Any SQLite adapter
@earendil-works/chord/sqlite/node NodeSqliteDatabase (with exclusive(operation) for work that must run alone on the connection outside a transaction, such as checkpoints), openNodeSqliteDatabase, and NodeSqliteDatabaseOptions. Node
@earendil-works/chord/sqlite/cloudflare DurableObjectSqliteDatabase and the structural DurableObjectSqliteStorage binding type. Cloudflare Durable Objects

Quick start

import { Type } from "typebox";
import { createHost, hostNamed, method, remote } from "@earendil-works/chord";

// contract.ts: shared by provider and consumers
export const Counter = remote("demo.counter", {
	version: 1,
	input: Type.Object({ name: Type.String() }),
	host: hostNamed("server"), // where its objects live; callers never name a host
	variants: {
		default: {
			state: { count: Type.Number() },
			methods: { increment: method(Type.Object({ by: Type.Number() })) },
		},
	},
});

const host = createHost({ name: "server", self: { kind: "server" } });

host.serve(Counter, ({ name }, _principal, self) => {
	const count = self.state("count", Type.Number(), 0);
	return {
		variant: "default",
		count,
		increment: async ({ by }, ctx) => {
			count.change(ctx, (n) => n + by);
		},
	};
});

await host.root(async (ctx) => {
	const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends
	clicks.count.subscribe((n) => console.log("count", n));
	await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
});

Documentation

  • Tutorial: a shared todo list, built up section by section. Every example runs as test/tutorial.test.ts.
  • Usage guide: API reference for contracts, objects, state, roots, lifetime, plugins, hot reload, hosts, and links.
  • Specification: compatibility, lifetime, and reload semantics, plus known limitations and implementation decisions.
  • Delta guide: ownership, lifecycle, operation, and replica contracts of @earendil-works/chord/delta.

Explicit Context telemetry

Context always carries a telemetry: TelemetrySpan, alongside abortSignal. BACKGROUND_CONTEXT and TODO_CONTEXT use the shared no-op span NOOP_TELEMETRY_CONTEXT. Adapters (TelemetryContext) belong to hosts and start root spans; adapters are never placed in a Context.

import { BACKGROUND_CONTEXT, traceIn, withTelemetry } from '@earendil-works/chord/context';
import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry';

const telemetry = new InMemoryTelemetryContext();
await telemetry.startSpan({ name: 'app.request' }, async (span) => {
  const context = withTelemetry(span, BACKGROUND_CONTEXT);
  await traceIn(context, { name: 'app.load' }, async (child) => {
    child.telemetry.addEvent('cache.miss');
    return traceIn(child, { name: 'app.read' }, (nested) => readAccount(nested));
  });
});

withTelemetry(span, context) derives a new context; it never changes its parent, cancellation, or typed values. traceIn(context, options, fn) creates a callback-managed child span, and fn receives its derived context. Every context already supports telemetry.addEvent() and telemetry.addLink(). Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. value(), ContextKey, and withContextValue() remain supported.

These helpers operate on the standalone Context API. They do not yet instrument Chord hosts, methods, or network frames.

SQLite telemetry

import { createSqliteTelemetry } from '@earendil-works/chord/telemetry-sqlite';
import { openNodeSqliteDatabase } from '@earendil-works/chord/sqlite/node';
import { renderTrace } from '@earendil-works/pi-telemetry/render';

const db = await openNodeSqliteDatabase('telemetry.db');
const telemetry = await createSqliteTelemetry(db);
try {
  await telemetry.startSpan({ name: 'app.request' }, async (span) => {
    span.addEvent('request.accepted');
    await span.startSpan({ name: 'app.read' }, () => readAccount());
  });
  await telemetry.flush();
  const { traces } = await telemetry.findTraces({ name: 'app.request', limit: 10 });
  for (const trace of traces) console.log(renderTrace(trace.spans));
} finally {
  try { await telemetry.close(); } finally { await db.close(); }
}

The adapter is portable over SqliteDatabase and owns no connection. Recording performs no SQL. A one-second timer flushes settled spans and completed ten-second metric intervals in one transaction, including retention cleanup. Metrics retain count, sum, minimum, and maximum per name/kind/unit/attribute series, not raw samples, buckets, or exemplars. close() stops recording and drains partial metric intervals without waiting for business callbacks or closing the database. Late span completions are ignored.

Retention defaults to seven days. maxQueuedSpans defaults to 10,000 and counts settled spans awaiting commit, including an in-flight batch. The same limit independently bounds pending metric interval/series pairs. Overflow increments droppedSpans or droppedMeasurements; the limits bound counts, not bytes or active spans. A write failure stops recording permanently: flush() and close() reject with the original error without retrying.

The adapter implements TelemetryStore: findTraces(), getTrace(), and findMetrics() read committed rows, including after adapter close while the database remains open. See stored telemetry for filters, pagination, and cross-process trace assembly. Schema tables are telemetry_spans, telemetry_metrics, and telemetry_schema.

Tracking JSON deltas

Import the standalone transactional tracker from @earendil-works/chord/delta:

import { applyImmutable, track } from "@earendil-works/chord/delta";

const tracker = track({ output: "", count: 0 });
const change = tracker.beginChange();
change.state.output += "done\n";
change.state.count += 1;
const prepared = change.prepare();

tracker.adopt(prepared);
const replica = applyImmutable(prepared.base, prepared.ops);

tracker.value is always the latest adopted immutable revision. Preparation does not change authority; adoption validates the preparation and swaps the root pointer. Assigned containers are copied by value and unchanged subtrees may be shared between revisions.

The tracker uses trusted immutable ownership rather than defensive copying or freezing. track(initial), prepareReplace(value), replicatedState(initial), and replace(context, value) take ownership of alias-free strict-JSON roots without walking them. Callers must not mutate transferred or published data. Values placed through drafts are already copied, so that walk also rejects non-strict JSON before the draft changes. Published values are not frozen. In-process consumers may share their containers with the provider; mutating a consumed value violates the contract and can corrupt authority. Clone or serialize at any mutable trust boundary.

Replicated state provides the same model through a callback. replicatedState is a standalone observable value, independent of hosts; Durable uses it for document, view, and task-graph states:

import { replicatedState } from "@earendil-works/chord";

const initial = { output: "", count: 0 };
const status = replicatedState(initial); // transfers ownership of initial
status.change(context, (draft) => {
	draft.output += "done\n";
	draft.count += 1;
});

A successful change() publishes exactly one atomic revision. If its callback throws, the original value and sequence remain unchanged. Draft references become unusable when the callback returns. The tracker emits string append and front-truncate operations, array splices and permutations, sets, and deletes; large edit sets may fold into a complete replacement.

replicatedState(source, { onError }) instead attaches a publication-only state to an authoritative revision source (ReplicatedStateSource): it captures a snapshot, then publishes every committed frame in cursor order.

Public state subscriptions

state.subscribe(async (value, context, delivery) => { ... }) serializes callbacks independently for each subscription. Hydration starts immediately when a value is available, and its returned promise must settle before updates start. Synchronous callbacks still run synchronously. Read the captured value inside an asynchronous callback: state.value may already refer to a later revision.

Each subscription retains at most 100 pending complete-value deliveries, excluding the running callback. On overflow, only the newest pending value, context, and delivery metadata are retained; a not-yet-started initial hydration is preserved. Thus public delivery sequences may skip. This is a frame-count policy, not a byte limit.

The returned unsubscribe function immediately discards pending work and prevents new callbacks. It neither aborts nor waits for the running callback. Synchronous throws and promise rejections are observed, reported, and do not stop other subscriptions or subsequent callbacks. Attached sources use their onError handler; mutable states (and attached sources without a handler) report failures by throwing in a microtask. Unsubscribing does not hide a later rejection from the running callback.

Cells of Chord objects and their remote readables have their own subscription model; see State in the usage guide.