Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
149 lines
7.7 KiB
Markdown
149 lines
7.7 KiB
Markdown
# @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](https://github.com/sinclairzx81/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 and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `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 |
|
|
|
|
## Quick start
|
|
|
|
```ts
|
|
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](docs/tutorial.md): a shared todo list, built up section by section. Every example runs as `test/tutorial.test.ts`.
|
|
- [Usage guide](docs/guide.md): API reference for contracts, objects, state, roots, lifetime, plugins, hot reload, hosts, and links.
|
|
- [Specification](docs/spec.md): compatibility, lifetime, and reload semantics, plus known limitations and implementation decisions.
|
|
- [Delta guide](src/delta/README.md): ownership, lifecycle, operation, and replica contracts of `@earendil-works/chord/delta`.
|
|
|
|
## Tracking JSON deltas
|
|
|
|
Import the standalone transactional tracker from `@earendil-works/chord/delta`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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](docs/guide.md#state) in the usage guide.
|