mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
feat: add transactional replicated state
This commit is contained in:
@@ -399,12 +399,12 @@ export default defineFacet({
|
||||
state,
|
||||
async toggle({ active }, ctx) {
|
||||
await c.value(planMode).set(active, ctx); // durable
|
||||
state.state.active = active; state.publish(ctx); // live
|
||||
state.change(ctx, (draft) => { draft.active = active; }); // live
|
||||
},
|
||||
});
|
||||
env.onActivate(async () => { // reopen: rebuild the projection from Pico
|
||||
state.state.active = (await c.value(planMode).get(BACKGROUND_CONTEXT)) ?? false;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
const active = (await c.value(planMode).get(BACKGROUND_CONTEXT)) ?? false;
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => { draft.active = active; });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -453,7 +453,7 @@ export const harnessSessionFacet = defineFacet({
|
||||
await handle.open(storage, { models, processHost }, ctx);
|
||||
handle.onConversation((c) => { // existing at open, and every fork/subagent created later
|
||||
const view = env.replicatedState(c.snapshot()); // built on the line
|
||||
c.attachView(view); // bounded ordered adapter: raw envelopes → view.state + publish(), off the line (below)
|
||||
c.attachView(view); // bounded ordered adapter: raw envelopes → one view.change(), off the line (below)
|
||||
env.own(conversations.spawn(String(c.id), { view, send: (i, cx) => c.send(i, cx).then((r) => r.id), /* … */ }));
|
||||
});
|
||||
});
|
||||
@@ -467,14 +467,12 @@ replicated member; a subscriber reads `value.commit.events` after each delivery.
|
||||
No revision or storage sequence is placed in the view: Chord's
|
||||
`ReplicatedStateDelivery.sequence` is contiguous per member and plays that role.
|
||||
|
||||
Publication must not happen on the Session line. Chord's `publish()` invokes
|
||||
Publication must not happen on the Session line. Chord's `change()` invokes
|
||||
source and subscriber listeners synchronously and a subscriber may throw or be
|
||||
slow, and `subscribe()` itself publishes pending mutations. The bridge is a
|
||||
bounded ordered adapter: the kernel enqueues raw envelopes from its own view
|
||||
tracker; the adapter, off the line, applies one envelope's ops to
|
||||
`view.state`, sets `commit.events`, and calls `publish(ctx)` in the **same
|
||||
synchronous block** (no await between apply and publish). One raw envelope =
|
||||
one publish = one Chord sequence. If the queue overflows or `publish` throws,
|
||||
slow. The bridge is a bounded ordered adapter: the kernel enqueues raw envelopes
|
||||
from its own view tracker; the adapter, off the line, applies one envelope's ops
|
||||
and sets `commit.events` inside one `view.change(ctx, callback)` transaction.
|
||||
One raw envelope = one change = one Chord sequence. If the queue overflows or `change` throws,
|
||||
close that keyed instance and respawn it with a fresh snapshot; a persisted Pico
|
||||
commit is never turned into a failure. Chord's subscription snapshot then gives
|
||||
late joiners the member at its current sequence, so §9 of
|
||||
|
||||
@@ -499,11 +499,9 @@ rules are:
|
||||
- No `AsyncLocalStorage`: line re-entry is detected through the Chord context
|
||||
the Session hands to its callbacks (`hardening-handoff.md` §7).
|
||||
- Chord bridge (`plugins.md` §6.1): a bounded ordered adapter applies one raw
|
||||
envelope's ops to the Chord `MutableReplicatedState` and calls `publish()` in
|
||||
the same synchronous block (Chord's `subscribe()` publishes pending mutations
|
||||
itself, so apply and publish must not be separated by an await); overflow or
|
||||
publish failure closes and respawns that instance; a persisted commit is never
|
||||
failed by a listener.
|
||||
envelope's ops inside one Chord `MutableReplicatedState.change()` transaction;
|
||||
overflow or publication failure closes and respawns that instance, and a
|
||||
persisted commit is never failed by a listener.
|
||||
- Tests: for every row in §6, assert the exact ops and events of that commit
|
||||
(memory backend, fake provider), plus snapshot-equals-fold after every
|
||||
envelope and a head-in-parent capture case.
|
||||
|
||||
@@ -1682,12 +1682,11 @@ Pi/application concerns, not Chord concepts.
|
||||
history, invalidate the old binding, hydrate the new one and restart its live counter. Reject
|
||||
deliveries from retired bindings. A periodic checkpoint does not by itself restart an existing
|
||||
replication stream or require a client-visible update when the state is unchanged.
|
||||
- **Actual Chord boundary.** `replicatedState(initial)` exposes tracked `.state`, published `.value`
|
||||
and synchronous `.publish(context)`; its public subscription delivers complete values. Its
|
||||
decoded-operation subscription and producer registration are private. Moreover, subscribing to
|
||||
a producer or its service can flush pending mutations. Therefore mutating an exposed producer
|
||||
and then awaiting storage is NOT a safe persistence adapter. Draft tracking must remain private
|
||||
until commit; the exact supported Chord adapter seam is still to be designed (§25).
|
||||
- **Actual Chord boundary.** `replicatedState(initial)` exposes immutable `.value` plus synchronous
|
||||
atomic `.change(context, callback)` and `.replace(context, value)` operations; its public
|
||||
subscription delivers complete values. A change callback receives a transaction-scoped
|
||||
copy-on-write draft that is revoked on return. A thrown callback publishes nothing and preserves
|
||||
the exact prior value. Decoded-operation subscription and producer registration remain private.
|
||||
- **Delta ownership and encoding.** Chord Delta publicly supplies `track`, `apply`, `applyImmutable`,
|
||||
`encoder` and `decoder`. Decoded `Op[]` carry complete paths; compressed `WireOp[]` may depend on
|
||||
prior path definitions. A complete replacement resets those dictionaries. Arbitrary wire batches
|
||||
|
||||
@@ -205,29 +205,29 @@ export const providersBuiltinSessionFacet = defineFacet({
|
||||
if (configuration.model === null) return;
|
||||
const spec = findSpec(catalog, configuration.model);
|
||||
if (spec === undefined || !spec.reasoning) return;
|
||||
state.set(
|
||||
{
|
||||
...state.value,
|
||||
configuration: {
|
||||
...configuration,
|
||||
thinkingLevel: nextThinkingLevel(configuration.thinkingLevel),
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
state.change(context, (draft) => {
|
||||
draft.configuration.thinkingLevel = nextThinkingLevel(configuration.thinkingLevel);
|
||||
});
|
||||
},
|
||||
|
||||
async select(model, context) {
|
||||
const spec = findSpec(state.value.catalog, model);
|
||||
if (spec === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
|
||||
const thinkingLevel = spec.reasoning ? state.value.configuration.thinkingLevel : "off";
|
||||
state.set({ ...state.value, configuration: { model, thinkingLevel } }, context);
|
||||
state.change(context, (draft) => {
|
||||
draft.configuration = { model, thinkingLevel };
|
||||
});
|
||||
},
|
||||
|
||||
async refresh(context) {
|
||||
state.set({ ...state.value, refresh: { status: "refreshing" } }, context);
|
||||
state.change(context, (draft) => {
|
||||
draft.refresh = { status: "refreshing" };
|
||||
});
|
||||
const errors = await providers.refresh(context.abortSignal);
|
||||
state.set({ ...state.value, catalog: providers.snapshot(), refresh: toRefreshStatus(errors) }, context);
|
||||
state.change(context, (draft) => {
|
||||
draft.catalog = providers.snapshot();
|
||||
draft.refresh = toRefreshStatus(errors);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -403,11 +403,13 @@ interface ReplicatedState<T> {
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void;
|
||||
}
|
||||
|
||||
interface MutableReplicatedState<T> extends ReplicatedState<T> {
|
||||
/** A providing state is always initialized. */
|
||||
interface MutableReplicatedState<T extends object> extends ReplicatedState<T> {
|
||||
/** A providing state is always initialized and immutable. */
|
||||
readonly value: T;
|
||||
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
|
||||
set(value: T, context: Context): void;
|
||||
/** Atomically publishes one copy-on-write transaction. */
|
||||
change(context: Context, mutate: (draft: Draft<T>) => void): void;
|
||||
/** Atomically replaces the complete value with a detached snapshot. */
|
||||
replace(context: Context, value: T): void;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -417,9 +419,9 @@ Required behavior:
|
||||
2. A cold remote replica has no value. Its `.value` is `undefined`, and `subscribe()` registers the listener without invoking it. This `undefined` is local readiness state and never crosses the wire.
|
||||
3. **Hydration** installs a complete snapshot atomically before updates flow. Subscribing before hydration is valid, and updates emitted concurrently with the snapshot are buffered, so the listener observes snapshot then updates with no gap.
|
||||
4. Once hydrated, `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. Snapshot hydration uses a fresh delivery context parented to the subscription; later updates reconstruct fresh delivery contexts from source trace metadata.
|
||||
5. State values are borrowed immutable JSON. The state runtime does not defensively clone reads, writes, snapshots, or listener deliveries. Callers transfer ownership to `set()` and must not mutate or retain values returned by `.value` or passed to listeners; copy explicitly when ownership is required. Process and transport serialization may naturally produce a detached value, but callers must not depend on object identity or detachment.
|
||||
5. State values are immutable JSON. Reads and listener deliveries return the immutable revision directly. `change()` creates lazy copy-on-write draft proxies, copies assigned containers by value, structurally shares unchanged subtrees, and revokes every draft when the callback returns.
|
||||
6. Disconnect, provider withdrawal, and route switching clear readiness, so `.value` becomes `undefined`. Reconnect or singleton replacement installs a complete fresh snapshot in the existing member facade before later updates flow. A presentation that wants stale display data must retain it separately alongside connection or attachment health.
|
||||
7. `set(value, context)` passes its context to local source listeners and publishes source trace metadata. Remote delivery reconstructs a fresh local `Context`; it never retains the source context object.
|
||||
7. A successful `change()` or `replace()` passes its context to local source listeners and publishes source trace metadata. If a change callback throws, the original value and sequence remain unchanged. Remote delivery reconstructs a fresh local `Context`; it never retains the source context object.
|
||||
|
||||
Anything a consumer must recover after reconnect is exposed as replicated state or pulled through a remote method. Replicated state is latest-value replication, not by itself durable session storage; the providing facet must reconstruct its authoritative value after a worker restart.
|
||||
|
||||
@@ -576,12 +578,18 @@ export const sessionDirectoryServerFacet = defineFacet({
|
||||
const state = env.replicatedState({ revision: 0, sessions: [] as SessionRecordSummary[] });
|
||||
|
||||
function publish(_change: ManagedSessionChange, context: Context) {
|
||||
state.set({ revision: state.value.revision + 1, sessions: managed.snapshot().map(toSummary) }, context);
|
||||
state.change(context, (draft) => {
|
||||
draft.revision += 1;
|
||||
draft.sessions = managed.snapshot().map(toSummary);
|
||||
});
|
||||
}
|
||||
|
||||
env.own(managed.onChanged(publish));
|
||||
env.onActivate(() =>
|
||||
state.set({ revision: 1, sessions: managed.snapshot().map(toSummary) }, BACKGROUND_CONTEXT),
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 1;
|
||||
draft.sessions = managed.snapshot().map(toSummary);
|
||||
}),
|
||||
);
|
||||
|
||||
env.provide(SessionDirectory, { state });
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type Context, defineService, type MutableReplicatedState, type ReplicatedState } from "@earendil-works/chord";
|
||||
import {
|
||||
type Context,
|
||||
type Draft,
|
||||
defineService,
|
||||
type MutableReplicatedState,
|
||||
type ReplicatedState,
|
||||
} from "@earendil-works/chord";
|
||||
import type { Op, Path, Seg } from "@earendil-works/chord/delta";
|
||||
import type { ConversationHandle, Harness } from "./harness.ts";
|
||||
import type {
|
||||
@@ -109,9 +115,10 @@ function bridgeWatch(
|
||||
while (!closed && queue.length > 0) {
|
||||
const envelope = queue.shift()!;
|
||||
try {
|
||||
applyTracked(view.state, envelope.ops);
|
||||
view.state.commit.events = structuredClone(envelope.events);
|
||||
view.publish(ctx);
|
||||
view.change(ctx, (draft) => {
|
||||
applyTracked(draft, envelope.ops);
|
||||
draft.commit.events = envelope.events as Draft<ViewEvent[]>;
|
||||
});
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
@@ -142,14 +149,29 @@ function bridgeWatch(
|
||||
};
|
||||
}
|
||||
|
||||
function applyTracked(root: PublishedConversationView, ops: readonly Op[]): void {
|
||||
function applyTracked(root: Draft<PublishedConversationView>, ops: readonly Op[]): void {
|
||||
for (const op of ops) {
|
||||
if (op[0] === "r") throw new Error("live Pico envelope unexpectedly replaced the view root");
|
||||
const path = op[1];
|
||||
if (op[0] === "p") {
|
||||
const target = resolve(root, path);
|
||||
if (!Array.isArray(target)) throw new Error(`Pico splice path is not an array: ${path.join(".")}`);
|
||||
target.splice(op[2], op[3], ...structuredClone(op[4]));
|
||||
(target as unknown[]).splice(op[2], op[3], ...op[4]);
|
||||
continue;
|
||||
}
|
||||
if (op[0] === "m") {
|
||||
const target = resolve(root, path);
|
||||
if (!Array.isArray(target) || target.length !== op[2].length) {
|
||||
throw new Error(`Pico permutation path is not a matching array: ${path.join(".")}`);
|
||||
}
|
||||
const array = target as unknown[];
|
||||
const previous = array.slice();
|
||||
const rank = new Map<unknown, number>();
|
||||
for (let index = 0; index < op[2].length; index++) {
|
||||
const value = previous[op[2][index]!]!;
|
||||
if (!rank.has(value)) rank.set(value, index);
|
||||
}
|
||||
array.sort((left, right) => rank.get(left)! - rank.get(right)!);
|
||||
continue;
|
||||
}
|
||||
const parent = resolve(root, path.slice(0, -1));
|
||||
@@ -162,7 +184,7 @@ function applyTracked(root: PublishedConversationView, ops: readonly Op[]): void
|
||||
continue;
|
||||
}
|
||||
const record = parent as Record<PropertyKey, unknown>;
|
||||
if (op[0] === "s") record[key] = structuredClone(op[2]);
|
||||
if (op[0] === "s") record[key] = op[2];
|
||||
else if (op[0] === "a") record[key] = `${String(record[key])}${op[2]}`;
|
||||
else record[key] = String(record[key]).slice(op[2]);
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@ test("Chord publication failure closes only the bridge and never rejects the per
|
||||
get value() {
|
||||
return state.value;
|
||||
},
|
||||
get state() {
|
||||
return state.state;
|
||||
},
|
||||
subscribe: (listener) => state.subscribe(listener),
|
||||
publish() {
|
||||
change() {
|
||||
throw new Error("publish failed");
|
||||
},
|
||||
replace() {
|
||||
throw new Error("publish failed");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ The names below are provisional, but the distinctions are required.
|
||||
- **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.
|
||||
- **Replicated state**: initialized immutable source revisions 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.
|
||||
|
||||
@@ -361,7 +361,7 @@ An instance address is `(service ID, key, generation)`. Reusing a closed key cre
|
||||
|
||||
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.
|
||||
The source API has an initialized immutable value, `change(context, callback)`, `replace(context, value)`, and subscriptions. A remote or disconnected replica has `value === undefined` until hydration.
|
||||
|
||||
Required semantics:
|
||||
|
||||
@@ -371,12 +371,12 @@ Required semantics:
|
||||
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.
|
||||
7. The source API exposes atomic copy-on-write transactions. A successful transaction publishes one decoded operation batch; a callback failure discards all tentative copies. Connection adapters encode each batch 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.
|
||||
12. Values are immutable data. Chord returns raw immutable values for reads and listener delivery. Transaction drafts are revocable and assigned containers are copied by value; committed revisions structurally share unchanged subtrees.
|
||||
|
||||
State identity is structural:
|
||||
|
||||
@@ -392,10 +392,9 @@ Explicit non-goals:
|
||||
- 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.
|
||||
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 a transaction-scoped copy-on-write draft; Chord derives compact operations from the previous and next immutable revisions and uses a complete replacement when that is smaller. 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
|
||||
|
||||
|
||||
+22
-16
@@ -32,11 +32,12 @@ The design has a few connected pieces:
|
||||
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.
|
||||
connected consumers. Producers publish atomic copy-on-write transactions with
|
||||
`change(context, callback)`; consumers receive complete immutable values. Draft
|
||||
proxies exist only during the callback and are revoked afterward. Chord compares
|
||||
the previous and next immutable revisions to produce one decoded operation batch,
|
||||
while each remote client/state stream owns independent path-codec state. Replicas
|
||||
become unready on disconnect or replacement until they are rehydrated.
|
||||
|
||||
- **Delta tracking** records and coalesces operations over tracked plain JSON.
|
||||
It preserves common string and array operations, supports durable base
|
||||
@@ -104,22 +105,27 @@ 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:
|
||||
revisions. Replicated state instead uses transaction-scoped copy-on-write drafts:
|
||||
|
||||
```ts
|
||||
const status = env.replicatedState({ output: "", count: 0 });
|
||||
status.state.output += "done\n";
|
||||
status.state.count += 1;
|
||||
status.publish(context);
|
||||
status.change(context, (draft) => {
|
||||
draft.output += "done\n";
|
||||
draft.count += 1;
|
||||
});
|
||||
```
|
||||
|
||||
`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.
|
||||
A successful `change()` publishes exactly one atomic revision. If its callback
|
||||
throws, the original value and sequence remain unchanged. Draft handles are revoked
|
||||
when the callback returns and assigned containers are copied by value. Unchanged
|
||||
subtrees are shared between immutable revisions. Chord derives string append and
|
||||
front-truncate operations, array splices and permutations, sets, and deletes from
|
||||
the two revisions; a large delta falls back to a complete snapshot. Remote
|
||||
connection plumbing encodes each batch independently for every client/state
|
||||
pairing. `replace(context, value)` publishes a detached complete value directly.
|
||||
|
||||
The standalone [Delta guide](src/delta/README.md) documents the lower-level mutable
|
||||
tracker, which remains available separately from replicated state.
|
||||
|
||||
## Bundling and loading facets
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ 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()`.
|
||||
deleting, updating a string, splicing an array, or permuting an array. Producers
|
||||
use `track()`; replicas use `apply()` or `applyImmutable()`.
|
||||
|
||||
```ts
|
||||
import { apply, track } from "@earendil-works/chord/delta";
|
||||
@@ -26,8 +26,9 @@ produce a redundant batch.
|
||||
|
||||
`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.
|
||||
Chord's replicated-state producers use a separate transaction-scoped
|
||||
copy-on-write draft and derive these operation batches from immutable revisions;
|
||||
consumers still observe complete immutable values.
|
||||
|
||||
## Sending or storing changes
|
||||
|
||||
@@ -87,9 +88,10 @@ A path is an array of object keys and array indices:
|
||||
| `["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. |
|
||||
| `["m", path, permutation]` | Reorder an array so `new[i] = old[permutation[i]]`. |
|
||||
|
||||
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.
|
||||
`a`, and `t` cannot address the root. `p` and `m` may address a root array.
|
||||
|
||||
### Encoded `WireOp`
|
||||
|
||||
@@ -110,6 +112,8 @@ A `PathRef` is either an inline path or a non-negative numeric path ID.
|
||||
| `["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. |
|
||||
| `["m", pathRef, permutation]` | Reorder with an inline or interned path. |
|
||||
| `["m", permutation]` | Reorder using the previous path. |
|
||||
|
||||
For example, adjacent decoded operations on one path:
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ export type PathRef<P extends Path = Path> = P | number;
|
||||
* Tuples are the form — in memory, on the wire, on disk.
|
||||
*
|
||||
* `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target
|
||||
* the root: the type forbids it. `p` may, because a tracked value can itself be
|
||||
* an array. Operation shape is not canonical: an array may be emptied by either
|
||||
* the root: the type forbids it. `p` and `m` may, because a tracked value can
|
||||
* itself be an array. Operation shape is not canonical: an array may be emptied by either
|
||||
* a replacement or a root splice.
|
||||
*
|
||||
* `Op` knows nothing about the path dictionary. Interning, id references and
|
||||
@@ -33,7 +33,9 @@ export type Op =
|
||||
| readonly ["d", NonEmptyPath]
|
||||
| readonly ["a", NonEmptyPath, string]
|
||||
| readonly ["t", NonEmptyPath, number]
|
||||
| readonly ["p", Path, number, number, JsonValue[]];
|
||||
| readonly ["p", Path, number, number, JsonValue[]]
|
||||
/** Reorder an array in place: `new[i] = old[permutation[i]]`. */
|
||||
| readonly ["m", Path, number[]];
|
||||
|
||||
/**
|
||||
* What crosses a boundary. Adds two compressions and nothing else:
|
||||
@@ -57,6 +59,8 @@ export type WireOp =
|
||||
| readonly ["t", number]
|
||||
| readonly ["p", PathRef, number, number, JsonValue[]]
|
||||
| readonly ["p", number, number, JsonValue[]]
|
||||
| readonly ["m", PathRef, number[]]
|
||||
| readonly ["m", number[]]
|
||||
| readonly ["#", number, Path];
|
||||
|
||||
// ─── Classification ──────────────────────────────────────────────────────────
|
||||
@@ -1493,6 +1497,11 @@ export function assertValidOp(op: unknown): asserts op is Op {
|
||||
if (!Array.isArray(op[4])) throw new TypeError("p items");
|
||||
return;
|
||||
}
|
||||
case "m":
|
||||
if (op.length !== 3) throw new TypeError("m arity");
|
||||
assertPathArg(op[1]);
|
||||
assertPermutation(op[2]);
|
||||
return;
|
||||
// Silently skipping an unknown verb is how a newer producer's op vanishes.
|
||||
default:
|
||||
throw new TypeError(`unknown op verb: ${String(op[0])}`);
|
||||
@@ -1505,6 +1514,17 @@ function assertPathArg(p: unknown, nonEmpty = false): void {
|
||||
assertSafePath(p as Path);
|
||||
}
|
||||
|
||||
function assertPermutation(value: unknown): asserts value is number[] {
|
||||
if (!Array.isArray(value)) throw new TypeError("m permutation is not an array");
|
||||
const seen = new Uint8Array(value.length);
|
||||
for (const index of value) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= value.length || seen[index] !== 0) {
|
||||
throw new TypeError("m permutation is not a bijection");
|
||||
}
|
||||
seen[index] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** The same, for the wire grammar: ids and short forms are legal here. */
|
||||
export function assertValidWireOp(op: unknown): asserts op is WireOp {
|
||||
if (!Array.isArray(op) || op.length === 0) throw new TypeError("op is not a tuple");
|
||||
@@ -1556,6 +1576,11 @@ export function assertValidWireOp(op: unknown): asserts op is WireOp {
|
||||
if (!Array.isArray(items)) throw new TypeError("p items");
|
||||
return;
|
||||
}
|
||||
case "m":
|
||||
if (op.length === 3) okRef(op[1]);
|
||||
else if (op.length !== 2) throw new TypeError("m arity");
|
||||
assertPermutation(op[op.length - 1]);
|
||||
return;
|
||||
case "#": {
|
||||
if (op.length !== 3 || !Number.isInteger(op[1]) || (op[1] as number) < 0 || !Array.isArray(op[2])) {
|
||||
throw new TypeError("# shape");
|
||||
@@ -1650,6 +1675,13 @@ function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (op[0] === "m") {
|
||||
const target_ = path.length === 0 ? root : resolve(root, path);
|
||||
if (!Array.isArray(target_) || target_.length !== op[2].length) throw new PathError(path);
|
||||
const previous = target_.slice();
|
||||
for (let index = 0; index < op[2].length; index++) target_[index] = previous[op[2][index]!]!;
|
||||
continue;
|
||||
}
|
||||
|
||||
// s/d/a/t can never target the root — the type forbids it.
|
||||
const parent = resolve(root, path.slice(0, -1)) as Record<Seg, JsonValue>;
|
||||
@@ -1700,7 +1732,7 @@ export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T
|
||||
root = op[1];
|
||||
continue;
|
||||
}
|
||||
root = copyContainers(root, op[0] === "p" ? op[1] : op[1].slice(0, -1));
|
||||
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1));
|
||||
root = applyOps(root, [op]);
|
||||
}
|
||||
return root as unknown as T;
|
||||
@@ -1831,6 +1863,9 @@ export function encoder(): Encoder {
|
||||
case "p":
|
||||
out.push(["p", op[2], op[3], op[4]]);
|
||||
break;
|
||||
case "m":
|
||||
out.push(["m", op[2]]);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1864,6 +1899,9 @@ export function encoder(): Encoder {
|
||||
case "p":
|
||||
out.push(["p", ref, op[2], op[3], op[4]]);
|
||||
break;
|
||||
case "m":
|
||||
out.push(["m", ref, op[2]]);
|
||||
break;
|
||||
}
|
||||
previous = key;
|
||||
}
|
||||
@@ -1919,7 +1957,7 @@ export function decoder(): Decoder {
|
||||
previous = path;
|
||||
}
|
||||
|
||||
if (op[0] !== "p" && path.length === 0) throw new PathError(path);
|
||||
if (op[0] !== "p" && op[0] !== "m" && path.length === 0) throw new PathError(path);
|
||||
switch (op[0]) {
|
||||
case "s":
|
||||
out.push(["s", path as NonEmptyPath, (short ? op[1] : op[2]) as JsonValue]);
|
||||
@@ -1940,6 +1978,9 @@ export function decoder(): Decoder {
|
||||
out.push(["p", path, i, r, items]);
|
||||
break;
|
||||
}
|
||||
case "m":
|
||||
out.push(["m", path, (short ? op[1] : op[2]) as number[]]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
type WireServiceProviderUpdate,
|
||||
type WireServiceSubscriptionSnapshot,
|
||||
} from "./services/wire.ts";
|
||||
export type { Draft } from "./state/draft.ts";
|
||||
export type {
|
||||
Context,
|
||||
ContextKey,
|
||||
|
||||
@@ -42,6 +42,7 @@ interface ProviderInstance {
|
||||
interface ProviderSubscriber {
|
||||
readonly listener: (update: ServiceProviderUpdate, context: Context) => void;
|
||||
readonly buffer: { readonly update: ServiceProviderUpdate; readonly context: Context }[];
|
||||
readonly snapshotSequences: Map<string, number>;
|
||||
active: boolean;
|
||||
terminated: boolean;
|
||||
closed: boolean;
|
||||
@@ -248,13 +249,14 @@ export class RemoteServiceProvider {
|
||||
const subscriber: ProviderSubscriber = {
|
||||
listener,
|
||||
buffer: [],
|
||||
snapshotSequences: new Map(),
|
||||
active: false,
|
||||
terminated: false,
|
||||
closed: false,
|
||||
};
|
||||
this.#publishPending(registration);
|
||||
registration.subscribers.add(subscriber);
|
||||
const snapshot = this.#snapshot(registration);
|
||||
recordSnapshotSequences(subscriber.snapshotSequences, snapshot.instances);
|
||||
return {
|
||||
snapshot,
|
||||
activate: () => {
|
||||
@@ -417,21 +419,6 @@ export class RemoteServiceProvider {
|
||||
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"
|
||||
@@ -469,7 +456,7 @@ export class RemoteServiceProvider {
|
||||
const deliveryContext = context ?? serviceDeliveryContext();
|
||||
const errors: unknown[] = [];
|
||||
for (const subscriber of registration.subscribers) {
|
||||
if (subscriber.closed) continue;
|
||||
if (subscriber.closed || updateCoveredBySnapshot(subscriber.snapshotSequences, update)) continue;
|
||||
const entry = { update, context: deliveryContext };
|
||||
if (!subscriber.active) {
|
||||
subscriber.buffer.push(entry);
|
||||
@@ -499,6 +486,48 @@ export class RemoteServiceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function recordSnapshotSequences(sequences: Map<string, number>, instances: readonly ServiceInstanceSnapshot[]): void {
|
||||
for (const instance of instances) {
|
||||
for (const member of instance.members) {
|
||||
if (member.kind === "state") sequences.set(stateMemberKey(instance.instance, member.name), member.sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCoveredBySnapshot(sequences: Map<string, number>, update: ServiceProviderUpdate): boolean {
|
||||
switch (update.type) {
|
||||
case "state": {
|
||||
const key = stateMemberKey(update.instance, update.member);
|
||||
const sequence = sequences.get(key);
|
||||
if (sequence === undefined) return false;
|
||||
if (update.sequence <= sequence) return true;
|
||||
sequences.delete(key);
|
||||
return false;
|
||||
}
|
||||
case "replaced":
|
||||
sequences.clear();
|
||||
recordSnapshotSequences(sequences, [update.snapshot]);
|
||||
return false;
|
||||
case "spawned":
|
||||
recordSnapshotSequences(sequences, [update.instance]);
|
||||
return false;
|
||||
case "unavailable":
|
||||
sequences.clear();
|
||||
return false;
|
||||
case "closed": {
|
||||
const prefix = `${JSON.stringify([update.instance.key, update.instance.generation]).slice(0, -1)},`;
|
||||
for (const key of sequences.keys()) {
|
||||
if (key.startsWith(prefix)) sequences.delete(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stateMemberKey(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return JSON.stringify(instance === undefined ? [member] : [instance.key, instance.generation, member]);
|
||||
}
|
||||
|
||||
export function createRemoteServiceEndpoint(provider: RemoteServiceProvider): RemoteServiceEndpoint {
|
||||
const subscriptions = new Map<string, ServiceSubscription>();
|
||||
let disposed = false;
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { applyImmutable, isBase, type Op, type Tracker, track } from "../delta/index.ts";
|
||||
import { applyImmutable, isBase, type Op } from "../delta/index.ts";
|
||||
import { diffRevisions } from "../state/diff.ts";
|
||||
import { produceWithMetadata } from "../state/draft.ts";
|
||||
import { JsonRevisionStore } from "../state/value.ts";
|
||||
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState, ReplicatedStateDelivery } from "../types.ts";
|
||||
import { registerReplicatedStateInternals } from "./state-internals.ts";
|
||||
|
||||
type Publication<T> = {
|
||||
value: T;
|
||||
ops: readonly Op[];
|
||||
sequence: number;
|
||||
context: Context;
|
||||
};
|
||||
|
||||
export class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #listeners = new Map<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void, number>();
|
||||
readonly #sourceListeners = new Set<(ops: readonly Op[], sequence: number, context: Context) => void>();
|
||||
readonly #tracker: Tracker<T>;
|
||||
#publishedValue: T;
|
||||
readonly #store = new JsonRevisionStore();
|
||||
readonly #publications: Publication<T>[] = [];
|
||||
#value: T;
|
||||
#sequence = 0;
|
||||
#changing = false;
|
||||
#delivering = false;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.#tracker = track(initial);
|
||||
this.#publishedValue = applyImmutable(undefined, this.#tracker.flush()) as unknown as T;
|
||||
this.#value = this.#store.import(initial);
|
||||
const thisSource = this;
|
||||
registerReplicatedStateInternals(this, {
|
||||
get sequence() {
|
||||
return thisSource.#sequence;
|
||||
},
|
||||
get value() {
|
||||
return thisSource.#publishedValue;
|
||||
return thisSource.#value;
|
||||
},
|
||||
publish: (context) => thisSource.publish(context),
|
||||
subscribe: (listener) => {
|
||||
thisSource.#sourceListeners.add(listener);
|
||||
return () => thisSource.#sourceListeners.delete(listener);
|
||||
@@ -30,36 +41,86 @@ export class MutableReplicatedStateImpl<T extends object> implements MutableRepl
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#publishedValue;
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
get state(): T {
|
||||
return this.#tracker.state;
|
||||
change(context: Context, mutate: Parameters<MutableReplicatedState<T>["change"]>[1]): void {
|
||||
if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback");
|
||||
this.#changing = true;
|
||||
let next: T;
|
||||
try {
|
||||
const produced = produceWithMetadata(this.#value, mutate);
|
||||
if (produced.value === this.#value) return;
|
||||
next = this.#store.commit(produced.value, produced.owned);
|
||||
} finally {
|
||||
this.#changing = false;
|
||||
}
|
||||
this.#commit(next, context);
|
||||
}
|
||||
|
||||
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);
|
||||
replace(context: Context, value: T): void {
|
||||
if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback");
|
||||
this.#commit(this.#store.import(value), context);
|
||||
}
|
||||
|
||||
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 });
|
||||
const sequence = this.#sequence;
|
||||
const value = this.#value;
|
||||
this.#listeners.set(listener, sequence);
|
||||
try {
|
||||
listener(value, serviceDeliveryContext(), { kind: "hydrate", sequence });
|
||||
} catch (error) {
|
||||
this.#listeners.delete(listener);
|
||||
throw error;
|
||||
}
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
#commit(next: T, context: Context): void {
|
||||
const ops = diffRevisions(this.#value as unknown as JsonValue, next as unknown as JsonValue);
|
||||
if (ops.length === 0) return;
|
||||
this.#value = next;
|
||||
this.#sequence += 1;
|
||||
this.#publications.push({ value: next, ops, sequence: this.#sequence, context });
|
||||
if (this.#delivering) return;
|
||||
this.#delivering = true;
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
for (
|
||||
let publication = this.#publications.shift();
|
||||
publication !== undefined;
|
||||
publication = this.#publications.shift()
|
||||
) {
|
||||
for (const listener of [...this.#sourceListeners]) {
|
||||
try {
|
||||
listener(publication.ops, publication.sequence, publication.context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
const delivery = { kind: "update", sequence: publication.sequence } as const;
|
||||
for (const [listener, hydratedSequence] of [...this.#listeners]) {
|
||||
if (publication.sequence <= hydratedSequence) continue;
|
||||
try {
|
||||
listener(publication.value, publication.context, delivery);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#delivering = false;
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Replicated state listeners failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
readonly #store = new JsonRevisionStore();
|
||||
#value: T | undefined;
|
||||
#sequence: number | undefined;
|
||||
|
||||
@@ -83,10 +144,16 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
|
||||
}
|
||||
|
||||
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);
|
||||
let next: T;
|
||||
try {
|
||||
if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch");
|
||||
next = this.#store.adopt(applyImmutable<T>(undefined, ops));
|
||||
} catch (error) {
|
||||
this.clear();
|
||||
throw error;
|
||||
}
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#value = next;
|
||||
this.#deliverAll(context, { kind: "hydrate", sequence });
|
||||
}
|
||||
|
||||
@@ -98,9 +165,15 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
|
||||
this.clear();
|
||||
throw new Error("Replicated state update sequence has a gap");
|
||||
}
|
||||
const value = applyImmutable(this.#value, ops);
|
||||
let next: T;
|
||||
try {
|
||||
next = this.#store.adopt(applyImmutable(this.#value, ops));
|
||||
} catch (error) {
|
||||
this.clear();
|
||||
throw error;
|
||||
}
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#value = next;
|
||||
this.#deliverAll(context, { kind: "update", sequence });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from "../delta/index.ts";
|
||||
import type { JsonValue } from "../types.ts";
|
||||
|
||||
const DEFAULT_OVERLAP_SCAN = 65_536;
|
||||
const MAX_DELTA_OPERATIONS = 4_096;
|
||||
|
||||
const isContainer = (value: JsonValue): value is JsonValue[] | Record<string, JsonValue> =>
|
||||
value !== null && typeof value === "object";
|
||||
|
||||
const emitSet = (path: Path, value: JsonValue, operations: Op[]): void => {
|
||||
if (path.length === 0) operations.push(["r", value]);
|
||||
else operations.push(["s", path as NonEmptyPath, value]);
|
||||
};
|
||||
|
||||
const equalJson = (left: JsonValue, right: JsonValue): boolean => {
|
||||
if (left === right) return true;
|
||||
if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (Array.isArray(left)) {
|
||||
const other = right as JsonValue[];
|
||||
if (left.length !== other.length) return false;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (!equalJson(left[index]!, other[index]!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const other = right as Record<string, JsonValue>;
|
||||
const keys = Object.keys(left);
|
||||
if (keys.length !== Object.keys(other).length) return false;
|
||||
for (const key of keys) {
|
||||
if (!Object.hasOwn(other, key) || !equalJson(left[key]!, other[key]!)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): number[] | undefined => {
|
||||
if (before.length !== after.length) return undefined;
|
||||
const positions = new Map<JsonValue, { indices: number[]; used: number }>();
|
||||
for (let index = 0; index < before.length; index++) {
|
||||
const value = before[index]!;
|
||||
const entry = positions.get(value);
|
||||
if (entry === undefined) positions.set(value, { indices: [index], used: 0 });
|
||||
else entry.indices.push(index);
|
||||
}
|
||||
const result = new Array<number>(after.length);
|
||||
for (let index = 0; index < after.length; index++) {
|
||||
const entry = positions.get(after[index]!);
|
||||
if (entry === undefined || entry.used === entry.indices.length) return undefined;
|
||||
result[index] = entry.indices[entry.used++]!;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => {
|
||||
if (before === after) return;
|
||||
if (after.length > before.length && after.slice(0, before.length) === before) {
|
||||
operations.push(["a", path, after.slice(before.length)]);
|
||||
return;
|
||||
}
|
||||
const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN);
|
||||
if (shared === 0) {
|
||||
operations.push(["s", path, after]);
|
||||
return;
|
||||
}
|
||||
operations.push(["t", path, before.length - shared]);
|
||||
if (after.length > shared) operations.push(["a", path, after.slice(shared)]);
|
||||
};
|
||||
|
||||
const emitSameLengthShift = (
|
||||
before: readonly JsonValue[],
|
||||
after: readonly JsonValue[],
|
||||
path: Path,
|
||||
operations: Op[],
|
||||
): boolean => {
|
||||
const length = before.length;
|
||||
if (length < 2) return false;
|
||||
let candidates = 0;
|
||||
for (let removed = 1; removed < length && candidates < 16; removed++) {
|
||||
if (before[removed] !== after[0]) continue;
|
||||
candidates += 1;
|
||||
let matches = true;
|
||||
for (let index = removed; index < length; index++) {
|
||||
if (before[index] !== after[index - removed]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matches) continue;
|
||||
operations.push(["p", path, 0, removed, []]);
|
||||
operations.push(["p", path, length - removed, 0, after.slice(length - removed)]);
|
||||
return true;
|
||||
}
|
||||
candidates = 0;
|
||||
for (let inserted = 1; inserted < length && candidates < 16; inserted++) {
|
||||
if (after[inserted] !== before[0]) continue;
|
||||
candidates += 1;
|
||||
let matches = true;
|
||||
for (let index = inserted; index < length; index++) {
|
||||
if (after[index] !== before[index - inserted]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matches) continue;
|
||||
operations.push(["p", path, 0, 0, after.slice(0, inserted)]);
|
||||
operations.push(["p", path, length, inserted, []]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[]): void => {
|
||||
if (before.length === after.length) {
|
||||
let mismatches = 0;
|
||||
for (let index = 0; index < after.length; index++) {
|
||||
if (before[index] !== after[index]) mismatches += 1;
|
||||
}
|
||||
if (mismatches === 0) return;
|
||||
if (mismatches > 1) {
|
||||
const order = permutation(before, after);
|
||||
if (order !== undefined) {
|
||||
operations.push(["m", path, order]);
|
||||
return;
|
||||
}
|
||||
if (emitSameLengthShift(before, after, path, operations)) return;
|
||||
}
|
||||
for (let index = 0; index < after.length; index++) {
|
||||
if (operations.length > MAX_DELTA_OPERATIONS) return;
|
||||
diffValue(before[index]!, after[index]!, [...path, index], operations);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = 0;
|
||||
const shortest = Math.min(before.length, after.length);
|
||||
while (prefix < shortest && equalJson(before[prefix]!, after[prefix]!)) prefix += 1;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < shortest - prefix &&
|
||||
equalJson(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!)
|
||||
) {
|
||||
suffix += 1;
|
||||
}
|
||||
operations.push(["p", path, prefix, before.length - prefix - suffix, after.slice(prefix, after.length - suffix)]);
|
||||
};
|
||||
|
||||
const diffObject = (
|
||||
before: Record<string, JsonValue>,
|
||||
after: Record<string, JsonValue>,
|
||||
path: Path,
|
||||
operations: Op[],
|
||||
): void => {
|
||||
const beforeKeys = Object.keys(before);
|
||||
const afterKeys = Object.keys(after);
|
||||
if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) {
|
||||
if (!equalJson(before, after)) emitSet(path, after, operations);
|
||||
return;
|
||||
}
|
||||
for (const key of afterKeys) {
|
||||
if (operations.length > MAX_DELTA_OPERATIONS) return;
|
||||
if (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations);
|
||||
else emitSet([...path, key], after[key]!, operations);
|
||||
}
|
||||
for (const key of beforeKeys) {
|
||||
if (operations.length > MAX_DELTA_OPERATIONS) return;
|
||||
if (!Object.hasOwn(after, key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]);
|
||||
}
|
||||
};
|
||||
|
||||
const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[]): void => {
|
||||
if (before === after || operations.length > MAX_DELTA_OPERATIONS) return;
|
||||
if (typeof before === "string" && typeof after === "string" && path.length > 0) {
|
||||
emitString(before, after, path as NonEmptyPath, operations);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
diffArray(before, after, path, operations);
|
||||
return;
|
||||
}
|
||||
if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) {
|
||||
diffObject(before, after, path, operations);
|
||||
return;
|
||||
}
|
||||
emitSet(path, after, operations);
|
||||
};
|
||||
|
||||
const jsonCost = (value: JsonValue): number => {
|
||||
if (value === null) return 4;
|
||||
if (typeof value === "string") return value.length + 2;
|
||||
if (typeof value === "number") return String(value).length;
|
||||
if (typeof value === "boolean") return value ? 4 : 5;
|
||||
if (Array.isArray(value)) {
|
||||
let cost = 2;
|
||||
for (let index = 0; index < value.length; index++) cost += jsonCost(value[index]!) + (index === 0 ? 0 : 1);
|
||||
return cost;
|
||||
}
|
||||
let cost = 2;
|
||||
let index = 0;
|
||||
for (const key of Object.keys(value)) {
|
||||
cost += key.length + 3 + jsonCost(value[key]!) + (index++ === 0 ? 0 : 1);
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
|
||||
const pathCost = (path: Path): number => {
|
||||
let cost = 2;
|
||||
for (let index = 0; index < path.length; index++) {
|
||||
const segment: Seg = path[index]!;
|
||||
cost += (typeof segment === "string" ? segment.length + 2 : String(segment).length) + (index === 0 ? 0 : 1);
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
|
||||
const operationCost = (operation: Op): number => {
|
||||
switch (operation[0]) {
|
||||
case "r":
|
||||
return 6 + jsonCost(operation[1]);
|
||||
case "s":
|
||||
return 7 + pathCost(operation[1]) + jsonCost(operation[2]);
|
||||
case "d":
|
||||
return 6 + pathCost(operation[1]);
|
||||
case "a":
|
||||
return 7 + pathCost(operation[1]) + operation[2].length + 2;
|
||||
case "t":
|
||||
return 7 + pathCost(operation[1]) + String(operation[2]).length;
|
||||
case "p":
|
||||
return (
|
||||
10 +
|
||||
pathCost(operation[1]) +
|
||||
String(operation[2]).length +
|
||||
String(operation[3]).length +
|
||||
jsonCost(operation[4])
|
||||
);
|
||||
case "m":
|
||||
return 7 + pathCost(operation[1]) + jsonCost(operation[2]);
|
||||
}
|
||||
};
|
||||
|
||||
/** Compute a compact operation batch from two immutable JSON revisions. */
|
||||
export function diffRevisions(before: JsonValue, after: JsonValue): Op[] {
|
||||
const operations: Op[] = [];
|
||||
diffValue(before, after, [], operations);
|
||||
if (operations.length > MAX_DELTA_OPERATIONS) return [["r", after]];
|
||||
if (operations.length === 0 || operations[0]?.[0] === "r") return operations;
|
||||
let deltaCost = 2;
|
||||
for (const operation of operations) deltaCost += operationCost(operation) + 1;
|
||||
if (deltaCost < 65_536) return operations;
|
||||
const snapshotCost = jsonCost(after) + 6;
|
||||
return deltaCost >= snapshotCost ? [["r", after]] : operations;
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
/** A mutable transaction-scoped view of a JSON value, preserving tuple positions. */
|
||||
export type Draft<T, Depth extends readonly unknown[] = []> = Depth["length"] extends 8
|
||||
? T
|
||||
: T extends null | boolean | number | string
|
||||
? T
|
||||
: T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends object
|
||||
? { -readonly [Key in keyof T]: Draft<T[Key], [...Depth, unknown]> }
|
||||
: T;
|
||||
|
||||
export type ProduceMetadata<T> = {
|
||||
value: T;
|
||||
owned: WeakSet<object>;
|
||||
};
|
||||
|
||||
type Container = Record<string, unknown> | unknown[];
|
||||
|
||||
type DraftState = {
|
||||
base: Container;
|
||||
context: DraftContext;
|
||||
copy: Container | undefined;
|
||||
methods: Map<string, unknown> | undefined;
|
||||
proxy: object;
|
||||
target: unknown[] | undefined;
|
||||
};
|
||||
|
||||
type DraftContext = {
|
||||
active: boolean;
|
||||
created: DraftState[];
|
||||
owned: WeakSet<object>;
|
||||
proxies: WeakMap<object, DraftState>;
|
||||
states: WeakMap<object, DraftState>;
|
||||
};
|
||||
|
||||
const RELEASED_BASE: Container = Object.freeze({});
|
||||
const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
|
||||
|
||||
/** Run a mutation recipe and return its value with transaction ownership metadata. */
|
||||
export function produceWithMetadata<T extends object>(base: T, recipe: (draft: Draft<T>) => void): ProduceMetadata<T> {
|
||||
const context: DraftContext = {
|
||||
active: true,
|
||||
created: [],
|
||||
owned: new WeakSet<object>(),
|
||||
proxies: new WeakMap<object, DraftState>(),
|
||||
states: new WeakMap<object, DraftState>(),
|
||||
};
|
||||
const root = getState(context, base as Container);
|
||||
try {
|
||||
const outcome = (recipe as (draft: Draft<T>) => unknown)(root.proxy as Draft<T>);
|
||||
if (isPromiseLike(outcome)) {
|
||||
void Promise.resolve(outcome).catch(() => undefined);
|
||||
throw new TypeError("Replicated state change callbacks must be synchronous");
|
||||
}
|
||||
return {
|
||||
value: finalize(root, new Set<DraftState>(), new WeakMap<DraftState, Container>()) as T,
|
||||
owned: context.owned,
|
||||
};
|
||||
} finally {
|
||||
context.active = false;
|
||||
for (const state of context.created) {
|
||||
state.base = RELEASED_BASE;
|
||||
state.copy = undefined;
|
||||
state.methods = undefined;
|
||||
state.proxy = RELEASED_BASE;
|
||||
state.target = undefined;
|
||||
}
|
||||
context.created.length = 0;
|
||||
context.proxies = new WeakMap();
|
||||
context.states = new WeakMap();
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a mutation recipe against a copy-on-write draft of `base`. */
|
||||
export function produce<T extends object>(base: T, recipe: (draft: Draft<T>) => void): T {
|
||||
return produceWithMetadata(base, recipe).value;
|
||||
}
|
||||
|
||||
function getState(context: DraftContext, base: Container): DraftState {
|
||||
const existing = context.states.get(base);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
const target = Array.isArray(base) ? [] : undefined;
|
||||
if (target !== undefined) Reflect.set(target, "length", base.length);
|
||||
const state: DraftState = {
|
||||
base,
|
||||
context,
|
||||
copy: undefined,
|
||||
methods: undefined,
|
||||
proxy: target ?? {},
|
||||
target,
|
||||
};
|
||||
state.proxy = new Proxy(target ?? state, createHandler(state));
|
||||
context.states.set(base, state);
|
||||
context.proxies.set(state.proxy, state);
|
||||
context.created.push(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
type DraftHandler = ProxyHandler<object> & { state: DraftState };
|
||||
|
||||
const handlerPrototype: ProxyHandler<object> = {
|
||||
deleteProperty(this: DraftHandler, _target, property): boolean {
|
||||
const state = this.state;
|
||||
assertStringWrite(property);
|
||||
const current = currentValue(state);
|
||||
if (Array.isArray(current)) throw new TypeError("Draft arrays cannot contain holes");
|
||||
if (!Object.hasOwn(current, property)) return true;
|
||||
const deleted = Reflect.deleteProperty(ensureCopy(state), property);
|
||||
syncArrayTarget(state);
|
||||
return deleted;
|
||||
},
|
||||
defineProperty(this: DraftHandler): never {
|
||||
assertActive(this.state.context);
|
||||
throw new TypeError("Defining draft properties is not supported");
|
||||
},
|
||||
get(this: DraftHandler, _target, property): unknown {
|
||||
const state = this.state;
|
||||
const current = currentValue(state);
|
||||
const value = Reflect.get(current, property, state.proxy);
|
||||
if (Array.isArray(current) && typeof property === "string" && ARRAY_MUTATORS.has(property)) {
|
||||
return arrayMutator(state, property, value);
|
||||
}
|
||||
return draftValue(state.context, value);
|
||||
},
|
||||
getOwnPropertyDescriptor(this: DraftHandler, _target, property): PropertyDescriptor | undefined {
|
||||
const state = this.state;
|
||||
const current = currentValue(state);
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(current, property);
|
||||
if (descriptor === undefined) return undefined;
|
||||
if (Array.isArray(current) && property === "length") {
|
||||
syncArrayTarget(state);
|
||||
return Reflect.getOwnPropertyDescriptor(state.target!, property);
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: descriptor.enumerable,
|
||||
value: "value" in descriptor ? draftValue(state.context, descriptor.value) : undefined,
|
||||
writable: true,
|
||||
};
|
||||
},
|
||||
getPrototypeOf(this: DraftHandler): object | null {
|
||||
assertActive(this.state.context);
|
||||
return Object.getPrototypeOf(this.state.base);
|
||||
},
|
||||
has(this: DraftHandler, _target, property): boolean {
|
||||
return Reflect.has(currentValue(this.state), property);
|
||||
},
|
||||
isExtensible(this: DraftHandler): boolean {
|
||||
assertActive(this.state.context);
|
||||
return true;
|
||||
},
|
||||
ownKeys(this: DraftHandler): ArrayLike<string | symbol> {
|
||||
return Reflect.ownKeys(currentValue(this.state));
|
||||
},
|
||||
preventExtensions(this: DraftHandler): never {
|
||||
assertActive(this.state.context);
|
||||
throw new TypeError("Drafts cannot be made non-extensible");
|
||||
},
|
||||
set(this: DraftHandler, _target, property, value): boolean {
|
||||
const state = this.state;
|
||||
assertStringWrite(property);
|
||||
const current = currentValue(state);
|
||||
let stored: unknown;
|
||||
if (isContainer(value)) {
|
||||
stored = cloneAssigned(value, state.context, new Set<object>());
|
||||
} else {
|
||||
assertJsonPrimitive(value);
|
||||
stored = value;
|
||||
}
|
||||
if (Object.hasOwn(current, property) && Object.is(Reflect.get(current, property), stored)) return true;
|
||||
const copy = ensureCopy(state);
|
||||
if (Array.isArray(copy) && property === "length") {
|
||||
const written = Reflect.set(copy, property, stored);
|
||||
syncArrayTarget(state);
|
||||
return written;
|
||||
}
|
||||
Object.defineProperty(copy, property, {
|
||||
value: stored,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
syncArrayTarget(state);
|
||||
return true;
|
||||
},
|
||||
setPrototypeOf(this: DraftHandler): never {
|
||||
assertActive(this.state.context);
|
||||
throw new TypeError("Changing a draft prototype is not supported");
|
||||
},
|
||||
};
|
||||
|
||||
function createHandler(state: DraftState): ProxyHandler<object> {
|
||||
const handler = Object.create(handlerPrototype) as DraftHandler;
|
||||
handler.state = state;
|
||||
return handler;
|
||||
}
|
||||
|
||||
function arrayMutator(state: DraftState, property: string, value: unknown): unknown {
|
||||
assertActive(state.context);
|
||||
const cached = state.methods?.get(property);
|
||||
if (cached !== undefined) return cached;
|
||||
if (typeof value !== "function") return draftValue(state.context, value);
|
||||
const wrapped = function (this: unknown, ...args: unknown[]): unknown {
|
||||
assertActive(state.context);
|
||||
const receiver = isContainer(this) ? state.context.proxies.get(this) : undefined;
|
||||
if (receiver === undefined) return Reflect.apply(value, this, args);
|
||||
const target = ensureCopy(receiver);
|
||||
if (!Array.isArray(target)) throw new TypeError("Array mutator receiver is not an array draft");
|
||||
try {
|
||||
return mutateArray(receiver, target, property, args);
|
||||
} finally {
|
||||
syncArrayTarget(receiver);
|
||||
}
|
||||
};
|
||||
if (state.methods === undefined) state.methods = new Map();
|
||||
state.methods.set(property, wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function mutateArray(state: DraftState, target: unknown[], property: string, args: unknown[]): unknown {
|
||||
const { context } = state;
|
||||
switch (property) {
|
||||
case "push": {
|
||||
const items = cloneArrayItems(args, context);
|
||||
for (const item of items) defineArrayValue(target, target.length, item);
|
||||
return target.length;
|
||||
}
|
||||
case "pop": {
|
||||
if (target.length === 0) return undefined;
|
||||
const index = target.length - 1;
|
||||
const removed = target[index];
|
||||
Reflect.deleteProperty(target, String(index));
|
||||
target.length = index;
|
||||
return draftValue(context, removed);
|
||||
}
|
||||
case "shift":
|
||||
return draftValue(context, Reflect.apply(Array.prototype.shift, target, []));
|
||||
case "unshift": {
|
||||
const items = cloneArrayItems(args, context);
|
||||
if (hasInheritedGrowthIndex(target, target.length + items.length)) {
|
||||
spliceArray(target, items, 0, 0, context);
|
||||
return target.length;
|
||||
}
|
||||
return Reflect.apply(Array.prototype.unshift, target, items);
|
||||
}
|
||||
case "splice": {
|
||||
const length = target.length;
|
||||
const start = args.length === 0 ? 0 : clampArrayIndex(toIntegerOrInfinity(args[0]), length);
|
||||
const remove =
|
||||
args.length === 0
|
||||
? 0
|
||||
: args.length === 1
|
||||
? length - start
|
||||
: Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start);
|
||||
const items = cloneArrayItems(args.slice(2), context);
|
||||
if (
|
||||
!mayRunCoercionCode(args[0]) &&
|
||||
!mayRunCoercionCode(args[1]) &&
|
||||
!hasInheritedGrowthIndex(target, length - remove + items.length)
|
||||
) {
|
||||
const removed = Reflect.apply(Array.prototype.splice, target, [start, remove, ...items]) as unknown[];
|
||||
return removed.map((item) => draftValue(context, item));
|
||||
}
|
||||
return spliceArray(target, items, start, remove, context, length);
|
||||
}
|
||||
case "sort": {
|
||||
const comparator = args[0];
|
||||
if (comparator !== undefined && typeof comparator !== "function") {
|
||||
Reflect.apply(Array.prototype.sort, target, args);
|
||||
} else {
|
||||
Reflect.apply(Array.prototype.sort, target, [
|
||||
(left: unknown, right: unknown): number => {
|
||||
const draftedLeft = draftValue(context, left);
|
||||
const draftedRight = draftValue(context, right);
|
||||
if (typeof comparator === "function") {
|
||||
return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number;
|
||||
}
|
||||
const leftString = String(draftedLeft);
|
||||
const rightString = String(draftedRight);
|
||||
return leftString < rightString ? -1 : leftString > rightString ? 1 : 0;
|
||||
},
|
||||
]);
|
||||
}
|
||||
return state.proxy;
|
||||
}
|
||||
case "reverse":
|
||||
Reflect.apply(Array.prototype.reverse, target, []);
|
||||
return state.proxy;
|
||||
case "fill": {
|
||||
const length = target.length;
|
||||
const start = args.length > 1 ? clampArrayIndex(toIntegerOrInfinity(args[1]), length) : 0;
|
||||
const end =
|
||||
args.length > 2 && args[2] !== undefined ? clampArrayIndex(toIntegerOrInfinity(args[2]), length) : length;
|
||||
if (end <= start) return state.proxy;
|
||||
const supplied = args[0];
|
||||
for (let index = start; index < end; index++) {
|
||||
const item = isContainer(supplied)
|
||||
? cloneAssigned(supplied, context, new Set<object>())
|
||||
: assertJsonPrimitive(supplied);
|
||||
defineArrayValue(target, index, item);
|
||||
}
|
||||
return state.proxy;
|
||||
}
|
||||
case "copyWithin": {
|
||||
const length = target.length;
|
||||
const to = clampArrayIndex(toIntegerOrInfinity(args[0]), length);
|
||||
const from = clampArrayIndex(toIntegerOrInfinity(args[1]), length);
|
||||
const end =
|
||||
args.length > 2 && args[2] !== undefined ? clampArrayIndex(toIntegerOrInfinity(args[2]), length) : length;
|
||||
const count = Math.min(Math.max(end - from, 0), length - to);
|
||||
const source = target.slice(from, from + count);
|
||||
for (let offset = 0; offset < count; offset++) {
|
||||
const item = source[offset];
|
||||
const itemState = isContainer(item) ? context.states.get(item) : undefined;
|
||||
const current = (itemState?.proxy as Container | undefined) ?? item;
|
||||
const copy = isContainer(current)
|
||||
? cloneAssigned(current, context, new Set<object>())
|
||||
: assertJsonPrimitive(current);
|
||||
defineArrayValue(target, to + offset, copy);
|
||||
}
|
||||
return state.proxy;
|
||||
}
|
||||
}
|
||||
throw new TypeError(`Unsupported array mutator: ${property}`);
|
||||
}
|
||||
|
||||
function cloneArrayItems(items: readonly unknown[], context: DraftContext): unknown[] {
|
||||
return items.map((item) => {
|
||||
if (isContainer(item)) return cloneAssigned(item, context, new Set<object>());
|
||||
assertJsonPrimitive(item);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function hasInheritedGrowthIndex(target: unknown[], nextLength: number): boolean {
|
||||
for (let index = target.length; index < nextLength; index++) {
|
||||
if (index in target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mayRunCoercionCode(value: unknown): boolean {
|
||||
return (typeof value === "object" && value !== null) || typeof value === "function";
|
||||
}
|
||||
|
||||
function spliceArray(
|
||||
target: unknown[],
|
||||
items: readonly unknown[],
|
||||
index: number,
|
||||
remove: number,
|
||||
context: DraftContext,
|
||||
length = target.length,
|
||||
): unknown[] {
|
||||
const removed = new Array<unknown>(remove);
|
||||
for (let offset = 0; offset < remove; offset++) {
|
||||
const source = index + offset;
|
||||
if (!(source in target)) continue;
|
||||
defineArrayValue(removed, offset, draftValue(context, target[source]));
|
||||
}
|
||||
if (items.length < remove) {
|
||||
for (let source = index + remove; source < length; source++) {
|
||||
defineArrayValue(target, source - remove + items.length, target[source]);
|
||||
}
|
||||
} else if (items.length > remove) {
|
||||
for (let source = length - 1; source >= index + remove; source--) {
|
||||
defineArrayValue(target, source - remove + items.length, target[source]);
|
||||
}
|
||||
}
|
||||
for (let offset = 0; offset < items.length; offset++) defineArrayValue(target, index + offset, items[offset]);
|
||||
target.length = length - remove + items.length;
|
||||
return removed;
|
||||
}
|
||||
|
||||
function defineArrayValue(target: unknown[], index: number, value: unknown): void {
|
||||
Object.defineProperty(target, index, {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function toIntegerOrInfinity(value: unknown): number {
|
||||
const number = +(value as unknown as number);
|
||||
if (Number.isNaN(number) || number === 0) return 0;
|
||||
return Number.isFinite(number) ? Math.trunc(number) : number;
|
||||
}
|
||||
|
||||
function clampArrayIndex(value: number, length: number): number {
|
||||
if (value === Number.NEGATIVE_INFINITY) return 0;
|
||||
if (value < 0) return Math.max(length + value, 0);
|
||||
return Math.min(value, length);
|
||||
}
|
||||
|
||||
function currentValue(state: DraftState): Container {
|
||||
assertActive(state.context);
|
||||
return state.copy ?? state.base;
|
||||
}
|
||||
|
||||
function ensureCopy(state: DraftState): Container {
|
||||
if (state.copy !== undefined) return state.copy;
|
||||
if (state.context.owned.has(state.base)) {
|
||||
state.copy = state.base;
|
||||
} else {
|
||||
state.copy = shallowOwnedCopy(state.base, state.context);
|
||||
}
|
||||
return state.copy;
|
||||
}
|
||||
|
||||
function shallowOwnedCopy(value: Container, context: DraftContext): Container {
|
||||
if (Array.isArray(value)) {
|
||||
const copy = value.slice();
|
||||
context.owned.add(copy);
|
||||
return copy;
|
||||
}
|
||||
const copy = Object.create(Object.getPrototypeOf(value)) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(copy, key, {
|
||||
value: value[key],
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
context.owned.add(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function cloneAssigned(value: Container, context: DraftContext, ancestors: Set<object>): Container {
|
||||
if (ancestors.has(value)) throw new TypeError("Assigned JSON values cannot contain cycles");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
assertDenseArray(value);
|
||||
const clone: unknown[] = [];
|
||||
context.owned.add(clone);
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
clone.push(cloneAssignedValue(Reflect.get(value, String(index)), context, ancestors));
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("Assigned JSON containers must be plain objects or arrays");
|
||||
}
|
||||
const clone = Object.create(prototype) as Record<string, unknown>;
|
||||
context.owned.add(clone);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key === "symbol") throw new TypeError("Assigned JSON objects cannot have symbol properties");
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError("Assigned JSON objects must contain enumerable data properties");
|
||||
}
|
||||
Object.defineProperty(clone, key, {
|
||||
value: cloneAssignedValue(descriptor.value, context, ancestors),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return clone;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneAssignedValue(value: unknown, context: DraftContext, ancestors: Set<object>): unknown {
|
||||
if (isContainer(value)) return cloneAssigned(value, context, ancestors);
|
||||
assertJsonPrimitive(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertJsonPrimitive(value: unknown): null | boolean | number | string {
|
||||
if (value === undefined) throw new TypeError("Assigned values cannot be undefined");
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value !== "string" &&
|
||||
typeof value !== "boolean" &&
|
||||
!(typeof value === "number" && Number.isFinite(value))
|
||||
) {
|
||||
throw new TypeError("Assigned values must be strict JSON values");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function draftValue(context: DraftContext, value: unknown): unknown {
|
||||
if (!isContainer(value)) return value;
|
||||
return getState(context, value).proxy;
|
||||
}
|
||||
|
||||
function isContainer(value: unknown): value is Container {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
return (
|
||||
((typeof value === "object" && value !== null) || typeof value === "function") &&
|
||||
typeof (value as { then?: unknown }).then === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function assertActive(context: DraftContext): void {
|
||||
if (!context.active) throw new TypeError("Cannot use a draft outside its change callback");
|
||||
}
|
||||
|
||||
function assertStringWrite(property: string | symbol): asserts property is string {
|
||||
if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported");
|
||||
}
|
||||
|
||||
function syncArrayTarget(state: DraftState): void {
|
||||
const current = currentValue(state);
|
||||
if (Array.isArray(current)) Reflect.set(state.target!, "length", current.length);
|
||||
}
|
||||
|
||||
function finalize(
|
||||
state: DraftState,
|
||||
finalizing: Set<DraftState>,
|
||||
finalized: WeakMap<DraftState, Container>,
|
||||
): Container {
|
||||
const cached = finalized.get(state);
|
||||
if (cached !== undefined) return cached;
|
||||
if (finalizing.has(state)) throw new TypeError("Cyclic draft state is not supported");
|
||||
finalizing.add(state);
|
||||
try {
|
||||
if (state.copy !== undefined && shallowEqual(state.base, state.copy)) state.copy = undefined;
|
||||
const current = currentValue(state);
|
||||
let result = current;
|
||||
for (const key of Object.keys(current)) {
|
||||
const value = Reflect.get(current, key);
|
||||
if (!isContainer(value)) continue;
|
||||
const child = state.context.states.get(value);
|
||||
if (child === undefined) continue;
|
||||
const finalizedChild = finalize(child, finalizing, finalized);
|
||||
if (finalizedChild === value) continue;
|
||||
if (result === state.base) result = shallowOwnedCopy(state.base, state.context);
|
||||
Object.defineProperty(result, key, {
|
||||
value: finalizedChild,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
if (state.copy !== undefined && Array.isArray(result)) assertDenseArray(result);
|
||||
finalized.set(state, result);
|
||||
return result;
|
||||
} finally {
|
||||
finalizing.delete(state);
|
||||
}
|
||||
}
|
||||
|
||||
function shallowEqual(left: Container, right: Container): boolean {
|
||||
if (Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false;
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
for (const key of leftKeys) {
|
||||
if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertDenseArray(value: unknown[]): void {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) {
|
||||
throw new TypeError("Draft arrays must be dense and contain only indexed entries");
|
||||
}
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index) || value[index] === undefined) {
|
||||
throw new TypeError("Draft arrays cannot contain holes or undefined entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { JsonValue } from "../types.ts";
|
||||
|
||||
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
|
||||
|
||||
const isContainer = (value: unknown): value is JsonContainer => typeof value === "object" && value !== null;
|
||||
|
||||
/** Owns and freezes immutable JSON revisions while reusing already-owned subtrees. */
|
||||
export class JsonRevisionStore {
|
||||
readonly #owned = new WeakSet<object>();
|
||||
|
||||
import<T extends object>(value: T): T {
|
||||
return this.#clone(value, new Set<object>()) as T;
|
||||
}
|
||||
|
||||
commit<T extends object>(value: T, transactionOwned: WeakSet<object>): T {
|
||||
return this.#finish(value as JsonContainer, transactionOwned, new Set<object>(), new WeakSet<object>()) as T;
|
||||
}
|
||||
|
||||
adopt<T extends JsonValue>(value: T): T {
|
||||
return this.#adopt(value, new Set<object>(), new WeakSet<object>()) as T;
|
||||
}
|
||||
|
||||
#clone(value: unknown, ancestors: Set<object>): JsonValue {
|
||||
if (!isContainer(value)) return assertPrimitive(value);
|
||||
if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
assertDenseArray(value);
|
||||
const result = new Array<JsonValue>(value.length);
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
Object.defineProperty(result, index, {
|
||||
value: this.#clone(value[index], ancestors),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
this.#owned.add(result);
|
||||
return Object.freeze(result) as unknown as JsonValue;
|
||||
}
|
||||
assertPlainObject(value);
|
||||
const result = Object.create(Object.getPrototypeOf(value)) as Record<string, JsonValue>;
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError("Replicated state objects must contain enumerable data properties");
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
value: this.#clone(descriptor.value, ancestors),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
this.#owned.add(result);
|
||||
return Object.freeze(result) as unknown as JsonValue;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
#adopt(value: JsonValue, ancestors: Set<object>, finished: WeakSet<object>): JsonValue {
|
||||
if (!isContainer(value) || this.#owned.has(value)) return isContainer(value) ? value : assertPrimitive(value);
|
||||
if (finished.has(value)) return value;
|
||||
if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
assertDenseArray(value);
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const child = this.#adopt(value[index]!, ancestors, finished);
|
||||
if (child !== value[index]) defineValue(value, String(index), child as JsonContainer);
|
||||
}
|
||||
} else {
|
||||
assertPlainObject(value);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError("Replicated state objects must contain enumerable data properties");
|
||||
}
|
||||
const child = this.#adopt(descriptor.value as JsonValue, ancestors, finished);
|
||||
if (child !== descriptor.value && isContainer(child)) defineValue(value, key, child);
|
||||
}
|
||||
}
|
||||
finished.add(value);
|
||||
this.#owned.add(value);
|
||||
return Object.freeze(value) as unknown as JsonValue;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
#finish(
|
||||
value: JsonContainer,
|
||||
transactionOwned: WeakSet<object>,
|
||||
ancestors: Set<object>,
|
||||
placements: WeakSet<object>,
|
||||
): JsonContainer {
|
||||
if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles");
|
||||
if (placements.has(value)) return this.#clone(value, new Set<object>()) as JsonContainer;
|
||||
placements.add(value);
|
||||
if (this.#owned.has(value)) return value;
|
||||
if (!transactionOwned.has(value)) return this.#clone(value, ancestors) as JsonContainer;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
assertDenseArray(value);
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const child = value[index];
|
||||
if (!isContainer(child)) {
|
||||
assertPrimitive(child);
|
||||
continue;
|
||||
}
|
||||
const next = this.#finish(child, transactionOwned, ancestors, placements);
|
||||
if (next !== child) defineValue(value, String(index), next);
|
||||
}
|
||||
} else {
|
||||
assertPlainObject(value);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError("Replicated state objects must contain enumerable data properties");
|
||||
}
|
||||
if (!isContainer(descriptor.value)) {
|
||||
assertPrimitive(descriptor.value);
|
||||
continue;
|
||||
}
|
||||
const next = this.#finish(descriptor.value, transactionOwned, ancestors, placements);
|
||||
if (next !== descriptor.value) defineValue(value, key, next);
|
||||
}
|
||||
}
|
||||
this.#owned.add(value);
|
||||
return Object.freeze(value) as unknown as JsonContainer;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defineValue(target: object, key: string, value: JsonContainer): void {
|
||||
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
|
||||
}
|
||||
|
||||
function assertPrimitive(value: unknown): null | boolean | number | string {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
throw new TypeError("Replicated state values must be strict JSON");
|
||||
}
|
||||
|
||||
function assertPlainObject(value: object): void {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("Replicated state containers must be plain objects or arrays");
|
||||
}
|
||||
}
|
||||
|
||||
function assertDenseArray(value: readonly unknown[]): void {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) {
|
||||
throw new TypeError("Replicated state arrays must be dense and contain only indexed entries");
|
||||
}
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index) || value[index] === undefined) {
|
||||
throw new TypeError("Replicated state arrays cannot contain holes or undefined entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Op } from "./delta/index.ts";
|
||||
import type { RemoteServiceProvider } from "./services/provider.ts";
|
||||
import type { Draft } from "./state/draft.ts";
|
||||
|
||||
export type { RemoteServiceError } from "./services/errors.ts";
|
||||
export type { RemoteServiceProvider } from "./services/provider.ts";
|
||||
@@ -49,10 +50,13 @@ export interface ReplicatedState<T> {
|
||||
|
||||
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;
|
||||
/**
|
||||
* Atomically publish one synchronous copy-on-write mutation. Draft handles are revoked when the callback returns.
|
||||
* Async callbacks and non-JSON assignments, including `undefined`, throw without changing the value.
|
||||
*/
|
||||
change(context: Context, mutate: (draft: Draft<T>) => void): void;
|
||||
/** Atomically replace the complete value with a detached immutable JSON snapshot. */
|
||||
replace(context: Context, value: T): void;
|
||||
}
|
||||
|
||||
declare const SERVICE_TYPE: unique symbol;
|
||||
|
||||
@@ -324,8 +324,9 @@ describe("facet host", () => {
|
||||
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);
|
||||
sources[0]!.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 3;
|
||||
});
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
@@ -419,13 +420,15 @@ describe("facet host", () => {
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(deliveries).toBe(1);
|
||||
state!.state.value = 1;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
state!.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
});
|
||||
expect(deliveries).toBe(2);
|
||||
|
||||
await host.dispose();
|
||||
state!.state.value = 2;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
state!.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 2;
|
||||
});
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -258,12 +258,14 @@ test("remote service endpoints publish and clean up provider subscriptions", asy
|
||||
),
|
||||
).resolves.toMatchObject({ serviceId: Counter.id, mode: "singleton" });
|
||||
|
||||
state.state.value = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
});
|
||||
expect(updates).toEqual([{ type: "state", member: "state", sequence: 1, ops: [["s", ["value"], 1]] }]);
|
||||
endpoint.dispose();
|
||||
state.state.value = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 2;
|
||||
});
|
||||
expect(updates).toHaveLength(1);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
@@ -100,9 +100,10 @@ describe("remote services", () => {
|
||||
expect(delivered).toBe(state.value);
|
||||
const hydrated = delivered;
|
||||
|
||||
state.state.selected = { provider: "test", modelId: "one" };
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.selected = { provider: "test", modelId: "one" };
|
||||
draft.revision = 1;
|
||||
});
|
||||
expect(state.value).toEqual({ selected: { provider: "test", modelId: "one" }, revision: 1 });
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
@@ -111,25 +112,25 @@ describe("remote services", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("publishes a redundant update when tracked mutations restore the prior value", () => {
|
||||
test("does not publish when a transaction restores the prior value", () => {
|
||||
const state = replicatedState({ value: 1 });
|
||||
const deliveries: Array<{ kind: string; sequence: number }> = [];
|
||||
state.subscribe((_value, _context, delivery) => deliveries.push(delivery));
|
||||
state.state.value = 2;
|
||||
state.state.value = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 2;
|
||||
draft.value = 1;
|
||||
});
|
||||
expect(state.value).toEqual({ value: 1 });
|
||||
expect(deliveries).toEqual([
|
||||
{ kind: "hydrate", sequence: 0 },
|
||||
{ kind: "update", sequence: 1 },
|
||||
]);
|
||||
expect(deliveries).toEqual([{ kind: "hydrate", sequence: 0 }]);
|
||||
});
|
||||
|
||||
test("flushes pending mutations before hydrating a new state subscriber", () => {
|
||||
test("hydrates a new subscriber from the latest atomic revision", () => {
|
||||
const state = replicatedState({ entries: [{ id: "one" }] });
|
||||
const first: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => first.push(value));
|
||||
state.state.entries.push({ id: "two" });
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.entries.push({ id: "two" });
|
||||
});
|
||||
|
||||
const second: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => second.push(value));
|
||||
@@ -172,9 +173,10 @@ describe("remote services", () => {
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select(model, context) {
|
||||
state.state.selected = model;
|
||||
state.state.revision += 1;
|
||||
state.publish(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.selected = model;
|
||||
draft.revision += 1;
|
||||
});
|
||||
publishedState = state.value;
|
||||
},
|
||||
});
|
||||
@@ -239,8 +241,9 @@ describe("remote services", () => {
|
||||
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);
|
||||
source.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.entries.push({ id: "two" });
|
||||
});
|
||||
|
||||
expect(updates).toContainEqual({
|
||||
type: "state",
|
||||
@@ -251,7 +254,9 @@ describe("remote services", () => {
|
||||
expect(previous).toEqual({ entries: [{ id: "one" }], retained: { value: 1 } });
|
||||
expect(timeline.state.value).toEqual(next);
|
||||
|
||||
source.state.entries.push({ id: "three" });
|
||||
source.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.entries.push({ id: "three" });
|
||||
});
|
||||
const late = provider.subscribe(Timeline.id, "singleton", () => {});
|
||||
expect(updates.at(-1)).toEqual({
|
||||
type: "state",
|
||||
@@ -353,6 +358,42 @@ describe("remote services", () => {
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("does not replay a queued revision already covered by a new subscription snapshot", () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, { state, async select() {} });
|
||||
const lateUpdates: number[] = [];
|
||||
let late: ReturnType<typeof provider.subscribe> | undefined;
|
||||
const first = provider.subscribe(Models.id, "singleton", (update) => {
|
||||
if (update.type !== "state" || update.sequence !== 1) return;
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 2;
|
||||
});
|
||||
late = provider.subscribe(Models.id, "singleton", (next) => {
|
||||
if (next.type === "state") lateUpdates.push(next.sequence);
|
||||
});
|
||||
expect(late.snapshot.instances[0]?.members).toContainEqual({
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 2,
|
||||
ops: [["r", { selected: null, revision: 2 }]],
|
||||
});
|
||||
late.activate();
|
||||
});
|
||||
first.activate();
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 1;
|
||||
});
|
||||
expect(lateUpdates).toEqual([]);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 3;
|
||||
});
|
||||
expect(lateUpdates).toEqual([3]);
|
||||
late?.close();
|
||||
first.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("replays every buffered update before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
@@ -363,10 +404,12 @@ describe("remote services", () => {
|
||||
delivered += 1;
|
||||
throw failure;
|
||||
});
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 1;
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 2;
|
||||
});
|
||||
|
||||
expect(() => subscription.activate()).toThrow("Failed to activate remote service subscription");
|
||||
expect(delivered).toBe(2);
|
||||
@@ -491,8 +534,9 @@ describe("remote services", () => {
|
||||
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);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 1;
|
||||
});
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
@@ -582,19 +626,22 @@ describe("remote services", () => {
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(revisions).toEqual([]);
|
||||
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 1;
|
||||
});
|
||||
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);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 2;
|
||||
});
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await namespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
state.state.revision = 3;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 3;
|
||||
});
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(3);
|
||||
@@ -638,8 +685,9 @@ describe("remote services", () => {
|
||||
expect(observed[0]).toMatchObject({ question: { question: "First?" } });
|
||||
|
||||
const firstService = observed[0]!.service;
|
||||
firstRequest.state.question = "Updated?";
|
||||
firstRequest.publish(BACKGROUND_CONTEXT);
|
||||
firstRequest.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.question = "Updated?";
|
||||
});
|
||||
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 }));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyImmutable, assertValidOp, assertValidWireOp, decoder, encoder, type Op } from "../src/delta/index.ts";
|
||||
import { diffRevisions } from "../src/state/diff.ts";
|
||||
import type { JsonValue } from "../src/types.ts";
|
||||
|
||||
const expectDiff = (before: JsonValue, after: JsonValue, expected: JsonValue): void => {
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual(expected);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
};
|
||||
|
||||
describe("immutable revision diff", () => {
|
||||
it("emits sets and deletes", () => {
|
||||
expectDiff({ keep: 1, change: 1, remove: true }, { keep: 1, change: 2, add: 3 }, [
|
||||
["s", ["change"], 2],
|
||||
["s", ["add"], 3],
|
||||
["d", ["remove"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits string append and front truncation", () => {
|
||||
expectDiff({ text: "hello" }, { text: "hello world" }, [["a", ["text"], " world"]]);
|
||||
expectDiff({ text: "hello world" }, { text: "world" }, [["t", ["text"], 6]]);
|
||||
expectDiff({ text: "abcdefgh" }, { text: "defghxyz" }, [
|
||||
["t", ["text"], 3],
|
||||
["a", ["text"], "xyz"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents array insertion, removal, and shift with splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b] }, { values: [a, c, b] }, [["p", ["values"], 1, 0, [c]]]);
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c] }, [["p", ["values"], 0, 1, []]]);
|
||||
});
|
||||
|
||||
it("collapses a same-length queue update to two splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
const d = { id: "d" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c, d] }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [d]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a permutation for a pure reorder", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [c, a, b] }, [["m", ["values"], [2, 0, 1]]]);
|
||||
});
|
||||
|
||||
it("validates and encodes permutations", () => {
|
||||
const operations: Op[] = [
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", ["values"], [1, 2, 0]],
|
||||
];
|
||||
for (const operation of operations) assertValidOp(operation);
|
||||
const wire = encoder().encode(operations);
|
||||
expect(wire).toEqual([
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", [1, 2, 0]],
|
||||
]);
|
||||
for (const operation of wire) assertValidWireOp(operation);
|
||||
expect(decoder().decode(wire)).toEqual(operations);
|
||||
expect(() => assertValidOp(["m", ["values"], [0, 0]])).toThrow(/bijection/);
|
||||
});
|
||||
|
||||
it("emits nothing for deeply equal reconstructed values", () => {
|
||||
expect(diffRevisions({ value: { nested: [1, 2] } }, { value: { nested: [1, 2] } })).toEqual([]);
|
||||
expect(diffRevisions({ values: [{ id: 1 }, { id: 2 }] }, { values: [{ id: 1 }, { id: 2 }] })).toEqual([]);
|
||||
expect(diffRevisions({ values: [true, true, true] }, { values: [true, true, true] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a leaf edit inside a reconstructed array narrow", () => {
|
||||
expectDiff(
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "two" },
|
||||
],
|
||||
},
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "changed" },
|
||||
],
|
||||
},
|
||||
[["s", ["values", 1, "label"], "changed"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a base operation when leaf operations are larger", () => {
|
||||
const before = { values: Array.from({ length: 40_000 }, () => 0) };
|
||||
const after = { values: Array.from({ length: 40_000 }, () => 1) };
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual([["r", after]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Draft, produce, produceWithMetadata } from "../src/state/draft.ts";
|
||||
|
||||
describe("produce", () => {
|
||||
it("returns the base when the recipe makes no semantic write", () => {
|
||||
const base = { count: 1, nested: { value: "same" }, values: [1, 2, 3] };
|
||||
const result = produce(base, (draft) => {
|
||||
draft.count = 1;
|
||||
draft.nested.value = "same";
|
||||
delete (draft as { missing?: boolean }).missing;
|
||||
draft.values.sort((left, right) => left - right);
|
||||
});
|
||||
expect(result).toBe(base);
|
||||
});
|
||||
|
||||
it("copies only changed branches and keeps stable draft identities", () => {
|
||||
const base = {
|
||||
changed: { count: 1, sibling: { value: "kept" } },
|
||||
untouched: { value: 2 },
|
||||
};
|
||||
let first: Draft<typeof base.changed> | undefined;
|
||||
const { value, owned } = produceWithMetadata(base, (draft) => {
|
||||
first = draft.changed;
|
||||
expect(draft.changed).toBe(first);
|
||||
draft.changed.count = 2;
|
||||
draft.changed.count = 3;
|
||||
});
|
||||
|
||||
expect(value).not.toBe(base);
|
||||
expect(value.changed).not.toBe(base.changed);
|
||||
expect(value.changed.sibling).toBe(base.changed.sibling);
|
||||
expect(value.untouched).toBe(base.untouched);
|
||||
expect(value.changed.count).toBe(3);
|
||||
expect(owned.has(value)).toBe(true);
|
||||
expect(owned.has(value.changed)).toBe(true);
|
||||
expect(owned.has(value.changed.sibling)).toBe(false);
|
||||
expect(owned.has(value.untouched)).toBe(false);
|
||||
expect(owned.has(base)).toBe(false);
|
||||
});
|
||||
|
||||
it("supports property assignment and deletion without mutating a frozen base", () => {
|
||||
const nested: Readonly<{ old: boolean }> = Object.freeze({ old: true });
|
||||
const base: Readonly<{
|
||||
name: string;
|
||||
nested: Readonly<{ old: boolean }>;
|
||||
optional?: string;
|
||||
}> = Object.freeze({ name: "before", nested, optional: "remove" });
|
||||
const result = produce(base, (draft) => {
|
||||
draft.name = "after";
|
||||
delete draft.optional;
|
||||
draft.nested.old = false;
|
||||
});
|
||||
|
||||
expect(result).toEqual({ name: "after", nested: { old: false } });
|
||||
expect(base).toEqual({ name: "before", nested: { old: true }, optional: "remove" });
|
||||
expect(nested.old).toBe(true);
|
||||
});
|
||||
|
||||
it("assigns base containers by value instead of creating aliases", () => {
|
||||
const shared = { value: 1 };
|
||||
const base: { source: { value: number }; alias: { value: number } | null } = { source: shared, alias: null };
|
||||
const { value, owned } = produceWithMetadata(base, (draft) => {
|
||||
draft.alias = base.source;
|
||||
draft.alias.value = 2;
|
||||
});
|
||||
|
||||
expect(base.source.value).toBe(1);
|
||||
expect(value.source).toBe(base.source);
|
||||
expect(value.source.value).toBe(1);
|
||||
expect(value.alias).toEqual({ value: 2 });
|
||||
expect(value.alias).not.toBe(value.source);
|
||||
expect(owned.has(value)).toBe(true);
|
||||
expect(owned.has(value.alias!)).toBe(true);
|
||||
expect(owned.has(value.source)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves tuple positions in draft types", () => {
|
||||
const base = { tuple: ["value", 1] as readonly [string, number] };
|
||||
produce(base, (draft) => {
|
||||
const text: string = draft.tuple[0];
|
||||
const count: number = draft.tuple[1];
|
||||
draft.tuple = [text, count + 1];
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects asynchronous recipes without changing the base", () => {
|
||||
const base = { value: 1 };
|
||||
expect(() =>
|
||||
produce(base, async (draft) => {
|
||||
draft.value = 2;
|
||||
await Promise.resolve();
|
||||
}),
|
||||
).toThrow(/synchronous/);
|
||||
expect(base).toEqual({ value: 1 });
|
||||
});
|
||||
|
||||
it("rolls back and revokes every created draft when the recipe throws", () => {
|
||||
const base = { child: { count: 1 }, values: [1, 2] };
|
||||
const failure = new Error("stop");
|
||||
let escapedChild: Draft<typeof base.child> | undefined;
|
||||
let escapedArray: Draft<typeof base.values> | undefined;
|
||||
|
||||
expect(() =>
|
||||
produce(base, (draft) => {
|
||||
escapedChild = draft.child;
|
||||
escapedArray = draft.values;
|
||||
draft.child.count = 9;
|
||||
draft.values.push(3);
|
||||
throw failure;
|
||||
}),
|
||||
).toThrow(failure);
|
||||
expect(base).toEqual({ child: { count: 1 }, values: [1, 2] });
|
||||
expect(() => escapedChild?.count).toThrow(TypeError);
|
||||
expect(() => escapedArray?.length).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("revokes escaped drafts after a successful recipe", () => {
|
||||
const base = { child: { count: 1 } };
|
||||
let escapedRoot: Draft<typeof base> | undefined;
|
||||
let escapedChild: Draft<typeof base.child> | undefined;
|
||||
const result = produce(base, (draft) => {
|
||||
escapedRoot = draft;
|
||||
escapedChild = draft.child;
|
||||
draft.child.count = 2;
|
||||
});
|
||||
|
||||
expect(result.child.count).toBe(2);
|
||||
expect(() => escapedRoot?.child).toThrow(TypeError);
|
||||
expect(() => escapedChild?.count).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("supports native mutating array methods", () => {
|
||||
const base = { values: [3, 1, 2] };
|
||||
const result = produce(base, (draft) => {
|
||||
draft.values.push(4);
|
||||
expect(draft.values.pop()).toBe(4);
|
||||
draft.values.unshift(0);
|
||||
expect(draft.values.shift()).toBe(0);
|
||||
expect(draft.values.splice(1, 1, 5, 4)).toEqual([1]);
|
||||
draft.values.sort((left, right) => left - right);
|
||||
draft.values.reverse();
|
||||
draft.values.fill(9, 1, 3);
|
||||
draft.values.copyWithin(1, 0, 2);
|
||||
});
|
||||
|
||||
expect(result.values).toEqual([5, 5, 9, 2]);
|
||||
expect(base.values).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("copies inserted draft values without detaching their original handle", () => {
|
||||
const base = { values: [{ value: 1 }, { value: 2 }] };
|
||||
const result = produce(base, (draft) => {
|
||||
const held = draft.values[0]!;
|
||||
draft.values.unshift(held);
|
||||
held.value = 9;
|
||||
});
|
||||
expect(result.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]);
|
||||
expect(result.values[0]).not.toBe(result.values[1]);
|
||||
});
|
||||
|
||||
it("fill reads its value after coercing its range", () => {
|
||||
const base = { values: [{ value: 0 }], other: { value: 1 } };
|
||||
const result = produce(base, (draft) => {
|
||||
draft.values.fill(draft.other, {
|
||||
valueOf() {
|
||||
draft.other.value = 2;
|
||||
return 0;
|
||||
},
|
||||
} as unknown as number);
|
||||
});
|
||||
expect(result.values).toEqual([{ value: 2 }]);
|
||||
expect(
|
||||
produce(base, (draft) => {
|
||||
draft.values.fill(undefined as unknown as { value: number }, 0, 0);
|
||||
}),
|
||||
).toBe(base);
|
||||
});
|
||||
|
||||
it("copies object values for fill and copyWithin", () => {
|
||||
const base = { values: [{ value: 1 }, { value: 2 }, { value: 3 }] };
|
||||
const result = produce(base, (draft) => {
|
||||
draft.values.copyWithin(1, 0, 1);
|
||||
draft.values[0]!.value = 9;
|
||||
});
|
||||
expect(result.values).toEqual([{ value: 9 }, { value: 1 }, { value: 3 }]);
|
||||
expect(result.values[0]).not.toBe(result.values[1]);
|
||||
const filled = produce({ ...base, other: { value: 4 } }, (draft) => {
|
||||
draft.values.fill(draft.other);
|
||||
draft.values[0]!.value = 5;
|
||||
});
|
||||
expect(filled.values).toEqual([{ value: 5 }, { value: 4 }, { value: 4 }]);
|
||||
expect(new Set([...filled.values, filled.other]).size).toBe(4);
|
||||
});
|
||||
|
||||
it("uses the actual receiver for borrowed array mutators", () => {
|
||||
const base = { left: [{ value: 1 }, { value: 2 }], right: [{ value: 3 }] };
|
||||
const external = [{ value: 0 }];
|
||||
const result = produce(base, (draft) => {
|
||||
const held = draft.left[0]!;
|
||||
draft.right.reverse.call(draft.left);
|
||||
held.value = 9;
|
||||
draft.right.push.call(external, { value: 2 });
|
||||
expect(() => {
|
||||
const push = draft.right.push;
|
||||
push({ value: 4 });
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
expect(result.left).toEqual([{ value: 2 }, { value: 9 }]);
|
||||
expect(external).toEqual([{ value: 0 }, { value: 2 }]);
|
||||
});
|
||||
|
||||
it("copies user assignments made while an array mutator coerces arguments", () => {
|
||||
const base = { values: [{ value: 1 }], other: { value: 2 } };
|
||||
const result = produce(base, (draft) => {
|
||||
draft.values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
draft.values[0] = draft.other;
|
||||
return 0;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
);
|
||||
draft.values[0]!.value = 3;
|
||||
});
|
||||
expect(result).toEqual({ values: [{ value: 3 }], other: { value: 2 } });
|
||||
expect(result.values[0]).not.toBe(result.other);
|
||||
|
||||
const sameArray = produce({ values: [{ value: 1 }, { value: 2 }] }, (draft) => {
|
||||
draft.values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
draft.values[1] = draft.values[0]!;
|
||||
return 0;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
);
|
||||
draft.values[0]!.value = 9;
|
||||
});
|
||||
expect(sameArray.values).toEqual([{ value: 9 }, { value: 1 }]);
|
||||
expect(sameArray.values[0]).not.toBe(sameArray.values[1]);
|
||||
});
|
||||
|
||||
it("copyWithin preserves handles across coercion side effects", () => {
|
||||
const base = { values: [{ value: 1 }, { value: 2 }, { value: 3 }] };
|
||||
const result = produce(base, (draft) => {
|
||||
const held = draft.values[2]!;
|
||||
draft.values.copyWithin(
|
||||
0,
|
||||
{
|
||||
valueOf() {
|
||||
draft.values.reverse();
|
||||
return 0;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
);
|
||||
held.value = 9;
|
||||
});
|
||||
expect(result.values).toEqual([{ value: 9 }, { value: 2 }, { value: 1 }]);
|
||||
});
|
||||
|
||||
it("preserves native coercion semantics in array arguments", () => {
|
||||
const spliced = produce({ values: [1, 2, 3] }, (draft) => {
|
||||
draft.values.splice(Number.POSITIVE_INFINITY, 0, 4);
|
||||
});
|
||||
expect(spliced.values).toEqual([1, 2, 3, 4]);
|
||||
const filled = produce({ values: [1, 2, 3] }, (draft) => {
|
||||
draft.values.fill(9, Number.POSITIVE_INFINITY);
|
||||
draft.values.copyWithin(Number.POSITIVE_INFINITY, 0);
|
||||
});
|
||||
expect(filled.values).toEqual([1, 2, 3]);
|
||||
const explicitUndefined = produce({ values: [1, 2, 3], copied: [1, 2, 3] }, (draft) => {
|
||||
draft.values.fill(9, 0, undefined as unknown as number);
|
||||
draft.copied.copyWithin(1, 0, undefined as unknown as number);
|
||||
});
|
||||
expect(explicitUndefined.values).toEqual([9, 9, 9]);
|
||||
expect(explicitUndefined.copied).toEqual([1, 1, 2]);
|
||||
const capturedLength = produce({ values: [1, 2] }, (draft) => {
|
||||
draft.values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
draft.values.push(3);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
);
|
||||
});
|
||||
expect(capturedLength.values).toEqual([1, 2]);
|
||||
const removedLength = produce({ values: [1, 2, 3], removed: 0 }, (draft) => {
|
||||
const removed = draft.values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
draft.values.length = 0;
|
||||
return 0;
|
||||
},
|
||||
} as unknown as number,
|
||||
3,
|
||||
);
|
||||
draft.removed = removed.length;
|
||||
});
|
||||
expect(removedLength).toEqual({ values: [], removed: 3 });
|
||||
});
|
||||
|
||||
it("default sort observes pending nested edits", () => {
|
||||
const base = { values: [[2], [1]] };
|
||||
const result = produce(base, (draft) => {
|
||||
draft.values[0]![0] = 0;
|
||||
draft.values.sort();
|
||||
});
|
||||
expect(result.values).toEqual([[0], [1]]);
|
||||
});
|
||||
|
||||
it("keeps comparator edits when sorting object drafts", () => {
|
||||
const base = {
|
||||
rows: [
|
||||
{ rank: 2, comparisons: 0 },
|
||||
{ rank: 1, comparisons: 0 },
|
||||
],
|
||||
};
|
||||
const result = produce(base, (draft) => {
|
||||
draft.rows.sort((left, right) => {
|
||||
left.comparisons++;
|
||||
right.comparisons++;
|
||||
return left.rank - right.rank;
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.rows.map((row) => row.rank)).toEqual([1, 2]);
|
||||
expect(result.rows.map((row) => row.comparisons)).toEqual([1, 1]);
|
||||
expect(result.rows[0]).not.toBe(base.rows[1]);
|
||||
expect(result.rows[1]).not.toBe(base.rows[0]);
|
||||
expect(base.rows).toEqual([
|
||||
{ rank: 2, comparisons: 0 },
|
||||
{ rank: 1, comparisons: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops writes through detached child handles", () => {
|
||||
const kept = { value: "kept" };
|
||||
const removed = { value: "removed" };
|
||||
const base = { child: removed, items: [removed, kept] };
|
||||
const result = produce(base, (draft) => {
|
||||
const child = draft.child;
|
||||
const shifted = draft.items.shift();
|
||||
delete (draft as { child?: Draft<typeof removed>; items: Draft<typeof base.items> }).child;
|
||||
child.value = "detached child";
|
||||
if (shifted !== undefined) shifted.value = "detached item";
|
||||
});
|
||||
|
||||
expect(result).toEqual({ items: [kept] });
|
||||
expect(result.items[0]).toBe(kept);
|
||||
expect(removed.value).toBe("removed");
|
||||
});
|
||||
|
||||
it("deep-clones assigned values, expands aliases, and reports every clone as owned", () => {
|
||||
const shared = { value: 2 };
|
||||
const external = { left: shared, right: shared };
|
||||
const base: { payload: typeof external | null; untouched: { value: number } } = {
|
||||
payload: null,
|
||||
untouched: { value: 1 },
|
||||
};
|
||||
const { value, owned } = produceWithMetadata(base, (draft) => {
|
||||
draft.payload = external;
|
||||
draft.payload.left.value = 3;
|
||||
});
|
||||
|
||||
expect(external.left.value).toBe(2);
|
||||
expect(value.payload).not.toBe(external);
|
||||
expect(value.payload?.left).not.toBe(shared);
|
||||
expect(value.payload?.right).not.toBe(shared);
|
||||
expect(value.payload?.left).not.toBe(value.payload?.right);
|
||||
expect(value.payload).toEqual({ left: { value: 3 }, right: { value: 2 } });
|
||||
expect(value.untouched).toBe(base.untouched);
|
||||
expect(owned.has(value)).toBe(true);
|
||||
expect(owned.has(value.payload!)).toBe(true);
|
||||
expect(owned.has(value.payload!.left)).toBe(true);
|
||||
expect(owned.has(value.payload!.right)).toBe(true);
|
||||
expect(owned.has(external)).toBe(false);
|
||||
expect(owned.has(shared)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cycles in assigned values without changing the base", () => {
|
||||
const cyclic: { self?: unknown } = {};
|
||||
cyclic.self = cyclic;
|
||||
const base: { payload: object | null } = { payload: null };
|
||||
expect(() =>
|
||||
produce(base, (draft) => {
|
||||
draft.payload = cyclic;
|
||||
}),
|
||||
).toThrow(/cycles/);
|
||||
expect(base.payload).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects sparse or undefined array results", () => {
|
||||
const base = { values: [1, 2] };
|
||||
expect(() =>
|
||||
produce(base, (draft) => {
|
||||
delete draft.values[0];
|
||||
}),
|
||||
).toThrow(/dense|holes/);
|
||||
expect(() =>
|
||||
produce(base, (draft) => {
|
||||
draft.values[0] = undefined as unknown as number;
|
||||
}),
|
||||
).toThrow(/undefined/);
|
||||
expect(() =>
|
||||
produce(base, (draft) => {
|
||||
draft.values.length = 4;
|
||||
}),
|
||||
).toThrow(/dense|holes/);
|
||||
expect(base.values).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("writes own properties without invoking inherited setters", () => {
|
||||
Object.defineProperty(Object.prototype, "trap", {
|
||||
set() {
|
||||
throw new Error("inherited setter ran");
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const result = produce({ nested: {} as Record<string, number> }, (draft) => {
|
||||
draft.nested.trap = 1;
|
||||
});
|
||||
expect(result).toEqual({ nested: { trap: 1 } });
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>).trap;
|
||||
}
|
||||
|
||||
let pushed: { values: number[] } | undefined;
|
||||
let unshifted: { values: number[] } | undefined;
|
||||
Object.defineProperty(Array.prototype, "3", {
|
||||
set() {
|
||||
throw new Error("inherited array setter ran");
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
pushed = produce({ values: [1, 2, 3] }, (draft) => {
|
||||
draft.values.push(4);
|
||||
});
|
||||
unshifted = produce({ values: [1, 2, 3] }, (draft) => {
|
||||
draft.values.unshift(0);
|
||||
});
|
||||
} finally {
|
||||
delete (Array.prototype as unknown as Record<string, unknown>)["3"];
|
||||
}
|
||||
expect(pushed).toEqual({ values: [1, 2, 3, 4] });
|
||||
expect(unshifted).toEqual({ values: [0, 1, 2, 3] });
|
||||
});
|
||||
|
||||
it("rejects reflective mutations and symbol writes", () => {
|
||||
const base = { value: 1 };
|
||||
expect(() => produce(base, (draft) => Object.defineProperty(draft, "other", { value: 2 }))).toThrow(TypeError);
|
||||
expect(() => produce(base, (draft) => Object.setPrototypeOf(draft, null))).toThrow(TypeError);
|
||||
expect(() => produce(base, (draft) => Object.preventExtensions(draft))).toThrow(TypeError);
|
||||
expect(() => produce(base, (draft) => Reflect.set(draft, Symbol("key"), 2))).toThrow(/Symbol/);
|
||||
expect(base).toEqual({ value: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import { applyImmutable, type Op } from "../src/delta/index.ts";
|
||||
import { replicatedState } from "../src/index.ts";
|
||||
import { getReplicatedStateInternals } from "../src/services/state-internals.ts";
|
||||
import type { Draft } from "../src/state/draft.ts";
|
||||
|
||||
type Item = { id: number; text: string; score: number };
|
||||
type Document = {
|
||||
items: Item[];
|
||||
text: string;
|
||||
meta: { revision: number; label?: string };
|
||||
};
|
||||
|
||||
type MutableDocument = Document | Draft<Document>;
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const random =
|
||||
(seed: number): (() => number) =>
|
||||
() => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
|
||||
const mutate = (document: MutableDocument, choice: number, value: number): void => {
|
||||
const item = (): Item => ({ id: value, text: `item-${value}`, score: value % 7 });
|
||||
switch (choice) {
|
||||
case 0:
|
||||
document.text += `-${value}`;
|
||||
break;
|
||||
case 1:
|
||||
document.text = `${document.text.slice(Math.min(2, document.text.length))}${value}`;
|
||||
break;
|
||||
case 2:
|
||||
document.items.push(item());
|
||||
break;
|
||||
case 3:
|
||||
document.items.unshift(item());
|
||||
break;
|
||||
case 4:
|
||||
if (document.items.length > 0) document.items.shift();
|
||||
break;
|
||||
case 5:
|
||||
if (document.items.length > 0) document.items.pop();
|
||||
break;
|
||||
case 6: {
|
||||
const index = document.items.length === 0 ? 0 : value % (document.items.length + 1);
|
||||
document.items.splice(index, document.items.length === 0 ? 0 : value % 2, item());
|
||||
break;
|
||||
}
|
||||
case 7:
|
||||
document.items.reverse();
|
||||
break;
|
||||
case 8:
|
||||
document.items.sort((left, right) => left.id - right.id);
|
||||
break;
|
||||
case 9:
|
||||
if (document.items.length > 0) document.items[value % document.items.length]!.score = value;
|
||||
break;
|
||||
case 10:
|
||||
document.meta.revision += 1;
|
||||
document.meta.label = `revision-${value}`;
|
||||
break;
|
||||
case 11:
|
||||
delete document.meta.label;
|
||||
break;
|
||||
case 12:
|
||||
if (document.items.length > 1) document.items[1] = clone(document.items[0]!);
|
||||
break;
|
||||
default:
|
||||
for (let index = 0; index < Math.min(2, document.items.length); index++) document.items[index] = item();
|
||||
}
|
||||
};
|
||||
|
||||
it("converges across randomized transactional revisions", () => {
|
||||
for (let seed = 1; seed <= 100; seed++) {
|
||||
const rng = random(seed);
|
||||
const initial: Document = {
|
||||
items: Array.from({ length: 4 }, (_, id) => ({ id, text: `item-${id}`, score: 0 })),
|
||||
text: "start",
|
||||
meta: { revision: 0 },
|
||||
};
|
||||
const state = replicatedState(initial);
|
||||
const expected = clone(initial);
|
||||
let replica = clone(state.value);
|
||||
let latest: readonly Op[] = [];
|
||||
getReplicatedStateInternals(state)!.subscribe((operations) => {
|
||||
latest = operations;
|
||||
});
|
||||
for (let step = 0; step < 100; step++) {
|
||||
const choice = Math.floor(rng() * 14);
|
||||
const value = seed * 1_000 + step;
|
||||
mutate(expected, choice, value);
|
||||
latest = [];
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => mutate(draft, choice, value));
|
||||
replica = applyImmutable(replica, latest);
|
||||
expect(state.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected);
|
||||
expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected);
|
||||
expect(Object.isFrozen(state.value)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { produceWithMetadata } from "../src/state/draft.ts";
|
||||
import { JsonRevisionStore } from "../src/state/value.ts";
|
||||
|
||||
describe("JSON revision ownership", () => {
|
||||
it("imports detached frozen JSON and expands aliases", () => {
|
||||
const shared = { value: 1 };
|
||||
const input = { left: shared, right: shared };
|
||||
const store = new JsonRevisionStore();
|
||||
const value = store.import(input);
|
||||
expect(value).toEqual(input);
|
||||
expect(value).not.toBe(input);
|
||||
expect(value.left).not.toBe(value.right);
|
||||
expect(Object.isFrozen(value)).toBe(true);
|
||||
expect(Object.isFrozen(value.left)).toBe(true);
|
||||
});
|
||||
|
||||
it("commits transaction copies in place while sharing unchanged branches", () => {
|
||||
const store = new JsonRevisionStore();
|
||||
const base = store.import({ changed: { value: 1 }, retained: { value: 2 } });
|
||||
const produced = produceWithMetadata(base, (draft) => {
|
||||
draft.changed.value = 3;
|
||||
});
|
||||
const next = store.commit(produced.value, produced.owned);
|
||||
expect(next).toBe(produced.value);
|
||||
expect(next.changed).toBe(produced.value.changed);
|
||||
expect(next.retained).toBe(base.retained);
|
||||
expect(Object.isFrozen(next)).toBe(true);
|
||||
expect(Object.isFrozen(next.changed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-JSON values and cycles", () => {
|
||||
const store = new JsonRevisionStore();
|
||||
expect(() => store.import({ value: Number.NaN })).toThrow(/strict JSON/);
|
||||
expect(() => store.import({ value: new Date() })).toThrow(/plain objects/);
|
||||
const cyclic: { self?: object } = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(() => store.import(cyclic)).toThrow(/cycles/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import { replicatedState } from "../src/index.ts";
|
||||
import { ReplicatedStateReplica } from "../src/services/state.ts";
|
||||
import { getReplicatedStateInternals } from "../src/services/state-internals.ts";
|
||||
import type { Draft } from "../src/state/draft.ts";
|
||||
import type { JsonValue } from "../src/types.ts";
|
||||
|
||||
describe("transactional replicated state", () => {
|
||||
it("publishes one immutable structurally shared revision", () => {
|
||||
const initial = { changed: { value: 1 }, retained: { value: 2 } };
|
||||
const state = replicatedState(initial);
|
||||
const previous = state.value;
|
||||
const deliveries: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.changed.value = 3;
|
||||
draft.changed.value = 4;
|
||||
});
|
||||
expect(state.value).toEqual({ changed: { value: 4 }, retained: { value: 2 } });
|
||||
expect(state.value).not.toBe(previous);
|
||||
expect(state.value.changed).not.toBe(previous.changed);
|
||||
expect(state.value.retained).toBe(previous.retained);
|
||||
expect(Object.isFrozen(state.value)).toBe(true);
|
||||
expect(deliveries).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("rolls back callback failures and revokes escaped drafts", () => {
|
||||
const state = replicatedState({ nested: { value: 1 } });
|
||||
const previous = state.value;
|
||||
let escaped: Draft<{ value: number }> | undefined;
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
escaped = draft.nested;
|
||||
draft.nested.value = 2;
|
||||
throw new Error("stop");
|
||||
}),
|
||||
).toThrow("stop");
|
||||
expect(state.value).toBe(previous);
|
||||
expect(() => escaped?.value).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("rejects nested changes and replacements without losing the outer rollback", () => {
|
||||
const state = replicatedState({ left: 0, right: 0 });
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left = 1;
|
||||
state.change(BACKGROUND_CONTEXT, (nested) => {
|
||||
nested.right = 2;
|
||||
});
|
||||
}),
|
||||
).toThrow(/reentrantly/);
|
||||
expect(state.value).toEqual({ left: 0, right: 0 });
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, () => {
|
||||
state.replace(BACKGROUND_CONTEXT, { left: 1, right: 2 });
|
||||
}),
|
||||
).toThrow(/change callback/);
|
||||
expect(state.value).toEqual({ left: 0, right: 0 });
|
||||
});
|
||||
|
||||
it("queues listener-triggered changes in sequence order", () => {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const sourceSequences: number[] = [];
|
||||
const deliveries: Array<{ value: number; sequence: number }> = [];
|
||||
const lateDeliveries: Array<{ kind: string; sequence: number }> = [];
|
||||
let nested = false;
|
||||
getReplicatedStateInternals(state)!.subscribe((_operations, sequence) => {
|
||||
if (!nested) {
|
||||
nested = true;
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 2;
|
||||
});
|
||||
state.subscribe((_value, _context, delivery) => lateDeliveries.push(delivery));
|
||||
}
|
||||
void sequence;
|
||||
});
|
||||
getReplicatedStateInternals(state)!.subscribe((_operations, sequence) => sourceSequences.push(sequence));
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") deliveries.push({ value: value.value, sequence: delivery.sequence });
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
});
|
||||
expect(sourceSequences).toEqual([1, 2]);
|
||||
expect(deliveries).toEqual([
|
||||
{ value: 1, sequence: 1 },
|
||||
{ value: 2, sequence: 2 },
|
||||
]);
|
||||
expect(lateDeliveries).toEqual([{ kind: "hydrate", sequence: 2 }]);
|
||||
});
|
||||
|
||||
it("isolates listener failures after committing the revision", () => {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const received: number[] = [];
|
||||
getReplicatedStateInternals(state)!.subscribe(() => {
|
||||
throw new Error("listener failed");
|
||||
});
|
||||
getReplicatedStateInternals(state)!.subscribe((_operations, sequence) => received.push(sequence));
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
}),
|
||||
).toThrow("listener failed");
|
||||
expect(state.value).toEqual({ value: 1 });
|
||||
expect(received).toEqual([1]);
|
||||
});
|
||||
|
||||
it("copies assigned values by value", () => {
|
||||
const external = { value: 1 };
|
||||
const state = replicatedState<{ left: { value: number } | null; right: { value: number } | null }>({
|
||||
left: null,
|
||||
right: null,
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left = external;
|
||||
draft.right = external;
|
||||
draft.left.value = 2;
|
||||
});
|
||||
expect(external.value).toBe(1);
|
||||
expect(state.value).toEqual({ left: { value: 2 }, right: { value: 1 } });
|
||||
expect(state.value.left).not.toBe(state.value.right);
|
||||
});
|
||||
|
||||
it("expands reused owned subtrees into independent placements", () => {
|
||||
const state = replicatedState({ left: { value: 1 }, right: { value: 2 } });
|
||||
state.replace(BACKGROUND_CONTEXT, { left: state.value.left, right: state.value.left });
|
||||
expect(state.value.left).not.toBe(state.value.right);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left.value = 9;
|
||||
});
|
||||
expect(state.value).toEqual({ left: { value: 9 }, right: { value: 1 } });
|
||||
|
||||
const overlapping = replicatedState({ parent: { child: { value: 1 } }, other: { value: 2 } });
|
||||
overlapping.replace(BACKGROUND_CONTEXT, {
|
||||
parent: overlapping.value.parent,
|
||||
other: overlapping.value.parent.child,
|
||||
});
|
||||
expect(overlapping.value.parent.child).not.toBe(overlapping.value.other);
|
||||
overlapping.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.other.value = 9;
|
||||
});
|
||||
expect(overlapping.value).toEqual({ parent: { child: { value: 1 } }, other: { value: 9 } });
|
||||
});
|
||||
|
||||
it("preserves compact string, splice, and permutation operations", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
const state = replicatedState({ text: "abcdefgh", values: [a, b, c] });
|
||||
const batches: JsonValue[] = [];
|
||||
getReplicatedStateInternals(state)!.subscribe((operations) => batches.push(operations as unknown as JsonValue));
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.text = "defghxyz";
|
||||
draft.values.shift();
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.values.reverse();
|
||||
});
|
||||
expect(batches).toEqual([
|
||||
[
|
||||
["t", ["text"], 3],
|
||||
["a", ["text"], "xyz"],
|
||||
["p", ["values"], 0, 1, []],
|
||||
],
|
||||
[["m", ["values"], [1, 0]]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears a replica when an adopted update is invalid", () => {
|
||||
const errors: Error[] = [];
|
||||
const replica = new ReplicatedStateReplica<{ value: number }>((error) => errors.push(error));
|
||||
replica.hydrate(0, [["r", { value: 0 }]], BACKGROUND_CONTEXT);
|
||||
expect(() => replica.update(1, [["s", ["value"], Number.NaN]], BACKGROUND_CONTEXT)).toThrow(/strict JSON/);
|
||||
expect(replica.value).toBeUndefined();
|
||||
expect(() => replica.update(2, [["s", ["value"], 2]], BACKGROUND_CONTEXT)).toThrow(/before hydration/);
|
||||
|
||||
const malformed = new ReplicatedStateReplica<{ values: number[] }>((error) => errors.push(error));
|
||||
malformed.hydrate(0, [["r", { values: [1, 2] }]], BACKGROUND_CONTEXT);
|
||||
expect(() => malformed.update(1, [["m", ["values"], [0]]], BACKGROUND_CONTEXT)).toThrow();
|
||||
expect(malformed.value).toBeUndefined();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("replaces atomically and ignores deeply equal replacements", () => {
|
||||
const state = replicatedState({ value: { nested: 1 }, retained: { nested: 2 } });
|
||||
const previous = state.value;
|
||||
state.replace(BACKGROUND_CONTEXT, { value: { nested: 1 }, retained: { nested: 2 } });
|
||||
expect(state.value).toBe(previous);
|
||||
state.replace(BACKGROUND_CONTEXT, { ...state.value, value: { nested: 2 } });
|
||||
expect(state.value).toEqual({ value: { nested: 2 }, retained: { nested: 2 } });
|
||||
expect(state.value.retained).not.toBe(previous.retained);
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@ export default defineFacet({
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
workerActivations.state.count += 1;
|
||||
workerActivations.publish(BACKGROUND_CONTEXT);
|
||||
workerActivations.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.count += 1;
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -361,13 +361,7 @@ export function createSessionServiceSource(client: Client, options: ServiceSourc
|
||||
}
|
||||
|
||||
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T, context: Context): void {
|
||||
const target = state.state as Record<string, unknown>;
|
||||
const replacement = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(replacement, key)) delete target[key];
|
||||
}
|
||||
Object.assign(target, replacement);
|
||||
state.publish(context);
|
||||
state.replace(context, value);
|
||||
}
|
||||
|
||||
function toServerConnectionState(client: Client, attempt: number, error?: Error): ServerConnectionState {
|
||||
|
||||
@@ -58,31 +58,36 @@ export function createModelsService(
|
||||
const index = levels.indexOf(current);
|
||||
const next = levels[(index + 1) % levels.length] ?? "off";
|
||||
await lane.setThinkingLevel(next, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
const configuration = await readConfiguration(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.configuration = configuration;
|
||||
});
|
||||
},
|
||||
async getThinkingLevels(context) {
|
||||
return [...(await readThinkingLevels(context))];
|
||||
},
|
||||
async refresh(context) {
|
||||
state.state.refresh = { status: "refreshing" };
|
||||
state.publish(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.refresh = { status: "refreshing" };
|
||||
});
|
||||
const refresh = modelRuntime?.refresh({ signal: context.abortSignal });
|
||||
if (refresh === undefined) {
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = { status: "done" };
|
||||
state.publish(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.catalog = catalog;
|
||||
draft.configuration = configuration;
|
||||
draft.refresh = { status: "done" };
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await refresh;
|
||||
const errors = Object.fromEntries([...result.errors].map(([id, error]) => [id, error.message]));
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors };
|
||||
state.publish(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.catalog = catalog;
|
||||
draft.configuration = configuration;
|
||||
draft.refresh = Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors };
|
||||
});
|
||||
},
|
||||
async select(model, context) {
|
||||
const selected = modelRuntime?.getModel(model.provider, model.modelId);
|
||||
@@ -90,8 +95,10 @@ export function createModelsService(
|
||||
await lane.setModel({ provider: selected.provider, modelId: selected.id }, context);
|
||||
settingsManager?.setDefaultModelAndProvider(selected.provider, selected.id);
|
||||
await settingsManager?.flush();
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
const configuration = await readConfiguration(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.configuration = configuration;
|
||||
});
|
||||
},
|
||||
async selectThinking(level, context) {
|
||||
const levels = await readThinkingLevels(context);
|
||||
@@ -99,18 +106,21 @@ export function createModelsService(
|
||||
throw new Error(`Thinking level ${level} is unavailable; choose one of: ${levels.join(", ")}`);
|
||||
}
|
||||
await lane.setThinkingLevel(level, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
const configuration = await readConfiguration(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.configuration = configuration;
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
service,
|
||||
async activate(context) {
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = { status: "idle" };
|
||||
state.publish(context);
|
||||
state.change(context, (draft) => {
|
||||
draft.catalog = catalog;
|
||||
draft.configuration = configuration;
|
||||
draft.refresh = { status: "idle" };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@ export async function createExperimentalServerServices(options: {
|
||||
const refreshNow = async (context: Context): Promise<void> => {
|
||||
const sessions = await options.list(context);
|
||||
revision += 1;
|
||||
directory.state.revision = revision;
|
||||
directory.state.sessions = sessions;
|
||||
directory.publish(context);
|
||||
directory.change(context, (draft) => {
|
||||
draft.revision = revision;
|
||||
draft.sessions = sessions;
|
||||
});
|
||||
};
|
||||
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const result = mutationTail.catch(() => {}).then(operation);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
|
||||
import { type Context, defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type AgentLane,
|
||||
@@ -26,17 +26,11 @@ export function createTranscriptService(
|
||||
let rebase: Promise<void> | undefined;
|
||||
let rebaseError: Error | undefined;
|
||||
|
||||
const publishSnapshot = (
|
||||
next: LaneSnapshot,
|
||||
event: LaneWatchEvent | null,
|
||||
context: Parameters<typeof state.publish>[0],
|
||||
): void => {
|
||||
state.state.snapshot = next as LaneTranscriptSnapshot;
|
||||
state.state.event = event;
|
||||
state.publish(context);
|
||||
const publishSnapshot = (next: LaneSnapshot, event: LaneWatchEvent | null, context: Context): void => {
|
||||
state.replace(context, { snapshot: next as LaneTranscriptSnapshot, event });
|
||||
};
|
||||
|
||||
const scheduleRebase = (context: Parameters<typeof state.publish>[0]): void => {
|
||||
const scheduleRebase = (context: Context): void => {
|
||||
if (rebase !== undefined) return;
|
||||
const activeWatch = watch;
|
||||
if (activeWatch === undefined) return;
|
||||
@@ -56,15 +50,17 @@ export function createTranscriptService(
|
||||
);
|
||||
};
|
||||
|
||||
const onEvent = (event: HarnessEvent, context: Parameters<typeof state.publish>[0]): void => {
|
||||
const onEvent = (event: HarnessEvent, context: Context): void => {
|
||||
if (rebaseError !== undefined) throw rebaseError;
|
||||
const forwarded = toLaneWatchEvent(event);
|
||||
if (forwarded === undefined) return;
|
||||
const snapshot = state.state.snapshot;
|
||||
if (snapshot === null) throw new Error("Transcript service is not active");
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") scheduleRebase(context);
|
||||
state.state.event = forwarded;
|
||||
state.publish(context);
|
||||
if (state.value.snapshot === null) throw new Error("Transcript service is not active");
|
||||
let needsRebase = false;
|
||||
state.change(context, (draft) => {
|
||||
needsRebase = reduceLaneSnapshot(draft.snapshot as unknown as LaneSnapshot, event) === "rebase";
|
||||
draft.event = forwarded;
|
||||
});
|
||||
if (needsRebase) scheduleRebase(context);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -64,13 +64,7 @@ function createLoopbackServiceTransport(provider: RemoteServiceProvider): Remote
|
||||
}
|
||||
|
||||
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T): void {
|
||||
const target = state.state as unknown as Record<string, unknown>;
|
||||
const replacement = value as unknown as Record<string, unknown>;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(replacement, key)) delete target[key];
|
||||
}
|
||||
Object.assign(target, replacement);
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.replace(BACKGROUND_CONTEXT, value);
|
||||
}
|
||||
|
||||
function laneSnapshot(): LaneSnapshot {
|
||||
@@ -126,30 +120,33 @@ describe("experimental client TUI", () => {
|
||||
});
|
||||
const create = vi.fn(async () => {
|
||||
const created = session("two", 2);
|
||||
directoryState.state.revision = 2;
|
||||
directoryState.state.sessions.push(created);
|
||||
directoryState.publish(BACKGROUND_CONTEXT);
|
||||
directoryState.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 2;
|
||||
draft.sessions.push(created);
|
||||
});
|
||||
return created;
|
||||
});
|
||||
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
|
||||
modelsState.state.configuration.model = model;
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.configuration.model = model;
|
||||
});
|
||||
});
|
||||
const selectThinking = vi.fn(async (thinkingLevel: "off" | "high") => {
|
||||
modelsState.state.configuration.thinkingLevel = thinkingLevel;
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.configuration.thinkingLevel = thinkingLevel;
|
||||
});
|
||||
});
|
||||
const transcriptState = replicatedState<TranscriptState>({
|
||||
snapshot: laneSnapshot() as LaneTranscriptSnapshot,
|
||||
event: null,
|
||||
});
|
||||
const emitTranscriptEvent = (event: LaneWatchEvent): void => {
|
||||
const snapshot = transcriptState.state.snapshot as LaneSnapshot;
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") {
|
||||
throw new Error("Test transcript event unexpectedly requires a rebase");
|
||||
}
|
||||
transcriptState.state.event = event;
|
||||
transcriptState.publish(BACKGROUND_CONTEXT);
|
||||
transcriptState.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
if (reduceLaneSnapshot(draft.snapshot as unknown as LaneSnapshot, event) === "rebase") {
|
||||
throw new Error("Test transcript event unexpectedly requires a rebase");
|
||||
}
|
||||
draft.event = event;
|
||||
});
|
||||
};
|
||||
let finishPrompt!: () => void;
|
||||
const promptFinished = new Promise<void>((resolve) => {
|
||||
@@ -430,12 +427,14 @@ describe("experimental client TUI", () => {
|
||||
|
||||
await component.close();
|
||||
const rendersAfterClose = requestRender.mock.calls.length;
|
||||
directoryState.state.revision = 3;
|
||||
directoryState.state.sessions = [];
|
||||
directoryState.publish(BACKGROUND_CONTEXT);
|
||||
directoryState.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.revision = 3;
|
||||
draft.sessions = [];
|
||||
});
|
||||
publishReplacement(attachment, { status: "detached" });
|
||||
modelsState.state.refresh = { status: "refreshing" };
|
||||
modelsState.publish(BACKGROUND_CONTEXT);
|
||||
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.refresh = { status: "refreshing" };
|
||||
});
|
||||
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
|
||||
} finally {
|
||||
await component.close();
|
||||
|
||||
@@ -58,9 +58,10 @@ committed value and adapter-owned delivery sequence, and forward committed ops w
|
||||
tracker or re-diff. Disposal releases observation, not the document. Even Chord's
|
||||
hydration requests must never flush an uncommitted draft.
|
||||
|
||||
Current `env.replicatedState(initial)` creates a **mutable, in-memory** tracker
|
||||
with `.state` and `.publish(context)`, not a durable source adapter. There is no
|
||||
`replicatedState.own(source)`. Do not mirror documents into another tracker.
|
||||
Current `env.replicatedState(initial)` creates an **in-memory**, immutable
|
||||
latest-value source with transaction-scoped `.change(context, callback)` and
|
||||
`.replace(context, value)`, not a durable source adapter. There is no
|
||||
`replicatedState.own(source)`. Do not mirror documents into another state source.
|
||||
|
||||
## 1. A Session-wide canvas
|
||||
|
||||
|
||||
Reference in New Issue
Block a user