chore(vendor): import pi 0.85.1
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: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
860
third_party/pi/packages/chord/PLANNING.md
vendored
Normal file
860
third_party/pi/packages/chord/PLANNING.md
vendored
Normal file
@@ -0,0 +1,860 @@
|
||||
# Chord implementation plan
|
||||
|
||||
> **Status:** Active implementation plan. Context, strict JSON, replicated state, service publication/consumption, the facet host/loader, and initial Node facet bundling and generation loading now live in Chord. Symmetric RPC and structural generation replacement remain planned. This is not a stable public API contract yet.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Chord will be the application-neutral foundation for:
|
||||
|
||||
1. loading, composing, unloading, reloading, and bundling plugins;
|
||||
2. declaring and consuming local or remote services;
|
||||
3. transporting service calls and subscriptions over symmetric RPC plumbing; and
|
||||
4. replicating authoritative latest-value state to local and remote consumers.
|
||||
|
||||
The current Pi experiments prove many required behaviors, but Chord will be implemented from scratch. Existing source may be used as test and design evidence, not copied into this package. Compatibility with experimental APIs or wire messages is not a requirement.
|
||||
|
||||
## 2. Dependency boundary
|
||||
|
||||
The dependency direction is strict:
|
||||
|
||||
```text
|
||||
@earendil-works/chord
|
||||
↑
|
||||
Pi agent, protocol, server, coding agent, TUI, and future applications
|
||||
```
|
||||
|
||||
Chord must:
|
||||
|
||||
- have no dependency on another Pi workspace package;
|
||||
- contain no imports from `@earendil-works/pi-*` or relative paths outside `packages/chord`;
|
||||
- use application-neutral vocabulary in source, errors, tests, and examples;
|
||||
- own any generic runtime types required by its public API, including strict JSON values and invocation cancellation context;
|
||||
- keep Node-specific loading and bundling separate from the platform-neutral runtime; and
|
||||
- be buildable, testable, packable, and usable without resolving another Pi package.
|
||||
|
||||
A generic third-party dependency is not prohibited, but each dependency must be justified. The runtime should initially prefer standard JavaScript APIs. The bundler may use one pinned implementation behind a Chord-owned adapter.
|
||||
|
||||
The following terms must not become Chord concepts: Session, Harness, AgentLane, server, client, attachment, TUI, model, tool, hook, provider credential, or workspace. Those belong to consumers.
|
||||
|
||||
## 3. Architectural model
|
||||
|
||||
### 3.1 Working vocabulary
|
||||
|
||||
The names below are provisional, but the distinctions are required.
|
||||
|
||||
- **Plugin**: one independently activated composition unit with a stable ID and synchronous setup function.
|
||||
- **Plugin module**: a JavaScript module exporting one or more plugins for one application-selected entry.
|
||||
- **Loaded generation**: plugins plus the resources owned by loading their module generation.
|
||||
- **Host**: one assembled plugin and service graph.
|
||||
- **Service token**: a stable runtime service ID plus type information and locality policy.
|
||||
- **Provider**: the owner of one singleton service or one keyed service collection.
|
||||
- **Connection**: a transport-neutral source of services outside the current host.
|
||||
- **Peer**: one endpoint of a symmetric RPC channel. Either peer may provide and consume services.
|
||||
- **Replicated state**: initialized mutable source state with read-only local or remote replicas.
|
||||
|
||||
A product feature may ship multiple plugin-module entries for different application environments. Chord does not group those entries into a cross-process runtime object and does not interpret their entry names.
|
||||
|
||||
### 3.2 Layering
|
||||
|
||||
The implementation should be divided into these layers:
|
||||
|
||||
```text
|
||||
plugin loader and bundler
|
||||
↓
|
||||
plugin host, lifecycle, and dependency graph
|
||||
↓
|
||||
service tokens, providers, facades, and keyed instances
|
||||
↓
|
||||
replicated state and service subscriptions
|
||||
↓
|
||||
symmetric RPC peer and transport adapter
|
||||
↓
|
||||
strict JSON, invocation context, cancellation, and errors
|
||||
```
|
||||
|
||||
The local service path must not require RPC serialization. The remote path must use the same service semantics through a strict wire boundary.
|
||||
|
||||
## 4. Invocation context and strict JSON
|
||||
|
||||
Current experiments depend on Pi's Harness `Context`, which Chord cannot import. Chord therefore needs a small neutral invocation context.
|
||||
|
||||
The initial context should provide:
|
||||
|
||||
- an optional `AbortSignal` for cancellation of one invocation or observation;
|
||||
- immutable typed local values through Chord-owned context keys;
|
||||
- a background root;
|
||||
- child derivation, cancellation, and cancellation-aware waiting helpers; and
|
||||
- no built-in telemetry, identity, authentication, or application values.
|
||||
|
||||
Applications may define their own context keys. A Pi adapter can carry telemetry and authenticated identity through such keys without Chord knowing their types.
|
||||
|
||||
A context object never crosses RPC as a business value. The calling peer sends cancellation control and, if configured, an opaque strict-JSON metadata carrier. The receiving adapter constructs a fresh local context. The adapter, not remote business arguments, installs authenticated local identity.
|
||||
|
||||
Chord owns the static `JsonValue` contract and provides `JsonRepresentation<T>` plus `isJsonValue()` for adapter boundaries. The service runtime deliberately performs no automatic recursive validation; concrete serializers remain responsible for rejecting unsupported values. Remote arguments, results, errors, snapshots, updates, catalogues, and RPC envelopes are expected to be finite strict JSON:
|
||||
|
||||
- finite numbers only;
|
||||
- no `undefined`, sparse arrays, symbols, prototypes, cycles, classes, functions, `Map`, or `Set`; and
|
||||
- `null`, rather than `undefined`, for business-level absence.
|
||||
|
||||
Application schema validation remains the application's responsibility. Chord validates structural control envelopes, but runtime enforcement of the strict-JSON boundary is currently deferred to serializers.
|
||||
|
||||
## 5. Plugins and lifecycle
|
||||
|
||||
### 5.1 Plugin shape
|
||||
|
||||
The intended author model is equivalent to:
|
||||
|
||||
```ts
|
||||
interface Plugin {
|
||||
readonly id: string;
|
||||
setup(environment: PluginEnvironment): void;
|
||||
}
|
||||
```
|
||||
|
||||
Setup is synchronous declaration. It may:
|
||||
|
||||
- provide a singleton service;
|
||||
- declare ownership of a keyed service;
|
||||
- acquire a singleton service handle;
|
||||
- declare an observation of keyed service instances;
|
||||
- create replicated state;
|
||||
- register an activation callback;
|
||||
- register owned resource cleanup; and
|
||||
- register final deactivation work.
|
||||
|
||||
Setup must not:
|
||||
|
||||
- invoke a service or read replicated state through an acquired facade;
|
||||
- perform asynchronous work;
|
||||
- introduce a new service dependency later from an event handler or activation callback; or
|
||||
- mutate the active host generation.
|
||||
|
||||
The host records setup calls in a private generation ledger. Plugin authors do not maintain a parallel `requires`/`provides` manifest.
|
||||
|
||||
### 5.2 Assembly
|
||||
|
||||
After every plugin has completed setup, the host must:
|
||||
|
||||
1. collect local provisions and catalogues from configured remote service sources;
|
||||
2. resolve every hard requirement to exactly one local or connected provision;
|
||||
3. reject missing providers, duplicate providers, ambiguous source offers, and singleton/keyed mismatches;
|
||||
4. reject duplicate plugin IDs;
|
||||
5. derive provider-to-consumer lifecycle edges;
|
||||
6. reject dependency cycles;
|
||||
7. construct local and remote service bindings while handles are still inaccessible;
|
||||
8. hydrate required connected services; and
|
||||
9. activate providers before consumers.
|
||||
|
||||
A plugin may provide and consume the same token without creating a self-cycle. Optional dependencies are not part of the first contract; they require a distinct acquisition API later.
|
||||
|
||||
### 5.3 Resource ownership
|
||||
|
||||
Every plugin generation owns:
|
||||
|
||||
- activation callbacks;
|
||||
- explicit cleanup functions;
|
||||
- keyed instances added through its provider handle;
|
||||
- keyed observations and their tasks; and
|
||||
- service provisions.
|
||||
|
||||
Disposal is idempotent. Consumers deactivate before providers, and each plugin's resources dispose in reverse registration order. Cleanup continues after individual failures and reports one error or an `AggregateError` after all cleanup attempts finish.
|
||||
|
||||
Keyed observation handlers receive a fresh cancellable context. Closing or replacing the instance aborts only that handler task. A handler failure is reported through host policy unless its context was cancelled as normal cleanup.
|
||||
|
||||
### 5.4 Loaded module generations
|
||||
|
||||
Module loading and plugin activation are separate ownership domains:
|
||||
|
||||
```ts
|
||||
interface LoadedPlugins {
|
||||
readonly plugins: readonly Plugin[];
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PluginLoader {
|
||||
load(): Promise<LoadedPlugins>;
|
||||
}
|
||||
```
|
||||
|
||||
Required loaders:
|
||||
|
||||
- a static loader for built-ins and tests;
|
||||
- an ordered combined loader with reverse disposal and startup-failure cleanup; and
|
||||
- a bundle/module loader for one selected manifest entry.
|
||||
|
||||
The loader owns one source generation. The host owns active plugin lifecycles. A coordinator must deactivate a retired generation before disposing its `LoadedPlugins`.
|
||||
|
||||
Node's default ESM loader retains every imported module generation in its process-wide cache. Chord therefore bundles Node facets as CommonJS and compiles each generation directly with `node:vm`, without inserting plugin code into either Node module cache. Chord's unload contract remains deactivation, removal of service reachability, cleanup, and release of loader-owned references/resources. Once no plugin-created timer, listener, callback, or other escaped reference remains, V8 can garbage-collect the compiled generation; collection timing is not deterministic.
|
||||
|
||||
## 6. Loading, unloading, and reload
|
||||
|
||||
### 6.1 Host updates
|
||||
|
||||
Host updates are serialized and have two forms.
|
||||
|
||||
#### Shape-preserving replacement
|
||||
|
||||
A targeted replacement is shape-preserving when each replaced plugin keeps the same:
|
||||
|
||||
- plugin ID;
|
||||
- required service IDs and modes;
|
||||
- provided service IDs and modes; and
|
||||
- remotely exposable singleton member names and kinds.
|
||||
|
||||
The sequence is:
|
||||
|
||||
```text
|
||||
load replacement module generation
|
||||
→ run replacement setup
|
||||
→ validate replacement shape and remote implementations
|
||||
→ activate replacements in dependency order while the old providers remain routed
|
||||
→ replace each local or remote singleton directly without withdrawing it
|
||||
→ deactivate replaced plugins in reverse dependency order
|
||||
→ dispose the retired loaded generation
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- existing local and remote singleton facades keep object identity;
|
||||
- captured local and remote methods dispatch to the old provider before cutover and the replacement after cutover;
|
||||
- replicated-state facades install the replacement snapshot without becoming unhydrated;
|
||||
- every singleton switches directly from its old target to its replacement, but a multi-service reload is not graph-transactional;
|
||||
- keyed instances from the old plugin close; instances staged by the replacement use fresh generations;
|
||||
- setup, shape-validation, or replacement-activation failure leaves the active generation unchanged;
|
||||
- named host resources acquired during activation support overlapping staged replacements, so candidate cleanup restores the old registration and retired cleanup cannot remove the replacement; and
|
||||
- any failure after replacement begins terminates the host rather than attempting to preserve a partially transitioned graph.
|
||||
|
||||
There is no rollback after cutover. Terminal cleanup revokes every facet handle before best-effort disposal; only pre-cutover candidate cleanup and ordinary host disposal guarantee dependency-ordered cleanup. Committed application effects are outside the reload transaction.
|
||||
|
||||
#### Structural replacement
|
||||
|
||||
Adding or removing plugins, changing service shape, or changing connection selection is structural. The first implementation should replace the complete host generation rather than attempt a partial affected-subgraph update:
|
||||
|
||||
```text
|
||||
load and synchronously set up the complete desired generation
|
||||
→ resolve and validate its graph without activation effects
|
||||
→ begin cutover
|
||||
→ withdraw old providers and deactivate the old graph
|
||||
→ install and activate the new graph
|
||||
→ dispose the old loaded generation
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- validation failure before cutover leaves the old graph active;
|
||||
- after cutover starts, failure disposes the complete host because the retired graph cannot be restored;
|
||||
- removing a provider while retaining a hard consumer is rejected during candidate validation;
|
||||
- service facades owned outside plugin lifecycles remain stable when service ID and mode survive the replacement;
|
||||
- removed services become permanently disconnected for the retired generation; and
|
||||
- a future optimization may retain unaffected plugins, but it is not required for the initial structural update.
|
||||
|
||||
This full-generation path supplies actual plugin load and unload semantics without first implementing a complex partial-graph transaction.
|
||||
|
||||
### 6.2 Calls during unload
|
||||
|
||||
Replacement never leaves a singleton facade without a target: an invocation resolves either the old implementation or its replacement. Work already running in a retired facet is not drained; later use of that facet's revoked handles may fail as stale work. A future killable-isolate host terminates such work directly. Transport disconnect still cancels only connection-owned invocations; it does not imply application-level cancellation or rollback.
|
||||
|
||||
### 6.3 Update failure reporting
|
||||
|
||||
An update result must distinguish:
|
||||
|
||||
- load failure;
|
||||
- setup or graph validation failure before cutover;
|
||||
- old-generation deactivation failure;
|
||||
- replacement activation failure;
|
||||
- service rebinding failure; and
|
||||
- retired loader disposal failure.
|
||||
|
||||
Multiple failures are aggregated without hiding the first transition failure. Concurrent update, unload, and host-dispose requests are serialized or rejected with a stable lifecycle error.
|
||||
|
||||
## 7. Services
|
||||
|
||||
### 7.1 Tokens and modes
|
||||
|
||||
A service token has:
|
||||
|
||||
- a non-empty stable string ID;
|
||||
- a TypeScript contract type;
|
||||
- a locality policy: remotely exposable by default or explicitly process-local; and
|
||||
- no provider instance of its own.
|
||||
|
||||
Chord reserves a prefix for control-plane IDs. Duplicate IDs in one catalogue are invalid.
|
||||
|
||||
Two modes are required:
|
||||
|
||||
- **singleton**: one provider, many consumers; and
|
||||
- **keyed**: one collection owner, dynamic instances, many observers.
|
||||
|
||||
One token has one mode in one host graph. Mixing singleton and keyed use is an assembly or protocol error.
|
||||
|
||||
### 7.2 Local services
|
||||
|
||||
Process-local services:
|
||||
|
||||
- may expose arbitrary objects, synchronous methods, classes, functions, native handles, or non-JSON values;
|
||||
- are never included in a remote catalogue;
|
||||
- use the same graph ordering, stable handles, keyed generations, and lifecycle behavior as remote services; and
|
||||
- are trusted composition, not a security boundary.
|
||||
|
||||
A local singleton consumer receives a stable lazy facade, not the provider object. This removes setup-order dependence and lets provider replacement update captured methods.
|
||||
|
||||
Remotely exposable services use the provider/binding path even when provider and consumer share a host. An internal loopback binding keeps replacement, replicated-state, and keyed-generation semantics independent of placement. Only explicitly process-local services bypass that path.
|
||||
|
||||
### 7.3 Remote service contracts
|
||||
|
||||
A remotely exposable implementation may contain only own data properties classified as:
|
||||
|
||||
- asynchronous methods whose business arguments and result are strict JSON, with one Chord invocation context in the declared position; or
|
||||
- branded replicated-state values.
|
||||
|
||||
The provider derives a runtime member table from the implementation. Plugin authors do not maintain a second method/state descriptor. Unsupported accessors, fields, or member kinds are rejected before publication.
|
||||
|
||||
Type-level checks should reject obviously invalid remote contracts. Runtime checks remain mandatory because types do not authenticate peers or survive JavaScript consumption.
|
||||
|
||||
Remote methods:
|
||||
|
||||
- return promises;
|
||||
- return strict JSON or `void`;
|
||||
- receive a fresh local invocation context;
|
||||
- map caller cancellation to exactly one request;
|
||||
- do not queue while disconnected; and
|
||||
- expose stable error codes and sanitized messages across the wire.
|
||||
|
||||
Arbitrary returned object references, callbacks, function serialization, and general object-graph remoting are outside the initial scope.
|
||||
|
||||
### 7.4 Stable singleton facades
|
||||
|
||||
`use(token)` returns a facet-owned capability view over a source-independent host service slot. Repeated acquisition within one facet returns the same view; different facet lifecycles receive different views. Member slots are created lazily on property access and validated against provider metadata when bound.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- inaccessible during setup and after the owning facet lifecycle ends;
|
||||
- directly bound after local graph assembly or remote hydration;
|
||||
- stable across provider withdrawal and replacement;
|
||||
- method invocation fails while disconnected;
|
||||
- state reads return `undefined` while unhydrated;
|
||||
- a remotely exposable replacement preserves the complete member name/kind table; and
|
||||
- a provider omitting an already-accessed member is a binding error.
|
||||
|
||||
### 7.5 Keyed services
|
||||
|
||||
`provideMany(token)` declares one keyed owner during setup and returns a generation-owned `ServiceSpawner`. While active, `spawn(key, implementation)`:
|
||||
|
||||
- requires a non-empty key unique among live instances;
|
||||
- creates a host-owned monotonically increasing generation for that key;
|
||||
- publishes methods and initial state atomically;
|
||||
- returns an idempotent close function; and
|
||||
- is automatically closed during plugin disposal.
|
||||
|
||||
An instance address is `(service ID, key, generation)`. Reusing a closed key creates a new generation. Stale facades cannot call the replacement.
|
||||
|
||||
`observe(token, handler)`:
|
||||
|
||||
- reconciles a complete initial instance directory;
|
||||
- starts one task per instance only after all initial state members hydrate;
|
||||
- preserves ordered additions, replacements, and removals;
|
||||
- aborts the instance task on close, replacement, disconnect, or observation disposal;
|
||||
- gives each observation an observer-lifetime service view that becomes inaccessible when its task is aborted; and
|
||||
- never represents service facades inside replicated JSON state.
|
||||
|
||||
## 8. Replicated state
|
||||
|
||||
Replicated state is authoritative one-writer latest-value replication.
|
||||
|
||||
The source API has an initialized value, `set(value, context)`, and subscriptions. A remote or disconnected replica has `value === undefined` until hydration.
|
||||
|
||||
Required semantics:
|
||||
|
||||
1. The source is always initialized.
|
||||
2. Source and replica values are strict JSON for remotely exposable state.
|
||||
3. Subscribing to initialized local or hydrated replica state immediately delivers the current value with a fresh delivery context.
|
||||
4. Subscribing to cold state registers without immediate delivery.
|
||||
5. Subscription establishment installs update capture before taking the snapshot.
|
||||
6. Updates racing the snapshot are buffered and delivered after the snapshot with no gap.
|
||||
7. The source API exposes a tracked mutable state and explicit publication. Each publication flushes one decoded operation batch; connection adapters encode it independently per client/state stream, and replicas apply batches only in sequence order.
|
||||
8. A sequence gap clears readiness and triggers complete resubscription or reports a terminal binding error; stale state must not continue as current silently.
|
||||
9. Disconnect, provider withdrawal, route change, and replacement clear replica readiness.
|
||||
10. Reconnect or replacement installs a complete fresh snapshot in the existing state facade before later updates.
|
||||
11. Listener exceptions are isolated and reported through host policy.
|
||||
12. Values are immutable data. Chord does not defensively clone local reads, local writes, or local listener delivery. Delta application preserves prior values and may structurally share unchanged data, but callers must not depend on identity.
|
||||
|
||||
State identity is structural:
|
||||
|
||||
```text
|
||||
provider binding + service ID + optional keyed address + member name
|
||||
```
|
||||
|
||||
There is no separate state ID.
|
||||
|
||||
Explicit non-goals:
|
||||
|
||||
- durability or reconstruction after process restart;
|
||||
- event history;
|
||||
- CRDT merging or multiple writers;
|
||||
- offline mutation replay;
|
||||
- automatic unchanged-value suppression; and
|
||||
- high-frequency stream transport.
|
||||
|
||||
Chord exposes an intent-preserving JSON delta primitive and uses its operation batches internally for remote replicated state. Initial hydration and reconnection carry a complete root replacement; producers mutate tracked state and flush compact operations on publication. Replicated-state sources do not select reducers or interact with path encoders. Every client/state pairing owns an independent encoder, and sequence handling rejects gaps before a later operation can be applied.
|
||||
|
||||
## 9. Symmetric RPC plumbing
|
||||
|
||||
### 9.1 No client/server mode
|
||||
|
||||
Chord must not expose `Client`, `Server`, `SessionConnection`, or similar topology classes. It exposes a symmetric peer over an application-supplied duplex channel. Either endpoint may register handlers, provide services, call methods, or subscribe to state.
|
||||
|
||||
Application adapters own:
|
||||
|
||||
- sockets, pipes, WebSockets, workers, or loopback delivery;
|
||||
- framing and reconnect policy;
|
||||
- authentication and authorization;
|
||||
- routing and target selection;
|
||||
- process ownership;
|
||||
- attachment or selection state; and
|
||||
- application protocol envelopes surrounding a Chord message.
|
||||
|
||||
Chord sees one connected peer and already-decoded strict-JSON messages.
|
||||
|
||||
### 9.2 RPC layer
|
||||
|
||||
The generic RPC peer should own:
|
||||
|
||||
- request ID allocation scoped to one peer connection;
|
||||
- request/response correlation;
|
||||
- handler registration;
|
||||
- cancellation frames and one request-local `AbortController`;
|
||||
- disconnect rejection and cleanup;
|
||||
- stable serializable error envelopes;
|
||||
- ordered notification delivery;
|
||||
- malformed-message rejection; and
|
||||
- optional context metadata hooks.
|
||||
|
||||
The channel contract should be small: send one strict-JSON message, receive messages in order, observe close, and close. `send()` should be awaitable so adapters can provide backpressure.
|
||||
|
||||
A peer disconnect:
|
||||
|
||||
- rejects outbound requests;
|
||||
- aborts inbound request contexts;
|
||||
- closes service subscriptions and keyed observation tasks; and
|
||||
- does not cancel application-owned jobs or perform durable business mutations.
|
||||
|
||||
### 9.3 Service protocol over RPC
|
||||
|
||||
The service layer adds transport-neutral operations for:
|
||||
|
||||
- catalogue discovery;
|
||||
- singleton and keyed subscription open/close;
|
||||
- complete subscription snapshots;
|
||||
- method invocation;
|
||||
- invocation cancellation;
|
||||
- replicated-state updates;
|
||||
- singleton unavailable/replaced events; and
|
||||
- keyed instance spawned/closed events.
|
||||
|
||||
The wire protocol contains no server ID, session ID, attachment ID, route, user identity, or host kind. A Pi router may wrap or forward Chord envelopes using its own control fields without parsing service business payloads.
|
||||
|
||||
Provider catalogue and subscription state must come from actual service provisions, not a handwritten application inventory.
|
||||
|
||||
### 9.4 Wire errors and validation
|
||||
|
||||
Chord needs stable generic error codes for:
|
||||
|
||||
- service not allowed or absent;
|
||||
- mode mismatch;
|
||||
- member absent or used with the wrong kind;
|
||||
- keyed instance absent or stale;
|
||||
- invalid strict-JSON value;
|
||||
- cancellation;
|
||||
- malformed RPC message;
|
||||
- peer disconnected; and
|
||||
- internal provider failure.
|
||||
|
||||
Unexpected provider exceptions become a sanitized internal error by default. Stack traces and arbitrary exception fields do not cross the wire. Applications may register or map additional stable error codes through an adapter, but Chord does not own application error taxonomies.
|
||||
|
||||
### 9.5 Protocol evolution
|
||||
|
||||
The first implementation must version Chord's RPC/service envelope independently of any application protocol. Version negotiation can be a peer handshake or an adapter-guaranteed constructor parameter, but incompatible peers must fail before service calls are admitted.
|
||||
|
||||
Member and DTO compatibility across plugin-generation skew is an application responsibility. Chord guarantees only its generic envelope semantics.
|
||||
|
||||
## 10. Bundling
|
||||
|
||||
### 10.1 Purpose
|
||||
|
||||
The bundler turns one or more application-declared ESM or TypeScript plugin entries into independently loadable Node CommonJS artifacts. It is not a package manager or plugin registry.
|
||||
|
||||
Each entry is built independently. Chord does not assume names such as `server`, `session`, `tui`, or `web`; entry names are opaque application data.
|
||||
|
||||
### 10.2 Initial input and output
|
||||
|
||||
The initial bundler should accept:
|
||||
|
||||
- plugin identity and optional version metadata;
|
||||
- a mapping of opaque entry names to TypeScript or JavaScript source files;
|
||||
- an output directory;
|
||||
- an application-supplied external-module allowlist;
|
||||
- source-map and minification options; and
|
||||
- optional define/platform settings needed for the Node CommonJS build.
|
||||
|
||||
The package-level API additionally accepts a plugin package directory, derives identity and version from `package.json`, applies application-supplied conventional entry paths when those files exist, and lets `chord.facets` override or disable conventions. Package discovery does not install dependencies or run lifecycle scripts.
|
||||
|
||||
It should emit:
|
||||
|
||||
- one content-addressed CommonJS file per entry;
|
||||
- source maps when enabled;
|
||||
- a versioned strict-JSON manifest;
|
||||
- content hashes or integrity values;
|
||||
- declared external imports; and
|
||||
- enough metadata for diagnostics and fresh generation loads.
|
||||
|
||||
Writes should use a temporary output directory followed by an atomic rename so a loader never sees a half-written generation.
|
||||
|
||||
### 10.3 Bundle rules
|
||||
|
||||
- `@earendil-works/chord` must be externalized so a plugin uses the host's one runtime and branding symbols.
|
||||
- Other dependencies are bundled by default. The explicit bundler API uses an application external allowlist; the package-level API also externalizes peer dependencies because the host provides them.
|
||||
- Built-in module use may be allowed for Node entries but is not a trust or sandbox policy.
|
||||
- Dynamic imports must be lowered through the loader's restricted `require`, and unresolved externals must be reported deterministically.
|
||||
- Bundle output must not depend on Pi's repository path aliases.
|
||||
- Rebuilding unchanged inputs should produce stable content except for documented metadata.
|
||||
- Diagnostics must identify the entry and original source location.
|
||||
|
||||
The concrete bundler engine is an implementation detail. Select it with a spike covering TypeScript and ESM inputs, CommonJS output, source maps, externals, content hashing, and programmatic diagnostics before adding a dependency.
|
||||
|
||||
### 10.4 Bundle loading
|
||||
|
||||
The bundle loader must:
|
||||
|
||||
1. parse and validate the manifest;
|
||||
2. select one application-requested entry;
|
||||
3. verify integrity before activation when integrity is present;
|
||||
4. compile it directly with `node:vm` as a fresh generation outside Node's module caches;
|
||||
5. validate that its exports are plugins with unique non-empty IDs;
|
||||
6. return `LoadedPlugins`; and
|
||||
7. provide idempotent loader disposal.
|
||||
|
||||
Plugin discovery, installation, version resolution, download, signature trust, and update policy remain application responsibilities.
|
||||
|
||||
## 11. Pi migration boundary
|
||||
|
||||
The following existing files describe behavior that should become Chord responsibility through a rewrite:
|
||||
|
||||
| Existing area | Chord responsibility |
|
||||
|---|---|
|
||||
| `packages/agent/src/plugins/services/types.ts` | service tokens, modes, remote contract checks, strict JSON, snapshots, updates, connection interfaces |
|
||||
| `packages/agent/src/plugins/services/replicated-state.ts` | authoritative replicated state and delivery semantics |
|
||||
| `packages/agent/src/plugins/services/provider.ts` | provider classification, calls, singleton replacement, keyed generations, snapshots |
|
||||
| `packages/agent/src/plugins/services/namespace.ts` | stable remote facades, hydration, state updates, keyed observation |
|
||||
| `packages/coding-agent/src/experimental/facets.ts` | plugin environment, dependency ledger, lifecycle graph, host, reload |
|
||||
| `packages/coding-agent/src/experimental/facet-loader.ts` | static and combined loaders plus loaded-generation ownership |
|
||||
| generic service sections of `packages/protocol/src/protocol.ts` | Chord-owned versioned service/RPC envelope |
|
||||
|
||||
The following must remain outside Chord:
|
||||
|
||||
| Existing area | Downstream responsibility |
|
||||
|---|---|
|
||||
| `packages/coding-agent/src/experimental/services/connection.ts` | Pi connection state, selected-session attachment, route rebinding, Pi client adapter |
|
||||
| `packages/coding-agent/src/experimental/services/server.ts` | server-wide session directory and management implementations |
|
||||
| `packages/coding-agent/src/experimental/services/worker.ts` | Session worker host construction and Pi protocol publication adapter |
|
||||
| `packages/server`, `packages/client`, and process managers | framing, routing, authentication, attachment, process lifecycle, reconnect policy |
|
||||
| slash-command, model, account, transcript, TUI, and agent-controller services | application contracts and plugin implementations |
|
||||
| `source-resolver.ts` and Pi internal process entrypoints | Pi source execution and process policy |
|
||||
|
||||
`packages/agent/docs/plugins.md`, `packages/agent/docs/rpc.md`, the experimental service tests, and the remote plugin fixture are behavioral input. They are not normative Chord APIs. Once migration finishes, generic semantics should be documented in Chord and Pi documents should cover only their host-specific contracts and adapters.
|
||||
|
||||
Migration should happen only after Chord passes its standalone conformance suite:
|
||||
|
||||
1. implement Chord without changing Pi callers;
|
||||
2. add thin Pi adapters and migrate generic service imports;
|
||||
3. migrate the experimental plugin host and loaders;
|
||||
4. adapt Pi's routed protocol to carry Chord envelopes;
|
||||
5. run local, loopback, framed, keyed-generation, reload, and TUI integration tests; and
|
||||
6. delete duplicate experimental generic implementations only after all consumers use Chord.
|
||||
|
||||
No compatibility shim is required unless separately requested.
|
||||
|
||||
## 12. Proposed source layout
|
||||
|
||||
This is a planning aid, not a requirement to create all files immediately.
|
||||
|
||||
```text
|
||||
packages/chord/
|
||||
src/
|
||||
index.ts platform-neutral public API
|
||||
api.ts root-exported functions
|
||||
types.ts root-exported types, including strict JSON values
|
||||
context/
|
||||
index.ts invocation context constants and functions
|
||||
errors.ts lifecycle, service, and RPC errors
|
||||
services/
|
||||
types.ts tokens, contracts, modes, snapshots
|
||||
state.ts source and replica state primitives
|
||||
state-internals.ts private replicated-state metadata
|
||||
provider.ts singleton/keyed provider runtime
|
||||
facade.ts stable local and remote facades
|
||||
host-bindings.ts graph-facing service slots
|
||||
rpc/
|
||||
peer.ts symmetric request/cancel plumbing
|
||||
protocol.ts versioned generic envelopes and parsing
|
||||
services.ts service protocol over a peer
|
||||
loopback.ts deterministic in-memory duplex transport
|
||||
plugins/
|
||||
types.ts plugin and environment types
|
||||
lifecycle.ts activation and resource ownership
|
||||
graph.ts validation and ordering
|
||||
host.ts start, update, reload, dispose
|
||||
loader.ts static and combined loaders
|
||||
node/
|
||||
bundle.ts Node CommonJS bundler
|
||||
bundle-loader.ts manifest validation and generation loading
|
||||
test/
|
||||
...
|
||||
test-fixtures/
|
||||
bundled-plugin/
|
||||
README.md
|
||||
PLANNING.md
|
||||
```
|
||||
|
||||
If Node-only APIs are exported, they should use a separate package export such as `@earendil-works/chord/node` or `@earendil-works/chord/bundler`; importing the main runtime must not load Node-only modules.
|
||||
|
||||
## 13. Work packages
|
||||
|
||||
### WP0 — Contract decisions and test harness
|
||||
|
||||
Deliver:
|
||||
|
||||
- settle public vocabulary and the invocation-context shape;
|
||||
- settle the admitted-call unload policy;
|
||||
- settle protocol version negotiation;
|
||||
- choose whether remote context position is fixed and trailing;
|
||||
- create deterministic in-memory duplex and controllable race test helpers;
|
||||
- add package-boundary checks preventing Pi imports; and
|
||||
- add compile-only fixtures for valid and invalid remote contracts.
|
||||
|
||||
Exit condition: every later work package can target explicit behavior without importing experimental implementations.
|
||||
|
||||
### WP1 — Foundations and replicated state
|
||||
|
||||
Deliver:
|
||||
|
||||
- strict JSON type/checking;
|
||||
- neutral context, cancellation, and cancellation-aware waiting;
|
||||
- mutable authoritative state;
|
||||
- cold replica state;
|
||||
- snapshot hydration, ordered updates, clear, and rehydrate;
|
||||
- borrowed immutable value contract; and
|
||||
- listener error reporting.
|
||||
|
||||
Tests include cold/hydrated subscriptions, immediate delivery, source update order, snapshot/update races, sequence gaps, cancellation, invalid JSON, and listener failure isolation.
|
||||
|
||||
### WP2 — Symmetric RPC peer
|
||||
|
||||
Deliver:
|
||||
|
||||
- versioned peer envelopes and validation;
|
||||
- request/response correlation;
|
||||
- inbound handler dispatch;
|
||||
- cancellation and disconnect behavior;
|
||||
- serializable sanitized errors;
|
||||
- ordered notifications; and
|
||||
- loopback transport.
|
||||
|
||||
Tests include requests initiated from both peers, crossed concurrent requests, duplicate/unknown IDs, pre-aborted requests, cancellation isolation, malformed messages, send failure, disconnect during calls, and handler exceptions.
|
||||
|
||||
### WP3 — Service provider and facades
|
||||
|
||||
Deliver:
|
||||
|
||||
- service tokens and locality policy;
|
||||
- singleton and keyed provider registration;
|
||||
- implementation member classification;
|
||||
- local service slots and stable facades;
|
||||
- remote method invocation over `RpcPeer`;
|
||||
- catalogues generated from provisions;
|
||||
- keyed generations and stale-call fencing; and
|
||||
- service error mapping.
|
||||
|
||||
Tests include local and loopback paths, captured method stability, provider withdrawal/replacement, mode errors, unsupported members, strict-JSON argument/result checks, local-only isolation, keyed reuse, and concurrent callers.
|
||||
|
||||
### WP4 — Remote state and service subscriptions
|
||||
|
||||
Deliver:
|
||||
|
||||
- complete singleton and keyed subscription snapshots;
|
||||
- atomic snapshot/update buffering;
|
||||
- state update publication;
|
||||
- singleton unavailable/replaced events;
|
||||
- keyed directory hydration and ordered reconciliation;
|
||||
- observer task cancellation; and
|
||||
- clear/rehydrate on connection replacement.
|
||||
|
||||
Tests include late subscribers, updates racing hydration, multiple state members, provider replacement, keyed hydration before handlers, disconnect, reconnect, sequence gaps, stale frames, and observer errors.
|
||||
|
||||
### WP5 — Plugin host and graph
|
||||
|
||||
Deliver:
|
||||
|
||||
- plugin/environment APIs;
|
||||
- setup-derived ledger;
|
||||
- graph validation and topological ordering;
|
||||
- local and connected service resolution;
|
||||
- lifecycle ownership;
|
||||
- startup failure cleanup;
|
||||
- host disposal; and
|
||||
- static loader integration.
|
||||
|
||||
Tests include consumer-before-provider setup, missing/duplicate/ambiguous providers, mode mismatch, cycles, asynchronous setup rejection, activation order, reverse cleanup, cleanup aggregation, connection hydration failure, and service access guards.
|
||||
|
||||
### WP6 — Generation loading, unload, and reload
|
||||
|
||||
Deliver:
|
||||
|
||||
- combined loaders;
|
||||
- complete structural generation replacement;
|
||||
- shape-preserving targeted reload;
|
||||
- stable surviving service slots;
|
||||
- keyed instance retirement;
|
||||
- generation fencing for late calls and updates;
|
||||
- admitted-call drain/cancel policy; and
|
||||
- precise failure reporting and loader disposal order.
|
||||
|
||||
Tests include load failure cleanup, setup failure with old generation retained, unload with retained hard consumer rejection, provider gaps, captured local and remote methods, replacement snapshots, activation failure after cutover, disposal failure aggregation, concurrent update/dispose, and old-generation late publication.
|
||||
|
||||
### WP7 — Node CommonJS bundler and bundle loader
|
||||
|
||||
Deliver:
|
||||
|
||||
- bundler-engine spike and decision;
|
||||
- programmatic bundler API;
|
||||
- versioned manifest;
|
||||
- independent content-addressed entries;
|
||||
- source maps and diagnostics;
|
||||
- Chord externalization;
|
||||
- atomic output replacement;
|
||||
- manifest/integrity validation; and
|
||||
- fresh VM-compiled generation loading outside Node's module caches.
|
||||
|
||||
Tests bundle an application-neutral fixture with two opaque entries and a third-party dependency, load each independently, activate it, reload changed source, prove unchanged source hashes are stable, reject corrupt manifests/integrity, and prove output resolves no Pi packages.
|
||||
|
||||
### WP8 — Pi adoption
|
||||
|
||||
This work is downstream of Chord rather than an implementation dependency.
|
||||
|
||||
Deliver:
|
||||
|
||||
- Pi adapters for context metadata and routed RPC transport;
|
||||
- migration of experimental service and plugin host consumers;
|
||||
- preservation of selected-session and TUI behavior outside Chord;
|
||||
- framed integration and reload coverage; and
|
||||
- removal of superseded generic experimental code.
|
||||
|
||||
Exit condition: Pi depends on Chord, while Chord remains independently packable and contains no Pi imports.
|
||||
|
||||
## 14. Required conformance matrix
|
||||
|
||||
The standalone suite must cover at least:
|
||||
|
||||
### Plugin graph and lifecycle
|
||||
|
||||
- declaration only during setup;
|
||||
- setup service access rejection;
|
||||
- deterministic graph ordering;
|
||||
- missing, duplicate, ambiguous, and cyclic dependencies;
|
||||
- activation and reverse disposal;
|
||||
- startup and disposal failure aggregation;
|
||||
- local and connected providers in one graph;
|
||||
- complete structural load/unload; and
|
||||
- shape-preserving reload.
|
||||
|
||||
### Services
|
||||
|
||||
- singleton and keyed modes;
|
||||
- unrestricted local contracts;
|
||||
- remote member classification;
|
||||
- strict JSON and `void` results;
|
||||
- stable local and remote facades;
|
||||
- provider withdrawal and replacement;
|
||||
- keyed generation fencing;
|
||||
- per-call cancellation; and
|
||||
- connection and host access guards.
|
||||
|
||||
### Replication
|
||||
|
||||
- initialized source and cold replica;
|
||||
- immediate hydrated subscription delivery;
|
||||
- snapshot/update race freedom;
|
||||
- ordered updates and gap handling;
|
||||
- disconnect clearing;
|
||||
- replacement and reconnect rehydration;
|
||||
- keyed state before observer startup; and
|
||||
- listener/handler cleanup.
|
||||
|
||||
### RPC
|
||||
|
||||
- symmetric calls in both directions;
|
||||
- request correlation and cancellation isolation;
|
||||
- malformed and oversized-message policy;
|
||||
- sanitized errors;
|
||||
- disconnect cleanup;
|
||||
- subscription cleanup;
|
||||
- protocol-version mismatch; and
|
||||
- deterministic in-memory and framed-adapter tests.
|
||||
|
||||
### Loading and bundling
|
||||
|
||||
- loader order and reverse disposal;
|
||||
- fresh generation evaluation and garbage-collection eligibility;
|
||||
- manifest validation and integrity;
|
||||
- independent entries;
|
||||
- dependency bundling and explicit externals;
|
||||
- source maps and diagnostics;
|
||||
- atomic output; and
|
||||
- packed-package execution outside the monorepo.
|
||||
|
||||
Race tests should control exact points rather than use timing: subscription capture versus state update, request admission versus cancellation, provider withdrawal versus invocation, instance close versus call, reload cutover versus update publication, and host dispose versus activation.
|
||||
|
||||
## 15. Non-goals for the initial implementation
|
||||
|
||||
- Pi host APIs or built-in Pi services;
|
||||
- plugin discovery, installation, download, registry, or package resolution;
|
||||
- trust policy, code signing, sandboxing, or capability security;
|
||||
- network listeners, socket framing, reconnect loops, routing, or authentication;
|
||||
- server/client roles or fixed process topologies;
|
||||
- durable state, database integration, migrations, or transactional application writes;
|
||||
- CRDTs, offline writes, or mutation replay;
|
||||
- arbitrary object remoting, callbacks, remote references, or garbage collection of references;
|
||||
- serialized UI trees, remote tools, or remote hooks;
|
||||
- generic contribution registries; applications can expose these as process-local services;
|
||||
- automatic retries of mutating calls after uncertain disconnects; and
|
||||
- backward compatibility with experimental Pi APIs or wire formats.
|
||||
|
||||
## 16. Decisions required before implementation
|
||||
|
||||
The following decisions should be recorded in this document or small ADRs before WP1/WP2/WP7 begins:
|
||||
|
||||
1. Final public names: `Plugin` versus `Facet`, `Host`, `Peer`, and `Connection`.
|
||||
2. The exact neutral context API and remote method context position.
|
||||
3. Whether context metadata propagation is in the first RPC envelope or added with a compatible optional field.
|
||||
4. Wait-versus-cancel policy for admitted provider calls during unload.
|
||||
5. Peer protocol negotiation and maximum message-size ownership.
|
||||
6. State sequence-gap recovery: automatic resubscribe versus terminal binding failure.
|
||||
7. Bundler engine and its runtime/development dependency placement.
|
||||
8. Manifest schema, integrity algorithm, and external-module resolution contract.
|
||||
9. Whether browser-compatible core behavior is an immediate tested requirement or only an architectural constraint.
|
||||
|
||||
None of these decisions should introduce Pi concepts into Chord.
|
||||
|
||||
## 17. Definition of done
|
||||
|
||||
Chord's initial scope is complete when:
|
||||
|
||||
- a standalone application unrelated to Pi can define local and remote singleton/keyed services;
|
||||
- two symmetric peers can call each other, cancel calls, subscribe, disconnect, and rehydrate;
|
||||
- replicated state satisfies the snapshot/update and replacement semantics above;
|
||||
- plugins activate from a validated graph and own all registered resources;
|
||||
- a running host can load, unload, structurally replace, and shape-preservingly reload plugin generations;
|
||||
- stable service facades survive provider replacement as specified;
|
||||
- the Node bundler emits and reloads independent content-addressed plugin entries;
|
||||
- all race and lifecycle conformance tests pass;
|
||||
- the packed package works outside the Pi monorepo; and
|
||||
- an automated boundary check proves that Chord has no imports or dependencies on the rest of Pi.
|
||||
205
third_party/pi/packages/chord/README.md
vendored
Normal file
205
third_party/pi/packages/chord/README.md
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
# @earendil-works/chord
|
||||
|
||||
Chord is an application-composition runtime for systems assembled from
|
||||
plugins/extensions. It provides facets, services, replicated state, and a
|
||||
pluggable remote-service boundary. It is developed as a standalone package in
|
||||
the Pi monorepo, but it is not a Pi package: it does not depend on any other Pi
|
||||
workspace package and can be used by unrelated applications.
|
||||
|
||||
## What Chord is for
|
||||
|
||||
A single application feature may need to run in several environments: for
|
||||
example, an agent worker, a terminal UI, and a remote WebUI. Chord provides the
|
||||
generic machinery to write such extensions in a way that is both delightful for
|
||||
humans as well as agents.
|
||||
|
||||
The design has a few connected pieces:
|
||||
|
||||
- **Plugins** are synchronous setup units that declare the services they provide
|
||||
and require. After every plugin has declared its shape, a host validates the
|
||||
complete dependency graph, binds services, activates providers before consumers,
|
||||
and disposes resources in reverse dependency order. These units are called
|
||||
*facets*.
|
||||
|
||||
- **Facets** are parts of a plugin. Each facet is bundled up separately and runs
|
||||
in the process or environment where it's supposed to run. You can use facets
|
||||
to split a plugin into separate pieces that need to be loaded into different
|
||||
processes and environments (think backend, browser, TUI etc.)
|
||||
|
||||
- **Services** are typed, stable tokens with either one provider (**singleton**)
|
||||
or dynamic keyed instances (**keyed**). A service can be process-local, with
|
||||
an unrestricted JavaScript contract, or remotely exposable. Consumers retain a
|
||||
stable facade while a provider disconnects or is replaced.
|
||||
|
||||
- **Replicated state** exposes authoritative state to local and remote
|
||||
connected consumers. Producers mutate the tracked `state` proxy and call
|
||||
`publish(context)`; consumers receive complete immutable values. Chord flushes
|
||||
one decoded operation batch per publication, while each remote client/state
|
||||
stream owns independent path-codec state. Replicas become unready on disconnect
|
||||
or replacement until they are rehydrated.
|
||||
|
||||
- **Delta tracking** derives compact operations from tracked plain JSON at
|
||||
flush time. It preserves string append/front-truncation and array-append
|
||||
behavior without retaining mutation history, supports durable base batches,
|
||||
and validates untrusted operations as they are applied.
|
||||
|
||||
- **Remote service sources** advertise services available outside a facet host
|
||||
and open bindings for the services its facets require. Bindings carry logical
|
||||
calls and subscriptions through an application-supplied adapter. Chord
|
||||
requires strict-JSON arguments, results, snapshots, updates, and catalogues,
|
||||
but does not prescribe framing, routing, transport, or an application wire
|
||||
envelope. `JsonRepresentation<T>` derives a wire-safe type for application data
|
||||
with unknown payloads, while `isJsonValue()` validates received values at an
|
||||
adapter boundary. Symmetric RPC peers are planned as one optional
|
||||
implementation of this boundary.
|
||||
|
||||
- **Context** Chord provides a Go-like context system for cancellation and
|
||||
invocation-scoped application values. Applications can carry permissions or
|
||||
telemetry through those values without Chord depending on either.
|
||||
|
||||
The current runtime exports service tokens, singleton and keyed providers,
|
||||
remote bindings, replicated state, facet hosts, and facet loaders from
|
||||
`@earendil-works/chord`. Import public types and general runtime APIs from the
|
||||
package root. Context constants and functions live in
|
||||
`@earendil-works/chord/context` because their generic names should not pollute
|
||||
the root API.
|
||||
Chord-owned identifiers use the `chord.*` namespace and its reserved service
|
||||
prefix is `$chord.*`.
|
||||
|
||||
## Remote service adapters
|
||||
|
||||
Chord owns its transport-independent service wire grammar. Consumer adapters
|
||||
use `createServiceCatalogueCall()`, `createServiceSubscribeCall()`, and
|
||||
`createServiceUnsubscribeCall()` for `$chord.service` control calls.
|
||||
`createRemoteServiceEndpoint()` handles those calls for one provider consumer,
|
||||
including subscription activation and cleanup. `parseServiceCall()`,
|
||||
`parseServiceCatalogue()`, and the decoded/wire snapshot and update parsers
|
||||
validate Chord semantics after an adapter has established a strict-JSON
|
||||
boundary. `RemoteServiceErrorCode` and `REMOTE_SERVICE_ERROR_CODES` define the
|
||||
service errors that may cross that boundary.
|
||||
|
||||
Replicated state operations use one `createServiceStateEncoder()` at the
|
||||
provider side and one `createServiceStateDecoder()` at the consumer side for
|
||||
each subscription. Those registries create an independent Delta path dictionary
|
||||
for every instance/member state and reset it on replacement, unavailability,
|
||||
close, or fresh hydration. Applications may place these values inside any
|
||||
routing, request, response, or event envelope; Chord does not prescribe that
|
||||
outer protocol.
|
||||
|
||||
## Tracking JSON deltas
|
||||
|
||||
Import the standalone delta primitive from `@earendil-works/chord/delta`:
|
||||
|
||||
```ts
|
||||
import { apply, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const changes = track({ output: "", count: 0 });
|
||||
changes.flush(); // opening base batch
|
||||
changes.state.output += "done\n";
|
||||
changes.state.count += 1;
|
||||
|
||||
const ops = changes.flush();
|
||||
const replica = apply({ output: "", count: 0 }, ops);
|
||||
```
|
||||
|
||||
The first flush is always a complete base batch. Later flushes contain path-based
|
||||
changes. `applyImmutable()` applies those batches while preserving prior replica
|
||||
revisions. `replicatedState(initial)` uses tracking directly:
|
||||
|
||||
```ts
|
||||
const status = env.replicatedState({ output: "", count: 0 });
|
||||
status.state.output += "done\n";
|
||||
status.state.count += 1;
|
||||
status.publish(context);
|
||||
```
|
||||
|
||||
`publish()` flushes once; remote connection plumbing encodes that operation batch
|
||||
independently for every client/state pairing. String assignments preserve pure
|
||||
appends and rolling-window movement as append and front-truncate operations;
|
||||
unrelated rewrites fall back to a set. Values inserted into tracked state become
|
||||
tracker-owned and must subsequently be mutated only through `state`. See the
|
||||
[Delta guide](src/delta/README.md) for mutation, array, lifecycle, and
|
||||
consumer-ownership rules.
|
||||
|
||||
## Bundling and loading facets
|
||||
|
||||
`@earendil-works/chord/bundler` uses esbuild to turn ESM or TypeScript application
|
||||
entries into independent, content-addressed CommonJS files. The package-level API
|
||||
reads plugin identity and build configuration from `package.json`, then applies
|
||||
facet path conventions supplied by the host application:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@example/my-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/chord": "^0.84.4"
|
||||
},
|
||||
"chord": {
|
||||
"facets": {
|
||||
"worker": "./src/custom-worker.ts",
|
||||
"presentation": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { bundleFacetPackage } from "@earendil-works/chord/bundler";
|
||||
|
||||
await bundleFacetPackage({
|
||||
packagePath: "/path/to/my-plugin",
|
||||
outdir: "/application-owned/plugin-builds/my-plugin",
|
||||
defaultFacets: {
|
||||
worker: "src/worker.ts",
|
||||
presentation: "src/presentation.ts",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Existing conventional files become entries unless `chord.facets` overrides or
|
||||
disables them. Peer dependencies are externalized and resolved against the host
|
||||
when loading. Chord never installs dependencies or runs package lifecycle
|
||||
scripts. `bundleFacets()` remains available as the lower-level API for callers
|
||||
that already have explicit plugin identity and entry mappings.
|
||||
|
||||
The output directory contains one `.cjs` file per entry plus
|
||||
`chord-facets.json`. Load one application-selected entry through the Node-only
|
||||
loader:
|
||||
|
||||
```ts
|
||||
import { createFacetBundleLoader } from "@earendil-works/chord/node";
|
||||
|
||||
const loader = createFacetBundleLoader({
|
||||
manifestPath: "/application-owned/plugin-builds/my-plugin/chord-facets.json",
|
||||
entry: "worker",
|
||||
resolveExternal: (specifier) => import.meta.resolve(specifier),
|
||||
});
|
||||
const loaded = await loader.load();
|
||||
```
|
||||
|
||||
Each `load()` verifies SHA-256 integrity and compiles the CommonJS body directly
|
||||
with `node:vm` instead of putting the plugin into Node's CommonJS or ESM module
|
||||
cache. Externals are resolved by the host and loaded through a restricted
|
||||
`require`; esbuild lowers dynamic imports so they use the same path. Disposing a
|
||||
retired generation releases the loader's facet references, making its compiled
|
||||
code eligible for garbage collection once plugin-owned resources are also gone.
|
||||
|
||||
For transport to another Node host, `readFacetBundleArtifact()` packages one
|
||||
verified manifest entry with its source, and `createFacetBundleArtifactLoader()`
|
||||
materializes fresh temporary generations while resolving externals against the
|
||||
receiving host.
|
||||
|
||||
To reload, load a candidate, pass its facets to `FacetHost.reload()`, dispose the
|
||||
candidate on failure, and dispose the retired `LoadedFacets` only after a
|
||||
successful cutover. The host activates and validates the candidate while the old
|
||||
providers remain routed, then replaces each singleton directly without an
|
||||
unavailable interval. Stable service handles therefore do not become disconnected
|
||||
during an ordinary reload. Keyed instances
|
||||
remain incarnation-specific and replacements receive fresh generations. The
|
||||
bundler writes a complete temporary directory before replacing the previous
|
||||
output, so loaders do not observe partially built generations.
|
||||
|
||||
See [PLANNING.md](PLANNING.md) for the broader RPC and generation-loading
|
||||
architecture.
|
||||
72
third_party/pi/packages/chord/package.json
vendored
Normal file
72
third_party/pi/packages/chord/package.json
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@earendil-works/chord",
|
||||
"version": "0.85.1",
|
||||
"description": "Application composition runtime for services, replicated state, RPC, and plugins",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"source": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./context": {
|
||||
"source": "./src/context/index.ts",
|
||||
"types": "./dist/context/index.d.ts",
|
||||
"import": "./dist/context/index.js"
|
||||
},
|
||||
"./delta": {
|
||||
"source": "./src/delta/index.ts",
|
||||
"types": "./dist/delta/index.d.ts",
|
||||
"import": "./dist/delta/index.js"
|
||||
},
|
||||
"./bundler": {
|
||||
"source": "./src/bundler.ts",
|
||||
"types": "./dist/bundler.d.ts",
|
||||
"import": "./dist/bundler.js"
|
||||
},
|
||||
"./node": {
|
||||
"source": "./src/node.ts",
|
||||
"types": "./dist/node.d.ts",
|
||||
"import": "./dist/node.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"src/delta/README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"application",
|
||||
"plugins",
|
||||
"rpc",
|
||||
"services",
|
||||
"state"
|
||||
],
|
||||
"author": "Earendil Works",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/chord"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
90
third_party/pi/packages/chord/src/api.ts
vendored
Normal file
90
third_party/pi/packages/chord/src/api.ts
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
import { FacetKernel } from "./facets/host.ts";
|
||||
import { disposeLoadedFacets } from "./facets/loader.ts";
|
||||
import { RemoteServiceBindingImpl } from "./services/consumer.ts";
|
||||
import { MutableReplicatedStateImpl } from "./services/state.ts";
|
||||
import type {
|
||||
Facet,
|
||||
FacetHost,
|
||||
FacetLoader,
|
||||
FacetOptions,
|
||||
LoadedFacets,
|
||||
MutableReplicatedState,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceContract,
|
||||
Service,
|
||||
} from "./types.ts";
|
||||
|
||||
/** Create an active host for one complete set of facets. */
|
||||
export async function createFacetHost(options: FacetOptions): Promise<FacetHost> {
|
||||
const kernel = new FacetKernel(options);
|
||||
await kernel.activate();
|
||||
return Object.freeze({
|
||||
services: kernel.provider,
|
||||
reload: (facets: readonly Facet[]) => kernel.reload(facets),
|
||||
dispose: () => kernel.dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createStaticFacetLoader(facets: readonly Facet[]): FacetLoader {
|
||||
const loadedFacets = Object.freeze([...facets]);
|
||||
return {
|
||||
async load() {
|
||||
return { facets: loadedFacets, async dispose() {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function combineFacetLoaders(loaders: readonly FacetLoader[]): FacetLoader {
|
||||
return {
|
||||
async load() {
|
||||
const loaded: LoadedFacets[] = [];
|
||||
try {
|
||||
for (const loader of loaders) loaded.push(await loader.load());
|
||||
} catch (error) {
|
||||
const cleanupErrors = await disposeLoadedFacets(loaded.reverse());
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Facet loading and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let disposed = false;
|
||||
return {
|
||||
facets: Object.freeze(loaded.flatMap(({ facets }) => facets)),
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const errors = await disposeLoadedFacets([...loaded].reverse());
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose loaded facets");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function defineFacet(facet: Facet): Facet {
|
||||
return facet;
|
||||
}
|
||||
|
||||
export function defineService<T>(id: string, options: { readonly local: true }): Service<T>;
|
||||
export function defineService<T>(
|
||||
id: string,
|
||||
...options: [RemoteServiceContract<T>] extends [never]
|
||||
? readonly [options: never]
|
||||
: readonly [options?: { readonly local?: false }]
|
||||
): Service<T>;
|
||||
export function defineService(id: string, options?: { readonly local?: boolean }): Service<unknown> {
|
||||
if (id.length === 0) throw new TypeError("Service ID must not be empty");
|
||||
// TODO: check if the reserved namespace should be part of Chord.
|
||||
if (id.startsWith("$chord.")) throw new TypeError("Service IDs beginning with $chord. are reserved");
|
||||
return Object.freeze({ id, local: options?.local ?? false });
|
||||
}
|
||||
|
||||
export function createRemoteServiceBinding(options: RemoteServiceBindingOptions): RemoteServiceBinding {
|
||||
return new RemoteServiceBindingImpl(options);
|
||||
}
|
||||
|
||||
export function replicatedState<T extends object>(initial: T): MutableReplicatedState<T> {
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
}
|
||||
9
third_party/pi/packages/chord/src/bundler.ts
vendored
Normal file
9
third_party/pi/packages/chord/src/bundler.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
BundleFacetsOptions,
|
||||
BundleFacetsResult,
|
||||
FacetBundlePlatform,
|
||||
} from "./node/bundle.ts";
|
||||
export { bundleFacets } from "./node/bundle.ts";
|
||||
export type { FacetBundleEntry, FacetBundleManifest } from "./node/manifest.ts";
|
||||
export type { BundleFacetPackageOptions, BundleFacetPackageResult } from "./node/package.ts";
|
||||
export { bundleFacetPackage } from "./node/package.ts";
|
||||
121
third_party/pi/packages/chord/src/context/index.ts
vendored
Normal file
121
third_party/pi/packages/chord/src/context/index.ts
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Context, ContextKey } from "../types.ts";
|
||||
|
||||
const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
|
||||
token: Symbol("chord.abortSignal"),
|
||||
});
|
||||
|
||||
abstract class BaseContext implements Context {
|
||||
abstract value<T>(key: ContextKey<T>): T | undefined;
|
||||
abstract toString(): string;
|
||||
|
||||
get abortSignal(): AbortSignal | undefined {
|
||||
return this.value(ABORT_SIGNAL_CONTEXT_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyContext extends BaseContext {
|
||||
readonly #name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
super();
|
||||
this.#name = name;
|
||||
}
|
||||
|
||||
value<T>(_key: ContextKey<T>): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.#name;
|
||||
}
|
||||
}
|
||||
|
||||
class ContextValue<T> extends BaseContext {
|
||||
readonly #parent: Context;
|
||||
readonly #key: ContextKey<T>;
|
||||
readonly #value: T;
|
||||
|
||||
constructor(parent: Context, key: ContextKey<T>, value: T) {
|
||||
super();
|
||||
this.#parent = parent;
|
||||
this.#key = key;
|
||||
this.#value = value;
|
||||
}
|
||||
|
||||
value<Value>(key: ContextKey<Value>): Value | undefined {
|
||||
if (key.token === this.#key.token) return this.#value as unknown as Value;
|
||||
return this.#parent.value(key);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`;
|
||||
}
|
||||
}
|
||||
|
||||
export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");
|
||||
export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");
|
||||
|
||||
export function createContextKey<T>(description: string): ContextKey<T> {
|
||||
return Object.freeze({ token: Symbol(description) });
|
||||
}
|
||||
|
||||
/** Derive a context containing one additional or replaced value. */
|
||||
export function withContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context {
|
||||
return new ContextValue(parent, key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a context cancelled by either the parent signal or the supplied signal.
|
||||
* The parent context remains unchanged.
|
||||
*/
|
||||
export function withAbortSignal(signal: AbortSignal, context: Context): Context {
|
||||
const parentSignal = context.abortSignal;
|
||||
const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]);
|
||||
return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context);
|
||||
}
|
||||
|
||||
/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */
|
||||
export function withoutAbortSignal(context: Context): Context {
|
||||
return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);
|
||||
}
|
||||
|
||||
/** Derive an independently cancellable child context. */
|
||||
export function withCancel(context: Context): {
|
||||
readonly context: Context;
|
||||
readonly cancel: (reason?: unknown) => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
context: withAbortSignal(controller.signal, context),
|
||||
cancel: (reason?: unknown) => controller.abort(reason),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a promise until it settles or the invocation is cancelled.
|
||||
* Cancellation rejects only this waiter; it does not cancel the underlying promise.
|
||||
*/
|
||||
export function awaitWithContext<T>(promise: Promise<T>, context: Context): Promise<T> {
|
||||
const signal = context.abortSignal;
|
||||
if (signal === undefined) return promise;
|
||||
if (signal.aborted) return Promise.reject(abortError(signal));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(abortError(signal));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
void promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const reason: unknown = signal.reason;
|
||||
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
319
third_party/pi/packages/chord/src/delta/README.md
vendored
Normal file
319
third_party/pi/packages/chord/src/delta/README.md
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
# Chord Delta
|
||||
|
||||
Chord Delta synchronizes JSON values from an authoritative producer to an
|
||||
ordered replica. It is available from `@earendil-works/chord/delta`.
|
||||
|
||||
A change is represented by an `Op`: a JSON tuple for replacing, setting,
|
||||
deleting, updating a string, or splicing an array. Producers use `track()`;
|
||||
replicas use `apply()` or `applyImmutable()`.
|
||||
|
||||
```ts
|
||||
import { apply, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const tracker = track({ output: "", entries: [] as string[] });
|
||||
let replica = apply(undefined, tracker.flush());
|
||||
|
||||
tracker.state.output += "done\n";
|
||||
tracker.state.entries.push("result");
|
||||
replica = apply(replica, tracker.flush());
|
||||
```
|
||||
|
||||
The first `flush()` returns one operation containing the complete value. Each
|
||||
later flush returns the operations needed to transform the previously published
|
||||
value into the current value. It returns `[]` when the value has not changed.
|
||||
|
||||
`applyImmutable()` copies only containers along changed paths and shares
|
||||
unchanged subtrees. It does not mutate, clone, or freeze either complete input.
|
||||
Chord's replicated-state producers mutate a tracked proxy and publish operation
|
||||
batches; consumers still observe complete immutable values.
|
||||
|
||||
## Sending or storing changes
|
||||
|
||||
`flush()` produces decoded `Op[]` with complete paths. This is convenient for
|
||||
local use but repeats long paths on the wire or disk.
|
||||
|
||||
`encoder()` compresses those paths and returns `WireOp[]`. `decoder()` validates
|
||||
the encoded tuples, restores complete paths, and returns the `Op[]` required by
|
||||
`apply()`:
|
||||
|
||||
```ts
|
||||
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const tracker = track({ output: "" });
|
||||
const enc = encoder(); // producer side
|
||||
const dec = decoder(); // consumer side
|
||||
let replica: { output: string } | undefined;
|
||||
|
||||
const send = () => {
|
||||
const ops = tracker.flush();
|
||||
const wire = enc.encode(ops); // serialize or store WireOp[] here
|
||||
const received = dec.decode(wire);
|
||||
replica = apply(replica, received);
|
||||
};
|
||||
```
|
||||
|
||||
Encoding is optional for local application. Never pass `WireOp[]` directly to
|
||||
`apply()`.
|
||||
|
||||
An encoder and decoder are stateful. Use one pair for each ordered stream. The
|
||||
encoder assigns numeric IDs to paths used across batches; the decoder remembers
|
||||
the corresponding definitions. A complete-value operation resets both path
|
||||
dictionaries, so replay can begin at that batch with a fresh decoder.
|
||||
|
||||
Path omission is local to one batch. Numeric path IDs may span batches. Each
|
||||
independently hydrated replicated-state stream needs its own encoder and decoder.
|
||||
Do not share a pair between state members or subscriptions, even when their
|
||||
batches use the same ordered transport connection.
|
||||
|
||||
## Operation vocabulary
|
||||
|
||||
A path is an array of object keys and array indices:
|
||||
|
||||
```ts
|
||||
["operation", "message", "content", 0, "text"]
|
||||
```
|
||||
|
||||
### Decoded `Op`
|
||||
|
||||
`track().flush()` returns these tuples, and `apply()` accepts them:
|
||||
|
||||
| Tuple | Meaning |
|
||||
| --- | --- |
|
||||
| `["r", value]` | Replace the complete value. |
|
||||
| `["s", path, value]` | Set a property or array element. |
|
||||
| `["d", path]` | Delete an object property. |
|
||||
| `["a", path, text]` | Append to a string. |
|
||||
| `["t", path, count]` | Remove UTF-16 code units from a string's front. |
|
||||
| `["p", path, index, remove, items]` | Splice an array. |
|
||||
|
||||
Except for `r`, every decoded operation carries its complete path. `s`, `d`,
|
||||
`a`, and `t` cannot address the root. `p` may address a root array.
|
||||
|
||||
### Encoded `WireOp`
|
||||
|
||||
A `PathRef` is either an inline path or a non-negative numeric path ID.
|
||||
`WireOp` supports the following tuples:
|
||||
|
||||
| Tuple | Meaning |
|
||||
| --- | --- |
|
||||
| `["r", value]` | Complete replacement; identical to decoded form. |
|
||||
| `["#", id, path]` | Define a numeric path ID. |
|
||||
| `["s", pathRef, value]` | Set with an inline or interned path. |
|
||||
| `["s", value]` | Set using the previous path in this batch. |
|
||||
| `["d", pathRef]` | Delete with an inline or interned path. |
|
||||
| `["d"]` | Delete using the previous path. |
|
||||
| `["a", pathRef, text]` | Append with an inline or interned path. |
|
||||
| `["a", text]` | Append using the previous path. |
|
||||
| `["t", pathRef, count]` | Front-truncate with an inline or interned path. |
|
||||
| `["t", count]` | Front-truncate using the previous path. |
|
||||
| `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. |
|
||||
| `["p", index, remove, items]` | Splice using the previous path. |
|
||||
|
||||
For example, adjacent decoded operations on one path:
|
||||
|
||||
```ts
|
||||
[
|
||||
["t", ["output"], 200],
|
||||
["a", ["output"], "next chunk"],
|
||||
]
|
||||
```
|
||||
|
||||
encode to:
|
||||
|
||||
```ts
|
||||
[
|
||||
["t", ["output"], 200],
|
||||
["a", "next chunk"], // reuses ["output"]
|
||||
]
|
||||
```
|
||||
|
||||
When `output` is used again in a later batch, the encoder defines an ID on its
|
||||
second explicit use:
|
||||
|
||||
```ts
|
||||
[
|
||||
["#", 0, ["output"]],
|
||||
["a", 0, "more"],
|
||||
]
|
||||
```
|
||||
|
||||
Later batches can use `0` directly until a complete-value operation resets the
|
||||
dictionary.
|
||||
|
||||
## Producing changes
|
||||
|
||||
Read and mutate `tracker.state` as a normal object:
|
||||
|
||||
```ts
|
||||
tracker.state.status = "running";
|
||||
tracker.state.settings.theme = "dark";
|
||||
tracker.state.messages.push(message);
|
||||
delete tracker.state.retry;
|
||||
```
|
||||
|
||||
Only the value at flush time is published:
|
||||
|
||||
```ts
|
||||
tracker.state.status = "starting";
|
||||
tracker.state.status = "running";
|
||||
tracker.flush(); // one set to "running"
|
||||
```
|
||||
|
||||
Replacing an object or array is valid. Delta compares its properties and elements
|
||||
with the previously published value:
|
||||
|
||||
```ts
|
||||
tracker.state.settings = {
|
||||
...plainSettings,
|
||||
theme: "dark",
|
||||
};
|
||||
```
|
||||
|
||||
Unchanged properties produce no operations. Changed nested strings and arrays
|
||||
still use string and splice operations.
|
||||
|
||||
### Strings
|
||||
|
||||
Appending text produces an `a` operation:
|
||||
|
||||
```ts
|
||||
tracker.state.output += "next line\n";
|
||||
```
|
||||
|
||||
Moving a bounded text window forward produces `t` followed by `a` when the old
|
||||
suffix matches the new prefix:
|
||||
|
||||
```ts
|
||||
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
|
||||
```
|
||||
|
||||
An unrelated replacement produces `s`.
|
||||
|
||||
### Arrays
|
||||
|
||||
Use normal array methods:
|
||||
|
||||
```ts
|
||||
tracker.state.messages.push(first);
|
||||
tracker.state.messages.push(second);
|
||||
tracker.state.messages.splice(3, 1, replacement);
|
||||
```
|
||||
|
||||
All `push()` calls before one flush produce one tail `p`. Changes to older
|
||||
elements remain separate, regardless of whether they happen before or after the
|
||||
pushes. Changes to newly pushed elements are included in the pushed values.
|
||||
|
||||
Front or middle insertion, removal, sorting, reversing, `fill()`, and
|
||||
`copyWithin()` are supported. A structural change combined with edits to elements
|
||||
whose indices moved may compare and publish the retained suffix positionally:
|
||||
|
||||
```ts
|
||||
tracker.state.items.shift();
|
||||
tracker.state.items[0].status = "changed";
|
||||
// A shift followed by push in the same flush has the same issue.
|
||||
```
|
||||
|
||||
The emitted data can then scale with the retained suffix, or with the complete
|
||||
array, rather than only the changed element. When batching is under your control,
|
||||
flush the structural change before editing elements at their new indices.
|
||||
|
||||
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
|
||||
`length` creates explicit `null` elements; decreasing it removes elements.
|
||||
|
||||
`fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
|
||||
them to place one mutable object at multiple live paths.
|
||||
|
||||
### Optional properties
|
||||
|
||||
Optional object properties use absence. They do not require `null`:
|
||||
|
||||
```ts
|
||||
type Settings = { label?: string; count: number };
|
||||
const tracker = track<Settings>({ count: 0 });
|
||||
|
||||
tracker.state.label = "active";
|
||||
tracker.state.label = undefined; // produces d
|
||||
// `delete tracker.state.label` is equivalent
|
||||
```
|
||||
|
||||
`undefined` is accepted only as assignment syntax for deleting an object
|
||||
property. It is not a JSON value. Initial and assigned objects cannot contain own
|
||||
`undefined` values, and array elements cannot be `undefined`. Use `null` when an
|
||||
array position or explicit empty value must remain present.
|
||||
|
||||
## State ownership
|
||||
|
||||
The object passed to `track()` becomes tracker-owned. The same applies to objects
|
||||
later assigned into state or inserted into arrays.
|
||||
|
||||
After insertion, a retained reference may be read but must not be mutated or
|
||||
inserted at another live location. The tracker relies on this ownership rule; it
|
||||
does not recursively validate values or detect aliases:
|
||||
|
||||
```ts
|
||||
const item = { status: "new" };
|
||||
tracker.state.item = item;
|
||||
|
||||
tracker.state.item.status = "ready"; // supported: tracked mutation
|
||||
item.status = "broken"; // unsupported: bypasses tracking
|
||||
tracker.state.other = item; // unsupported: one object at two live paths
|
||||
```
|
||||
|
||||
The same restriction applies across separate array calls:
|
||||
|
||||
```ts
|
||||
tracker.state.items.push(item);
|
||||
tracker.state.items.push(item); // unsupported alias
|
||||
```
|
||||
|
||||
Use distinct objects when values must appear at multiple paths. Perform
|
||||
mutations through `tracker.state`; do not put a proxy read from `tracker.state`
|
||||
back into tracked state.
|
||||
|
||||
Tracked state must be a mutable JSON tree:
|
||||
|
||||
- strings, booleans, finite numbers, `null`, arrays, and plain objects;
|
||||
- no cycles or one mutable object stored at multiple locations;
|
||||
- no sparse arrays, accessors, frozen objects, symbols, classes, functions,
|
||||
`Map`, or `Set`.
|
||||
|
||||
Do not keep a child proxy across an array operation that changes indices. Read
|
||||
the child again from its new index.
|
||||
|
||||
## Tracker lifecycle
|
||||
|
||||
```ts
|
||||
tracker.flush(); // publish changes since the previous flush
|
||||
tracker.rebase(); // make the next flush a complete replacement
|
||||
tracker.discard(); // accept current changes without publishing them
|
||||
tracker.state = replacement; // replace the root; next flush is complete
|
||||
```
|
||||
|
||||
`discard()` intentionally prevents current changes from reaching existing
|
||||
replicas. Use it only when those replicas do not need the discarded changes.
|
||||
|
||||
`apply()` adopts object and array payloads from its input batch. Do not freeze a
|
||||
batch before applying it, and do not apply one in-memory batch to multiple
|
||||
mutable replicas unless each replica owns that batch. A serialized and decoded
|
||||
batch is already detached. `applyImmutable()` instead treats its previous value
|
||||
and operation payloads as immutable, so one batch can safely fan out in-process.
|
||||
|
||||
A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream.
|
||||
Discard its decoder and replica, then recover from a later base batch. `apply()`
|
||||
is not transactional; operations before the failing operation may already have
|
||||
changed the replica.
|
||||
|
||||
## Limits
|
||||
|
||||
- Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
|
||||
gap detection, retries, and persistence policy belong to the surrounding
|
||||
protocol or storage format.
|
||||
- Object identity is not replicated. Tracked mutable state must be a tree;
|
||||
immutable inputs may share references, but replicas need not preserve them.
|
||||
- Object key insertion order is not replicated. Do not compare or hash replicas
|
||||
using serialized key order.
|
||||
- Array operations that change indices may publish a wider array region, as
|
||||
described under Arrays.
|
||||
- Object-valued keys named `__proto__`, `constructor`, or `prototype` can be read
|
||||
and serialized, but cannot be mutated through that key. Replace the nearest
|
||||
ordinarily named parent instead.
|
||||
1267
third_party/pi/packages/chord/src/delta/index.ts
vendored
Normal file
1267
third_party/pi/packages/chord/src/delta/index.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
906
third_party/pi/packages/chord/src/facets/host.ts
vendored
Normal file
906
third_party/pi/packages/chord/src/facets/host.ts
vendored
Normal file
@@ -0,0 +1,906 @@
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { RemoteServiceBindingImpl } from "../services/consumer.ts";
|
||||
import { ServiceSlot } from "../services/handle.ts";
|
||||
import { InstanceDirectory, type InstanceDirectoryEntry } from "../services/instances.ts";
|
||||
import { createLoopbackServiceTransport } from "../services/loopback.ts";
|
||||
import { RemoteServiceProvider, validateRemoteServiceImplementation } from "../services/provider.ts";
|
||||
import { MutableReplicatedStateImpl } from "../services/state.ts";
|
||||
import type {
|
||||
Context,
|
||||
Facet,
|
||||
FacetEnvironment,
|
||||
FacetOptions,
|
||||
RemoteServiceContract,
|
||||
RemoteServiceSource,
|
||||
RemoteServices,
|
||||
Service,
|
||||
ServiceMode,
|
||||
ServiceSpawner,
|
||||
} from "../types.ts";
|
||||
|
||||
interface FacetServiceReference {
|
||||
readonly serviceId: string;
|
||||
readonly service: Service<unknown>;
|
||||
readonly mode: ServiceMode;
|
||||
}
|
||||
|
||||
interface ExternalService {
|
||||
readonly service: Service<unknown>;
|
||||
readonly mode: ServiceMode;
|
||||
readonly source: RemoteServiceSource;
|
||||
}
|
||||
|
||||
interface FacetShape {
|
||||
facetId: string;
|
||||
requires: readonly FacetServiceReference[];
|
||||
provides: readonly FacetServiceReference[];
|
||||
}
|
||||
|
||||
interface FacetRuntime extends FacetShape {
|
||||
requires: FacetServiceReference[];
|
||||
provides: FacetServiceReference[];
|
||||
readonly lifecycle: FacetLifecycle;
|
||||
readonly provisions: FacetProvision[];
|
||||
readonly singletonViews: Map<string, unknown>;
|
||||
}
|
||||
|
||||
type LifecycleState = "setting_up" | "prepared" | "active" | "disposing" | "dead";
|
||||
type GenerationPhase =
|
||||
| "setup"
|
||||
| "assembling"
|
||||
| "connecting"
|
||||
| "activating"
|
||||
| "active"
|
||||
| "reloading"
|
||||
| "disposing"
|
||||
| "dead";
|
||||
type Disposal = () => void | Promise<void>;
|
||||
|
||||
class FacetLifecycle {
|
||||
readonly id: string;
|
||||
readonly #effects: Disposal[] = [];
|
||||
readonly #observations: Array<() => Disposal> = [];
|
||||
readonly #activate: Array<() => void | Promise<void>> = [];
|
||||
#state: LifecycleState = "setting_up";
|
||||
#serviceAccess = false;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
assertSettingUp(operation: string): void {
|
||||
if (this.#state !== "setting_up") {
|
||||
throw new Error(`Facet ${this.id} can ${operation} only during setup`);
|
||||
}
|
||||
}
|
||||
|
||||
assertRunning(operation: string): void {
|
||||
if (this.#state !== "setting_up" && this.#state !== "active") {
|
||||
throw new Error(`Facet ${this.id} cannot ${operation} while ${this.#state}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertActive(operation: string): void {
|
||||
if (this.#state !== "active") throw new Error(`Facet ${this.id} can ${operation} only while active`);
|
||||
}
|
||||
|
||||
assertServiceAccess(): void {
|
||||
if (!this.#serviceAccess) {
|
||||
throw new Error(`Facet ${this.id} service handles cannot be used while ${this.#state}`);
|
||||
}
|
||||
}
|
||||
|
||||
revoke(): void {
|
||||
this.#serviceAccess = false;
|
||||
}
|
||||
|
||||
own(disposal: Disposal): void {
|
||||
this.assertRunning("own resources");
|
||||
this.#effects.push(disposal);
|
||||
}
|
||||
|
||||
observe(start: () => Disposal): void {
|
||||
this.assertSettingUp("observe services");
|
||||
this.#observations.push(start);
|
||||
}
|
||||
|
||||
onActivate(callback: () => void | Promise<void>): void {
|
||||
this.assertSettingUp("register activation callbacks");
|
||||
this.#activate.push(callback);
|
||||
}
|
||||
|
||||
prepared(): void {
|
||||
this.assertSettingUp("finish setup");
|
||||
this.#state = "prepared";
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
if (this.#state !== "prepared") throw new Error(`Facet ${this.id} is not prepared`);
|
||||
this.#state = "active";
|
||||
this.#serviceAccess = true;
|
||||
for (const start of this.#observations) this.#effects.push(start());
|
||||
for (const callback of this.#activate) await callback();
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.#state === "dead") return;
|
||||
this.#state = "disposing";
|
||||
const errors: unknown[] = [];
|
||||
for (const effect of this.#effects.splice(0).reverse()) {
|
||||
try {
|
||||
await effect();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
this.#observations.length = 0;
|
||||
this.#activate.length = 0;
|
||||
this.#serviceAccess = false;
|
||||
this.#state = "dead";
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, `Failed to dispose facet ${this.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyedServiceSource {
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
|
||||
}
|
||||
|
||||
interface LocalKeyedRegistration {
|
||||
readonly generations: Map<string, number>;
|
||||
readonly directory: InstanceDirectory<InstanceDirectoryEntry>;
|
||||
}
|
||||
|
||||
class LocalKeyedServiceRegistry implements KeyedServiceSource {
|
||||
readonly #registrations = new Map<string, LocalKeyedRegistration>();
|
||||
#disposed = false;
|
||||
|
||||
constructor(services: readonly { readonly id: string }[], onError: (error: Error) => void) {
|
||||
const ids = services.map(({ id }) => id);
|
||||
if (new Set(ids).size !== ids.length) throw new TypeError("Local keyed service registry has duplicate IDs");
|
||||
for (const serviceId of ids) {
|
||||
this.#registrations.set(serviceId, {
|
||||
generations: new Map(),
|
||||
directory: new InstanceDirectory({ ready: true, onError }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spawn<T>(service: Service<T>, key: string, implementation: T): () => void {
|
||||
this.#assertActive();
|
||||
if (key.length === 0) throw new TypeError("Local service instance key must not be empty");
|
||||
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
|
||||
throw new TypeError(`Local service ${service.id} implementation must be an object`);
|
||||
}
|
||||
const registration = this.#registration(service.id);
|
||||
if (registration.directory.get(key) !== undefined) {
|
||||
throw new Error(`Local service ${service.id} already has a live instance with key ${key}`);
|
||||
}
|
||||
const generation = (registration.generations.get(key) ?? 0) + 1;
|
||||
registration.generations.set(key, generation);
|
||||
const instance: InstanceDirectoryEntry = {
|
||||
key,
|
||||
generation,
|
||||
service: implementation as object,
|
||||
deactivate() {},
|
||||
};
|
||||
registration.directory.insert(instance);
|
||||
let closed = false;
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
registration.directory.remove(instance);
|
||||
};
|
||||
}
|
||||
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
this.#assertActive();
|
||||
return this.#registration(service.id).directory.observe(handler);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
for (const registration of this.#registrations.values()) registration.directory.dispose();
|
||||
this.#registrations.clear();
|
||||
}
|
||||
|
||||
#registration(serviceId: string): LocalKeyedRegistration {
|
||||
const registration = this.#registrations.get(serviceId);
|
||||
if (registration === undefined) throw new Error(`Local keyed service ${serviceId} is not registered`);
|
||||
return registration;
|
||||
}
|
||||
|
||||
#assertActive(): void {
|
||||
if (this.#disposed) throw new Error("Local keyed service registry is disposed");
|
||||
}
|
||||
}
|
||||
|
||||
class HostServiceSlots {
|
||||
readonly #singletons = new Map<string, ServiceSlot>();
|
||||
readonly #keyedSources = new Map<string, KeyedServiceSource>();
|
||||
|
||||
getSingleton<T>(service: Service<T>, assertAccess: () => void): T {
|
||||
let slot = this.#singletons.get(service.id);
|
||||
if (slot === undefined) {
|
||||
slot = new ServiceSlot(service.id, !service.local);
|
||||
this.#singletons.set(service.id, slot);
|
||||
}
|
||||
return slot.view<T>(assertAccess);
|
||||
}
|
||||
|
||||
hasSingleton(serviceId: string): boolean {
|
||||
return this.#singletons.has(serviceId);
|
||||
}
|
||||
|
||||
observe<T>(
|
||||
service: Service<T>,
|
||||
assertAccess: () => void,
|
||||
handler: (service: T, context: Context) => void | Promise<void>,
|
||||
): Disposal {
|
||||
const source = this.#keyedSources.get(service.id);
|
||||
if (source === undefined) throw new Error(`Service ${service.id} is disconnected`);
|
||||
let stopped = false;
|
||||
const stop = source.observe(service, (target, context) => {
|
||||
const slot = new ServiceSlot(service.id, !service.local);
|
||||
slot.bind(target as object);
|
||||
return handler(
|
||||
slot.view<T>(() => {
|
||||
assertAccess();
|
||||
if (stopped || context.abortSignal?.aborted) {
|
||||
throw new Error(`Keyed service ${service.id} observation is closed`);
|
||||
}
|
||||
}),
|
||||
context,
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
stop();
|
||||
};
|
||||
}
|
||||
|
||||
bindSingleton(serviceId: string, target: object): void {
|
||||
this.#singletons.get(serviceId)?.bind(target);
|
||||
}
|
||||
|
||||
bindKeyed(serviceId: string, services: KeyedServiceSource): void {
|
||||
this.#keyedSources.set(serviceId, services);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const slot of this.#singletons.values()) slot.unbind();
|
||||
this.#singletons.clear();
|
||||
this.#keyedSources.clear();
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceInstanceInstaller<T> = (key: string, implementation: T) => () => void;
|
||||
|
||||
interface StagedServiceInstance<T> {
|
||||
readonly key: string;
|
||||
readonly implementation: T;
|
||||
release?: () => void;
|
||||
}
|
||||
|
||||
class StagedServiceSpawner<T> implements ServiceSpawner<T> {
|
||||
readonly #lifecycle: FacetLifecycle;
|
||||
readonly #validate: (key: string, implementation: T) => void;
|
||||
readonly #instances = new Map<string, StagedServiceInstance<T>>();
|
||||
#installer: ServiceInstanceInstaller<T> | undefined;
|
||||
|
||||
constructor(lifecycle: FacetLifecycle, validate: (key: string, implementation: T) => void) {
|
||||
this.#lifecycle = lifecycle;
|
||||
this.#validate = validate;
|
||||
}
|
||||
|
||||
connect(installer: ServiceInstanceInstaller<T>): void {
|
||||
if (this.#installer !== undefined) throw new Error("Facet service provider is already connected");
|
||||
this.#installer = installer;
|
||||
for (const instance of this.#instances.values()) {
|
||||
instance.release = installer(instance.key, instance.implementation);
|
||||
}
|
||||
}
|
||||
|
||||
spawn(key: string, implementation: T): () => void {
|
||||
this.#lifecycle.assertActive("spawn service instances");
|
||||
this.#validate(key, implementation);
|
||||
if (this.#instances.has(key)) throw new Error(`Facet service already has a live instance with key ${key}`);
|
||||
const instance: StagedServiceInstance<T> = { key, implementation };
|
||||
this.#instances.set(key, instance);
|
||||
if (this.#installer !== undefined) instance.release = this.#installer(key, implementation);
|
||||
const close = (): void => {
|
||||
if (this.#instances.get(key) !== instance) return;
|
||||
this.#instances.delete(key);
|
||||
instance.release?.();
|
||||
};
|
||||
this.#lifecycle.own(close);
|
||||
return close;
|
||||
}
|
||||
}
|
||||
|
||||
type FacetProvision =
|
||||
| {
|
||||
readonly kind: "singleton";
|
||||
readonly service: { readonly id: string; readonly local: boolean };
|
||||
readonly implementation: object;
|
||||
install(provider: RemoteServiceProvider): void;
|
||||
validateReplacement(provider: RemoteServiceProvider): void;
|
||||
replace(provider: RemoteServiceProvider): void;
|
||||
}
|
||||
| {
|
||||
readonly kind: "keyed";
|
||||
readonly service: { readonly id: string; readonly local: boolean };
|
||||
connectLocal(registry: LocalKeyedServiceRegistry): void;
|
||||
connectRemote(provider: RemoteServiceProvider): void;
|
||||
};
|
||||
|
||||
/** Private lifecycle and dependency kernel behind the atomic host entry point. */
|
||||
export class FacetKernel {
|
||||
readonly #initialFacets: readonly Facet[];
|
||||
readonly #serviceSources: readonly RemoteServiceSource[];
|
||||
readonly #onError: (error: Error) => void;
|
||||
readonly #facets = new Map<string, FacetRuntime>();
|
||||
readonly #serviceSlots: HostServiceSlots;
|
||||
readonly #sourceBindings = new Map<RemoteServiceSource, RemoteServices>();
|
||||
#activationOrder: readonly string[] = [];
|
||||
#provider: RemoteServiceProvider | undefined;
|
||||
#internalServices: RemoteServices | undefined;
|
||||
#localKeyedServices: LocalKeyedServiceRegistry | undefined;
|
||||
#phase: GenerationPhase = "setup";
|
||||
|
||||
constructor(options: FacetOptions) {
|
||||
const ids = options.facets.map((facet) => facet.id);
|
||||
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty");
|
||||
if (new Set(ids).size !== ids.length) throw new Error("Facet IDs must be unique within a generation");
|
||||
this.#initialFacets = options.facets;
|
||||
this.#serviceSources = options.serviceSources ?? [];
|
||||
this.#onError = options.onError ?? (() => {});
|
||||
this.#serviceSlots = new HostServiceSlots();
|
||||
}
|
||||
|
||||
get provider(): RemoteServiceProvider {
|
||||
if (this.#provider === undefined) throw new Error("Facet service provider is not assembled");
|
||||
return this.#provider;
|
||||
}
|
||||
|
||||
#createFacetRuntime(facetId: string): FacetRuntime {
|
||||
return {
|
||||
facetId,
|
||||
requires: [],
|
||||
provides: [],
|
||||
lifecycle: new FacetLifecycle(facetId),
|
||||
provisions: [],
|
||||
singletonViews: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
#setupFacet(facet: Facet, record: FacetRuntime): void {
|
||||
const result: unknown = facet.setup(this.#environment(record));
|
||||
if (isPromiseLike(result)) {
|
||||
void Promise.resolve(result).catch(() => {});
|
||||
throw new Error(`Facet ${facet.id} setup must be synchronous`);
|
||||
}
|
||||
record.lifecycle.prepared();
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
const records: FacetRuntime[] = [];
|
||||
try {
|
||||
for (const facet of this.#initialFacets) {
|
||||
const record = this.#createFacetRuntime(facet.id);
|
||||
this.#facets.set(facet.id, record);
|
||||
this.#setupFacet(facet, record);
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
this.#phase = "assembling";
|
||||
const externalServices = await this.#resolveExternalServices(records);
|
||||
this.#activationOrder = validateFacets(records, externalServices);
|
||||
this.#assembleProviders();
|
||||
this.#bindServices(externalServices);
|
||||
|
||||
this.#phase = "connecting";
|
||||
await Promise.all(
|
||||
[...this.#sourceBindings.values(), this.#internalServiceBinding].map((services) =>
|
||||
services.ready(BACKGROUND_CONTEXT),
|
||||
),
|
||||
);
|
||||
|
||||
this.#phase = "activating";
|
||||
for (const id of this.#activationOrder) await this.#facets.get(id)!.lifecycle.activate();
|
||||
this.#phase = "active";
|
||||
} catch (error) {
|
||||
const cleanupErrors = await this.#terminate();
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Facet generation startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reload(facets: readonly Facet[]): Promise<void> {
|
||||
if (this.#phase !== "active") throw new Error(`Facet host cannot reload while ${this.#phase}`);
|
||||
const ids = facets.map(({ id }) => id);
|
||||
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty");
|
||||
if (new Set(ids).size !== ids.length) throw new Error("Reloaded facet IDs must be unique");
|
||||
for (const id of ids) {
|
||||
if (!this.#facets.has(id)) throw new Error(`Facet ${id} is not active`);
|
||||
}
|
||||
this.#phase = "reloading";
|
||||
|
||||
const staged: FacetRuntime[] = [];
|
||||
const candidates: FacetRuntime[] = [];
|
||||
try {
|
||||
for (const facet of facets) {
|
||||
const record = this.#createFacetRuntime(facet.id);
|
||||
staged.push(record);
|
||||
this.#setupFacet(facet, record);
|
||||
const previous = this.#facets.get(facet.id)!;
|
||||
if (!sameFacetShape(previous, record)) {
|
||||
throw new Error(`Reloaded facet ${facet.id} must preserve its service requirements and provisions`);
|
||||
}
|
||||
this.#validateReplacementProvisions(record.provisions);
|
||||
candidates.push(record);
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupErrors = await disposeFacetRecords(staged.reverse());
|
||||
if (cleanupErrors.length > 0) {
|
||||
const abortErrors = await this.#abort();
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupErrors, ...abortErrors],
|
||||
"Facet reload setup and cleanup failed",
|
||||
);
|
||||
}
|
||||
this.#phase = "active";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const replacements = new Map(candidates.map((record) => [record.facetId, record]));
|
||||
const candidateOrder = this.#activationOrder.flatMap((id) => {
|
||||
const candidate = replacements.get(id);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
try {
|
||||
for (const candidate of candidateOrder) await candidate.lifecycle.activate();
|
||||
for (const candidate of candidateOrder) this.#validateReplacementProvisions(candidate.provisions);
|
||||
} catch (error) {
|
||||
const cleanupErrors = await disposeFacetRecords([...candidateOrder].reverse());
|
||||
if (cleanupErrors.length > 0) {
|
||||
const abortErrors = await this.#abort();
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupErrors, ...abortErrors],
|
||||
"Facet reload activation and cleanup failed",
|
||||
);
|
||||
}
|
||||
this.#phase = "active";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previous = candidateOrder.map(({ facetId }) => this.#facets.get(facetId)!);
|
||||
for (const candidate of candidateOrder) this.#facets.set(candidate.facetId, candidate);
|
||||
try {
|
||||
for (const candidate of candidateOrder) {
|
||||
for (const provision of candidate.provisions) {
|
||||
if (provision.kind !== "singleton") continue;
|
||||
if (provision.service.local) {
|
||||
this.#serviceSlots.bindSingleton(provision.service.id, provision.implementation);
|
||||
} else {
|
||||
provision.replace(this.provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
const retirementErrors = await disposeFacetRecords(previous.reverse());
|
||||
if (retirementErrors.length === 1) throw retirementErrors[0];
|
||||
if (retirementErrors.length > 1) {
|
||||
throw new AggregateError(retirementErrors, "Failed to retire replaced facets");
|
||||
}
|
||||
for (const candidate of candidateOrder) {
|
||||
for (const provision of candidate.provisions) {
|
||||
if (provision.kind !== "keyed") continue;
|
||||
if (provision.service.local) provision.connectLocal(this.#localKeyedRegistry);
|
||||
else provision.connectRemote(this.provider);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const abortErrors = await this.#abort(previous);
|
||||
throw new AggregateError([error, ...abortErrors], "Facet reload failed after cutover");
|
||||
}
|
||||
this.#phase = "active";
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.#phase === "dead") return;
|
||||
if (this.#phase !== "active") throw new Error(`Facet host cannot be disposed while ${this.#phase}`);
|
||||
const errors = await this.#terminate();
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet generation");
|
||||
}
|
||||
|
||||
#validateReplacementProvisions(provisions: readonly FacetProvision[]): void {
|
||||
for (const provision of provisions) {
|
||||
if (provision.kind !== "singleton" || provision.service.local) continue;
|
||||
provision.validateReplacement(this.provider);
|
||||
}
|
||||
}
|
||||
|
||||
#environment(runtime: FacetRuntime): FacetEnvironment {
|
||||
const { lifecycle, provisions } = runtime;
|
||||
return {
|
||||
provide: <T>(service: Service<T>, implementation: NoInfer<T>): void => {
|
||||
lifecycle.assertSettingUp("provide services");
|
||||
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
|
||||
throw new TypeError(`Service ${service.id} implementation must be an object`);
|
||||
}
|
||||
recordServiceReference(runtime.provides, service, "singleton");
|
||||
provisions.push({
|
||||
kind: "singleton",
|
||||
service,
|
||||
implementation,
|
||||
install: (provider) => provider.provide(service, implementation as NoInfer<RemoteServiceContract<T>>),
|
||||
validateReplacement: (provider) =>
|
||||
provider.validateReplacement(service, implementation as NoInfer<RemoteServiceContract<T>>),
|
||||
replace: (provider) => provider.replace(service, implementation as NoInfer<RemoteServiceContract<T>>),
|
||||
});
|
||||
},
|
||||
provideMany: <T>(service: Service<T>): ServiceSpawner<T> => {
|
||||
lifecycle.assertSettingUp("provide service instances");
|
||||
recordServiceReference(runtime.provides, service, "keyed");
|
||||
const instances = new StagedServiceSpawner<T>(lifecycle, (key, implementation) => {
|
||||
if (key.length === 0) throw new TypeError("Facet service instance key must not be empty");
|
||||
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
|
||||
throw new TypeError(`Facet service ${service.id} implementation must be an object`);
|
||||
}
|
||||
if (!service.local) validateRemoteServiceImplementation(service.id, implementation);
|
||||
});
|
||||
provisions.push({
|
||||
kind: "keyed",
|
||||
service,
|
||||
connectLocal: (registry) =>
|
||||
instances.connect((key, implementation) => registry.spawn(service, key, implementation)),
|
||||
connectRemote: (provider) =>
|
||||
instances.connect((key, implementation) =>
|
||||
provider.spawn(service, key, implementation as NoInfer<RemoteServiceContract<T>>),
|
||||
),
|
||||
});
|
||||
return instances;
|
||||
},
|
||||
use: <T>(service: Service<T>): T => {
|
||||
lifecycle.assertSettingUp("acquire services");
|
||||
recordServiceReference(runtime.requires, service, "singleton");
|
||||
let view = runtime.singletonViews.get(service.id);
|
||||
if (view === undefined) {
|
||||
view = this.#serviceSlots.getSingleton<T>(service, () => lifecycle.assertServiceAccess());
|
||||
runtime.singletonViews.set(service.id, view);
|
||||
}
|
||||
return view as T;
|
||||
},
|
||||
observe: <T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): void => {
|
||||
lifecycle.assertSettingUp("observe services");
|
||||
recordServiceReference(runtime.requires, service, "keyed");
|
||||
lifecycle.observe(() =>
|
||||
this.#serviceSlots.observe(service, () => lifecycle.assertServiceAccess(), handler),
|
||||
);
|
||||
},
|
||||
replicatedState: <T extends object>(initial: T) => {
|
||||
lifecycle.assertRunning("create replicated state");
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
},
|
||||
own: (disposal) => lifecycle.own(disposal),
|
||||
onActivate: (callback) => lifecycle.onActivate(callback),
|
||||
onDeactivate: (callback) => lifecycle.own(callback),
|
||||
};
|
||||
}
|
||||
|
||||
async #resolveExternalServices(records: readonly FacetShape[]): Promise<ReadonlyMap<string, ExternalService>> {
|
||||
const catalogues = await Promise.all(
|
||||
this.#serviceSources.map(async (source) => ({
|
||||
source,
|
||||
entries: await source.catalogue(BACKGROUND_CONTEXT),
|
||||
})),
|
||||
);
|
||||
const offered = new Map<string, { readonly mode: ServiceMode; readonly source: RemoteServiceSource }>();
|
||||
for (const { source, entries } of catalogues) {
|
||||
for (const { serviceId, mode } of entries) {
|
||||
if (offered.has(serviceId)) {
|
||||
throw new Error(`Facet host service ${serviceId} is offered by more than one source`);
|
||||
}
|
||||
offered.set(serviceId, { mode, source });
|
||||
}
|
||||
}
|
||||
|
||||
const local = new Set(records.flatMap(({ provides }) => provides.map(({ serviceId }) => serviceId)));
|
||||
const external = new Map<string, ExternalService>();
|
||||
for (const { requires } of records) {
|
||||
for (const requirement of requires) {
|
||||
if (local.has(requirement.serviceId) || external.has(requirement.serviceId)) continue;
|
||||
let source = offered.get(requirement.serviceId);
|
||||
if (source === undefined) {
|
||||
const deferred = this.#serviceSources.filter(
|
||||
({ acceptsUnavailableServices }) => acceptsUnavailableServices,
|
||||
);
|
||||
if (deferred.length > 1) {
|
||||
throw new Error(`Facet host service ${requirement.serviceId} has more than one deferred source`);
|
||||
}
|
||||
if (deferred.length === 1) source = { mode: requirement.mode, source: deferred[0]! };
|
||||
}
|
||||
if (source !== undefined) {
|
||||
external.set(requirement.serviceId, { ...source, service: requirement.service });
|
||||
}
|
||||
}
|
||||
}
|
||||
const serviceIdsBySource = new Map<RemoteServiceSource, string[]>();
|
||||
for (const [serviceId, { source }] of external) {
|
||||
let serviceIds = serviceIdsBySource.get(source);
|
||||
if (serviceIds === undefined) {
|
||||
serviceIds = [];
|
||||
serviceIdsBySource.set(source, serviceIds);
|
||||
}
|
||||
serviceIds.push(serviceId);
|
||||
}
|
||||
for (const [source, serviceIds] of serviceIdsBySource) {
|
||||
this.#sourceBindings.set(
|
||||
source,
|
||||
source.open({
|
||||
services: serviceIds.map((id) => ({ id })),
|
||||
assertAccess: () => this.#assertServiceTargetAccess(),
|
||||
onError: this.#onError,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return external;
|
||||
}
|
||||
|
||||
#assembleProviders(): void {
|
||||
const provisions = this.#provisions();
|
||||
const remoteProvisions = provisions.filter(({ service }) => !service.local);
|
||||
const provider = new RemoteServiceProvider(
|
||||
remoteProvisions.map(({ service, kind }) => ({ service, mode: kind })),
|
||||
);
|
||||
const internalServices = new RemoteServiceBindingImpl({
|
||||
services: remoteProvisions.map(({ service }) => service),
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
assertAccess: () => this.#assertServiceTargetAccess(),
|
||||
onError: this.#onError,
|
||||
});
|
||||
const localKeyedServices = new LocalKeyedServiceRegistry(
|
||||
provisions.flatMap((provision) =>
|
||||
provision.kind === "keyed" && provision.service.local ? [provision.service] : [],
|
||||
),
|
||||
this.#onError,
|
||||
);
|
||||
this.#provider = provider;
|
||||
this.#internalServices = internalServices;
|
||||
this.#localKeyedServices = localKeyedServices;
|
||||
for (const provision of provisions) {
|
||||
if (provision.kind === "singleton") {
|
||||
if (!provision.service.local) provision.install(provider);
|
||||
} else if (provision.service.local) {
|
||||
provision.connectLocal(localKeyedServices);
|
||||
} else {
|
||||
provision.connectRemote(provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#bindServices(externalServices: ReadonlyMap<string, ExternalService>): void {
|
||||
for (const provision of this.#provisions()) {
|
||||
if (provision.kind === "singleton") {
|
||||
if (!this.#serviceSlots.hasSingleton(provision.service.id)) continue;
|
||||
const target = provision.service.local
|
||||
? provision.implementation
|
||||
: (this.#internalServiceBinding.use(provision.service) as object);
|
||||
this.#serviceSlots.bindSingleton(provision.service.id, target);
|
||||
} else {
|
||||
this.#serviceSlots.bindKeyed(
|
||||
provision.service.id,
|
||||
provision.service.local ? this.#localKeyedRegistry : this.#internalServiceBinding,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [serviceId, { service, mode, source }] of externalServices) {
|
||||
const services = this.#sourceBindings.get(source);
|
||||
if (services === undefined) throw new Error(`Service source for ${serviceId} is not open`);
|
||||
if (mode === "singleton") {
|
||||
this.#serviceSlots.bindSingleton(serviceId, services.use(service) as object);
|
||||
} else {
|
||||
this.#serviceSlots.bindKeyed(serviceId, services);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#provisions(): FacetProvision[] {
|
||||
return [...this.#facets.values()].flatMap(({ provisions }) => provisions);
|
||||
}
|
||||
|
||||
get #localKeyedRegistry(): LocalKeyedServiceRegistry {
|
||||
if (this.#localKeyedServices === undefined) throw new Error("Facet keyed services are not assembled");
|
||||
return this.#localKeyedServices;
|
||||
}
|
||||
|
||||
get #internalServiceBinding(): RemoteServices {
|
||||
if (this.#internalServices === undefined) throw new Error("Facet remote services are not assembled");
|
||||
return this.#internalServices;
|
||||
}
|
||||
|
||||
async #disposeServiceBindings(): Promise<unknown[]> {
|
||||
const bindings = [...this.#sourceBindings.values()];
|
||||
this.#sourceBindings.clear();
|
||||
if (this.#internalServices !== undefined) bindings.push(this.#internalServices);
|
||||
this.#internalServices = undefined;
|
||||
const results = await Promise.allSettled(bindings.map((services) => services.dispose(BACKGROUND_CONTEXT)));
|
||||
return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
}
|
||||
|
||||
#assertServiceTargetAccess(): void {
|
||||
if (
|
||||
this.#phase !== "activating" &&
|
||||
this.#phase !== "active" &&
|
||||
this.#phase !== "reloading" &&
|
||||
this.#phase !== "disposing"
|
||||
) {
|
||||
throw new Error(`Facet service targets cannot be used during ${this.#phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #abort(extraRecords: readonly FacetRuntime[] = []): Promise<unknown[]> {
|
||||
for (const record of this.#facets.values()) record.lifecycle.revoke();
|
||||
for (const record of extraRecords) record.lifecycle.revoke();
|
||||
return this.#terminate(extraRecords);
|
||||
}
|
||||
|
||||
async #terminate(extraRecords: readonly FacetRuntime[] = []): Promise<unknown[]> {
|
||||
this.#phase = "disposing";
|
||||
const errors = await this.#disposeLifecycles();
|
||||
errors.push(...(await disposeFacetRecords([...extraRecords].reverse())));
|
||||
try {
|
||||
this.#localKeyedServices?.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
this.#localKeyedServices = undefined;
|
||||
errors.push(...(await this.#disposeServiceBindings()));
|
||||
try {
|
||||
this.#serviceSlots.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
this.#provider?.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
this.#phase = "dead";
|
||||
return errors;
|
||||
}
|
||||
|
||||
async #disposeLifecycles(): Promise<unknown[]> {
|
||||
const errors: unknown[] = [];
|
||||
const order =
|
||||
this.#activationOrder.length > 0 ? [...this.#activationOrder].reverse() : [...this.#facets.keys()].reverse();
|
||||
for (const id of order) {
|
||||
const record = this.#facets.get(id);
|
||||
if (record === undefined) continue;
|
||||
this.#facets.delete(id);
|
||||
try {
|
||||
await record.lifecycle.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
async function disposeFacetRecords(records: readonly FacetRuntime[]): Promise<unknown[]> {
|
||||
const errors: unknown[] = [];
|
||||
for (const record of records) {
|
||||
try {
|
||||
await record.lifecycle.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateFacets(
|
||||
records: readonly FacetShape[],
|
||||
externalServices: ReadonlyMap<string, { readonly mode: ServiceMode }>,
|
||||
): string[] {
|
||||
const providers = new Map<
|
||||
string,
|
||||
{ readonly facetId: string | undefined; readonly mode: ServiceMode | undefined }
|
||||
>();
|
||||
for (const [serviceId, { mode }] of externalServices) providers.set(serviceId, { facetId: undefined, mode });
|
||||
for (const record of records) {
|
||||
for (const provision of record.provides) {
|
||||
const existing = providers.get(provision.serviceId);
|
||||
if (existing !== undefined) {
|
||||
if (existing.mode !== undefined && existing.mode !== provision.mode) {
|
||||
throw new Error(`Service ${provision.serviceId} is provided as both singleton and keyed`);
|
||||
}
|
||||
if (existing.facetId === undefined) {
|
||||
throw new Error(`Service ${provision.serviceId} is provided by both the host and ${record.facetId}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Service ${provision.serviceId} is provided by both ${existing.facetId} and ${record.facetId}`,
|
||||
);
|
||||
}
|
||||
providers.set(provision.serviceId, { facetId: record.facetId, mode: provision.mode });
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies = new Map(records.map((record) => [record.facetId, new Set<string>()]));
|
||||
const dependents = new Map(records.map((record) => [record.facetId, new Set<string>()]));
|
||||
for (const record of records) {
|
||||
for (const requirement of record.requires) {
|
||||
const provider = providers.get(requirement.serviceId);
|
||||
if (provider === undefined) {
|
||||
throw new Error(
|
||||
`Facet ${record.facetId} requires local/${requirement.serviceId}/${requirement.mode}, but no facet provides it`,
|
||||
);
|
||||
}
|
||||
if (provider.mode !== undefined && provider.mode !== requirement.mode) {
|
||||
throw new Error(
|
||||
`Facet ${record.facetId} requires ${requirement.serviceId} as ${requirement.mode}, but ${provider.facetId ?? "the host"} provides it as ${provider.mode}`,
|
||||
);
|
||||
}
|
||||
if (provider.facetId === undefined || provider.facetId === record.facetId) continue;
|
||||
dependencies.get(record.facetId)!.add(provider.facetId);
|
||||
dependents.get(provider.facetId)!.add(record.facetId);
|
||||
}
|
||||
}
|
||||
return topologicalOrder(records, dependencies, dependents);
|
||||
}
|
||||
|
||||
function topologicalOrder(
|
||||
records: readonly FacetShape[],
|
||||
dependencies: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
dependents: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): string[] {
|
||||
const remaining = new Map([...dependencies].map(([id, values]) => [id, values.size]));
|
||||
const ready = records.map((record) => record.facetId).filter((id) => remaining.get(id) === 0);
|
||||
const order: string[] = [];
|
||||
while (ready.length > 0) {
|
||||
const id = ready.shift()!;
|
||||
order.push(id);
|
||||
for (const dependent of dependents.get(id) ?? []) {
|
||||
const count = remaining.get(dependent)! - 1;
|
||||
remaining.set(dependent, count);
|
||||
if (count === 0) ready.push(dependent);
|
||||
}
|
||||
}
|
||||
if (order.length !== records.length) {
|
||||
const cycle = records.map((record) => record.facetId).filter((id) => remaining.get(id)! > 0);
|
||||
throw new Error(`Facet dependency cycle: ${cycle.join(", ")}`);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function recordServiceReference(
|
||||
target: FacetServiceReference[],
|
||||
service: { readonly id: string },
|
||||
mode: ServiceMode,
|
||||
): void {
|
||||
if (target.some((reference) => reference.serviceId === service.id && reference.mode === mode)) return;
|
||||
target.push({ serviceId: service.id, service: service as Service<unknown>, mode });
|
||||
}
|
||||
|
||||
function sameFacetShape(left: FacetShape, right: FacetShape): boolean {
|
||||
return sameReferences(left.requires, right.requires) && sameReferences(left.provides, right.provides);
|
||||
}
|
||||
|
||||
function sameReferences(left: readonly FacetServiceReference[], right: readonly FacetServiceReference[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((reference) =>
|
||||
right.some((other) => other.serviceId === reference.serviceId && other.mode === reference.mode),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
||||
}
|
||||
6
third_party/pi/packages/chord/src/facets/loader.ts
vendored
Normal file
6
third_party/pi/packages/chord/src/facets/loader.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { LoadedFacets } from "../types.ts";
|
||||
|
||||
export async function disposeLoadedFacets(loaded: readonly LoadedFacets[]): Promise<unknown[]> {
|
||||
const results = await Promise.allSettled(loaded.map((entry) => entry.dispose()));
|
||||
return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
}
|
||||
79
third_party/pi/packages/chord/src/index.ts
vendored
Normal file
79
third_party/pi/packages/chord/src/index.ts
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Chord is a standalone application-composition runtime for agentic applications.
|
||||
*/
|
||||
export {
|
||||
combineFacetLoaders,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
defineService,
|
||||
replicatedState,
|
||||
} from "./api.ts";
|
||||
export { isJsonValue } from "./json.ts";
|
||||
export {
|
||||
isRemoteServiceErrorCode,
|
||||
REMOTE_SERVICE_ERROR_CODES,
|
||||
RemoteServiceError,
|
||||
type RemoteServiceErrorCode,
|
||||
} from "./services/errors.ts";
|
||||
export {
|
||||
createRemoteServiceEndpoint,
|
||||
type RemoteServiceEndpoint,
|
||||
RemoteServiceProvider,
|
||||
type ServiceUpdatePublisher,
|
||||
} from "./services/provider.ts";
|
||||
export {
|
||||
createServiceStateDecoder,
|
||||
createServiceStateEncoder,
|
||||
type ServiceStateDecoder,
|
||||
type ServiceStateEncoder,
|
||||
} from "./services/state-codec.ts";
|
||||
export {
|
||||
createServiceCatalogueCall,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
decodeServiceControlCall,
|
||||
parseServiceCall,
|
||||
parseServiceCatalogue,
|
||||
parseServiceProviderUpdate,
|
||||
parseServiceSubscriptionSnapshot,
|
||||
parseWireServiceProviderUpdate,
|
||||
parseWireServiceSubscriptionSnapshot,
|
||||
type ServiceControlCall,
|
||||
type WireServiceInstanceSnapshot,
|
||||
type WireServiceMemberSnapshot,
|
||||
type WireServiceProviderUpdate,
|
||||
type WireServiceSubscriptionSnapshot,
|
||||
} from "./services/wire.ts";
|
||||
export type {
|
||||
Context,
|
||||
ContextKey,
|
||||
Facet,
|
||||
FacetEnvironment,
|
||||
FacetHost,
|
||||
FacetLoader,
|
||||
FacetOptions,
|
||||
JsonRepresentation,
|
||||
JsonValue,
|
||||
LoadedFacets,
|
||||
MutableReplicatedState,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceSource,
|
||||
RemoteServices,
|
||||
RemoteServiceTransport,
|
||||
ReplicatedState,
|
||||
ReplicatedStateDelivery,
|
||||
Service,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSpawner,
|
||||
ServiceSubscription,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "./types.ts";
|
||||
50
third_party/pi/packages/chord/src/json.ts
vendored
Normal file
50
third_party/pi/packages/chord/src/json.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { JsonValue } from "./types.ts";
|
||||
|
||||
/** Return whether a value is finite strict JSON with plain objects and no cycles. */
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
return check(value, new Set<object>(), 0);
|
||||
}
|
||||
|
||||
function check(value: unknown, ancestors: Set<object>, depth: number): boolean {
|
||||
if (depth > 512) return false;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) return false;
|
||||
if (ancestors.has(value)) return false;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (
|
||||
descriptor === undefined ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor) ||
|
||||
!check(descriptor.value, ancestors, depth + 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) return false;
|
||||
if (Reflect.ownKeys(value).some((key) => typeof key !== "string")) return false;
|
||||
if (ancestors.has(value)) return false;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) {
|
||||
if (!descriptor.enumerable || !("value" in descriptor) || !check(descriptor.value, ancestors, depth + 1)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
24
third_party/pi/packages/chord/src/node.ts
vendored
Normal file
24
third_party/pi/packages/chord/src/node.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
export type {
|
||||
FacetBundleArtifactLoaderOptions,
|
||||
FacetBundleExternalResolver,
|
||||
FacetBundleLoaderOptions,
|
||||
} from "./node/bundle-loader.ts";
|
||||
export {
|
||||
createFacetBundleArtifactLoader,
|
||||
createFacetBundleLoader,
|
||||
readFacetBundleArtifact,
|
||||
readFacetBundleManifest,
|
||||
} from "./node/bundle-loader.ts";
|
||||
export type {
|
||||
FacetBundleArtifact,
|
||||
FacetBundleEntry,
|
||||
FacetBundleManifest,
|
||||
FacetBundlePlugin,
|
||||
} from "./node/manifest.ts";
|
||||
export {
|
||||
FACET_BUNDLE_ARTIFACT_FORMAT,
|
||||
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
||||
FACET_BUNDLE_FORMAT,
|
||||
FACET_BUNDLE_FORMAT_VERSION,
|
||||
FACET_BUNDLE_MANIFEST_FILE,
|
||||
} from "./node/manifest.ts";
|
||||
416
third_party/pi/packages/chord/src/node/bundle-loader.ts
vendored
Normal file
416
third_party/pi/packages/chord/src/node/bundle-loader.ts
vendored
Normal file
@@ -0,0 +1,416 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire, isBuiltin } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { compileFunction } from "node:vm";
|
||||
import type { Facet, FacetLoader, LoadedFacets } from "../types.ts";
|
||||
import {
|
||||
FACET_BUNDLE_ARTIFACT_FORMAT,
|
||||
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
||||
FACET_BUNDLE_FORMAT,
|
||||
FACET_BUNDLE_FORMAT_VERSION,
|
||||
FACET_BUNDLE_MANIFEST_FILE,
|
||||
type FacetBundleArtifact,
|
||||
type FacetBundleEntry,
|
||||
type FacetBundleManifest,
|
||||
} from "./manifest.ts";
|
||||
|
||||
export type FacetBundleExternalResolver = (specifier: string) => string | URL | undefined;
|
||||
|
||||
export interface FacetBundleLoaderOptions {
|
||||
readonly manifestPath: string | URL;
|
||||
readonly entry: string;
|
||||
/** Verify the entry's SHA-256 integrity before evaluating it. Defaults to true. */
|
||||
readonly verifyIntegrity?: boolean;
|
||||
/** Resolve host-provided external imports when the bundle is outside the host's package tree. */
|
||||
readonly resolveExternal?: FacetBundleExternalResolver;
|
||||
}
|
||||
|
||||
export interface FacetBundleArtifactLoaderOptions {
|
||||
readonly artifact: unknown;
|
||||
/** Resolve host-provided external imports against the receiving application. */
|
||||
readonly resolveExternal?: FacetBundleExternalResolver;
|
||||
/** Parent directory for materialized module generations. Defaults to the operating system temp directory. */
|
||||
readonly temporaryDirectory?: string;
|
||||
}
|
||||
|
||||
interface CommonJsModule {
|
||||
exports: unknown;
|
||||
}
|
||||
|
||||
/** Read and validate a versioned facet bundle manifest. */
|
||||
export async function readFacetBundleManifest(path: string | URL): Promise<FacetBundleManifest> {
|
||||
const manifestPath = toFilePath(path);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`Could not read facet bundle manifest ${manifestPath}`, { cause: error });
|
||||
}
|
||||
return validateManifest(parsed, manifestPath);
|
||||
}
|
||||
|
||||
/** Read and verify one transportable entry from a facet bundle on disk. */
|
||||
export async function readFacetBundleArtifact(options: {
|
||||
readonly manifestPath: string | URL;
|
||||
readonly entry: string;
|
||||
}): Promise<FacetBundleArtifact> {
|
||||
if (options.entry.length === 0) throw new TypeError("Facet bundle entry name must not be empty");
|
||||
const manifestPath = toFilePath(options.manifestPath);
|
||||
const manifest = await readFacetBundleManifest(manifestPath);
|
||||
const entry = manifest.entries[options.entry];
|
||||
if (entry === undefined) throw new Error(`Facet bundle ${manifest.plugin.id} has no entry named ${options.entry}`);
|
||||
const modulePath = resolveBundleFile(manifestPath, entry.file, "entry");
|
||||
const source = await readFile(modulePath, "utf8");
|
||||
verifySource(source, entry);
|
||||
const sourceMapContents =
|
||||
entry.sourceMap === undefined
|
||||
? undefined
|
||||
: await readFile(resolveBundleFile(manifestPath, entry.sourceMap, "source map"), "utf8");
|
||||
return Object.freeze({
|
||||
format: FACET_BUNDLE_ARTIFACT_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
||||
plugin: manifest.plugin,
|
||||
entryName: options.entry,
|
||||
entry,
|
||||
source,
|
||||
...(sourceMapContents === undefined ? {} : { sourceMapContents }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Materialize a transported artifact and create a fresh VM-compiled CommonJS generation for each load. */
|
||||
export function createFacetBundleArtifactLoader(options: FacetBundleArtifactLoaderOptions): FacetLoader {
|
||||
const artifact = validateArtifact(options.artifact);
|
||||
const temporaryParent = resolve(options.temporaryDirectory ?? tmpdir());
|
||||
return {
|
||||
async load(): Promise<LoadedFacets> {
|
||||
await mkdir(temporaryParent, { recursive: true });
|
||||
const directory = await mkdtemp(join(temporaryParent, "chord-facet-"));
|
||||
try {
|
||||
await materializeArtifact(directory, artifact);
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: join(directory, FACET_BUNDLE_MANIFEST_FILE),
|
||||
entry: artifact.entryName,
|
||||
resolveExternal: options.resolveExternal,
|
||||
}).load();
|
||||
let disposed = false;
|
||||
return {
|
||||
get facets() {
|
||||
return loaded.facets;
|
||||
},
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
await loaded.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet bundle artifact");
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Facet bundle artifact loading and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a reusable loader for one opaque entry in a facet bundle manifest. */
|
||||
export function createFacetBundleLoader(options: FacetBundleLoaderOptions): FacetLoader {
|
||||
if (options.entry.length === 0) throw new TypeError("Facet bundle entry name must not be empty");
|
||||
const manifestPath = toFilePath(options.manifestPath);
|
||||
return {
|
||||
async load(): Promise<LoadedFacets> {
|
||||
const manifest = await readFacetBundleManifest(manifestPath);
|
||||
const entry = manifest.entries[options.entry];
|
||||
if (entry === undefined) {
|
||||
throw new Error(`Facet bundle ${manifest.plugin.id} has no entry named ${options.entry}`);
|
||||
}
|
||||
const modulePath = resolveBundleFile(manifestPath, entry.file, "entry");
|
||||
try {
|
||||
const source = await readFile(modulePath, "utf8");
|
||||
if (options.verifyIntegrity !== false) verifySource(source, entry);
|
||||
const exported = executeCommonJsModule(source, modulePath, entry.externalImports, options.resolveExternal);
|
||||
let facets = facetsFromModule(exported, manifest.plugin.id, options.entry);
|
||||
let disposed = false;
|
||||
return {
|
||||
get facets() {
|
||||
return facets;
|
||||
},
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
facets = Object.freeze([]);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? `: ${error.message}` : "";
|
||||
throw new Error(`Could not load facet bundle entry ${manifest.plugin.id}/${options.entry}${detail}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function executeCommonJsModule(
|
||||
source: string,
|
||||
modulePath: string,
|
||||
externalImports: readonly string[],
|
||||
resolver?: FacetBundleExternalResolver,
|
||||
): unknown {
|
||||
const requireFromModule = createRequire(modulePath);
|
||||
const externalTargets = new Map<string, string>();
|
||||
for (const specifier of externalImports) {
|
||||
if (!isBuiltin(specifier)) validatePackageSpecifier(specifier);
|
||||
externalTargets.set(specifier, toRequireSpecifier(resolveExternalTarget(specifier, resolver)));
|
||||
}
|
||||
const targetFor = (specifier: string): string => {
|
||||
const target = externalTargets.get(specifier);
|
||||
if (target === undefined) throw new Error(`Facet bundle required undeclared external import: ${specifier}`);
|
||||
return target;
|
||||
};
|
||||
const requireExternal = Object.assign((specifier: string): unknown => requireFromModule(targetFor(specifier)), {
|
||||
resolve: (specifier: string): string => requireFromModule.resolve(targetFor(specifier)),
|
||||
});
|
||||
const commonJsModule: CommonJsModule = { exports: Object.create(null) };
|
||||
const compiled = compileFunction(source, ["exports", "require", "module", "__filename", "__dirname"], {
|
||||
filename: modulePath,
|
||||
});
|
||||
Reflect.apply(compiled, commonJsModule.exports, [
|
||||
commonJsModule.exports,
|
||||
requireExternal,
|
||||
commonJsModule,
|
||||
modulePath,
|
||||
dirname(modulePath),
|
||||
]);
|
||||
return commonJsModule.exports;
|
||||
}
|
||||
|
||||
function toRequireSpecifier(target: string): string {
|
||||
if (isAbsolute(target) || isBuiltin(target)) return target;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(target);
|
||||
} catch {
|
||||
return target;
|
||||
}
|
||||
if (url.protocol === "file:") return fileURLToPath(url);
|
||||
if (url.protocol === "node:") return url.href;
|
||||
throw new Error(`Facet bundle external target must be a file, package, or built-in module: ${target}`);
|
||||
}
|
||||
|
||||
function toFilePath(path: string | URL): string {
|
||||
if (typeof path === "string") return resolve(path);
|
||||
if (path.protocol !== "file:") throw new TypeError(`Facet bundle manifest must be a file URL, not ${path.protocol}`);
|
||||
return fileURLToPath(path);
|
||||
}
|
||||
|
||||
function resolveBundleFile(manifestPath: string, file: string, label: string): string {
|
||||
if (file.length === 0 || isAbsolute(file) || basename(file) !== file || file === "." || file === "..") {
|
||||
throw new Error(`Facet bundle ${label} must be a filename relative to its manifest`);
|
||||
}
|
||||
return resolve(dirname(manifestPath), file);
|
||||
}
|
||||
|
||||
function verifySource(source: string, entry: FacetBundleEntry): void {
|
||||
const expected = parseIntegrity(entry.integrity);
|
||||
const actual = createHash("sha256").update(source, "utf8").digest("base64");
|
||||
if (actual !== expected) throw new Error(`Facet bundle integrity check failed for ${entry.file}`);
|
||||
}
|
||||
|
||||
function parseIntegrity(integrity: string): string {
|
||||
const prefix = "sha256-";
|
||||
if (!integrity.startsWith(prefix) || integrity.length === prefix.length) {
|
||||
throw new Error("Facet bundle entry has an invalid SHA-256 integrity value");
|
||||
}
|
||||
return integrity.slice(prefix.length);
|
||||
}
|
||||
|
||||
function facetsFromModule(imported: unknown, pluginId: string, entryName: string): readonly Facet[] {
|
||||
if (!isRecord(imported)) throw new Error(`Facet bundle entry ${pluginId}/${entryName} did not export a module`);
|
||||
const exported = imported.default;
|
||||
const candidates: readonly unknown[] = Array.isArray(exported) ? (exported as readonly unknown[]) : [exported];
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(`Facet bundle entry ${pluginId}/${entryName} exported no facets`);
|
||||
}
|
||||
const facets: Facet[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!isRecord(candidate) || typeof candidate.id !== "string" || candidate.id.length === 0) {
|
||||
throw new Error(`Facet bundle entry ${pluginId}/${entryName} has a facet with an invalid ID`);
|
||||
}
|
||||
if (typeof candidate.setup !== "function") {
|
||||
throw new Error(`Facet bundle entry ${pluginId}/${entryName} facet ${candidate.id} has no setup function`);
|
||||
}
|
||||
facets.push(candidate as unknown as Facet);
|
||||
}
|
||||
const ids = facets.map(({ id }) => id);
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
throw new Error(`Facet bundle entry ${pluginId}/${entryName} exports duplicate facet IDs`);
|
||||
}
|
||||
return Object.freeze(facets);
|
||||
}
|
||||
|
||||
function validateArtifact(value: unknown): FacetBundleArtifact {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.format !== FACET_BUNDLE_ARTIFACT_FORMAT ||
|
||||
value.formatVersion !== FACET_BUNDLE_ARTIFACT_FORMAT_VERSION ||
|
||||
typeof value.entryName !== "string" ||
|
||||
value.entryName.length === 0 ||
|
||||
typeof value.source !== "string"
|
||||
) {
|
||||
throw new Error("Invalid facet bundle artifact");
|
||||
}
|
||||
const manifest = validateManifest(
|
||||
{
|
||||
format: FACET_BUNDLE_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
|
||||
plugin: value.plugin,
|
||||
entries: { [value.entryName]: value.entry },
|
||||
},
|
||||
"facet bundle artifact",
|
||||
);
|
||||
const entry = manifest.entries[value.entryName]!;
|
||||
if (entry.sourceMap === undefined) {
|
||||
if (value.sourceMapContents !== undefined) {
|
||||
throw new Error("Facet bundle artifact has source map contents without a source map");
|
||||
}
|
||||
} else if (typeof value.sourceMapContents !== "string") {
|
||||
throw new Error("Facet bundle artifact is missing its source map contents");
|
||||
}
|
||||
verifySource(value.source, entry);
|
||||
return Object.freeze({
|
||||
format: FACET_BUNDLE_ARTIFACT_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
||||
plugin: manifest.plugin,
|
||||
entryName: value.entryName,
|
||||
entry,
|
||||
source: value.source,
|
||||
...(value.sourceMapContents === undefined ? {} : { sourceMapContents: value.sourceMapContents }),
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeArtifact(directory: string, artifact: FacetBundleArtifact): Promise<void> {
|
||||
const manifest: FacetBundleManifest = {
|
||||
format: FACET_BUNDLE_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
|
||||
plugin: artifact.plugin,
|
||||
entries: { [artifact.entryName]: artifact.entry },
|
||||
};
|
||||
await Promise.all([
|
||||
writeFile(join(directory, artifact.entry.file), artifact.source, "utf8"),
|
||||
writeFile(join(directory, FACET_BUNDLE_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
|
||||
...(artifact.entry.sourceMap === undefined
|
||||
? []
|
||||
: [writeFile(join(directory, artifact.entry.sourceMap), artifact.sourceMapContents!, "utf8")]),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveExternalTarget(specifier: string, resolver?: FacetBundleExternalResolver): string {
|
||||
const resolved = resolver?.(specifier);
|
||||
if (resolved !== undefined) return typeof resolved === "string" ? resolved : resolved.href;
|
||||
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
|
||||
if (specifier === "@earendil-works/chord") return new URL(`../index.${extension}`, import.meta.url).href;
|
||||
if (specifier === "@earendil-works/chord/context") {
|
||||
return new URL(`../context/index.${extension}`, import.meta.url).href;
|
||||
}
|
||||
if (specifier === "@earendil-works/chord/node") return new URL(`../node.${extension}`, import.meta.url).href;
|
||||
return import.meta.resolve(specifier);
|
||||
}
|
||||
|
||||
function validatePackageSpecifier(specifier: string): void {
|
||||
const segments = specifier.split("/");
|
||||
const packageParts = specifier.startsWith("@") ? 2 : 1;
|
||||
if (
|
||||
specifier.startsWith(".") ||
|
||||
specifier.startsWith("/") ||
|
||||
specifier.startsWith("#") ||
|
||||
specifier.includes(":") ||
|
||||
specifier.includes("\\") ||
|
||||
segments.length < packageParts ||
|
||||
segments.some((part) => part.length === 0 || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error(`Facet bundle artifact has an unsupported external import: ${specifier}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifest(value: unknown, path: string): FacetBundleManifest {
|
||||
if (!isRecord(value) || value.format !== FACET_BUNDLE_FORMAT) {
|
||||
throw new Error(`Invalid facet bundle manifest format in ${path}`);
|
||||
}
|
||||
if (value.formatVersion !== FACET_BUNDLE_FORMAT_VERSION) {
|
||||
throw new Error(`Unsupported facet bundle manifest version in ${path}: ${String(value.formatVersion)}`);
|
||||
}
|
||||
if (!isRecord(value.plugin) || typeof value.plugin.id !== "string" || value.plugin.id.length === 0) {
|
||||
throw new Error(`Facet bundle manifest has an invalid plugin identity in ${path}`);
|
||||
}
|
||||
if (
|
||||
value.plugin.version !== undefined &&
|
||||
(typeof value.plugin.version !== "string" || value.plugin.version.length === 0)
|
||||
) {
|
||||
throw new Error(`Facet bundle manifest has an invalid plugin version in ${path}`);
|
||||
}
|
||||
if (!isRecord(value.entries) || Object.keys(value.entries).length === 0) {
|
||||
throw new Error(`Facet bundle manifest has no entries in ${path}`);
|
||||
}
|
||||
const entries: Record<string, FacetBundleEntry> = {};
|
||||
for (const [name, candidate] of Object.entries(value.entries)) {
|
||||
if (name.length === 0 || !isRecord(candidate)) {
|
||||
throw new Error(`Facet bundle manifest has an invalid entry in ${path}`);
|
||||
}
|
||||
if (typeof candidate.file !== "string") throw new Error(`Facet bundle entry ${name} has no file`);
|
||||
resolveBundleFile(path, candidate.file, `entry ${name}`);
|
||||
if (typeof candidate.integrity !== "string") throw new Error(`Facet bundle entry ${name} has no integrity`);
|
||||
parseIntegrity(candidate.integrity);
|
||||
if (
|
||||
!Array.isArray(candidate.externalImports) ||
|
||||
candidate.externalImports.some((item) => typeof item !== "string")
|
||||
) {
|
||||
throw new Error(`Facet bundle entry ${name} has invalid external imports`);
|
||||
}
|
||||
const externalImports = Object.freeze([...(candidate.externalImports as readonly string[])]);
|
||||
if (new Set(externalImports).size !== externalImports.length) {
|
||||
throw new Error(`Facet bundle entry ${name} has duplicate external imports`);
|
||||
}
|
||||
if (candidate.sourceMap !== undefined) {
|
||||
if (typeof candidate.sourceMap !== "string")
|
||||
throw new Error(`Facet bundle entry ${name} has an invalid source map`);
|
||||
resolveBundleFile(path, candidate.sourceMap, `entry ${name} source map`);
|
||||
}
|
||||
entries[name] = Object.freeze({
|
||||
file: candidate.file,
|
||||
integrity: candidate.integrity,
|
||||
externalImports,
|
||||
...(candidate.sourceMap === undefined ? {} : { sourceMap: candidate.sourceMap }),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
format: FACET_BUNDLE_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
|
||||
plugin: Object.freeze({
|
||||
id: value.plugin.id,
|
||||
...(value.plugin.version === undefined ? {} : { version: value.plugin.version }),
|
||||
}),
|
||||
entries: Object.freeze(entries),
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
222
third_party/pi/packages/chord/src/node/bundle.ts
vendored
Normal file
222
third_party/pi/packages/chord/src/node/bundle.ts
vendored
Normal file
@@ -0,0 +1,222 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
||||
import { type BuildOptions, build, type Message } from "esbuild";
|
||||
import {
|
||||
FACET_BUNDLE_FORMAT,
|
||||
FACET_BUNDLE_FORMAT_VERSION,
|
||||
FACET_BUNDLE_MANIFEST_FILE,
|
||||
type FacetBundleEntry,
|
||||
type FacetBundleManifest,
|
||||
} from "./manifest.ts";
|
||||
|
||||
export type FacetBundlePlatform = "node" | "browser" | "neutral";
|
||||
|
||||
export interface BundleFacetsOptions {
|
||||
readonly plugin: {
|
||||
readonly id: string;
|
||||
readonly version?: string;
|
||||
};
|
||||
/** Opaque application-selected entry names mapped to TypeScript or JavaScript source files. */
|
||||
readonly entries: Readonly<Record<string, string>>;
|
||||
readonly outdir: string;
|
||||
readonly workingDirectory?: string;
|
||||
/** Additional package imports intentionally left for the loading application to resolve. */
|
||||
readonly external?: readonly string[];
|
||||
readonly sourceMap?: boolean;
|
||||
readonly minify?: boolean;
|
||||
readonly define?: Readonly<Record<string, string>>;
|
||||
readonly platform?: FacetBundlePlatform;
|
||||
readonly target?: string | readonly string[];
|
||||
}
|
||||
|
||||
export interface BundleFacetsResult {
|
||||
readonly manifest: FacetBundleManifest;
|
||||
readonly manifestPath: string;
|
||||
}
|
||||
|
||||
/** Bundle each opaque facet entry into an independent content-addressed CommonJS file. */
|
||||
export async function bundleFacets(options: BundleFacetsOptions): Promise<BundleFacetsResult> {
|
||||
validateOptions(options);
|
||||
const workingDirectory = resolve(options.workingDirectory ?? process.cwd());
|
||||
const outputDirectory = resolve(workingDirectory, options.outdir);
|
||||
const outputParent = dirname(outputDirectory);
|
||||
await mkdir(outputParent, { recursive: true });
|
||||
const temporaryDirectory = join(outputParent, `.${basename(outputDirectory)}.tmp-${randomUUID()}`);
|
||||
await mkdir(temporaryDirectory);
|
||||
try {
|
||||
const entries: Record<string, FacetBundleEntry> = {};
|
||||
for (const [entryName, source] of Object.entries(options.entries).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
entries[entryName] = await bundleEntry({
|
||||
entryName,
|
||||
source: resolve(workingDirectory, source),
|
||||
temporaryDirectory,
|
||||
workingDirectory,
|
||||
options,
|
||||
});
|
||||
}
|
||||
const manifest: FacetBundleManifest = Object.freeze({
|
||||
format: FACET_BUNDLE_FORMAT,
|
||||
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
|
||||
plugin: Object.freeze({
|
||||
id: options.plugin.id,
|
||||
...(options.plugin.version === undefined ? {} : { version: options.plugin.version }),
|
||||
}),
|
||||
entries: Object.freeze(entries),
|
||||
});
|
||||
await writeFile(join(temporaryDirectory, FACET_BUNDLE_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
await replaceDirectory(temporaryDirectory, outputDirectory);
|
||||
return Object.freeze({
|
||||
manifest,
|
||||
manifestPath: join(outputDirectory, FACET_BUNDLE_MANIFEST_FILE),
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bundleEntry(input: {
|
||||
readonly entryName: string;
|
||||
readonly source: string;
|
||||
readonly temporaryDirectory: string;
|
||||
readonly workingDirectory: string;
|
||||
readonly options: BundleFacetsOptions;
|
||||
}): Promise<FacetBundleEntry> {
|
||||
const entryPrefix = `facet-${shortHash(input.entryName)}`;
|
||||
const requestedPlatform = input.options.platform ?? "node";
|
||||
const buildOptions: BuildOptions = {
|
||||
absWorkingDir: input.workingDirectory,
|
||||
banner: { js: '"use strict";' },
|
||||
bundle: true,
|
||||
define: input.options.define,
|
||||
entryNames: `${entryPrefix}-[hash]`,
|
||||
entryPoints: [input.source],
|
||||
external: [...new Set(["@earendil-works/chord", "@earendil-works/chord/*", ...(input.options.external ?? [])])],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "silent",
|
||||
metafile: true,
|
||||
minify: input.options.minify ?? false,
|
||||
outdir: input.temporaryDirectory,
|
||||
outExtension: { ".js": ".cjs" },
|
||||
platform: requestedPlatform,
|
||||
sourcemap: input.options.sourceMap === true ? "external" : false,
|
||||
supported: { "dynamic-import": false },
|
||||
target:
|
||||
input.options.target === undefined
|
||||
? requestedPlatform === "node"
|
||||
? "node22.19"
|
||||
: "es2022"
|
||||
: typeof input.options.target === "string"
|
||||
? input.options.target
|
||||
: [...input.options.target],
|
||||
write: true,
|
||||
};
|
||||
let result: Awaited<ReturnType<typeof build>>;
|
||||
try {
|
||||
result = await build(buildOptions);
|
||||
} catch (error) {
|
||||
const diagnostics = esbuildMessages(error);
|
||||
const detail = diagnostics.length === 0 ? "" : `\n${diagnostics.join("\n")}`;
|
||||
throw new Error(`Could not bundle facet entry ${input.entryName}${detail}`, { cause: error });
|
||||
}
|
||||
const metafile = result.metafile;
|
||||
if (metafile === undefined) throw new Error(`Facet entry ${input.entryName} produced no build metadata`);
|
||||
const outputs = Object.entries(metafile.outputs).filter(
|
||||
([path, output]) => output.entryPoint !== undefined && extname(path) === ".cjs",
|
||||
);
|
||||
if (outputs.length !== 1)
|
||||
throw new Error(`Facet entry ${input.entryName} did not produce exactly one JavaScript file`);
|
||||
const [outputPath, metadata] = outputs[0]!;
|
||||
const absoluteOutputPath = resolve(input.workingDirectory, outputPath);
|
||||
const file = relative(input.temporaryDirectory, absoluteOutputPath);
|
||||
if (file.length === 0 || file.startsWith(`..${sep}`) || basename(file) !== file) {
|
||||
throw new Error(`Facet entry ${input.entryName} produced an invalid output path`);
|
||||
}
|
||||
const sourceMap = input.options.sourceMap === true ? `${file}.map` : undefined;
|
||||
const allowedOutputs = new Set([absoluteOutputPath]);
|
||||
if (sourceMap !== undefined) allowedOutputs.add(join(input.temporaryDirectory, sourceMap));
|
||||
const unexpectedOutputs = Object.keys(metafile.outputs)
|
||||
.map((path) => resolve(input.workingDirectory, path))
|
||||
.filter((path) => !allowedOutputs.has(path));
|
||||
if (unexpectedOutputs.length > 0) {
|
||||
throw new Error(`Facet entry ${input.entryName} produced files other than its JavaScript bundle and source map`);
|
||||
}
|
||||
const contents = await readFile(absoluteOutputPath);
|
||||
if (sourceMap !== undefined) await stat(join(input.temporaryDirectory, sourceMap));
|
||||
return Object.freeze({
|
||||
file,
|
||||
integrity: `sha256-${createHash("sha256").update(contents).digest("base64")}`,
|
||||
externalImports: Object.freeze(
|
||||
[...new Set(metadata.imports.filter(({ external }) => external).map(({ path }) => path))].sort(),
|
||||
),
|
||||
...(sourceMap === undefined ? {} : { sourceMap }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateOptions(options: BundleFacetsOptions): void {
|
||||
if (options.plugin.id.length === 0) throw new TypeError("Facet bundle plugin ID must not be empty");
|
||||
if (options.plugin.version !== undefined && options.plugin.version.length === 0) {
|
||||
throw new TypeError("Facet bundle plugin version must not be empty");
|
||||
}
|
||||
const entries = Object.entries(options.entries);
|
||||
if (entries.length === 0) throw new TypeError("Facet bundle must contain at least one entry");
|
||||
for (const [name, source] of entries) {
|
||||
if (name.length === 0) throw new TypeError("Facet bundle entry name must not be empty");
|
||||
if (source.length === 0) throw new TypeError(`Facet bundle entry ${name} must have a source path`);
|
||||
}
|
||||
for (const external of options.external ?? []) {
|
||||
if (external.length === 0) throw new TypeError("Facet bundle external import must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceDirectory(temporaryDirectory: string, outputDirectory: string): Promise<void> {
|
||||
const backupDirectory = `${outputDirectory}.old-${randomUUID()}`;
|
||||
let movedExisting = false;
|
||||
try {
|
||||
await rename(outputDirectory, backupDirectory);
|
||||
movedExisting = true;
|
||||
} catch (error) {
|
||||
if (!isMissingPath(error)) throw error;
|
||||
}
|
||||
try {
|
||||
await rename(temporaryDirectory, outputDirectory);
|
||||
} catch (error) {
|
||||
if (movedExisting) await rename(backupDirectory, outputDirectory);
|
||||
throw error;
|
||||
}
|
||||
if (movedExisting) await rm(backupDirectory, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function esbuildMessages(error: unknown): string[] {
|
||||
if (!isRecord(error) || !Array.isArray(error.errors)) return [];
|
||||
return (error.errors as readonly unknown[]).flatMap((candidate) =>
|
||||
isEsbuildMessage(candidate) ? [formatEsbuildMessage(candidate)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function formatEsbuildMessage(message: Message): string {
|
||||
const location = message.location;
|
||||
return location === null
|
||||
? message.text
|
||||
: `${location.file}:${location.line}:${location.column + 1}: ${message.text}`;
|
||||
}
|
||||
|
||||
function isEsbuildMessage(value: unknown): value is Message {
|
||||
return isRecord(value) && typeof value.text === "string" && (value.location === null || isRecord(value.location));
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return isRecord(error) && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
39
third_party/pi/packages/chord/src/node/manifest.ts
vendored
Normal file
39
third_party/pi/packages/chord/src/node/manifest.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
export const FACET_BUNDLE_FORMAT = "chord.facet-bundle";
|
||||
export const FACET_BUNDLE_FORMAT_VERSION = 2;
|
||||
export const FACET_BUNDLE_MANIFEST_FILE = "chord-facets.json";
|
||||
export const FACET_BUNDLE_ARTIFACT_FORMAT = "chord.facet-bundle-artifact";
|
||||
export const FACET_BUNDLE_ARTIFACT_FORMAT_VERSION = 2;
|
||||
|
||||
export interface FacetBundleEntry {
|
||||
/** Content-addressed CommonJS filename relative to the manifest. */
|
||||
readonly file: string;
|
||||
/** SHA-256 subresource-integrity value for the JavaScript file. */
|
||||
readonly integrity: string;
|
||||
/** Imports intentionally left for the loading application to resolve. */
|
||||
readonly externalImports: readonly string[];
|
||||
/** Source map filename relative to the manifest, when emitted. */
|
||||
readonly sourceMap?: string;
|
||||
}
|
||||
|
||||
export interface FacetBundleManifest {
|
||||
readonly format: typeof FACET_BUNDLE_FORMAT;
|
||||
readonly formatVersion: typeof FACET_BUNDLE_FORMAT_VERSION;
|
||||
readonly plugin: FacetBundlePlugin;
|
||||
readonly entries: Readonly<Record<string, FacetBundleEntry>>;
|
||||
}
|
||||
|
||||
export interface FacetBundlePlugin {
|
||||
readonly id: string;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
/** One self-contained manifest entry suitable for storage or transport to another Node host. */
|
||||
export interface FacetBundleArtifact {
|
||||
readonly format: typeof FACET_BUNDLE_ARTIFACT_FORMAT;
|
||||
readonly formatVersion: typeof FACET_BUNDLE_ARTIFACT_FORMAT_VERSION;
|
||||
readonly plugin: FacetBundlePlugin;
|
||||
readonly entryName: string;
|
||||
readonly entry: FacetBundleEntry;
|
||||
readonly source: string;
|
||||
readonly sourceMapContents?: string;
|
||||
}
|
||||
232
third_party/pi/packages/chord/src/node/package.ts
vendored
Normal file
232
third_party/pi/packages/chord/src/node/package.ts
vendored
Normal file
@@ -0,0 +1,232 @@
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { type BundleFacetsResult, bundleFacets } from "./bundle.ts";
|
||||
|
||||
export interface BundleFacetPackageOptions {
|
||||
/** Plugin package directory or its package.json path. */
|
||||
readonly packagePath: string;
|
||||
readonly outdir: string;
|
||||
/** Application conventions applied when the corresponding source file exists. */
|
||||
readonly defaultFacets?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface BundleFacetPackageResult extends BundleFacetsResult {
|
||||
readonly packageDirectory: string;
|
||||
readonly packageJsonPath: string;
|
||||
}
|
||||
|
||||
interface FacetPackageMetadata {
|
||||
readonly packageDirectory: string;
|
||||
readonly packageJsonPath: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly peerDependencies: readonly string[];
|
||||
readonly configuredFacets: Readonly<Record<string, string | false>>;
|
||||
readonly external: readonly string[];
|
||||
readonly sourceMap: boolean;
|
||||
}
|
||||
|
||||
/** Build a plugin package using package.json metadata and application-provided facet conventions. */
|
||||
export async function bundleFacetPackage(options: BundleFacetPackageOptions): Promise<BundleFacetPackageResult> {
|
||||
const metadata = await readFacetPackageMetadata(options.packagePath);
|
||||
const entries = await resolveFacetEntries(metadata, options.defaultFacets ?? {});
|
||||
const external = [...metadata.peerDependencies, ...metadata.external].flatMap((specifier) => [
|
||||
specifier,
|
||||
`${specifier}/*`,
|
||||
]);
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: metadata.name, version: metadata.version },
|
||||
entries,
|
||||
outdir: options.outdir,
|
||||
workingDirectory: metadata.packageDirectory,
|
||||
external,
|
||||
sourceMap: metadata.sourceMap,
|
||||
});
|
||||
return Object.freeze({
|
||||
...result,
|
||||
packageDirectory: metadata.packageDirectory,
|
||||
packageJsonPath: metadata.packageJsonPath,
|
||||
});
|
||||
}
|
||||
|
||||
async function readFacetPackageMetadata(packagePath: string): Promise<FacetPackageMetadata> {
|
||||
if (packagePath.length === 0) throw new TypeError("Facet package path must not be empty");
|
||||
const candidate = resolve(packagePath);
|
||||
let packageDirectory: string;
|
||||
let packageJsonPath: string;
|
||||
let candidateStats: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
candidateStats = await stat(candidate);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not access facet package ${candidate}`, { cause: error });
|
||||
}
|
||||
if (candidateStats.isDirectory()) {
|
||||
packageDirectory = await realpath(candidate);
|
||||
packageJsonPath = join(packageDirectory, "package.json");
|
||||
} else if (candidateStats.isFile() && basename(candidate) === "package.json") {
|
||||
packageJsonPath = await realpath(candidate);
|
||||
packageDirectory = dirname(packageJsonPath);
|
||||
} else {
|
||||
throw new Error(`Facet package path must name a directory or package.json: ${candidate}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`Could not read facet package metadata ${packageJsonPath}`, { cause: error });
|
||||
}
|
||||
if (!isRecord(parsed)) throw new Error(`Facet package metadata must be an object: ${packageJsonPath}`);
|
||||
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
||||
throw new Error(`Facet package must have a non-empty name: ${packageJsonPath}`);
|
||||
}
|
||||
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
||||
throw new Error(`Facet package must have a non-empty version: ${packageJsonPath}`);
|
||||
}
|
||||
const peerDependencies = parsePeerDependencies(parsed.peerDependencies, packageJsonPath);
|
||||
const chord = parseChordConfiguration(parsed.chord, packageJsonPath);
|
||||
return {
|
||||
packageDirectory,
|
||||
packageJsonPath,
|
||||
name: parsed.name,
|
||||
version: parsed.version,
|
||||
peerDependencies,
|
||||
configuredFacets: chord.facets,
|
||||
external: chord.external,
|
||||
sourceMap: chord.sourceMap,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePeerDependencies(value: unknown, packageJsonPath: string): readonly string[] {
|
||||
if (value === undefined) return [];
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
Object.entries(value).some(([name, version]) => name.length === 0 || typeof version !== "string")
|
||||
) {
|
||||
throw new Error(`Facet package has invalid peerDependencies: ${packageJsonPath}`);
|
||||
}
|
||||
return Object.freeze(Object.keys(value).sort());
|
||||
}
|
||||
|
||||
function parseChordConfiguration(
|
||||
value: unknown,
|
||||
packageJsonPath: string,
|
||||
): {
|
||||
readonly facets: Readonly<Record<string, string | false>>;
|
||||
readonly external: readonly string[];
|
||||
readonly sourceMap: boolean;
|
||||
} {
|
||||
if (value === undefined) return { facets: Object.freeze({}), external: Object.freeze([]), sourceMap: true };
|
||||
if (!isRecord(value)) throw new Error(`Facet package chord configuration must be an object: ${packageJsonPath}`);
|
||||
if (Object.keys(value).some((key) => key !== "facets" && key !== "external" && key !== "sourceMap")) {
|
||||
throw new Error(`Facet package chord configuration has an unknown field: ${packageJsonPath}`);
|
||||
}
|
||||
const facets: Record<string, string | false> = {};
|
||||
if (value.facets !== undefined) {
|
||||
if (!isRecord(value.facets)) {
|
||||
throw new Error(`Facet package chord.facets must be an object: ${packageJsonPath}`);
|
||||
}
|
||||
for (const [name, source] of Object.entries(value.facets)) {
|
||||
if (name.length === 0 || (typeof source !== "string" && source !== false) || source === "") {
|
||||
throw new Error(`Facet package has an invalid chord.facets entry: ${packageJsonPath}`);
|
||||
}
|
||||
facets[name] = source;
|
||||
}
|
||||
}
|
||||
let external: readonly string[] = [];
|
||||
if (value.external !== undefined) {
|
||||
if (
|
||||
!Array.isArray(value.external) ||
|
||||
value.external.some((specifier) => typeof specifier !== "string" || specifier.length === 0)
|
||||
) {
|
||||
throw new Error(`Facet package chord.external must contain non-empty strings: ${packageJsonPath}`);
|
||||
}
|
||||
external = Object.freeze([...new Set(value.external as readonly string[])].sort());
|
||||
}
|
||||
if (value.sourceMap !== undefined && typeof value.sourceMap !== "boolean") {
|
||||
throw new Error(`Facet package chord.sourceMap must be a boolean: ${packageJsonPath}`);
|
||||
}
|
||||
return {
|
||||
facets: Object.freeze(facets),
|
||||
external,
|
||||
sourceMap: value.sourceMap ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveFacetEntries(
|
||||
metadata: FacetPackageMetadata,
|
||||
defaultFacets: Readonly<Record<string, string>>,
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [name, source] of Object.entries(defaultFacets)) {
|
||||
validateFacetMapping(name, source, "default");
|
||||
const path = resolvePackageEntry(metadata.packageDirectory, source, name);
|
||||
try {
|
||||
const entryStats = await stat(path);
|
||||
if (!entryStats.isFile()) throw new Error(`Default facet entry ${name} is not a file: ${path}`);
|
||||
const canonicalPath = await realpath(path);
|
||||
validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name);
|
||||
entries[name] = canonicalPath;
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
for (const [name, source] of Object.entries(metadata.configuredFacets)) {
|
||||
if (source === false) {
|
||||
delete entries[name];
|
||||
continue;
|
||||
}
|
||||
validateFacetMapping(name, source, "configured");
|
||||
const path = resolvePackageEntry(metadata.packageDirectory, source, name);
|
||||
let entryStats: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
entryStats = await stat(path);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not access configured facet entry ${name}: ${path}`, { cause: error });
|
||||
}
|
||||
if (!entryStats.isFile()) throw new Error(`Configured facet entry ${name} is not a file: ${path}`);
|
||||
const canonicalPath = await realpath(path);
|
||||
validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name);
|
||||
entries[name] = canonicalPath;
|
||||
}
|
||||
if (Object.keys(entries).length === 0) {
|
||||
throw new Error(`Facet package ${metadata.name} has no configured or conventional facet entries`);
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
function validateFacetMapping(name: string, source: string, kind: string): void {
|
||||
if (name.length === 0) throw new Error(`Facet package ${kind} entry name must not be empty`);
|
||||
if (source.length === 0) throw new Error(`Facet package ${kind} entry ${name} must have a source path`);
|
||||
}
|
||||
|
||||
function resolvePackageEntry(packageDirectory: string, source: string, name: string): string {
|
||||
if (isAbsolute(source)) throw new Error(`Facet package entry ${name} must be relative to the package directory`);
|
||||
const path = resolve(packageDirectory, source);
|
||||
const relativePath = relative(packageDirectory, path);
|
||||
if (
|
||||
relativePath.length === 0 ||
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error(`Facet package entry ${name} escapes the package directory`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function validateCanonicalPackageEntry(packageDirectory: string, path: string, name: string): void {
|
||||
const relativePath = relative(packageDirectory, path);
|
||||
if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
||||
throw new Error(`Facet package entry ${name} resolves outside the package directory`);
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return isRecord(error) && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
660
third_party/pi/packages/chord/src/services/consumer.ts
vendored
Normal file
660
third_party/pi/packages/chord/src/services/consumer.ts
vendored
Normal file
@@ -0,0 +1,660 @@
|
||||
import { awaitWithContext, BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceTransport,
|
||||
Service,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
} from "../types.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { ServiceSlot } from "./handle.ts";
|
||||
import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
|
||||
import { ReplicatedStateReplica, serviceDeliveryContext } from "./state.ts";
|
||||
|
||||
type ErrorReporter = (error: Error) => void;
|
||||
type ServiceMemberKind = ServiceMemberSnapshot["kind"];
|
||||
|
||||
class MemberSlot {
|
||||
readonly #serviceId: string;
|
||||
readonly #member: string;
|
||||
readonly #invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>;
|
||||
readonly #state: ReplicatedStateReplica;
|
||||
readonly #isActive: () => boolean;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly value: unknown;
|
||||
#kind: ServiceMemberKind | undefined;
|
||||
#expectedKind: ServiceMemberKind | undefined;
|
||||
|
||||
constructor(
|
||||
serviceId: string,
|
||||
member: string,
|
||||
invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>,
|
||||
isActive: () => boolean,
|
||||
assertAccess: () => void,
|
||||
reportError: ErrorReporter,
|
||||
) {
|
||||
this.#serviceId = serviceId;
|
||||
this.#member = member;
|
||||
this.#invoke = invoke;
|
||||
this.#isActive = isActive;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#state = new ReplicatedStateReplica(reportError);
|
||||
const callable = (): void => {};
|
||||
this.value = new Proxy(callable, {
|
||||
apply: (_target, _thisArg, args) => this.#call(args),
|
||||
get: (_target, property) => {
|
||||
if (property === "value") {
|
||||
this.#assertAccess();
|
||||
this.#expect("state");
|
||||
return this.#state.value;
|
||||
}
|
||||
if (property === "subscribe") return this.#subscribe.bind(this);
|
||||
if (property === Symbol.toStringTag) return "RemoteServiceMember";
|
||||
if (property === "then") return undefined;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setDescription(kind: ServiceMemberKind): void {
|
||||
if (this.#kind !== undefined && this.#kind !== kind) {
|
||||
throw new Error(`Remote service member ${this.#serviceId}.${this.#member} changed kind`);
|
||||
}
|
||||
this.#kind = kind;
|
||||
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${this.#serviceId}.${this.#member} is ${kind}, not ${this.#expectedKind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
this.setDescription("state");
|
||||
this.#state.hydrate(sequence, ops, context);
|
||||
}
|
||||
|
||||
update(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
this.setDescription("state");
|
||||
this.#state.update(sequence, ops, context);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#state.clear();
|
||||
}
|
||||
|
||||
#subscribe(listener: (value: JsonValue, context: Context) => void): () => void {
|
||||
this.#assertAccess();
|
||||
if (typeof listener !== "function") {
|
||||
throw new TypeError("Replicated state subscription listener must be a function");
|
||||
}
|
||||
this.#expect("state");
|
||||
return this.#state.subscribe(listener);
|
||||
}
|
||||
|
||||
#expect(kind: ServiceMemberKind): void {
|
||||
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${this.#serviceId}.${this.#member} was used as two different kinds`,
|
||||
);
|
||||
}
|
||||
this.#expectedKind = kind;
|
||||
if (this.#kind !== undefined && this.#kind !== kind) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${this.#serviceId}.${this.#member} is ${this.#kind}, not ${kind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#call(args: unknown[]): Promise<JsonValue | undefined> {
|
||||
this.#assertAccess();
|
||||
this.#expect("method");
|
||||
if (!this.#isActive()) {
|
||||
return Promise.reject(
|
||||
new RemoteServiceError("service_stale_instance", `Remote service ${this.#serviceId} binding is closed`),
|
||||
);
|
||||
}
|
||||
const context = args.at(-1);
|
||||
if (!isContext(context)) {
|
||||
return Promise.reject(
|
||||
new RemoteServiceError(
|
||||
"service_invalid_value",
|
||||
`Remote service method ${this.#serviceId}.${this.#member} requires a trailing Context`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const businessArgs = args.slice(0, -1) as JsonValue[];
|
||||
return this.#invoke(businessArgs, context);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceFacade {
|
||||
readonly #serviceId: string;
|
||||
readonly #address: ServiceInstanceAddress | undefined;
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #reportError: ErrorReporter;
|
||||
readonly #slots = new Map<string, MemberSlot>();
|
||||
readonly #descriptions = new Map<string, ServiceMemberKind>();
|
||||
readonly #isActive: () => boolean;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly proxy: object;
|
||||
|
||||
constructor(
|
||||
serviceId: string,
|
||||
address: ServiceInstanceAddress | undefined,
|
||||
transport: RemoteServiceTransport,
|
||||
isActive: () => boolean,
|
||||
assertAccess: () => void,
|
||||
reportError: ErrorReporter,
|
||||
) {
|
||||
this.#serviceId = serviceId;
|
||||
this.#address = address;
|
||||
this.#transport = transport;
|
||||
this.#isActive = isActive;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#reportError = reportError;
|
||||
this.proxy = new Proxy(Object.create(null) as object, {
|
||||
get: (_target, property) => {
|
||||
if (typeof property !== "string") return undefined;
|
||||
return this.#slot(property).value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
install(snapshot: ServiceInstanceSnapshot, context: Context): void {
|
||||
if (!sameAddress(snapshot.instance, this.#address))
|
||||
throw new Error("Remote service snapshot has the wrong address");
|
||||
const members = validateMembers(snapshot.members);
|
||||
for (const name of this.#slots.keys()) {
|
||||
if (!members.has(name)) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_not_found",
|
||||
`Unknown remote service member ${this.#serviceId}.${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#descriptions.clear();
|
||||
for (const [name, member] of members) this.#descriptions.set(name, member.kind);
|
||||
for (const member of members.values()) {
|
||||
const slot = this.#slots.get(member.name);
|
||||
if (member.kind === "state") {
|
||||
(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.ops, context);
|
||||
} else {
|
||||
slot?.setDescription(member.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(member: string, sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (this.#descriptions.get(member) !== "state") {
|
||||
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
|
||||
}
|
||||
this.#slot(member).update(sequence, ops, context);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const slot of this.#slots.values()) slot.clear();
|
||||
}
|
||||
|
||||
#slot(member: string): MemberSlot {
|
||||
let slot = this.#slots.get(member);
|
||||
if (slot !== undefined) return slot;
|
||||
slot = new MemberSlot(
|
||||
this.#serviceId,
|
||||
member,
|
||||
(args, context) =>
|
||||
this.#transport.invoke(
|
||||
{
|
||||
serviceId: this.#serviceId,
|
||||
...(this.#address === undefined ? {} : { instance: this.#address }),
|
||||
member,
|
||||
args,
|
||||
},
|
||||
context,
|
||||
),
|
||||
this.#isActive,
|
||||
this.#assertAccess,
|
||||
this.#reportError,
|
||||
);
|
||||
const kind = this.#descriptions.get(member);
|
||||
if (kind !== undefined) slot.setDescription(kind);
|
||||
this.#slots.set(member, slot);
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
|
||||
interface SingletonBinding {
|
||||
facade: ServiceFacade;
|
||||
subscription?: ServiceSubscription;
|
||||
starting?: Promise<void>;
|
||||
active: boolean;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface KeyedInstance extends InstanceDirectoryEntry {
|
||||
readonly facade: ServiceFacade;
|
||||
}
|
||||
|
||||
class KeyedBinding<T> {
|
||||
readonly #service: Service<T>;
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #reportError: ErrorReporter;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #onEmpty: () => void;
|
||||
readonly #instances: InstanceDirectory<KeyedInstance>;
|
||||
#subscription: ServiceSubscription | undefined;
|
||||
#starting: Promise<void> | undefined;
|
||||
#closed = false;
|
||||
#bound: boolean;
|
||||
#revision = 0;
|
||||
|
||||
constructor(
|
||||
service: Service<T>,
|
||||
transport: RemoteServiceTransport,
|
||||
reportError: ErrorReporter,
|
||||
assertAccess: () => void,
|
||||
onEmpty: () => void,
|
||||
bound: boolean,
|
||||
) {
|
||||
this.#service = service;
|
||||
this.#transport = transport;
|
||||
this.#reportError = reportError;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#onEmpty = onEmpty;
|
||||
this.#bound = bound;
|
||||
this.#instances = new InstanceDirectory({ ready: false, onError: reportError });
|
||||
}
|
||||
|
||||
observe(handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
if (this.#closed) throw new Error("Remote keyed service binding is closed");
|
||||
let stopped = false;
|
||||
const stop = this.#instances.observe<T>((service, context) => {
|
||||
const slot = new ServiceSlot(this.#service.id, true);
|
||||
void slot.bind(service as object);
|
||||
return handler(
|
||||
slot.view<T>(() => {
|
||||
this.#assertAccess();
|
||||
if (stopped || context.abortSignal?.aborted) {
|
||||
throw new RemoteServiceError(
|
||||
"service_stale_instance",
|
||||
`Remote service ${this.#service.id} observation is closed`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
context,
|
||||
);
|
||||
});
|
||||
if (this.#bound && this.#starting === undefined) {
|
||||
const revision = this.#revision;
|
||||
const starting = this.#start(revision);
|
||||
this.#starting = starting;
|
||||
void starting.catch((error: unknown) => {
|
||||
if (!this.#closed && this.#revision === revision && this.#bound) this.#reportError(toError(error));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
stop();
|
||||
if (this.#instances.observerCount === 0) this.#onEmpty();
|
||||
};
|
||||
}
|
||||
|
||||
async rebind(bound: boolean, context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#bound = bound;
|
||||
const revision = ++this.#revision;
|
||||
await this.#reset(context, false);
|
||||
if (this.#closed || this.#revision !== revision || this.#bound !== bound) return;
|
||||
if (bound && this.#instances.observerCount > 0) {
|
||||
const starting = this.#start(revision);
|
||||
this.#starting = starting;
|
||||
await starting;
|
||||
}
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.#starting ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async close(context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#revision += 1;
|
||||
await this.#reset(context, true);
|
||||
this.#instances.dispose();
|
||||
}
|
||||
|
||||
async #reset(context: Context, waitForStarting: boolean): Promise<void> {
|
||||
this.#instances.reset();
|
||||
const starting = this.#starting;
|
||||
this.#starting = undefined;
|
||||
const subscription = this.#subscription;
|
||||
this.#subscription = undefined;
|
||||
await Promise.all([
|
||||
waitForStarting ? starting?.catch(() => {}) : undefined,
|
||||
subscription === undefined ? undefined : subscription.close(context),
|
||||
]);
|
||||
}
|
||||
|
||||
async #start(revision: number): Promise<void> {
|
||||
const subscription = await this.#transport.subscribe(
|
||||
this.#service.id,
|
||||
"keyed",
|
||||
(update, context) => {
|
||||
if (this.#revision === revision) this.#update(update, context);
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
if (this.#closed || !this.#bound || this.#revision !== revision) {
|
||||
await subscription.close(BACKGROUND_CONTEXT);
|
||||
return;
|
||||
}
|
||||
this.#subscription = subscription;
|
||||
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
|
||||
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
|
||||
}
|
||||
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, serviceDeliveryContext());
|
||||
subscription.activate();
|
||||
this.#instances.ready();
|
||||
}
|
||||
|
||||
#update(update: ServiceProviderUpdate, context: Context): void {
|
||||
if (this.#closed) return;
|
||||
try {
|
||||
switch (update.type) {
|
||||
case "unavailable":
|
||||
case "replaced":
|
||||
throw new Error("Keyed service received a singleton lifecycle update");
|
||||
case "spawned":
|
||||
this.#spawn(update.instance, context);
|
||||
break;
|
||||
case "closed": {
|
||||
const instance = this.#instances.get(update.instance.key);
|
||||
if (instance?.generation === update.instance.generation) this.#instances.remove(instance);
|
||||
break;
|
||||
}
|
||||
case "state": {
|
||||
if (update.instance === undefined) throw new Error("Keyed state update has no instance address");
|
||||
const instance = this.#instances.get(update.instance.key);
|
||||
if (instance?.generation !== update.instance.generation) return;
|
||||
instance.facade.update(update.member, update.sequence, update.ops, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
#spawn(snapshot: ServiceInstanceSnapshot, context: Context): void {
|
||||
const address = snapshot.instance;
|
||||
if (address === undefined) throw new Error("Keyed service instance snapshot has no address");
|
||||
let active = true;
|
||||
const facade = new ServiceFacade(
|
||||
this.#service.id,
|
||||
address,
|
||||
this.#transport,
|
||||
() => active && !this.#closed,
|
||||
this.#assertAccess,
|
||||
this.#reportError,
|
||||
);
|
||||
facade.install(snapshot, context);
|
||||
this.#instances.replace({
|
||||
key: address.key,
|
||||
generation: address.generation,
|
||||
service: facade.proxy,
|
||||
facade,
|
||||
deactivate: () => {
|
||||
active = false;
|
||||
facade.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #allowlist = new Set<string>();
|
||||
readonly #reportError: ErrorReporter;
|
||||
readonly #modes = new Map<string, "singleton" | "keyed">();
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #singletons = new Map<string, SingletonBinding>();
|
||||
readonly #keyed = new Map<string, KeyedBinding<unknown>>();
|
||||
#bound: boolean;
|
||||
#readinessRevision = 0;
|
||||
#bindingTransition = Promise.resolve();
|
||||
#disposed = false;
|
||||
|
||||
constructor(options: RemoteServiceBindingOptions) {
|
||||
this.#transport = options.transport;
|
||||
const ids = options.services.map(({ id }) => id);
|
||||
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service binding has duplicate service IDs");
|
||||
for (const id of ids) this.#allowlist.add(id);
|
||||
this.#reportError = options.onError ?? (() => {});
|
||||
this.#assertAccess = options.assertAccess ?? (() => {});
|
||||
this.#bound = options.bound ?? true;
|
||||
}
|
||||
|
||||
use<T>(service: Service<T>): T {
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAvailable(service.id, "singleton");
|
||||
let binding = this.#singletons.get(service.id);
|
||||
if (binding !== undefined) return binding.facade.proxy as T;
|
||||
binding = { facade: undefined as unknown as ServiceFacade, active: true, revision: 0 };
|
||||
binding.facade = new ServiceFacade(
|
||||
service.id,
|
||||
undefined,
|
||||
this.#transport,
|
||||
() => binding!.active && !this.#disposed && this.#bound,
|
||||
() => this.#assertHandleAccess(),
|
||||
this.#reportError,
|
||||
);
|
||||
this.#singletons.set(service.id, binding);
|
||||
this.#readinessRevision += 1;
|
||||
if (this.#bound) {
|
||||
const revision = binding.revision;
|
||||
const starting = this.#startSingleton(service.id, binding, revision);
|
||||
binding.starting = starting;
|
||||
void starting.catch((error: unknown) => {
|
||||
if (binding.active && binding.revision === revision && !this.#disposed && this.#bound) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
return binding.facade.proxy as T;
|
||||
}
|
||||
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAvailable(service.id, "keyed");
|
||||
let binding = this.#keyed.get(service.id) as KeyedBinding<T> | undefined;
|
||||
if (binding === undefined) {
|
||||
binding = new KeyedBinding(
|
||||
service,
|
||||
this.#transport,
|
||||
this.#reportError,
|
||||
() => this.#assertHandleAccess(),
|
||||
() => {
|
||||
if (this.#keyed.get(service.id) !== binding) return;
|
||||
this.#keyed.delete(service.id);
|
||||
this.#readinessRevision += 1;
|
||||
void binding!.close(BACKGROUND_CONTEXT).catch(this.#reportError);
|
||||
},
|
||||
this.#bound,
|
||||
);
|
||||
this.#keyed.set(service.id, binding as KeyedBinding<unknown>);
|
||||
this.#readinessRevision += 1;
|
||||
}
|
||||
return binding.observe(handler);
|
||||
}
|
||||
|
||||
async ready(context: Context): Promise<void> {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
while (true) {
|
||||
const revision = this.#readinessRevision;
|
||||
const starts = [
|
||||
this.#bindingTransition,
|
||||
...[...this.#singletons.values()].flatMap((binding) =>
|
||||
binding.starting === undefined ? [] : [binding.starting],
|
||||
),
|
||||
...[...this.#keyed.values()].map((binding) => binding.ready()),
|
||||
];
|
||||
await awaitWithContext(
|
||||
Promise.all(starts).then(() => undefined),
|
||||
context,
|
||||
);
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
if (revision === this.#readinessRevision) return;
|
||||
}
|
||||
}
|
||||
|
||||
async rebind(bound: boolean, context: Context): Promise<void> {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
this.#bound = bound;
|
||||
this.#readinessRevision += 1;
|
||||
const transitions: Promise<void>[] = [];
|
||||
for (const [serviceId, binding] of this.#singletons) {
|
||||
binding.revision += 1;
|
||||
binding.facade.clear();
|
||||
const subscription = binding.subscription;
|
||||
delete binding.subscription;
|
||||
const revision = binding.revision;
|
||||
const starting = (async () => {
|
||||
await subscription?.close(context);
|
||||
if (bound) await this.#startSingleton(serviceId, binding, revision);
|
||||
})();
|
||||
binding.starting = starting;
|
||||
transitions.push(starting);
|
||||
}
|
||||
for (const binding of this.#keyed.values()) transitions.push(binding.rebind(bound, context));
|
||||
const completion = (async () => {
|
||||
const results = await Promise.allSettled(transitions);
|
||||
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (errors.length > 0)
|
||||
throw new AggregateError(
|
||||
errors.map(({ reason }) => reason),
|
||||
"Failed to rebind services",
|
||||
);
|
||||
})();
|
||||
this.#bindingTransition = completion;
|
||||
await completion;
|
||||
}
|
||||
|
||||
async dispose(context: Context): Promise<void> {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
const closes: Promise<void>[] = [];
|
||||
for (const binding of this.#singletons.values()) {
|
||||
binding.active = false;
|
||||
binding.facade.clear();
|
||||
if (binding.starting) closes.push(binding.starting.catch(() => {}));
|
||||
if (binding.subscription) closes.push(Promise.resolve(binding.subscription.close(context)));
|
||||
}
|
||||
for (const binding of this.#keyed.values()) closes.push(binding.close(context));
|
||||
this.#singletons.clear();
|
||||
this.#keyed.clear();
|
||||
const results = await Promise.allSettled(closes);
|
||||
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (errors.length > 0)
|
||||
throw new AggregateError(
|
||||
errors.map(({ reason }) => reason),
|
||||
"Failed to dispose services",
|
||||
);
|
||||
}
|
||||
|
||||
async #startSingleton(serviceId: string, binding: SingletonBinding, revision: number): Promise<void> {
|
||||
const subscription = await this.#transport.subscribe(
|
||||
serviceId,
|
||||
"singleton",
|
||||
(update, context) => {
|
||||
if (!binding.active || binding.revision !== revision) return;
|
||||
try {
|
||||
if (update.type === "unavailable") {
|
||||
binding.facade.clear();
|
||||
} else if (update.type === "replaced") {
|
||||
if (update.snapshot.instance !== undefined) {
|
||||
throw new Error("Singleton replacement has an instance address");
|
||||
}
|
||||
binding.facade.install(update.snapshot, context);
|
||||
} else if (update.type === "state" && update.instance === undefined) {
|
||||
binding.facade.update(update.member, update.sequence, update.ops, context);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
if (!binding.active || this.#disposed || !this.#bound || binding.revision !== revision) {
|
||||
await subscription.close(BACKGROUND_CONTEXT);
|
||||
return;
|
||||
}
|
||||
binding.subscription = subscription;
|
||||
const snapshot = subscription.snapshot;
|
||||
if (snapshot.mode !== "singleton" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {
|
||||
throw new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);
|
||||
}
|
||||
binding.facade.install(snapshot.instances[0]!, serviceDeliveryContext());
|
||||
subscription.activate();
|
||||
}
|
||||
|
||||
#assertHandleAccess(): void {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
this.#assertAccess();
|
||||
}
|
||||
|
||||
#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {
|
||||
if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
|
||||
}
|
||||
|
||||
#assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
if (!this.#allowlist.has(serviceId)) {
|
||||
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
|
||||
}
|
||||
const existing = this.#modes.get(serviceId);
|
||||
if (existing !== undefined && existing !== mode) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${serviceId} is already used as ${existing}`,
|
||||
);
|
||||
}
|
||||
this.#modes.set(serviceId, mode);
|
||||
}
|
||||
}
|
||||
|
||||
function validateMembers(members: readonly ServiceMemberSnapshot[]): ReadonlyMap<string, ServiceMemberSnapshot> {
|
||||
const result = new Map<string, ServiceMemberSnapshot>();
|
||||
for (const member of members) {
|
||||
if (member.name.length === 0 || result.has(member.name)) {
|
||||
throw new Error("Remote service has invalid member descriptions");
|
||||
}
|
||||
result.set(member.name, member);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress | undefined): boolean {
|
||||
if (left === undefined || right === undefined) return left === right;
|
||||
return left.key === right.key && left.generation === right.generation;
|
||||
}
|
||||
|
||||
function isContext(value: unknown): value is Context {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<Context>;
|
||||
return typeof candidate.value === "function" && typeof candidate.toString === "function";
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
26
third_party/pi/packages/chord/src/services/errors.ts
vendored
Normal file
26
third_party/pi/packages/chord/src/services/errors.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export const REMOTE_SERVICE_ERROR_CODES = [
|
||||
"service_not_allowed",
|
||||
"service_not_found",
|
||||
"service_mode_mismatch",
|
||||
"service_member_not_found",
|
||||
"service_member_mismatch",
|
||||
"service_instance_not_found",
|
||||
"service_stale_instance",
|
||||
"service_invalid_value",
|
||||
] as const;
|
||||
|
||||
export type RemoteServiceErrorCode = (typeof REMOTE_SERVICE_ERROR_CODES)[number];
|
||||
|
||||
export function isRemoteServiceErrorCode(value: unknown): value is RemoteServiceErrorCode {
|
||||
return typeof value === "string" && REMOTE_SERVICE_ERROR_CODES.includes(value as RemoteServiceErrorCode);
|
||||
}
|
||||
|
||||
export class RemoteServiceError extends Error {
|
||||
readonly code: RemoteServiceErrorCode;
|
||||
|
||||
constructor(code: RemoteServiceErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "RemoteServiceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
Normal file
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
interface ResolvedValue {
|
||||
readonly value: unknown;
|
||||
readonly receiver: object;
|
||||
}
|
||||
|
||||
type ValueResolver = () => ResolvedValue;
|
||||
|
||||
/** Host-owned mutable target with consumer-owned guarded views. */
|
||||
export class ServiceSlot {
|
||||
readonly #serviceId: string;
|
||||
readonly #wrapObjects: boolean;
|
||||
#implementation: object | undefined;
|
||||
|
||||
constructor(serviceId: string, wrapObjects: boolean) {
|
||||
this.#serviceId = serviceId;
|
||||
this.#wrapObjects = wrapObjects;
|
||||
}
|
||||
|
||||
view<T>(assertAccess: () => void): T {
|
||||
return new ServiceView(this, assertAccess, this.#wrapObjects).proxy as T;
|
||||
}
|
||||
|
||||
bind(implementation: object): void {
|
||||
this.#implementation = implementation;
|
||||
}
|
||||
|
||||
unbind(): void {
|
||||
this.#implementation = undefined;
|
||||
}
|
||||
|
||||
resolve(property: PropertyKey, assertAccess: () => void): ResolvedValue {
|
||||
assertAccess();
|
||||
const implementation = this.#implementation;
|
||||
if (implementation === undefined) throw new Error(`Service ${this.#serviceId} is disconnected`);
|
||||
return {
|
||||
value: Reflect.get(implementation, property, implementation),
|
||||
receiver: implementation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceView {
|
||||
readonly #slot: ServiceSlot;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #wrapObjects: boolean;
|
||||
readonly #members = new Map<PropertyKey, ValueView>();
|
||||
readonly proxy: object;
|
||||
|
||||
constructor(slot: ServiceSlot, assertAccess: () => void, wrapObjects: boolean) {
|
||||
this.#slot = slot;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#wrapObjects = wrapObjects;
|
||||
this.proxy = new Proxy(Object.create(null) as object, {
|
||||
get: (_target, property) => this.#getMember(property),
|
||||
});
|
||||
}
|
||||
|
||||
#getMember(property: PropertyKey): unknown {
|
||||
const resolve = (): ResolvedValue => this.#slot.resolve(property, this.#assertAccess);
|
||||
const current = resolve().value;
|
||||
if (!isObject(current) || (typeof current !== "function" && !this.#wrapObjects)) return current;
|
||||
let member = this.#members.get(property);
|
||||
if (member === undefined) {
|
||||
member = new ValueView(resolve, typeof current === "function");
|
||||
this.#members.set(property, member);
|
||||
}
|
||||
return member.proxy;
|
||||
}
|
||||
}
|
||||
|
||||
class ValueView {
|
||||
readonly #resolve: ValueResolver;
|
||||
readonly #children = new Map<PropertyKey, ValueView>();
|
||||
readonly proxy: object;
|
||||
|
||||
constructor(resolve: ValueResolver, callable: boolean) {
|
||||
this.#resolve = resolve;
|
||||
const target: object = callable ? () => undefined : Object.create(null);
|
||||
this.proxy = new Proxy(target, {
|
||||
apply: (_target, _thisArg, args) => this.#invoke(args),
|
||||
get: (_target, property) => this.#get(property),
|
||||
});
|
||||
}
|
||||
|
||||
#invoke(args: unknown[]): unknown {
|
||||
const { value, receiver } = this.#resolve();
|
||||
if (typeof value !== "function") throw new TypeError("Service member is not callable");
|
||||
return Reflect.apply(value, receiver, args);
|
||||
}
|
||||
|
||||
#get(property: PropertyKey): unknown {
|
||||
const resolve = (): ResolvedValue => {
|
||||
const parent = this.#resolve();
|
||||
if (!isObject(parent.value)) throw new TypeError("Service member does not have properties");
|
||||
return {
|
||||
value: Reflect.get(parent.value, property, parent.value),
|
||||
receiver: parent.value,
|
||||
};
|
||||
};
|
||||
const current = resolve().value;
|
||||
if (typeof current !== "function") return current;
|
||||
let child = this.#children.get(property);
|
||||
if (child === undefined) {
|
||||
child = new ValueView(resolve, true);
|
||||
this.#children.set(property, child);
|
||||
}
|
||||
return child.proxy;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === "object" && value !== null) || typeof value === "function";
|
||||
}
|
||||
147
third_party/pi/packages/chord/src/services/instances.ts
vendored
Normal file
147
third_party/pi/packages/chord/src/services/instances.ts
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
import { BACKGROUND_CONTEXT, withCancel } from "../context/index.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
export interface InstanceDirectoryEntry {
|
||||
readonly key: string;
|
||||
readonly generation: number;
|
||||
readonly service: object;
|
||||
deactivate(): void;
|
||||
}
|
||||
|
||||
interface Observer {
|
||||
readonly handler: (service: object, context: Context) => void | Promise<void>;
|
||||
readonly tasks: Map<InstanceDirectoryEntry, { readonly cancel: (reason?: unknown) => void }>;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Owns keyed instance lifetime and the cancellable tasks observing those instances. */
|
||||
export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
|
||||
readonly #entries = new Map<string, TEntry>();
|
||||
readonly #observers = new Set<Observer>();
|
||||
readonly #reportError: (error: Error) => void;
|
||||
#ready: boolean;
|
||||
#disposed = false;
|
||||
|
||||
constructor(options: { readonly ready: boolean; readonly onError: (error: Error) => void }) {
|
||||
this.#ready = options.ready;
|
||||
this.#reportError = options.onError;
|
||||
}
|
||||
|
||||
get observerCount(): number {
|
||||
return this.#observers.size;
|
||||
}
|
||||
|
||||
get(key: string): TEntry | undefined {
|
||||
return this.#entries.get(key);
|
||||
}
|
||||
|
||||
insert(entry: TEntry): void {
|
||||
this.#assertActive();
|
||||
if (this.#entries.has(entry.key)) {
|
||||
throw new Error(`Keyed service already has a live instance with key ${entry.key}`);
|
||||
}
|
||||
this.#entries.set(entry.key, entry);
|
||||
if (this.#ready) this.#startAll(entry);
|
||||
}
|
||||
|
||||
replace(entry: TEntry): void {
|
||||
this.#assertActive();
|
||||
const previous = this.#entries.get(entry.key);
|
||||
if (previous !== undefined) {
|
||||
if (previous.generation === entry.generation) {
|
||||
throw new Error("Keyed service repeated a live generation");
|
||||
}
|
||||
this.#remove(previous);
|
||||
}
|
||||
this.#entries.set(entry.key, entry);
|
||||
if (this.#ready) this.#startAll(entry);
|
||||
}
|
||||
|
||||
remove(entry: TEntry): void {
|
||||
if (this.#entries.get(entry.key) !== entry) return;
|
||||
this.#remove(entry);
|
||||
}
|
||||
|
||||
ready(): void {
|
||||
this.#assertActive();
|
||||
if (this.#ready) return;
|
||||
this.#ready = true;
|
||||
for (const entry of this.#entries.values()) this.#startAll(entry);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#ready = false;
|
||||
for (const entry of [...this.#entries.values()]) this.#remove(entry);
|
||||
}
|
||||
|
||||
observe<T>(handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
this.#assertActive();
|
||||
const observer: Observer = {
|
||||
handler: handler as Observer["handler"],
|
||||
tasks: new Map(),
|
||||
closed: false,
|
||||
};
|
||||
this.#observers.add(observer);
|
||||
if (this.#ready) {
|
||||
for (const entry of this.#entries.values()) this.#start(observer, entry);
|
||||
}
|
||||
return () => {
|
||||
if (observer.closed) return;
|
||||
observer.closed = true;
|
||||
for (const task of observer.tasks.values()) task.cancel();
|
||||
observer.tasks.clear();
|
||||
this.#observers.delete(observer);
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
for (const observer of this.#observers) {
|
||||
observer.closed = true;
|
||||
for (const task of observer.tasks.values()) task.cancel();
|
||||
observer.tasks.clear();
|
||||
}
|
||||
this.#observers.clear();
|
||||
for (const entry of [...this.#entries.values()]) {
|
||||
entry.deactivate();
|
||||
}
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
#remove(entry: TEntry): void {
|
||||
if (this.#entries.get(entry.key) !== entry) return;
|
||||
this.#entries.delete(entry.key);
|
||||
entry.deactivate();
|
||||
for (const observer of this.#observers) {
|
||||
observer.tasks.get(entry)?.cancel();
|
||||
observer.tasks.delete(entry);
|
||||
}
|
||||
}
|
||||
|
||||
#startAll(entry: TEntry): void {
|
||||
for (const observer of this.#observers) this.#start(observer, entry);
|
||||
}
|
||||
|
||||
#start(observer: Observer, entry: TEntry): void {
|
||||
if (observer.closed || observer.tasks.has(entry)) return;
|
||||
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
|
||||
observer.tasks.set(entry, { cancel });
|
||||
try {
|
||||
void Promise.resolve(observer.handler(entry.service, context)).catch((error: unknown) => {
|
||||
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
|
||||
});
|
||||
} catch (error) {
|
||||
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
#assertActive(): void {
|
||||
if (this.#disposed) throw new Error("Keyed service directory is disposed");
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
17
third_party/pi/packages/chord/src/services/loopback.ts
vendored
Normal file
17
third_party/pi/packages/chord/src/services/loopback.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { RemoteServiceTransport } from "../types.ts";
|
||||
import type { RemoteServiceProvider } from "./provider.ts";
|
||||
|
||||
/** Connects a provider to a binding without changing remote service semantics. */
|
||||
export function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
|
||||
return {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, listener);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
586
third_party/pi/packages/chord/src/services/provider.ts
vendored
Normal file
586
third_party/pi/packages/chord/src/services/provider.ts
vendored
Normal file
@@ -0,0 +1,586 @@
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
RemoteServiceContract,
|
||||
Service,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { serviceDeliveryContext } from "./state.ts";
|
||||
import { getReplicatedStateInternals, type ReplicatedStateInternals } from "./state-internals.ts";
|
||||
import { decodeServiceControlCall } from "./wire.ts";
|
||||
|
||||
type RemoteMethod = (...args: unknown[]) => unknown;
|
||||
|
||||
type InstanceMember =
|
||||
| { readonly kind: "method"; readonly method: RemoteMethod }
|
||||
| { readonly kind: "state"; readonly state: ReplicatedStateInternals };
|
||||
|
||||
type ServiceMemberKind = InstanceMember["kind"];
|
||||
|
||||
interface ClassifiedRemoteServiceImplementation {
|
||||
readonly implementation: object;
|
||||
readonly members: Map<string, InstanceMember>;
|
||||
}
|
||||
|
||||
interface ProviderInstance {
|
||||
readonly address?: ServiceInstanceAddress;
|
||||
readonly implementation: object;
|
||||
readonly members: ReadonlyMap<string, InstanceMember>;
|
||||
readonly removeMemberListeners: readonly (() => void)[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ProviderSubscriber {
|
||||
readonly listener: (update: ServiceProviderUpdate, context: Context) => void;
|
||||
readonly buffer: { readonly update: ServiceProviderUpdate; readonly context: Context }[];
|
||||
active: boolean;
|
||||
terminated: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
interface ServiceProviderDefinition {
|
||||
readonly service: { readonly id: string; readonly local?: boolean };
|
||||
readonly mode: ServiceMode;
|
||||
}
|
||||
|
||||
interface ServiceRegistration {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
singleton?: ProviderInstance;
|
||||
singletonShape?: ReadonlyMap<string, ServiceMemberKind>;
|
||||
readonly instances: Map<string, ProviderInstance>;
|
||||
readonly generations: Map<string, number>;
|
||||
readonly subscribers: Set<ProviderSubscriber>;
|
||||
}
|
||||
|
||||
export type ServiceUpdatePublisher = (
|
||||
subscriptionId: string,
|
||||
update: ServiceProviderUpdate,
|
||||
context: Context,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** Hosts one provider for one remote consumer and owns that consumer's subscriptions. */
|
||||
export interface RemoteServiceEndpoint {
|
||||
invoke(call: ServiceCall, publish: ServiceUpdatePublisher, context: Context): Promise<JsonValue | undefined>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class RemoteServiceProvider {
|
||||
readonly #catalogue: readonly ServiceCatalogueEntry[];
|
||||
readonly #registrations = new Map<string, ServiceRegistration>();
|
||||
#disposed = false;
|
||||
|
||||
constructor(entries: readonly (ServiceProviderDefinition | { readonly id: string })[]) {
|
||||
const definitions = entries.map(
|
||||
(entry): ServiceProviderDefinition => ("service" in entry ? entry : { service: entry, mode: "singleton" }),
|
||||
);
|
||||
for (const { service } of definitions) {
|
||||
if ("local" in service && service.local === true) {
|
||||
throw new TypeError(`Local service ${service.id} cannot be published remotely`);
|
||||
}
|
||||
}
|
||||
const ids = definitions.map(({ service }) => service.id);
|
||||
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service catalogue contains duplicate IDs");
|
||||
this.#catalogue = Object.freeze(
|
||||
definitions.map(({ service, mode }) => Object.freeze({ serviceId: service.id, mode })),
|
||||
);
|
||||
for (const { service, mode } of definitions) {
|
||||
this.#registrations.set(service.id, {
|
||||
serviceId: service.id,
|
||||
mode,
|
||||
instances: new Map(),
|
||||
generations: new Map(),
|
||||
subscribers: new Set(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get catalogue(): readonly ServiceCatalogueEntry[] {
|
||||
return this.#catalogue;
|
||||
}
|
||||
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
if (registration.singleton !== undefined) {
|
||||
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${service.id} already has a provider`);
|
||||
}
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
const shape = serviceMemberShape(classified.members);
|
||||
this.#assertSingletonShape(registration, shape);
|
||||
registration.singleton = this.#createInstance(registration, classified, undefined);
|
||||
registration.singletonShape = shape;
|
||||
}
|
||||
|
||||
/** Disconnect one singleton while preserving active subscriptions and remote facades. */
|
||||
withdraw<T>(service: Service<T>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
const previous = registration.singleton;
|
||||
if (previous === undefined) return;
|
||||
previous.active = false;
|
||||
for (const remove of previous.removeMemberListeners) remove();
|
||||
delete registration.singleton;
|
||||
this.#emit(registration, { type: "unavailable" });
|
||||
}
|
||||
|
||||
/** Check a singleton replacement without changing the active provider. */
|
||||
validateReplacement<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
this.#assertSingletonShape(registration, serviceMemberShape(classified.members));
|
||||
}
|
||||
|
||||
/** Replace one singleton without making its stable remote facade unavailable. */
|
||||
replace<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
const shape = serviceMemberShape(classified.members);
|
||||
this.#assertSingletonShape(registration, shape);
|
||||
const replacement = this.#createInstance(registration, classified, undefined);
|
||||
const previous = registration.singleton;
|
||||
if (previous !== undefined) {
|
||||
previous.active = false;
|
||||
for (const remove of previous.removeMemberListeners) remove();
|
||||
}
|
||||
registration.singleton = replacement;
|
||||
registration.singletonShape = shape;
|
||||
this.#emit(registration, { type: "replaced", snapshot: this.#snapshotInstance(replacement) });
|
||||
}
|
||||
|
||||
use<T>(service: Service<T>): T {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registrations.get(service.id);
|
||||
if (registration?.mode !== "singleton" || registration.singleton === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Remote service ${service.id} has no local provider`);
|
||||
}
|
||||
return registration.singleton.implementation as T;
|
||||
}
|
||||
|
||||
spawn<T>(service: Service<T>, key: string, implementation: NoInfer<RemoteServiceContract<T>>): () => void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
if (key.length === 0) throw new TypeError("Remote service instance key must not be empty");
|
||||
const registration = this.#registration(service.id, "keyed");
|
||||
if (registration.instances.has(key)) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${service.id} already has a live instance with key ${key}`,
|
||||
);
|
||||
}
|
||||
const generation = (registration.generations.get(key) ?? 0) + 1;
|
||||
registration.generations.set(key, generation);
|
||||
const address = { key, generation } satisfies ServiceInstanceAddress;
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
const instance = this.#createInstance(registration, classified, address);
|
||||
registration.instances.set(key, instance);
|
||||
this.#emit(registration, { type: "spawned", instance: this.#snapshotInstance(instance) });
|
||||
let closed = false;
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (registration.instances.get(key) !== instance) return;
|
||||
instance.active = false;
|
||||
for (const remove of instance.removeMemberListeners) remove();
|
||||
registration.instances.delete(key);
|
||||
this.#emit(registration, { type: "closed", instance: address });
|
||||
};
|
||||
}
|
||||
|
||||
async invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined> {
|
||||
this.#assertActive();
|
||||
this.#assertAllowed(call.serviceId);
|
||||
const registration = this.#registrations.get(call.serviceId);
|
||||
if (registration === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Unknown remote service ${call.serviceId}`);
|
||||
}
|
||||
const instance = this.#resolveInstance(registration, call.instance);
|
||||
const member = instance.members.get(call.member);
|
||||
if (member === undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_not_found",
|
||||
`Unknown remote service member ${call.serviceId}.${call.member}`,
|
||||
);
|
||||
}
|
||||
if (member.kind !== "method") {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${call.serviceId}.${call.member} is not a method`,
|
||||
);
|
||||
}
|
||||
const result: unknown = await Reflect.apply(member.method, instance.implementation, [...call.args, context]);
|
||||
return result as JsonValue | undefined;
|
||||
}
|
||||
|
||||
subscribe(
|
||||
serviceId: string,
|
||||
mode: ServiceMode,
|
||||
listener: (update: ServiceProviderUpdate, context: Context) => void,
|
||||
): ServiceSubscription {
|
||||
this.#assertActive();
|
||||
this.#assertAllowed(serviceId);
|
||||
const registration = this.#registration(serviceId, mode);
|
||||
if (registration.mode === "singleton" && registration.singleton === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Remote service ${serviceId} has no provider`);
|
||||
}
|
||||
const subscriber: ProviderSubscriber = {
|
||||
listener,
|
||||
buffer: [],
|
||||
active: false,
|
||||
terminated: false,
|
||||
closed: false,
|
||||
};
|
||||
this.#publishPending(registration);
|
||||
registration.subscribers.add(subscriber);
|
||||
const snapshot = this.#snapshot(registration);
|
||||
return {
|
||||
snapshot,
|
||||
activate: () => {
|
||||
if (subscriber.closed || subscriber.active) return;
|
||||
subscriber.active = true;
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
for (const entry of subscriber.buffer.splice(0)) {
|
||||
try {
|
||||
listener(entry.update, entry.context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (subscriber.terminated) subscriber.closed = true;
|
||||
}
|
||||
throwCollectedErrors(errors, "Failed to activate remote service subscription");
|
||||
},
|
||||
close: () => {
|
||||
if (subscriber.closed) return;
|
||||
subscriber.closed = true;
|
||||
subscriber.buffer.length = 0;
|
||||
registration.subscribers.delete(subscriber);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
const errors: unknown[] = [];
|
||||
for (const registration of this.#registrations.values()) {
|
||||
const singleton = registration.singleton;
|
||||
if (singleton !== undefined) {
|
||||
singleton.active = false;
|
||||
for (const remove of singleton.removeMemberListeners) remove();
|
||||
delete registration.singleton;
|
||||
try {
|
||||
this.#emit(registration, { type: "unavailable" });
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
for (const [key, instance] of [...registration.instances]) {
|
||||
instance.active = false;
|
||||
for (const remove of instance.removeMemberListeners) remove();
|
||||
registration.instances.delete(key);
|
||||
try {
|
||||
this.#emit(registration, { type: "closed", instance: instance.address! });
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
for (const subscriber of registration.subscribers) {
|
||||
if (subscriber.active) {
|
||||
subscriber.closed = true;
|
||||
subscriber.buffer.length = 0;
|
||||
} else {
|
||||
subscriber.terminated = true;
|
||||
}
|
||||
}
|
||||
registration.subscribers.clear();
|
||||
}
|
||||
this.#registrations.clear();
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose remote service provider");
|
||||
}
|
||||
|
||||
#registration(serviceId: string, mode: ServiceMode): ServiceRegistration {
|
||||
const registration = this.#registrations.get(serviceId);
|
||||
if (registration === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Unknown remote service ${serviceId}`);
|
||||
}
|
||||
if (registration.mode !== mode) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${serviceId} is ${registration.mode}, not ${mode}`,
|
||||
);
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
#createInstance(
|
||||
registration: ServiceRegistration,
|
||||
classified: ClassifiedRemoteServiceImplementation,
|
||||
address: ServiceInstanceAddress | undefined,
|
||||
): ProviderInstance {
|
||||
const removeMemberListeners: (() => void)[] = [];
|
||||
const instance: ProviderInstance = {
|
||||
...(address === undefined ? {} : { address }),
|
||||
implementation: classified.implementation,
|
||||
members: classified.members,
|
||||
removeMemberListeners,
|
||||
active: true,
|
||||
};
|
||||
for (const [name, member] of classified.members) {
|
||||
if (member.kind !== "state") continue;
|
||||
removeMemberListeners.push(
|
||||
member.state.subscribe((ops, sequence, context) => {
|
||||
if (!instance.active) return;
|
||||
this.#emit(
|
||||
registration,
|
||||
{
|
||||
type: "state",
|
||||
...(address === undefined ? {} : { instance: address }),
|
||||
member: name,
|
||||
sequence,
|
||||
ops,
|
||||
},
|
||||
context,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
#assertSingletonShape(registration: ServiceRegistration, replacement: ReadonlyMap<string, ServiceMemberKind>): void {
|
||||
const current = registration.singletonShape;
|
||||
if (current === undefined || sameServiceMemberShape(current, replacement)) return;
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service ${registration.serviceId} replacement must preserve its member shape`,
|
||||
);
|
||||
}
|
||||
|
||||
#resolveInstance(registration: ServiceRegistration, address: ServiceInstanceAddress | undefined): ProviderInstance {
|
||||
if (registration.mode === "singleton") {
|
||||
if (address !== undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${registration.serviceId} is singleton`,
|
||||
);
|
||||
}
|
||||
if (registration.singleton === undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_not_found",
|
||||
`Remote service ${registration.serviceId} has no provider`,
|
||||
);
|
||||
}
|
||||
return registration.singleton;
|
||||
}
|
||||
if (address === undefined) {
|
||||
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${registration.serviceId} is keyed`);
|
||||
}
|
||||
const instance = registration.instances.get(address.key);
|
||||
if (instance === undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_instance_not_found",
|
||||
`Remote service ${registration.serviceId} has no instance ${address.key}`,
|
||||
);
|
||||
}
|
||||
if (instance.address?.generation !== address.generation) {
|
||||
throw new RemoteServiceError(
|
||||
"service_stale_instance",
|
||||
`Remote service ${registration.serviceId} instance ${address.key} is stale`,
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
#publishPending(registration: ServiceRegistration): void {
|
||||
const context = serviceDeliveryContext();
|
||||
const instances =
|
||||
registration.mode === "singleton"
|
||||
? registration.singleton === undefined
|
||||
? []
|
||||
: [registration.singleton]
|
||||
: registration.instances.values();
|
||||
for (const instance of instances) {
|
||||
for (const member of instance.members.values()) {
|
||||
if (member.kind === "state") member.state.publish(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#snapshot(registration: ServiceRegistration): ServiceSubscriptionSnapshot {
|
||||
const instances =
|
||||
registration.mode === "singleton"
|
||||
? registration.singleton
|
||||
? [this.#snapshotInstance(registration.singleton)]
|
||||
: []
|
||||
: [...registration.instances.values()]
|
||||
.sort((left, right) => left.address!.key.localeCompare(right.address!.key))
|
||||
.map((instance) => this.#snapshotInstance(instance));
|
||||
return { serviceId: registration.serviceId, mode: registration.mode, instances };
|
||||
}
|
||||
|
||||
#snapshotInstance(instance: ProviderInstance): ServiceInstanceSnapshot {
|
||||
const members: ServiceMemberSnapshot[] = [];
|
||||
for (const [name, member] of instance.members) {
|
||||
if (member.kind === "method") {
|
||||
members.push({ name, kind: "method" });
|
||||
} else {
|
||||
members.push({
|
||||
name,
|
||||
kind: "state",
|
||||
sequence: member.state.sequence,
|
||||
ops: [["r", member.state.value as JsonValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(instance.address === undefined ? {} : { instance: instance.address }),
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
#emit(registration: ServiceRegistration, update: ServiceProviderUpdate, context?: Context): void {
|
||||
if (registration.subscribers.size === 0) return;
|
||||
const deliveryContext = context ?? serviceDeliveryContext();
|
||||
const errors: unknown[] = [];
|
||||
for (const subscriber of registration.subscribers) {
|
||||
if (subscriber.closed) continue;
|
||||
const entry = { update, context: deliveryContext };
|
||||
if (!subscriber.active) {
|
||||
subscriber.buffer.push(entry);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
subscriber.listener(entry.update, entry.context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
throwCollectedErrors(errors, `Failed to publish remote service ${registration.serviceId} update`);
|
||||
}
|
||||
|
||||
#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {
|
||||
if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
|
||||
}
|
||||
|
||||
#assertAllowed(serviceId: string): void {
|
||||
if (!this.#registrations.has(serviceId)) {
|
||||
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
|
||||
}
|
||||
}
|
||||
|
||||
#assertActive(): void {
|
||||
if (this.#disposed) throw new Error("Remote service provider is disposed");
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteServiceEndpoint(provider: RemoteServiceProvider): RemoteServiceEndpoint {
|
||||
const subscriptions = new Map<string, ServiceSubscription>();
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
async invoke(call, publish, context) {
|
||||
if (disposed) throw new Error("Remote service endpoint is disposed");
|
||||
const control = decodeServiceControlCall(call);
|
||||
if (control?.type === "catalogue") return provider.catalogue as unknown as JsonValue;
|
||||
if (control?.type === "subscribe") {
|
||||
if (subscriptions.has(control.subscriptionId)) {
|
||||
throw new Error("Service subscription ID is already active");
|
||||
}
|
||||
const subscription = provider.subscribe(control.serviceId, control.mode, (update, updateContext) => {
|
||||
void Promise.resolve(publish(control.subscriptionId, update, updateContext)).catch(() => {});
|
||||
});
|
||||
subscriptions.set(control.subscriptionId, subscription);
|
||||
subscription.activate();
|
||||
return subscription.snapshot as unknown as JsonValue;
|
||||
}
|
||||
if (control?.type === "unsubscribe") {
|
||||
const subscription = subscriptions.get(control.subscriptionId);
|
||||
if (subscription === undefined) throw new Error("Service subscription was not found");
|
||||
subscription.close();
|
||||
subscriptions.delete(control.subscriptionId);
|
||||
return undefined;
|
||||
}
|
||||
return provider.invoke(call, context);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const subscription of subscriptions.values()) subscription.close();
|
||||
subscriptions.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRemoteServiceImplementation(serviceId: string, implementation: unknown): void {
|
||||
void classifyRemoteServiceImplementation(serviceId, implementation);
|
||||
}
|
||||
|
||||
function classifyRemoteServiceImplementation(
|
||||
serviceId: string,
|
||||
implementation: unknown,
|
||||
): ClassifiedRemoteServiceImplementation {
|
||||
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
|
||||
throw new TypeError(`Remote service ${serviceId} implementation must be an object`);
|
||||
}
|
||||
const members = new Map<string, InstanceMember>();
|
||||
for (const name of Object.keys(implementation).sort()) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(implementation, name);
|
||||
if (descriptor === undefined || !("value" in descriptor)) {
|
||||
throw new TypeError(`Remote service member ${serviceId}.${name} must be a data property`);
|
||||
}
|
||||
if (typeof descriptor.value === "function") {
|
||||
members.set(name, { kind: "method", method: descriptor.value as RemoteMethod });
|
||||
continue;
|
||||
}
|
||||
const state = getReplicatedStateInternals(descriptor.value);
|
||||
if (state !== undefined) {
|
||||
members.set(name, { kind: "state", state });
|
||||
continue;
|
||||
}
|
||||
throw new TypeError(`Remote service member ${serviceId}.${name} is not remotely exposable`);
|
||||
}
|
||||
if (members.size === 0) throw new TypeError(`Remote service ${serviceId} has no members`);
|
||||
return { implementation, members };
|
||||
}
|
||||
|
||||
function throwCollectedErrors(errors: readonly unknown[], message: string): void {
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, message);
|
||||
}
|
||||
|
||||
function serviceMemberShape(members: ReadonlyMap<string, InstanceMember>): ReadonlyMap<string, ServiceMemberKind> {
|
||||
return new Map([...members].map(([name, member]) => [name, member.kind]));
|
||||
}
|
||||
|
||||
function sameServiceMemberShape(
|
||||
left: ReadonlyMap<string, ServiceMemberKind>,
|
||||
right: ReadonlyMap<string, ServiceMemberKind>,
|
||||
): boolean {
|
||||
return left.size === right.size && [...left].every(([name, kind]) => right.get(name) === kind);
|
||||
}
|
||||
158
third_party/pi/packages/chord/src/services/state-codec.ts
vendored
Normal file
158
third_party/pi/packages/chord/src/services/state-codec.ts
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
import { type Decoder, decoder, type Encoder, encoder } from "../delta/index.ts";
|
||||
import type {
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
import type { WireServiceProviderUpdate, WireServiceSubscriptionSnapshot } from "./wire.ts";
|
||||
|
||||
/** Stateful operation encoders for every replicated state in one service subscription. */
|
||||
export interface ServiceStateEncoder {
|
||||
encodeSnapshot(snapshot: ServiceSubscriptionSnapshot): WireServiceSubscriptionSnapshot;
|
||||
encodeUpdate(update: ServiceProviderUpdate): WireServiceProviderUpdate;
|
||||
}
|
||||
|
||||
/** Stateful operation decoders for every replicated state in one service subscription. */
|
||||
export interface ServiceStateDecoder {
|
||||
decodeSnapshot(snapshot: WireServiceSubscriptionSnapshot): ServiceSubscriptionSnapshot;
|
||||
decodeUpdate(update: WireServiceProviderUpdate): ServiceProviderUpdate;
|
||||
}
|
||||
|
||||
interface CodecEntry<C> {
|
||||
readonly instance: ServiceInstanceAddress | undefined;
|
||||
readonly codec: C;
|
||||
}
|
||||
|
||||
class StateCodecRegistry<C> {
|
||||
readonly #create: () => C;
|
||||
readonly #entries = new Map<string, CodecEntry<C>>();
|
||||
|
||||
constructor(create: () => C) {
|
||||
this.#create = create;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
add(instance: ServiceInstanceAddress | undefined, member: string): C {
|
||||
const key = stateKey(instance, member);
|
||||
if (this.#entries.has(key)) throw new Error(`Duplicate service state ${describeState(instance, member)}`);
|
||||
const codec = this.#create();
|
||||
this.#entries.set(key, { instance, codec });
|
||||
return codec;
|
||||
}
|
||||
|
||||
get(instance: ServiceInstanceAddress | undefined, member: string): C {
|
||||
const codec = this.#entries.get(stateKey(instance, member))?.codec;
|
||||
if (codec === undefined) throw new Error(`Unknown service state ${describeState(instance, member)}`);
|
||||
return codec;
|
||||
}
|
||||
|
||||
removeInstance(instance: ServiceInstanceAddress): void {
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (sameAddress(entry.instance, instance)) this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createServiceStateEncoder(): ServiceStateEncoder {
|
||||
const codecs = new StateCodecRegistry<Encoder>(encoder);
|
||||
return {
|
||||
encodeSnapshot(snapshot) {
|
||||
codecs.reset();
|
||||
return {
|
||||
...snapshot,
|
||||
instances: snapshot.instances.map((instance) => encodeInstance(instance, codecs)),
|
||||
};
|
||||
},
|
||||
encodeUpdate(update) {
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
return { ...update, ops: codecs.get(update.instance, update.member).encode(update.ops) };
|
||||
case "replaced":
|
||||
codecs.reset();
|
||||
return { ...update, snapshot: encodeInstance(update.snapshot, codecs) };
|
||||
case "spawned":
|
||||
return { ...update, instance: encodeInstance(update.instance, codecs) };
|
||||
case "unavailable":
|
||||
codecs.reset();
|
||||
return update;
|
||||
case "closed":
|
||||
codecs.removeInstance(update.instance);
|
||||
return update;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServiceStateDecoder(): ServiceStateDecoder {
|
||||
const codecs = new StateCodecRegistry<Decoder>(decoder);
|
||||
return {
|
||||
decodeSnapshot(snapshot) {
|
||||
codecs.reset();
|
||||
return {
|
||||
...snapshot,
|
||||
instances: snapshot.instances.map((instance) => decodeInstance(instance, codecs)),
|
||||
};
|
||||
},
|
||||
decodeUpdate(update) {
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
return { ...update, ops: codecs.get(update.instance, update.member).decode(update.ops) };
|
||||
case "replaced":
|
||||
codecs.reset();
|
||||
return { ...update, snapshot: decodeInstance(update.snapshot, codecs) };
|
||||
case "spawned":
|
||||
return { ...update, instance: decodeInstance(update.instance, codecs) };
|
||||
case "unavailable":
|
||||
codecs.reset();
|
||||
return update;
|
||||
case "closed":
|
||||
codecs.removeInstance(update.instance);
|
||||
return update;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodeInstance(
|
||||
instance: ServiceInstanceSnapshot,
|
||||
codecs: StateCodecRegistry<Encoder>,
|
||||
): WireServiceSubscriptionSnapshot["instances"][number] {
|
||||
return {
|
||||
...instance,
|
||||
members: instance.members.map((member) =>
|
||||
member.kind === "state"
|
||||
? { ...member, ops: codecs.add(instance.instance, member.name).encode(member.ops) }
|
||||
: member,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeInstance(
|
||||
instance: WireServiceSubscriptionSnapshot["instances"][number],
|
||||
codecs: StateCodecRegistry<Decoder>,
|
||||
): ServiceInstanceSnapshot {
|
||||
return {
|
||||
...instance,
|
||||
members: instance.members.map((member) =>
|
||||
member.kind === "state"
|
||||
? { ...member, ops: codecs.add(instance.instance, member.name).decode(member.ops) }
|
||||
: member,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function stateKey(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return JSON.stringify([instance?.key ?? null, instance?.generation ?? null, member]);
|
||||
}
|
||||
|
||||
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress): boolean {
|
||||
return left?.key === right.key && left.generation === right.generation;
|
||||
}
|
||||
|
||||
function describeState(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return instance === undefined ? member : `${instance.key}@${instance.generation}.${member}`;
|
||||
}
|
||||
20
third_party/pi/packages/chord/src/services/state-internals.ts
vendored
Normal file
20
third_party/pi/packages/chord/src/services/state-internals.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
export interface ReplicatedStateInternals {
|
||||
readonly sequence: number;
|
||||
readonly value: unknown;
|
||||
publish(context: Context): void;
|
||||
subscribe(listener: (ops: readonly Op[], sequence: number, context: Context) => void): () => void;
|
||||
}
|
||||
|
||||
const sources = new WeakMap<object, ReplicatedStateInternals>();
|
||||
|
||||
export function registerReplicatedStateInternals(value: object, internals: ReplicatedStateInternals): void {
|
||||
sources.set(value, internals);
|
||||
}
|
||||
|
||||
export function getReplicatedStateInternals(value: unknown): ReplicatedStateInternals | undefined {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
return sources.get(value);
|
||||
}
|
||||
139
third_party/pi/packages/chord/src/services/state.ts
vendored
Normal file
139
third_party/pi/packages/chord/src/services/state.ts
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { applyImmutable, isBase, type Op, type Tracker, track } from "../delta/index.ts";
|
||||
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState, ReplicatedStateDelivery } from "../types.ts";
|
||||
import { registerReplicatedStateInternals } from "./state-internals.ts";
|
||||
|
||||
export class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #sourceListeners = new Set<(ops: readonly Op[], sequence: number, context: Context) => void>();
|
||||
readonly #tracker: Tracker<T>;
|
||||
#publishedValue: T;
|
||||
#sequence = 0;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.#tracker = track(initial);
|
||||
this.#publishedValue = applyImmutable(undefined, this.#tracker.flush()) as unknown as T;
|
||||
const thisSource = this;
|
||||
registerReplicatedStateInternals(this, {
|
||||
get sequence() {
|
||||
return thisSource.#sequence;
|
||||
},
|
||||
get value() {
|
||||
return thisSource.#publishedValue;
|
||||
},
|
||||
publish: (context) => thisSource.publish(context),
|
||||
subscribe: (listener) => {
|
||||
thisSource.#sourceListeners.add(listener);
|
||||
return () => thisSource.#sourceListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#publishedValue;
|
||||
}
|
||||
|
||||
get state(): T {
|
||||
return this.#tracker.state;
|
||||
}
|
||||
|
||||
publish(context: Context): void {
|
||||
const ops = this.#tracker.flush();
|
||||
if (ops.length === 0) return;
|
||||
this.#sequence += 1;
|
||||
this.#publishedValue = applyImmutable(this.#publishedValue as unknown as JsonValue, ops) as unknown as T;
|
||||
for (const listener of [...this.#sourceListeners]) listener(ops, this.#sequence, context);
|
||||
const delivery = { kind: "update", sequence: this.#sequence } as const;
|
||||
for (const listener of [...this.#listeners]) listener(this.#publishedValue, context, delivery);
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
|
||||
const context = serviceDeliveryContext();
|
||||
this.publish(context);
|
||||
this.#listeners.add(listener);
|
||||
listener(this.#publishedValue, context, { kind: "hydrate", sequence: this.#sequence });
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/** A cold read-only state used by service consumers until a complete snapshot arrives. */
|
||||
export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements ReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #reportError: (error: Error) => void;
|
||||
#value: T | undefined;
|
||||
#sequence: number | undefined;
|
||||
|
||||
constructor(reportError: (error: Error) => void) {
|
||||
this.#reportError = reportError;
|
||||
}
|
||||
|
||||
get value(): T | undefined {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
if (this.#value !== undefined) {
|
||||
this.#deliver(listener, this.#value, serviceDeliveryContext(), {
|
||||
kind: "hydrate",
|
||||
sequence: this.#sequence!,
|
||||
});
|
||||
}
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch");
|
||||
const value = applyImmutable<T>(undefined, ops);
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#deliverAll(context, { kind: "hydrate", sequence });
|
||||
}
|
||||
|
||||
update(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (this.#sequence === undefined || this.#value === undefined) {
|
||||
throw new Error("Replicated state received an update before hydration");
|
||||
}
|
||||
if (sequence !== this.#sequence + 1) {
|
||||
this.clear();
|
||||
throw new Error("Replicated state update sequence has a gap");
|
||||
}
|
||||
const value = applyImmutable(this.#value, ops);
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#deliverAll(context, { kind: "update", sequence });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#value = undefined;
|
||||
this.#sequence = undefined;
|
||||
}
|
||||
|
||||
#deliverAll(context: Context, delivery: ReplicatedStateDelivery): void {
|
||||
if (this.#value === undefined) return;
|
||||
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context, delivery);
|
||||
}
|
||||
|
||||
#deliver(
|
||||
listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void,
|
||||
value: T,
|
||||
context: Context,
|
||||
delivery: ReplicatedStateDelivery,
|
||||
): void {
|
||||
try {
|
||||
listener(value, context, delivery);
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Context for synthetic service deliveries without a caller. */
|
||||
export function serviceDeliveryContext(): Context {
|
||||
// TODO: Add delivery-scoped cancellation or metadata if deliveries gain an owned lifecycle.
|
||||
return BACKGROUND_CONTEXT;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
Normal file
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
import { assertValidOp, assertValidWireOp, type WireOp } from "../delta/index.ts";
|
||||
import type {
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
|
||||
export type WireServiceMemberSnapshot =
|
||||
| { readonly name: string; readonly kind: "method" }
|
||||
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly ops: readonly WireOp[] };
|
||||
|
||||
export type WireServiceInstanceSnapshot = {
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly members: readonly WireServiceMemberSnapshot[];
|
||||
};
|
||||
|
||||
export type WireServiceSubscriptionSnapshot = {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
readonly instances: readonly WireServiceInstanceSnapshot[];
|
||||
};
|
||||
|
||||
export type WireServiceProviderUpdate =
|
||||
| {
|
||||
readonly type: "state";
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly member: string;
|
||||
readonly sequence: number;
|
||||
readonly ops: readonly WireOp[];
|
||||
}
|
||||
| { readonly type: "unavailable" }
|
||||
| { readonly type: "replaced"; readonly snapshot: WireServiceInstanceSnapshot }
|
||||
| { readonly type: "spawned"; readonly instance: WireServiceInstanceSnapshot }
|
||||
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
|
||||
|
||||
const SERVICE_CONTROL_ID = "$chord.service";
|
||||
const SERVICE_CATALOGUE_MEMBER = "catalogue";
|
||||
const SERVICE_SUBSCRIBE_MEMBER = "subscribe";
|
||||
const SERVICE_UNSUBSCRIBE_MEMBER = "unsubscribe";
|
||||
|
||||
export type ServiceControlCall =
|
||||
| { readonly type: "catalogue" }
|
||||
| {
|
||||
readonly type: "subscribe";
|
||||
readonly subscriptionId: string;
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
}
|
||||
| { readonly type: "unsubscribe"; readonly subscriptionId: string };
|
||||
|
||||
export function createServiceCatalogueCall(): ServiceCall {
|
||||
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_CATALOGUE_MEMBER, args: [] };
|
||||
}
|
||||
|
||||
export function createServiceSubscribeCall(subscriptionId: string, serviceId: string, mode: ServiceMode): ServiceCall {
|
||||
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_SUBSCRIBE_MEMBER, args: [subscriptionId, serviceId, mode] };
|
||||
}
|
||||
|
||||
export function createServiceUnsubscribeCall(subscriptionId: string): ServiceCall {
|
||||
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_UNSUBSCRIBE_MEMBER, args: [subscriptionId] };
|
||||
}
|
||||
|
||||
export function decodeServiceControlCall(call: ServiceCall): ServiceControlCall | undefined {
|
||||
if (call.serviceId !== SERVICE_CONTROL_ID || call.instance !== undefined) return undefined;
|
||||
if (call.member === SERVICE_CATALOGUE_MEMBER && call.args.length === 0) return { type: "catalogue" };
|
||||
if (
|
||||
call.member === SERVICE_SUBSCRIBE_MEMBER &&
|
||||
call.args.length === 3 &&
|
||||
isId(call.args[0]) &&
|
||||
isId(call.args[1]) &&
|
||||
(call.args[2] === "singleton" || call.args[2] === "keyed")
|
||||
) {
|
||||
return {
|
||||
type: "subscribe",
|
||||
subscriptionId: call.args[0],
|
||||
serviceId: call.args[1],
|
||||
mode: call.args[2],
|
||||
};
|
||||
}
|
||||
if (call.member === SERVICE_UNSUBSCRIBE_MEMBER && call.args.length === 1 && isId(call.args[0])) {
|
||||
return { type: "unsubscribe", subscriptionId: call.args[0] };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseServiceCall(value: unknown): ServiceCall {
|
||||
const call = record(value, "service call");
|
||||
assertKeys(call, ["serviceId", "member", "args"], ["instance"], "service call");
|
||||
if (!isId(call.serviceId) || !isId(call.member) || !Array.isArray(call.args)) {
|
||||
throw new TypeError("Invalid service call");
|
||||
}
|
||||
if (call.instance !== undefined) assertAddress(call.instance);
|
||||
return value as ServiceCall;
|
||||
}
|
||||
|
||||
export function parseServiceCatalogue(value: unknown): readonly ServiceCatalogueEntry[] {
|
||||
if (!Array.isArray(value)) throw new TypeError("Invalid service catalogue");
|
||||
const ids = new Set<string>();
|
||||
for (const candidate of value) {
|
||||
const entry = record(candidate, "service catalogue entry");
|
||||
assertKeys(entry, ["serviceId", "mode"], [], "service catalogue entry");
|
||||
if (!isId(entry.serviceId) || !isMode(entry.mode) || ids.has(entry.serviceId)) {
|
||||
throw new TypeError("Invalid service catalogue");
|
||||
}
|
||||
ids.add(entry.serviceId);
|
||||
}
|
||||
return value as unknown as readonly ServiceCatalogueEntry[];
|
||||
}
|
||||
|
||||
export function parseServiceSubscriptionSnapshot(value: unknown): ServiceSubscriptionSnapshot {
|
||||
assertSubscriptionSnapshot(value, assertValidOp);
|
||||
return value as ServiceSubscriptionSnapshot;
|
||||
}
|
||||
|
||||
export function parseWireServiceSubscriptionSnapshot(value: unknown): WireServiceSubscriptionSnapshot {
|
||||
assertSubscriptionSnapshot(value, assertValidWireOp);
|
||||
return value as WireServiceSubscriptionSnapshot;
|
||||
}
|
||||
|
||||
export function parseServiceProviderUpdate(value: unknown): ServiceProviderUpdate {
|
||||
assertProviderUpdate(value, assertValidOp);
|
||||
return value as ServiceProviderUpdate;
|
||||
}
|
||||
|
||||
export function parseWireServiceProviderUpdate(value: unknown): WireServiceProviderUpdate {
|
||||
assertProviderUpdate(value, assertValidWireOp);
|
||||
return value as WireServiceProviderUpdate;
|
||||
}
|
||||
|
||||
function assertSubscriptionSnapshot(value: unknown, assertOp: (value: unknown) => void): void {
|
||||
const snapshot = record(value, "service subscription snapshot");
|
||||
assertKeys(snapshot, ["serviceId", "mode", "instances"], [], "service subscription snapshot");
|
||||
if (!isId(snapshot.serviceId) || !isMode(snapshot.mode) || !Array.isArray(snapshot.instances)) {
|
||||
throw new TypeError("Invalid service subscription snapshot");
|
||||
}
|
||||
for (const instance of snapshot.instances) assertInstance(instance, assertOp);
|
||||
}
|
||||
|
||||
function assertProviderUpdate(value: unknown, assertOp: (value: unknown) => void): void {
|
||||
const update = record(value, "service provider update");
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
assertKeys(update, ["type", "member", "sequence", "ops"], ["instance"], "state update");
|
||||
if (!isId(update.member) || !isInteger(update.sequence, 1) || !Array.isArray(update.ops)) {
|
||||
throw new TypeError("Invalid service state update");
|
||||
}
|
||||
if (update.instance !== undefined) assertAddress(update.instance);
|
||||
for (const op of update.ops) assertOp(op);
|
||||
return;
|
||||
case "unavailable":
|
||||
assertKeys(update, ["type"], [], "unavailable update");
|
||||
return;
|
||||
case "replaced":
|
||||
assertKeys(update, ["type", "snapshot"], [], "replacement update");
|
||||
assertInstance(update.snapshot, assertOp);
|
||||
return;
|
||||
case "spawned":
|
||||
assertKeys(update, ["type", "instance"], [], "spawn update");
|
||||
assertInstance(update.instance, assertOp);
|
||||
return;
|
||||
case "closed":
|
||||
assertKeys(update, ["type", "instance"], [], "close update");
|
||||
assertAddress(update.instance);
|
||||
return;
|
||||
default:
|
||||
throw new TypeError("Invalid service provider update");
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstance(value: unknown, assertOp: (value: unknown) => void): void {
|
||||
const instance = record(value, "service instance snapshot");
|
||||
assertKeys(instance, ["members"], ["instance"], "service instance snapshot");
|
||||
if (instance.instance !== undefined) assertAddress(instance.instance);
|
||||
if (!Array.isArray(instance.members)) throw new TypeError("Invalid service instance snapshot");
|
||||
for (const candidate of instance.members) {
|
||||
const member = record(candidate, "service member snapshot");
|
||||
if (member.kind === "method") {
|
||||
assertKeys(member, ["name", "kind"], [], "service method snapshot");
|
||||
if (!isId(member.name)) throw new TypeError("Invalid service method snapshot");
|
||||
continue;
|
||||
}
|
||||
if (member.kind === "state") {
|
||||
assertKeys(member, ["name", "kind", "sequence", "ops"], [], "service state snapshot");
|
||||
if (!isId(member.name) || !isInteger(member.sequence, 0) || !Array.isArray(member.ops)) {
|
||||
throw new TypeError("Invalid service state snapshot");
|
||||
}
|
||||
for (const op of member.ops) assertOp(op);
|
||||
continue;
|
||||
}
|
||||
throw new TypeError("Invalid service member snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
function assertAddress(value: unknown): asserts value is ServiceInstanceAddress {
|
||||
const address = record(value, "service instance address");
|
||||
assertKeys(address, ["key", "generation"], [], "service instance address");
|
||||
if (!isId(address.key) || !isInteger(address.generation, 1)) {
|
||||
throw new TypeError("Invalid service instance address");
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, description: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError(`Invalid ${description}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
description: string,
|
||||
): void {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new TypeError(`Invalid ${description}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isId(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isMode(value: unknown): value is ServiceMode {
|
||||
return value === "singleton" || value === "keyed";
|
||||
}
|
||||
|
||||
function isInteger(value: unknown, minimum: number): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= minimum;
|
||||
}
|
||||
261
third_party/pi/packages/chord/src/types.ts
vendored
Normal file
261
third_party/pi/packages/chord/src/types.ts
vendored
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Op } from "./delta/index.ts";
|
||||
import type { RemoteServiceProvider } from "./services/provider.ts";
|
||||
|
||||
export type { RemoteServiceError } from "./services/errors.ts";
|
||||
export type { RemoteServiceProvider } from "./services/provider.ts";
|
||||
|
||||
/** Typed identity for one value carried by a {@link Context}. */
|
||||
export interface ContextKey<T> {
|
||||
readonly token: symbol;
|
||||
/** Type-only marker that prevents keys with different value types from being interchangeable. */
|
||||
readonly valueType?: (value: T) => T;
|
||||
}
|
||||
|
||||
/** Immutable invocation-scoped values passed explicitly through operations. */
|
||||
export interface Context {
|
||||
readonly abortSignal: AbortSignal | undefined;
|
||||
value<T>(key: ContextKey<T>): T | undefined;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
type IsAny<T> = 0 extends 1 & T ? true : false;
|
||||
|
||||
/** Strict-JSON representation of an application data type. Unknown payloads become JsonValue. */
|
||||
export type JsonRepresentation<T> = IsAny<T> extends true
|
||||
? JsonValue
|
||||
: unknown extends T
|
||||
? JsonValue
|
||||
: T extends null | boolean | number | string
|
||||
? T
|
||||
: T extends readonly (infer TItem)[]
|
||||
? JsonRepresentation<TItem>[]
|
||||
: T extends object
|
||||
? { [TKey in keyof T]: JsonRepresentation<T[TKey]> }
|
||||
: never;
|
||||
|
||||
export interface ReplicatedStateDelivery {
|
||||
readonly kind: "hydrate" | "update";
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
export interface ReplicatedState<T> {
|
||||
/** Immutable value, or undefined until hydration. Later updates do not mutate previously returned values. */
|
||||
readonly value: T | undefined;
|
||||
/** Listener values are immutable and may structurally share unchanged data with other revisions. */
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> {
|
||||
readonly value: T;
|
||||
/** Mutable tracked state. All writes must go through this proxy. */
|
||||
readonly state: T;
|
||||
/** Publish the changes made through {@link state} since the previous publication. */
|
||||
publish(context: Context): void;
|
||||
}
|
||||
|
||||
declare const SERVICE_TYPE: unique symbol;
|
||||
|
||||
export type ServiceMode = "singleton" | "keyed";
|
||||
|
||||
/** Stable identity for one shared TypeScript service contract. */
|
||||
export interface Service<T> {
|
||||
readonly id: string;
|
||||
/** Process-local services accept unrestricted object contracts and are never published remotely. */
|
||||
readonly local: boolean;
|
||||
readonly [SERVICE_TYPE]?: (value: T) => T;
|
||||
}
|
||||
|
||||
type InvalidJsonPart<T> = IsAny<T> extends true
|
||||
? T
|
||||
: unknown extends T
|
||||
? never
|
||||
: [T] extends [JsonValue]
|
||||
? [JsonValue] extends [T]
|
||||
? never
|
||||
: InvalidJsonStructure<T>
|
||||
: InvalidJsonStructure<T>;
|
||||
|
||||
type InvalidJsonProperty<T> = [Exclude<T, undefined>] extends [never] ? T : InvalidJsonPart<Exclude<T, undefined>>;
|
||||
|
||||
type InvalidJsonStructure<T> = T extends null | boolean | number | string
|
||||
? never
|
||||
: T extends readonly (infer TItem)[]
|
||||
? InvalidJsonPart<TItem>
|
||||
: T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends object
|
||||
? { [TKey in keyof T]-?: InvalidJsonProperty<T[TKey]> }[keyof T]
|
||||
: T;
|
||||
|
||||
type InvalidRemoteMember<T> = T extends ReplicatedState<infer TValue>
|
||||
? InvalidJsonPart<TValue> extends never
|
||||
? never
|
||||
: "state value is not JSON"
|
||||
: T extends (...args: [...infer TArgs, Context]) => Promise<infer TResult>
|
||||
? InvalidJsonPart<TArgs[number]> extends never
|
||||
? TResult extends void
|
||||
? never
|
||||
: InvalidJsonPart<TResult> extends never
|
||||
? never
|
||||
: "method result is not JSON or void"
|
||||
: "method argument is not JSON"
|
||||
: "member is not a remote method or ReplicatedState";
|
||||
|
||||
type InvalidRemoteMemberNames<T> = {
|
||||
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
|
||||
}[keyof T];
|
||||
|
||||
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
|
||||
|
||||
export interface ServiceSpawner<T> {
|
||||
spawn(key: string, implementation: T): () => void;
|
||||
}
|
||||
|
||||
export interface RemoteServices {
|
||||
use<T>(service: Service<T>): T;
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
|
||||
/** Wait until every currently acquired service has installed its initial snapshot. */
|
||||
ready(context: Context): Promise<void>;
|
||||
dispose(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export type ServiceCatalogueEntry = {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
};
|
||||
|
||||
export type ServiceInstanceAddress = {
|
||||
readonly key: string;
|
||||
readonly generation: number;
|
||||
};
|
||||
|
||||
export type ServiceMemberSnapshot =
|
||||
| { readonly name: string; readonly kind: "method" }
|
||||
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly ops: readonly Op[] };
|
||||
|
||||
export type ServiceInstanceSnapshot = {
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly members: readonly ServiceMemberSnapshot[];
|
||||
};
|
||||
|
||||
export type ServiceSubscriptionSnapshot = {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
readonly instances: readonly ServiceInstanceSnapshot[];
|
||||
};
|
||||
|
||||
export type ServiceProviderUpdate =
|
||||
| {
|
||||
readonly type: "state";
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly member: string;
|
||||
readonly sequence: number;
|
||||
readonly ops: readonly Op[];
|
||||
}
|
||||
| { readonly type: "unavailable" }
|
||||
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
|
||||
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
|
||||
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
|
||||
|
||||
export type ServiceCall = {
|
||||
readonly serviceId: string;
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly member: string;
|
||||
/** Borrowed immutable values. Chord validates but does not clone them. */
|
||||
readonly args: readonly JsonValue[];
|
||||
};
|
||||
|
||||
export interface ServiceSubscription {
|
||||
readonly snapshot: ServiceSubscriptionSnapshot;
|
||||
activate(): void;
|
||||
close(context?: Context): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable wire boundary consumed by a remote service binding.
|
||||
*
|
||||
* Implementations choose transport, framing, routing, and envelope encoding. Values crossing this
|
||||
* boundary must remain strict JSON. Chord does not clone values or require a particular application wire protocol;
|
||||
* adapters own serialization and any isolation copies they require.
|
||||
*
|
||||
*/
|
||||
export interface RemoteServiceTransport {
|
||||
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
|
||||
subscribe(
|
||||
serviceId: string,
|
||||
mode: ServiceMode,
|
||||
listener: (update: ServiceProviderUpdate, context: Context) => void,
|
||||
context: Context,
|
||||
): Promise<ServiceSubscription>;
|
||||
}
|
||||
|
||||
export interface RemoteServiceBindingOptions {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
readonly transport: RemoteServiceTransport;
|
||||
readonly bound?: boolean;
|
||||
readonly onError?: (error: Error) => void;
|
||||
readonly assertAccess?: () => void;
|
||||
}
|
||||
|
||||
export interface RemoteServiceBinding extends RemoteServices {
|
||||
rebind(bound: boolean, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FacetEnvironment {
|
||||
/** Declare a hard dependency on one singleton service and return its stable handle. */
|
||||
use<T>(service: Service<T>): T;
|
||||
/** Declare a hard dependency on a keyed service and observe each live instance. */
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): void;
|
||||
/** Declare and install this facet's singleton implementation of a service. */
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<T>): void;
|
||||
/** Declare ownership of a multi-instance service and return its deferred spawning capability. */
|
||||
provideMany<T>(service: Service<T>): ServiceSpawner<T>;
|
||||
/** Create initialized mutable state suitable for exposing through a service implementation. */
|
||||
replicatedState<T extends object>(initial: T): MutableReplicatedState<T>;
|
||||
/** Give the facet ownership of a resource cleanup function. */
|
||||
own(disposal: () => void | Promise<void>): void;
|
||||
/** Register asynchronous initialization after dependencies are bound and ready. */
|
||||
onActivate(callback: () => void | Promise<void>): void;
|
||||
/** Register final facet teardown. */
|
||||
onDeactivate(callback: () => void | Promise<void>): void;
|
||||
}
|
||||
|
||||
export interface Facet {
|
||||
readonly id: string;
|
||||
setup(env: FacetEnvironment): void;
|
||||
}
|
||||
|
||||
export interface RemoteServiceSource {
|
||||
/** Whether this currently unavailable source may provisionally own absent requirements. */
|
||||
readonly acceptsUnavailableServices: boolean;
|
||||
catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]>;
|
||||
open(options: {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
assertAccess(): void;
|
||||
onError(error: Error): void;
|
||||
}): RemoteServices;
|
||||
}
|
||||
|
||||
export interface FacetOptions {
|
||||
readonly facets: readonly Facet[];
|
||||
readonly serviceSources?: readonly RemoteServiceSource[];
|
||||
readonly onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface FacetHost {
|
||||
readonly services: RemoteServiceProvider;
|
||||
/** Activate and replace facets with matching IDs without disconnecting consumer service handles. */
|
||||
reload(facets: readonly Facet[]): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoadedFacets {
|
||||
readonly facets: readonly Facet[];
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FacetLoader {
|
||||
load(): Promise<LoadedFacets>;
|
||||
}
|
||||
34
third_party/pi/packages/chord/test/boundary.test.ts
vendored
Normal file
34
third_party/pi/packages/chord/test/boundary.test.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDirectory = resolve(packageDirectory, "src");
|
||||
const IMPORT_SPECIFIER = /(?:import|export)\s+(?:type\s+)?(?:[^;]*?\sfrom\s*)?["']([^"']+)["']/gu;
|
||||
|
||||
describe("package boundary", () => {
|
||||
test("does not depend on Pi packages or files outside Chord", async () => {
|
||||
const manifest = JSON.parse(await readFile(resolve(packageDirectory, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@earendil-works/pi-"))).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
const paths = (await readdir(sourceDirectory, { recursive: true })).filter((path) => path.endsWith(".ts")).sort();
|
||||
const violations: string[] = [];
|
||||
for (const path of paths) {
|
||||
const file = resolve(sourceDirectory, path);
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const match of source.matchAll(IMPORT_SPECIFIER)) {
|
||||
const specifier = match[1]!;
|
||||
if (specifier.startsWith("@earendil-works/pi-")) violations.push(`${path}: ${specifier}`);
|
||||
if (specifier.startsWith(".") && !resolve(dirname(file), specifier).startsWith(`${sourceDirectory}/`)) {
|
||||
violations.push(`${path}: ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
259
third_party/pi/packages/chord/test/bundle.test.ts
vendored
Normal file
259
third_party/pi/packages/chord/test/bundle.test.ts
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { bundleFacetPackage, bundleFacets } from "../src/bundler.ts";
|
||||
import { createFacetHost, defineFacet, defineService } from "../src/index.ts";
|
||||
import {
|
||||
createFacetBundleArtifactLoader,
|
||||
createFacetBundleLoader,
|
||||
readFacetBundleArtifact,
|
||||
readFacetBundleManifest,
|
||||
} from "../src/node.ts";
|
||||
|
||||
interface GenerationValue {
|
||||
read(): string;
|
||||
}
|
||||
|
||||
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const temporaryDirectories: string[] = [];
|
||||
const GenerationValue = defineService<GenerationValue>("test.bundle.generation", { local: true });
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
describe("facet bundles", () => {
|
||||
test("builds independent content-addressed entries and loads fresh reloadable generations", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sourceDirectory = join(directory, "src");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await mkdir(sourceDirectory);
|
||||
await writeFile(
|
||||
join(sourceDirectory, "helper.ts"),
|
||||
'export const decorate = (value: string): string => "generation:" + value;\n',
|
||||
);
|
||||
const entryPath = join(sourceDirectory, "entry.ts");
|
||||
const presentationPath = join(sourceDirectory, "presentation.ts");
|
||||
await writeGeneration(entryPath, "A");
|
||||
await writeFile(presentationPath, 'export default { id: "bundle-presentation", setup() {} };\n');
|
||||
const facetEntries = { presentation: presentationPath, worker: entryPath };
|
||||
|
||||
const firstBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "1" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
const firstEntry = firstBuild.manifest.entries.worker!;
|
||||
expect(firstEntry.file).toMatch(/^facet-[a-f0-9]{12}-[A-Z0-9]+\.cjs$/u);
|
||||
expect(firstEntry.sourceMap).toBe(`${firstEntry.file}.map`);
|
||||
expect(firstEntry.externalImports).toEqual(["@earendil-works/chord"]);
|
||||
expect(firstBuild.manifest.entries.presentation!.file).not.toBe(firstEntry.file);
|
||||
expect((await readdir(outputDirectory)).filter((path) => path.endsWith(".cjs"))).toHaveLength(2);
|
||||
const firstSource = await readFile(join(outputDirectory, firstEntry.file), "utf8");
|
||||
expect(firstSource).toContain('require("@earendil-works/chord")');
|
||||
expect(firstSource).toContain("module.exports");
|
||||
expect((await readFile(firstBuild.manifestPath, "utf8")).endsWith("\n")).toBe(true);
|
||||
|
||||
const presentation = await createFacetBundleLoader({
|
||||
manifestPath: firstBuild.manifestPath,
|
||||
entry: "presentation",
|
||||
}).load();
|
||||
expect(presentation.facets.map(({ id }) => id)).toEqual(["bundle-presentation"]);
|
||||
await presentation.dispose();
|
||||
|
||||
const artifact = await readFacetBundleArtifact({
|
||||
manifestPath: firstBuild.manifestPath,
|
||||
entry: "worker",
|
||||
});
|
||||
const materializedDirectory = join(directory, "materialized");
|
||||
const transported = await createFacetBundleArtifactLoader({
|
||||
artifact: structuredClone(artifact),
|
||||
temporaryDirectory: materializedDirectory,
|
||||
}).load();
|
||||
expect(transported.facets.map(({ id }) => id)).toEqual(["bundle-provider"]);
|
||||
expect(await readdir(materializedDirectory)).toHaveLength(1);
|
||||
await transported.dispose();
|
||||
expect(await readdir(materializedDirectory)).toEqual([]);
|
||||
|
||||
const secondBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "1" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
expect(secondBuild.manifest.entries.worker).toEqual(firstEntry);
|
||||
|
||||
const loader = createFacetBundleLoader({ manifestPath: secondBuild.manifestPath, entry: "worker" });
|
||||
const loadedA = await loader.load();
|
||||
const loadedACopy = await loader.load();
|
||||
expect(loadedACopy.facets[0]).not.toBe(loadedA.facets[0]);
|
||||
await loadedACopy.dispose();
|
||||
|
||||
let retained: GenerationValue | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "bundle-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(GenerationValue);
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
|
||||
expect(retained!.read()).toBe("generation:A");
|
||||
|
||||
await writeGeneration(entryPath, "B");
|
||||
const thirdBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "2" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
expect(thirdBuild.manifest.entries.worker!.file).not.toBe(firstEntry.file);
|
||||
const loadedB = await loader.load();
|
||||
await host.reload(loadedB.facets);
|
||||
await loadedA.dispose();
|
||||
expect(retained!.read()).toBe("generation:B");
|
||||
|
||||
await host.dispose();
|
||||
await loadedB.dispose();
|
||||
});
|
||||
|
||||
test("loads host externals through the controlled CommonJS require", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-external-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const externalPath = join(directory, "host.mjs");
|
||||
const entryPath = join(directory, "entry.ts");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await writeFile(externalPath, 'export const named = "host-named";\n');
|
||||
await writeFile(
|
||||
entryPath,
|
||||
'import { named } from "@example/host";\n' +
|
||||
'export const loadDynamic = () => import("@example/dynamic");\n' +
|
||||
'if (named !== "host-named") throw new Error("external mismatch");\n' +
|
||||
'export default { id: "external-facet", setup() {} };\n',
|
||||
);
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: "external-bundle" },
|
||||
entries: { worker: entryPath },
|
||||
external: ["@example/host", "@example/dynamic"],
|
||||
outdir: outputDirectory,
|
||||
});
|
||||
const entry = result.manifest.entries.worker!;
|
||||
const source = await readFile(join(outputDirectory, entry.file), "utf8");
|
||||
expect(source).not.toContain("import(");
|
||||
expect(source).toContain('require("@example/dynamic")');
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: result.manifestPath,
|
||||
entry: "worker",
|
||||
resolveExternal: (specifier) =>
|
||||
specifier === "@example/host" || specifier === "@example/dynamic" ? pathToFileURL(externalPath) : undefined,
|
||||
}).load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["external-facet"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
|
||||
test("builds plugin packages from conventional and configured facet entries", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sourceDirectory = join(directory, "src");
|
||||
await mkdir(sourceDirectory);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@example/conventional-plugin",
|
||||
version: "1.2.3",
|
||||
peerDependencies: { "@example/host": "^1.0.0" },
|
||||
})}\n`,
|
||||
),
|
||||
writeFile(
|
||||
join(sourceDirectory, "session.ts"),
|
||||
'import "@example/host/plugin"; export default { id: "package-session", setup() {} };\n',
|
||||
),
|
||||
writeFile(join(sourceDirectory, "tui.ts"), 'export default { id: "package-tui", setup() {} };\n'),
|
||||
writeFile(join(sourceDirectory, "contract.ts"), "export const ignored = true;\n"),
|
||||
writeFile(join(sourceDirectory, "presentation.ts"), 'export default { id: "configured-tui", setup() {} };\n'),
|
||||
]);
|
||||
|
||||
const conventional = await bundleFacetPackage({
|
||||
packagePath: directory,
|
||||
outdir: join(directory, "build"),
|
||||
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts", browser: "src/browser.ts" },
|
||||
});
|
||||
expect(conventional.packageDirectory).toBe(directory);
|
||||
expect(conventional.manifest.plugin).toEqual({ id: "@example/conventional-plugin", version: "1.2.3" });
|
||||
expect(Object.keys(conventional.manifest.entries)).toEqual(["session", "tui"]);
|
||||
expect(conventional.manifest.entries.session?.externalImports).toEqual(["@example/host/plugin"]);
|
||||
expect(conventional.manifest.entries.tui?.sourceMap).toMatch(/\.cjs\.map$/u);
|
||||
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@example/conventional-plugin",
|
||||
version: "2.0.0",
|
||||
chord: { facets: { session: false, tui: "src/presentation.ts" }, sourceMap: false },
|
||||
})}\n`,
|
||||
);
|
||||
const configured = await bundleFacetPackage({
|
||||
packagePath: join(directory, "package.json"),
|
||||
outdir: join(directory, "build"),
|
||||
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts" },
|
||||
});
|
||||
expect(Object.keys(configured.manifest.entries)).toEqual(["tui"]);
|
||||
expect(configured.manifest.entries.tui?.sourceMap).toBeUndefined();
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: configured.manifestPath,
|
||||
entry: "tui",
|
||||
}).load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["configured-tui"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
|
||||
test("rejects invalid plugin package entry configuration", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "invalid-plugin",
|
||||
version: "1.0.0",
|
||||
chord: { facets: { tui: "../outside.ts" } },
|
||||
})}\n`,
|
||||
);
|
||||
await expect(bundleFacetPackage({ packagePath: directory, outdir: join(directory, "build") })).rejects.toThrow(
|
||||
"escapes the package directory",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects corrupt entries and invalid module exports", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const entryPath = join(directory, "entry.ts");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await writeFile(entryPath, "export default { id: 'missing-setup' };\n");
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: "invalid-bundle" },
|
||||
entries: { invalid: entryPath },
|
||||
outdir: outputDirectory,
|
||||
});
|
||||
const loader = createFacetBundleLoader({ manifestPath: result.manifestPath, entry: "invalid" });
|
||||
await expect(loader.load()).rejects.toThrow("has no setup function");
|
||||
|
||||
const manifest = await readFacetBundleManifest(result.manifestPath);
|
||||
await writeFile(join(outputDirectory, manifest.entries.invalid!.file), "export default {};\n");
|
||||
await expect(loader.load()).rejects.toThrow("integrity check failed");
|
||||
});
|
||||
});
|
||||
|
||||
async function writeGeneration(path: string, generation: string): Promise<void> {
|
||||
await writeFile(
|
||||
path,
|
||||
`import "@earendil-works/chord";\n` +
|
||||
`import { decorate } from "./helper.ts";\n` +
|
||||
`const Value = { id: "test.bundle.generation", local: true };\n` +
|
||||
`export default { id: "bundle-provider", setup(env) {\n` +
|
||||
` env.provide(Value, { read() { return decorate(${JSON.stringify(generation)}); } });\n` +
|
||||
`}};\n`,
|
||||
);
|
||||
}
|
||||
89
third_party/pi/packages/chord/test/context.test.ts
vendored
Normal file
89
third_party/pi/packages/chord/test/context.test.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
awaitWithContext,
|
||||
BACKGROUND_CONTEXT,
|
||||
createContextKey,
|
||||
TODO_CONTEXT,
|
||||
withAbortSignal,
|
||||
withCancel,
|
||||
withContextValue,
|
||||
withoutAbortSignal,
|
||||
} from "../src/context/index.ts";
|
||||
|
||||
describe("Context", () => {
|
||||
it("provides distinct empty root contexts", () => {
|
||||
const key = createContextKey<string>("value");
|
||||
|
||||
expect(TODO_CONTEXT).not.toBe(BACKGROUND_CONTEXT);
|
||||
expect(TODO_CONTEXT.abortSignal).toBeUndefined();
|
||||
expect(TODO_CONTEXT.value(key)).toBeUndefined();
|
||||
expect(String(BACKGROUND_CONTEXT)).toBe("[Context BACKGROUND_CONTEXT]");
|
||||
expect(String(TODO_CONTEXT)).toBe("[Context TODO_CONTEXT]");
|
||||
});
|
||||
|
||||
it("layers typed values without modifying parents", () => {
|
||||
const firstKey = createContextKey<string>("first");
|
||||
const secondKey = createContextKey<number>("second");
|
||||
const first = withContextValue(firstKey, "one", BACKGROUND_CONTEXT);
|
||||
const second = withContextValue(secondKey, 2, first);
|
||||
const replaced = withContextValue(firstKey, "updated", second);
|
||||
|
||||
expect(BACKGROUND_CONTEXT.value(firstKey)).toBeUndefined();
|
||||
expect(first.value(firstKey)).toBe("one");
|
||||
expect(first.value(secondKey)).toBeUndefined();
|
||||
expect(second.value(firstKey)).toBe("one");
|
||||
expect(second.value(secondKey)).toBe(2);
|
||||
expect(replaced.value(firstKey)).toBe("updated");
|
||||
expect(second.value(firstKey)).toBe("one");
|
||||
expect(String(replaced)).toBe("[Context BACKGROUND_CONTEXT].WithValue(first).WithValue(second).WithValue(first)");
|
||||
});
|
||||
|
||||
it("inherits parent cancellation and isolates child cancellation", () => {
|
||||
const parentController = new AbortController();
|
||||
const parent = withAbortSignal(parentController.signal, BACKGROUND_CONTEXT);
|
||||
const child = withCancel(parent);
|
||||
const sibling = withCancel(parent);
|
||||
const childListener = vi.fn();
|
||||
child.context.abortSignal?.addEventListener("abort", childListener);
|
||||
|
||||
child.cancel("child");
|
||||
expect(child.context.abortSignal?.aborted).toBe(true);
|
||||
expect(child.context.abortSignal?.reason).toBe("child");
|
||||
expect(sibling.context.abortSignal?.aborted).toBe(false);
|
||||
expect(parent.abortSignal?.aborted).toBe(false);
|
||||
expect(childListener).toHaveBeenCalledOnce();
|
||||
|
||||
parentController.abort("parent");
|
||||
expect(sibling.context.abortSignal?.aborted).toBe(true);
|
||||
expect(sibling.context.abortSignal?.reason).toBe("parent");
|
||||
});
|
||||
|
||||
it("masks caller cancellation for mandatory cleanup", () => {
|
||||
const controller = new AbortController();
|
||||
const key = createContextKey<string>("value");
|
||||
const context = withContextValue(key, "preserved", withAbortSignal(controller.signal, BACKGROUND_CONTEXT));
|
||||
const cleanup = withoutAbortSignal(context);
|
||||
|
||||
controller.abort();
|
||||
expect(context.abortSignal?.aborted).toBe(true);
|
||||
expect(cleanup.abortSignal).toBeUndefined();
|
||||
expect(cleanup.value(key)).toBe("preserved");
|
||||
});
|
||||
|
||||
it("stops waiting when the invocation is cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT);
|
||||
let resolveWork!: (value: string) => void;
|
||||
const work = new Promise<string>((resolve) => {
|
||||
resolveWork = resolve;
|
||||
});
|
||||
const waiting = awaitWithContext(work, context);
|
||||
const cancellation = new Error("cancelled");
|
||||
|
||||
controller.abort(cancellation);
|
||||
await expect(waiting).rejects.toBe(cancellation);
|
||||
resolveWork("completed later");
|
||||
await expect(work).resolves.toBe("completed later");
|
||||
await expect(awaitWithContext(Promise.resolve("completed"), BACKGROUND_CONTEXT)).resolves.toBe("completed");
|
||||
});
|
||||
});
|
||||
1141
third_party/pi/packages/chord/test/delta.test.ts
vendored
Normal file
1141
third_party/pi/packages/chord/test/delta.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
394
third_party/pi/packages/chord/test/facet-loader.test.ts
vendored
Normal file
394
third_party/pi/packages/chord/test/facet-loader.test.ts
vendored
Normal file
@@ -0,0 +1,394 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
combineFacetLoaders,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
defineService,
|
||||
type FacetLoader,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
interface GenerationValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
const LocalGenerationValue = defineService<GenerationValue>("test.experimental.local-generation-value", {
|
||||
local: true,
|
||||
});
|
||||
const RemoteGenerationValue = defineService<GenerationValue>("test.experimental.remote-generation-value");
|
||||
|
||||
const firstFacet = defineFacet({ id: "first", setup() {} });
|
||||
const secondFacet = defineFacet({ id: "second", setup() {} });
|
||||
|
||||
describe("facet loader", () => {
|
||||
test("combines loaded facets in loader order and disposes generations in reverse", async () => {
|
||||
const trace: string[] = [];
|
||||
const first: FacetLoader = {
|
||||
async load() {
|
||||
trace.push("load first");
|
||||
return {
|
||||
facets: [firstFacet],
|
||||
async dispose() {
|
||||
trace.push("dispose first");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const second: FacetLoader = {
|
||||
async load() {
|
||||
trace.push("load second");
|
||||
return {
|
||||
facets: [secondFacet],
|
||||
async dispose() {
|
||||
trace.push("dispose second");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const loaded = await combineFacetLoaders([first, second]).load();
|
||||
expect(loaded.facets).toEqual([firstFacet, secondFacet]);
|
||||
await loaded.dispose();
|
||||
await loaded.dispose();
|
||||
expect(trace).toEqual(["load first", "load second", "dispose second", "dispose first"]);
|
||||
});
|
||||
|
||||
test("cleans up loaded facets when a later loader fails", async () => {
|
||||
const dispose = vi.fn(async () => {});
|
||||
const failure = new Error("load failed");
|
||||
const first: FacetLoader = {
|
||||
async load() {
|
||||
return { facets: [firstFacet], dispose };
|
||||
},
|
||||
};
|
||||
const second: FacetLoader = {
|
||||
async load() {
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(combineFacetLoaders([first, second]).load()).rejects.toBe(failure);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("keeps local and RPC service handles stable when their provider facet reloads", async () => {
|
||||
const trace: string[] = [];
|
||||
let localValue: GenerationValue | undefined;
|
||||
let generation = 0;
|
||||
let reportReplacementStarted!: () => void;
|
||||
const replacementStarted = new Promise<void>((resolve) => {
|
||||
reportReplacementStarted = resolve;
|
||||
});
|
||||
let continueReplacement!: () => void;
|
||||
const replacementCanContinue = new Promise<void>((resolve) => {
|
||||
continueReplacement = resolve;
|
||||
});
|
||||
const consumer = defineFacet({
|
||||
id: "consumer",
|
||||
setup(env) {
|
||||
trace.push("setup consumer");
|
||||
localValue = env.use(LocalGenerationValue);
|
||||
env.onActivate(async () => {
|
||||
trace.push(`activate consumer:${await localValue!.read(BACKGROUND_CONTEXT)}`);
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("deactivate consumer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const loader: FacetLoader = {
|
||||
async load() {
|
||||
const name = generation++ === 0 ? "A" : "B";
|
||||
trace.push(`load ${name}`);
|
||||
return {
|
||||
facets: [
|
||||
defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
trace.push(`setup provider ${name}`);
|
||||
const implementation: GenerationValue = {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
};
|
||||
env.provide(LocalGenerationValue, implementation);
|
||||
env.provide(RemoteGenerationValue, implementation);
|
||||
env.onActivate(async () => {
|
||||
trace.push(`activate provider ${name}`);
|
||||
if (name === "B") {
|
||||
reportReplacementStarted();
|
||||
await replacementCanContinue;
|
||||
}
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate provider ${name}`);
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
async dispose() {
|
||||
trace.push(`unload ${name}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const loadedA = await loader.load();
|
||||
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
|
||||
const originalLocalHandle = localValue!;
|
||||
const localRead = originalLocalHandle.read;
|
||||
const remoteServices = createRemoteServiceBinding({
|
||||
services: [RemoteGenerationValue],
|
||||
transport: createLoopbackServiceTransport(host.services),
|
||||
});
|
||||
const originalRemoteHandle = remoteServices.use(RemoteGenerationValue);
|
||||
const remoteRead = originalRemoteHandle.read;
|
||||
await remoteServices.ready(BACKGROUND_CONTEXT);
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
env.provide(LocalGenerationValue, {
|
||||
async read() {
|
||||
return "invalid";
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("Reloaded facet provider must preserve its service requirements and provisions");
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
const loadedB = await loader.load();
|
||||
const reload = host.reload(loadedB.facets);
|
||||
await replacementStarted;
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
continueReplacement();
|
||||
await reload;
|
||||
await loadedA.dispose();
|
||||
|
||||
expect(localValue).toBe(originalLocalHandle);
|
||||
expect(remoteServices.use(RemoteGenerationValue)).toBe(originalRemoteHandle);
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
|
||||
|
||||
await remoteServices.dispose(BACKGROUND_CONTEXT);
|
||||
await host.dispose();
|
||||
await loadedB.dispose();
|
||||
expect(trace).toEqual([
|
||||
"load A",
|
||||
"setup consumer",
|
||||
"setup provider A",
|
||||
"activate provider A",
|
||||
"activate consumer:A",
|
||||
"load B",
|
||||
"setup provider B",
|
||||
"activate provider B",
|
||||
"deactivate provider A",
|
||||
"unload A",
|
||||
"deactivate consumer",
|
||||
"deactivate provider B",
|
||||
"unload B",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects remote singleton member shape changes before reload cutover", async () => {
|
||||
let retained: GenerationValue | undefined;
|
||||
let providerDisposed = false;
|
||||
const consumer = defineFacet({
|
||||
id: "shape-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(RemoteGenerationValue);
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "shape-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return "A";
|
||||
},
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
providerDisposed = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "shape-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async renamed() {
|
||||
return "B";
|
||||
},
|
||||
} as unknown as GenerationValue);
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("replacement must preserve its member shape");
|
||||
expect(providerDisposed).toBe(false);
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await host.dispose();
|
||||
expect(providerDisposed).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates the host when old cleanup fails after cutover", async () => {
|
||||
const cleanupFailure = new Error("cleanup failed");
|
||||
const provider = (name: string, failCleanup: boolean) =>
|
||||
defineFacet({
|
||||
id: "cleanup-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
if (failCleanup) throw cleanupFailure;
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A", true)] });
|
||||
|
||||
await expect(host.reload([provider("B", false)])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("cleans failed candidate activation in reverse dependency order", async () => {
|
||||
const failure = new Error("consumer activation failed");
|
||||
const trace: string[] = [];
|
||||
const provider = (name: string) =>
|
||||
defineFacet({
|
||||
id: "ordered-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate provider ${name}`);
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate provider ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const consumer = (name: string, fail: boolean) =>
|
||||
defineFacet({
|
||||
id: "ordered-consumer",
|
||||
setup(env) {
|
||||
env.use(RemoteGenerationValue);
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate consumer ${name}`);
|
||||
if (fail) throw failure;
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate consumer ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer("A", false), provider("A")] });
|
||||
trace.length = 0;
|
||||
|
||||
await expect(host.reload([consumer("B", true), provider("B")])).rejects.toBe(failure);
|
||||
expect(trace).toEqual([
|
||||
"activate provider B",
|
||||
"activate consumer B",
|
||||
"deactivate consumer B",
|
||||
"deactivate provider B",
|
||||
]);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("terminates the host when replacement publication fails after cutover", async () => {
|
||||
const publicationFailure = new Error("publication failed");
|
||||
const provider = (name: string) =>
|
||||
defineFacet({
|
||||
id: "publication-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(RemoteGenerationValue.id, "singleton", () => {
|
||||
throw publicationFailure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("keeps the old generation active when replacement activation fails before cutover", async () => {
|
||||
const failure = new Error("replacement activation failed");
|
||||
const trace: string[] = [];
|
||||
let retained: GenerationValue | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "terminal-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(RemoteGenerationValue);
|
||||
env.onDeactivate(() => {
|
||||
trace.push("deactivate consumer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (name: string, fail: boolean) =>
|
||||
defineFacet({
|
||||
id: "terminal-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate ${name}`);
|
||||
if (fail) throw failure;
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A", false)] });
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await expect(host.reload([provider("B", true)])).rejects.toBe(failure);
|
||||
expect(trace).toEqual(["activate A", "activate B", "deactivate B"]);
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await host.reload([]);
|
||||
await host.dispose();
|
||||
expect(trace).toEqual(["activate A", "activate B", "deactivate B", "deactivate consumer", "deactivate A"]);
|
||||
});
|
||||
|
||||
test("creates a reusable static loader", async () => {
|
||||
const loader = createStaticFacetLoader([firstFacet]);
|
||||
const first = await loader.load();
|
||||
const second = await loader.load();
|
||||
expect(first.facets).toEqual([firstFacet]);
|
||||
expect(second.facets).toEqual([firstFacet]);
|
||||
await Promise.all([first.dispose(), second.dispose()]);
|
||||
});
|
||||
});
|
||||
673
third_party/pi/packages/chord/test/facets.test.ts
vendored
Normal file
673
third_party/pi/packages/chord/test/facets.test.ts
vendored
Normal file
@@ -0,0 +1,673 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
defineFacet,
|
||||
defineService,
|
||||
type MutableReplicatedState,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceSource,
|
||||
type ReplicatedState,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
interface Source {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface Projection {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface KeyedValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface Watched {
|
||||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
interface HostValues {
|
||||
readonly name: string;
|
||||
readonly use: string;
|
||||
}
|
||||
|
||||
interface LocalKeyedValue {
|
||||
readonly metadata: Map<string, string>;
|
||||
read(): string;
|
||||
}
|
||||
|
||||
interface LeftValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface RightValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface CombinedValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
const Source = defineService<Source>("test.experimental.source");
|
||||
const Projection = defineService<Projection>("test.experimental.projection");
|
||||
const KeyedValue = defineService<KeyedValue>("test.experimental.keyed-value");
|
||||
const Watched = defineService<Watched>("test.experimental.watched");
|
||||
const HostValues = defineService<HostValues>("test.experimental.host-values", { local: true });
|
||||
const LocalKeyedValue = defineService<LocalKeyedValue>("test.experimental.local-keyed-value", { local: true });
|
||||
const LeftValue = defineService<LeftValue>("test.experimental.left-value");
|
||||
const RightValue = defineService<RightValue>("test.experimental.right-value");
|
||||
const CombinedValue = defineService<CombinedValue>("test.experimental.combined-value");
|
||||
|
||||
describe("facet host", () => {
|
||||
test("discovers setup dependencies before connecting stable service handles", async () => {
|
||||
const trace: string[] = [];
|
||||
let sourceHandle: Source | undefined;
|
||||
const projection = defineFacet({
|
||||
id: "projection",
|
||||
setup(env) {
|
||||
trace.push("setup projection");
|
||||
sourceHandle = env.use(Source);
|
||||
expect(() => sourceHandle!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet projection service handles cannot be used while setting_up",
|
||||
);
|
||||
env.provide(Projection, {
|
||||
read: (context) => sourceHandle!.read(context),
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate projection");
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("dispose projection");
|
||||
});
|
||||
},
|
||||
});
|
||||
const source = defineFacet({
|
||||
id: "source",
|
||||
setup(env) {
|
||||
trace.push("setup source");
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "value";
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate source");
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("dispose source");
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [projection, source] });
|
||||
|
||||
expect(trace).toEqual(["setup projection", "setup source", "activate source", "activate projection"]);
|
||||
expect(await sourceHandle!.read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
expect(await host.services.use(Projection).read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
|
||||
await host.dispose();
|
||||
expect(trace.slice(-2)).toEqual(["dispose projection", "dispose source"]);
|
||||
});
|
||||
|
||||
test("connects keyed observations only when the observing facet activates", async () => {
|
||||
const trace: string[] = [];
|
||||
const observer = defineFacet({
|
||||
id: "observer",
|
||||
setup(env) {
|
||||
env.observe(KeyedValue, async (service, context) => {
|
||||
trace.push(`observe ${await service.read(context)}`);
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate observer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
trace.push("activate provider");
|
||||
values.spawn("one", {
|
||||
async read() {
|
||||
return "one";
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [observer, provider] });
|
||||
await vi.waitFor(() => expect(trace).toContain("observe one"));
|
||||
|
||||
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
|
||||
const remoteServices = createRemoteServiceBinding({
|
||||
services: [KeyedValue],
|
||||
transport: createLoopbackServiceTransport(host.services),
|
||||
});
|
||||
const remoteValues: string[] = [];
|
||||
remoteServices.observe(KeyedValue, async (service, context) => {
|
||||
remoteValues.push(await service.read(context));
|
||||
});
|
||||
await remoteServices.ready(BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => expect(remoteValues).toEqual(["one"]));
|
||||
|
||||
await remoteServices.dispose(BACKGROUND_CONTEXT);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("routes remotely exposable keyed services through the host provider", async () => {
|
||||
const observed: Array<{ service: KeyedValue; context: Context }> = [];
|
||||
const consumer = defineFacet({
|
||||
id: "remote-keyed-consumer",
|
||||
setup(env) {
|
||||
env.observe(KeyedValue, (service, context) => {
|
||||
observed.push({ service, context });
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "remote-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A")] });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
const first = observed[0]!;
|
||||
await expect(first.service.read(first.context)).resolves.toBe("A");
|
||||
|
||||
await host.reload([provider("B")]);
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(first.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => first.service.read(first.context)).toThrow("observation is closed");
|
||||
await expect(observed[1]!.service.read(observed[1]!.context)).resolves.toBe("B");
|
||||
|
||||
await host.dispose();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates the host when keyed replacement publication fails", async () => {
|
||||
const failure = new Error("spawn publication failed");
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "failing-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
|
||||
if (update.type === "spawned") throw failure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
expect(() => host.services.use(KeyedValue)).toThrow("Remote service provider is disposed");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("terminates the host when keyed retirement publication fails", async () => {
|
||||
const failure = new Error("close publication failed");
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "failing-keyed-retirement-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
|
||||
if (update.type === "closed") throw failure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("keeps unrestricted local keyed services process-local across provider reloads", async () => {
|
||||
const observed: Array<{ service: LocalKeyedValue; context: Context }> = [];
|
||||
const consumer = defineFacet({
|
||||
id: "local-keyed-consumer",
|
||||
setup(env) {
|
||||
env.observe(LocalKeyedValue, (service, context) => {
|
||||
observed.push({ service, context });
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "local-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(LocalKeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
metadata: new Map([["value", value]]),
|
||||
read: () => value,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A")] });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
const first = observed[0]!;
|
||||
expect(first.service.read()).toBe("A");
|
||||
expect(first.service.metadata.get("value")).toBe("A");
|
||||
expect(host.services.catalogue).not.toContainEqual({ serviceId: LocalKeyedValue.id, mode: "keyed" });
|
||||
expect(() => host.services.use(LocalKeyedValue)).toThrow("process-local");
|
||||
|
||||
await host.reload([provider("B")]);
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(first.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => first.service.read()).toThrow(`Keyed service ${LocalKeyedValue.id} observation is closed`);
|
||||
expect(observed[1]!.service.read()).toBe("B");
|
||||
expect(observed[1]!.service.metadata.get("value")).toBe("B");
|
||||
|
||||
await host.dispose();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps remotely exposable local state replicas stable across provider reloads", async () => {
|
||||
const sources: MutableReplicatedState<{ value: number }>[] = [];
|
||||
const revisions: number[] = [];
|
||||
let watched: Watched | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "state-consumer",
|
||||
setup(env) {
|
||||
watched = env.use(Watched);
|
||||
env.onActivate(() => {
|
||||
env.own(watched!.state.subscribe(({ value }) => revisions.push(value)));
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: number) =>
|
||||
defineFacet({
|
||||
id: "state-provider",
|
||||
setup(env) {
|
||||
const state = env.replicatedState({ value });
|
||||
sources.push(state);
|
||||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider(1)] });
|
||||
const retainedState = watched!.state;
|
||||
expect(retainedState.value).toEqual({ value: 1 });
|
||||
expect(revisions).toEqual([1]);
|
||||
|
||||
await host.reload([provider(2)]);
|
||||
expect(watched!.state).toBe(retainedState);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
sources[0]!.state.value = 3;
|
||||
sources[0]!.publish(BACKGROUND_CONTEXT);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await host.dispose();
|
||||
expect(() => retainedState.value).toThrow("Facet state-consumer service handles cannot be used while dead");
|
||||
});
|
||||
|
||||
test("scopes singleton service views to each facet lifecycle", async () => {
|
||||
const consumerHandles: Source[] = [];
|
||||
const cleanupValues: string[] = [];
|
||||
let peerHandle: Source | undefined;
|
||||
const consumer = (generation: string) =>
|
||||
defineFacet({
|
||||
id: "scoped-consumer",
|
||||
setup(env) {
|
||||
const source = env.use(Source);
|
||||
expect(env.use(Source)).toBe(source);
|
||||
consumerHandles.push(source);
|
||||
expect(() => source.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while setting_up",
|
||||
);
|
||||
env.onDeactivate(async () => {
|
||||
cleanupValues.push(`${generation}:${await source.read(BACKGROUND_CONTEXT)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const peer = defineFacet({
|
||||
id: "peer-consumer",
|
||||
setup(env) {
|
||||
peerHandle = env.use(Source);
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "scoped-provider",
|
||||
setup(env) {
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "value";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer("A"), peer, provider] });
|
||||
expect(consumerHandles[0]).not.toBe(peerHandle);
|
||||
const retainedOldRead = consumerHandles[0]!.read;
|
||||
|
||||
await host.reload([consumer("B")]);
|
||||
expect(cleanupValues).toEqual(["A:value"]);
|
||||
expect(Object.is(consumerHandles[1], consumerHandles[0])).toBe(false);
|
||||
expect(() => consumerHandles[0]!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
expect(() => retainedOldRead(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
await expect(consumerHandles[1]!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
|
||||
await expect(peerHandle!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
|
||||
|
||||
await host.dispose();
|
||||
expect(cleanupValues).toEqual(["A:value", "B:value"]);
|
||||
expect(() => consumerHandles[1]!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
expect(() => peerHandle!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet peer-consumer service handles cannot be used while dead",
|
||||
);
|
||||
});
|
||||
|
||||
test("owns resources registered during activation", async () => {
|
||||
let state: MutableReplicatedState<{ value: number }> | undefined;
|
||||
let deliveries = 0;
|
||||
const consumer = defineFacet({
|
||||
id: "consumer",
|
||||
setup(env) {
|
||||
const watched = env.use(Watched);
|
||||
env.onActivate(() => {
|
||||
env.own(
|
||||
watched.state.subscribe(() => {
|
||||
deliveries += 1;
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
state = env.replicatedState({ value: 0 });
|
||||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(deliveries).toBe(1);
|
||||
state!.state.value = 1;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
|
||||
await host.dispose();
|
||||
state!.state.value = 2;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
test("provides arbitrary host services through the facet graph", async () => {
|
||||
const values: HostValues = { name: "session", use: "host value" };
|
||||
const consumer = defineFacet({
|
||||
id: "host-service-consumer",
|
||||
setup(env) {
|
||||
const hostValues = env.use(HostValues);
|
||||
expect(() => hostValues.use).toThrow(
|
||||
"Facet host-service-consumer service handles cannot be used while setting_up",
|
||||
);
|
||||
env.onActivate(() => {
|
||||
expect(hostValues).not.toBe(values);
|
||||
expect(hostValues.name).toBe("session");
|
||||
expect(hostValues.use).toBe("host value");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "host-service-provider",
|
||||
setup(env) {
|
||||
env.provide(HostValues, values);
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(() => host.services.use(HostValues)).toThrow("Service test.experimental.host-values is process-local");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("combines connected services and facet-provided services in one host", async () => {
|
||||
const leftProvider = new RemoteServiceProvider([LeftValue]);
|
||||
leftProvider.provide(LeftValue, {
|
||||
async read() {
|
||||
return "left";
|
||||
},
|
||||
});
|
||||
const rightProvider = new RemoteServiceProvider([RightValue]);
|
||||
rightProvider.provide(RightValue, {
|
||||
async read() {
|
||||
return "right";
|
||||
},
|
||||
});
|
||||
const leftNamespace = createRemoteServiceBinding({
|
||||
services: [LeftValue],
|
||||
transport: createLoopbackServiceTransport(leftProvider),
|
||||
bound: false,
|
||||
});
|
||||
const rightNamespace = createRemoteServiceBinding({
|
||||
services: [RightValue],
|
||||
transport: createLoopbackServiceTransport(rightProvider),
|
||||
bound: false,
|
||||
});
|
||||
const serviceSources = [
|
||||
{ namespace: leftNamespace, provider: leftProvider },
|
||||
{ namespace: rightNamespace, provider: rightProvider },
|
||||
].map(({ namespace, provider }) => {
|
||||
const ready = namespace.ready.bind(namespace);
|
||||
return Object.assign(namespace, {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return provider.catalogue;
|
||||
},
|
||||
open() {
|
||||
return namespace;
|
||||
},
|
||||
async ready(context: Context) {
|
||||
await namespace.rebind(true, context);
|
||||
await ready(context);
|
||||
},
|
||||
});
|
||||
});
|
||||
const facet = defineFacet({
|
||||
id: "combined",
|
||||
setup(env) {
|
||||
const left = env.use(LeftValue);
|
||||
const right = env.use(RightValue);
|
||||
env.provide(CombinedValue, {
|
||||
async read(context) {
|
||||
return `${await left.read(context)} ${await right.read(context)}`;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const host = await createFacetHost({ facets: [facet], serviceSources });
|
||||
await expect(host.services.use(CombinedValue).read(BACKGROUND_CONTEXT)).resolves.toBe("left right");
|
||||
|
||||
await host.dispose();
|
||||
await Promise.all([leftNamespace.dispose(BACKGROUND_CONTEXT), rightNamespace.dispose(BACKGROUND_CONTEXT)]);
|
||||
leftProvider.dispose();
|
||||
rightProvider.dispose();
|
||||
});
|
||||
|
||||
test("rejects a service offered by multiple sources", async () => {
|
||||
const duplicate = {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return [{ serviceId: LeftValue.id, mode: "singleton" as const }];
|
||||
},
|
||||
open() {
|
||||
throw new Error("Ambiguous sources must not open");
|
||||
},
|
||||
} satisfies RemoteServiceSource;
|
||||
const consumer = defineFacet({
|
||||
id: "duplicate-consumer",
|
||||
setup(env) {
|
||||
env.use(LeftValue);
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [consumer], serviceSources: [duplicate, duplicate] })).rejects.toThrow(
|
||||
`Facet host service ${LeftValue.id} is offered by more than one source`,
|
||||
);
|
||||
});
|
||||
|
||||
test("reopens source bindings from changed catalogues for a replacement generation", async () => {
|
||||
const leftProvider = new RemoteServiceProvider([LeftValue]);
|
||||
leftProvider.provide(LeftValue, {
|
||||
async read() {
|
||||
return "left";
|
||||
},
|
||||
});
|
||||
const rightProvider = new RemoteServiceProvider([RightValue]);
|
||||
rightProvider.provide(RightValue, {
|
||||
async read() {
|
||||
return "right";
|
||||
},
|
||||
});
|
||||
let currentProvider = leftProvider;
|
||||
let opened = 0;
|
||||
let disposed = 0;
|
||||
const source = {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return currentProvider.catalogue;
|
||||
},
|
||||
open(options) {
|
||||
opened += 1;
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: options.services,
|
||||
transport: createLoopbackServiceTransport(currentProvider),
|
||||
bound: false,
|
||||
assertAccess: options.assertAccess,
|
||||
onError: options.onError,
|
||||
});
|
||||
const ready = namespace.ready.bind(namespace);
|
||||
const dispose = namespace.dispose.bind(namespace);
|
||||
return Object.assign(namespace, {
|
||||
async ready(context: Context) {
|
||||
await namespace.rebind(true, context);
|
||||
await ready(context);
|
||||
},
|
||||
async dispose(context: Context) {
|
||||
disposed += 1;
|
||||
await dispose(context);
|
||||
},
|
||||
});
|
||||
},
|
||||
} satisfies RemoteServiceSource;
|
||||
const values: string[] = [];
|
||||
const leftConsumer = defineFacet({
|
||||
id: "left-consumer",
|
||||
setup(env) {
|
||||
const left = env.use(LeftValue);
|
||||
env.onActivate(async () => {
|
||||
values.push(await left.read(BACKGROUND_CONTEXT));
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await createFacetHost({ facets: [leftConsumer], serviceSources: [source] });
|
||||
await first.dispose();
|
||||
|
||||
currentProvider = rightProvider;
|
||||
const rightConsumer = defineFacet({
|
||||
id: "right-consumer",
|
||||
setup(env) {
|
||||
const right = env.use(RightValue);
|
||||
env.onActivate(async () => {
|
||||
values.push(await right.read(BACKGROUND_CONTEXT));
|
||||
});
|
||||
},
|
||||
});
|
||||
const second = await createFacetHost({ facets: [rightConsumer], serviceSources: [source] });
|
||||
await second.dispose();
|
||||
|
||||
expect(values).toEqual(["left", "right"]);
|
||||
expect(opened).toBe(2);
|
||||
expect(disposed).toBe(2);
|
||||
leftProvider.dispose();
|
||||
rightProvider.dispose();
|
||||
});
|
||||
|
||||
test("rejects missing dependencies, cycles, and asynchronous setup", async () => {
|
||||
let activated = false;
|
||||
const missing = defineFacet({
|
||||
id: "missing",
|
||||
setup(env) {
|
||||
env.use(Source);
|
||||
env.onActivate(() => {
|
||||
activated = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [missing] })).rejects.toThrow(
|
||||
"Facet missing requires local/test.experimental.source/singleton, but no facet provides it",
|
||||
);
|
||||
expect(activated).toBe(false);
|
||||
|
||||
const first = defineFacet({
|
||||
id: "first",
|
||||
setup(env) {
|
||||
env.use(Projection);
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "first";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const second = defineFacet({
|
||||
id: "second",
|
||||
setup(env) {
|
||||
env.use(Source);
|
||||
env.provide(Projection, {
|
||||
async read() {
|
||||
return "second";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [first, second] })).rejects.toThrow(
|
||||
"Facet dependency cycle: first, second",
|
||||
);
|
||||
|
||||
const asynchronous = defineFacet({
|
||||
id: "asynchronous",
|
||||
async setup() {
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [asynchronous] })).rejects.toThrow(
|
||||
"Facet asynchronous setup must be synchronous",
|
||||
);
|
||||
});
|
||||
});
|
||||
1
third_party/pi/packages/chord/test/helpers.ts
vendored
Normal file
1
third_party/pi/packages/chord/test/helpers.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { createLoopbackServiceTransport } from "../src/services/loopback.ts";
|
||||
14
third_party/pi/packages/chord/test/json.test.ts
vendored
Normal file
14
third_party/pi/packages/chord/test/json.test.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isJsonValue } from "../src/index.ts";
|
||||
|
||||
describe("isJsonValue", () => {
|
||||
test("checks strict JSON without normalizing it", () => {
|
||||
expect(isJsonValue({ nested: [1, true, null] })).toBe(true);
|
||||
expect(isJsonValue({ omitted: undefined })).toBe(false);
|
||||
expect(isJsonValue(new Uint8Array([1]))).toBe(false);
|
||||
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
const cyclic: { self?: unknown } = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
});
|
||||
269
third_party/pi/packages/chord/test/service-wire.test.ts
vendored
Normal file
269
third_party/pi/packages/chord/test/service-wire.test.ts
vendored
Normal file
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
createRemoteServiceEndpoint,
|
||||
createServiceCatalogueCall,
|
||||
createServiceStateDecoder,
|
||||
createServiceStateEncoder,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
decodeServiceControlCall,
|
||||
defineService,
|
||||
parseServiceCall,
|
||||
parseServiceCatalogue,
|
||||
parseServiceProviderUpdate,
|
||||
parseServiceSubscriptionSnapshot,
|
||||
parseWireServiceProviderUpdate,
|
||||
parseWireServiceSubscriptionSnapshot,
|
||||
RemoteServiceProvider,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("service wire protocol", () => {
|
||||
test("encodes control calls and validates service values", () => {
|
||||
expect(decodeServiceControlCall(createServiceCatalogueCall())).toEqual({ type: "catalogue" });
|
||||
expect(
|
||||
parseServiceCatalogue([
|
||||
{ serviceId: "pi.models", mode: "singleton" },
|
||||
{ serviceId: "pi.dialogs", mode: "keyed" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ serviceId: "pi.models", mode: "singleton" },
|
||||
{ serviceId: "pi.dialogs", mode: "keyed" },
|
||||
]);
|
||||
const subscribe = createServiceSubscribeCall("subscription-1", "pi.models", "singleton");
|
||||
expect(decodeServiceControlCall(subscribe)).toEqual({
|
||||
type: "subscribe",
|
||||
subscriptionId: "subscription-1",
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
});
|
||||
expect(decodeServiceControlCall(createServiceUnsubscribeCall("subscription-1"))).toEqual({
|
||||
type: "unsubscribe",
|
||||
subscriptionId: "subscription-1",
|
||||
});
|
||||
expect(
|
||||
parseServiceCall({
|
||||
serviceId: "pi.question-dialog",
|
||||
instance: { key: "invocation-1", generation: 2 },
|
||||
member: "submit",
|
||||
args: [{ outcome: "selected", index: 0 }],
|
||||
}),
|
||||
).toMatchObject({ member: "submit" });
|
||||
});
|
||||
|
||||
test("rejects malformed service values", () => {
|
||||
expect(() => parseServiceCall({ serviceId: "pi.models", member: "list", args: [], extra: true })).toThrow(
|
||||
"Invalid service call",
|
||||
);
|
||||
expect(() => parseServiceCatalogue([{ serviceId: "pi.models", mode: "unknown" }])).toThrow(
|
||||
"Invalid service catalogue",
|
||||
);
|
||||
expect(() => parseServiceProviderUpdate({ type: "state", member: "state", sequence: 0, ops: [] })).toThrow(
|
||||
"Invalid service state update",
|
||||
);
|
||||
expect(() =>
|
||||
parseWireServiceProviderUpdate({ type: "state", member: "state", sequence: 1, ops: [["?", 0]] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("validates decoded and wire snapshots and updates", () => {
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 1 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(parseServiceSubscriptionSnapshot(snapshot)).toBe(snapshot);
|
||||
const encoder = createServiceStateEncoder();
|
||||
const wireSnapshot = encoder.encodeSnapshot(snapshot);
|
||||
expect(parseWireServiceSubscriptionSnapshot(wireSnapshot)).toBe(wireSnapshot);
|
||||
const update: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
expect(parseServiceProviderUpdate(update)).toBe(update);
|
||||
const wireUpdate = encoder.encodeUpdate(update);
|
||||
expect(parseWireServiceProviderUpdate(wireUpdate)).toBe(wireUpdate);
|
||||
});
|
||||
|
||||
test("keeps one operation codec pair for one subscription state", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
|
||||
const first: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 1]],
|
||||
};
|
||||
const second: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
const firstWire = enc.encodeUpdate(first);
|
||||
const secondWire = enc.encodeUpdate(second);
|
||||
expect(firstWire).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondWire).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(dec.decodeUpdate(firstWire)).toEqual(first);
|
||||
expect(dec.decodeUpdate(secondWire)).toEqual(second);
|
||||
});
|
||||
|
||||
test("isolates operation dictionaries between states and subscriptions", () => {
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.states",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "left", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
{ name: "right", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstEncoder = createServiceStateEncoder();
|
||||
const firstDecoder = createServiceStateDecoder();
|
||||
const secondEncoder = createServiceStateEncoder();
|
||||
const secondDecoder = createServiceStateDecoder();
|
||||
firstDecoder.decodeSnapshot(firstEncoder.encodeSnapshot(snapshot));
|
||||
secondDecoder.decodeSnapshot(secondEncoder.encodeSnapshot(snapshot));
|
||||
|
||||
const update = (member: string, sequence: number, revision: number): ServiceProviderUpdate => ({
|
||||
type: "state",
|
||||
member,
|
||||
sequence,
|
||||
ops: [["s", ["revision"], revision]],
|
||||
});
|
||||
const firstLeft = firstEncoder.encodeUpdate(update("left", 1, 1));
|
||||
const firstRight = firstEncoder.encodeUpdate(update("right", 1, 1));
|
||||
const secondLeft = firstEncoder.encodeUpdate(update("left", 2, 2));
|
||||
const secondRight = firstEncoder.encodeUpdate(update("right", 2, 2));
|
||||
expect(firstLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(firstRight).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondLeft).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(secondRight).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(firstDecoder.decodeUpdate(firstLeft)).toEqual(update("left", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(firstRight)).toEqual(update("right", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(secondLeft)).toEqual(update("left", 2, 2));
|
||||
expect(firstDecoder.decodeUpdate(secondRight)).toEqual(update("right", 2, 2));
|
||||
|
||||
const independentLeft = secondEncoder.encodeUpdate(update("left", 1, 1));
|
||||
expect(independentLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondDecoder.decodeUpdate(independentLeft)).toEqual(update("left", 1, 1));
|
||||
|
||||
const leftBase: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "left",
|
||||
sequence: 3,
|
||||
ops: [["r", { revision: 3 }]],
|
||||
};
|
||||
expect(firstDecoder.decodeUpdate(firstEncoder.encodeUpdate(leftBase))).toEqual(leftBase);
|
||||
const thirdRight = firstEncoder.encodeUpdate(update("right", 3, 3));
|
||||
expect(thirdRight).toMatchObject({ ops: [["s", 0, 3]] });
|
||||
expect(firstDecoder.decodeUpdate(thirdRight)).toEqual(update("right", 3, 3));
|
||||
});
|
||||
|
||||
test("creates and removes keyed instance codecs with their lifecycle", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.dialogs",
|
||||
mode: "keyed",
|
||||
instances: [],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
const address = { key: "dialog-1", generation: 1 };
|
||||
const spawned: ServiceProviderUpdate = {
|
||||
type: "spawned",
|
||||
instance: {
|
||||
instance: address,
|
||||
members: [{ name: "request", kind: "state", sequence: 0, ops: [["r", { value: 0 }]] }],
|
||||
},
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(spawned))).toEqual(spawned);
|
||||
const update: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
instance: address,
|
||||
member: "request",
|
||||
sequence: 1,
|
||||
ops: [["s", ["value"], 1]],
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(update))).toEqual(update);
|
||||
const closed: ServiceProviderUpdate = { type: "closed", instance: address };
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(closed))).toEqual(closed);
|
||||
expect(() => enc.encodeUpdate({ ...update, sequence: 2 })).toThrow("Unknown service state");
|
||||
});
|
||||
});
|
||||
|
||||
interface Counter {
|
||||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
const Counter = defineService<Counter>("test.counter");
|
||||
|
||||
test("remote service endpoints publish and clean up provider subscriptions", async () => {
|
||||
const provider = new RemoteServiceProvider([Counter]);
|
||||
const state = replicatedState({ value: 0 });
|
||||
provider.provide(Counter, { state });
|
||||
const endpoint = createRemoteServiceEndpoint(provider);
|
||||
const updates: ServiceProviderUpdate[] = [];
|
||||
const publish = (_subscriptionId: string, update: ServiceProviderUpdate): void => {
|
||||
updates.push(update);
|
||||
};
|
||||
|
||||
await expect(endpoint.invoke(createServiceCatalogueCall(), publish, BACKGROUND_CONTEXT)).resolves.toEqual([
|
||||
{ serviceId: Counter.id, mode: "singleton" },
|
||||
]);
|
||||
await expect(
|
||||
endpoint.invoke(
|
||||
createServiceSubscribeCall("subscription-1", Counter.id, "singleton"),
|
||||
publish,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toMatchObject({ serviceId: Counter.id, mode: "singleton" });
|
||||
|
||||
state.state.value = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(updates).toEqual([{ type: "state", member: "state", sequence: 1, ops: [["s", ["value"], 1]] }]);
|
||||
endpoint.dispose();
|
||||
state.state.value = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(updates).toHaveLength(1);
|
||||
provider.dispose();
|
||||
});
|
||||
679
third_party/pi/packages/chord/test/services.test.ts
vendored
Normal file
679
third_party/pi/packages/chord/test/services.test.ts
vendored
Normal file
@@ -0,0 +1,679 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
createRemoteServiceBinding,
|
||||
defineService,
|
||||
type JsonValue,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceTransport,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
type ModelRef = { provider: string; modelId: string };
|
||||
type ModelsState = {
|
||||
selected: ModelRef | null;
|
||||
revision: number;
|
||||
};
|
||||
interface Models {
|
||||
readonly state: ReplicatedState<ModelsState>;
|
||||
select(model: ModelRef, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
const Models = defineService<Models>("test.models");
|
||||
|
||||
type Question = { question: string };
|
||||
interface QuestionDialogs {
|
||||
readonly request: ReplicatedState<Question>;
|
||||
submit(answer: string, context: Context): Promise<{ accepted: boolean }>;
|
||||
}
|
||||
|
||||
const QuestionDialogs = defineService<QuestionDialogs>("test.question-dialog");
|
||||
|
||||
type EchoPayload = { value: string };
|
||||
interface Echo {
|
||||
echo(payload: EchoPayload, context: Context): Promise<EchoPayload>;
|
||||
}
|
||||
|
||||
const Echo = defineService<Echo>("test.echo");
|
||||
|
||||
interface Timeline {
|
||||
readonly state: ReplicatedState<{ entries: { id: string }[]; retained: { value: number } }>;
|
||||
}
|
||||
|
||||
const Timeline = defineService<Timeline>("test.timeline");
|
||||
|
||||
interface JsonPassthrough {
|
||||
call(value: JsonValue, context: Context): Promise<JsonValue>;
|
||||
}
|
||||
|
||||
interface NonJsonArgument {
|
||||
call(value: Date, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
interface NonJsonResult {
|
||||
call(context: Context): Promise<bigint>;
|
||||
}
|
||||
|
||||
interface NonJsonState {
|
||||
readonly state: ReplicatedState<{ value: undefined }>;
|
||||
}
|
||||
|
||||
describe("remote services", () => {
|
||||
test("checks remote JSON contracts only at compile time", () => {
|
||||
expect(defineService<JsonPassthrough>("test.json-passthrough").local).toBe(false);
|
||||
const defineInvalidContracts = (): void => {
|
||||
// @ts-expect-error Remote service arguments must be JSON-compatible.
|
||||
defineService<NonJsonArgument>("test.non-json-argument");
|
||||
// @ts-expect-error Remote service results must be JSON-compatible.
|
||||
defineService<NonJsonResult>("test.non-json-result");
|
||||
// @ts-expect-error Replicated state must be JSON-compatible.
|
||||
defineService<NonJsonState>("test.non-json-state");
|
||||
};
|
||||
expect(defineInvalidContracts).not.toThrow();
|
||||
expect(defineService<NonJsonArgument>("test.local-non-json", { local: true }).local).toBe(true);
|
||||
});
|
||||
|
||||
test("marks services remotable by default and reserves Chord service IDs", () => {
|
||||
const local = defineService<{ readonly value: string }>("test.local", { local: true });
|
||||
expect(Models.local).toBe(false);
|
||||
expect(local.local).toBe(true);
|
||||
expect(() => defineService("$chord.internal", { local: true })).toThrow(
|
||||
"Service IDs beginning with $chord. are reserved",
|
||||
);
|
||||
expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely");
|
||||
});
|
||||
|
||||
test("tracks mutable source state while publishing immutable revisions", () => {
|
||||
const initial: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initial);
|
||||
let delivered: ModelsState | undefined;
|
||||
const deliveries: string[] = [];
|
||||
const unsubscribe = state.subscribe((value, _context, delivery) => {
|
||||
delivered = value;
|
||||
deliveries.push(delivery.kind);
|
||||
});
|
||||
expect(state.value).toEqual(initial);
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
const hydrated = delivered;
|
||||
|
||||
state.state.selected = { provider: "test", modelId: "one" };
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(state.value).toEqual({ selected: { provider: "test", modelId: "one" }, revision: 1 });
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
expect(hydrated).toEqual({ selected: null, revision: 0 });
|
||||
expect(deliveries).toEqual(["hydrate", "update"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("flushes pending mutations before hydrating a new state subscriber", () => {
|
||||
const state = replicatedState({ entries: [{ id: "one" }] });
|
||||
const first: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => first.push(value));
|
||||
state.state.entries.push({ id: "two" });
|
||||
|
||||
const second: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => second.push(value));
|
||||
|
||||
expect(first).toEqual([{ entries: [{ id: "one" }] }, { entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
expect(second).toEqual([{ entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
});
|
||||
|
||||
test("does not defensively clone method arguments or results", async () => {
|
||||
const provider = new RemoteServiceProvider([Echo]);
|
||||
let received: EchoPayload | undefined;
|
||||
const response: EchoPayload = { value: "response" };
|
||||
provider.provide(Echo, {
|
||||
async echo(payload) {
|
||||
received = payload;
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Echo],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const echo = namespace.use(Echo);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const request: EchoPayload = { value: "request" };
|
||||
|
||||
await expect(echo.echo(request, BACKGROUND_CONTEXT)).resolves.toBe(response);
|
||||
expect(received).toBe(request);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("provides and consumes one singleton with replicated state", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
expect(provider.catalogue).toEqual([{ serviceId: Models.id, mode: "singleton" }]);
|
||||
const initialState: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initialState);
|
||||
let publishedState: ModelsState | undefined;
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select(model, context) {
|
||||
state.state.selected = model;
|
||||
state.state.revision += 1;
|
||||
state.publish(context);
|
||||
publishedState = state.value;
|
||||
},
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
|
||||
const first = namespace.use(Models);
|
||||
const second = namespace.use(Models);
|
||||
expect(first).toBe(second);
|
||||
expect(first.state.value).toBeUndefined();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toEqual(initialState);
|
||||
|
||||
const updates: ModelsState[] = [];
|
||||
const unsubscribe = second.state.subscribe((value) => updates.push(value));
|
||||
await first.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toEqual(publishedState);
|
||||
expect(first.state.value).toEqual({
|
||||
selected: { provider: "test", modelId: "one" },
|
||||
revision: 1,
|
||||
});
|
||||
expect(updates).toEqual([
|
||||
{ selected: null, revision: 0 },
|
||||
{ selected: { provider: "test", modelId: "one" }, revision: 1 },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const lateNamespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const lateModels = lateNamespace.use(Models);
|
||||
await lateNamespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(lateModels.state.value?.revision).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
await Promise.all([namespace.dispose(BACKGROUND_CONTEXT), lateNamespace.dispose(BACKGROUND_CONTEXT)]);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("publishes compact tracked operations through the remote provider", async () => {
|
||||
const provider = new RemoteServiceProvider([Timeline]);
|
||||
const initial = { entries: [{ id: "one" }], retained: { value: 1 } };
|
||||
const source = replicatedState(initial);
|
||||
provider.provide(Timeline, { state: source });
|
||||
const updates: Array<Parameters<Parameters<typeof provider.subscribe>[2]>[0]> = [];
|
||||
const raw = provider.subscribe(Timeline.id, "singleton", (update) => updates.push(update));
|
||||
expect(raw.snapshot.instances[0]?.members).toEqual([
|
||||
{ name: "state", kind: "state", sequence: 0, ops: [["r", initial]] },
|
||||
]);
|
||||
raw.activate();
|
||||
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Timeline],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const timeline = namespace.use(Timeline);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const previous = timeline.state.value;
|
||||
const next = { entries: [{ id: "one" }, { id: "two" }], retained: initial.retained };
|
||||
source.state.entries.push({ id: "two" });
|
||||
source.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(updates).toContainEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["p", ["entries"], 1, 0, [{ id: "two" }]]],
|
||||
});
|
||||
expect(previous).toEqual({ entries: [{ id: "one" }], retained: { value: 1 } });
|
||||
expect(timeline.state.value).toEqual(next);
|
||||
|
||||
source.state.entries.push({ id: "three" });
|
||||
const late = provider.subscribe(Timeline.id, "singleton", () => {});
|
||||
expect(updates.at(-1)).toEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["p", ["entries"], 2, 0, [{ id: "three" }]]],
|
||||
});
|
||||
expect(late.snapshot.instances[0]?.members).toEqual([
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 2,
|
||||
ops: [["r", { entries: [{ id: "one" }, { id: "two" }, { id: "three" }], retained: { value: 1 } }]],
|
||||
},
|
||||
]);
|
||||
late.close();
|
||||
raw.close();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("keeps singleton facades stable when their provider is replaced", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const state = models.state;
|
||||
const select = models.select;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(state.value?.revision).toBe(1);
|
||||
|
||||
provider.withdraw(Models);
|
||||
expect(state.value).toBeUndefined();
|
||||
await expect(select({ provider: "test", modelId: "unavailable" }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
|
||||
code: "service_not_found",
|
||||
});
|
||||
|
||||
const replacementSelect = vi.fn(async () => {});
|
||||
provider.replace(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
|
||||
select: replacementSelect,
|
||||
});
|
||||
|
||||
expect(namespace.use(Models)).toBe(models);
|
||||
expect(models.state).toBe(state);
|
||||
expect(state.value?.revision).toBe(2);
|
||||
await select({ provider: "test", modelId: "replacement" }, BACKGROUND_CONTEXT);
|
||||
expect(replacementSelect).toHaveBeenCalledOnce();
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
async select() {},
|
||||
} as unknown as Models),
|
||||
).toThrow("replacement must preserve its member shape");
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
async state() {},
|
||||
async select() {},
|
||||
} as unknown as Models),
|
||||
).toThrow("replacement must preserve its member shape");
|
||||
expect(state.value?.revision).toBe(2);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("delivers active subscriber updates before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
let delivered = 0;
|
||||
const failing = provider.subscribe(Models.id, "singleton", () => {
|
||||
throw failure;
|
||||
});
|
||||
const succeeding = provider.subscribe(Models.id, "singleton", () => {
|
||||
delivered += 1;
|
||||
});
|
||||
failing.activate();
|
||||
succeeding.activate();
|
||||
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
|
||||
async select() {},
|
||||
}),
|
||||
).toThrow(failure);
|
||||
expect(delivered).toBe(1);
|
||||
|
||||
failing.close();
|
||||
succeeding.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("replays every buffered update before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, { state, async select() {} });
|
||||
let delivered = 0;
|
||||
const subscription = provider.subscribe(Models.id, "singleton", () => {
|
||||
delivered += 1;
|
||||
throw failure;
|
||||
});
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(() => subscription.activate()).toThrow("Failed to activate remote service subscription");
|
||||
expect(delivered).toBe(2);
|
||||
subscription.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("clears retained facades when providers and bindings are disposed", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const state = models.state;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(state.value?.revision).toBe(1);
|
||||
|
||||
provider.dispose();
|
||||
expect(state.value).toBeUndefined();
|
||||
await expect(models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
"Remote service provider is disposed",
|
||||
);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
expect(() => state.value).toThrow("Remote service binding is disposed");
|
||||
});
|
||||
|
||||
test("applies provider disposal buffered while subscriptions are starting", async () => {
|
||||
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
provider.spawn(QuestionDialogs, "pending", {
|
||||
request: replicatedState<Question>({ question: "Pending?" }),
|
||||
async submit() {
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models, QuestionDialogs],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const observed: QuestionDialogs[] = [];
|
||||
namespace.observe(QuestionDialogs, (service) => {
|
||||
observed.push(service);
|
||||
});
|
||||
|
||||
provider.dispose();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(observed).toEqual([]);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("keeps deferred service handles inaccessible until host activation", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
|
||||
async select() {},
|
||||
});
|
||||
let active = false;
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
bound: false,
|
||||
assertAccess() {
|
||||
if (!active) throw new Error("Service handles are not active");
|
||||
},
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
|
||||
expect(() => models.state.value).toThrow("Service handles are not active");
|
||||
expect(() => models.state.subscribe(() => {})).toThrow("Service handles are not active");
|
||||
expect(() => models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).toThrow(
|
||||
"Service handles are not active",
|
||||
);
|
||||
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
active = true;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("rejects namespace readiness when initial hydration fails", async () => {
|
||||
const failure = new Error("initial hydration failed");
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
subscribe: () => Promise.reject(failure),
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
|
||||
await expect(namespace.ready(BACKGROUND_CONTEXT)).rejects.toBe(failure);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toEqual([failure]);
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("buffers state updates that race subscription hydration", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select() {},
|
||||
});
|
||||
const transport: RemoteServiceTransport = {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, listener);
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
const namespace = createRemoteServiceBinding({ services: [Models], transport });
|
||||
const models = namespace.use(Models);
|
||||
const revisions: number[] = [];
|
||||
models.state.subscribe((value) => revisions.push(value.revision));
|
||||
|
||||
await vi.waitFor(() => expect(revisions).toEqual([0, 1]));
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate", 0],
|
||||
["gap", 2],
|
||||
] as const)("clears replicated state after a %s operation sequence", async (_kind, sequence) => {
|
||||
let sendUpdate: ((update: Parameters<Parameters<RemoteServiceTransport["subscribe"]>[2]>[0]) => void) | undefined;
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
async subscribe(_serviceId, _mode, listener) {
|
||||
sendUpdate = (update) => listener(update, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: {
|
||||
serviceId: Models.id,
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "select", kind: "method" },
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 0,
|
||||
ops: [["r", { selected: null, revision: 0 }]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
activate() {},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
sendUpdate?.({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence,
|
||||
ops: [["r", { selected: null, revision: sequence }]],
|
||||
});
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.message).toContain("sequence has a gap");
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("hydrates cold ReplicatedState replicas and replaces them across rebinds", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
bound: false,
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const revisions: number[] = [];
|
||||
models.state.subscribe((value) => revisions.push(value.revision));
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(revisions).toEqual([]);
|
||||
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
expect(revisions).toEqual([1]);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await namespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
state.state.revision = 3;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(3);
|
||||
expect(revisions).toEqual([1, 2, 3]);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("hydrates keyed state before observe handlers and fences reused keys", async () => {
|
||||
const provider = new RemoteServiceProvider([{ service: QuestionDialogs, mode: "keyed" }]);
|
||||
expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]);
|
||||
const transport = createLoopbackServiceTransport(provider);
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [QuestionDialogs],
|
||||
transport,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const observed: {
|
||||
question: Question | undefined;
|
||||
service: QuestionDialogs;
|
||||
context: Context;
|
||||
}[] = [];
|
||||
const stop = namespace.observe(QuestionDialogs, (service, context) => {
|
||||
observed.push({
|
||||
question: service.request.value,
|
||||
service,
|
||||
context,
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() => expect(errors).toEqual([]));
|
||||
|
||||
const firstRequest = replicatedState<Question>({ question: "First?" });
|
||||
const firstSubmit = vi.fn(async () => ({ accepted: true }));
|
||||
const closeFirst = provider.spawn(QuestionDialogs, "invocation-1", {
|
||||
request: firstRequest,
|
||||
submit: firstSubmit,
|
||||
});
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
expect(observed[0]).toMatchObject({ question: { question: "First?" } });
|
||||
|
||||
const firstService = observed[0]!.service;
|
||||
firstRequest.state.question = "Updated?";
|
||||
firstRequest.publish(BACKGROUND_CONTEXT);
|
||||
expect(firstService.request.value).toEqual({ question: "Updated?" });
|
||||
await expect(firstService.submit("yes", BACKGROUND_CONTEXT)).resolves.toEqual({ accepted: true });
|
||||
expect(firstSubmit).toHaveBeenCalledWith("yes", expect.objectContaining({ abortSignal: undefined }));
|
||||
|
||||
const retainedFirstSubmit = firstService.submit;
|
||||
closeFirst();
|
||||
expect(observed[0]!.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => firstService.request.value).toThrow("observation is closed");
|
||||
expect(() => retainedFirstSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
|
||||
const secondRequest = replicatedState<Question>({ question: "Again?" });
|
||||
const closeSecond = provider.spawn(QuestionDialogs, "invocation-1", {
|
||||
request: secondRequest,
|
||||
async submit() {
|
||||
return { accepted: false };
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(observed[1]).toMatchObject({ question: { question: "Again?" } });
|
||||
expect(Object.is(observed[1]!.service, firstService)).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const secondService = observed[1]!.service;
|
||||
const retainedSecondSubmit = secondService.submit;
|
||||
stop();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => secondService.request.value).toThrow("observation is closed");
|
||||
expect(() => retainedSecondSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
closeSecond();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("rejects mode mixing and unsupported members", () => {
|
||||
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
|
||||
async select() {},
|
||||
});
|
||||
expect(() => provider.spawn(Models, "wrong", {} as Models)).toThrow(/singleton/);
|
||||
expect(() =>
|
||||
provider.spawn(QuestionDialogs, "invalid", {
|
||||
request: new Date() as unknown as ReplicatedState<Question>,
|
||||
async submit() {
|
||||
return { accepted: true };
|
||||
},
|
||||
}),
|
||||
).toThrow(/not remotely exposable/);
|
||||
provider.dispose();
|
||||
});
|
||||
});
|
||||
9
third_party/pi/packages/chord/tsconfig.build.json
vendored
Normal file
9
third_party/pi/packages/chord/tsconfig.build.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user