pi-mono/packages/chord/CHANGELOG.md
Mario Zechner a5d209afd0 feat(chord): reload restarts every affected object; old work finishes on old code
The pure swap is gone: a reload restarts every live object of a changed plugin
(documents and tables kept). Calls running at the commit and every call they cause
(the ctx call chain; an old flag on call from trusted links) run on the open old
attempt, which seals once its work drained and its successor's recorded requests are
ready, bounded by reloadDrainTimeout (5 s); then the successor's factory runs. New work
waits at the gate; same-host consumers see the draining object live; a reload cycle
fails at once with unavailable/reload_cycle. Example 64, design section 21 rewritten.
2026-11-05 18:07:11 +00:00

65 KiB

Changelog

[Unreleased]

Breaking Changes

  • Chord v2 is the package's root: @earendil-works/chord exports v2 (was @earendil-works/chord/next). Chord v1 moved unchanged to @earendil-works/chord/old (was the root) and @earendil-works/chord/old/node (was /node); it stays until p2 runs on v2. The foundations v1's root re-exported have their own subpaths: @earendil-works/chord/json (JsonValue, JsonRepresentation, copyJson, isJsonValue) and @earendil-works/chord/watch (Watch, NativeWatch and the watch types), beside /context and /delta. Migration: imports of @earendil-works/chord/next drop /next; v1's imports of @earendil-works/chord become @earendil-works/chord/old, and /node becomes /old/node; imports of Context, Draft, JsonValue, copyJson or the watch types from the root move to /context, /delta, /json and /watch. In the repository, v2's src/next is now src, src/next-node is src/node, src/next-web, src/next-react and src/next-tui are src/web, src/react and src/tui, v1 is in src/old, and tests and docs moved likewise (test/next to test, docs/next to docs, v1's to test/old and docs/old).
  • Chord v1 (@earendil-works/chord/old, @earendil-works/chord/old/node) removed, with its tests and docs (src/old, test/old, docs/old). Apps still on v1 pin @earendil-works/chord@1.5.0.
  • Chord v2's startWorker no longer takes telemetry (it was added unreleased in slice 9): with a telemetry store on the server, the kit's workers send their spans and metrics to it, and no worker opens the store's file. startServer's telemetry is a store with ingest (createSqliteTelemetry's). Migration: drop telemetry from startWorker and the store's path from the worker's environment.
  • Chord v2's createWorkers returns a promise and rejects when the worker base does not build (decision "Workers start from the base code the server booted with"): it bundles the worker kind's entry and the base code it imports once with esbuild (--conditions from execArgv; Node builtins and packages resolved under node_modules external, by their files; workspace packages linked into node_modules inlined; import.meta.url and __dirname naming each source file; split at each dynamic import() esbuild can follow, so a lazily imported module and the packages it imports load only when the worker imports it; inline source maps, forked with --enable-source-maps) into a private temporary directory removed at close, and forks every worker from that snapshot: an edit of the app's source while the server runs (an agent half way through a change) no longer reaches workers forked later, which kept failing to start until the edit was fixed; a server restart applies it. workers.inputs lists the files the base was built from; the app kit's startServer builds each worker kind's base before loading (a broken one rejects the start, everything closed) and marks base when one of those files changes (it marked only the entry).
  • Chord v2's host.reload restarts every live object of a changed plugin; the pure swap in place is removed (decision "Reload restarts every affected object; old work finishes on old code"). ReloadReport.objects no longer has the kind swapped: a live object is restarted, or parked { cause } when its successor's recorded requests find a failure. Old work (the calls running at the commit and every call they cause through their ctx, and calls marked old over a trusted link) runs on the object's open old attempt; new calls wait at the gate for the new code. The old attempt is sealed once that work drained and its successor's requests are ready, bounded by the new host option reloadDrainTimeout (default 5 s, at most closeTimeout; the app kit's timeouts.reloadDrainTimeout reaches the server and every worker): a method still running then is cut released and reported drain_timeout. A wait of old work that its own old attempt blocks fails at once with unavailable/reload_cycle; a getter of old work for a version the new code dropped binds the open old attempt or fails at once. call gains old (binary flag 0x20, the text codec's seventh element; a relay drops it from untrusted links; PROTOCOL_VERSION stays 1); waiting causes keep the reason unavailable/version. Consequences: a reference shows loading briefly after every reload of its object (it stayed bound across a swap); a value read right after host.reload resolves may still be the old code's; an in-memory job run ends aborted { reload } at every reload of its object (it finished on its old code across a swap); routines restart when the successor goes live. Migration: drop swapped from code reading the report; open a root inside a method with withoutAbortSignal(ctx), not a fresh context, so its calls are old work; move work longer than reloadDrainTimeout into jobs.

Added

  • The sync layer (experimental): @earendil-works/chord/sync moves a store between devices through a remote, from the store's outbox (openStore(backend, ctx, { outbox: true })). Every device continues a store as its own: uploadSession() uploads the outbox commits after the last sequence the copy shares with the remote as one gzip batch into a slot the remote creates once per start sequence, trims the outbox through what the remote acknowledged, and reports a copy as diverged when another device's upload created that slot first, or behind when its head fell below what it had uploaded. A store whose outbox does not reach back to its first commit starts with an archive slot (store.export()). pullSession() catches a copy up through applyBatch(), resumes an interrupted pull, or restores into an empty backend (importStore() for an archive); foldSession() folds the history into a snapshot archive, keeping folded slots taken. FlushScheduler decides when to upload. FileSystemSyncRemote, a remote in a directory, and FileSyncStateStore, sync state in a directory, are for development and tests; they take their file operations as a SyncFileSystem (Pi Durable's environments fit it as they are).

  • Chord v2's Node components and client surfaces are exported: @earendil-works/chord/http, /workers, /workers/child (the worker's side, workerMain and processLock, without esbuild), /plugins, /plugins/runtime and /app; /surfaces (the web and terminal surfaces' server side), /web, /react, /tui and /tui/launcher.

  • Chord v2's plugins component gains the plugin model (slice 12): createPlugins({ api: { name, versions } }) checks every import of the app's versioned API (@acme/app/v3, per-kind modules such as @acme/app/v3/web): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be { path, required }: a required root's plugin failing at load rejects it with a PluginError after the load settled. Files named <name>.<kind>.<ext> (contract.web.tsx) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (hello.server.ts at a root's top, grouped by name, host packages only). installPlugin({ git, ref } | { npm, version }, root, ctx) installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its package.json ("chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }), used by discovery and installPlugin (without it: one plugin when the package's top has facet files, else its subfolders with a package.json). testPlugin(dir, { kind, modules, stubs, principal }, ctx) builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: docs/plugin-authoring.md.

  • Chord v2 gains the app kit (slice 13, @earendil-works/chord/app): startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx) lays out the data directory (server/store.sqlite, workers/<kind>/<address>/, plugins/, logs/, owner-token; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; startWorker({ api, kinds, busy?, onError }) is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects AppInfo and WorkerInfo serve process values to plugins. Sessions (Sessions: issue, verify, revoke, list; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most limits.ephemeralSessions) authenticate a signed HttpOnly, SameSite=Strict cookie or Authorization: Bearer; a one-time owner link per start (app.ownerLink(), the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (POST /_chord/pair, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. Files per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most limits.uploads uploads under way. app.exportData(address, path, ctx) and app.importData(archive, address, ctx) move the data a worker created (its store and files) as a tar without stopping anything: a running worker writes its own store at one consistent point, else the server reads it under the worker's process lock (import refusing an existing address, another app's archive, every path leaving the target, and archives past limits.importBytes or limits.importEntries). app.applyChanges(ctx) and app.plugins.install(source, ctx). Optional plugins as exported facets: defineUsers(profile), inviteFacet(), ephemeralLoginFacet().

  • Chord v2 gains web and terminal surfaces (slice 11): webSurface and tuiSurface (@earendil-works/chord/surfaces) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})) and one combined file, with WebScripts and TuiScripts showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (PageAssets, pageScripts). startPage (@earendil-works/chord/web) runs a page's client host: the connection loop (keepConnected: 250 ms doubling to 5 s, wakes, silent sockets, canRetry), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (@earendil-works/chord/react): HostProvider, useHold, useWatch, useRegistry, useRefState (React is an optional peer dependency). The terminal: startTerminal (@earendil-works/chord/tui/launcher) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); startTerminalRuntime and TuiScope (@earendil-works/chord/tui) inside it. Themes: the Themes registry, ThemeChoice (one dark and one light pick per principal), followActiveTheme, themeVariables, terminalColor. startServer gains web and tui. Plugin builds scope *.module.css files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an opened hook.

  • Chord v2's HttpRequest gains remoteAddress, the peer's address as the listener knows it (for limits per client).

  • Chord v2's HTTP component gains readSignedCookie(request, name, secret): readCookie's value with its signed expiry, for renewing a cookie before it expires.

  • Chord v2's root exports invalid(reason, message, details?) and the new forbidden(message, details?), the ChordErrors a plugin throws for arguments or params it refuses (invalid/args, invalid/params) and for a principal it refuses (forbidden); an app's API re-exports them, and the caller gets the same { code, reason } as from Chord's own checks.

  • Chord v2's React bindings gain usePageState(key, initial), useState's pair with the value kept by the page's host under key instead of the component: a component of a reloaded web plugin is a new function and remounts (its useState starts over), and page state is what survives (a half-typed message, a scroll position); any value, never on the wire, gone with the page. TuiScope gains pageState(key, initial) on the same per-host store, its changes calling the scope's changed.

  • Chord v2 reports mistakes that waited silently through onError (decision "Mistakes that wait are reported at once"): unbound_long { waitingFor, error, since }, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first compose threw) once after 5 s, on one timer per host (it keeps waiting until its readyTimeout); registry_undeclared { registry, plugins }, a registry with contributions and no provider at a publication (load, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; transaction_cancelled { object, error }, a document change or transaction that did not commit because the context it was started with ended first (a root whose fn returned, a caller that cancelled, its object's end), besides rejecting.

  • Chord v2 gains live queries: self.query(table, q, limit) in the factory of a table's owner is a watchable of the query's rows (at most limit, in its order), usable as a state member and a derive source. Chord runs it before the object goes live and again inside the commit step of every commit of the object that writes the table, publishing only the diff in that commit's batch; a commit writing no table with a live query runs nothing (chord.query.runs counts the runs). Only the object's own tables; the limit is required (invalid/args at declaration). A run that fails (a row the code cannot read) is a failed member, reported once as the new HostError query_failed { object, table, error }, and heals at the next good run. Each touching commit runs the query once (100 rows: about 60 µs, 80 µs watched): not for tables written hundreds of times a second.

  • Chord v2 gains messages and languages in the app kit (design §23): defineMessages(namespace, { en: {...}, de: {...} }) returns a typed t(key, ...args) (the first catalog types the others: a missing key, an unknown one or a wrong argument is a type error; values are strings or functions), t.message(key, ...args) builds a message as data (MessageData, { namespace, key, params }, for state members typed with MessageSchema), t.render(message) renders one (a foreign namespace or an unknown key from a link shows the key), t.translations lists its catalogs, and MessagesOf<typeof t> types a translation. The Translations registry takes a list of { namespace, locale, messages, override? } per contributor (one catalog per namespace and locale: the first by plugin name wins, reported; override: true wins); a missing key falls back down the locale's chain (pt-BR, pt) to the first catalog, reported once. LocaleChoice keeps one locale per principal on the server (the theme choice's store, now shared as one preference); a client follows it with followActiveLanguage, defaulting to navigator.languages on a page and the system locale in the terminal (startTerminal's locales), matched against the catalogs, switched live. useMessages(t) (/react) and TuiScope.messages(t) (/tui) render in the host's language and re-render or redraw on a change; startPage sets lang and dir on the page (followLanguage, /web); formatTime and formatNumber use Intl in the language being rendered. The kit serves Translations on the server, workers, pages and terminals and LocaleChoice on the server; client errors gain { kind: "messages", report }.

  • Chord v2 gains telemetry across hosts (slice 9): CHORD_TELEMETRY_SCHEMA (root export) types every span, event and metric Chord records. With a host adapter (createHost({ telemetry })), calls (chord.call), methods (chord.method, local and remote), relays (chord.relay), holds (chord.hold), activations (chord.activate, ending live, failed, parked or ended, with chord.retry and chord.park events), natives' opens and closes, load, reload (a chord.reload.fate event per object), resolves (a worker start included), revocations, routine calls, job phases (intent, run, finish: a persisted run resumed in a new attempt or process stays under its stored carrier) and HTTP requests (chord.http: method, route pattern, status, never the path) are spans; failures carry a fixed category and the message cut at 500 characters, a cancel is no error; commits are chord.commit { documents, ops, rows, duration } events of the span they ran under. hold and call carry the caller's trace carrier (trace, at most 8 keys, keys and values of at most 256 characters of printable ASCII, else the message fails the message schema and the link closes; the binary codec's kind byte flag 0x40), relays forward their own span's, and the provider runs the method under it (an absent or unknown carrier: the adapter's root); a reference's carrier rides on its first hold only, a re-hold after a reconnect being a fresh root linked to it. A hold's type and its address's kind are bounded at 256 characters by the message schema, and span attributes a peer chose at 256 characters. ctx.trace(name, attributes, fn) runs fn in a child span with a child ctx. Host-wide metrics: chord.link.bytes, chord.link.state_messages, chord.commit.duration, chord.calls.in_flight, chord.holds.active, chord.links.open, chord.park.duration, chord.backpressure.wait. Without an adapter nothing is started, built or sent: a call over a link costs what it did before. createHttp, startServer and startWorker take a telemetry adapter (createHttp continues a W3C traceparent only from a request a contributed relay verified: an app's proxy must strip it from browsers). A host with an adapter counts the cost counters of its links' and root scopes' work on it also when the app passed a context without a span. The SQLite telemetry store keeps spans of one microsecond in the order they started.

  • Chord v2's telemetry closes four gaps found writing the tutorial: chord.commit counts the table rows a commit's writes changed (rows; a commit writing only rows no longer reads documents=0 ops=0); every span Chord starts, ctx.trace's too, carries chord.host, the host's address key or client without an address (Host gains address); the SQLite telemetry store keeps span times in whole microseconds from pi-telemetry's telemetryNow (store version 2: a recorder migrates a version 1 file in place, its rows kept); and openTelemetryStore(path) (new subpath @earendil-works/chord/telemetry-sqlite/node) reads a store file read-only, implementing TelemetryStore only, refusing a file without a store or with one of another version. openNodeSqliteDatabase takes mode: "read-only".

  • Chord v2's telemetry is stored in one place, the server (decisions "Telemetry from browser pages", "The server is the one place telemetry is stored", "Who a span was for"). Pages and workers record in memory with Chord's TelemetryRecorder (the SQLite store's own recorder too) and send finished spans in batches over their link to the server's PageTelemetry and WorkerTelemetry (ingest(batch)), which the app kit serves when startServer has a telemetry store; the store gains ingest (never replacing a stored span). startPage({ telemetry: true }) records the page and sends its spans every 3 s and when hidden; the server checks a page's batch strictly (at most 100 spans, every field bounded, ids printable ASCII, no control characters), keeps only spans under the page's own ids (browser:<its incarnation>:<n>), caps them at 1,200 per page and minute, marks them chord.host: browser and stamps them with the principal of their link (a page's own chord.* attributes are dropped). A worker sends its spans and metrics every second, stamped with its host. host.root(fn, ctx, { trace: { name, attributes } }) runs fn in a new root span (a click). Method and call spans record whom they were for: chord.principal (the principal key) by default, or what the host's new principalAttributes hook returns (createHost, startServer, startWorker). ServeOptions.untraced serves a type whose calls start no method span. Host gains incarnation and principalAttributes.

  • Chord v2's TuiScope gains optimistic(read): the reader and show(value, call), which shows value in place of the reader's until every call shown settled (React's useOptimistic for terminal UIs). The guide's "Optimistic UI" shows React's useOptimistic and startTransition over a Chord call, what it relies on and what it does not cover; example 63-optimistic-answers.ts.

Changed

  • Chord v2's HTTP component denies by default (slice 13): a request or plugin socket without a principal gets 401 before plugin code runs unless its route or socket is marked public: true ({ handler, public: true }; a crossSite route is public), and the /chord links endpoint refuses requests without a principal.
  • Chord v2's host.get, host.hold, host.registry and a root's ctx.get on a host whose load was not called throw not_ready at once ("call host.load first") instead of waiting for a publication; a client with nothing to load calls load([]). Holds arriving on the host's links before load still wait for its publication. The HTTP component answers a request before load 503 at once.
  • Chord v2's host.load and host.reload throw for a plugin name that is no npm package name, before they change anything (it was listed in the report's failed).
  • Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's exports, pinned in dependencies; slice 10's imports by plugin name (@app/notes/contract) are removed, and a relative path leaving the root is a build error.
  • Chord v2's host.reload starts the objects' fates dependents first: removals, then the other fates in the reverse of the order the objects went live (the report keeps its order). A pure dependent reloaded with its provider (a view over a store in one plugin) binds the provider while it is still live and swaps instead of parking until the provider's new instance is live: its contributions are replaced in one batch and its holds over links never show loading. A dependent still parks when its new code needs the provider's new version, when its provider went live after it, or when it needs something not live.
  • Chord v2 keeps calls over a link across a loading unbind (decision "Calls in flight survive a loading unbind"): a hold whose object restarts, whose swap parks or whose live failure retries no longer answers its calls in flight unavailable; each is answered by the instance it already runs on or by the new instance once it is live (or by its failure, readyTimeout, its end), and calls a consumer sent by the old names before it saw the loading wait the same way instead of not_ready. A failed unbind (a removed type, a dropped version, a swap parked on a failed dependency) still rejects them.
  • Chord v2's store writes the commits queued behind a running commit step in one backend transaction (group commit, decision S18): each keeps its own seq, outbox commit and adoption in queue order, the metadata is written once; a refusal in a group rolls it back and runs its commits one by one, so only the refusing commit is refused.
  • Chord v2 sends a call's answer after its state (decision "A call's answer follows its state"): before a host sends a result or error, its own or one it forwards as a relay, it sends the origin's state still waiting for a slot of the window (one state message, past the window if it is full; acknowledged like any other). A call settling in a page now finds every change its answering host, and a relaying host, committed before answering, so an optimistic value is replaced by the real one instead of flickering back to the old value. Nothing extra is sent when nothing waits.

Fixed

  • Chord v2's workers on macOS and the BSDs: a worker killed by a signal (the grace's or the start timer's SIGKILL) left its lock's socket file, and every later start of its address failed with ... is a stale lock file, retry too, until the file was removed by hand. The workers component now removes a lock socket file nobody accepts on before each start and after a child's exit by a signal.
  • Chord v2's processLock on macOS spun until its context ended when a regular file sat at the lock's path (ENOTSOCK); it now fails closed naming the file, as for a socket file nobody listens on.
  • Chord v2's heartbeat closed every link after the process had slept (a laptop's sleep) or blocked its event loop, before reading what the peer had sent meanwhile. Time the process did not run no longer counts as silence: after such a pause each open link pings at once and closes only if its peer stays silent for close - ping (15 s by default).
  • Chord v2's workers no longer leave an address failed when a running worker loses its link (the heartbeat, a protocol error, the worker closing its own link): the worker is stopped, worker_link_lost { address, error } is reported, and its holders get a new worker. A second lost link within a minute of the last recovery still fails the address (public error worker lost its link). Crashes, fatals and failed starts still stay failed until retry or a rollout.
  • Chord v2's objects no longer answer a call not_ready during a reload while the caller sees them live: a call over a link to an object whose swap had parked (its new code waiting for a dependency) or whose restart had drained, sent while its hold was still bound to the old instance, got not_ready: ... not live yet. Such a call now waits for the object's new attempt (live, failed or ended), bounded by readyTimeout (unavailable/park_timeout); a parked attempt showing failed { waiting } rejects it with that cause; it stays when the hold then leaves the old instance with loading.

[1.5.0] - 2026-11-02

Breaking Changes

  • Schemas are JSON Schema checked by @earendil-works/pi-schema instead of TypeBox, which is no longer a dependency of Chord. Contracts, self.state(...) schemas and method signatures take pi-schema builders or any JSON Schema object (TypeBox-built schemas still validate, being JSON Schema, but type as unknown); handler and reference types come from pi-schema's Static. Validation, input defaults and the stripping of unknown fields work on any JSON Schema by its shape (before, defaults and stripping applied only to TypeBox-built schemas). format is an annotation, no longer checked: str({ format: "email" }) accepts "nope" (TypeBox checked the formats it knew); check a format in the handler where it matters. An invalid error's message is now <path> <message> with pi-schema's paths and messages (a missing field at its own path, missing; expected string, got number instead of must be string), and its details are pi-schema's ValidationErrors ({ path, keyword, message }) instead of TypeBox's errors. Link frames, descriptions, fingerprints and identities are unchanged for schemas with the same JSON. Migration, keeping a contract's fingerprint (it is computed from the schema's JSON): Type.String/Number/Integer/Boolean/Null/Unknown/Array/Union/Optional(...) become str/num/int/bool/nul/unknown/arr/union/opt(...) with the same options; Type.Object(p) becomes obj(p, { open: true }) (pi-schema's obj is closed by default) and Type.Object(p, { additionalProperties: false }) obj(p); Type.Enum(values) becomes oneOf(values); Type.Literal(v) becomes { type: typeof v, const: v } (pi-schema's lit(v) has no type); Type.Record(Type.String(), v) becomes { type: "object", patternProperties: { "^.*$": v } } (pi-schema's map(v) uses additionalProperties); Type.Intersect(list) becomes { allOf: list }; Type.Function(params, result) and Type.Void() become { type: "function", parameters, returnType } and { type: "void" }; Type.Unsafe<T>(schema) becomes schema as Schema<T>; Type.Immutable(schema) becomes the schema (cast its Schema<readonly T[]> type); opt(...) marks only an obj property; Static and TSchema come from pi-schema (Static, Schema). pi-schema's own lit, map and closed obj give other JSON and so other fingerprints, which matters only while peers of both versions talk.
  • traceIn(options, fn, context) takes the context last (was traceIn(context, options, fn)), like the rest of the API.
  • Watch listeners take one change object before the context: WatchHandle.start((change, context) => …) with change: WatchChange<T> = { value, ops, retired }, and NativeWatchHandle.start(({ value }, context) => …) with NativeWatchChange<T>. A change is plain data, those fields only; the frame's context is the second argument. Watch.advance(change, context) takes the same object, and a frame delivered alone, with nothing folded into it, reaches the listener as the object the source passed. Migration: start(async (value, ops, ctx) => …) becomes start(async ({ value, ops }, ctx) => …), start(async (value, ctx) => …) on a native watch start(async ({ value }, ctx) => …), and advance(value, ops, ctx) becomes advance({ value, ops, retired: false }, ctx).
  • SqliteExecutor.runCounted(sql, ...params) runs one statement like run and resolves with the rows it inserted, updated or deleted, as SQLite's changes() counts them (index writes and rows changed by triggers do not count). The Node driver takes it from node:sqlite; the Durable Object driver reads SELECT changes() after the statement (the cursor's rowsWritten also counts index writes), so run stays as cheap as before. Migration: custom SqliteDatabase and SqliteExecutor implementations add runCounted.
  • Retirement is a flag, not an end: WatchEnd loses retired, and Watch.retire(context) is removed. A source reports a retirement with advance({ value, ops, retired: true }, context), whose value is what the address shows after it, and the watch goes on: the next call has retired: true, folded with every other pending frame, so a document created at the address afterwards arrives in the same or a later call as [["r", value]]. endAfterDelivery() tells a watch its source sends nothing more: it takes no more frames, delivers what is pending, and ends ended after its last call returned, at once when nothing is pending and no call runs; it stays attached until then, so the source's end() (such as at its close), stop(), cancellation, or a throwing listener still end it first. Migration: a listener that waited for closed to give retired or tested for a null value tests change.retired; a source that called retire(ctx) calls advance({ value, ops, retired: true }, ctx), then endAfterDelivery() when its owner ends with the retirement.
  • Watch listeners get a tagged call, and a watch stays open through a failure its source recovers from: WatchCall<T> is { kind: "value", value, ops, retired } or { kind: "failed", error }, and NativeWatchCall<T> is { kind: "value", value } or { kind: "failed", error } (replacing WatchChange<T> and NativeWatchChange<T>). After a failed call watch.value keeps the last value and the next value arrives as [["r", value]]; while the listener is busy, a failure drops the pending values (value then failed folds into the failure) and a value replaces a pending failure (failed then value folds into the value, as r). Watch.advance(call, context) takes a WatchCall, a value call delivered alone still reaches the listener as the object the source passed (no allocation), and Watch.fail(error, context) and NativeWatch.fail(error, context) send a failure. Migration: start(async ({ value, ops }, ctx) => …) becomes start(async (call, ctx) => { if (call.kind === "failed") return; const { value, ops } = call; … }), a native start(async ({ value }, ctx) => …) likewise; advance({ value, ops, retired }, ctx) becomes advance({ kind: "value", value, ops, retired }, ctx).
  • WatchEnd is tagged by kind instead of reason, and a watch whose acquisition context is aborted ends aborted instead of cancelled: { kind: "stopped" | "aborted" | "ended" }, { kind: "listener_error", error }, or { kind: "failed", error } (a final failure only). Watch.cancel() is abort(), observeCancellation(signal) is observeAbort(signal), and end(failure?) takes the end: end({ kind: "ended" }) or end({ kind: "failed", error }). Migration: end.reason === "cancelled" becomes end.kind === "aborted", every other end.reason becomes end.kind; watch.end() becomes watch.end({ kind: "ended" }), watch.end({ error }) becomes watch.end({ kind: "failed", error }).

Added

  • Chord v2's store (@earendil-works/chord/next) gains ChordStore.commitSeries(commits, receive, ctx): several commits in one backend transaction, each at its own seq with its own outbox row, the store metadata written once. Each commit's check runs on a step that sees the commits before it, before its writes; a check that throws refuses that commit and stores the ones before it, while a failed statement or write rolls the whole series back, and a failed COMMIT stops the store. It replaces commitInBatch, which is removed. Durable's StoreStorage stores each Session commit call through it.
  • Chord v2 has a package export again, @earendil-works/chord/next (experimental): every layer's API for apps (contracts, data, documents, hosts, jobs, links, state, the store), and, marked internal, the store's surface for adapters (StoreOwnerTables, OwnerTable, the documents engine's reads and writes, the commit step, applyBatch). v2's store gains one id allocator for every id in it, claims per table and per document namespace, Durable's Storage semantics for a refused commit (it rejects that commit only; an unknown outcome stops the store), document histories none, latest and rewindable with reads at a point, archives carrying document revisions, and batch apply; v2 objects gain self.follow (another runtime's synchronous observers as sources) and persisted jobs (persist: { resume }, a stored abort(id, ctx), deferred state changes).
  • Chord v2 (@earendil-works/chord/next) gains registries, writables, wake signals and root scopes (slice 8): defineRegistry<C, V>(name, { version }) tokens shared by plugins, served by the defining plugin's facet with host.serve(R, { compose, accepts? }); contributions from facets (defineFacet((host, self) => self.contribute(R, value))) and host-lifetime objects (StoreSelf.contribute), plain values or Chord watchables, in a table sorted by plugin name and composed lazily (once at the end of load's setups, then when read or followed, in the batch that changed the table); readers through self.registry(R) (a declared dependency: an object activates once the registry has a value, and fails transient with the new reason unavailable/undeclared when nobody serves it) and host.registry(R, ctx), a RegistryValue<V> with observe(observer, ctx); older contributions and readers adapted by accepts; the helpers keyedEntries and orderedEntries; the onError kinds registry_conflict, registry_failed, contribution_failed and contribution_refused. self.writable(initial) is an in-process value an attempt sets (identity compared); native sources (writables, registry values, ref.state) are derive sources. self.retryOn(...sources) retries a failed object when a registry value changes. host.root(fn, context, { principal? }) runs code outside objects with a handler's ctx, sealed when fn settles, context aborts or the host closes.
  • Chord v2 gains plugins (slice 7c): src/next-node/plugins (Node, to be exported as @earendil-works/chord/next/plugins) finds plugin directories, builds each facet with esbuild (now an optional peer dependency of @earendil-works/chord, imported only there), evaluates it with vm.Script, maps plugin stack traces (mapStack), installs plugin dependencies, watches plugin and base code, and rolls reloads out across hosts. host.load and host.reload work per plugin: a plugin whose setup, factory or declarations fail is left out at load ({ failed }) or kept on its old code at reload (failed in the report, reload(facets, ctx, { failed }) for build and evaluation failures), with a closure rule keeping a consumer old when its provider was kept old; an abort after a reload's commit rejects with details.committed.
  • Chord v2 gains HTTP and workers (slice 10): src/next-node/http (to be exported as @earendil-works/chord/next/http) builds a Web-standard fetch handler from the routes, wraps, sockets, hosts, relays and CSP sources plugins contribute to the registry Http, with authenticators from the registry Authenticators, responses as descriptions (json, html, bytes with ranges, redirect, error; fileResponse), and a security baseline on by default (provenance by the socket and Host, foreign requests anonymous, Origin checks, default headers with a per-response CSP nonce, signed cookies, safe downloads); Chord's own Node listener as a host-lifetime object, nodeRequest for apps mounting it on their own Node server, the links endpoint and plugin sockets with a heartbeat, closed at once when their contribution leaves. ws is an optional peer dependency, imported only there. src/next-node/workers (createWorkers, workerMain) runs a forked worker per address of a host kind, started on demand, stopped when idle, never restarted by itself (a failure stays until retry or a rollout), its plugins built by the server and shipped. The plugins component defines one definition per facet kind (kinds: { name: { platform, hostPackages } }, modules given at plugins.load(host, { kind, core, modules })), splits loading into createPluginRuntime() (no esbuild), offers scripts(kind), rolls out with one result per host (RolloutStep returns { hosts }), and resolves cross-plugin imports by plugin name (@app/notes/contract). Kernel: host.route returns a RouteHandle (retry, suspend) and a route has no backoff timer (a failed resolve fails its holders waiting); host.root(fn, ctx, { principal: undefined }) runs anonymous; a link reads origin only on parent and route links (a protocol error on a child link) and drops a per-origin record once it is idle.
  • Chord v2, experimental and not exported yet: the next object runtime in src/next (contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store), with its tests and runnable examples in test/next and its design in docs/next (design.md, the decision log decisions.md, the slice plan slices.md). Its API changes with every slice until it gets an entry point.
  • NativeWatchable<T>: an in-process value that changes and can be watched (value, watch(context) returning a NativeWatchHandle<T>), the in-process counterpart of Watchable<T>.
  • WatchHandle<T> and WatchEnd, the one watch API, moved from Durable: a serialized observation with value, start(listener), stop(), and closed; the listener gets a WatchChange<T> ({ value, ops, retired }) and the context. WatchEnd reasons are stopped, cancelled, ended, listener_error, and failed.
  • Watch<T>, the one watch handle implementation, moved from Durable: a source creates one per watch and feeds it with advance(change, context) (retired: true for a retirement), end() or end({ error }), endAfterDelivery(), and observeCancellation(signal). Frames that arrive while the listener runs (or before start) are delivered in one call with the newest value, their ops folded by PendingOps and the newest frame's context; past 10,000 pending ops after folding, the call gets one root replacement [["r", newest]] instead (replaces the collapse after 100 undelivered frames); termination drops the listener and every reference into the source.
  • Watchable<T>: a value that changes and can be watched (value, watch(context) returning a WatchHandle<T>), the common interface of documents, derives and other observable values.
  • NativeWatchHandle<T> and its implementation NativeWatch<T>: the same lifecycle for in-process values, with listener ({ value }, context) (NativeWatchChange<T>) and no ops: one call per value in order, and past 100 pending values one call with the value of the optional replace() or the newest value.
  • PendingOps and PendingTake in @earendil-works/chord/delta: the pending ops of one slow consumer, kept raw and folded while they wait (appends to one path join, a primitive set replaces earlier leaf ops on its path, r drops everything before it); size counts the pending ops after folding; take() empties the queue and hands over the folded ops, or replace past 10,000 pending ops. Received batches and op tuples are never mutated.
  • ownedSlice(text, start, end?) in @earendil-works/chord/delta: a slice of a string that does not keep the whole string alive (#9265)
  • appendText(draft, key, text) in @earendil-works/chord/delta: append to a string in a draft at O(text) cost, recorded as an append op without comparing the old and new strings (a plain draft[key] += text makes prepare() read the whole string, O(length) per change); StringKeys<T> names the keys it accepts (#9265)

Changed

  • Chord v2 (@earendil-works/chord/next) checks its schemas with @earendil-works/pi-schema instead of TypeBox: its helpers (str, num, int, bool, nul, lit, oneOf, union, obj, arr, map, opt, bytes, unknown) build on pi-schema's builders and keep the JSON contracts were fingerprinted with, so every fingerprint, normal form and stored record stays the same (a test records them and reads a store written before the switch). Static and Schema (was TSchema) come from pi-schema; opt(...) marks an object field or a table column and is no schema elsewhere (a top-level opt had no effect before); a schema with a non-enumerable key (a TypeBox schema's metadata) is refused at declaration; map(bytes()) is accepted now (it was refused while bytes() carried a TypeBox refinement). format is an annotation, no longer checked ({ type: "string", format: "email" } accepts "nope"; TypeBox checked the formats it knew). A failed check's message comes from pi-schema (args/title: expected string, got integer), its details.path escapes ~ and / (JSON Pointer); reasons and codes are unchanged. Declaring and compiling are cheaper (a 20-method contract 1.07 ms to 0.86 ms, a validator's first use 172 µs to 48 µs), a check costs the same (0.25 µs), the message envelope check up to 100 ns more per message.
  • The delta tracker keeps its one open change in a plain field and needs a WeakRef only for a second change open at the same time (one per change before). A WeakRef target survives the scavenges of its job, so a run of changes in one job promoted every change's overlay to old space: 200,000 small changes in one job now promote 2 MB instead of 47.5 MB and take 3.1 µs each instead of 3.8 µs (3.0 instead of 3.5 µs when they span jobs). A draft dropped without prepare() or abort() now stays referenced by its tracker until the tracker's next beginChange() or adopt().
  • applyImmutable(), applyImmutableBatches() and tracker revisions copy each container on a changed path with spread (or Object.assign() onto a null-prototype object) instead of one Object.defineProperty() per key, about 25 times faster per container (#9265)

Fixed

  • A host a route resolves is admitted even when the relayed reference that asked for it is released, its client disconnects, or its principal is refused or revoked while the route resolves. Before, the parent never read the new host's link: a spawned worker's own references to its parent (its lifetime: "host" objects' upstream references) waited until they failed unavailable/ready_timeout, reported as a possible reference cycle across hosts.
  • The Node SQLite driver keeps the 256 most recently used prepared statements per connection instead of every statement it ever prepared, so callers generating SQL of unbounded shapes (a query builder) no longer grow memory without bound.
  • Fixed append ops from the delta tracker and diffRevisions() keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit (#9265)

[1.4.0] - 2026-10-21

Breaking Changes

  • Custom Context implementations must provide telemetry: TelemetrySpan; use the no-op span NOOP_TELEMETRY_CONTEXT when disabled. withTelemetry() accepts spans, not adapters.

Added

  • @earendil-works/chord/telemetry-sqlite: portable SQLite telemetry recording with bounded settled-span queues, ten-second metric aggregates, periodic flushes, and retention cleanup.
  • Context.telemetry is an explicit, non-optional telemetry parent (no-op by default). withTelemetry(telemetry, context) derives it without changing values or cancellation; traceIn(context, options, fn) starts a child span and passes its derived context to the callback.
  • @earendil-works/chord/sqlite: portable SQLite interfaces, atomic migration runner, and schema-prefix validation, with Node (/sqlite/node) and Cloudflare Durable Object (/sqlite/cloudflare) drivers. Moved from Durable, which keeps its storage implementations and re-exports the moved public names.
  • @earendil-works/chord/output: Progress takes an optional ProgressClock (now, setTimer) to pace commits on, by default Date.now and setTimeout.

[1.3.0] - 2026-10-15

Breaking Changes

  • Terminology follows the concepts: a reference (what get returns) is Ref<C> (was Handle<C>), its status RefStatus (was HandleStatus); what a factory returns is ImplementationOf<C> (was BehaviorOf<C>); a resource's options are a ResourceDeclaration (was ResourceSpec); epochs are code versions: Ctx.codeVersion, RootOptions.codeVersion, Host.codeVersion/previousCodeVersion, and the codeVersion fields of frames and reload reports (all were epoch). Docs and messages say object (was record) and reference (was handle and hold); the hold and release frame types keep their names
  • A remote object's variant is named variant (was kind): factories return { variant: "editor", … }, references expose ref.variant and shape.variant, and the shape frame carries variant. variant is reserved as a member name; kind no longer is
  • Links have a protocol version (PROTOCOL_VERSION, now 2), announced in hello. Peers of different versions do not talk: the side that notices closes the link with reason protocol-version <n>, and both sides report it through LinkOptions.onProtocolMismatch. Peers without a version (older Chord) are refused
  • serve(…, { lifetime }) replaces eager: "referenced" (the default) creates an object at the first get of its address and disposes it after the last reference is released (plus linger); "host" creates the type's one object when its facet loads and keeps it until the host closes or a reload removes its facet. lifetime: "host" is refused at serve for remote types, for inputs that do not accept {} and together with linger; every get of such a type reaches its one object, created for the host principal, and another input is invalid/input. eager: { input } and the extra objects host-principal gets with other inputs created are gone. Only "host" objects may register (reason invalid/not_host_lifetime, was not_eager). LoadOptions.awaitHostObjects replaces awaitEager
  • Declarations are calls by name: self.state(name, schema, initial, { version?, migrate? }) returns the cell, self.resource(name, { version, deps?, exclusive?, open, close? }) the resource (rev is now version), self.on(name, source, [select,] handler) declares a listener; self.derive(sources, fn) and self.task(fn) take no name. cell(), derivation(), resource(), listener(), self.resources, self.listen, self.tasks and the declaration maps are gone, and so are the types CellDecl, ResourceDecl, DerivationDecl, ListenerDecl. A repeated name of one kind in a run is invalid/duplicate
  • State versions belong to cells: ServeOptions.state is gone; a cell's version (default 1) and migrate(old, from) replace it. A raised version migrates only that cell; a lowered one refuses the reload
  • A derivation is new at every reload (computed at the commit) instead of kept with its function replaced. A listener whose state source a reload replaced subscribes again and gets the current value again: listeners on state must reconcile. Listeners on state subscribe when they activate, so they get the value current then
  • method(args?, result?): without arguments a method is called ref.name(ctx?) and implemented (ctx) => …; without a result it is void (callers get undefined). Descriptions record both by leaving args/result out (fingerprints of such methods change), call frames omit args and result frames value for them. Coverage requires both sides to agree on arguments; a void consumer accepts a result
  • Remote contracts declare where their objects live, and callers no longer name a host: remote(name, { …, host: hostNamed("server") }) or host: hostFamily("session:", "session") (the host session: + the input's required string field session, checked by TypeScript). host is required on remote contracts; GetOptions.at is removed. A get normalizes the input, resolves the host, and acquires locally or over the link to it. serve refuses a type whose placement does not include the host (also at a reload), a host refuses a hold whose input its own contract places elsewhere (invalid/placement), and a relay refuses a hold whose target disagrees with its contract token. The placement is part of the description and fingerprint; coverage requires equal placements, and accepts adapters do not bridge placements. Remote references are addressed by type, normalized input and principal, without a host name. PROTOCOL_VERSION is 3 (remote tokens carry host; a hold's input is normalized). New exports: hostNamed, hostFamily, Placement, PlacementFor, HostNamed, HostFamily
  • FacetApi has a signal (below): code that builds a FacetApi by hand, for example to wrap serve, must pass it on
  • Principals are typed and supplied by the app: createHost<P>, Host<P>, defineFacet<P>, FacetApi<P>, Serve<P>, Factory<C, P>, Self<P>, Ctx<P>, GetOptions<P>, RootOptions<P>, Route<P>, AcceptOptions<P> and PrincipalMatch<P> take the app's principal type (default unknown), so factories, methods, listeners and tasks narrow on it without casts. Chord invents no principals any more: createHost({ self }) (required) is the principal of the host's own code, replacing the built-in hostPrincipal ({ host: true }, removed); connect(link, { principal }) (required) is what the parent's own requests act as, replacing { host: <parent name> }; Route.principal is required, replacing the default { host: <host name> }. HostOptions.upstream is { link, principal, ...LinkOptions } (was a link, with upstreamOptions). The host's own principal is recognized by principalKey, not object identity. The Principal type is gone; ConnectOptions and PrincipalPattern are new
  • serve(type, factory, { lifetime: "host" }) compiles only for a local type whose input has no required fields, and without linger; serve still refuses the rest at runtime. ServeOptions is a union of ReferencedServeOptions and HostServeOptions, and serve takes ServeOptionsFor<C>
  • An awaited reference has its state, local or remote. ctx.get and host.get are async: they resolve once the reference is live and every state member of its variant has its first snapshot, and reject with the activation error, aborted (the ctx aborted or its call settled; host.get's signal) or unavailable/ready_timeout after HostOptions.readyTimeout (default 60000 ms), releasing only that reference. host.hold is the synchronous form (possibly loading) for UI runtimes; ref.ready stays. self.get stays synchronous: an object goes live only once its references are ready and hydrated, and the state it exposes or derives from (also forwarded from a remote reference held another way) has its values; activation and reload prepare wait at most readyTimeout (a reference cycle across hosts fails with a hint instead of hanging)
  • State members are typed T (was T | undefined) and StateSource<T> is Readable<T>. Reading state before the first hydration throws not_ready, outside the current variant not_in_variant, after the reference ended its end error. A remote reference is a union discriminated by variant: another variant's members are reachable only after narrowing. MembersOf is gone. A listener added before a member is readable gets its snapshot then (no undefined snapshot first)
  • A remote reference stays subscribed to its variant's state while held, with or without listeners (it no longer forgets its value when the last listener leaves). GetOptions.state: false makes a method-only reference: no state unless another reference to the object wants it, ready once live
  • Shape.codeVersion and the shape frame's codeVersion are gone
  • PROTOCOL_VERSION is 4: a hold implies the state of its variant (state: false opts out until sub; unsub stops it); the provider answers with units, a shape listing the members the consumer lacks (state) followed by their snapshots; state frames are addressed by hold and member (hold, m) with as: "snap" | "reset" | "ops" | "error" and value (absent: undefined); sub/unsub take a hold; resync asks for a member again; again marks a re-hold after a link loss; origin (replacing client) names the client connection a hold comes from

Added

  • @earendil-works/chord/output: OutputBuffer, which keeps a head or tail window of a stream at a cost proportional to each chunk, Progress, which paces commits of a changing value by their size, plus boundOutput, sanitizeOutput, characterEnd, utf8ByteLength, and the OutputLimits, BoundedOutput, OutputSlice, and OutputSkip types. Moved from @earendil-works/pi-durable, which now uses them from here.
  • Remote references to one object share one binding per host and key (type, normalized input identity, principal key, contract fingerprint): one hold, one stream per state member, calls over the same hold; every reference keeps its own status, observers and release. HostOptions.linger ({ types: { [typeName]: ms }, cap }, cap default 4) keeps a binding held and hydrated after its last reference is released, so a reference taken again meanwhile costs nothing on the link
  • Holds from clients carry an opaque origin: the id the accepting host gave the client connection, forwarded by relays and honored only from the parent link, so a host can tell client connections apart; a disconnect releases that client's holds at once
  • Flow control by units: the first unit of a new reference passes the window, so a get never waits behind other references' state; units for reloads and re-holds wait, built lazily from the current values; waiting ops are capped per link (LinkOptions.pendingCap, default four windows), beyond which the oldest streams become resets (a relay asks downstream with resync)
  • StateOptions, EventOf, ValueOf and Lifetime types
  • self.state(name, schema, { init }, options?): a cell whose first value comes from its source. init(ctx) (may be async) runs once the object's resources are open and its references live, before the object goes live, so no method, listener, task or subscriber sees another value; a throw or an invalid value fails the activation. A reload keeps the cell without running init; a cell a reload adds runs it while the reload prepares (a failure refuses the reload). Until then the cell's value throws not_ready. StateInit type
  • Links are flow controlled end to end: peers acknowledge what they processed (ack frames, announced with acks in hello), and while a window of frames is unacknowledged (window in LinkOptions, default 256 KiB), state frames wait at the sender, coalesced per subscription, instead of queuing in front of call results. A stalled peer costs a bounded slot per subscription; a slot that outgrows it becomes a reset. Calls, results and control frames are never held back, so under load a result can arrive before state changes made before it
  • maxCalls in AcceptOptions limits the calls a peer may have in progress on a link; further ones are answered at once with unavailable (reason too_many_calls) and the link stays open
  • FacetApi.signal aborts once that run of the facet is no longer installed: a committed reload replaced or removed it, the reload that loaded it aborted, or the host closed. While a reload prepares, the installed and the candidate run are both live
  • HostOptions.taskStopDeadline (default 5000 ms) bounds how long a reload waits for aborted tasks before it starts their replacements
  • Links deflate call arguments, results and state snapshots whose JSON exceeds PACK_THRESHOLD (64K characters): over IPC as a base64 field, over WebSockets as a binary message. Relaying hosts forward the deflated bytes unread; the host that uses the payload inflates it. webSocketLink(socket, { compress, acceptPacked }) (WebSocketLinkOptions) controls sending and accepting packed frames (servers should refuse them from untrusted clients), and inflateMessage turns a packed WebSocket message back into a text frame

Changed

  • Link.send returns the encoded frame's size as sent (deflated payloads count as sent) when the transport knows it (memory and WebSocket links); flow control counts these sizes, so links that return nothing (IPC, custom links) are not windowed
  • A reload starts an object's new tasks only after its aborted ones finished (at most taskStopDeadline, then anyway, reported); before, the old and the new run of a task worked side by side
  • When a reload forces the retirement of the previous code version, it first aborts the roots still running on it (their ctx.signal, reason superseded) and gives them up to 1 s to stop before it closes what only that code used; a call with such a ctx is superseded. Before, resources were closed under running roots, which were never told

Fixed

  • nodeLoader registers every facet's directory before importing any, so a facet that imports a file of a facet listed after it gets the same module copy as that facet; before, the load order could produce duplicates.
  • A link that is already closed when a host accepts or routes to it is no longer kept as a connection: before, a route that returned a dead link (a child process that exited before its link was wrapped) made every later call to that host name fail with unavailable until restart, and leaked the connection's ping timer.
  • Fixed processLock() failing with listen EINVAL on macOS and the BSDs when the temporary directory is too deep for a socket path (sun_path holds 104 bytes); the lock socket then lives in a private per-user directory, /tmp/chord2-<uid>
  • A child host whose hello arrived while a cluster reload ran was marked in step without taking part, so it stayed on the old code version and every call to it failed superseded until it reconnected. It is now synced or reconciled when the reload ends; a child already behind is fenced at once, so its roots' calls up are admitted meanwhile. A reconciliation that is running when a reload starts likewise syncs its child only after that reload, against the committed code version
  • A child host retired its previous code version as soon as a cluster reload's prepare arrived, before its parent did, so the parent's old roots got superseded on it. The coordinator now retires before it sends prepare, a child waits for its parent's retire (at most retireDeadline), and a child coming in step (or about to be reconciled) is told which code versions the parent retired before
  • Disposed objects stayed reachable from the host until an object with the same address was created again (every distinct guest, session or input leaked one object, its references and the code they bind)
  • References kept every code version's services, and with them the factory code of every reload, reachable for as long as they lived (held references, objects of lifetime "host"); old code generations could never be collected
  • Call, task and root contexts were linked to long-lived signals with AbortSignal.any; Node keeps such dependants reachable while they have abort listeners, so a listener on ctx.signal kept its closure (and its code generation) alive for the object's lifetime. Contexts now detach when they close
  • IPC links and the host manager's control channel wrote to a peer process that had exited without a callback, so the EPIPE Node reports for a write in the gap between the exit and disconnect (an ack sent just after a worker shut down) became an unhandled error event that crashed the process. The link now ends on a failed write (reason ipc send failed: EPIPE), and control messages to a gone peer are dropped
  • A relayed call ran under the origin's cancel signal only, so a forced retirement of the relaying host could not cancel it before closing the old code's resources; it now follows its root's signal
  • A resource kept across a reload closed with the close of the factory run that first opened it, and kept that run's code reachable; it now adopts the newest declaration at commit
  • forkDriver passed its own CHORD2_LOCK to forked children, so a child host that forked its own children made those without a lock of their own fail with locked
  • A call through a remote reference accepted a ctx from another host, or an object not made by Chord, and carried its root over the link; it now rejects it with invalid/foreign_ctx like a local reference (#10959)

[1.2.0] - 2026-10-12

Breaking Changes

  • Replaced the facet and service runtime with a typed host runtime: contracts (local, remote, method, opaque) are runtime values described with TypeBox schemas, objects are served by hosts (createHost, defineFacet, defineRegistry) and reached through handles, and hosts exchange JSON frames over memory, Node IPC, or WebSocket links (memoryLinkPair, ipcLink, webSocketLink, acceptWebSockets), with hot reload of plugins. See docs/tutorial.md and docs/guide.md
  • Removed defineService, createFacetHost, createRemoteServiceBinding, createStaticFacetLoader, combineFacetLoaders, the remote-service endpoint, provider, error, and wire helpers, and their types. The old defineFacet, Facet, and FacetLoader are replaced by the new runtime's names of the same spelling
  • Removed the ./bundler export and the facet bundle loaders of ./node; ./node now exports the child host manager (createHostManager, forkDriver, childControl, processLock) and the facet module loader nodeLoader
  • Removed @earendil-works/pi-client, @earendil-works/pi-protocol, and @earendil-works/pi-server, which served only the old runtime; they are no longer released

Changed

  • replicatedState, copyJson, isJsonValue, Context, JsonValue, JsonRepresentation, Draft, and the replicated state types keep their names on the root export; ./context and ./delta are unchanged, and ./context also exports the Context and ContextKey types
  • Depends on typebox instead of esbuild

Fixed

  • Fixed appending to an array costing a walk over the whole array: the delta tracker now plans inserts and removals that keep the remaining elements in order from the changed ranges alone, and applyImmutable builds an appended array in one copy instead of two. 32,000 one-item appends, one commit each, take 1.4 s instead of 11.3 s