chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -1,319 +1,193 @@
# Chord Delta
Chord Delta synchronizes JSON values from an authoritative producer to an
ordered replica. It is available from `@earendil-works/chord/delta`.
Chord Delta produces immutable JSON revisions and exact operation batches for
ordered replicas. Import it 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()`.
Immutability is an ownership contract. Nothing is frozen or defensively copied,
so an illegal mutation is not detected. It silently corrupts state.
```ts
import { apply, track } from "@earendil-works/chord/delta";
import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "", entries: [] as string[] });
let replica = apply(undefined, tracker.flush());
const initial = { output: "", entries: [] as { id: number }[] };
const tracker = track(initial); // `initial` is transferred: never mutate it again
tracker.state.output += "done\n";
tracker.state.entries.push("result");
replica = apply(replica, tracker.flush());
const change = tracker.beginChange();
change.state.output += "done\n"; // the draft is mutable only while the change is open
change.state.entries.push({ id: 1 }); // placed values are cloned; the caller keeps its object
const prepared = change.prepare(); // draft handles are unusable from here on
// tracker.value, prepared.base, prepared.value, and prepared.ops (including paths
// and payloads) are immutable by contract.
tracker.adopt(prepared); // tracker.value === prepared.value
// Shares containers with prepared.base and the op payloads: never mutate it either.
const replica = applyImmutable(prepared.base, prepared.ops);
```
The first `flush()` returns one operation containing the complete value. Each
later flush returns the operations needed to transform the previously published
value into the current value. It returns `[]` when the value has not changed.
## Mutation rights
`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.
| Value | May it be mutated? |
| --- | --- |
| Root passed to `track()`, `prepareReplace()`, `replicatedState()`, or `replace()` | No. Ownership moved to the tracker. |
| `change.state` and handles read from it | Yes, only while that change is open. After `prepare()`, `abort()`, or another adoption, every use throws. Writes through a handle whose element was removed from the draft are ignored. |
| External value after assigning or inserting it into a draft | Yes. The draft stored a validated clone. |
| `tracker.value`, `prepared.base`, retained older revisions | No. |
| `prepared.value`, `prepared.ops`, op tuples, paths, permutations, payloads | No. Payloads may be the same objects as parts of `prepared.value`. |
| `applyImmutable()` / `applyImmutableBatches()` inputs and result | No. The result shares containers with both inputs. |
| Values from replicated state and cells (`value`, listener values, in-process handles) | No. In-process consumers may share the provider's containers. |
| Mutable replica passed to `apply()` | Only through `apply()`, with batches it owns exclusively. Code that edits it between batches breaks convergence. |
| Batch passed to `apply()` | Consumed. `apply()` adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again. |
## Sending or storing changes
## Replaying batches
`flush()` produces decoded `Op[]` with complete paths. This is convenient for
local use but repeats long paths on the wire or disk.
`encoder()` compresses those paths and returns `WireOp[]`. `decoder()` validates
the encoded tuples, restores complete paths, and returns the `Op[]` required by
`apply()`:
Immutable replay is the preferred in-process path. One batch can fan out to any
number of immutable replicas:
```ts
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "" });
const enc = encoder(); // producer side
const dec = decoder(); // consumer side
let replica: { output: string } | undefined;
const send = () => {
const ops = tracker.flush();
const wire = enc.encode(ops); // serialize or store WireOp[] here
const received = dec.decode(wire);
replica = apply(replica, received);
};
let replica = tracker.value; // shared with authority; never mutated
// For each adopted batch, in order:
replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base
```
Encoding is optional for local application. Never pass `WireOp[]` directly to
`apply()`.
An encoder and decoder are stateful. Use one pair for each ordered stream. The
encoder assigns numeric IDs to paths used across batches; the decoder remembers
the corresponding definitions. A complete-value operation resets both path
dictionaries, so replay can begin at that batch with a fresh decoder.
Path omission is local to one batch. Numeric path IDs may span batches. Each
independently hydrated replicated-state stream needs its own encoder and decoder.
Do not share a pair between state members or subscriptions, even when their
batches use the same ordered transport connection.
## Operation vocabulary
A path is an array of object keys and array indices:
When only the final result of an ordered backlog is needed, replay its batches
without concatenating their operations. One copy-on-write scope is shared across
the complete call, so no intermediate revision is exposed or safe to retain:
```ts
["operation", "message", "content", 0, "text"]
replica = applyImmutableBatches(
replica,
queuedFrames.map((frame) => frame.ops),
);
```
### Decoded `Op`
Use separate `applyImmutable()` calls when every intermediate revision is
published or retained.
`track().flush()` returns these tuples, and `apply()` accepts them:
Mutable replay needs a detached starting root and a detached copy of every batch
for every replica. Never apply one in-memory batch to two mutable replicas:
```ts
import { apply } from "@earendil-works/chord/delta";
const detach = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
let replica = detach(tracker.value); // exclusively owned starting root
// For each adopted batch, in order:
replica = apply(replica, detach(prepared.ops)); // fresh copy for this replica only
// Only apply() may change `replica`; do not edit it in application code.
```
Parsing a separately serialized message per replica is also a valid detachment.
`decode()` does not copy payloads, so decoding one in-memory `WireOp[]` for two
mutable replicas makes them alias each other.
## Do and don't
- Do build a fresh root and then transfer it; validate untrusted roots at your ingestion boundary.
- Do keep structural sharing between revisions, for example `replace(ctx, { ...state.value, changed })`.
- Do clone or serialize before handing any published value to code that may mutate it.
- Do settle every change with `prepare()` or `abort()`.
- Don't reuse one container at two places in a transferred root.
- Don't mutate anything the tracker owns, published, or received from a batch.
- Don't keep draft handles past their change or place a settled handle.
- Don't infer meaning from op shape. Only the resulting value is contractual.
## Roots and placements
Full roots are trusted. `track()` and `prepareReplace()` take ownership in O(1)
and never walk the tree. Roots must be alias-free, acyclic strict JSON: dense
plain arrays, plain or null-prototype objects with enumerable own data
properties, strings, booleans, finite numbers, and `null`. Violations have
unspecified behavior. For example, a container shared by two keys can make
`prepared.value` diverge from what replicas compute from `prepared.ops`.
Draft placements are copied, and that copy walk also validates. Placements come
from property writes, index writes, `push`, `unshift`, `splice`, `fill`, and
`copyWithin`. A non-strict value throws `TypeError` before the draft changes;
for example, `push(valid, invalid)` inserts nothing. Rejected values include:
cycles, accessors (never invoked), symbol keys, class instances, sparse or
non-plain arrays, functions, bigint, `NaN`, infinities, and `undefined`.
Null-prototype objects are accepted and preserved. Placing a draft handle clones
its current content. Placing one value at several paths yields independent
containers.
`undefined` rules:
- `draft.obj.key = undefined` deletes `key`.
- `arr[i] = undefined`, `push(undefined)`, `unshift`, `splice`, and `fill` with `undefined` throw.
- `undefined` nested anywhere inside a placed object or array throws. It is not dropped like `JSON.stringify` does.
Arrays stay dense. Writing past the next index and deleting an element throw.
Growing `length` inserts `null`; shrinking it removes elements.
## Lifecycle
`tracker.value` is the latest adopted revision. `beginChange()` opens an overlay
draft over it and never modifies it. A draft may stay open across `await`.
`prepare()` materializes the candidate and ops and does not change authority.
`adopt()` checks the preparation and swaps the root pointer.
Several changes may be open or prepared from one revision. Adopting one makes all
others stale: open drafts become unusable and their `prepare()` throws, and
prepared competitors are rejected by `adopt()`. `adopt()` also rejects foreign,
aborted, and already consumed preparations. `Change.abort()` or
`Prepared.abort()` after `prepare()` prevents adoption. The candidate stays
readable.
No-op batches:
- Empty `ops` means `prepared.value === prepared.base`. Writes restored to their original value and deeply equal container assignments usually normalize to empty.
- Equality ignores key order and prototype. A deeply equal assignment keeps the previous revision's order and prototype, even if the draft showed the new ones.
- Structural array edits may emit a nonempty exact batch even when the result is deeply equal.
- Adopting a no-op still advances `tracker.revision` and stales competitors. Replicated state does not publish no-ops.
`prepareReplace(value)` is a whole-root operation, not a diff. If `value` is
deeply equal to the current root, `ops` is empty and the current root is kept
(the comparison may traverse both trees). Otherwise `ops` is `[["r", value]]`
and `prepared.value === value`, so replicas receive the complete root.
Array mutators: `push`, `pop`, `shift`, `unshift`, `splice`, `reverse`, `sort`,
`fill`, and `copyWithin`. Held handles follow elements through reindexing.
## Operations
Paths contain object keys and non-negative integer array indices.
| Tuple | Meaning |
| --- | --- |
| `["r", value]` | Replace the complete value. |
| `["s", path, value]` | Set a property or array element. |
| `["d", path]` | Delete an object property. |
| `["s", path, value]` | Set an object property or array element. |
| `["d", path]` | Delete an object property or remove an array element. |
| `["a", path, text]` | Append to a string. |
| `["t", path, count]` | Remove UTF-16 code units from a string's front. |
| `["t", path, count]` | Remove `count` UTF-16 code units from a string's front. |
| `["p", path, index, remove, items]` | Splice an array. |
| `["m", path, permutation]` | Reorder an array: `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.
Batches are exact but not canonical. The same change may use different tuples,
and large edit sets may fold into a region splice, an ancestor `s`, or `r`.
### Encoded `WireOp`
Reserved keys: the tracker never emits `__proto__`, `constructor`, or
`prototype` as a path segment. A mutation at or below such a key is folded into a
set of the nearest safe ancestor, or `r` at the root. `apply()`, `applyImmutable()`, `applyImmutableBatches()`, and `decoder()` reject
those segments with `UnsafePathError`.
The appliers write values as own data properties, never through a prototype setter.
Appliers check op shape and path safety but not payload strictness. Chord's
remote state applies batches decoded from JSON frames, so their payloads are strict
JSON; validate payloads of batches from any other source.
A `PathRef` is either an inline path or a non-negative numeric path ID.
`WireOp` supports the following tuples:
`encoder()` interns repeated paths into `WireOp` tuples; `decoder()` validates
and restores `Op` tuples. Use one encoder/decoder pair per ordered state stream.
Path IDs span batches and an `r` resets both dictionaries. After a decode or
apply error, discard the decoder and replica and recover from a later `r`.
| Tuple | Meaning |
| --- | --- |
| `["r", value]` | Complete replacement; identical to decoded form. |
| `["#", id, path]` | Define a numeric path ID. |
| `["s", pathRef, value]` | Set with an inline or interned path. |
| `["s", value]` | Set using the previous path in this batch. |
| `["d", pathRef]` | Delete with an inline or interned path. |
| `["d"]` | Delete using the previous path. |
| `["a", pathRef, text]` | Append with an inline or interned path. |
| `["a", text]` | Append using the previous path. |
| `["t", pathRef, count]` | Front-truncate with an inline or interned path. |
| `["t", count]` | Front-truncate using the previous path. |
| `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. |
| `["p", index, remove, items]` | Splice using the previous path. |
## Limits and footguns
For example, adjacent decoded operations on one path:
```ts
[
["t", ["output"], 200],
["a", ["output"], "next chunk"],
]
```
encode to:
```ts
[
["t", ["output"], 200],
["a", "next chunk"], // reuses ["output"]
]
```
When `output` is used again in a later batch, the encoder defines an ID on its
second explicit use:
```ts
[
["#", 0, ["output"]],
["a", 0, "more"],
]
```
Later batches can use `0` directly until a complete-value operation resets the
dictionary.
## Producing changes
Read and mutate `tracker.state` as a normal object:
```ts
tracker.state.status = "running";
tracker.state.settings.theme = "dark";
tracker.state.messages.push(message);
delete tracker.state.retry;
```
Only the value at flush time is published:
```ts
tracker.state.status = "starting";
tracker.state.status = "running";
tracker.flush(); // one set to "running"
```
Replacing an object or array is valid. Delta compares its properties and elements
with the previously published value:
```ts
tracker.state.settings = {
...plainSettings,
theme: "dark",
};
```
Unchanged properties produce no operations. Changed nested strings and arrays
still use string and splice operations.
### Strings
Appending text produces an `a` operation:
```ts
tracker.state.output += "next line\n";
```
Moving a bounded text window forward produces `t` followed by `a` when the old
suffix matches the new prefix:
```ts
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
```
An unrelated replacement produces `s`.
### Arrays
Use normal array methods:
```ts
tracker.state.messages.push(first);
tracker.state.messages.push(second);
tracker.state.messages.splice(3, 1, replacement);
```
All `push()` calls before one flush produce one tail `p`. Changes to older
elements remain separate, regardless of whether they happen before or after the
pushes. Changes to newly pushed elements are included in the pushed values.
Front or middle insertion, removal, sorting, reversing, `fill()`, and
`copyWithin()` are supported. A structural change combined with edits to elements
whose indices moved may compare and publish the retained suffix positionally:
```ts
tracker.state.items.shift();
tracker.state.items[0].status = "changed";
// A shift followed by push in the same flush has the same issue.
```
The emitted data can then scale with the retained suffix, or with the complete
array, rather than only the changed element. When batching is under your control,
flush the structural change before editing elements at their new indices.
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
`length` creates explicit `null` elements; decreasing it removes elements.
`fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
them to place one mutable object at multiple live paths.
### Optional properties
Optional object properties use absence. They do not require `null`:
```ts
type Settings = { label?: string; count: number };
const tracker = track<Settings>({ count: 0 });
tracker.state.label = "active";
tracker.state.label = undefined; // produces d
// `delete tracker.state.label` is equivalent
```
`undefined` is accepted only as assignment syntax for deleting an object
property. It is not a JSON value. Initial and assigned objects cannot contain own
`undefined` values, and array elements cannot be `undefined`. Use `null` when an
array position or explicit empty value must remain present.
## State ownership
The object passed to `track()` becomes tracker-owned. The same applies to objects
later assigned into state or inserted into arrays.
After insertion, a retained reference may be read but must not be mutated or
inserted at another live location. The tracker relies on this ownership rule; it
does not recursively validate values or detect aliases:
```ts
const item = { status: "new" };
tracker.state.item = item;
tracker.state.item.status = "ready"; // supported: tracked mutation
item.status = "broken"; // unsupported: bypasses tracking
tracker.state.other = item; // unsupported: one object at two live paths
```
The same restriction applies across separate array calls:
```ts
tracker.state.items.push(item);
tracker.state.items.push(item); // unsupported alias
```
Use distinct objects when values must appear at multiple paths. Perform
mutations through `tracker.state`; do not put a proxy read from `tracker.state`
back into tracked state.
Tracked state must be a mutable JSON tree:
- strings, booleans, finite numbers, `null`, arrays, and plain objects;
- no cycles or one mutable object stored at multiple locations;
- no sparse arrays, accessors, frozen objects, symbols, classes, functions,
`Map`, or `Set`.
Do not keep a child proxy across an array operation that changes indices. Read
the child again from its new index.
## Tracker lifecycle
```ts
tracker.flush(); // publish changes since the previous flush
tracker.rebase(); // make the next flush a complete replacement
tracker.discard(); // accept current changes without publishing them
tracker.state = replacement; // replace the root; next flush is complete
```
`discard()` intentionally prevents current changes from reaching existing
replicas. Use it only when those replicas do not need the discarded changes.
`apply()` adopts object and array payloads from its input batch. Do not freeze a
batch before applying it, and do not apply one in-memory batch to multiple
mutable replicas unless each replica owns that batch. A serialized and decoded
batch is already detached. `applyImmutable()` instead treats its previous value
and operation payloads as immutable, so one batch can safely fan out in-process.
A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream.
Discard its decoder and replica, then recover from a later base batch. `apply()`
is not transactional; operations before the failing operation may already have
changed the replica.
## Limits
- Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
gap detection, retries, and persistence policy belong to the surrounding
protocol or storage format.
- Object identity is not replicated. Tracked mutable state must be a tree;
immutable inputs may share references, but replicas need not preserve them.
- Object key insertion order is not replicated. Do not compare or hash replicas
using serialized key order.
- Array operations that change indices may publish a wider array region, as
described under Arrays.
- Object-valued keys named `__proto__`, `constructor`, or `prototype` can be read
and serialized, but cannot be mutated through that key. Replace the nearest
ordinarily named parent instead.
- No freezing. Mutating any immutable value corrupts state silently.
- In-process sharing: local handles, cell subscribers, and `replicatedState` subscribers receive the provider's containers. A consumer mutation corrupts authority and makes remote replicas diverge.
- A change inside a large flat array copies that array's pointer storage for the new revision.
- Placement validation costs time and memory on bulk inserts. In a benchmark unshifting 100k three-field objects, it added about 19 ms and 20 MiB of transient heap.
- Draft reads go through proxies. In one measurement, 20,000 hot nested reads took about 9 ms through the tracker versus 0.08 ms on raw objects; compare with `node --expose-gc packages/chord/test/delta-traversal.bench.ts --quick`.
- External proxies as placements, argument coercion callbacks that mutate the draft, non-primitive array indices, and non-numeric comparator results are out of contract. So are sort comparators that mutate, prepare, abort, or adopt. Behavior is unspecified.
- Object identity is not replicated. Each path is an independent value placement.
- One tracker is one revision sequence. Delivery order and persistence belong to the surrounding protocol.

View File

@@ -0,0 +1,128 @@
import type { JsonValue } from "../json.ts";
import type { Op, Path, Seg } from "./index.ts";
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
type CopiedPath = { root: JsonValue; target: JsonContainer };
const DATA_DESCRIPTOR: PropertyDescriptor = {
value: undefined,
writable: true,
enumerable: true,
configurable: true,
};
/** Apply self-produced trusted operations while copying each touched container once. */
export function applyImmutableTrusted<T>(target: T, operations: readonly Op[]): T {
let root = target as unknown as JsonValue;
const owned = new WeakSet<object>();
for (const operation of operations) {
if (operation[0] === "r") {
root = operation[1];
continue;
}
if (operation[0] === "p" || operation[0] === "m") {
const copied = copyPath(root, operation[1], operation[1].length, owned);
root = copied.root;
if (!Array.isArray(copied.target)) throw new TypeError("Trusted array operation target is not an array");
if (operation[0] === "p") spliceTrusted(copied.target, operation[2], operation[3], operation[4]);
else permuteTrusted(copied.target, operation[2]);
continue;
}
const path = operation[1];
const copied = copyPath(root, path, path.length - 1, owned);
root = copied.root;
const key = path[path.length - 1]!;
switch (operation[0]) {
case "s":
defineData(copied.target, key, operation[2]);
break;
case "d":
if (Array.isArray(copied.target)) {
if (typeof key !== "number") throw new TypeError("Trusted array deletion key is not numeric");
spliceTrusted(copied.target, key, 1, []);
} else Reflect.deleteProperty(copied.target, key);
break;
case "a": {
const current = read(copied.target, key);
if (typeof current !== "string") throw new TypeError("Trusted append target is not a string");
defineData(copied.target, key, `${current}${operation[2]}`);
break;
}
case "t": {
const current = read(copied.target, key);
if (typeof current !== "string") throw new TypeError("Trusted truncate target is not a string");
defineData(copied.target, key, current.slice(operation[2]));
break;
}
}
}
return root as unknown as T;
}
function copyPath(root: JsonValue, path: Path, length: number, owned: WeakSet<object>): CopiedPath {
if (!isContainer(root)) throw new TypeError("Trusted operation root is not a container");
let copiedRoot = root;
if (!owned.has(root)) {
copiedRoot = shallowCopy(root);
owned.add(copiedRoot);
}
let destination = copiedRoot as JsonContainer;
for (let index = 0; index < length; index++) {
const segment = path[index]!;
const child = read(destination, segment);
if (!isContainer(child)) throw new TypeError("Trusted operation path is not a container");
if (owned.has(child)) {
destination = child;
continue;
}
const copy = shallowCopy(child);
defineData(destination, segment, copy);
owned.add(copy);
destination = copy;
}
return { root: copiedRoot, target: destination };
}
function shallowCopy(value: JsonContainer): JsonContainer {
if (Array.isArray(value)) return Array.from(value);
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const key of Object.keys(value)) defineData(result, key, value[key]!);
return result;
}
function read(target: JsonContainer, key: Seg): JsonValue {
return (target as Record<Seg, JsonValue>)[key]!;
}
function defineData(target: JsonContainer, key: Seg, value: JsonValue): void {
DATA_DESCRIPTOR.value = value;
Object.defineProperty(target, key, DATA_DESCRIPTOR);
DATA_DESCRIPTOR.value = undefined;
}
function spliceTrusted(target: JsonValue[], start: number, remove: number, items: readonly JsonValue[]): void {
const oldLength = target.length;
const delta = items.length - remove;
if (delta > 0) {
target.length = oldLength + delta;
target.copyWithin(start + items.length, start + remove, oldLength);
} else if (delta < 0) {
target.copyWithin(start + items.length, start + remove, oldLength);
target.length = oldLength + delta;
}
for (let index = 0; index < items.length; index++) defineData(target, start + index, items[index]!);
}
function permuteTrusted(target: JsonValue[], permutation: readonly number[]): void {
if (target.length !== permutation.length) throw new TypeError("Trusted permutation length mismatch");
const previous = target.slice();
for (let index = 0; index < permutation.length; index++) target[index] = previous[permutation[index]!]!;
}
function isContainer(value: unknown): value is JsonContainer {
return value !== null && typeof value === "object";
}

View File

@@ -0,0 +1,523 @@
import type { JsonValue } from "../json.ts";
import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from "./index.ts";
const DEFAULT_OVERLAP_SCAN = 65_536;
const MAX_DELTA_OPERATIONS = 4_096;
const overflowedBatches = new WeakSet<Op[]>();
const emitOperation = (operations: Op[], operation: Op): void => {
if (overflowedBatches.has(operations)) return;
if (operations.length >= MAX_DELTA_OPERATIONS) {
overflowedBatches.add(operations);
return;
}
operations.push(operation);
};
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) emitOperation(operations, ["r", value]);
else emitOperation(operations, ["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) {
emitOperation(operations, ["a", path, after.slice(before.length)]);
return;
}
const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN);
if (shared === 0) {
emitOperation(operations, ["s", path, after]);
return;
}
emitOperation(operations, ["t", path, before.length - shared]);
if (after.length > shared) emitOperation(operations, ["a", path, after.slice(shared)]);
};
type ArrayMatch = readonly [before: number, after: number];
type MatchCandidate = {
before: number;
after: number;
previous: number;
};
const MAX_IDENTITY_CANDIDATES = 200_000;
const MAX_SEMANTIC_CELLS = 65_536;
const sameValue = (left: JsonValue, right: JsonValue): boolean => left === right || equalJson(left, right);
const lcsMatches = (
before: readonly JsonValue[],
after: readonly JsonValue[],
equal: (left: JsonValue, right: JsonValue) => boolean,
maxCells: number,
): ArrayMatch[] | undefined => {
if (before.length === 0 || after.length === 0) return [];
if (before.length * after.length > maxCells) return undefined;
const width = after.length + 1;
const lengths = new Uint32Array((before.length + 1) * width);
for (let left = before.length - 1; left >= 0; left--) {
for (let right = after.length - 1; right >= 0; right--) {
const at = left * width + right;
lengths[at] = equal(before[left]!, after[right]!)
? lengths[(left + 1) * width + right + 1]! + 1
: Math.max(lengths[(left + 1) * width + right]!, lengths[left * width + right + 1]!);
}
}
const matches: ArrayMatch[] = [];
let left = 0;
let right = 0;
while (left < before.length && right < after.length) {
if (
equal(before[left]!, after[right]!) &&
lengths[left * width + right] === lengths[(left + 1) * width + right + 1]! + 1
) {
matches.push([left++, right++]);
} else if (lengths[(left + 1) * width + right]! >= lengths[left * width + right + 1]!) left += 1;
else right += 1;
}
return matches;
};
const semanticallyAligned = (left: JsonValue, right: JsonValue): boolean => {
if (sameValue(left, right)) return true;
if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) return false;
return left.some((value, index) => isContainer(value) && value === right[index]);
}
const leftObject = left as Record<string, JsonValue>;
const rightObject = right as Record<string, JsonValue>;
for (const key of Object.keys(leftObject)) {
const value = leftObject[key];
if (isContainer(value) && Object.hasOwn(rightObject, key) && value === rightObject[key]) return true;
}
return false;
};
const lowerBound = (values: readonly number[], value: number): number => {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (values[middle]! < value) low = middle + 1;
else high = middle;
}
return low;
};
const identitySubsequence = (
before: readonly JsonValue[],
beforeStart: number,
beforeEnd: number,
after: readonly JsonValue[],
afterStart: number,
afterEnd: number,
): ArrayMatch[] | undefined => {
const beforeCount = beforeEnd - beforeStart;
const afterCount = afterEnd - afterStart;
const matches: ArrayMatch[] = [];
if (afterCount < beforeCount) {
let beforeIndex = beforeStart;
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
while (beforeIndex < beforeEnd && before[beforeIndex] !== after[afterIndex]) beforeIndex += 1;
if (beforeIndex === beforeEnd) return undefined;
matches.push([beforeIndex++, afterIndex]);
}
return matches;
}
if (beforeCount < afterCount) {
let afterIndex = afterStart;
for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex++) {
while (afterIndex < afterEnd && before[beforeIndex] !== after[afterIndex]) afterIndex += 1;
if (afterIndex === afterEnd) return undefined;
matches.push([beforeIndex, afterIndex++]);
}
return matches;
}
return undefined;
};
const greedyIdentityAnchors = (
positions: ReadonlyMap<JsonValue, readonly number[]>,
after: readonly JsonValue[],
afterStart: number,
afterEnd: number,
): ArrayMatch[] => {
const matches: ArrayMatch[] = [];
let previous = -1;
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
const candidates = positions.get(after[afterIndex]!);
if (candidates === undefined) continue;
const at = lowerBound(candidates, previous + 1);
const beforeIndex = candidates[at];
if (beforeIndex === undefined) continue;
matches.push([beforeIndex, afterIndex]);
previous = beforeIndex;
}
return matches;
};
const identityAnchors = (
before: readonly JsonValue[],
beforeStart: number,
beforeEnd: number,
after: readonly JsonValue[],
afterStart: number,
afterEnd: number,
): ArrayMatch[] => {
const positions = new Map<JsonValue, number[]>();
for (let index = beforeStart; index < beforeEnd; index++) {
const value = before[index]!;
const existing = positions.get(value);
if (existing === undefined) positions.set(value, [index]);
else existing.push(index);
}
let candidateCount = 0;
for (let index = afterStart; index < afterEnd; index++) {
candidateCount += positions.get(after[index]!)?.length ?? 0;
if (candidateCount > MAX_IDENTITY_CANDIDATES) {
return greedyIdentityAnchors(positions, after, afterStart, afterEnd);
}
}
if (candidateCount === 0) return [];
const candidates: MatchCandidate[] = [];
const tails: number[] = [];
const tailValues: number[] = [];
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
const beforePositions = positions.get(after[afterIndex]!);
if (beforePositions === undefined) continue;
for (let index = beforePositions.length - 1; index >= 0; index--) {
const beforeIndex = beforePositions[index]!;
const at = lowerBound(tailValues, beforeIndex);
const candidateIndex = candidates.length;
candidates.push({ before: beforeIndex, after: afterIndex, previous: at === 0 ? -1 : tails[at - 1]! });
tails[at] = candidateIndex;
tailValues[at] = beforeIndex;
}
}
const matches: ArrayMatch[] = [];
let candidateIndex = tails[tails.length - 1] ?? -1;
while (candidateIndex >= 0) {
const candidate = candidates[candidateIndex]!;
matches.push([candidate.before, candidate.after]);
candidateIndex = candidate.previous;
}
matches.reverse();
return matches;
};
const processArrayMatches = (
before: JsonValue[],
after: JsonValue[],
path: Path,
operations: Op[],
beforeStart: number,
beforeEnd: number,
afterStart: number,
afterEnd: number,
outputStart: number,
matches: readonly ArrayMatch[],
): void => {
let beforeAt = beforeStart;
let afterAt = afterStart;
let outputAt = outputStart;
for (const [beforeMatch, afterMatch] of matches) {
if (overflowedBatches.has(operations)) return;
diffArrayRegion(before, after, path, operations, beforeAt, beforeMatch, afterAt, afterMatch, outputAt);
outputAt += afterMatch - afterAt;
if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) {
diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations);
}
outputAt += 1;
beforeAt = beforeMatch + 1;
afterAt = afterMatch + 1;
}
if (!overflowedBatches.has(operations)) {
diffArrayRegion(before, after, path, operations, beforeAt, beforeEnd, afterAt, afterEnd, outputAt);
}
};
function diffArrayRegion(
before: JsonValue[],
after: JsonValue[],
path: Path,
operations: Op[],
beforeStart: number,
beforeEnd: number,
afterStart: number,
afterEnd: number,
outputStart: number,
): void {
if (overflowedBatches.has(operations)) return;
while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeStart]!, after[afterStart]!)) {
beforeStart += 1;
afterStart += 1;
outputStart += 1;
}
while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeEnd - 1]!, after[afterEnd - 1]!)) {
beforeEnd -= 1;
afterEnd -= 1;
}
const beforeCount = beforeEnd - beforeStart;
const afterCount = afterEnd - afterStart;
if (beforeCount === 0 && afterCount === 0) return;
if (beforeCount === 0 || afterCount === 0) {
emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]);
return;
}
if (beforeCount === afterCount) {
const positional: ArrayMatch[] = [];
for (let offset = 0; offset < beforeCount; offset++) {
if (sameValue(before[beforeStart + offset]!, after[afterStart + offset]!)) {
positional.push([beforeStart + offset, afterStart + offset]);
}
}
if (positional.length > 0) {
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
positional,
);
return;
}
}
const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd);
if (subsequence !== undefined && subsequence.length > 0) {
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
subsequence,
);
return;
}
const identity = identityAnchors(before, beforeStart, beforeEnd, after, afterStart, afterEnd);
if (identity.length > 0) {
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
identity,
);
return;
}
const semantic = lcsMatches(
before.slice(beforeStart, beforeEnd),
after.slice(afterStart, afterEnd),
semanticallyAligned,
MAX_SEMANTIC_CELLS,
);
if (semantic !== undefined && semantic.length > 0) {
const absolute = semantic.map(
([beforeIndex, afterIndex]) => [beforeStart + beforeIndex, afterStart + afterIndex] as const,
);
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
absolute,
);
return;
}
if (beforeCount === 1 && afterCount === 1) {
diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations);
return;
}
emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]);
}
const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[]): void => {
if (before === after || equalJson(before, after)) return;
if (
before.length === after.length &&
before.length > 1 &&
!sameValue(before[0]!, after[0]!) &&
!sameValue(before[before.length - 1]!, after[after.length - 1]!)
) {
const order = permutation(before, after);
if (order !== undefined) {
emitOperation(operations, ["m", path, order]);
return;
}
}
diffArrayRegion(before, after, path, operations, 0, before.length, 0, after.length, 0);
};
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 (overflowedBatches.has(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 (overflowedBatches.has(operations)) return;
if (!Object.hasOwn(after, key)) emitOperation(operations, ["d", [...path, key] as unknown as NonEmptyPath]);
}
};
const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[]): void => {
if (before === after || overflowedBatches.has(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 (overflowedBatches.has(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;
}

View File

@@ -0,0 +1,10 @@
/** 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;

View File

@@ -1,9 +1,11 @@
import type { JsonValue } from "../types.ts";
import type { JsonValue } from "../json.ts";
export type { JsonValue } from "../types.ts";
export type { JsonValue } from "../json.ts";
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
// ─────────────────────────────────────────────────────────────────────────────
// chord/delta — flush-time change tracking over plain JSON.
// chord/delta — immutable revision tracking and operations over plain JSON.
//
// Depends on nothing else in the harness. Session storage, the runtime and the
// facet host consume it; keep the arrows pointing that way.
@@ -20,9 +22,9 @@ 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, and only because a tracked value can
* itself be an array — but a `p` that replaces its entire target is normalised to
* `r`/`s` at flush time, so a root `p` is always a partial modification.
* 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
* omitted paths live in `WireOp` and exist only between `encode` and `decode`.
@@ -33,7 +35,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 +61,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 ──────────────────────────────────────────────────────────
@@ -103,650 +109,11 @@ export function overlap(a: string, b: string, scan: number, probe = 64, maxCandi
return 0;
}
// ─── Tracker ─────────────────────────────────────────────────────────────────
// ─── Immutable revision tracking ─────────────────────────────────────────────
export interface TrackerOptions {
maxOverlapScan?: number;
}
export interface Tracker<T extends object> {
/**
* The tracked value. Mutate and read state only through this proxy. Values
* inserted into it are adopted: callers may retain read-only references, but
* must not mutate them outside this proxy.
*/
state: T;
/** The untracked current value. Mutating it bypasses change tracking. */
readonly target: T;
flush(): Op[];
/** Make the next flush a complete base batch without changing the value. */
rebase(): void;
/** Accept pending mutations locally without emitting them. */
discard(): void;
readonly dirty: boolean;
}
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
const cloneJson = <T extends JsonValue>(value: T): T => {
if (!isObj(value)) return value;
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const [key, child] of Object.entries(value)) {
Object.defineProperty(result, key, {
value: cloneJson(child),
writable: true,
enumerable: true,
configurable: true,
});
}
return result as T;
};
const INDEX = /^(?:0|[1-9]\d*)$/;
const norm = (target: object, key: string | symbol): Seg | symbol =>
typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key;
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
const MISSING = Symbol("missing");
type MaybeJson = JsonValue | typeof MISSING;
type ArrayDirty = { kind: "append"; start: number } | { kind: "diff" } | { kind: "replace" };
type DirtyNode = { valueDirty?: true; array?: ArrayDirty; children: Map<Seg, DirtyNode> };
const dirtyNode = (): DirtyNode => ({ children: new Map() });
const spliceItems = (target: unknown[], index: number, remove: number, items: JsonValue[]): JsonValue[] => {
const removed = Reflect.apply(Array.prototype.splice, target, [index, remove]) as JsonValue[];
const chunkSize = 10_000;
for (let offset = 0; offset < items.length; offset += chunkSize) {
Reflect.apply(Array.prototype.splice, target, [index + offset, 0, ...items.slice(offset, offset + chunkSize)]);
}
return removed;
};
const jsonEqual = (left: JsonValue, right: JsonValue): boolean => {
if (left === right) return true;
if (!isObj(left) || !isObj(right) || Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index++) {
if (!jsonEqual(left[index]!, right[index]!)) return false;
}
return true;
}
const leftObject = left as Record<string, JsonValue>;
const rightObject = right as Record<string, JsonValue>;
const leftKeys = Object.keys(leftObject);
const rightKeys = Object.keys(rightObject);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!Object.hasOwn(rightObject, key) || !jsonEqual(leftObject[key]!, rightObject[key]!)) return false;
}
return true;
};
const ownValue = (value: JsonValue, segment: Seg): MaybeJson => {
if (!isObj(value) || !Object.hasOwn(value, segment)) return MISSING;
return (value as Record<Seg, JsonValue>)[segment]!;
};
const emitSet = (path: Path, value: JsonValue, out: Op[]): void => {
const snapshot = cloneJson(value);
if (path.length === 0) out.push(["r", snapshot]);
else out.push(["s", [...path] as unknown as NonEmptyPath, snapshot]);
};
const emitDelete = (path: Path, out: Op[]): void => {
if (path.length === 0) throw new TypeError("the tracked root cannot be deleted");
out.push(["d", [...path] as unknown as NonEmptyPath]);
};
const diffString = (before: string, after: string, path: Path, scan: number, out: Op[]): void => {
if (before === after) return;
if (path.length === 0) {
emitSet(path, after, out);
return;
}
const at = [...path] as unknown as NonEmptyPath;
// NOT `after.startsWith(before)`. `after` is usually a cons string — the
// producer just did `s += chunk` — and V8's startsWith walks a cons char by
// char. `slice(...) === before` flattens once and compares with memcmp.
// Measured on a 200 KB string growing by 8 bytes per flush: 845 us -> 42 us.
if (after.length > before.length && after.slice(0, before.length) === before) {
out.push(["a", at, after.slice(before.length)]);
return;
}
const shared = overlap(before, after, scan);
if (shared === 0) {
out.push(["s", at, after]);
return;
}
out.push(["t", at, before.length - shared]);
if (after.length > shared) out.push(["a", at, after.slice(shared)]);
};
const diffValue = (before: MaybeJson, after: MaybeJson, path: Path, scan: number, out: Op[]): void => {
if (before === MISSING) {
if (after !== MISSING) emitSet(path, after, out);
return;
}
if (after === MISSING) {
emitDelete(path, out);
return;
}
if (before === after) return;
if (typeof before === "string" && typeof after === "string") {
diffString(before, after, path, scan, out);
return;
}
if (Array.isArray(before) && Array.isArray(after)) {
diffArray(before, after, path, scan, out);
return;
}
if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) {
diffObject(before as Record<string, JsonValue>, after as Record<string, JsonValue>, path, scan, out);
return;
}
emitSet(path, after, out);
};
function diffObject(
before: Record<string, JsonValue>,
after: Record<string, JsonValue>,
path: Path,
scan: number,
out: Op[],
): void {
if ([...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) {
emitSet(path, after, out);
return;
}
for (const key of Object.keys(after)) {
diffValue(Object.hasOwn(before, key) ? before[key]! : MISSING, after[key]!, [...path, key], scan, out);
}
for (const key of Object.keys(before)) {
if (!Object.hasOwn(after, key)) emitDelete([...path, key], out);
}
}
function diffArray(before: JsonValue[], after: JsonValue[], path: Path, scan: number, out: Op[]): void {
if (before.length === after.length) {
for (let index = 0; index < after.length; index++) {
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
}
return;
}
let prefix = 0;
while (prefix < before.length && prefix < after.length && jsonEqual(before[prefix]!, after[prefix]!)) prefix++;
let suffix = 0;
while (
suffix < before.length - prefix &&
suffix < after.length - prefix &&
jsonEqual(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!)
) {
suffix++;
}
const shorter = Math.min(before.length, after.length);
if (prefix + suffix === shorter) {
const remove = before.length - prefix - suffix;
const items = after.slice(prefix, after.length - suffix);
if (prefix === 0 && remove === before.length) emitSet(path, after, out);
else out.push(["p", [...path], prefix, remove, cloneJson(items)]);
return;
}
// Structural movement combined with retained-index edits has no unique
// alignment. Preserve the retained index deltas and express only the tail
// length change structurally. It may be broader than the producer's intent,
// but never degrades those edits to a whole-array replacement.
for (let index = 0; index < shorter; index++) {
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
}
if (after.length > before.length) {
out.push(["p", [...path], before.length, 0, cloneJson(after.slice(before.length))]);
} else if (before.length > after.length) {
if (after.length === 0) emitSet(path, after, out);
else out.push(["p", [...path], after.length, before.length - after.length, []]);
}
}
const walkDirty = (before: JsonValue, after: JsonValue, node: DirtyNode, path: Path, scan: number, out: Op[]): void => {
if (node.valueDirty) {
diffValue(before, after, path, scan, out);
return;
}
if (node.array !== undefined) {
if (node.array.kind === "replace") {
if (!jsonEqual(before, after)) emitSet(path, after, out);
return;
}
if (!Array.isArray(before) || !Array.isArray(after) || node.array.kind === "diff") {
diffValue(before, after, path, scan, out);
return;
}
const start = node.array.start;
if (before.length !== start || after.length < start) {
diffValue(before, after, path, scan, out);
return;
}
for (const [segment, child] of node.children) {
if (typeof segment !== "number" || segment >= start) continue;
const previous = ownValue(before, segment);
const current = ownValue(after, segment);
if (previous === MISSING || current === MISSING || child.valueDirty) {
diffValue(previous, current, [...path, segment], scan, out);
} else if (isObj(previous) && isObj(current)) {
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
} else {
diffValue(previous, current, [...path, segment], scan, out);
}
}
const items = after.slice(start);
if (items.length > 0) out.push(["p", [...path], start, 0, cloneJson(items)]);
return;
}
for (const [segment, child] of node.children) {
const previous = ownValue(before, segment);
const current = ownValue(after, segment);
if (previous === MISSING || current === MISSING || child.valueDirty) {
diffValue(previous, current, [...path, segment], scan, out);
continue;
}
if (!isObj(previous) || !isObj(current)) {
diffValue(previous, current, [...path, segment], scan, out);
continue;
}
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
}
};
/**
* Bring `baseline` up to `root` along the dirty paths by sharing references.
* Returns false — having changed nothing — if any dirty node is an array
* change other than a pure append, so the caller can replay ops instead.
* Cloning a whole array there is O(n) per flush; replay is O(changes).
*
* Strings are immutable, so root's `after` is shared outright, and sharing it
* also means the next flush compares against a flat string rather than a cons.
* Objects are cloned because root keeps mutating them.
*/
const syncBaseline = (baseline: JsonValue, root: JsonValue, node: DirtyNode): boolean => {
if (!canSync(node)) return false;
syncInto(baseline, root, node);
return true;
};
const canSync = (node: DirtyNode): boolean => {
if (node.array !== undefined && node.array.kind !== "append") return false;
for (const child of node.children.values()) if (!canSync(child)) return false;
return true;
};
const syncInto = (baseline: JsonValue, root: JsonValue, node: DirtyNode): void => {
const parent = baseline as Record<string | number, JsonValue>;
if (node.array?.kind === "append" && Array.isArray(baseline) && Array.isArray(root)) {
const start = node.array.start;
for (const [index, child] of node.children) {
if (typeof index === "number" && index < start) syncChild(parent, root, index, child);
}
for (let i = start; i < root.length; i++) {
baseline.push(isObj(root[i]) ? cloneJson(root[i] as JsonValue) : (root[i] as JsonValue));
}
return;
}
for (const [segment, child] of node.children) syncChild(parent, root, segment, child);
};
const syncChild = (
parent: Record<string | number, JsonValue>,
root: JsonValue,
segment: Seg,
child: DirtyNode,
): void => {
const current = ownValue(root, segment);
const previous = ownValue(parent as JsonValue, segment);
if (current === MISSING) {
if (Array.isArray(parent)) parent.splice(segment as number, 1);
else delete parent[segment];
return;
}
if (child.valueDirty || !isObj(current) || !isObj(previous) || Array.isArray(current) !== Array.isArray(previous)) {
parent[segment] = isObj(current) ? cloneJson(current as JsonValue) : (current as JsonValue);
return;
}
syncInto(previous as JsonValue, current as JsonValue, child);
};
const cloneOp = (op: Op): Op => {
switch (op[0]) {
case "r":
return ["r", cloneJson(op[1])];
case "s":
return ["s", op[1], cloneJson(op[2])];
case "p":
return ["p", op[1], op[2], op[3], cloneJson(op[4])];
default:
return op;
}
};
export function track<T extends object>(root: T, options: TrackerOptions = {}): Tracker<T> {
const scan = options.maxOverlapScan ?? 65_536;
let pending = dirtyNode();
let hasPending = false;
let baseline: JsonValue | undefined;
let forceBase = true;
const clearPending = (): void => {
pending = dirtyNode();
hasPending = false;
};
const ensureNode = (path: Path): DirtyNode | undefined => {
hasPending = true;
let node = pending;
for (const segment of path) {
if (node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") return undefined;
let child = node.children.get(segment);
if (child === undefined) child = dirtyNode();
else node.children.delete(segment);
node.children.set(segment, child);
node = child;
}
return node;
};
const findNode = (path: Path): DirtyNode | undefined => {
let node = pending;
for (const segment of path) {
const child = node.children.get(segment);
if (child === undefined) return undefined;
node = child;
}
return node;
};
const markValue = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined) return;
node.valueDirty = true;
node.array = undefined;
node.children.clear();
};
const markArrayAppend = (path: Path, start: number): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") {
return;
}
if (node.array === undefined) node.array = { kind: "append", start };
};
const markArrayDiff = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "replace") return;
node.array = { kind: "diff" };
node.children.clear();
};
const markArrayReplace = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty) return;
node.array = { kind: "replace" };
node.children.clear();
};
const appendStart = (path: Path): number | undefined => {
const array = findNode(path)?.array;
return array?.kind === "append" ? array.start : undefined;
};
const guard = (segment: Seg | symbol): Seg => {
if (typeof segment === "symbol") throw new UnsafePathError(String(segment));
if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) throw new UnsafePathError(segment);
return segment;
};
const adoptItems = (values: readonly unknown[]): JsonValue[] => values as JsonValue[];
const integer = (value: unknown): number => {
const number = Number(value);
if (Number.isNaN(number) || number === 0) return 0;
return Number.isFinite(number) ? Math.trunc(number) : number;
};
const spliceRange = (length: number, args: readonly unknown[]): { index: number; remove: number } => {
const rawStart = args.length === 0 ? 0 : integer(args[0]);
const index = rawStart < 0 ? Math.max(0, length + rawStart) : Math.min(rawStart, length);
const remove =
args.length === 0
? 0
: args.length === 1
? length - index
: Math.max(0, Math.min(integer(args[1]), length - index));
return { index, remove };
};
const wrap = <V extends object>(object: V, path: Path, blockedSegment?: Seg): V => {
const childProxies = new Map<string | symbol, { target: object; proxy: object }>();
const proxy = new Proxy(object, {
get(target, key, receiver) {
if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) {
return (...args: unknown[]) => {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
const before = target.length;
let result: unknown;
switch (key) {
case "push": {
const items = adoptItems(args);
if (items.length > 0) markArrayAppend(path, before);
spliceItems(target, before, 0, items);
result = target.length;
break;
}
case "unshift": {
const items = adoptItems(args);
if (items.length > 0) markArrayDiff(path);
spliceItems(target, 0, 0, items);
result = target.length;
break;
}
case "pop":
if (before > 0) {
const start = appendStart(path);
if (start === undefined || before - 1 < start) markArrayDiff(path);
}
result = Reflect.apply(Array.prototype.pop, target, args);
break;
case "shift":
if (before > 0) markArrayDiff(path);
result = Reflect.apply(Array.prototype.shift, target, args);
break;
case "splice": {
const items = adoptItems(args.slice(2));
const { index, remove } = spliceRange(before, args);
if (remove > 0 || items.length > 0) {
const start = appendStart(path);
if (index === 0 && remove === before) markArrayReplace(path);
else if (start !== undefined && index >= start) {
// The final append payload includes all tail edits.
} else if (index === before && remove === 0) markArrayAppend(path, before);
else markArrayDiff(path);
}
result = spliceItems(target, index, remove, items);
break;
}
default:
markArrayDiff(path);
result = Reflect.apply(Array.prototype[key as "sort"], target, args);
}
if (key === "pop") childProxies.delete(String(before - 1));
else if (key !== "push") childProxies.clear();
return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? proxy : result;
};
}
const value = Reflect.get(target, key, receiver);
if (!isObj(value)) return value;
const cached = childProxies.get(key);
if (cached?.target === value) return cached.proxy;
const rawSegment = norm(target, key);
let segment: Seg;
let childBlocked = blockedSegment;
if (blockedSegment !== undefined) {
if (typeof rawSegment === "symbol") throw new UnsafePathError(String(rawSegment));
segment = rawSegment;
} else if (
typeof rawSegment === "string" &&
RESERVED_SEGMENTS.has(rawSegment) &&
Object.hasOwn(target, key)
) {
segment = rawSegment;
childBlocked = rawSegment;
} else segment = guard(rawSegment);
const child = wrap(value, [...path, segment], childBlocked);
childProxies.set(key, { target: value, proxy: child });
return child;
},
set(target, key, value) {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
if (Array.isArray(target) && key === "length") {
const before = target.length;
const next = Number(value);
if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) {
return Reflect.set(target, key, value);
}
if (next < before) {
const start = appendStart(path);
if (next === 0) markArrayReplace(path);
else if (start === undefined || next < start) markArrayDiff(path);
Reflect.set(target, key, next);
childProxies.clear();
} else if (next > before) {
markArrayAppend(path, before);
target.length = next;
target.fill(null, before);
}
return true;
}
const segment = guard(norm(target, key));
if (Array.isArray(target)) {
if (typeof segment !== "number") throw new UnsafePathError(segment);
if (segment > target.length) throw new UnsafePathError(segment);
}
const at = [...path, segment] as unknown as NonEmptyPath;
if (value === undefined) {
if (Array.isArray(target)) {
throw new TypeError("undefined would create a sparse array; use splice instead");
}
markValue(at);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
}
const previous = (target as Record<string | symbol, unknown>)[key];
if (previous === value) return true;
const cached = childProxies.get(key);
if (cached !== undefined && cached.target === previous && cached.proxy === value) return true;
if (Array.isArray(target)) {
const index = segment as number;
if (index === target.length) markArrayAppend(path, target.length);
else {
const start = appendStart(path);
if (start === undefined || index < start) markValue(at);
}
} else markValue(at);
childProxies.delete(key);
return Reflect.set(target, key, value);
},
deleteProperty(target, key) {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
const segment = guard(norm(target, key));
if (Array.isArray(target)) {
if (typeof segment !== "number") throw new UnsafePathError(segment);
throw new TypeError("delete would create a sparse array; use splice instead");
}
markValue([...path, segment]);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
},
defineProperty() {
throw new TypeError("defineProperty is not supported on tracked state; use assignment");
},
setPrototypeOf() {
throw new TypeError("setPrototypeOf is not supported on tracked state");
},
preventExtensions() {
throw new TypeError("preventExtensions is not supported on tracked state");
},
});
return proxy as V;
};
let state = wrap(root, []);
return {
get state() {
return state;
},
get target() {
return root;
},
set state(next: T) {
if (next === state) {
clearPending();
forceBase = true;
return;
}
clearPending();
root = next;
state = wrap(root, []);
baseline = undefined;
forceBase = true;
},
rebase() {
clearPending();
forceBase = true;
},
get dirty() {
return forceBase || hasPending;
},
discard() {
baseline = cloneJson(root as unknown as JsonValue);
clearPending();
},
flush() {
if (forceBase) {
const value = cloneJson(root as unknown as JsonValue);
baseline = cloneJson(root as unknown as JsonValue);
forceBase = false;
clearPending();
return [["r", value]];
}
if (!hasPending || baseline === undefined) return [];
const out: Op[] = [];
walkDirty(baseline, root as unknown as JsonValue, pending, [], scan, out);
// Advance the baseline by SHARING references from root where that is
// cheap and exact — scalars, strings, and array appends. Replaying the
// ops rebuilds every touched string via slice + concat: two window-sized
// allocations per flush and a cons the next flush must flatten. For
// anything the sync cannot express cheaply (a non-append array change),
// it declines and the original replay runs unchanged.
if (!syncBaseline(baseline as JsonValue, root as unknown as JsonValue, pending)) {
if (out.length > 0) baseline = apply(baseline, out.map(cloneOp));
}
clearPending();
return out;
},
};
}
export { diffRevisions } from "./diff.ts";
export type { Draft } from "./draft.ts";
export { type Change, type Prepared, type Tracker, track } from "./tracker.ts";
// ─── Path safety ─────────────────────────────────────────────────────────────
@@ -812,6 +179,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])}`);
@@ -824,6 +196,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");
@@ -875,6 +258,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");
@@ -969,6 +357,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>;
@@ -1010,22 +405,74 @@ function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
return root as unknown as T;
}
/** Apply decoded operations without mutating the previous immutable value. */
/** Apply one decoded operation batch without mutating the previous immutable value. */
export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {
return applyImmutableBatches(target, [ops]);
}
/**
* Apply decoded operation batches as one final-result-only replay.
*
* Containers copied for an earlier batch may be mutated while applying a later
* batch, so this deliberately exposes no intermediate revisions.
*/
export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T {
let root = target as unknown as JsonValue;
for (const op of ops) {
if (op[0] === "r") {
const owned = new WeakSet<object>();
for (const ops of batches) {
for (const op of ops) {
assertValidOp(op);
root = op[1];
continue;
if (op[0] === "r") {
root = op[1];
continue;
}
if (op[0] === "p") {
const appended = appendCopy(root, op, owned);
if (appended !== undefined) {
root = appended;
continue;
}
}
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned);
root = applyOps(root, [op]);
}
root = copyContainers(root, op[0] === "p" ? op[1] : op[1].slice(0, -1));
root = applyOps(root, [op]);
}
return root as unknown as T;
}
function copyContainers(root: JsonValue, path: Path): JsonValue {
/**
* An append to an array not yet copied in this replay, built as one copy with the items (`concat`) instead of a copy that
* the insert then grows, which copies it again. `undefined` for any other splice and for any path that is not a plain
* walk over own properties, which takes the general path and so fails with its errors.
*/
function appendCopy(root: JsonValue, op: Extract<Op, { 0: "p" }>, owned: WeakSet<object>): JsonValue | undefined {
const [, path, index, deleteCount, items] = op;
if (deleteCount !== 0) return undefined;
let target: JsonValue = root;
for (const segment of path) {
const valid =
typeof segment === "string"
? !RESERVED_SEGMENTS.has(segment) && isObj(target) && !Array.isArray(target)
: Number.isInteger(segment) && segment >= 0 && Array.isArray(target);
if (!valid || !Object.hasOwn(target as object, segment)) return undefined;
target = (target as Record<Seg, JsonValue>)[segment]!;
}
if (!Array.isArray(target) || owned.has(target) || index !== target.length) return undefined;
const appended = target.concat(items);
owned.add(appended);
if (path.length === 0) return appended;
const parentPath = path.slice(0, -1);
const copiedRoot = copyContainers(root, parentPath, owned);
Object.defineProperty(resolve(copiedRoot, parentPath), path[path.length - 1]!, {
value: appended,
writable: true,
enumerable: true,
configurable: true,
});
return copiedRoot;
}
function copyContainers(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
if (Array.isArray(value)) return value.slice();
if (!isObj(value)) throw new PathError(path);
@@ -1043,13 +490,23 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
}
return result;
};
const copiedRoot = copy(root);
let source = root;
if (!isObj(root)) throw new PathError(path);
let copiedRoot: JsonValue[] | Record<string, JsonValue>;
if (owned.has(root)) copiedRoot = root as JsonValue[] | Record<string, JsonValue>;
else {
copiedRoot = copy(root);
owned.add(copiedRoot);
}
let destination = copiedRoot;
for (const segment of path) {
if (!isObj(source) || !Object.hasOwn(source, segment)) throw new PathError(path);
if (Array.isArray(source) && typeof segment !== "number") throw new UnsafePathError(segment);
const child = (source as Record<Seg, JsonValue>)[segment]!;
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
const child = (destination as Record<Seg, JsonValue>)[segment]!;
if (!isObj(child)) throw new PathError(path);
if (owned.has(child)) {
destination = child as JsonValue[] | Record<string, JsonValue>;
continue;
}
const copiedChild = copy(child);
Object.defineProperty(destination, segment, {
value: copiedChild,
@@ -1057,7 +514,7 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
enumerable: true,
configurable: true,
});
source = child;
owned.add(copiedChild);
destination = copiedChild;
}
return copiedRoot;
@@ -1087,6 +544,10 @@ function resolve(root: JsonValue, path: Path): JsonValue {
// Path interning and arity omission live between the tracker and a boundary;
// `Op` and `apply` know nothing about them.
//
// Consecutive batches of one encoder may be concatenated into one batch (links
// coalesce waiting state this way): only path ids span batches, and every batch
// starts with an explicit path.
//
// ONE PAIR PER INDEPENDENT STATE STREAM. Every decoder must observe exactly the
// batches encoded by its matching encoder, beginning with that state's base.
// Sharing a transport connection does not make separately hydrated states one
@@ -1150,6 +611,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;
}
@@ -1183,6 +647,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;
}
@@ -1192,7 +659,8 @@ export function encoder(): Encoder {
}
export interface Decoder {
decode(wire: readonly WireOp[]): Op[];
/** Ops as received: each is validated (`assertValidWireOp`) before use. */
decode(wire: readonly unknown[]): Op[];
}
export function decoder(): Decoder {
@@ -1238,7 +706,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]);
@@ -1259,6 +727,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;

View File

@@ -0,0 +1,75 @@
import type { JsonValue } from "../json.ts";
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
const isContainer = (value: JsonValue): value is JsonContainer => typeof value === "object" && value !== null;
/** Validates immutable replica revisions while skipping containers validated in earlier revisions. */
export class JsonRevisionValidator {
readonly #validated = new WeakSet<object>();
validate<T extends JsonValue>(value: T): T {
return this.#validate(value, new Set<object>(), new WeakSet<object>()) as T;
}
#validate(value: JsonValue, ancestors: Set<object>, finished: WeakSet<object>): JsonValue {
if (!isContainer(value) || this.#validated.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++) this.#validate(value[index]!, ancestors, finished);
} 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");
}
this.#validate(descriptor.value as JsonValue, ancestors, finished);
}
}
finished.add(value);
this.#validated.add(value);
return value;
} finally {
ancestors.delete(value);
}
}
}
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++) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (
descriptor === undefined ||
!descriptor.enumerable ||
!("value" in descriptor) ||
descriptor.value === undefined
) {
throw new TypeError(
"Replicated state arrays must contain enumerable indexed data properties with defined values",
);
}
}
}

File diff suppressed because it is too large Load Diff