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:
448
third_party/pi/packages/chord/src/delta/README.md
vendored
448
third_party/pi/packages/chord/src/delta/README.md
vendored
@@ -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.
|
||||
|
||||
128
third_party/pi/packages/chord/src/delta/apply-immutable-trusted.ts
vendored
Normal file
128
third_party/pi/packages/chord/src/delta/apply-immutable-trusted.ts
vendored
Normal 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";
|
||||
}
|
||||
523
third_party/pi/packages/chord/src/delta/diff.ts
vendored
Normal file
523
third_party/pi/packages/chord/src/delta/diff.ts
vendored
Normal 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;
|
||||
}
|
||||
10
third_party/pi/packages/chord/src/delta/draft.ts
vendored
Normal file
10
third_party/pi/packages/chord/src/delta/draft.ts
vendored
Normal 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;
|
||||
803
third_party/pi/packages/chord/src/delta/index.ts
vendored
803
third_party/pi/packages/chord/src/delta/index.ts
vendored
@@ -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;
|
||||
|
||||
75
third_party/pi/packages/chord/src/delta/revision-validator.ts
vendored
Normal file
75
third_party/pi/packages/chord/src/delta/revision-validator.ts
vendored
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
2239
third_party/pi/packages/chord/src/delta/tracker.ts
vendored
Normal file
2239
third_party/pi/packages/chord/src/delta/tracker.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user