diff --git a/packages/agent/src/harness/pico3/legacy-tracker.ts b/packages/agent/src/harness/pico3/legacy-tracker.ts index 8bd952d12..e97e9b7a4 100644 --- a/packages/agent/src/harness/pico3/legacy-tracker.ts +++ b/packages/agent/src/harness/pico3/legacy-tracker.ts @@ -19,7 +19,6 @@ export interface Tracker { class LegacyTracker implements Tracker { private readonly tracker: ChordTracker; private change: Change | undefined; - private wrappers = new WeakMap(); private forceBase = true; constructor(initial: T) { @@ -28,7 +27,7 @@ class LegacyTracker implements Tracker { get state(): T { this.change ??= this.tracker.beginChange(); - return this.wrap(this.change.state as T); + return this.change.state as T; } get target(): T { @@ -44,7 +43,6 @@ class LegacyTracker implements Tracker { if (this.change !== undefined) { const change = this.change; this.change = undefined; - this.wrappers = new WeakMap(); prepared = change.prepare(); // Pico3 adopts before storage. A storage failure faults the owning Session. this.tracker.adopt(prepared); @@ -59,30 +57,6 @@ class LegacyTracker implements Tracker { rebase(): void { this.forceBase = true; } - - private wrap(draft: V): V { - const cached = this.wrappers.get(draft); - if (cached !== undefined) return cached as V; - const tracker = this; - const proxy = new Proxy(draft, { - get(target, key, receiver) { - const value = Reflect.get(target, key, receiver); - if (typeof value === "function") { - return (...args: unknown[]) => { - const result = Reflect.apply(value, target, args); - return typeof result === "object" && result !== null ? tracker.wrap(result) : result; - }; - } - return typeof value === "object" && value !== null ? tracker.wrap(value) : value; - }, - set(target, key, value, receiver) { - if (value === undefined && !Array.isArray(target)) return Reflect.deleteProperty(target, key); - return Reflect.set(target, key, value, receiver); - }, - }); - this.wrappers.set(draft, proxy); - return proxy; - } } export function track(initial: T): Tracker { diff --git a/packages/agent/test/harness/pico3/legacy-tracker.test.ts b/packages/agent/test/harness/pico3/legacy-tracker.test.ts new file mode 100644 index 000000000..39b2ff5d0 --- /dev/null +++ b/packages/agent/test/harness/pico3/legacy-tracker.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { applyImmutable } from "@earendil-works/chord/delta"; +import { test } from "vitest"; +import { track } from "../../../src/harness/pico3/legacy-tracker.ts"; + +test("preserves draft-sourced descendant edits without exposing tracker metadata", () => { + const tracker = track({ + a: { child: { value: 1 } }, + b: null as { child: { value: number } } | null, + rows: [{ child: { value: 1 } }, { child: { value: 2 } }], + }); + let replica = applyImmutable(undefined, tracker.flush()); + + tracker.state.a.child.value = 2; + tracker.state.b = tracker.state.a; + tracker.state.rows[1] = tracker.state.rows[0]!; + tracker.state.rows[1]!.child.value = 3; + const operations = tracker.flush(); + replica = applyImmutable(replica, operations); + + assert.deepEqual(tracker.target, { + a: { child: { value: 2 } }, + b: { child: { value: 2 } }, + rows: [{ child: { value: 1 } }, { child: { value: 3 } }], + }); + assert.deepEqual(replica, tracker.target); + assert.notEqual(tracker.target.a, tracker.target.b); + assert.notEqual(tracker.target.a.child, tracker.target.b?.child); + assert.notEqual(tracker.target.rows[0], tracker.target.rows[1]); + assert.notEqual(tracker.target.rows[0]!.child, tracker.target.rows[1]!.child); +}); diff --git a/packages/agent/test/harness/pico3/spec-view-events.test.ts b/packages/agent/test/harness/pico3/spec-view-events.test.ts index 4a2ed4f43..7b54a8ed1 100644 --- a/packages/agent/test/harness/pico3/spec-view-events.test.ts +++ b/packages/agent/test/harness/pico3/spec-view-events.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { isDeepStrictEqual } from "node:util"; import type { Context } from "@earendil-works/chord"; +import { apply } from "@earendil-works/chord/delta"; import type { AssistantMessage, AssistantMessageEvent } from "@earendil-works/pi-ai"; import { onTestFinished, test } from "vitest"; import type { ConversationHandle } from "../../../src/harness/pico3/harness.ts"; @@ -175,23 +177,20 @@ test("a self-head commit rewrites one transcript entry with matching entry/head onTestFinished(() => env.close()); await env.root.write({ kind: "before" }, ctx); const watch = await specWatch(env.root); + const before = structuredClone(watch.view); let envelope: Envelope | undefined; watch.start((next) => { envelope = next; }); await env.root.reset("fresh", ctx); assert.ok(envelope); - const entryOps = envelope.ops.filter((op) => op[0] !== "r" && op[1][0] === "entries"); - assert.deepEqual( - entryOps - .map((op) => { - if (op[0] !== "s") throw new Error(`Unexpected transcript operation ${op[0]}`); - assert.equal(op[1][1], 0); - return op[1][2]; - }) - .sort(), - ["head", "id", "kind", "model"], - ); + const after = apply(before, envelope.ops); + const previousEntry = watch.view.entries[0] as unknown as Record; + const nextEntry = after.entries[0] as unknown as Record; + const changedKeys = [...new Set([...Object.keys(previousEntry), ...Object.keys(nextEntry)])] + .filter((key) => !isDeepStrictEqual(previousEntry[key], nextEntry[key])) + .sort(); + assert.deepEqual(changedKeys, ["head", "id", "kind", "model"]); assert.deepEqual( envelope.events.map((event) => event.type), ["head.moved", "entry.added"], diff --git a/packages/chord/README.md b/packages/chord/README.md index a782a3e51..153a71337 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -32,12 +32,12 @@ The design has a few connected pieces: stable facade while a provider disconnects or is replaced. - **Replicated state** exposes authoritative state to local and remote - connected consumers. Producers publish atomic copy-on-write transactions with + connected consumers. Producers publish atomic overlay transactions with `change(context, callback)`; consumers receive complete immutable values. Draft - proxies exist only during the callback and are revoked afterward. Chord compares - the previous and next immutable revisions to produce one decoded operation batch, - while each remote client/state stream owns independent path-codec state. Replicas - become unready on disconnect or replacement until they are rehydrated. + proxies exist only during the callback and become unusable afterward. Preparation + materializes a structurally shared immutable candidate and one exact decoded + operation batch, while each remote client/state stream owns independent path-codec + state. Replicas become unready on disconnect or replacement until rehydrated. - **Delta tracking** records and coalesces operations over tracked plain JSON. It preserves common string and array operations, supports durable base @@ -89,26 +89,41 @@ outer protocol. ## Tracking JSON deltas -Import the standalone delta primitive from `@earendil-works/chord/delta`: +Import the standalone transactional tracker from `@earendil-works/chord/delta`: ```ts -import { apply, track } from "@earendil-works/chord/delta"; +import { applyImmutable, track } from "@earendil-works/chord/delta"; -const changes = track({ output: "", count: 0 }); -changes.flush(); // opening base batch -changes.state.output += "done\n"; -changes.state.count += 1; +const tracker = track({ output: "", count: 0 }); +const change = tracker.beginChange(); +change.state.output += "done\n"; +change.state.count += 1; +const prepared = change.prepare(); -const ops = changes.flush(); -const replica = apply({ output: "", count: 0 }, ops); +tracker.adopt(prepared); +const replica = applyImmutable(prepared.base, prepared.ops); ``` -The first flush is always a complete base batch. Later flushes contain path-based -changes. `applyImmutable()` applies those batches while preserving prior replica -revisions. Replicated state instead uses transaction-scoped copy-on-write drafts: +`tracker.value` is always the latest adopted immutable revision. Preparation does +not change authority; adoption validates the preparation and swaps the root pointer. +Assigned containers are copied by value and unchanged subtrees may be shared between +revisions. + +The tracker uses trusted immutable ownership rather than defensive copying or +freezing. `track(initial)`, `prepareReplace(value)`, `replicatedState(initial)`, and +`replace(context, value)` take ownership of alias-free strict-JSON roots without +walking them. Callers must not mutate transferred or published data. Values placed +through drafts are already copied, so that walk also rejects non-strict JSON before +the draft changes. Published values are not frozen. In-process loopback consumers +may share their containers with the provider; mutating a consumed value violates +the contract and can corrupt authority. Clone or serialize at any mutable trust +boundary. + +Replicated state provides the same model through a callback: ```ts -const status = env.replicatedState({ output: "", count: 0 }); +const initial = { output: "", count: 0 }; +const status = env.replicatedState(initial); // transfers ownership of initial status.change(context, (draft) => { draft.output += "done\n"; draft.count += 1; @@ -116,16 +131,14 @@ status.change(context, (draft) => { ``` A successful `change()` publishes exactly one atomic revision. If its callback -throws, the original value and sequence remain unchanged. Draft handles are revoked -when the callback returns and assigned containers are copied by value. Unchanged -subtrees are shared between immutable revisions. Chord derives string append and -front-truncate operations, array splices and permutations, sets, and deletes from -the two revisions; a large delta falls back to a complete snapshot. Remote -connection plumbing encodes each batch independently for every client/state -pairing. `replace(context, value)` publishes a detached complete value directly. +throws, the original value and sequence remain unchanged. Draft handles become +unusable when the callback returns. Chord emits string append and front-truncate +operations, array splices and permutations, sets, and deletes; large edit sets may +fold into a complete replacement. Remote connection plumbing encodes each batch +independently for every client/state pairing. -The standalone [Delta guide](src/delta/README.md) documents the lower-level mutable -tracker, which remains available separately from replicated state. +The standalone [Delta guide](src/delta/README.md) defines the complete ownership, +lifecycle, operation, and replica contracts. ## Bundling and loading facets diff --git a/packages/chord/src/api.ts b/packages/chord/src/api.ts index d7961eed8..b566965c1 100644 --- a/packages/chord/src/api.ts +++ b/packages/chord/src/api.ts @@ -92,6 +92,10 @@ export function replicatedState( source: ReplicatedStateSource, options?: ReplicatedStateSourceOptions, ): AttachedReplicatedState; +/** + * Create authoritative state by taking immutable ownership of an alias-free strict-JSON root. + * The caller must not mutate `initial` after this call. + */ export function replicatedState(initial: T): MutableReplicatedState; export function replicatedState( initialOrSource: object | ReplicatedStateSource, diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md index ea7d53a0a..d8527dd2d 100644 --- a/packages/chord/src/delta/README.md +++ b/packages/chord/src/delta/README.md @@ -1,408 +1,177 @@ # 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, splicing an array, or permuting 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, 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 operations whose application transforms the previously -published value into the current value. It returns `[]` when no tracked mutation -is pending, but a mutation window that restores its starting value may still -produce a redundant batch. +## 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 use a separate transaction-scoped -copy-on-write draft and derive these operation batches from immutable revisions; -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()` inputs and result | No. The result shares containers with both inputs. | +| Values from replicated state (`value`, listener values, loopback consumers) | 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: +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 -["operation", "message", "content", 0, "text"] +import { apply } from "@earendil-works/chord/delta"; + +const detach = (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. ``` -### Decoded `Op` +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. -`track().flush()` returns these tuples, and `apply()` accepts them: +## 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 so `new[i] = old[permutation[i]]`. | - -Except for `r`, every decoded operation carries its complete path. `s`, `d`, -`a`, and `t` cannot address the root. `p` and `m` may address a root array. - -### Encoded `WireOp` - -A `PathRef` is either an inline path or a non-negative numeric path ID. -`WireOp` supports the following tuples: - -| Tuple | Meaning | -| --- | --- | -| `["r", value]` | Complete replacement; identical to decoded form. | -| `["#", id, path]` | Define a numeric path ID. | -| `["s", pathRef, value]` | Set with an inline or interned path. | -| `["s", value]` | Set using the previous path in this batch. | -| `["d", pathRef]` | Delete with an inline or interned path. | -| `["d"]` | Delete using the previous path. | -| `["a", pathRef, text]` | Append with an inline or interned path. | -| `["a", text]` | Append using the previous path. | -| `["t", pathRef, count]` | Front-truncate with an inline or interned path. | -| `["t", count]` | Front-truncate using the previous path. | -| `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. | -| `["p", index, remove, items]` | Splice using the previous path. | -| `["m", pathRef, permutation]` | Reorder with an inline or interned path. | -| `["m", permutation]` | Reorder using the previous path. | - -For example, adjacent decoded operations on one path: - -```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; -``` - -Operations are coalesced within a flush window when doing so is cheap and safe: - -```ts -tracker.state.status = "starting"; -tracker.state.status = "running"; -tracker.flush(); // one set to "running" -``` - -The operation sequence is not canonical. Equivalent changes may use different -verbs, and mutations that cancel can still produce a nonempty batch. Consumers -must depend on the resulting value, not the exact tuples or their minimality. - -Replacing an object or array is valid. Delta compares its properties and elements -with the outgoing value at assignment time: - -```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 -previous 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); -``` - -Adjacent `push()` calls are normally coalesced into one tail `p`. Intervening -operations may keep them separate to preserve ordering. Changes to older -elements remain separate, and changes to newly pushed elements may be folded -into the pushed values when no structural operation intervenes. - -Front or middle insertion and removal are recorded directly. Edits before and -after an index-changing operation remain ordered against the array generation -they addressed. Sorting, reversing, `fill()`, and `copyWithin()` emit a snapshot -of the affected array; repeated whole-array mutators can therefore produce a -redundant snapshot even when their combined result restores the prior value. - -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; an object -they place at several indices is published at each. - -### Large mutation windows - -#### Build once, assign once - -Every object or array assignment is diffed immediately. Do not repeatedly assign -large intermediate values before one flush: - -```ts -// Avoid: traverses every intermediate tree. -for (const frame of frames) tracker.state.view = render(frame); - -// Prefer: only the final tree crosses the tracked boundary. -const nextView = frames.reduce((view, frame) => renderInto(view, frame), initialView); -tracker.state.view = nextView; -``` - -For a few changes, mutate the leaves directly: - -```ts -for (const update of updates) { - tracker.state.view.rows[update.index]!.status = update.status; -} -``` - -#### Do not cancel whole-array mutators - -`sort()`, `reverse()`, `fill()`, and `copyWithin()` record snapshots. Cancelling -them still publishes the final snapshot: - -```ts -// Avoid: final value is unchanged, but a snapshot may still be sent. -tracker.state.items.reverse(); -tracker.state.items.reverse(); - -// Prefer: decide before mutating tracked state. -if (needsReverse) tracker.state.items.reverse(); -``` - -#### Publish large inserts and edit sets in chunks - -Pending inserted values are cloned for replica ownership. Very large unflushed -pushes therefore temporarily retain both the live values and their operation -payloads. Long operation/path histories may also collapse to a complete base -batch, increasing snapshot and wire cost. - -```ts -for (const chunk of chunks(items, 1_000)) { - tracker.state.items.push(...chunk); - replica = apply(replica, tracker.flush()); // send each batch in a real producer -} -``` - -Use the same pattern for large sets of unrelated edits: apply a bounded chunk, -publish it, then continue. - -### Optional properties - -Optional object properties use absence. They do not require `null`: - -```ts -type Settings = { label?: string; count: number }; -const tracker = track({ 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, as does any object later -assigned into state or inserted into an array. Mutate through `tracker.state`: - -```ts -const item = { status: "new" }; -tracker.state.item = item; - -tracker.state.item.status = "ready"; // tracked -item.status = "broken"; // NOT tracked: silently diverges from the replica -``` - -A reference retained from `tracker.state` stays correct across operations that -renumber it, and across the removal of the element it points at: - -```ts -const held = tracker.state.items[2]; -tracker.state.items.unshift(other); -held.name = "edited"; // publishes items[3].name - -tracker.state.items.splice(3, 1); -held.name = "gone"; // element is no longer in the tree: mutated, nothing published -``` - -One object may occupy several paths. Each live path is published: - -```ts -tracker.state.a = tracker.state.items[0]; -tracker.state.items[0].k = 1; // publishes both a.k and items[0].k -``` - -Tracked state must be a mutable JSON tree: - -- strings, booleans, finite numbers, `null`, arrays, and plain objects; -- no cycles; -- no sparse arrays, accessors, frozen objects, symbols, classes, functions, - `Map`, or `Set`. - -## Proxy lifetime and large reads - -Proxy caches are weak. Reading a subtree does not permanently retain its proxies -just because the underlying plain objects remain in the document. A proxy still -held by application code keeps its identity; held descendants retain the ancestor -tracking metadata needed to follow array reindexing. Explicit alias locations are -remembered separately from the lifetime of their public proxies. - -Collection is automatic, not a `flush()` side effect. JavaScript keeps newly -created or dereferenced `WeakRef` targets alive until the current job ends, and -finalizer cleanup can run later. A synchronous traversal can therefore still have -a substantial allocation peak. Retained-memory measurements must allow event-loop -turns as well as GC; a synchronous `gc()` immediately after the traversal is not -sufficient to measure weak-cache reclamation. - -This does not eliminate proxy construction/trap costs or full comparisons on -container assignment. `tracker.target` is available for read-only bulk inspection -without creating proxies. Never mutate through it; all tracked mutations must go -through `tracker.state` or a proxy obtained from it. - -See the [delta investigation findings](../../../durable/docs/chord-delta-findings.md) -for the full-traversal regression, measured trade-offs, and reproduction commands. - -## 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. - -`flush()` guarantees convergence, not a minimal or canonical diff. Any nonempty -batch advances replicated-state sequence numbers and notifies subscribers, even -if applying it leaves the value deeply equal to the previous revision. To bound -pending operation and path history, a sufficiently long mutation window may -collapse to a complete base batch automatically. This bounds accumulated log -metadata, not payload bytes or peak allocation, and trades one full-value -snapshot for an additional recovery point. - -`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. One object at several paths publishes each - path separately, and a replica holds a distinct value at each. -- 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. +| `["m", path, permutation]` | Reorder an array: `new[i] = old[permutation[i]]`. | + +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`. + +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()`, 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 +replicated-state replicas validate each resulting revision. + +`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`. + +## Limits and footguns + +- No freezing. Mutating any immutable value corrupts state silently. +- Loopback sharing: in-process service consumers may 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. +- 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. diff --git a/packages/chord/src/delta/cow/apply-immutable.ts b/packages/chord/src/delta/apply-immutable-batch.ts similarity index 96% rename from packages/chord/src/delta/cow/apply-immutable.ts rename to packages/chord/src/delta/apply-immutable-batch.ts index 991bba005..338e251b1 100644 --- a/packages/chord/src/delta/cow/apply-immutable.ts +++ b/packages/chord/src/delta/apply-immutable-batch.ts @@ -1,4 +1,4 @@ -import type { JsonValue } from "../../types.ts"; +import type { JsonValue } from "../types.ts"; import { apply as applyMutable, assertValidOp, @@ -7,7 +7,7 @@ import { PathError, type Seg, UnsafePathError, -} from "../index.ts"; +} from "./index.ts"; type JsonContainer = JsonValue[] | Record; diff --git a/packages/chord/src/delta/apply-immutable-trusted.ts b/packages/chord/src/delta/apply-immutable-trusted.ts new file mode 100644 index 000000000..9049ab040 --- /dev/null +++ b/packages/chord/src/delta/apply-immutable-trusted.ts @@ -0,0 +1,128 @@ +import type { JsonValue } from "../types.ts"; +import type { Op, Path, Seg } from "./index.ts"; + +type JsonContainer = JsonValue[] | Record; +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(target: T, operations: readonly Op[]): T { + let root = target as unknown as JsonValue; + const owned = new WeakSet(); + 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): 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)[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"; +} diff --git a/packages/chord/src/delta/astra/README.md b/packages/chord/src/delta/astra/README.md deleted file mode 100644 index 82449d6e6..000000000 --- a/packages/chord/src/delta/astra/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Astra trusted contract - -Astra is an exact overlay tracker for trusted mutable live state. `track()` and -`prepareReplace()` take ownership of their roots in O(1); callers must not mutate -transferred roots except through an active Astra draft. Successful adoption -mutates the committed root in place, so previously retained committed references -observe later transactions. - -Inputs and placements must already be alias-free strict JSON trees: plain objects, -dense arrays, strings, booleans, finite numbers, and `null`. Cycles, accessors, -proxies, symbols, classes, sparse arrays, `undefined`, and non-finite numbers are -outside the contract. Astra deliberately does not validate or freeze complete -input trees. - -Supported draft operations use ordinary property access, primitive array indices, -and non-reentrant array callbacks. The following are outside the trusted contract: - -- coercion callbacks that mutate the draft while an array method is converting an argument; -- mutations or `prepare()` calls from inside a `sort()` comparator; -- bigint or object-valued comparator results requiring coercion; -- non-primitive array indices. - -These exclusions avoid callback-order and coercion machinery on the overlay hot -paths. Behavior for excluded operations is unspecified. diff --git a/packages/chord/src/delta/astra/index.ts b/packages/chord/src/delta/astra/index.ts deleted file mode 100644 index 24e6889c6..000000000 --- a/packages/chord/src/delta/astra/index.ts +++ /dev/null @@ -1,2005 +0,0 @@ -import type { JsonValue } from "../../types.ts"; -import type { Draft } from "../draft.ts"; -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts"; - -export type { Draft, JsonValue, Op }; - -type Primitive = null | boolean | number | string; -type Container = JsonValue[] | Record; -type Stored = JsonValue; -type Status = "open" | "prepared" | "consumed" | "aborted" | "stale" | "faulted"; - -type InsertSource = { values: Stored[] }; -type ParentKind = 0 | 1 | 2; // object, base-array entry, inserted-array entry - -type BasePiece = { kind: "base"; start: number; length: number; step: 1 | -1 }; -type InsertPiece = { kind: "insert"; source: InsertSource; start: number; length: number; step: 1 | -1 }; -type Piece = BasePiece | InsertPiece; - -type PieceNode = { - piece: Piece; - left: PieceNode | undefined; - right: PieceNode | undefined; - priority: number; - elements: number; -}; - -type PieceLocation = { piece: Piece; logicalStart: number; minimum: number; maximum: number }; - -type DenseRegion = { start: number; length: number }; -type DenseCandidates = { indices: number[]; bits: Uint8Array | undefined; length: number }; - -type ArrayPlan = { - removeRuns: number[]; - permutation: number[] | undefined; - insertRuns: number[]; -}; - -type ArrayOverlay = { - root: PieceNode | undefined; - pieces: Piece[] | undefined; - baseOverrides: Map; - insertOverrides: Map>; - structural: boolean; - generation: number; - plan: ArrayPlan | undefined; - seed: number; - locatedOffset: number; - baseLocations: PieceLocation[] | undefined; - insertLocations: Map | undefined; -}; - -type OverlayNode = { - context: OverlayContext; - base: Container; - parent: OverlayNode | undefined; - parentKind: ParentKind; - parentKey: string | number; - parentSource?: InsertSource; - parentExpected?: Container; - parentPlacement: boolean; - target: object; - proxy: object; - writeKey?: string; - writeValue?: Stored; - writes?: Map; - deleteKey?: string; - deletes?: Set; - readded?: Set; - array?: ArrayOverlay; - dirty?: boolean; - preparedPath?: Path; - applyDepth?: number; -}; - -type OverlayContext = { - owner: object; - tracker: WeakRef>; - baseRevision: number; - status: Status; - root: OverlayNode | undefined; - dirty: OverlayNode[]; - nodes: OverlayNode[]; - rawNodes: WeakMap; - ops: Op[] | undefined; - replacement: boolean; - replacementNoop: boolean; - baseValue: object | undefined; - adoptedValue: object | undefined; - registryRef: WeakRef | undefined; -}; - -type PreparedMetadata = { context: OverlayContext }; - -const NODE = Symbol("astra.overlay.node"); -const PREPARED = new WeakMap(); -const RELEASED: Record = {}; -const ARRAY_MUTATORS = new Set([ - "push", - "pop", - "shift", - "unshift", - "splice", - "reverse", - "sort", - "fill", - "copyWithin", -]); -const MAX_SPLICE_CHUNK = 10_000; -const MAX_DELTA_OPERATIONS = 4_096; - -export interface Prepared { - readonly base: T; - readonly value: T; - readonly ops: readonly Op[]; - readonly baseRevision: number; - abort(): void; -} - -export interface Change { - readonly state: Draft; - prepare(): Prepared; - abort(): void; -} - -export interface Tracker { - readonly value: T; - readonly revision: number; - beginChange(): Change; - prepareReplace(value: T): Prepared; - adopt(prepared: Prepared): void; -} - -class PreparedImpl implements Prepared { - readonly #context: OverlayContext; - readonly #view: T; - readonly base: T; - - constructor(context: OverlayContext, value: T) { - this.#context = context; - this.#view = value; - this.base = context.baseValue as T; - PREPARED.set(this, { context }); - } - - get value(): T { - return (this.#context.status === "consumed" ? this.#context.adoptedValue : this.#view) as T; - } - - get baseRevision(): number { - return this.#context.baseRevision; - } - - get ops(): readonly Op[] { - return ensureOperations(this.#context); - } - - abort(): void { - abortContext(this.#context); - } -} - -class ChangeImpl implements Change { - readonly #state: WeakRef; - #context: OverlayContext | undefined; - #preparedContext: WeakRef | undefined; - #settled = false; - - constructor(context: OverlayContext) { - this.#context = context; - this.#state = new WeakRef(context.root!.proxy); - } - - get state(): Draft { - const state = this.#state.deref(); - if (state === undefined) throw new TypeError("Cannot use a settled overlay"); - return state as Draft; - } - - prepare(): Prepared { - if (this.#settled) throw new Error("Change has already been settled"); - const context = this.#context!; - assertWritable(context); - context.status = "prepared"; - try { - if (!context.replacement) context.ops = emitOperations(context); - const prepared = new PreparedImpl(context, context.root!.proxy as T); - this.#preparedContext = new WeakRef(context); - this.#settled = true; - return prepared; - } catch (error) { - context.status = "aborted"; - clearContext(context); - this.#settled = true; - throw error; - } finally { - this.#context = undefined; - } - } - - abort(): void { - if (this.#settled) { - const context = this.#preparedContext?.deref(); - this.#preparedContext = undefined; - if (context !== undefined) abortContext(context); - return; - } - this.#settled = true; - abortContext(this.#context!); - this.#context = undefined; - } -} - -class TrackerImpl implements Tracker { - readonly #owner = {}; - readonly #contexts = new Set>(); - #value: T; - #revision = 0; - #pruneBudget = 256; - #fault: unknown; - - constructor(initial: T) { - this.#value = initial; - } - - get value(): T { - this.#assertHealthy(); - return this.#value; - } - - get revision(): number { - return this.#revision; - } - - beginChange(): Change { - this.#assertHealthy(); - const context = createContext(this, this.#owner, this.#revision, this.#value, false, this.#value); - this.#register(context); - return new ChangeImpl(context); - } - - prepareReplace(value: T): Prepared { - this.#assertHealthy(); - const context = createContext(this, this.#owner, this.#revision, value, true, this.#value); - context.status = "prepared"; - this.#register(context); - return new PreparedImpl(context, context.root!.proxy as T); - } - - adopt(prepared: Prepared): void { - this.#assertHealthy(); - const context = PREPARED.get(prepared as object)?.context; - if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); - if (context.status === "consumed") throw new Error("Prepared change has already been used"); - if (context.status === "aborted") throw new Error("Prepared change has been aborted"); - if (context.status === "stale") throw new Error("Prepared change is stale"); - if (context.status !== "prepared") throw new Error("Prepared change is not ready"); - if (context.baseRevision !== this.#revision) { - context.status = "stale"; - clearContext(context); - throw new Error("Prepared change is stale"); - } - - // Detach payloads before the candidate placements become committed mutable data. - ensureOperations(context); - try { - if (context.replacement) { - if (!context.replacementNoop) this.#value = context.root!.base as T; - } else applyOverlay(context); - } catch (error) { - context.status = "faulted"; - this.#fault = error; - this.#invalidate(context); - throw new Error("Tracker faulted after a partial trusted adoption", { cause: error }); - } - context.adoptedValue = this.#value; - context.status = "consumed"; - this.#revision += 1; - this.#invalidate(context); - } - - releaseContext(context: OverlayContext): void { - if (context.registryRef !== undefined) this.#contexts.delete(context.registryRef); - context.registryRef = undefined; - } - - #register(context: OverlayContext): void { - const reference = new WeakRef(context); - context.registryRef = reference; - this.#contexts.add(reference); - this.#pruneBudget -= 1; - if (this.#pruneBudget === 0) { - this.#prune(); - this.#pruneBudget = Math.max(256, this.#contexts.size); - } - } - - #prune(): void { - for (const reference of this.#contexts) { - if (reference.deref() === undefined) this.#contexts.delete(reference); - } - } - - #invalidate(winner: OverlayContext): void { - for (const reference of this.#contexts) { - const context = reference.deref(); - if (context === undefined) { - this.#contexts.delete(reference); - continue; - } - if (context !== winner && (context.status === "open" || context.status === "prepared")) { - context.status = "stale"; - } - clearContext(context); - this.#contexts.delete(reference); - } - this.#pruneBudget = 256; - } - - replacementBase(context: OverlayContext): T | undefined { - return context.baseRevision === this.#revision ? this.#value : undefined; - } - - #assertHealthy(): void { - if (this.#fault !== undefined) throw new Error("Tracker is faulted", { cause: this.#fault }); - } -} - -/** Take ownership of an alias-free mutable strict-JSON root in O(1). */ -export function track(initial: T): Tracker { - return new TrackerImpl(initial); -} - -function createContext( - tracker: TrackerImpl, - owner: object, - baseRevision: number, - root: T, - replacement: boolean, - base: T, -): OverlayContext { - const context: OverlayContext = { - owner, - tracker: new WeakRef(tracker as unknown as TrackerImpl), - baseRevision, - status: "open", - root: undefined, - dirty: [], - nodes: [], - rawNodes: new WeakMap(), - ops: undefined, - replacement, - replacementNoop: false, - baseValue: base, - adoptedValue: undefined, - registryRef: undefined, - }; - context.root = createNode(context, root as Container, undefined); - return context; -} - -const sharedObjectHandler: ProxyHandler = { - deleteProperty(target, property) { - return deleteProperty(nodeForTarget(target), property); - }, - defineProperty(target) { - assertWritable(nodeForTarget(target).context); - throw new TypeError("Defining overlay properties is not supported"); - }, - get(target, property) { - const node = nodeForTarget(target); - if (property === NODE) return node; - return getProperty(node, property); - }, - getOwnPropertyDescriptor(target, property) { - return getDescriptor(nodeForTarget(target), property); - }, - getPrototypeOf(target) { - const node = nodeForTarget(target); - assertReadable(node.context); - return Object.getPrototypeOf(node.base); - }, - has(target, property) { - return hasProperty(nodeForTarget(target), property); - }, - isExtensible(target) { - assertReadable(nodeForTarget(target).context); - return true; - }, - ownKeys(target) { - return ownKeys(nodeForTarget(target)); - }, - preventExtensions(target) { - assertWritable(nodeForTarget(target).context); - throw new TypeError("Overlays cannot be made non-extensible"); - }, - set(target, property, value) { - return setProperty(nodeForTarget(target), property, value); - }, - setPrototypeOf(target) { - assertWritable(nodeForTarget(target).context); - throw new TypeError("Changing an overlay prototype is not supported"); - }, -}; - -const sharedArrayHandler: ProxyHandler = sharedObjectHandler; - -function createNode( - context: OverlayContext, - base: Container, - parent: OverlayNode | undefined, - parentKind: ParentKind = 0, - parentKey: string | number = "", - parentSource: InsertSource | undefined = undefined, - parentExpected: Container | undefined = undefined, - parentPlacement = false, -): OverlayNode { - const existing = context.rawNodes.get(base); - if (existing !== undefined) return existing; - const target: object = Array.isArray(base) ? [] : {}; - if (Array.isArray(target)) target.length = (base as JsonValue[]).length; - const node: OverlayNode = { - context, - base, - parent, - parentKind, - parentKey, - parentPlacement, - target, - proxy: target, - }; - if (parentSource !== undefined) node.parentSource = parentSource; - if (parentExpected !== undefined) node.parentExpected = parentExpected; - Object.defineProperty(target, NODE, { value: node, configurable: true }); - node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler); - context.rawNodes.set(base, node); - context.nodes.push(node); - return node; -} - -function nodeForTarget(target: object): OverlayNode { - const node = Object.getOwnPropertyDescriptor(target, NODE)?.value as OverlayNode | undefined; - if (node === undefined) throw new TypeError("Cannot use a settled overlay"); - return node; -} - -function assertReadable(context: OverlayContext): void { - if ( - context.status === "consumed" || - context.status === "aborted" || - context.status === "stale" || - context.status === "faulted" - ) { - throw new TypeError("Cannot use a settled overlay"); - } -} - -function assertWritable(context: OverlayContext): void { - assertReadable(context); - if (context.status !== "open") throw new TypeError("Prepared overlays are read-only"); -} - -function getProperty(node: OverlayNode, property: PropertyKey): unknown { - assertReadable(node.context); - if (Array.isArray(node.base)) { - const overlay = arrayOverlay(node); - if (property === "length") return arrayLength(overlay); - if (ARRAY_MUTATORS.has(property)) return arrayMutators[property as keyof typeof arrayMutators]; - const index = arrayIndex(property); - if (index !== undefined) { - if (index >= arrayLength(overlay)) return undefined; - const piece = locatePiece(overlay, index); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - const value = entryValueAt(node, piece, sourceIndex); - if (!isContainer(value)) return value; - return createNode( - node.context, - value, - node, - piece.kind === "base" ? 1 : 2, - sourceIndex, - piece.kind === "insert" ? piece.source : undefined, - value, - piece.kind === "insert" || hasEntryOverrideAt(overlay, piece, sourceIndex), - ).proxy; - } - return Reflect.get(Array.prototype, property, node.proxy); - } - if (typeof property === "symbol") return Reflect.get(node.base, property, node.proxy); - const key = String(property); - if (!objectHas(node, key)) { - if (isObjectDeleted(node, key) || Object.hasOwn(node.base, key)) return undefined; - return Reflect.get(node.base, property, node.proxy); - } - const value = objectValue(node, key); - if (!isContainer(value)) return value; - return createNode(node.context, value, node, 0, key, undefined, value, hasObjectWrite(node, key)).proxy; -} - -function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { - assertWritable(node.context); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(node.base)) { - if (property === "length") { - setArrayLength(node, toArrayLength(supplied)); - return true; - } - const index = arrayIndex(property); - if (index === undefined) throw new TypeError("Only array indices and length can be written"); - if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); - setArrayIndex(node, index, clonePlacement(supplied)); - return true; - } - const key = String(property); - if (supplied === undefined) return deleteProperty(node, key); - const stored = clonePlacement(supplied); - const current = objectValue(node, key); - const wasDeleted = isObjectDeleted(node, key); - if (!wasDeleted && !isContainer(stored) && current === stored) return true; - setObjectWrite(node, key, stored); - if (wasDeleted && Object.hasOwn(node.base, key)) { - if (node.readded === undefined) node.readded = new Set(); - node.readded.add(key); - } - deleteObjectDeletion(node, key); - markDirty(node); - return true; -} - -function deleteProperty(node: OverlayNode, property: PropertyKey): boolean { - assertWritable(node.context); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(node.base)) throw new TypeError("Overlay arrays cannot contain holes"); - const key = String(property); - if (!objectHas(node, key)) return true; - deleteObjectWrite(node, key); - node.readded?.delete(key); - setObjectDeletion(node, key); - markDirty(node); - return true; -} - -function hasProperty(node: OverlayNode, property: PropertyKey): boolean { - assertReadable(node.context); - if (Array.isArray(node.base)) { - if (property === "length") return true; - const index = arrayIndex(property); - if (index !== undefined) return index < arrayLength(arrayOverlay(node)); - return property in Array.prototype; - } - return typeof property === "symbol" ? property in node.base : objectHas(node, String(property)); -} - -function ownKeys(node: OverlayNode): ArrayLike { - assertReadable(node.context); - if (Array.isArray(node.base)) { - const length = arrayLength(arrayOverlay(node)); - return [...Array.from({ length }, (_, index) => String(index)), "length"]; - } - const keys = Object.keys(node.base).filter((key) => !isObjectDeleted(node, key) && !node.readded?.has(key)); - const seen = new Set(keys); - if (node.writeKey !== undefined && !seen.has(node.writeKey)) { - keys.push(node.writeKey); - seen.add(node.writeKey); - } - for (const key of node.writes?.keys() ?? []) { - if (seen.has(key)) continue; - keys.push(key); - seen.add(key); - } - const indices: number[] = []; - const strings: string[] = []; - for (const key of keys) { - const index = arrayIndex(key); - if (index === undefined) strings.push(key); - else indices.push(index); - } - indices.sort((left, right) => left - right); - return [...indices.map(String), ...strings]; -} - -function getDescriptor(node: OverlayNode, property: PropertyKey): PropertyDescriptor | undefined { - assertReadable(node.context); - if (Array.isArray(node.base)) { - if (property === "length") { - const length = arrayLength(arrayOverlay(node)); - (node.target as unknown[]).length = length; - return Reflect.getOwnPropertyDescriptor(node.target, "length"); - } - const index = arrayIndex(property); - if (index === undefined || index >= arrayLength(arrayOverlay(node))) return undefined; - } else { - if (typeof property === "symbol" || !objectHas(node, String(property))) return undefined; - } - return { - configurable: true, - enumerable: true, - writable: node.context.status === "open", - value: getProperty(node, property), - }; -} - -function hasObjectWrite(node: OverlayNode, key: string): boolean { - return node.writeKey === key || (node.writes?.has(key) ?? false); -} - -function setObjectWrite(node: OverlayNode, key: string, value: Stored): void { - if (node.writes !== undefined) { - node.writes.set(key, value); - return; - } - if (node.writeKey === undefined || node.writeKey === key) { - node.writeKey = key; - node.writeValue = value; - return; - } - node.writes = new Map([ - [node.writeKey, node.writeValue!], - [key, value], - ]); - node.writeKey = undefined; - node.writeValue = undefined; -} - -function deleteObjectWrite(node: OverlayNode, key: string): void { - if (node.writeKey === key) { - node.writeKey = undefined; - node.writeValue = undefined; - } else node.writes?.delete(key); -} - -function isObjectDeleted(node: OverlayNode, key: string): boolean { - return node.deleteKey === key || (node.deletes?.has(key) ?? false); -} - -function setObjectDeletion(node: OverlayNode, key: string): void { - if (node.deletes !== undefined) { - node.deletes.add(key); - return; - } - if (node.deleteKey === undefined || node.deleteKey === key) { - node.deleteKey = key; - return; - } - node.deletes = new Set([node.deleteKey, key]); - node.deleteKey = undefined; -} - -function deleteObjectDeletion(node: OverlayNode, key: string): void { - if (node.deleteKey === key) node.deleteKey = undefined; - else node.deletes?.delete(key); -} - -function objectHas(node: OverlayNode, key: string): boolean { - if (isObjectDeleted(node, key)) return false; - return hasObjectWrite(node, key) || Object.hasOwn(node.base, key); -} - -function objectValue(node: OverlayNode, key: string): Stored { - if (node.writeKey === key) return node.writeValue!; - if (node.writes?.has(key)) return node.writes.get(key)!; - return (node.base as Record)[key]!; -} - -function markDirty(node: OverlayNode): void { - if (node.dirty) return; - node.dirty = true; - node.context.dirty.push(node); -} - -function arrayOverlay(node: OverlayNode): ArrayOverlay { - if (node.array !== undefined) return node.array; - const base = node.base as JsonValue[]; - const overlay: ArrayOverlay = { - root: undefined, - pieces: undefined, - baseOverrides: new Map(), - insertOverrides: new Map(), - structural: false, - generation: 0, - plan: undefined, - seed: 0x9e3779b9, - locatedOffset: 0, - baseLocations: undefined, - insertLocations: undefined, - }; - if (base.length > 0) - overlay.root = createPieceNode(overlay, { kind: "base", start: 0, length: base.length, step: 1 }); - node.array = overlay; - return overlay; -} - -function nextPiecePriority(overlay: ArrayOverlay): number { - let value = overlay.seed; - value ^= value << 13; - value ^= value >>> 17; - value ^= value << 5; - overlay.seed = value >>> 0; - return overlay.seed; -} - -function treeElements(node: PieceNode | undefined): number { - return node?.elements ?? 0; -} - -function updatePieceNode(node: PieceNode): void { - node.elements = treeElements(node.left) + node.piece.length + treeElements(node.right); -} - -function createPieceNode(overlay: ArrayOverlay, piece: Piece): PieceNode { - return { piece, left: undefined, right: undefined, priority: nextPiecePriority(overlay), elements: piece.length }; -} - -function mergePieceTrees(left: PieceNode | undefined, right: PieceNode | undefined): PieceNode | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - if (left.priority >= right.priority) { - left.right = mergePieceTrees(left.right, right); - updatePieceNode(left); - return left; - } - right.left = mergePieceTrees(left, right.left); - updatePieceNode(right); - return right; -} - -function splitPieceTree( - overlay: ArrayOverlay, - root: PieceNode | undefined, - index: number, -): [PieceNode | undefined, PieceNode | undefined] { - if (root === undefined) return [undefined, undefined]; - const leftLength = treeElements(root.left); - if (index < leftLength) { - const [left, right] = splitPieceTree(overlay, root.left, index); - root.left = right; - updatePieceNode(root); - return [left, root]; - } - const pieceEnd = leftLength + root.piece.length; - if (index > pieceEnd) { - const [left, right] = splitPieceTree(overlay, root.right, index - pieceEnd); - root.right = left; - updatePieceNode(root); - return [root, right]; - } - if (index === leftLength) { - const left = root.left; - root.left = undefined; - updatePieceNode(root); - return [left, root]; - } - if (index === pieceEnd) { - const right = root.right; - root.right = undefined; - updatePieceNode(root); - return [root, right]; - } - const offset = index - leftLength; - const first = { ...root.piece, length: offset }; - const second = { - ...root.piece, - start: root.piece.start + root.piece.step * offset, - length: root.piece.length - offset, - }; - return [ - mergePieceTrees(root.left, createPieceNode(overlay, first)), - mergePieceTrees(createPieceNode(overlay, second), root.right), - ]; -} - -function leftmostPieceNode(node: PieceNode): PieceNode { - while (node.left !== undefined) node = node.left; - return node; -} - -function rightmostPieceNode(node: PieceNode): PieceNode { - while (node.right !== undefined) node = node.right; - return node; -} - -function mergeablePieces(left: Piece, right: Piece): boolean { - if (left.kind !== right.kind) return false; - if (left.kind === "insert" && left.source !== (right as InsertPiece).source) return false; - if (left.length === 1 && right.length === 1) return Math.abs(right.start - left.start) === 1; - return left.step === right.step && left.start + left.step * left.length === right.start; -} - -function joinNormalized( - overlay: ArrayOverlay, - left: PieceNode | undefined, - right: PieceNode | undefined, -): PieceNode | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - const leftPiece = rightmostPieceNode(left).piece; - const rightPiece = leftmostPieceNode(right).piece; - if (!mergeablePieces(leftPiece, rightPiece)) return mergePieceTrees(left, right); - const [leftRest] = splitPieceTree(overlay, left, treeElements(left) - leftPiece.length); - const [, rightRest] = splitPieceTree(overlay, right, rightPiece.length); - const step = leftPiece.length === 1 ? ((rightPiece.start - leftPiece.start) as 1 | -1) : leftPiece.step; - const combined: Piece = - leftPiece.kind === "base" - ? { kind: "base", start: leftPiece.start, length: leftPiece.length + rightPiece.length, step } - : { - kind: "insert", - source: leftPiece.source, - start: leftPiece.start, - length: leftPiece.length + rightPiece.length, - step, - }; - return joinNormalized(overlay, joinNormalized(overlay, leftRest, createPieceNode(overlay, combined)), rightRest); -} - -function flattenPieceTree(node: PieceNode | undefined, output: Piece[]): void { - if (node === undefined) return; - flattenPieceTree(node.left, output); - output.push(node.piece); - flattenPieceTree(node.right, output); -} - -function piecesOf(overlay: ArrayOverlay): readonly Piece[] { - if (overlay.pieces === undefined) { - overlay.pieces = []; - flattenPieceTree(overlay.root, overlay.pieces); - } - return overlay.pieces; -} - -function treeFromPieces(overlay: ArrayOverlay, pieces: Piece[]): PieceNode | undefined { - mergePieces(pieces); - let root: PieceNode | undefined; - for (const piece of pieces) root = mergePieceTrees(root, createPieceNode(overlay, piece)); - return root; -} - -function replaceAllPieces(overlay: ArrayOverlay, pieces: Piece[]): void { - overlay.root = treeFromPieces(overlay, pieces); - overlay.pieces = undefined; - overlay.baseLocations = undefined; - overlay.insertLocations = undefined; -} - -function arrayLength(overlay: ArrayOverlay): number { - return treeElements(overlay.root); -} - -function locatePiece(overlay: ArrayOverlay, index: number): Piece { - let node = overlay.root; - while (node !== undefined) { - const leftLength = treeElements(node.left); - if (index < leftLength) node = node.left; - else if (index >= leftLength + node.piece.length) { - index -= leftLength + node.piece.length; - node = node.right; - } else { - overlay.locatedOffset = index - leftLength; - return node.piece; - } - } - throw new RangeError("Array overlay index is out of range"); -} - -function arrayIndex(property: PropertyKey): number | undefined { - if (typeof property !== "string" || property === "") return undefined; - const index = Number(property); - if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined; - return index; -} - -function entryValueAt(node: OverlayNode, piece: Piece, sourceIndex: number): Stored { - const overlay = arrayOverlay(node); - if (piece.kind === "base") { - return overlay.baseOverrides.has(sourceIndex) - ? overlay.baseOverrides.get(sourceIndex)! - : (node.base as JsonValue[])[sourceIndex]!; - } - const overrides = overlay.insertOverrides.get(piece.source); - return overrides?.has(sourceIndex) ? overrides.get(sourceIndex)! : piece.source.values[sourceIndex]!; -} - -function hasEntryOverrideAt(overlay: ArrayOverlay, piece: Piece, sourceIndex: number): boolean { - return piece.kind === "base" - ? overlay.baseOverrides.has(sourceIndex) - : (overlay.insertOverrides.get(piece.source)?.has(sourceIndex) ?? false); -} - -function extendRightmostPiece(node: PieceNode, amount: number): void { - if (node.right !== undefined) extendRightmostPiece(node.right, amount); - else node.piece.length += amount; - updatePieceNode(node); -} - -function invalidatePieceCaches(overlay: ArrayOverlay): void { - overlay.pieces = undefined; - overlay.baseLocations = undefined; - overlay.insertLocations = undefined; - overlay.plan = undefined; -} - -function replacePieceRange(node: OverlayNode, index: number, remove: number, inserted: Piece[]): void { - if (remove === 0 && inserted.length === 0) return; - const overlay = arrayOverlay(node); - if (remove === 0 && index === arrayLength(overlay) && inserted.length === 1 && overlay.root !== undefined) { - const addition = inserted[0]!; - const tail = rightmostPieceNode(overlay.root).piece; - if ( - addition.kind === "insert" && - tail.kind === "insert" && - tail.step === 1 && - tail.start + tail.length === tail.source.values.length - ) { - for (const value of addition.source.values) tail.source.values.push(value); - extendRightmostPiece(overlay.root, addition.length); - invalidatePieceCaches(overlay); - overlay.structural = true; - overlay.generation += 1; - (node.target as unknown[]).length = arrayLength(overlay); - markDirty(node); - return; - } - } - const [left, rest] = splitPieceTree(overlay, overlay.root, index); - const [, right] = splitPieceTree(overlay, rest, remove); - const middle = treeFromPieces(overlay, inserted); - overlay.root = joinNormalized(overlay, joinNormalized(overlay, left, middle), right); - invalidatePieceCaches(overlay); - overlay.structural = true; - overlay.generation += 1; - (node.target as unknown[]).length = arrayLength(overlay); - markDirty(node); -} - -function mergePieces(pieces: Piece[]): void { - for (let index = 1; index < pieces.length; ) { - const left = pieces[index - 1]!; - const right = pieces[index]!; - const sameSource = - left.kind === right.kind && (left.kind === "base" || left.source === (right as InsertPiece).source); - if (sameSource && left.length === 1 && right.length === 1 && Math.abs(right.start - left.start) === 1) { - left.step = (right.start - left.start) as 1 | -1; - left.length = 2; - pieces.splice(index, 1); - } else if (sameSource && left.step === right.step && left.start + left.step * left.length === right.start) { - left.length += right.length; - pieces.splice(index, 1); - } else index += 1; - } -} - -function insertPiece(items: Stored[]): InsertPiece[] { - if (items.length === 0) return []; - return [{ kind: "insert", source: { values: items }, start: 0, length: items.length, step: 1 }]; -} - -function setArrayIndex(node: OverlayNode, index: number, stored: Stored): void { - const overlay = arrayOverlay(node); - const length = arrayLength(overlay); - if (index === length) { - replacePieceRange(node, length, 0, insertPiece([stored])); - return; - } - const piece = locatePiece(overlay, index); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - const current = entryValueAt(node, piece, sourceIndex); - if (!isContainer(stored) && current === stored) return; - if (piece.kind === "base") { - if (!isContainer(stored) && stored === (node.base as JsonValue[])[sourceIndex]) { - overlay.baseOverrides.delete(sourceIndex); - } else overlay.baseOverrides.set(sourceIndex, stored); - } else { - let overrides = overlay.insertOverrides.get(piece.source); - if (!isContainer(stored) && stored === piece.source.values[sourceIndex]) { - overrides?.delete(sourceIndex); - if (overrides?.size === 0) overlay.insertOverrides.delete(piece.source); - } else { - if (overrides === undefined) { - overrides = new Map(); - overlay.insertOverrides.set(piece.source, overrides); - } - overrides.set(sourceIndex, stored); - } - } - markDirty(node); -} - -function setArrayLength(node: OverlayNode, next: number): void { - const current = arrayLength(arrayOverlay(node)); - if (next === current) return; - if (next < current) replacePieceRange(node, next, current - next, []); - else replacePieceRange(node, current, 0, insertPiece(Array.from({ length: next - current }, () => null))); -} - -function toArrayLength(value: unknown): number { - const number = Number(value); - if (!Number.isInteger(number) || number < 0 || number >= 4_294_967_296) throw new RangeError("Invalid array length"); - return number; -} - -function toIntegerOrInfinity(value: unknown): number { - const number = Number(value); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; -} - -function clampIndex(value: number, length: number): number { - if (value === Number.NEGATIVE_INFINITY) return 0; - if (value < 0) return Math.max(length + value, 0); - return Math.min(value, length); -} - -function mutatorNode(receiver: unknown): OverlayNode | undefined { - if (!isContainer(receiver)) return undefined; - const node = Reflect.get(receiver, NODE) as OverlayNode | undefined; - if (node === undefined) return undefined; - if (!Array.isArray(node.base)) throw new TypeError("Array mutator called on incompatible receiver"); - assertWritable(node.context); - return node; -} - -const arrayMutators = { - push(this: unknown, ...items: unknown[]): number { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.push, this, items) as number; - const length = arrayLength(arrayOverlay(node)); - replacePieceRange(node, length, 0, insertPiece(items.map(clonePlacement))); - return length + items.length; - }, - pop(this: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.pop, this, []); - const length = arrayLength(arrayOverlay(node)); - if (length === 0) return undefined; - const value = getProperty(node, String(length - 1)); - replacePieceRange(node, length - 1, 1, []); - return value; - }, - shift(this: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.shift, this, []); - const length = arrayLength(arrayOverlay(node)); - if (length === 0) return undefined; - const value = getProperty(node, "0"); - replacePieceRange(node, 0, 1, []); - return value; - }, - unshift(this: unknown, ...items: unknown[]): number { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.unshift, this, items) as number; - replacePieceRange(node, 0, 0, insertPiece(items.map(clonePlacement))); - return arrayLength(arrayOverlay(node)); - }, - splice(this: unknown, ...args: unknown[]): unknown[] { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; - const length = arrayLength(arrayOverlay(node)); - const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - start - : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); - const removed = Array.from({ length: remove }, (_, offset) => getProperty(node, String(start + offset))); - const items = args.slice(2).map(clonePlacement); - replacePieceRange(node, start, remove, insertPiece(items)); - setArrayLength(node, length - remove + items.length); - return removed; - }, - reverse(this: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.reverse, this, []); - const overlay = arrayOverlay(node); - if (arrayLength(overlay) < 2) return node.proxy; - const pieces = [...piecesOf(overlay)].reverse(); - for (const piece of pieces) { - piece.start += piece.step * (piece.length - 1); - piece.step = piece.step === 1 ? -1 : 1; - } - replaceAllPieces(overlay, pieces); - overlay.structural = true; - overlay.generation += 1; - overlay.plan = undefined; - markDirty(node); - return node.proxy; - }, - sort(this: unknown, comparator?: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); - if (comparator !== undefined && typeof comparator !== "function") - throw new TypeError("Comparator must be a function"); - const overlay = arrayOverlay(node); - const insertedSources: InsertSource[] = []; - const insertedIndices: number[] = []; - const insertedValues: unknown[] = []; - const baseValues: unknown[] = new Array((node.base as JsonValue[]).length); - const order: number[] = []; - for (const piece of piecesOf(overlay)) { - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - if (piece.kind === "base") { - order.push(sourceIndex); - baseValues[sourceIndex] = publicSortValue(node, sourceIndex, insertedSources, insertedIndices); - } else { - insertedSources.push(piece.source); - insertedIndices.push(sourceIndex); - const token = -insertedSources.length; - order.push(token); - insertedValues.push(publicSortValue(node, token, insertedSources, insertedIndices)); - } - } - } - const baseSnapshot = new Map(overlay.baseOverrides); - const insertSnapshots = new Map>(); - for (const [source, overrides] of overlay.insertOverrides) insertSnapshots.set(source, new Map(overrides)); - const generation = overlay.generation; - order.sort((left, right) => { - const leftValue = left < 0 ? insertedValues[-left - 1] : baseValues[left]; - const rightValue = right < 0 ? insertedValues[-right - 1] : baseValues[right]; - if (typeof comparator === "function") - return Number(Reflect.apply(comparator, undefined, [leftValue, rightValue])); - const a = String(leftValue); - const b = String(rightValue); - return a < b ? -1 : a > b ? 1 : 0; - }); - if ( - baseSnapshot.size > 0 || - insertSnapshots.size > 0 || - overlay.baseOverrides.size > 0 || - overlay.insertOverrides.size > 0 - ) { - for (const token of order) { - restoreSortOverride(overlay, token, insertedSources, insertedIndices, baseSnapshot, insertSnapshots); - } - } - const comparatorWasStructural = overlay.generation !== generation; - const currentLength = arrayLength(overlay); - const samePrefix = - currentLength >= order.length && - order.every((token, index) => sameSortTokenAt(overlay, index, token, insertedSources, insertedIndices)); - if (!samePrefix) { - replacePieceRange( - node, - 0, - Math.min(order.length, currentLength), - piecesFromSortOrder(order, insertedSources, insertedIndices), - ); - } - if (comparatorWasStructural) deduplicateArrayEntries(node); - return node.proxy; - }, - fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); - const length = arrayLength(arrayOverlay(node)); - const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - if (end <= start) return node.proxy; - const items = Array.from({ length: end - start }, () => clonePlacement(supplied)); - replacePieceRange(node, start, end - start, insertPiece(items)); - return node.proxy; - }, - copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { - const node = mutatorNode(this); - if (node === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); - const length = arrayLength(arrayOverlay(node)); - const target = clampIndex(toIntegerOrInfinity(targetArg), length); - const start = clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - const count = Math.min(Math.max(end - start, 0), length - target); - const values = Array.from({ length: count }, (_, offset) => - clonePlacement(getProperty(node, String(start + offset))), - ); - replacePieceRange(node, target, count, insertPiece(values)); - return node.proxy; - }, -}; - -function sortTokenSource(token: number, sources: readonly InsertSource[]): InsertSource | undefined { - return token < 0 ? sources[-token - 1] : undefined; -} - -function sortTokenIndex(token: number, indices: readonly number[]): number { - return token < 0 ? indices[-token - 1]! : token; -} - -function publicSortValue( - node: OverlayNode, - token: number, - sources: readonly InsertSource[], - indices: readonly number[], -): unknown { - const overlay = arrayOverlay(node); - const source = sortTokenSource(token, sources); - const sourceIndex = sortTokenIndex(token, indices); - const value = - source === undefined - ? overlay.baseOverrides.has(sourceIndex) - ? overlay.baseOverrides.get(sourceIndex)! - : (node.base as JsonValue[])[sourceIndex]! - : (overlay.insertOverrides.get(source)?.has(sourceIndex) ?? false) - ? overlay.insertOverrides.get(source)!.get(sourceIndex)! - : source.values[sourceIndex]!; - if (!isContainer(value)) return value; - return createNode( - node.context, - value, - node, - source === undefined ? 1 : 2, - sourceIndex, - source, - value, - source !== undefined || overlay.baseOverrides.has(sourceIndex), - ).proxy; -} - -function restoreSortOverride( - overlay: ArrayOverlay, - token: number, - sources: readonly InsertSource[], - indices: readonly number[], - baseSnapshot: ReadonlyMap, - insertSnapshots: ReadonlyMap>, -): void { - const source = sortTokenSource(token, sources); - const sourceIndex = sortTokenIndex(token, indices); - if (source === undefined) { - if (baseSnapshot.has(sourceIndex)) overlay.baseOverrides.set(sourceIndex, baseSnapshot.get(sourceIndex)!); - else overlay.baseOverrides.delete(sourceIndex); - return; - } - let overrides = overlay.insertOverrides.get(source); - const snapshot = insertSnapshots.get(source); - if (snapshot?.has(sourceIndex)) { - if (overrides === undefined) { - overrides = new Map(); - overlay.insertOverrides.set(source, overrides); - } - overrides.set(sourceIndex, snapshot.get(sourceIndex)!); - } else { - overrides?.delete(sourceIndex); - if (overrides?.size === 0) overlay.insertOverrides.delete(source); - } -} - -function sameSortTokenAt( - overlay: ArrayOverlay, - logicalIndex: number, - token: number, - sources: readonly InsertSource[], - indices: readonly number[], -): boolean { - const piece = locatePiece(overlay, logicalIndex); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - const tokenSource = sortTokenSource(token, sources); - return ( - sourceIndex === sortTokenIndex(token, indices) && - ((piece.kind === "base" && tokenSource === undefined) || - (piece.kind === "insert" && piece.source === tokenSource)) - ); -} - -function deduplicateArrayEntries(node: OverlayNode): void { - const overlay = arrayOverlay(node); - const seenBase = new Set(); - const seenInsert = new Map>(); - const next: Piece[] = []; - let duplicated = false; - for (const piece of piecesOf(overlay)) { - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - let seen: boolean; - if (piece.kind === "base") { - seen = seenBase.has(sourceIndex); - seenBase.add(sourceIndex); - } else { - let indices = seenInsert.get(piece.source); - if (indices === undefined) { - indices = new Set(); - seenInsert.set(piece.source, indices); - } - seen = indices.has(sourceIndex); - indices.add(sourceIndex); - } - if (seen) { - duplicated = true; - appendMergedPiece( - next, - insertPiece([cloneStored(entryValueAt(node, piece, sourceIndex), node.context)])[0]!, - ); - } else appendMergedPiece(next, singletonPiece(piece, sourceIndex)); - } - } - if (!duplicated) return; - replaceAllPieces(overlay, next); - overlay.structural = true; - overlay.generation += 1; - overlay.plan = undefined; - markDirty(node); -} - -function piecesFromSortOrder( - order: readonly number[], - sources: readonly InsertSource[], - indices: readonly number[], -): Piece[] { - const pieces: Piece[] = []; - for (const token of order) { - const source = token < 0 ? sources[-token - 1] : undefined; - const sourceIndex = token < 0 ? indices[-token - 1]! : token; - const previous = pieces.at(-1); - const sameSource = - previous !== undefined && - ((source === undefined && previous.kind === "base") || - (source !== undefined && previous.kind === "insert" && previous.source === source)); - if (previous !== undefined && sameSource) { - if (previous.length === 1) { - const step = sourceIndex - previous.start; - if (step === 1 || step === -1) { - previous.step = step; - previous.length = 2; - continue; - } - } else if (previous.start + previous.step * previous.length === sourceIndex) { - previous.length += 1; - continue; - } - } - pieces.push( - source === undefined - ? { kind: "base", start: sourceIndex, length: 1, step: 1 } - : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }, - ); - } - return pieces; -} - -function appendMergedPiece(pieces: Piece[], piece: Piece): void { - const previous = pieces.at(-1); - const sameSource = - previous !== undefined && - previous.kind === piece.kind && - (previous.kind === "base" || previous.source === (piece as InsertPiece).source); - if (previous !== undefined && sameSource && previous.length === 1 && piece.length === 1) { - const step = piece.start - previous.start; - if (step === 1 || step === -1) { - previous.step = step; - previous.length = 2; - return; - } - } - if ( - previous !== undefined && - sameSource && - previous.step === piece.step && - previous.start + previous.step * previous.length === piece.start - ) { - previous.length += piece.length; - } else pieces.push(piece); -} - -function singletonPiece(piece: Piece, sourceIndex: number): Piece { - return piece.kind === "base" - ? { kind: "base", start: sourceIndex, length: 1, step: 1 } - : { kind: "insert", source: piece.source, start: sourceIndex, length: 1, step: 1 }; -} - -function clonePlacement(value: unknown): Stored { - const proxyNode = isContainer(value) ? (Reflect.get(value, NODE) as OverlayNode | undefined) : undefined; - return proxyNode === undefined ? cloneJson(value) : cloneNode(proxyNode); -} - -function cloneJson(value: unknown): Stored { - if (!isContainer(value)) return value as Primitive; - if (Array.isArray(value)) return value.map(cloneJson); - const result = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record)[key])); - return result; -} - -function cloneNode(node: OverlayNode): Container { - assertReadable(node.context); - if (Array.isArray(node.base)) { - const overlay = arrayOverlay(node); - const result: JsonValue[] = []; - for (const piece of piecesOf(overlay)) { - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); - } - } - return result; - } - const result = Object.create(Object.getPrototypeOf(node.base)) as Record; - for (const key of ownKeys(node) as string[]) - defineData(result, key, cloneStored(objectValue(node, key), node.context)); - return result; -} - -function cloneStored(value: Stored, context: OverlayContext): Stored { - if (!isContainer(value)) return value; - const node = context.rawNodes.get(value); - return node === undefined ? cloneJson(value) : cloneNode(node); -} - -function defineData(target: object, key: PropertyKey, value: JsonValue): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function emitOperations(context: OverlayContext): Op[] { - const operations: Op[] = []; - const forcedFolds = new Map(); - const emissionPaths = new Map(); - const denseIndices = new Map(); - for (const node of context.dirty) { - recordDenseArrayPosition(node, denseIndices); - if (Array.isArray(node.base)) { - const overlay = arrayOverlay(node); - if (!overlay.structural && overlay.baseOverrides.size > 0) { - let candidates = denseIndices.get(node); - if (candidates === undefined) { - candidates = { indices: [], bits: undefined, length: (node.base as JsonValue[]).length }; - denseIndices.set(node, candidates); - } - for (const index of overlay.baseOverrides.keys()) addDenseCandidate(candidates, index); - } - } - } - const denseRegions = new Map(); - for (const [array, candidates] of denseIndices) { - const path = resolvePath(array); - if (path === undefined || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { - continue; - } - const regions = buildDenseRegions(candidates); - if (regions.length > 0) denseRegions.set(array, regions); - } - for (const node of context.dirty) { - const path = resolvePath(node); - if (path === undefined) continue; - node.applyDepth = path.length; - if (hasCoveringDenseRegion(node, path, denseRegions)) continue; - emissionPaths.set(node, path); - if (!Array.isArray(node.base) && hasReservedMutation(node)) forcedFolds.set(node, path); - const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); - if (reservedAt >= 0) { - let ancestor = node; - for (let depth = path.length; depth > reservedAt; depth--) ancestor = ancestor.parent!; - forcedFolds.set(ancestor, path.slice(0, reservedAt)); - } - } - for (const [node, path] of forcedFolds) emissionPaths.set(node, path); - for (const node of denseRegions.keys()) { - const path = resolvePath(node); - if (path !== undefined && !hasCoveringDenseRegion(node, path, denseRegions)) emissionPaths.set(node, path); - } - let maxDepth = 0; - for (const path of emissionPaths.values()) maxDepth = Math.max(maxDepth, path.length); - const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); - for (const [node, path] of emissionPaths) buckets[path.length]!.push(node); - const folded = new Set(); - for (const bucket of buckets) { - for (const node of bucket) { - const path = emissionPaths.get(node)!; - if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) continue; - if (forcedFolds.has(node)) { - emitSet(operations, path, cloneNode(node)); - folded.add(node); - continue; - } - if (Array.isArray(node.base)) emitArrayOperations(node, path, operations, denseRegions.get(node)); - else emitObjectOperations(node, path, operations); - if (operations.length > MAX_DELTA_OPERATIONS) { - locatedDenseArray = undefined; - return [["r", cloneNode(context.root!)]]; - } - } - } - locatedDenseArray = undefined; - return operations; -} - -let locatedDenseArray: OverlayNode | undefined; -let locatedDenseIndex = 0; - -function locateDenseArrayPosition(node: OverlayNode): boolean { - let child = node; - while (child.parent !== undefined) { - const parent = child.parent; - if (Array.isArray(parent.base)) { - const overlay = arrayOverlay(parent); - if (child.parentKind !== 1 || overlay.structural) return false; - const sourceIndex = child.parentKey as number; - const current = overlay.baseOverrides.has(sourceIndex) - ? overlay.baseOverrides.get(sourceIndex) - : (parent.base as JsonValue[])[sourceIndex]; - if (current !== child.parentExpected) return false; - locatedDenseArray = parent; - locatedDenseIndex = sourceIndex; - return true; - } - child = parent; - } - return false; -} - -function hasCoveringDenseRegion( - node: OverlayNode, - path: Path, - denseRegions: ReadonlyMap, -): boolean { - for (let parent = node.parent; parent !== undefined; parent = parent.parent) { - const regions = denseRegions.get(parent); - if (regions === undefined) continue; - const parentPath = resolvePath(parent); - if (parentPath === undefined || path.length <= parentPath.length) continue; - let matches = true; - for (let index = 0; index < parentPath.length; index++) { - if (path[index] !== parentPath[index]) { - matches = false; - break; - } - } - if (!matches) continue; - const index = path[parentPath.length]; - if (typeof index === "number" && regionContaining(regions, index)) return true; - } - return false; -} - -function addDenseCandidate(candidates: DenseCandidates, index: number): void { - if (candidates.bits !== undefined) { - candidates.bits[index] = 1; - return; - } - candidates.indices.push(index); - if (candidates.indices.length < 256) return; - candidates.bits = new Uint8Array(candidates.length); - for (const existing of candidates.indices) candidates.bits[existing] = 1; - candidates.indices.length = 0; -} - -function recordDenseArrayPosition(node: OverlayNode, groups: Map): void { - if (!locateDenseArrayPosition(node)) return; - let candidates = groups.get(locatedDenseArray!); - if (candidates === undefined) { - candidates = { indices: [], bits: undefined, length: (locatedDenseArray!.base as JsonValue[]).length }; - groups.set(locatedDenseArray!, candidates); - } - addDenseCandidate(candidates, locatedDenseIndex); -} - -function buildDenseRegions(candidates: DenseCandidates): DenseRegion[] { - if (candidates.bits === undefined) return []; - const regions: DenseRegion[] = []; - const bits = candidates.bits; - for (let at = 0; at < bits.length; ) { - while (at < bits.length && bits[at] === 0) at += 1; - if (at === bits.length) break; - const start = at; - let end = at; - let count = 0; - let gap = 0; - while (at < bits.length) { - if (bits[at] !== 0) { - count += 1; - end = at; - gap = 0; - } else if (++gap > 1) break; - at += 1; - } - const length = end - start + 1; - if (count >= 256 && count * 2 >= length) regions.push({ start, length }); - } - return regions; -} - -function regionContaining(regions: readonly DenseRegion[], index: number): boolean { - for (const region of regions) if (index >= region.start && index < region.start + region.length) return true; - return false; -} - -function hasReservedMutation(node: OverlayNode): boolean { - if (node.writeKey !== undefined && RESERVED_SEGMENTS.has(node.writeKey)) return true; - if (node.deleteKey !== undefined && RESERVED_SEGMENTS.has(node.deleteKey)) return true; - for (const key of node.writes?.keys() ?? []) if (RESERVED_SEGMENTS.has(key)) return true; - for (const key of node.deletes ?? []) if (RESERVED_SEGMENTS.has(key)) return true; - return false; -} - -function hasFoldedAncestor(node: OverlayNode, folded: ReadonlySet): boolean { - for (let parent = node.parent; parent !== undefined; parent = parent.parent) if (folded.has(parent)) return true; - return false; -} - -function hasPlacementAncestor(node: OverlayNode): boolean { - for (let current: OverlayNode | undefined = node; current?.parent !== undefined; current = current.parent) { - if (current.parentPlacement) return true; - } - return false; -} - -function emitObjectWrite(node: OverlayNode, path: Path, operations: Op[], key: string, value: Stored): void { - const nextPath = [...path, key] as unknown as NonEmptyPath; - const before = Object.hasOwn(node.base, key) ? (node.base as Record)[key] : undefined; - emitChangedValue(operations, nextPath, before, cloneStored(value, node.context)); -} - -function emitObjectOperations(node: OverlayNode, path: Path, operations: Op[]): void { - if (node.writeKey !== undefined) emitObjectWrite(node, path, operations, node.writeKey, node.writeValue!); - for (const [key, value] of node.writes ?? []) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - emitObjectWrite(node, path, operations, key, value); - } - if (node.deleteKey !== undefined && Object.hasOwn(node.base, node.deleteKey)) { - operations.push(["d", [...path, node.deleteKey] as unknown as NonEmptyPath]); - } - for (const key of node.deletes ?? []) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - if (Object.hasOwn(node.base, key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); - } -} - -function buildArrayPlan(node: OverlayNode): ArrayPlan { - const overlay = arrayOverlay(node); - if (overlay.plan !== undefined) return overlay.plan; - const base = node.base as JsonValue[]; - const pieces = piecesOf(overlay); - const retained = new Uint8Array(base.length); - const targetBase: number[] = []; - for (const piece of pieces) { - if (piece.kind !== "base") continue; - for (let offset = 0; offset < piece.length; offset++) { - const index = piece.start + piece.step * offset; - retained[index] = 1; - targetBase.push(index); - } - } - const removeRuns: number[] = []; - for (let end = base.length; end > 0; ) { - if (retained[end - 1] !== 0) { - end -= 1; - continue; - } - let start = end - 1; - while (start > 0 && retained[start - 1] === 0) start -= 1; - removeRuns.push(start, end - start); - end = start; - } - const retainedBase: number[] = []; - for (let index = 0; index < retained.length; index++) if (retained[index] !== 0) retainedBase.push(index); - let permutation: number[] | undefined; - if (targetBase.some((value, index) => value !== retainedBase[index])) { - const positions = new Map(retainedBase.map((value, index) => [value, index])); - permutation = targetBase.map((value) => positions.get(value)!); - } - const insertRuns: number[] = []; - let logicalIndex = 0; - for (let pieceIndex = 0; pieceIndex < pieces.length; ) { - const piece = pieces[pieceIndex]!; - if (piece.kind === "base") { - logicalIndex += piece.length; - pieceIndex += 1; - continue; - } - const startPiece = pieceIndex; - while (pieceIndex < pieces.length && pieces[pieceIndex]!.kind === "insert") { - logicalIndex += pieces[pieceIndex]!.length; - pieceIndex += 1; - } - insertRuns.push(logicalIndex - rangeLength(pieces, startPiece, pieceIndex), startPiece, pieceIndex); - } - overlay.plan = { removeRuns, permutation, insertRuns }; - return overlay.plan; -} - -function rangeLength(pieces: readonly Piece[], start: number, end: number): number { - let length = 0; - for (let index = start; index < end; index++) length += pieces[index]!.length; - return length; -} - -function emitArrayOperations( - node: OverlayNode, - path: Path, - operations: Op[], - denseRegions: readonly DenseRegion[] | undefined, -): void { - const overlay = arrayOverlay(node); - const base = node.base as JsonValue[]; - for (const region of denseRegions ?? []) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - operations.push(["p", [...path], region.start, region.length, cloneArrayRegion(node, region)]); - } - if (overlay.structural) { - const plan = buildArrayPlan(node); - for (let index = 0; index < plan.removeRuns.length; index += 2) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - operations.push(["p", [...path], plan.removeRuns[index]!, plan.removeRuns[index + 1]!, []]); - } - if (plan.permutation !== undefined) operations.push(["m", [...path], plan.permutation.slice()]); - const pieces = piecesOf(overlay); - for (let run = 0; run < plan.insertRuns.length; run += 3) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - const logicalIndex = plan.insertRuns[run]!; - const items: JsonValue[] = []; - for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { - const piece = pieces[pieceIndex]! as InsertPiece; - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); - } - } - operations.push(["p", [...path], logicalIndex, 0, items]); - } - } - for (const [baseIndex, value] of overlay.baseOverrides) { - if (operations.length > MAX_DELTA_OPERATIONS) return; - const index = findEntryIndex(overlay, 1, baseIndex, undefined); - if (index === undefined || (denseRegions !== undefined && regionContaining(denseRegions, index))) continue; - emitChangedValue( - operations, - [...path, index] as unknown as NonEmptyPath, - base[baseIndex], - cloneStored(value, node.context), - ); - } -} - -function cloneArrayRegion(node: OverlayNode, region: DenseRegion): JsonValue[] { - const overlay = arrayOverlay(node); - const result: JsonValue[] = []; - for (let index = region.start; index < region.start + region.length; index++) { - const piece = locatePiece(overlay, index); - const sourceIndex = piece.start + piece.step * overlay.locatedOffset; - result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); - } - return result; -} - -function emitChangedValue(operations: Op[], path: NonEmptyPath, before: JsonValue | undefined, after: JsonValue): void { - if (!isContainer(after) && before === after) return; - if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return; - if (typeof before === "string" && typeof after === "string") { - if (after.startsWith(before)) { - if (after.length > before.length) operations.push(["a", path, after.slice(before.length)]); - return; - } - const shared = overlap(before, after, 65_536); - if (shared > 0) { - operations.push(["t", path, before.length - shared]); - if (after.length > shared) operations.push(["a", path, after.slice(shared)]); - return; - } - } - operations.push(["s", path, after]); -} - -function emitSet(operations: Op[], path: Path, value: JsonValue): void { - if (path.length === 0) operations.push(["r", value]); - else operations.push(["s", path as NonEmptyPath, value]); -} - -function resolvePath(node: OverlayNode): Path | undefined { - if (node.preparedPath !== undefined) return node.preparedPath; - if (node.parent === undefined) { - node.preparedPath = []; - return node.preparedPath; - } - const parentPath = resolvePath(node.parent); - if (parentPath === undefined) return undefined; - if (node.parentKind === 0) { - const key = node.parentKey as string; - if (!objectHas(node.parent, key) || objectValue(node.parent, key) !== node.parentExpected) return undefined; - node.preparedPath = [...parentPath, key]; - return node.preparedPath; - } - const overlay = arrayOverlay(node.parent); - const index = findEntryIndex(overlay, node.parentKind, node.parentKey as number, node.parentSource); - if (index === undefined) return undefined; - const piece = locatePiece(overlay, index); - if (entryValueAt(node.parent, piece, node.parentKey as number) !== node.parentExpected) return undefined; - node.preparedPath = [...parentPath, index]; - return node.preparedPath; -} - -function ensurePieceLocations(overlay: ArrayOverlay): void { - if (overlay.baseLocations !== undefined) return; - overlay.baseLocations = []; - overlay.insertLocations = new Map(); - let logicalStart = 0; - for (const piece of piecesOf(overlay)) { - const last = piece.start + piece.step * (piece.length - 1); - const location = { - piece, - logicalStart, - minimum: Math.min(piece.start, last), - maximum: Math.max(piece.start, last), - }; - if (piece.kind === "base") overlay.baseLocations.push(location); - else { - let locations = overlay.insertLocations.get(piece.source); - if (locations === undefined) { - locations = []; - overlay.insertLocations.set(piece.source, locations); - } - locations.push(location); - } - logicalStart += piece.length; - } - const byMinimum = (left: PieceLocation, right: PieceLocation): number => left.minimum - right.minimum; - overlay.baseLocations.sort(byMinimum); - for (const locations of overlay.insertLocations.values()) locations.sort(byMinimum); -} - -function findEntryIndex( - overlay: ArrayOverlay, - kind: ParentKind, - sourceIndex: number, - source: InsertSource | undefined, -): number | undefined { - ensurePieceLocations(overlay); - const locations = kind === 1 ? overlay.baseLocations! : overlay.insertLocations!.get(source!); - if (locations === undefined) return undefined; - let low = 0; - let high = locations.length; - while (low < high) { - const middle = (low + high) >>> 1; - if (locations[middle]!.minimum <= sourceIndex) low = middle + 1; - else high = middle; - } - const location = locations[low - 1]; - if (location === undefined || sourceIndex > location.maximum) return undefined; - const offset = (sourceIndex - location.piece.start) / location.piece.step; - return offset >= 0 && offset < location.piece.length ? location.logicalStart + offset : undefined; -} - -function ensureOperations(context: OverlayContext): Op[] { - if (context.ops !== undefined) return context.ops; - assertReadable(context); - if (context.replacement) { - const base = context.tracker.deref()?.replacementBase(context); - context.replacementNoop = - base !== undefined && equalTrustedJson(base as unknown as JsonValue, context.root!.base as JsonValue); - context.ops = context.replacementNoop ? [] : [["r", cloneNode(context.root!)]]; - } else context.ops = emitOperations(context); - return context.ops; -} - -function equalTrustedJson(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 (!equalTrustedJson(left[index]!, other[index]!)) return false; - return true; - } - const other = right as Record; - const keys = Object.keys(left); - if (keys.length !== Object.keys(other).length) return false; - for (const key of keys) if (!Object.hasOwn(other, key) || !equalTrustedJson(left[key]!, other[key]!)) return false; - return true; -} - -function applyOverlay(context: OverlayContext): void { - let maxDepth = 0; - for (const node of context.dirty) if (node.applyDepth !== undefined) maxDepth = Math.max(maxDepth, node.applyDepth); - const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); - for (const node of context.dirty) if (node.applyDepth !== undefined) buckets[node.applyDepth]!.push(node); - for (let depth = buckets.length - 1; depth >= 0; depth--) { - for (const node of buckets[depth]!) { - if (Array.isArray(node.base)) applyArrayNode(node); - else applyObjectNode(node); - } - } -} - -function applyObjectNode(node: OverlayNode): void { - const target = node.base as Record; - if (node.deleteKey !== undefined) Reflect.deleteProperty(target, node.deleteKey); - for (const key of node.deletes ?? []) Reflect.deleteProperty(target, key); - for (const key of node.readded ?? []) Reflect.deleteProperty(target, key); - if (node.writeKey !== undefined) applyObjectWrite(target, node, node.writeKey, node.writeValue!); - for (const [key, value] of node.writes ?? []) applyObjectWrite(target, node, key, value); -} - -function applyObjectWrite(target: Record, node: OverlayNode, key: string, value: JsonValue): void { - if (Object.hasOwn(target, key) && !node.readded?.has(key)) { - const current = target[key]!; - if (isContainer(current) && isContainer(value) && equalTrustedJson(current, value)) return; - target[key] = value; - } else defineData(target, key, value); -} - -function applyArrayNode(node: OverlayNode): void { - const target = node.base as JsonValue[]; - const overlay = arrayOverlay(node); - if (overlay.structural) { - const plan = buildArrayPlan(node); - for (let index = 0; index < plan.removeRuns.length; index += 2) { - target.splice(plan.removeRuns[index]!, plan.removeRuns[index + 1]!); - } - if (plan.permutation !== undefined) permuteInPlace(target, plan.permutation); - const pieces = piecesOf(overlay); - for (let run = 0; run < plan.insertRuns.length; run += 3) { - const items: JsonValue[] = []; - for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { - const piece = pieces[pieceIndex]! as InsertPiece; - for (let offset = 0; offset < piece.length; offset++) { - const sourceIndex = piece.start + piece.step * offset; - items.push(entryValueAt(node, piece, sourceIndex)); - } - } - insertChunked(target, plan.insertRuns[run]!, items); - } - } - for (const [baseIndex, value] of overlay.baseOverrides) { - const index = findEntryIndex(overlay, 1, baseIndex, undefined); - if (index === undefined) continue; - const current = target[index]!; - if (isContainer(current) && isContainer(value) && equalTrustedJson(current, value)) continue; - target[index] = value; - } -} - -function permuteInPlace(values: JsonValue[], permutation: readonly number[]): void { - const visited = new Uint8Array(permutation.length); - for (let start = 0; start < permutation.length; start++) { - if (visited[start] !== 0) continue; - let current = start; - const saved = values[start]!; - while (true) { - visited[current] = 1; - const source = permutation[current]!; - if (source === start) { - values[current] = saved; - break; - } - values[current] = values[source]!; - current = source; - } - } -} - -function insertChunked(target: JsonValue[], index: number, items: JsonValue[]): void { - for (let offset = 0; offset < items.length; offset += MAX_SPLICE_CHUNK) { - target.splice(index + offset, 0, ...items.slice(offset, offset + MAX_SPLICE_CHUNK)); - } -} - -function abortContext(context: OverlayContext): void { - if ( - context.status === "aborted" || - context.status === "consumed" || - context.status === "stale" || - context.status === "faulted" - ) - return; - context.status = "aborted"; - clearContext(context); -} - -function clearContext(context: OverlayContext): void { - context.tracker.deref()?.releaseContext(context); - if (locatedDenseArray?.context === context) locatedDenseArray = undefined; - for (const node of context.nodes) { - Reflect.deleteProperty(node.target, NODE); - node.writes?.clear(); - node.deletes?.clear(); - node.readded?.clear(); - node.array?.pieces?.splice(0); - if (node.array !== undefined) { - node.array.root = undefined; - node.array.baseLocations = undefined; - node.array.insertLocations = undefined; - } - node.array?.baseOverrides.clear(); - node.array?.insertOverrides.clear(); - node.base = RELEASED; - node.parent = undefined; - node.parentSource = undefined; - node.parentExpected = undefined; - node.target = RELEASED; - node.proxy = RELEASED; - node.writeKey = undefined; - node.writeValue = undefined; - node.writes = undefined; - node.deleteKey = undefined; - node.deletes = undefined; - node.readded = undefined; - node.array = undefined; - node.preparedPath = undefined; - node.applyDepth = undefined; - } - context.dirty.length = 0; - context.nodes.length = 0; - context.rawNodes = new WeakMap(); - context.root = undefined; - context.baseValue = undefined; -} - -function isContainer(value: unknown): value is Container { - return value !== null && typeof value === "object"; -} diff --git a/packages/chord/src/delta/cow/README.md b/packages/chord/src/delta/cow/README.md deleted file mode 100644 index 49e4d132d..000000000 --- a/packages/chord/src/delta/cow/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Trusted COW contract - -This variant provides immutable copy-on-write revisions without recursive input validation or deep freezing. - -- `track(initial)` and `prepareReplace(value)` take ownership. Inputs are alias-free, acyclic strict JSON made from dense arrays, plain data objects, finite numbers, strings, booleans, and `null`. Accessors, custom mutation hooks, symbols, classes, sparse arrays, and external proxies are outside the contract. -- Do not mutate transferred roots, `tracker.value`, `Prepared.base`, `Prepared.value`, or `Prepared.ops` externally. `Prepared.value` and `Prepared.ops` are independent immutable ownership transfers. -- Values assigned through a draft are cloned by value for each placement. Mutate transaction state only through its draft handles. -- Every change must settle exactly once through `prepare()` followed by `adopt()`, or through `abort()`. Draft handles are invalid after preparation or abortion. -- Array indices and mutator index arguments must be primitive numbers. Objects with coercion callbacks and mutations caused during argument coercion are outside the contract. -- Sort comparators must be synchronous and pure: they return ordinary numbers and do not mutate drafts, prepare transactions, or start nested operations. - -Violating this contract is programmer error and may not be detected at runtime. diff --git a/packages/chord/src/delta/cow/diff.ts b/packages/chord/src/delta/cow/diff.ts deleted file mode 100644 index e635a58fd..000000000 --- a/packages/chord/src/delta/cow/diff.ts +++ /dev/null @@ -1,562 +0,0 @@ -import type { JsonValue } from "../../types.ts"; -import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts"; -import type { DiffHints } from "./draft.ts"; - -const DEFAULT_OVERLAP_SCAN = 65_536; -const MAX_DELTA_OPERATIONS = 4_096; -const overflowedBatches = new WeakSet(); - -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 => - 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; - 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; - // Cheap native probes before building a map: shifted/appended arrays fail here. - if (before.indexOf(after[after.length - 1]!) === -1 || before.indexOf(after[0]!) === -1) return undefined; - // A single index per value; duplicates upgrade to an index list. - const positions = new Map(); - for (let index = 0; index < before.length; index++) { - const value = before[index]!; - const entry = positions.get(value); - if (entry === undefined) positions.set(value, index); - else if (typeof entry === "number") positions.set(value, [entry, index]); - else entry.push(index); - } - const used = new Map(); - const result = new Array(after.length); - for (let index = 0; index < after.length; index++) { - const value = after[index]!; - const entry = positions.get(value); - if (entry === undefined) return undefined; - if (typeof entry === "number") { - if (used.has(value)) return undefined; - used.set(value, 1); - result[index] = entry; - continue; - } - const taken = used.get(value) ?? 0; - if (taken === entry.length) return undefined; - used.set(value, taken + 1); - result[index] = entry[taken]!; - } - 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; - const rightObject = right as Record; - 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 indexOfIdentity = (values: readonly JsonValue[], value: JsonValue, start: number, end: number): number => { - if (!isContainer(value)) return -1; // primitives are not identity anchors - const at = values.indexOf(value, start); - return at === -1 || at >= end ? -1 : at; -}; - -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, - 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(); - 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[], - hints: DiffHints, - 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, hints, beforeAt, beforeMatch, afterAt, afterMatch, outputAt); - outputAt += afterMatch - afterAt; - if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) { - diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations, hints); - } - outputAt += 1; - beforeAt = beforeMatch + 1; - afterAt = afterMatch + 1; - } - if (!overflowedBatches.has(operations)) { - diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeEnd, afterAt, afterEnd, outputAt); - } -}; - -function diffArrayRegion( - before: JsonValue[], - after: JsonValue[], - path: Path, - operations: Op[], - hints: DiffHints, - 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) { - // Streaming positional alignment: equivalent to collecting positional - // matches and diffing the gaps between them, without a tuple per match. - let matched = false; - let gapStart = -1; - for (let offset = 0; offset <= beforeCount; offset++) { - const same = offset < beforeCount && sameValue(before[beforeStart + offset]!, after[afterStart + offset]!); - if (same) { - matched = true; - if (gapStart !== -1) { - if (overflowedBatches.has(operations)) return; - diffArrayRegion( - before, - after, - path, - operations, - hints, - beforeStart + gapStart, - beforeStart + offset, - afterStart + gapStart, - afterStart + offset, - outputStart + gapStart, - ); - gapStart = -1; - } - } else if (gapStart === -1) gapStart = offset; - } - if (matched) { - if (gapStart !== -1 && !overflowedBatches.has(operations)) { - diffArrayRegion( - before, - after, - path, - operations, - hints, - beforeStart + gapStart, - beforeEnd, - afterStart + gapStart, - afterEnd, - outputStart + gapStart, - ); - } - return; - } - } - - // Identity run fast paths (queue shift+push, unshift+pop). The run must be - // longer than both remainders so it is the unique longest anchor chain; the - // output then equals the LIS alignment below without a map or match list. - { - const k = indexOfIdentity(before, after[afterStart]!, beforeStart, beforeEnd); - if (k !== -1) { - let run = 0; - const limit = Math.min(beforeEnd - k, afterCount); - // Container-only runs: object identity is unique, so this run is the unique longest anchor chain. - while (run < limit && before[k + run] === after[afterStart + run] && isContainer(before[k + run]!)) run += 1; - if (k + run === beforeEnd && run > k - beforeStart && run > afterCount - run) { - if (k > beforeStart) emitOperation(operations, ["p", path, outputStart, k - beforeStart, []]); - if (run < afterCount) { - emitOperation(operations, ["p", path, outputStart + run, 0, after.slice(afterStart + run, afterEnd)]); - } - return; - } - } - const j = indexOfIdentity(after, before[beforeStart]!, afterStart, afterEnd); - if (j !== -1) { - let run = 0; - const limit = Math.min(afterEnd - j, beforeCount); - while (run < limit && before[beforeStart + run] === after[j + run] && isContainer(after[j + run]!)) run += 1; - if (j + run === afterEnd && run > j - afterStart && run > beforeCount - run) { - const inserted = j - afterStart; - if (inserted > 0) emitOperation(operations, ["p", path, outputStart, 0, after.slice(afterStart, j)]); - if (run < beforeCount) { - emitOperation(operations, ["p", path, outputStart + inserted + run, beforeCount - run, []]); - } - return; - } - } - } - - if (beforeCount === 1 && afterCount === 1) { - // Every alignment strategy below ends in diffValue for a 1x1 region. - diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints); - return; - } - - const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd); - if (subsequence !== undefined && subsequence.length > 0) { - processArrayMatches( - before, - after, - path, - operations, - hints, - 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, - hints, - 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, - hints, - beforeStart, - beforeEnd, - afterStart, - afterEnd, - outputStart, - absolute, - ); - return; - } - - if (beforeCount === 1 && afterCount === 1) { - diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints); - return; - } - emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]); -} - -const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[], hints: DiffHints): void => { - if (before === after) return; - const hint = hints.get(after); - if (hint?.base === before && hint.keys.length < 2_048) { - for (const index of hint.keys) diffValue(before[index]!, after[index]!, [...path, index], operations, hints); - return; - } - if (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, hints, 0, before.length, 0, after.length, 0); -}; - -const diffObject = ( - before: Record, - after: Record, - path: Path, - operations: Op[], - hints: DiffHints, -): 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, hints); - 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[], hints: DiffHints): 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, hints); - return; - } - if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) { - diffObject(before, after, path, operations, hints); - return; - } - emitSet(path, after, operations); -}; - -/** Compute a normalized batch, with deterministic operation-count safety folding only. */ -export function diffRevisions(before: JsonValue, after: JsonValue, hints: DiffHints = new WeakMap()): Op[] { - const operations: Op[] = []; - diffValue(before, after, [], operations, hints); - if (overflowedBatches.has(operations)) return [["r", after]]; - return operations; -} diff --git a/packages/chord/src/delta/cow/draft.ts b/packages/chord/src/delta/cow/draft.ts deleted file mode 100644 index 75d966de5..000000000 --- a/packages/chord/src/delta/cow/draft.ts +++ /dev/null @@ -1,717 +0,0 @@ -import type { JsonValue } from "../../types.ts"; - -/** Mutable transaction-scoped view of a JSON value, preserving tuple positions. */ -export type Draft = 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; - -type Container = Record | unknown[]; -type ChildKey = string | number; - -export type ArrayDiffHint = { - readonly base: JsonValue[]; - readonly keys: readonly number[]; -}; - -export type DiffHints = WeakMap; - -export type ProduceMetadata = { - value: T; - hints: DiffHints; -}; - -type DraftContext = { - active: boolean; - states: WeakMap; - created: DraftState[]; - hints: DiffHints; -}; - -type ChildIndex = DraftState | Map; -type ChangedIndex = number | Set; - -class DraftState { - context: DraftContext; - base: Container; - copy: Container | undefined; - parent: DraftState | undefined; - key: ChildKey | undefined; - proxy: object; - target: object; - dirty = false; - structural = false; - children: ChildIndex | undefined; - changedIndices: ChangedIndex | undefined; - finalized: Container | undefined; - - constructor(context: DraftContext, base: Container, parent: DraftState | undefined, key: ChildKey | undefined) { - this.context = context; - this.base = base; - this.copy = undefined; - this.parent = parent; - this.key = key; - this.proxy = this; - this.target = this; - this.children = undefined; - this.changedIndices = undefined; - this.finalized = undefined; - } -} - -const DRAFT_STATE = Symbol("cow draft state"); -const RELEASED: Record = {}; -const MAX_NATIVE_ARRAY_INSERT_ITEMS = 10_000; -const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); - -export type DraftTransaction = { - readonly state: Draft; - finish(): ProduceMetadata; - abort(): void; -}; - -export function createDraftTransaction(base: T): DraftTransaction { - const context: DraftContext = { - active: true, - states: new WeakMap(), - created: [], - hints: new WeakMap(), - }; - const root = stateFor(context, base as Container, undefined, undefined); - const release = (): void => { - assertActive(context); - context.active = false; - for (const state of context.created) { - state.base = RELEASED; - state.copy = undefined; - state.parent = undefined; - state.key = undefined; - state.proxy = RELEASED; - state.target = RELEASED; - state.children = undefined; - state.changedIndices = undefined; - state.finalized = undefined; - } - context.created.length = 0; - context.states = new WeakMap(); - }; - return { - state: root.proxy as Draft, - finish() { - assertActive(context); - try { - return { value: finalize(root) as T, hints: context.hints }; - } finally { - release(); - } - }, - abort: release, - }; -} - -/** Compatibility helper for synchronous COW recipes. */ -export function produceWithMetadata(base: T, recipe: (draft: Draft) => void): ProduceMetadata { - const transaction = createDraftTransaction(base); - try { - const outcome = (recipe as (draft: Draft) => unknown)(transaction.state); - if (isPromiseLike(outcome)) { - void Promise.resolve(outcome).catch(() => undefined); - throw new TypeError("Replicated state change callbacks must be synchronous"); - } - return transaction.finish(); - } catch (error) { - try { - transaction.abort(); - } catch { - // finish() already released the transaction. - } - throw error; - } -} - -export function produce(base: T, recipe: (draft: Draft) => void): T { - return produceWithMetadata(base, recipe).value; -} - -function stateFor( - context: DraftContext, - base: Container, - parent: DraftState | undefined, - key: ChildKey | undefined, -): DraftState { - const existing = context.states.get(base); - if (existing !== undefined) return existing; - const state = new DraftState(context, base, parent, key); - const target: object = Array.isArray(base) ? [] : state; - if (Array.isArray(base)) { - (target as unknown[]).length = base.length; - Object.defineProperty(target, DRAFT_STATE, { value: state, configurable: true }); - } - state.target = target; - state.proxy = new Proxy(target, SHARED_HANDLER); - context.states.set(base, state); - context.created.push(state); - return state; -} - -const SHARED_HANDLER: ProxyHandler = { - deleteProperty(target, property): boolean { - const state = targetState(target); - assertWritable(state, property); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(state.base)) throw new TypeError("Draft arrays cannot contain holes"); - return deleteObjectProperty(state, property); - }, - defineProperty(target): never { - assertActive(targetState(target).context); - throw new TypeError("Defining draft properties is not supported"); - }, - get(target, property): unknown { - const state = targetState(target); - assertActive(state.context); - if (property === DRAFT_STATE) return state; - if (Array.isArray(state.base) && typeof property === "string" && ARRAY_MUTATORS.has(property)) { - return MUTATORS[property as keyof typeof MUTATORS]; - } - const value = Reflect.get(currentValue(state), property, state.proxy); - return draftValue(state.context, value, state, childKey(property)); - }, - getOwnPropertyDescriptor(target, property): PropertyDescriptor | undefined { - const state = targetState(target); - assertActive(state.context); - const descriptor = Reflect.getOwnPropertyDescriptor(currentValue(state), property); - if (descriptor === undefined) return undefined; - if (Array.isArray(state.base) && property === "length") { - syncArrayTarget(state); - return Reflect.getOwnPropertyDescriptor(state.target, property); - } - return { - configurable: true, - enumerable: descriptor.enumerable, - writable: true, - value: - "value" in descriptor ? draftValue(state.context, descriptor.value, state, childKey(property)) : undefined, - }; - }, - getPrototypeOf(target): object | null { - const state = targetState(target); - assertActive(state.context); - return Object.getPrototypeOf(state.base); - }, - has(target, property): boolean { - const state = targetState(target); - assertActive(state.context); - return Reflect.has(currentValue(state), property); - }, - isExtensible(target): boolean { - assertActive(targetState(target).context); - return true; - }, - ownKeys(target): ArrayLike { - const state = targetState(target); - assertActive(state.context); - return Reflect.ownKeys(currentValue(state)); - }, - preventExtensions(target): never { - assertActive(targetState(target).context); - throw new TypeError("Drafts cannot be made non-extensible"); - }, - set(target, property, value): boolean { - const state = targetState(target); - assertWritable(state, property); - return writeProperty(state, property, value); - }, - setPrototypeOf(target): never { - assertActive(targetState(target).context); - throw new TypeError("Changing a draft prototype is not supported"); - }, -}; - -function targetState(target: object): DraftState { - return Array.isArray(target) - ? (target as unknown as Record)[DRAFT_STATE]! - : (target as DraftState); -} - -function childKey(property: PropertyKey): ChildKey | undefined { - if (typeof property === "symbol") return undefined; - const index = arrayIndex(property); - return index ?? property; -} - -function draftState(value: unknown): DraftState | undefined { - if (!isContainer(value)) return undefined; - return (value as Record)[DRAFT_STATE]; -} - -function draftValue(context: DraftContext, value: unknown, parent?: DraftState, key?: ChildKey): unknown { - if (!isContainer(value)) return value; - const existingDraft = draftState(value); - if (existingDraft !== undefined) return value; - return stateFor(context, value, parent, key).proxy; -} - -function currentValue(state: DraftState): Container { - assertActive(state.context); - return state.copy ?? state.base; -} - -function ensureCopy(state: DraftState): Container { - markChanged(state); - if (state.copy !== undefined) return state.copy; - state.copy = shallowCopy(state.base); - return state.copy; -} - -function shallowCopy(value: Container): Container { - if (Array.isArray(value)) return Array.from(value); - if (Object.getPrototypeOf(value) === null) - return Object.assign(Object.create(null) as Record, value); - return { ...value }; -} - -function markChanged(state: DraftState): void { - let current: DraftState | undefined = state; - while (current !== undefined && !current.dirty) { - current.dirty = true; - const parent: DraftState | undefined = current.parent; - if (parent !== undefined && current.key !== undefined) registerChild(parent, current.key, current); - current = parent; - } -} - -function registerChild(parent: DraftState, key: ChildKey, child: DraftState): void { - const children = parent.children; - if (children === undefined) parent.children = child; - else if (children instanceof Map) children.set(key, child); - else if (children !== child) - parent.children = new Map([ - [children.key!, children], - [key, child], - ]); - if (Array.isArray(parent.base) && typeof key === "number") markArrayIndex(parent, key); -} - -function markArrayIndex(state: DraftState, index: number): void { - const changed = state.changedIndices; - if (changed === undefined) state.changedIndices = index; - else if (changed instanceof Set) changed.add(index); - else if (changed !== index) state.changedIndices = new Set([changed, index]); -} - -function writeProperty(state: DraftState, property: string | symbol, supplied: unknown): boolean { - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); - if (Array.isArray(state.base)) { - if (property === "length") return writeArrayLength(state, supplied); - const index = arrayIndex(property); - if (index === undefined) throw new TypeError("Only array indices and length can be written"); - const current = currentValue(state) as unknown[]; - if (index > current.length) throw new TypeError("Draft arrays cannot contain holes"); - const stored = clonePlacement(supplied, state.context); - if (index < current.length && Object.is(current[index], stored)) return true; - if (index < current.length) detachCurrentChild(state, current[index]); - const copy = ensureCopy(state) as unknown[]; - defineData(copy, String(index), stored); - if (index === current.length) state.structural = true; - else markArrayIndex(state, index); - syncArrayTarget(state); - return true; - } - if (supplied === undefined) return deleteObjectProperty(state, property); - const stored = clonePlacement(supplied, state.context); - const current = currentValue(state) as Record; - if (Object.hasOwn(current, property) && !isContainer(stored) && Object.is(current[property], stored)) return true; - if (Object.hasOwn(current, property)) detachCurrentChild(state, current[property]); - const copy = ensureCopy(state) as Record; - defineData(copy, property, stored); - return true; -} - -function writeArrayLength(state: DraftState, supplied: unknown): boolean { - const probe: unknown[] = []; - Reflect.set(probe, "length", supplied); - const next = probe.length; - const current = (currentValue(state) as unknown[]).length; - if (next === current) return true; - const copy = ensureCopy(state) as unknown[]; - if (next < current) copy.length = next; - else { - copy.length = next; - for (let index = current; index < next; index++) copy[index] = null; - } - state.structural = true; - syncArrayTarget(state); - return true; -} - -function deleteObjectProperty(state: DraftState, property: string): boolean { - const current = currentValue(state); - if (!Object.hasOwn(current, property)) return true; - detachCurrentChild(state, (current as Record)[property]); - Reflect.deleteProperty(ensureCopy(state), property); - return true; -} - -function detachCurrentChild(parent: DraftState, value: unknown): void { - if (!isContainer(value)) return; - const child = draftState(value) ?? parent.context.states.get(value); - if (child?.parent !== parent) return; - const children = parent.children; - if (children === child) parent.children = undefined; - else if (children instanceof Map && child.key !== undefined && children.get(child.key) === child) { - children.delete(child.key); - } - child.parent = undefined; - child.key = undefined; -} - -function arrayIndex(property: PropertyKey): number | undefined { - if (typeof property !== "string" || property === "") return undefined; - const index = Number(property); - if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined; - return index; -} - -function mutatorState(receiver: unknown): DraftState | undefined { - if (!isContainer(receiver)) return undefined; - return draftState(receiver); -} - -const MUTATORS = { - push(this: unknown, ...args: unknown[]): number { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.push, this, args) as number; - assertActive(state.context); - if (args.length === 0) return (currentValue(state) as unknown[]).length; - const target = ensureCopy(state) as unknown[]; - for (const item of cloneItems(args, state.context)) defineData(target, String(target.length), item); - state.structural = true; - syncArrayTarget(state); - return target.length; - }, - pop(this: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.pop, this, []); - assertActive(state.context); - const current = currentValue(state) as unknown[]; - if (current.length === 0) return undefined; - const target = ensureCopy(state) as unknown[]; - const value = target[target.length - 1]; - target.length -= 1; - state.structural = true; - syncArrayTarget(state); - return draftValue(state.context, value, state, target.length); - }, - shift(this: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.shift, this, []); - assertActive(state.context); - const current = currentValue(state) as unknown[]; - if (current.length === 0) return undefined; - const target = ensureCopy(state) as unknown[]; - const value = Reflect.apply(Array.prototype.shift, target, []); - state.structural = true; - syncArrayTarget(state); - return draftValue(state.context, value, state, 0); - }, - unshift(this: unknown, ...args: unknown[]): number { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.unshift, this, args) as number; - assertActive(state.context); - if (args.length === 0) return (currentValue(state) as unknown[]).length; - const target = ensureCopy(state) as unknown[]; - const items = cloneItems(args, state.context); - insertChunked(target, 0, items); - state.structural = true; - syncArrayTarget(state); - return target.length; - }, - splice(this: unknown, ...args: unknown[]): unknown[] { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; - assertActive(state.context); - const length = (currentValue(state) as unknown[]).length; - const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - start - : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); - const inputs = cloneItems(args.slice(2), state.context); - if (remove === 0 && inputs.length === 0) return []; - const target = ensureCopy(state) as unknown[]; - const removed = spliceCapturedLength(target, start, remove, inputs, length); - state.structural = true; - syncArrayTarget(state); - return removed.map((value, offset) => draftValue(state.context, value, state, start + offset)); - }, - sort(this: unknown, comparator?: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); - assertActive(state.context); - if (comparator !== undefined && typeof comparator !== "function") - throw new TypeError("Comparator must be a function"); - const target = ensureCopy(state) as unknown[]; - state.structural = true; - Reflect.apply(Array.prototype.sort, target, [ - (left: unknown, right: unknown): number => { - const draftedLeft = draftValue(state.context, left, state); - const draftedRight = draftValue(state.context, right, state); - if (typeof comparator === "function") { - // Array.prototype.sort performs ToNumber on the return value. Returning - // it directly preserves native bigint TypeError behavior. - return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number; - } - const leftString = String(draftedLeft); - const rightString = String(draftedRight); - return leftString < rightString ? -1 : leftString > rightString ? 1 : 0; - }, - ]); - syncArrayTarget(state); - return state.proxy; - }, - reverse(this: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.reverse, this, []); - assertActive(state.context); - const target = ensureCopy(state) as unknown[]; - if (target.length > 1) { - Reflect.apply(Array.prototype.reverse, target, []); - state.structural = true; - } - return state.proxy; - }, - fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); - assertActive(state.context); - const length = (currentValue(state) as unknown[]).length; - const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - if (end <= start) return state.proxy; - const target = ensureCopy(state) as unknown[]; - for (let index = start; index < end; index++) target[index] = clonePlacement(supplied, state.context); - state.structural = true; - return state.proxy; - }, - copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { - const state = mutatorState(this); - if (state === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); - assertActive(state.context); - const length = (currentValue(state) as unknown[]).length; - const targetIndex = clampIndex(toIntegerOrInfinity(targetArg), length); - const start = clampIndex(toIntegerOrInfinity(startArg), length); - const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); - const count = Math.min(Math.max(end - start, 0), length - targetIndex); - if (count === 0) return state.proxy; - const source = (currentValue(state) as unknown[]).slice(start, start + count); - const target = ensureCopy(state) as unknown[]; - for (let offset = 0; offset < count; offset++) - target[targetIndex + offset] = cloneLogical(source[offset], state.context); - state.structural = true; - return state.proxy; - }, -}; - -function cloneItems(values: readonly unknown[], context: DraftContext): unknown[] { - return values.map((value) => clonePlacement(value, context)); -} - -function clonePlacement(value: unknown, _context: DraftContext): unknown { - if (!isContainer(value)) return value; - const state = draftState(value); - return state === undefined ? cloneJson(value) : cloneState(state); -} - -/** Fast trusted strict-JSON clone. Inputs are caller-guaranteed plain, dense, acyclic trees. */ -export function cloneJson(value: T): T { - if (!isContainer(value)) return value; - if (Array.isArray(value)) return value.map((child) => cloneJson(child)) as T; - const result = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record)[key])); - return result as T; -} - -function cloneState(state: DraftState): Container { - assertActive(state.context); - const context = state.context; - const current = state.copy ?? state.base; - if (Array.isArray(current)) { - const result = new Array(current.length); - for (let index = 0; index < current.length; index++) result[index] = cloneLogical(current[index], context); - return result; - } - const result = Object.create(Object.getPrototypeOf(current)) as Record; - for (const key of Object.keys(current)) defineData(result, key, cloneLogical(current[key], context)); - return result; -} - -function cloneLogical(value: unknown, context: DraftContext): unknown { - if (!isContainer(value)) return value; - const proxyState = draftState(value); - if (proxyState !== undefined) return cloneState(proxyState); - const state = context.states.get(value); - return state === undefined ? cloneJson(value) : cloneState(state); -} - -function finalize(state: DraftState): Container { - if (state.finalized !== undefined) return state.finalized; - if (!state.dirty) return state.base; - const current = state.copy ?? state.base; - let result = current; - if (Array.isArray(current) && state.structural) { - for (let index = 0; index < current.length; index++) { - const value = current[index]; - if (!isContainer(value)) continue; - const child = draftState(value) ?? state.context.states.get(value); - if (child === undefined || !child.dirty) continue; - const next = finalize(child); - if (next === value) continue; - if (result === state.base) result = shallowCopy(state.base); - (result as unknown[])[index] = next; - } - } else { - forEachDirtyChild(state, (key, child) => { - const value = (current as Record)[key]; - if (value !== child.base && draftState(value) !== child) return; - const next = finalize(child); - if (next === value) return; - if (result === state.base) result = shallowCopy(state.base); - defineData(result, String(key), next); - if (Array.isArray(current) && typeof key === "number") markArrayIndex(state, key); - }); - } - if (result !== state.base && shallowEqual(state.base, result)) result = state.base; - state.finalized = result; - if ( - Array.isArray(state.base) && - Array.isArray(result) && - result !== state.base && - !state.structural && - state.base.length === result.length - ) { - const base = state.base as unknown[]; - const output = result as unknown[]; - const changed = changedIndexList(state); - if (changed !== undefined) { - const keys = changed.filter((index) => index < base.length && index < output.length); - state.context.hints.set(result, { base: state.base as JsonValue[], keys }); - } - } - return result; -} - -function forEachDirtyChild(state: DraftState, visit: (key: ChildKey, child: DraftState) => void): void { - const children = state.children; - if (children === undefined) return; - if (children instanceof Map) { - for (const [key, child] of children) if (child.dirty) visit(key, child); - } else if (children.dirty && children.key !== undefined) visit(children.key, children); -} - -function changedIndexList(state: DraftState): number[] | undefined { - const changed = state.changedIndices; - if (changed === undefined) return []; - // The differ only consumes narrow hints. Avoid materializing and sorting a - // dense set that it would immediately discard. - if (changed instanceof Set) { - if (changed.size >= 2_048) return undefined; - return [...changed].sort((left, right) => left - right); - } - return [changed]; -} - -function shallowEqual(left: Container, right: Container): boolean { - if (Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) return false; - const leftRecord = left as Record; - const rightRecord = right as Record; - for (const key of leftKeys) { - if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false; - } - return true; -} - -function defineData(target: object, key: PropertyKey, value: unknown): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function spliceCapturedLength( - target: unknown[], - start: number, - remove: number, - items: readonly unknown[], - capturedLength: number, -): unknown[] { - const removed = new Array(remove); - for (let offset = 0; offset < remove; offset++) { - defineData(removed, String(offset), denseArrayValue(target, start + offset)); - } - if (items.length < remove) { - for (let source = start + remove; source < capturedLength; source++) { - defineData(target, String(source - remove + items.length), denseArrayValue(target, source)); - } - } else if (items.length > remove) { - for (let source = capturedLength - 1; source >= start + remove; source--) { - defineData(target, String(source - remove + items.length), denseArrayValue(target, source)); - } - } - for (let offset = 0; offset < items.length; offset++) defineData(target, String(start + offset), items[offset]); - target.length = capturedLength - remove + items.length; - return removed; -} - -function denseArrayValue(target: readonly unknown[], index: number): unknown { - return index < target.length && Object.hasOwn(target, index) && target[index] !== undefined ? target[index] : null; -} - -function insertChunked(target: unknown[], index: number, values: readonly unknown[]): void { - for (let offset = 0; offset < values.length; offset += MAX_NATIVE_ARRAY_INSERT_ITEMS) { - target.splice(index + offset, 0, ...values.slice(offset, offset + MAX_NATIVE_ARRAY_INSERT_ITEMS)); - } -} - -function toIntegerOrInfinity(value: unknown): number { - const number = +(value as number); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; -} - -function clampIndex(value: number, length: number): number { - if (value === Number.NEGATIVE_INFINITY) return 0; - if (value < 0) return Math.max(length + value, 0); - return Math.min(value, length); -} - -function syncArrayTarget(state: DraftState): void { - (state.target as unknown[]).length = ((state.copy ?? state.base) as unknown[]).length; -} - -function assertWritable(state: DraftState, property: PropertyKey): void { - assertActive(state.context); - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); -} - -function assertActive(context: DraftContext): void { - if (!context.active) throw new TypeError("Cannot use a draft outside its change callback"); -} - -function isContainer(value: unknown): value is Container { - return value !== null && typeof value === "object"; -} - -function isPromiseLike(value: unknown): value is PromiseLike { - return ( - ((typeof value === "object" && value !== null) || typeof value === "function") && - typeof (value as { then?: unknown }).then === "function" - ); -} diff --git a/packages/chord/src/delta/cow/index.ts b/packages/chord/src/delta/cow/index.ts deleted file mode 100644 index 118806804..000000000 --- a/packages/chord/src/delta/cow/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type { JsonValue, NonEmptyPath, Op, Path, PathRef, Seg, WireOp } from "../index.ts"; -export { - apply, - assertSafePath, - assertValidOp, - assertValidWireOp, - decoder, - encoder, - isBase, - isReplace, - overlap, - PathError, - RESERVED_SEGMENTS, - UnsafePathError, -} from "../index.ts"; -export { applyImmutable } from "./apply-immutable.ts"; -export { diffRevisions } from "./diff.ts"; -export type { Draft } from "./draft.ts"; -export type { Change, Prepared, Tracker } from "./tracker.ts"; -export { track } from "./tracker.ts"; diff --git a/packages/chord/src/delta/cow/tracker.ts b/packages/chord/src/delta/cow/tracker.ts deleted file mode 100644 index 20a34d4ae..000000000 --- a/packages/chord/src/delta/cow/tracker.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { JsonValue } from "../../types.ts"; -import type { Op } from "../index.ts"; -import { applyImmutable } from "./apply-immutable.ts"; -import { diffRevisions } from "./diff.ts"; -import { cloneJson, createDraftTransaction, type Draft, type DraftTransaction, type ProduceMetadata } from "./draft.ts"; - -export interface Prepared { - readonly base: T; - readonly value: T; - readonly ops: readonly Op[]; - abort(): void; -} - -export interface Change { - readonly state: Draft; - prepare(): Prepared; - abort(): void; -} - -export interface Tracker { - readonly value: T; - readonly revision: number; - beginChange(): Change; - prepareReplace(value: T): Prepared; - adopt(prepared: Prepared): void; -} - -type PreparedMetadata = { - readonly owner: object; - readonly revision: number; - status: "prepared" | "consumed" | "aborted"; -}; - -const PREPARED = new WeakMap(); - -class PreparedImpl implements Prepared { - readonly base: T; - readonly value: T; - readonly ops: readonly Op[]; - - constructor(metadata: PreparedMetadata, base: T, value: T, ops: readonly Op[]) { - this.base = base; - this.value = value; - this.ops = ops; - PREPARED.set(this, metadata); - Object.freeze(this); - } - - abort(): void { - const metadata = PREPARED.get(this)!; - if (metadata.status === "prepared") metadata.status = "aborted"; - } -} - -class ChangeImpl implements Change { - readonly state: Draft; - #tracker: TrackerImpl | undefined; - #transaction: DraftTransaction | undefined; - #preparedMetadata: PreparedMetadata | undefined; - #status: "open" | "prepared" | "aborted" = "open"; - - constructor(tracker: TrackerImpl, transaction: DraftTransaction) { - this.#tracker = tracker; - this.#transaction = transaction; - this.state = transaction.state; - } - - prepare(): Prepared { - if (this.#status !== "open") throw new Error("Change has already been settled"); - const tracker = this.#tracker!; - const transaction = this.#transaction!; - try { - const prepared = tracker.prepareDraft(transaction); - this.#preparedMetadata = PREPARED.get(prepared)!; - this.#status = "prepared"; - return prepared; - } catch (error) { - this.#status = "aborted"; - throw error; - } finally { - this.#tracker = undefined; - this.#transaction = undefined; - } - } - - abort(): void { - if (this.#status === "aborted") return; - if (this.#status === "prepared") { - if (this.#preparedMetadata?.status === "prepared") this.#preparedMetadata.status = "aborted"; - this.#preparedMetadata = undefined; - this.#status = "aborted"; - return; - } - this.#status = "aborted"; - const tracker = this.#tracker!; - const transaction = this.#transaction!; - this.#tracker = undefined; - this.#transaction = undefined; - try { - transaction.abort(); - } finally { - tracker.releaseChange(); - } - } -} - -class TrackerImpl implements Tracker { - readonly #owner = {}; - #value: T; - #revision = 0; - #changing = false; - - constructor(initial: T) { - this.#value = initial; - } - - get value(): T { - return this.#value; - } - - get revision(): number { - return this.#revision; - } - - beginChange(): Change { - this.#reserveChange(); - try { - return new ChangeImpl(this, createDraftTransaction(this.#value)); - } catch (error) { - this.#changing = false; - throw error; - } - } - - prepareReplace(value: T): Prepared { - this.#reserveChange(); - try { - return this.#prepare({ value, hints: new WeakMap() }, true); - } finally { - this.#changing = false; - } - } - - adopt(prepared: Prepared): void { - const metadata = PREPARED.get(prepared as object); - if (metadata?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); - if (metadata.status === "consumed") throw new Error("Prepared change has already been used"); - if (metadata.status === "aborted") throw new Error("Prepared change has been aborted"); - if (this.#changing) throw new Error("Cannot adopt while a change is active"); - if (metadata.revision !== this.#revision || this.#value !== prepared.base) - throw new Error("Prepared change is stale"); - metadata.status = "consumed"; - this.#value = prepared.value; - this.#revision += 1; - } - - prepareDraft(transaction: DraftTransaction): PreparedImpl { - try { - return this.#prepare(transaction.finish()); - } finally { - this.#changing = false; - } - } - - releaseChange(): void { - this.#changing = false; - } - - #reserveChange(): void { - if (this.#changing) throw new Error("Tracker already has an active change"); - this.#changing = true; - } - - #prepare(produced: ProduceMetadata, replacement = false): PreparedImpl { - const base = this.#value; - const operations = diffRevisions( - base as unknown as JsonValue, - produced.value as unknown as JsonValue, - produced.hints, - ); - if (operations.length === 0) { - return new PreparedImpl({ owner: this.#owner, revision: this.#revision, status: "prepared" }, base, base, []); - } - // Trusted ownership rule: value and public ops are independent immutable - // transfers. Draft revisions are reconstructed from a private detached batch: - // differ payloads may reference retained base objects, so applying them directly - // could introduce aliases. Replacements are already caller-transferred roots. - // No freeze or validation runs. - const value = replacement ? produced.value : applyImmutable(base, detachOperations(operations)); - return new PreparedImpl( - { owner: this.#owner, revision: this.#revision, status: "prepared" }, - base, - value, - detachOperations(operations), - ); - } -} - -/** Take O(1) ownership of an alias-free mutable strict-JSON root. */ -export function track(initial: T): Tracker { - return new TrackerImpl(initial); -} - -function detachOperations(operations: readonly Op[]): Op[] { - return operations.map((operation): Op => { - switch (operation[0]) { - case "r": - return ["r", cloneJson(operation[1])]; - case "s": - return ["s", operation[1], cloneJson(operation[2])]; - case "p": - return ["p", operation[1], operation[2], operation[3], operation[4].map((value) => cloneJson(value))]; - case "m": - return ["m", operation[1], operation[2].slice()]; - case "d": - return operation; - case "a": - return operation; - case "t": - return operation; - default: - throw new TypeError(`Unknown operation ${(operation as readonly [unknown])[0]}`); - } - }); -} diff --git a/packages/chord/src/delta/draft.ts b/packages/chord/src/delta/draft.ts index 995b3a00c..cb711495f 100644 --- a/packages/chord/src/delta/draft.ts +++ b/packages/chord/src/delta/draft.ts @@ -8,614 +8,3 @@ export type Draft = Depth["length"] ex : T extends object ? { -readonly [Key in keyof T]: Draft } : T; - -export type ProduceMetadata = { - value: T; - owned: WeakSet; -}; - -type Container = Record | unknown[]; - -type DraftState = { - base: Container; - context: DraftContext; - copy: Container | undefined; - methods: Map | undefined; - proxy: object; - target: unknown[] | undefined; -}; - -type DraftContext = { - active: boolean; - created: DraftState[]; - owned: WeakSet; - proxies: WeakMap; - states: WeakMap; -}; - -const RELEASED_BASE: Container = Object.freeze({}); -const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); -const MAX_NATIVE_ARRAY_INSERT_ITEMS = 10_000; - -export type DraftTransaction = { - readonly state: Draft; - finish(): ProduceMetadata; - abort(): void; -}; - -/** Create a revocable copy-on-write draft whose lifetime is controlled by its owner. */ -export function createDraftTransaction(base: T): DraftTransaction { - const context: DraftContext = { - active: true, - created: [], - owned: new WeakSet(), - proxies: new WeakMap(), - states: new WeakMap(), - }; - const root = getState(context, base as Container); - const release = (): void => { - assertActive(context); - context.active = false; - for (const state of context.created) { - state.base = RELEASED_BASE; - state.copy = undefined; - state.methods = undefined; - state.proxy = RELEASED_BASE; - state.target = undefined; - } - context.created.length = 0; - context.proxies = new WeakMap(); - context.states = new WeakMap(); - }; - return { - state: root.proxy as Draft, - finish() { - assertActive(context); - try { - return { - value: finalize(root, new Set(), new WeakMap()) as T, - owned: context.owned, - }; - } finally { - release(); - } - }, - abort: release, - }; -} - -/** @internal Compatibility helper for the existing replicated-state service. */ -export function produceWithMetadata(base: T, recipe: (draft: Draft) => void): ProduceMetadata { - const transaction = createDraftTransaction(base); - try { - const outcome = (recipe as (draft: Draft) => unknown)(transaction.state); - if (isPromiseLike(outcome)) { - void Promise.resolve(outcome).catch(() => undefined); - throw new TypeError("Replicated state change callbacks must be synchronous"); - } - return transaction.finish(); - } catch (error) { - try { - transaction.abort(); - } catch { - // finish() already released the transaction before propagating an error. - } - throw error; - } -} - -/** @internal Compatibility helper for the existing replicated-state service. */ -export function produce(base: T, recipe: (draft: Draft) => void): T { - return produceWithMetadata(base, recipe).value; -} - -function getState(context: DraftContext, base: Container): DraftState { - const existing = context.states.get(base); - if (existing !== undefined) return existing; - - const target = Array.isArray(base) ? [] : undefined; - if (target !== undefined) Reflect.set(target, "length", base.length); - const state: DraftState = { - base, - context, - copy: undefined, - methods: undefined, - proxy: target ?? {}, - target, - }; - state.proxy = new Proxy(target ?? state, createHandler(state)); - context.states.set(base, state); - context.proxies.set(state.proxy, state); - context.created.push(state); - return state; -} - -type DraftHandler = ProxyHandler & { state: DraftState }; - -const handlerPrototype: ProxyHandler = { - deleteProperty(this: DraftHandler, _target, property): boolean { - const state = this.state; - assertStringWrite(property); - const current = currentValue(state); - if (Array.isArray(current)) throw new TypeError("Draft arrays cannot contain holes"); - if (!Object.hasOwn(current, property)) return true; - const deleted = Reflect.deleteProperty(ensureCopy(state), property); - syncArrayTarget(state); - return deleted; - }, - defineProperty(this: DraftHandler): never { - assertActive(this.state.context); - throw new TypeError("Defining draft properties is not supported"); - }, - get(this: DraftHandler, _target, property): unknown { - const state = this.state; - const current = currentValue(state); - const value = Reflect.get(current, property, state.proxy); - if (Array.isArray(current) && typeof property === "string" && ARRAY_MUTATORS.has(property)) { - return arrayMutator(state, property, value); - } - return draftValue(state.context, value); - }, - getOwnPropertyDescriptor(this: DraftHandler, _target, property): PropertyDescriptor | undefined { - const state = this.state; - const current = currentValue(state); - const descriptor = Reflect.getOwnPropertyDescriptor(current, property); - if (descriptor === undefined) return undefined; - if (Array.isArray(current) && property === "length") { - syncArrayTarget(state); - return Reflect.getOwnPropertyDescriptor(state.target!, property); - } - return { - configurable: true, - enumerable: descriptor.enumerable, - value: "value" in descriptor ? draftValue(state.context, descriptor.value) : undefined, - writable: true, - }; - }, - getPrototypeOf(this: DraftHandler): object | null { - assertActive(this.state.context); - return Object.getPrototypeOf(this.state.base); - }, - has(this: DraftHandler, _target, property): boolean { - return Reflect.has(currentValue(this.state), property); - }, - isExtensible(this: DraftHandler): boolean { - assertActive(this.state.context); - return true; - }, - ownKeys(this: DraftHandler): ArrayLike { - return Reflect.ownKeys(currentValue(this.state)); - }, - preventExtensions(this: DraftHandler): never { - assertActive(this.state.context); - throw new TypeError("Drafts cannot be made non-extensible"); - }, - set(this: DraftHandler, _target, property, value): boolean { - const state = this.state; - assertStringWrite(property); - const current = currentValue(state); - let stored: unknown; - if (isContainer(value)) { - stored = cloneAssigned(value, state.context, new Set()); - } else { - assertJsonPrimitive(value); - stored = value; - } - if (Object.hasOwn(current, property) && Object.is(Reflect.get(current, property), stored)) return true; - const copy = ensureCopy(state); - if (Array.isArray(copy) && property === "length") { - const written = Reflect.set(copy, property, stored); - syncArrayTarget(state); - return written; - } - Object.defineProperty(copy, property, { - value: stored, - writable: true, - enumerable: true, - configurable: true, - }); - syncArrayTarget(state); - return true; - }, - setPrototypeOf(this: DraftHandler): never { - assertActive(this.state.context); - throw new TypeError("Changing a draft prototype is not supported"); - }, -}; - -function createHandler(state: DraftState): ProxyHandler { - const handler = Object.create(handlerPrototype) as DraftHandler; - handler.state = state; - return handler; -} - -function arrayMutator(state: DraftState, property: string, value: unknown): unknown { - assertActive(state.context); - const cached = state.methods?.get(property); - if (cached !== undefined) return cached; - if (typeof value !== "function") return draftValue(state.context, value); - const wrapped = function (this: unknown, ...args: unknown[]): unknown { - assertActive(state.context); - const receiver = isContainer(this) ? state.context.proxies.get(this) : undefined; - if (receiver === undefined) return Reflect.apply(value, this, args); - const target = ensureCopy(receiver); - if (!Array.isArray(target)) throw new TypeError("Array mutator receiver is not an array draft"); - try { - return mutateArray(receiver, target, property, args); - } finally { - syncArrayTarget(receiver); - } - }; - if (state.methods === undefined) state.methods = new Map(); - state.methods.set(property, wrapped); - return wrapped; -} - -function mutateArray(state: DraftState, target: unknown[], property: string, args: unknown[]): unknown { - const { context } = state; - switch (property) { - case "push": { - const items = cloneArrayItems(args, context); - for (const item of items) defineArrayValue(target, target.length, item); - return target.length; - } - case "pop": { - if (target.length === 0) return undefined; - const index = target.length - 1; - const removed = target[index]; - Reflect.deleteProperty(target, String(index)); - target.length = index; - return draftValue(context, removed); - } - case "shift": - return draftValue(context, Reflect.apply(Array.prototype.shift, target, [])); - case "unshift": { - const items = cloneArrayItems(args, context); - if ( - items.length <= MAX_NATIVE_ARRAY_INSERT_ITEMS && - !hasInheritedGrowthIndex(target, target.length + items.length) - ) { - return Reflect.apply(Array.prototype.unshift, target, items); - } - spliceArray(target, items, 0, 0, context); - return target.length; - } - case "splice": { - const length = target.length; - const start = args.length === 0 ? 0 : clampArrayIndex(toIntegerOrInfinity(args[0]), length); - const remove = - args.length === 0 - ? 0 - : args.length === 1 - ? length - start - : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); - const items = cloneArrayItems(args.slice(2), context); - if ( - items.length <= MAX_NATIVE_ARRAY_INSERT_ITEMS && - !mayRunCoercionCode(args[0]) && - !mayRunCoercionCode(args[1]) && - !hasInheritedGrowthIndex(target, length - remove + items.length) - ) { - return Reflect.apply(Array.prototype.splice, target, [start, remove, ...items]).map((item) => - draftValue(context, item), - ); - } - return spliceArray(target, items, start, remove, context, length); - } - case "sort": { - const comparator = args[0]; - if (comparator !== undefined && typeof comparator !== "function") { - Reflect.apply(Array.prototype.sort, target, args); - } else { - Reflect.apply(Array.prototype.sort, target, [ - (left: unknown, right: unknown): number => { - const draftedLeft = draftValue(context, left); - const draftedRight = draftValue(context, right); - if (typeof comparator === "function") { - return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number; - } - const leftString = String(draftedLeft); - const rightString = String(draftedRight); - return leftString < rightString ? -1 : leftString > rightString ? 1 : 0; - }, - ]); - } - return state.proxy; - } - case "reverse": - Reflect.apply(Array.prototype.reverse, target, []); - return state.proxy; - case "fill": { - const length = target.length; - const start = args.length > 1 ? clampArrayIndex(toIntegerOrInfinity(args[1]), length) : 0; - const end = - args.length > 2 && args[2] !== undefined ? clampArrayIndex(toIntegerOrInfinity(args[2]), length) : length; - if (end <= start) return state.proxy; - const supplied = args[0]; - for (let index = start; index < end; index++) { - const item = isContainer(supplied) - ? cloneAssigned(supplied, context, new Set()) - : assertJsonPrimitive(supplied); - defineArrayValue(target, index, item); - } - return state.proxy; - } - case "copyWithin": { - const length = target.length; - const to = clampArrayIndex(toIntegerOrInfinity(args[0]), length); - const from = clampArrayIndex(toIntegerOrInfinity(args[1]), length); - const end = - args.length > 2 && args[2] !== undefined ? clampArrayIndex(toIntegerOrInfinity(args[2]), length) : length; - const count = Math.min(Math.max(end - from, 0), length - to); - const source = target.slice(from, from + count); - for (let offset = 0; offset < count; offset++) { - const item = source[offset]; - const itemState = isContainer(item) ? context.states.get(item) : undefined; - const current = (itemState?.proxy as Container | undefined) ?? item; - const copy = isContainer(current) - ? cloneAssigned(current, context, new Set()) - : assertJsonPrimitive(current); - defineArrayValue(target, to + offset, copy); - } - return state.proxy; - } - } - throw new TypeError(`Unsupported array mutator: ${property}`); -} - -function cloneArrayItems(items: readonly unknown[], context: DraftContext): unknown[] { - return items.map((item) => { - if (isContainer(item)) return cloneAssigned(item, context, new Set()); - assertJsonPrimitive(item); - return item; - }); -} - -function hasInheritedGrowthIndex(target: unknown[], nextLength: number): boolean { - for (let index = target.length; index < nextLength; index++) { - if (index in target) return true; - } - return false; -} - -function mayRunCoercionCode(value: unknown): boolean { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - -function spliceArray( - target: unknown[], - items: readonly unknown[], - index: number, - remove: number, - context: DraftContext, - length = target.length, -): unknown[] { - const removed = new Array(remove); - for (let offset = 0; offset < remove; offset++) { - const source = index + offset; - if (!(source in target)) continue; - defineArrayValue(removed, offset, draftValue(context, target[source])); - } - if (items.length < remove) { - for (let source = index + remove; source < length; source++) { - defineArrayValue(target, source - remove + items.length, target[source]); - } - } else if (items.length > remove) { - for (let source = length - 1; source >= index + remove; source--) { - defineArrayValue(target, source - remove + items.length, target[source]); - } - } - for (let offset = 0; offset < items.length; offset++) defineArrayValue(target, index + offset, items[offset]); - target.length = length - remove + items.length; - return removed; -} - -function defineArrayValue(target: unknown[], index: number, value: unknown): void { - Object.defineProperty(target, index, { - value, - writable: true, - enumerable: true, - configurable: true, - }); -} - -function toIntegerOrInfinity(value: unknown): number { - const number = +(value as unknown as number); - if (Number.isNaN(number) || number === 0) return 0; - return Number.isFinite(number) ? Math.trunc(number) : number; -} - -function clampArrayIndex(value: number, length: number): number { - if (value === Number.NEGATIVE_INFINITY) return 0; - if (value < 0) return Math.max(length + value, 0); - return Math.min(value, length); -} - -function currentValue(state: DraftState): Container { - assertActive(state.context); - return state.copy ?? state.base; -} - -function ensureCopy(state: DraftState): Container { - if (state.copy !== undefined) return state.copy; - if (state.context.owned.has(state.base)) { - state.copy = state.base; - } else { - state.copy = shallowOwnedCopy(state.base, state.context); - } - return state.copy; -} - -function shallowOwnedCopy(value: Container, context: DraftContext): Container { - if (Array.isArray(value)) { - const copy = value.slice(); - context.owned.add(copy); - return copy; - } - const copy = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Object.keys(value)) { - Object.defineProperty(copy, key, { - value: value[key], - writable: true, - enumerable: true, - configurable: true, - }); - } - context.owned.add(copy); - return copy; -} - -function cloneAssigned(value: Container, context: DraftContext, ancestors: Set): Container { - if (ancestors.has(value)) throw new TypeError("Assigned JSON values cannot contain cycles"); - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - const clone: unknown[] = []; - context.owned.add(clone); - for (let index = 0; index < value.length; index++) { - clone.push(cloneAssignedValue(Reflect.get(value, String(index)), context, ancestors)); - } - return clone; - } - - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError("Assigned JSON containers must be plain objects or arrays"); - } - const clone = Object.create(prototype) as Record; - context.owned.add(clone); - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Assigned JSON objects cannot have symbol properties"); - const descriptor = Reflect.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Assigned JSON objects must contain enumerable data properties"); - } - Object.defineProperty(clone, key, { - value: cloneAssignedValue(descriptor.value, context, ancestors), - writable: true, - enumerable: true, - configurable: true, - }); - } - return clone; - } finally { - ancestors.delete(value); - } -} - -function cloneAssignedValue(value: unknown, context: DraftContext, ancestors: Set): unknown { - if (isContainer(value)) return cloneAssigned(value, context, ancestors); - assertJsonPrimitive(value); - return value; -} - -function assertJsonPrimitive(value: unknown): null | boolean | number | string { - if (value === undefined) throw new TypeError("Assigned values cannot be undefined"); - if ( - value !== null && - typeof value !== "string" && - typeof value !== "boolean" && - !(typeof value === "number" && Number.isFinite(value)) - ) { - throw new TypeError("Assigned values must be strict JSON values"); - } - return value; -} - -function draftValue(context: DraftContext, value: unknown): unknown { - if (!isContainer(value)) return value; - return getState(context, value).proxy; -} - -function isContainer(value: unknown): value is Container { - return typeof value === "object" && value !== null; -} - -function isPromiseLike(value: unknown): value is PromiseLike { - return ( - ((typeof value === "object" && value !== null) || typeof value === "function") && - typeof (value as { then?: unknown }).then === "function" - ); -} - -function assertActive(context: DraftContext): void { - if (!context.active) throw new TypeError("Cannot use a draft outside its change callback"); -} - -function assertStringWrite(property: string | symbol): asserts property is string { - if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); -} - -function syncArrayTarget(state: DraftState): void { - const current = currentValue(state); - if (Array.isArray(current)) Reflect.set(state.target!, "length", current.length); -} - -function finalize( - state: DraftState, - finalizing: Set, - finalized: WeakMap, -): Container { - const cached = finalized.get(state); - if (cached !== undefined) return cached; - if (finalizing.has(state)) throw new TypeError("Cyclic draft state is not supported"); - finalizing.add(state); - try { - if (state.copy !== undefined && shallowEqual(state.base, state.copy)) state.copy = undefined; - const current = currentValue(state); - let result = current; - for (const key of Object.keys(current)) { - const value = Reflect.get(current, key); - if (!isContainer(value)) continue; - const child = state.context.states.get(value); - if (child === undefined) continue; - const finalizedChild = finalize(child, finalizing, finalized); - if (finalizedChild === value) continue; - if (result === state.base) result = shallowOwnedCopy(state.base, state.context); - Object.defineProperty(result, key, { - value: finalizedChild, - writable: true, - enumerable: true, - configurable: true, - }); - } - if (state.copy !== undefined && Array.isArray(result)) assertDenseArray(result); - finalized.set(state, result); - return result; - } finally { - finalizing.delete(state); - } -} - -function shallowEqual(left: Container, right: Container): boolean { - if (Array.isArray(left) !== Array.isArray(right)) return false; - if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) return false; - const leftRecord = left as Record; - const rightRecord = right as Record; - for (const key of leftKeys) { - if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false; - } - return true; -} - -function assertDenseArray(value: unknown[]): void { - const keys = Reflect.ownKeys(value); - if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) { - throw new TypeError("Draft arrays must be dense and contain only indexed entries"); - } - for (let index = 0; index < value.length; index++) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if ( - descriptor === undefined || - !descriptor.enumerable || - !("value" in descriptor) || - descriptor.value === undefined - ) { - throw new TypeError("Draft arrays must contain enumerable indexed data properties with defined values"); - } - } -} diff --git a/packages/chord/src/delta/revision-validator.ts b/packages/chord/src/delta/revision-validator.ts new file mode 100644 index 000000000..d78373e7a --- /dev/null +++ b/packages/chord/src/delta/revision-validator.ts @@ -0,0 +1,75 @@ +import type { JsonValue } from "../types.ts"; + +type JsonContainer = JsonValue[] | Record; + +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(); + + validate(value: T): T { + return this.#validate(value, new Set(), new WeakSet()) as T; + } + + #validate(value: JsonValue, ancestors: Set, finished: WeakSet): 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", + ); + } + } +} diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts index d473fedec..703721031 100644 --- a/packages/chord/src/delta/tracker.ts +++ b/packages/chord/src/delta/tracker.ts @@ -1,13 +1,129 @@ import type { JsonValue } from "../types.ts"; -import { diffRevisions } from "./diff.ts"; -import { createDraftTransaction, type Draft, type DraftTransaction } from "./draft.ts"; -import type { Op } from "./index.ts"; -import { JsonRevisionStore } from "./value.ts"; +import { applyImmutable } from "./apply-immutable-batch.ts"; +import { applyImmutableTrusted } from "./apply-immutable-trusted.ts"; +import type { Draft } from "./draft.ts"; +import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts"; + +export type { Draft, JsonValue, Op }; + +type Primitive = null | boolean | number | string; +type Container = JsonValue[] | Record; +type Stored = JsonValue; +type StoredRef = Primitive | { index: number }; +type Status = "open" | "prepared" | "consumed" | "aborted" | "stale"; +type StatusCell = { value: Status }; + +type InsertSource = { refs: StoredRef[] }; +type ParentKind = 0 | 1 | 2; // object, base-array entry, inserted-array entry + +type BasePiece = { kind: "base"; start: number; length: number; step: 1 | -1 }; +type InsertPiece = { kind: "insert"; source: InsertSource; start: number; length: number; step: 1 | -1 }; +type Piece = BasePiece | InsertPiece; + +type PieceNode = { + piece: Piece; + left: PieceNode | undefined; + right: PieceNode | undefined; + priority: number; + elements: number; +}; + +type PieceLocation = { piece: Piece; logicalStart: number; minimum: number; maximum: number }; + +type DenseRegion = { start: number; length: number }; +type DenseCandidates = { indices: number[]; bits: Uint8Array | undefined; length: number }; + +type ArrayPlan = { + removeRuns: number[]; + permutation: number[] | undefined; + insertRuns: number[]; +}; + +type ArrayOverlay = { + root: PieceNode | undefined; + pieces: Piece[] | undefined; + baseOverrides: Map | undefined; + insertOverrides: Map> | undefined; + structural: boolean; + generation: number; + plan: ArrayPlan | undefined; + seed: number; + locatedOffset: number; + baseLocations: PieceLocation[] | undefined; + insertLocations: Map | undefined; +}; + +type OverlayNode = { + context: OverlayContext; + baseIndex: number; + parentIndex: number; + parentKind: ParentKind; + parentKey: string | number; + parentSource?: InsertSource; + parentPlacement: boolean; + target: object; + proxy: object; + writeKey?: string; + writeValue?: StoredRef; + writes?: Map; + deleteKey?: string; + deletes?: Set; + readded?: Set; + array?: ArrayOverlay; + dirty?: boolean; + subtreeDirty?: boolean; + preparedPath?: Path; +}; + +type OverlayContext = { + owner: object; + tracker: WeakRef>; + baseRevision: number; + status: StatusCell; + root: OverlayNode | undefined; + bases: Container[] | undefined; + stored: Container[] | undefined; + dirty: OverlayNode[]; + nodes: OverlayNode[]; + rawNodes: WeakMap | undefined; + ops: Op[] | undefined; + replacement: boolean; + replacementNoop: boolean; + baseValue: object | undefined; + overlayReleased: boolean; + simpleObjectMaterialization: boolean; + registryRef: WeakRef | undefined; +}; + +const NODE = Symbol("chord.delta.overlay.node"); +const PREPARED = new WeakMap(); +const RELEASED: Record = {}; +const ARRAY_MUTATORS = new Set([ + "push", + "pop", + "shift", + "unshift", + "splice", + "reverse", + "sort", + "fill", + "copyWithin", +]); +const MAX_DELTA_OPERATIONS = 4_096; +const MAX_SIMPLE_OBJECT_NODES = 128; +const DATA_DESCRIPTOR: PropertyDescriptor = { + value: undefined, + writable: true, + enumerable: true, + configurable: true, +}; export interface Prepared { readonly base: T; readonly value: T; readonly ops: readonly Op[]; + readonly baseRevision: number; + abort(): void; } export interface Change { @@ -18,172 +134,2111 @@ export interface Change { export interface Tracker { readonly value: T; + readonly revision: number; beginChange(): Change; prepareReplace(value: T): Prepared; adopt(prepared: Prepared): void; } -type PreparedMetadata = { - readonly owner: object; - readonly revision: number; - status: "prepared" | "consumed" | "aborted"; -}; - -const preparedMetadata = new WeakMap(); - class PreparedImpl implements Prepared { - readonly base: T; + readonly #context: OverlayContext; readonly value: T; + readonly base: T; readonly ops: readonly Op[]; - constructor(metadata: PreparedMetadata, base: T, value: T, ops: readonly Op[]) { - this.base = base; + constructor(context: OverlayContext, value: T, operations: readonly Op[]) { + this.#context = context; this.value = value; - this.ops = ops; - preparedMetadata.set(this, metadata); - Object.freeze(this); + this.base = context.baseValue as T; + this.ops = operations; + PREPARED.set(this, context); + } + + get baseRevision(): number { + return this.#context.baseRevision; + } + + abort(): void { + abortContext(this.#context); } } class ChangeImpl implements Change { - readonly state: Draft; - #tracker: TrackerImpl | undefined; - #transaction: DraftTransaction | undefined; - #preparedMetadata: PreparedMetadata | undefined; - #status: "open" | "prepared" | "aborted" = "open"; + #context: OverlayContext | undefined; + #preparedStatus: StatusCell | undefined; + #settled = false; - constructor(tracker: TrackerImpl, transaction: DraftTransaction) { - this.#tracker = tracker; - this.#transaction = transaction; - this.state = transaction.state; + constructor(context: OverlayContext) { + this.#context = context; + } + + get state(): Draft { + const state = this.#context?.root?.proxy; + if (state === undefined) throw new TypeError("Cannot use a settled overlay"); + return state as Draft; } prepare(): Prepared { - if (this.#status !== "open") throw new Error("Change has already been settled"); - const tracker = this.#tracker!; - const transaction = this.#transaction!; + if (this.#settled) throw new Error("Change has already been settled"); + const context = this.#context!; + assertWritable(context); + context.status.value = "prepared"; try { - const prepared = tracker.prepareDraft(transaction); - this.#preparedMetadata = preparedMetadata.get(prepared)!; - this.#status = "prepared"; + if (!context.replacement) context.ops = context.dirty.length === 0 ? [] : emitOperations(context); + const prepared = materializePrepared(context); + this.#preparedStatus = context.status; + this.#settled = true; return prepared; } catch (error) { - this.#status = "aborted"; + context.status.value = "aborted"; + clearContext(context); + this.#settled = true; throw error; } finally { - this.#tracker = undefined; - this.#transaction = undefined; + this.#context = undefined; } } abort(): void { - if (this.#status === "aborted") return; - if (this.#status === "prepared") { - if (this.#preparedMetadata?.status === "prepared") this.#preparedMetadata.status = "aborted"; - this.#preparedMetadata = undefined; - this.#status = "aborted"; + if (this.#settled) { + if (this.#preparedStatus?.value === "prepared") this.#preparedStatus.value = "aborted"; + this.#preparedStatus = undefined; return; } - this.#status = "aborted"; - const tracker = this.#tracker!; - const transaction = this.#transaction!; - this.#tracker = undefined; - this.#transaction = undefined; - try { - transaction.abort(); - } finally { - tracker.releaseChange(); - } + this.#settled = true; + abortContext(this.#context!); + this.#context = undefined; } } class TrackerImpl implements Tracker { - readonly #store = new JsonRevisionStore(); readonly #owner = {}; + readonly #contexts = new Set>(); + readonly #selfRef: WeakRef>; #value: T; #revision = 0; - #changing = false; + #pruneBudget = 256; constructor(initial: T) { - this.#value = this.#store.import(initial); + this.#value = initial; + this.#selfRef = new WeakRef(this as unknown as TrackerImpl); } get value(): T { return this.#value; } + get revision(): number { + return this.#revision; + } + beginChange(): Change { - this.#reserveChange(); + const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value); + this.#register(context); + return new ChangeImpl(context); + } + + prepareReplace(value: T): Prepared { + const context = createContext(this.#selfRef, this.#owner, this.#revision, value, true, this.#value); + context.status.value = "prepared"; + this.#register(context); try { - return new ChangeImpl(this, createDraftTransaction(this.#value)); + return materializePrepared(context); } catch (error) { - this.#changing = false; + context.status.value = "aborted"; + clearContext(context); throw error; } } - prepareReplace(value: T): Prepared { - this.#reserveChange(); - try { - return this.#prepare(this.#store.import(value)); - } finally { - this.#changing = false; - } - } - adopt(prepared: Prepared): void { - const metadata = prepared instanceof PreparedImpl ? preparedMetadata.get(prepared) : undefined; - if (metadata?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); - if (metadata.status === "consumed") throw new Error("Prepared change has already been used"); - if (metadata.status === "aborted") throw new Error("Prepared change has been aborted"); - if (this.#changing) throw new Error("Cannot adopt while a change is active"); - if (metadata.revision !== this.#revision || this.#value !== prepared.base) { + const context = PREPARED.get(prepared as object); + if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); + if (context.status.value === "consumed") throw new Error("Prepared change has already been used"); + if (context.status.value === "aborted") throw new Error("Prepared change has been aborted"); + if (context.status.value === "stale") throw new Error("Prepared change is stale"); + if (context.status.value !== "prepared") throw new Error("Prepared change is not ready"); + if (context.baseRevision !== this.#revision) { + context.status.value = "stale"; + clearContext(context); throw new Error("Prepared change is stale"); } - metadata.status = "consumed"; + + if (this.#value !== prepared.base) { + context.status.value = "stale"; + throw new Error("Prepared change is stale"); + } + // Materialization happened during prepare. Adoption is an infallible pointer + // swap so storage failure can discard the candidate without touching authority. this.#value = prepared.value; + context.status.value = "consumed"; this.#revision += 1; + this.#invalidate(context); } - prepareDraft(transaction: DraftTransaction): PreparedImpl { - try { - const produced = transaction.finish(); - return this.#prepare(this.#store.commit(produced.value, produced.owned)); - } finally { - this.#changing = false; + releaseContext(context: OverlayContext): void { + if (context.registryRef !== undefined) this.#contexts.delete(context.registryRef); + context.registryRef = undefined; + } + + #register(context: OverlayContext): void { + const reference = new WeakRef(context); + context.registryRef = reference; + this.#contexts.add(reference); + this.#pruneBudget -= 1; + if (this.#pruneBudget === 0) { + this.#prune(); + this.#pruneBudget = Math.max(256, this.#contexts.size); } } - releaseChange(): void { - this.#changing = false; + #prune(): void { + for (const reference of this.#contexts) { + if (reference.deref() === undefined) this.#contexts.delete(reference); + } } - #reserveChange(): void { - if (this.#changing) throw new Error("Tracker already has an active change"); - this.#changing = true; + #invalidate(winner: OverlayContext): void { + for (const reference of this.#contexts) { + const context = reference.deref(); + if (context === undefined || context === winner) continue; + if (context.status.value === "open" || context.status.value === "prepared") context.status.value = "stale"; + // Adoption must not walk every node touched by a competing draft. Drop the + // context's strong overlay references in O(1); externally retained proxies + // still see the stale status and are reclaimed with their holders. + releaseOverlayReferences(context); + } + this.#contexts.clear(); + this.#pruneBudget = 256; } - #prepare(candidate: T): PreparedImpl { - const base = this.#value; - const operations = diffRevisions(base as unknown as JsonValue, candidate as unknown as JsonValue); - const value = operations.length === 0 ? base : candidate; - const ops = freezeOperations(operations); - return new PreparedImpl({ owner: this.#owner, revision: this.#revision, status: "prepared" }, base, value, ops); + replacementBase(context: OverlayContext): T | undefined { + return context.baseRevision === this.#revision ? this.#value : undefined; } } -/** Create a tracker around an imported immutable JSON revision. */ +/** Take immutable ownership of an alias-free strict-JSON root in O(1). */ export function track(initial: T): Tracker { return new TrackerImpl(initial); } -function freezeOperations(operations: Op[]): readonly Op[] { - for (const operation of operations) freezeMetadata(operation); - return Object.freeze(operations); +function materializePrepared(context: OverlayContext): PreparedImpl { + const operations = ensureOperations(context); + const base = context.baseValue as T; + // Operation payloads are already detached from the mutable overlay. The + // resulting revision and public batch intentionally share those payloads under + // the trusted immutable-transfer contract. + const value = + operations.length === 0 + ? base + : context.simpleObjectMaterialization + ? (cloneNode(context.root!) as T) + : materializeOperations(base, operations); + const prepared = new PreparedImpl(context, value, operations); + if (context.nodes.length > 4_096) releaseOverlayReferences(context); + else clearContext(context); + return prepared; } -function freezeMetadata(value: unknown): void { - if (value === null || typeof value !== "object" || Object.isFrozen(value)) return; - for (const child of Object.values(value)) freezeMetadata(child); - Object.freeze(value); +function materializeOperations(base: T, operations: readonly Op[]): T { + for (const operation of operations) { + // Native splice/permutation remains faster for large structural batches. + if (operation[0] === "p" || operation[0] === "m") return applyImmutable(base, operations); + } + return applyImmutableTrusted(base, operations); +} + +function createContext( + tracker: WeakRef>, + owner: object, + baseRevision: number, + root: T, + replacement: boolean, + base: T, +): OverlayContext { + const context: OverlayContext = { + owner, + tracker, + baseRevision, + status: { value: "open" }, + root: undefined, + bases: [], + stored: [], + dirty: [], + nodes: [], + rawNodes: new WeakMap(), + ops: undefined, + replacement, + replacementNoop: false, + baseValue: base, + overlayReleased: false, + simpleObjectMaterialization: false, + registryRef: undefined, + }; + context.root = createNode(context, root as Container, undefined); + return context; +} + +const sharedObjectHandler: ProxyHandler = { + deleteProperty(target, property) { + return deleteProperty(nodeForTarget(target), property); + }, + defineProperty(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Defining overlay properties is not supported"); + }, + get(target, property) { + const node = nodeForTarget(target); + if (property === NODE) return node; + return getProperty(node, property); + }, + getOwnPropertyDescriptor(target, property) { + return getDescriptor(nodeForTarget(target), property); + }, + getPrototypeOf(target) { + const node = nodeForTarget(target); + assertReadable(node.context); + return Object.getPrototypeOf(nodeBase(node)); + }, + has(target, property) { + return hasProperty(nodeForTarget(target), property); + }, + isExtensible(target) { + assertReadable(nodeForTarget(target).context); + return true; + }, + ownKeys(target) { + return ownKeys(nodeForTarget(target)); + }, + preventExtensions(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Overlays cannot be made non-extensible"); + }, + set(target, property, value) { + return setProperty(nodeForTarget(target), property, value); + }, + setPrototypeOf(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Changing an overlay prototype is not supported"); + }, +}; + +const sharedArrayHandler: ProxyHandler = sharedObjectHandler; + +function createNode( + context: OverlayContext, + base: Container, + parent: OverlayNode | undefined, + parentKind: ParentKind = 0, + parentKey: string | number = "", + parentSource: InsertSource | undefined = undefined, + parentPlacement = false, +): OverlayNode { + const existing = context.rawNodes!.get(base); + if (existing !== undefined) return existing; + const target: object = Array.isArray(base) ? [] : {}; + const baseIndex = context.bases!.length; + const parentIndex = parent?.baseIndex ?? -1; + context.bases!.push(base); + const node: OverlayNode = { + context, + baseIndex, + parentIndex, + parentKind, + parentKey, + parentPlacement, + target, + proxy: target, + }; + if (parentSource !== undefined) node.parentSource = parentSource; + (target as Record)[NODE] = node; + node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler); + context.rawNodes!.set(base, node); + context.nodes.push(node); + return node; +} + +function nodeBase(node: OverlayNode): Container { + return node.context.bases![node.baseIndex]!; +} + +function nodeParent(node: OverlayNode): OverlayNode | undefined { + return node.parentIndex < 0 ? undefined : node.context.nodes[node.parentIndex]; +} + +function storeValue(context: OverlayContext, value: Stored): StoredRef { + if (!isContainer(value)) return value; + const index = context.stored!.length; + context.stored!.push(value); + return { index }; +} + +function storedValue(context: OverlayContext, reference: StoredRef): Stored { + return typeof reference === "object" && reference !== null ? context.stored![reference.index]! : reference; +} + +function replaceStoredValue(context: OverlayContext, reference: StoredRef, value: Stored): StoredRef { + if (isContainer(value)) { + if (typeof reference === "object" && reference !== null) { + context.stored![reference.index] = value; + return reference; + } + return storeValue(context, value); + } + if (typeof reference === "object" && reference !== null) context.stored![reference.index] = RELEASED; + return value; +} + +function releaseStoredValue(context: OverlayContext, reference: StoredRef): void { + if (typeof reference === "object" && reference !== null) context.stored![reference.index] = RELEASED; +} + +function nodeForTarget(target: object): OverlayNode { + const node = (target as Record)[NODE] as OverlayNode | undefined; + if (node === undefined) throw new TypeError("Cannot use a settled overlay"); + return node; +} + +function assertReadable(context: OverlayContext): void { + if ( + context.overlayReleased || + context.status.value === "consumed" || + context.status.value === "aborted" || + context.status.value === "stale" + ) { + throw new TypeError("Cannot use a settled overlay"); + } +} + +function assertWritable(context: OverlayContext): void { + assertReadable(context); + if (context.status.value !== "open") throw new TypeError("Prepared overlays are read-only"); +} + +function getProperty(node: OverlayNode, property: PropertyKey): unknown { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + if (property === "length") return arrayLength(overlay); + if (ARRAY_MUTATORS.has(property)) return arrayMutators[property as keyof typeof arrayMutators]; + const index = arrayIndex(property); + if (index !== undefined) return getArrayIndex(node, index); + return Reflect.get(Array.prototype, property, node.proxy); + } + if (typeof property === "symbol") return Reflect.get(nodeBase(node), property, node.proxy); + const key = String(property); + if (!objectHas(node, key)) { + if (isObjectDeleted(node, key) || Object.hasOwn(nodeBase(node), key)) return undefined; + return Reflect.get(nodeBase(node), property, node.proxy); + } + const value = objectValue(node, key); + if (!isContainer(value)) return value; + return createNode(node.context, value, node, 0, key, undefined, hasObjectWrite(node, key)).proxy; +} + +function getArrayIndex(node: OverlayNode, index: number): unknown { + const overlay = arrayOverlay(node); + if (index >= arrayLength(overlay)) return undefined; + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const value = entryValueAt(node, piece, sourceIndex); + if (!isContainer(value)) return value; + return createNode( + node.context, + value, + node, + piece.kind === "base" ? 1 : 2, + sourceIndex, + piece.kind === "insert" ? piece.source : undefined, + piece.kind === "insert" || hasEntryOverrideAt(overlay, piece, sourceIndex), + ).proxy; +} + +function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { + assertWritable(node.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(nodeBase(node))) { + if (property === "length") { + setArrayLength(node, toArrayLength(supplied)); + return true; + } + const index = arrayIndex(property); + if (index === undefined) throw new TypeError("Only array indices and length can be written"); + if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); + setArrayIndex(node, index, clonePlacement(supplied)); + return true; + } + const key = String(property); + if (supplied === undefined) return deleteProperty(node, key); + const stored = clonePlacement(supplied); + const current = objectValue(node, key); + const wasDeleted = isObjectDeleted(node, key); + if (!wasDeleted && !isContainer(stored) && current === stored) return true; + setObjectWrite(node, key, stored); + if (wasDeleted && Object.hasOwn(nodeBase(node), key)) { + if (node.readded === undefined) node.readded = new Set(); + node.readded.add(key); + } + deleteObjectDeletion(node, key); + markDirty(node); + return true; +} + +function deleteProperty(node: OverlayNode, property: PropertyKey): boolean { + assertWritable(node.context); + if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(nodeBase(node))) throw new TypeError("Overlay arrays cannot contain holes"); + const key = String(property); + if (!objectHas(node, key)) return true; + deleteObjectWrite(node, key); + node.readded?.delete(key); + setObjectDeletion(node, key); + markDirty(node); + return true; +} + +function hasProperty(node: OverlayNode, property: PropertyKey): boolean { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + if (property === "length") return true; + const index = arrayIndex(property); + if (index !== undefined) return index < arrayLength(arrayOverlay(node)); + return property in Array.prototype; + } + return typeof property === "symbol" ? property in nodeBase(node) : objectHas(node, String(property)); +} + +function ownKeys(node: OverlayNode): ArrayLike { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const length = arrayLength(arrayOverlay(node)); + const keys = new Array(length + 1); + for (let index = 0; index < length; index++) keys[index] = String(index); + keys[length] = "length"; + return keys; + } + let existingKeysOnly = + node.deleteKey === undefined && + node.deletes === undefined && + node.readded === undefined && + (node.writeKey === undefined || Object.hasOwn(nodeBase(node), node.writeKey)); + if (existingKeysOnly && node.writes !== undefined) { + for (const key of node.writes.keys()) { + if (!Object.hasOwn(nodeBase(node), key)) { + existingKeysOnly = false; + break; + } + } + } + if (existingKeysOnly) return Object.keys(nodeBase(node)); + const keys = Object.keys(nodeBase(node)).filter((key) => !isObjectDeleted(node, key) && !node.readded?.has(key)); + const seen = new Set(keys); + if (node.writeKey !== undefined && !seen.has(node.writeKey)) { + keys.push(node.writeKey); + seen.add(node.writeKey); + } + if (node.writes !== undefined) { + for (const key of node.writes.keys()) { + if (seen.has(key)) continue; + keys.push(key); + seen.add(key); + } + } + const indices: number[] = []; + const strings: string[] = []; + for (const key of keys) { + const index = arrayIndex(key); + if (index === undefined) strings.push(key); + else indices.push(index); + } + indices.sort((left, right) => left - right); + return [...indices.map(String), ...strings]; +} + +function getDescriptor(node: OverlayNode, property: PropertyKey): PropertyDescriptor | undefined { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + if (property === "length") { + const length = arrayLength(arrayOverlay(node)); + (node.target as unknown[]).length = length; + return Reflect.getOwnPropertyDescriptor(node.target, "length"); + } + const index = arrayIndex(property); + if (index === undefined || index >= arrayLength(arrayOverlay(node))) return undefined; + } else { + if (typeof property === "symbol" || !objectHas(node, String(property))) return undefined; + } + return { + configurable: true, + enumerable: true, + writable: node.context.status.value === "open", + value: getProperty(node, property), + }; +} + +function hasObjectWrite(node: OverlayNode, key: string): boolean { + return node.writeKey === key || (node.writes?.has(key) ?? false); +} + +function setObjectWrite(node: OverlayNode, key: string, value: Stored): void { + if (node.writes !== undefined) { + const reference = node.writes.get(key); + node.writes.set( + key, + reference === undefined ? storeValue(node.context, value) : replaceStoredValue(node.context, reference, value), + ); + return; + } + if (node.writeKey === undefined || node.writeKey === key) { + node.writeKey = key; + node.writeValue = + node.writeValue === undefined + ? storeValue(node.context, value) + : replaceStoredValue(node.context, node.writeValue, value); + return; + } + node.writes = new Map(); + node.writes.set(node.writeKey, node.writeValue!); + node.writes.set(key, storeValue(node.context, value)); + node.writeKey = undefined; + node.writeValue = undefined; +} + +function deleteObjectWrite(node: OverlayNode, key: string): void { + if (node.writeKey === key) { + node.writeKey = undefined; + releaseStoredValue(node.context, node.writeValue!); + node.writeValue = undefined; + } else { + const reference = node.writes?.get(key); + if (reference !== undefined) releaseStoredValue(node.context, reference); + node.writes?.delete(key); + } +} + +function isObjectDeleted(node: OverlayNode, key: string): boolean { + return node.deleteKey === key || (node.deletes?.has(key) ?? false); +} + +function setObjectDeletion(node: OverlayNode, key: string): void { + if (node.deletes !== undefined) { + node.deletes.add(key); + return; + } + if (node.deleteKey === undefined || node.deleteKey === key) { + node.deleteKey = key; + return; + } + node.deletes = new Set(); + node.deletes.add(node.deleteKey); + node.deletes.add(key); + node.deleteKey = undefined; +} + +function deleteObjectDeletion(node: OverlayNode, key: string): void { + if (node.deleteKey === key) node.deleteKey = undefined; + else node.deletes?.delete(key); +} + +function objectHas(node: OverlayNode, key: string): boolean { + if (isObjectDeleted(node, key)) return false; + return hasObjectWrite(node, key) || Object.hasOwn(nodeBase(node), key); +} + +function objectValue(node: OverlayNode, key: string): Stored { + if (node.writeKey === key) return storedValue(node.context, node.writeValue!); + const reference = node.writes?.get(key); + if (reference !== undefined) return storedValue(node.context, reference); + return (nodeBase(node) as Record)[key]!; +} + +function markDirty(node: OverlayNode): void { + if (node.dirty) return; + node.dirty = true; + node.context.dirty.push(node); + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) parent.subtreeDirty = true; +} + +function arrayOverlay(node: OverlayNode): ArrayOverlay { + if (node.array !== undefined) return node.array; + const base = nodeBase(node) as JsonValue[]; + const overlay: ArrayOverlay = { + root: undefined, + pieces: undefined, + baseOverrides: undefined, + insertOverrides: undefined, + structural: false, + generation: 0, + plan: undefined, + seed: 0x9e3779b9, + locatedOffset: 0, + baseLocations: undefined, + insertLocations: undefined, + }; + if (base.length > 0) + overlay.root = createPieceNode(overlay, { kind: "base", start: 0, length: base.length, step: 1 }); + node.array = overlay; + return overlay; +} + +function baseOverridesForWrite(overlay: ArrayOverlay): Map { + if (overlay.baseOverrides === undefined) overlay.baseOverrides = new Map(); + return overlay.baseOverrides; +} + +function insertOverridesForWrite(overlay: ArrayOverlay): Map> { + if (overlay.insertOverrides === undefined) overlay.insertOverrides = new Map(); + return overlay.insertOverrides; +} + +function nextPiecePriority(overlay: ArrayOverlay): number { + let value = overlay.seed; + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + overlay.seed = value >>> 0; + return overlay.seed; +} + +function treeElements(node: PieceNode | undefined): number { + return node?.elements ?? 0; +} + +function updatePieceNode(node: PieceNode): void { + node.elements = treeElements(node.left) + node.piece.length + treeElements(node.right); +} + +function createPieceNode(overlay: ArrayOverlay, piece: Piece): PieceNode { + return { piece, left: undefined, right: undefined, priority: nextPiecePriority(overlay), elements: piece.length }; +} + +function mergePieceTrees(left: PieceNode | undefined, right: PieceNode | undefined): PieceNode | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + if (left.priority >= right.priority) { + left.right = mergePieceTrees(left.right, right); + updatePieceNode(left); + return left; + } + right.left = mergePieceTrees(left, right.left); + updatePieceNode(right); + return right; +} + +function splitPieceTree( + overlay: ArrayOverlay, + root: PieceNode | undefined, + index: number, +): [PieceNode | undefined, PieceNode | undefined] { + if (root === undefined) return [undefined, undefined]; + const leftLength = treeElements(root.left); + if (index < leftLength) { + const [left, right] = splitPieceTree(overlay, root.left, index); + root.left = right; + updatePieceNode(root); + return [left, root]; + } + const pieceEnd = leftLength + root.piece.length; + if (index > pieceEnd) { + const [left, right] = splitPieceTree(overlay, root.right, index - pieceEnd); + root.right = left; + updatePieceNode(root); + return [root, right]; + } + if (index === leftLength) { + const left = root.left; + root.left = undefined; + updatePieceNode(root); + return [left, root]; + } + if (index === pieceEnd) { + const right = root.right; + root.right = undefined; + updatePieceNode(root); + return [root, right]; + } + const offset = index - leftLength; + const first = { ...root.piece, length: offset }; + const second = { + ...root.piece, + start: root.piece.start + root.piece.step * offset, + length: root.piece.length - offset, + }; + return [ + mergePieceTrees(root.left, createPieceNode(overlay, first)), + mergePieceTrees(createPieceNode(overlay, second), root.right), + ]; +} + +function leftmostPieceNode(node: PieceNode): PieceNode { + while (node.left !== undefined) node = node.left; + return node; +} + +function rightmostPieceNode(node: PieceNode): PieceNode { + while (node.right !== undefined) node = node.right; + return node; +} + +function mergeablePieces(left: Piece, right: Piece): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "insert" && left.source !== (right as InsertPiece).source) return false; + if (left.length === 1 && right.length === 1) return Math.abs(right.start - left.start) === 1; + return left.step === right.step && left.start + left.step * left.length === right.start; +} + +function joinNormalized( + overlay: ArrayOverlay, + left: PieceNode | undefined, + right: PieceNode | undefined, +): PieceNode | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + const leftPiece = rightmostPieceNode(left).piece; + const rightPiece = leftmostPieceNode(right).piece; + if (!mergeablePieces(leftPiece, rightPiece)) return mergePieceTrees(left, right); + const [leftRest] = splitPieceTree(overlay, left, treeElements(left) - leftPiece.length); + const [, rightRest] = splitPieceTree(overlay, right, rightPiece.length); + const step = leftPiece.length === 1 ? ((rightPiece.start - leftPiece.start) as 1 | -1) : leftPiece.step; + const combined: Piece = + leftPiece.kind === "base" + ? { kind: "base", start: leftPiece.start, length: leftPiece.length + rightPiece.length, step } + : { + kind: "insert", + source: leftPiece.source, + start: leftPiece.start, + length: leftPiece.length + rightPiece.length, + step, + }; + return joinNormalized(overlay, joinNormalized(overlay, leftRest, createPieceNode(overlay, combined)), rightRest); +} + +function flattenPieceTree(node: PieceNode | undefined, output: Piece[]): void { + if (node === undefined) return; + flattenPieceTree(node.left, output); + output.push(node.piece); + flattenPieceTree(node.right, output); +} + +function piecesOf(overlay: ArrayOverlay): readonly Piece[] { + if (overlay.pieces === undefined) { + overlay.pieces = []; + flattenPieceTree(overlay.root, overlay.pieces); + } + return overlay.pieces; +} + +function treeFromPieces(overlay: ArrayOverlay, pieces: Piece[]): PieceNode | undefined { + mergePieces(pieces); + let root: PieceNode | undefined; + for (const piece of pieces) root = mergePieceTrees(root, createPieceNode(overlay, piece)); + return root; +} + +function replaceAllPieces(overlay: ArrayOverlay, pieces: Piece[]): void { + overlay.root = treeFromPieces(overlay, pieces); + overlay.pieces = undefined; + overlay.baseLocations = undefined; + overlay.insertLocations = undefined; +} + +function arrayLength(overlay: ArrayOverlay): number { + return treeElements(overlay.root); +} + +function locatePiece(overlay: ArrayOverlay, index: number): Piece { + let node = overlay.root; + while (node !== undefined) { + const leftLength = treeElements(node.left); + if (index < leftLength) node = node.left; + else if (index >= leftLength + node.piece.length) { + index -= leftLength + node.piece.length; + node = node.right; + } else { + overlay.locatedOffset = index - leftLength; + return node.piece; + } + } + throw new RangeError("Array overlay index is out of range"); +} + +function arrayIndex(property: PropertyKey): number | undefined { + if (typeof property !== "string" || property.length === 0 || property.length > 10) return undefined; + if (property === "0") return 0; + const first = property.charCodeAt(0); + if (first < 49 || first > 57) return undefined; + let index = first - 48; + for (let offset = 1; offset < property.length; offset++) { + const digit = property.charCodeAt(offset) - 48; + if (digit < 0 || digit > 9) return undefined; + index = index * 10 + digit; + if (index >= 4_294_967_295) return undefined; + } + return index; +} + +function entryValueAt(node: OverlayNode, piece: Piece, sourceIndex: number): Stored { + const overlay = arrayOverlay(node); + if (piece.kind === "base") { + const valueIndex = overlay.baseOverrides?.get(sourceIndex); + return valueIndex === undefined + ? (nodeBase(node) as JsonValue[])[sourceIndex]! + : storedValue(node.context, valueIndex); + } + const overrides = overlay.insertOverrides?.get(piece.source); + const reference = overrides?.has(sourceIndex) ? overrides.get(sourceIndex)! : piece.source.refs[sourceIndex]!; + return storedValue(node.context, reference); +} + +function hasEntryOverrideAt(overlay: ArrayOverlay, piece: Piece, sourceIndex: number): boolean { + return piece.kind === "base" + ? (overlay.baseOverrides?.has(sourceIndex) ?? false) + : (overlay.insertOverrides?.get(piece.source)?.has(sourceIndex) ?? false); +} + +function extendRightmostPiece(node: PieceNode, amount: number): void { + if (node.right !== undefined) extendRightmostPiece(node.right, amount); + else node.piece.length += amount; + updatePieceNode(node); +} + +function invalidatePieceCaches(overlay: ArrayOverlay): void { + overlay.pieces = undefined; + overlay.baseLocations = undefined; + overlay.insertLocations = undefined; + overlay.plan = undefined; +} + +function replacePieceRange(node: OverlayNode, index: number, remove: number, inserted: Piece[]): void { + if (remove === 0 && inserted.length === 0) return; + const overlay = arrayOverlay(node); + if (remove === 0 && index === arrayLength(overlay) && inserted.length === 1 && overlay.root !== undefined) { + const addition = inserted[0]!; + const tail = rightmostPieceNode(overlay.root).piece; + if ( + addition.kind === "insert" && + tail.kind === "insert" && + tail.step === 1 && + tail.start + tail.length === tail.source.refs.length + ) { + for (let offset = 0; offset < addition.length; offset++) { + tail.source.refs.push(addition.source.refs[addition.start + offset]!); + } + extendRightmostPiece(overlay.root, addition.length); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + markDirty(node); + return; + } + } + const [left, rest] = splitPieceTree(overlay, overlay.root, index); + const [, right] = splitPieceTree(overlay, rest, remove); + const middle = treeFromPieces(overlay, inserted); + overlay.root = joinNormalized(overlay, joinNormalized(overlay, left, middle), right); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + markDirty(node); +} + +function mergePieces(pieces: Piece[]): void { + for (let index = 1; index < pieces.length; ) { + const left = pieces[index - 1]!; + const right = pieces[index]!; + const sameSource = + left.kind === right.kind && (left.kind === "base" || left.source === (right as InsertPiece).source); + if (sameSource && left.length === 1 && right.length === 1 && Math.abs(right.start - left.start) === 1) { + left.step = (right.start - left.start) as 1 | -1; + left.length = 2; + pieces.splice(index, 1); + } else if (sameSource && left.step === right.step && left.start + left.step * left.length === right.start) { + left.length += right.length; + pieces.splice(index, 1); + } else index += 1; + } +} + +function insertPiece(node: OverlayNode, items: Stored[], start = 0): InsertPiece[] { + if (items.length === start) return []; + const refs: StoredRef[] = []; + for (let index = start; index < items.length; index++) refs.push(storeValue(node.context, items[index]!)); + return [{ kind: "insert", source: { refs }, start: 0, length: refs.length, step: 1 }]; +} + +function insertPlacementPiece(node: OverlayNode, values: unknown[], start = 0): InsertPiece[] { + if (values.length === start) return []; + const storedLength = node.context.stored!.length; + try { + for (let index = start; index < values.length; index++) + values[index] = storeValue(node.context, clonePlacement(values[index])); + } catch (error) { + node.context.stored!.length = storedLength; + throw error; + } + return [ + { + kind: "insert", + source: { refs: values as StoredRef[] }, + start, + length: values.length - start, + step: 1, + }, + ]; +} + +function setArrayIndex(node: OverlayNode, index: number, stored: Stored): void { + const overlay = arrayOverlay(node); + const length = arrayLength(overlay); + if (index === length) { + replacePieceRange(node, length, 0, insertPiece(node, [stored])); + return; + } + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const current = entryValueAt(node, piece, sourceIndex); + if (!isContainer(stored) && current === stored) return; + if (piece.kind === "base") { + const existingIndex = overlay.baseOverrides?.get(sourceIndex); + if (!isContainer(stored) && stored === (nodeBase(node) as JsonValue[])[sourceIndex]) { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overlay.baseOverrides?.delete(sourceIndex); + } else if (existingIndex === undefined) { + baseOverridesForWrite(overlay).set(sourceIndex, storeValue(node.context, stored)); + } else overlay.baseOverrides!.set(sourceIndex, replaceStoredValue(node.context, existingIndex, stored)); + } else { + let overrides = overlay.insertOverrides?.get(piece.source); + const existingIndex = overrides?.get(sourceIndex); + if (!isContainer(stored) && stored === storedValue(node.context, piece.source.refs[sourceIndex]!)) { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overrides?.delete(sourceIndex); + if (overrides?.size === 0) overlay.insertOverrides?.delete(piece.source); + } else { + if (overrides === undefined) { + overrides = new Map(); + insertOverridesForWrite(overlay).set(piece.source, overrides); + } + if (existingIndex === undefined) overrides.set(sourceIndex, storeValue(node.context, stored)); + else overrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, stored)); + } + } + markDirty(node); +} + +function setArrayLength(node: OverlayNode, next: number): void { + const current = arrayLength(arrayOverlay(node)); + if (next === current) return; + if (next < current) replacePieceRange(node, next, current - next, []); + else + replacePieceRange( + node, + current, + 0, + insertPiece( + node, + Array.from({ length: next - current }, () => null), + ), + ); +} + +function toArrayLength(value: unknown): number { + const number = Number(value); + if (!Number.isInteger(number) || number < 0 || number >= 4_294_967_296) throw new RangeError("Invalid array length"); + return number; +} + +function toIntegerOrInfinity(value: unknown): number { + const number = Number(value); + if (Number.isNaN(number) || number === 0) return 0; + return Number.isFinite(number) ? Math.trunc(number) : number; +} + +function clampIndex(value: number, length: number): number { + if (value === Number.NEGATIVE_INFINITY) return 0; + if (value < 0) return Math.max(length + value, 0); + return Math.min(value, length); +} + +function mutatorNode(receiver: unknown): OverlayNode | undefined { + if (!isContainer(receiver)) return undefined; + const node = Reflect.get(receiver, NODE) as OverlayNode | undefined; + if (node === undefined) return undefined; + if (!Array.isArray(nodeBase(node))) throw new TypeError("Array mutator called on incompatible receiver"); + assertWritable(node.context); + return node; +} + +const arrayMutators = { + push(this: unknown, ...items: unknown[]): number { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.push, this, items) as number; + const length = arrayLength(arrayOverlay(node)); + replacePieceRange(node, length, 0, insertPlacementPiece(node, items)); + return length + items.length; + }, + pop(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.pop, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) return undefined; + const value = getArrayIndex(node, length - 1); + replacePieceRange(node, length - 1, 1, []); + return value; + }, + shift(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.shift, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) return undefined; + const value = getArrayIndex(node, 0); + replacePieceRange(node, 0, 1, []); + return value; + }, + unshift(this: unknown, ...items: unknown[]): number { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.unshift, this, items) as number; + replacePieceRange(node, 0, 0, insertPlacementPiece(node, items)); + return arrayLength(arrayOverlay(node)); + }, + splice(this: unknown, ...args: unknown[]): unknown[] { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[]; + const length = arrayLength(arrayOverlay(node)); + const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); + const remove = + args.length === 0 + ? 0 + : args.length === 1 + ? length - start + : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); + const removed = Array.from({ length: remove }, (_, offset) => getArrayIndex(node, start + offset)); + const itemStart = Math.min(2, args.length); + const itemCount = Math.max(args.length - 2, 0); + replacePieceRange(node, start, remove, insertPlacementPiece(node, args, itemStart)); + setArrayLength(node, length - remove + itemCount); + return removed; + }, + reverse(this: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.reverse, this, []); + const overlay = arrayOverlay(node); + if (arrayLength(overlay) < 2) return node.proxy; + const pieces = [...piecesOf(overlay)].reverse(); + for (const piece of pieces) { + piece.start += piece.step * (piece.length - 1); + piece.step = piece.step === 1 ? -1 : 1; + } + replaceAllPieces(overlay, pieces); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = undefined; + markDirty(node); + return node.proxy; + }, + sort(this: unknown, comparator?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]); + if (comparator !== undefined && typeof comparator !== "function") + throw new TypeError("Comparator must be a function"); + const overlay = arrayOverlay(node); + const insertedSources: InsertSource[] = []; + const insertedIndices: number[] = []; + const insertedValues: unknown[] = []; + const baseValues: unknown[] = new Array((nodeBase(node) as JsonValue[]).length); + const order: number[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + if (piece.kind === "base") { + order.push(sourceIndex); + baseValues[sourceIndex] = publicSortValue(node, sourceIndex, insertedSources, insertedIndices); + } else { + insertedSources.push(piece.source); + insertedIndices.push(sourceIndex); + const token = -insertedSources.length; + order.push(token); + insertedValues.push(publicSortValue(node, token, insertedSources, insertedIndices)); + } + } + } + const baseSnapshot = new Map(); + for (const [index, valueIndex] of overlay.baseOverrides ?? []) + baseSnapshot.set(index, storedValue(node.context, valueIndex)); + const insertSnapshots = new Map>(); + for (const [source, overrides] of overlay.insertOverrides ?? []) { + const snapshot = new Map(); + for (const [index, valueIndex] of overrides) snapshot.set(index, storedValue(node.context, valueIndex)); + insertSnapshots.set(source, snapshot); + } + const generation = overlay.generation; + order.sort((left, right) => { + const leftValue = left < 0 ? insertedValues[-left - 1] : baseValues[left]; + const rightValue = right < 0 ? insertedValues[-right - 1] : baseValues[right]; + if (typeof comparator === "function") return Number(comparator(leftValue, rightValue)); + const a = String(leftValue); + const b = String(rightValue); + return a < b ? -1 : a > b ? 1 : 0; + }); + if ( + baseSnapshot.size > 0 || + insertSnapshots.size > 0 || + (overlay.baseOverrides?.size ?? 0) > 0 || + (overlay.insertOverrides?.size ?? 0) > 0 + ) { + for (const token of order) { + restoreSortOverride(node, token, insertedSources, insertedIndices, baseSnapshot, insertSnapshots); + } + } + const comparatorWasStructural = overlay.generation !== generation; + const currentLength = arrayLength(overlay); + const samePrefix = + currentLength >= order.length && + order.every((token, index) => sameSortTokenAt(overlay, index, token, insertedSources, insertedIndices)); + if (!samePrefix) { + replacePieceRange( + node, + 0, + Math.min(order.length, currentLength), + piecesFromSortOrder(order, insertedSources, insertedIndices), + ); + } + if (comparatorWasStructural) deduplicateArrayEntries(node); + return node.proxy; + }, + fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + if (end <= start) return node.proxy; + const items = Array.from({ length: end - start }, () => clonePlacement(supplied)); + replacePieceRange(node, start, end - start, insertPiece(node, items)); + return node.proxy; + }, + copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown { + const node = mutatorNode(this); + if (node === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const target = clampIndex(toIntegerOrInfinity(targetArg), length); + const start = clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length); + const count = Math.min(Math.max(end - start, 0), length - target); + const values = Array.from({ length: count }, (_, offset) => clonePlacement(getArrayIndex(node, start + offset))); + replacePieceRange(node, target, count, insertPiece(node, values)); + return node.proxy; + }, +}; + +function sortTokenSource(token: number, sources: readonly InsertSource[]): InsertSource | undefined { + return token < 0 ? sources[-token - 1] : undefined; +} + +function sortTokenIndex(token: number, indices: readonly number[]): number { + return token < 0 ? indices[-token - 1]! : token; +} + +function publicSortValue( + node: OverlayNode, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], +): unknown { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + const overrideIndex = + source === undefined + ? overlay.baseOverrides?.get(sourceIndex) + : overlay.insertOverrides?.get(source)?.get(sourceIndex); + const value = + overrideIndex !== undefined + ? storedValue(node.context, overrideIndex) + : source === undefined + ? (nodeBase(node) as JsonValue[])[sourceIndex]! + : storedValue(node.context, source.refs[sourceIndex]!); + if (!isContainer(value)) return value; + return createNode( + node.context, + value, + node, + source === undefined ? 1 : 2, + sourceIndex, + source, + source !== undefined || (overlay.baseOverrides?.has(sourceIndex) ?? false), + ).proxy; +} + +function restoreSortOverride( + node: OverlayNode, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], + baseSnapshot: ReadonlyMap, + insertSnapshots: ReadonlyMap>, +): void { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + if (source === undefined) { + const existingIndex = overlay.baseOverrides?.get(sourceIndex); + const snapshotValue = baseSnapshot.get(sourceIndex); + if (snapshotValue !== undefined) { + if (existingIndex === undefined) + baseOverridesForWrite(overlay).set(sourceIndex, storeValue(node.context, snapshotValue)); + else overlay.baseOverrides!.set(sourceIndex, replaceStoredValue(node.context, existingIndex, snapshotValue)); + } else { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overlay.baseOverrides?.delete(sourceIndex); + } + return; + } + let overrides = overlay.insertOverrides?.get(source); + const existingIndex = overrides?.get(sourceIndex); + const snapshotValue = insertSnapshots.get(source)?.get(sourceIndex); + if (snapshotValue !== undefined) { + if (overrides === undefined) { + overrides = new Map(); + insertOverridesForWrite(overlay).set(source, overrides); + } + if (existingIndex === undefined) overrides.set(sourceIndex, storeValue(node.context, snapshotValue)); + else overrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, snapshotValue)); + } else { + if (existingIndex !== undefined) releaseStoredValue(node.context, existingIndex); + overrides?.delete(sourceIndex); + if (overrides?.size === 0) overlay.insertOverrides?.delete(source); + } +} + +function sameSortTokenAt( + overlay: ArrayOverlay, + logicalIndex: number, + token: number, + sources: readonly InsertSource[], + indices: readonly number[], +): boolean { + const piece = locatePiece(overlay, logicalIndex); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const tokenSource = sortTokenSource(token, sources); + return ( + sourceIndex === sortTokenIndex(token, indices) && + ((piece.kind === "base" && tokenSource === undefined) || + (piece.kind === "insert" && piece.source === tokenSource)) + ); +} + +function deduplicateArrayEntries(node: OverlayNode): void { + const overlay = arrayOverlay(node); + const seenBase = new Set(); + const seenInsert = new Map>(); + const next: Piece[] = []; + let duplicated = false; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + let seen: boolean; + if (piece.kind === "base") { + seen = seenBase.has(sourceIndex); + seenBase.add(sourceIndex); + } else { + let indices = seenInsert.get(piece.source); + if (indices === undefined) { + indices = new Set(); + seenInsert.set(piece.source, indices); + } + seen = indices.has(sourceIndex); + indices.add(sourceIndex); + } + if (seen) { + duplicated = true; + appendMergedPiece( + next, + insertPiece(node, [clonePlacementStored(entryValueAt(node, piece, sourceIndex), node.context)])[0]!, + ); + } else appendMergedPiece(next, singletonPiece(piece, sourceIndex)); + } + } + if (!duplicated) return; + replaceAllPieces(overlay, next); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = undefined; + markDirty(node); +} + +function piecesFromSortOrder( + order: readonly number[], + sources: readonly InsertSource[], + indices: readonly number[], +): Piece[] { + const pieces: Piece[] = []; + for (const token of order) { + const source = token < 0 ? sources[-token - 1] : undefined; + const sourceIndex = token < 0 ? indices[-token - 1]! : token; + const previous = pieces.at(-1); + const sameSource = + previous !== undefined && + ((source === undefined && previous.kind === "base") || + (source !== undefined && previous.kind === "insert" && previous.source === source)); + if (previous !== undefined && sameSource) { + if (previous.length === 1) { + const step = sourceIndex - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + continue; + } + } else if (previous.start + previous.step * previous.length === sourceIndex) { + previous.length += 1; + continue; + } + } + pieces.push( + source === undefined + ? { kind: "base", start: sourceIndex, length: 1, step: 1 } + : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }, + ); + } + return pieces; +} + +function appendMergedPiece(pieces: Piece[], piece: Piece): void { + const previous = pieces.at(-1); + const sameSource = + previous !== undefined && + previous.kind === piece.kind && + (previous.kind === "base" || previous.source === (piece as InsertPiece).source); + if (previous !== undefined && sameSource && previous.length === 1 && piece.length === 1) { + const step = piece.start - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + return; + } + } + if ( + previous !== undefined && + sameSource && + previous.step === piece.step && + previous.start + previous.step * previous.length === piece.start + ) { + previous.length += piece.length; + } else pieces.push(piece); +} + +function singletonPiece(piece: Piece, sourceIndex: number): Piece { + return piece.kind === "base" + ? { kind: "base", start: sourceIndex, length: 1, step: 1 } + : { kind: "insert", source: piece.source, start: sourceIndex, length: 1, step: 1 }; +} + +function clonePlacement(value: unknown): Stored { + const proxyNode = isContainer(value) ? (Reflect.get(value, NODE) as OverlayNode | undefined) : undefined; + return proxyNode === undefined ? cloneJson(value) : clonePlacementNode(proxyNode); +} + +function cloneJson(value: unknown, ancestors?: Set): Stored { + if (!isContainer(value)) return strictPrimitive(value); + const active = ancestors ?? new Set(); + if (active.has(value)) throw new TypeError("Draft placements cannot contain cycles"); + active.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1) { + throw new TypeError("Draft placements must contain dense plain arrays"); + } + const result = new Array(value.length); + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Draft placements must contain enumerable indexed data properties"); + } + defineData(result, String(index), cloneJson(descriptor.value, active)); + } + return result; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Draft placements must contain plain objects or arrays"); + } + const result = Object.create(prototype) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key === "symbol") throw new TypeError("Draft placements cannot contain symbol properties"); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Draft placements must contain enumerable data properties"); + } + defineData(result, key, cloneJson(descriptor.value, active)); + } + return result; + } finally { + active.delete(value); + } +} + +function strictPrimitive(value: unknown): Primitive { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + throw new TypeError("Draft placements must contain strict JSON values"); +} + +function cloneNode(node: OverlayNode): Container { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result; + } + const result = Object.create(Object.getPrototypeOf(nodeBase(node))) as Record; + for (const key of ownKeys(node) as string[]) + defineData(result, key, cloneStored(objectValue(node, key), node.context)); + return result; +} + +function cloneStored(value: Stored, context: OverlayContext): Stored { + if (!isContainer(value)) return value; + const node = context.rawNodes!.get(value); + return node === undefined || (!node.dirty && !node.subtreeDirty) ? value : cloneNode(node); +} + +function clonePlacementNode(node: OverlayNode): Container { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result.push(clonePlacementStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result; + } + const result = Object.create(Object.getPrototypeOf(nodeBase(node))) as Record; + for (const key of ownKeys(node) as string[]) + defineData(result, key, clonePlacementStored(objectValue(node, key), node.context)); + return result; +} + +function clonePlacementStored(value: Stored, context: OverlayContext): Stored { + if (!isContainer(value)) return strictPrimitive(value); + const node = context.rawNodes!.get(value); + return node === undefined ? cloneJson(value) : clonePlacementNode(node); +} + +function defineData(target: object, key: PropertyKey, value: JsonValue): void { + DATA_DESCRIPTOR.value = value; + Object.defineProperty(target, key, DATA_DESCRIPTOR); + DATA_DESCRIPTOR.value = undefined; +} + +function emitOperations(context: OverlayContext): Op[] { + const simple = emitSimpleObjectOperations(context); + if (simple !== undefined) return simple; + const operations: Op[] = []; + const forcedFolds = new Map(); + const emissionPaths = new Map(); + const denseIndices = new Map(); + for (const node of context.dirty) { + recordDenseArrayPosition(node, denseIndices); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + if (!overlay.structural && (overlay.baseOverrides?.size ?? 0) > 0) { + let candidates = denseIndices.get(node); + if (candidates === undefined) { + candidates = { indices: [], bits: undefined, length: (nodeBase(node) as JsonValue[]).length }; + denseIndices.set(node, candidates); + } + for (const index of overlay.baseOverrides!.keys()) addDenseCandidate(candidates, index); + } + } + } + const denseRegions = new Map(); + for (const [array, candidates] of denseIndices) { + const path = resolvePath(array); + if (path === undefined || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { + continue; + } + const regions = buildDenseRegions(candidates); + if (regions.length > 0) denseRegions.set(array, regions); + } + for (const node of context.dirty) { + const path = resolvePath(node); + if (path === undefined) continue; + if (hasCoveringDenseRegion(node, path, denseRegions)) continue; + emissionPaths.set(node, path); + if (!Array.isArray(nodeBase(node)) && hasReservedMutation(node)) forcedFolds.set(node, path); + const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); + if (reservedAt >= 0) { + let ancestor = node; + for (let depth = path.length; depth > reservedAt; depth--) ancestor = nodeParent(ancestor)!; + forcedFolds.set(ancestor, path.slice(0, reservedAt)); + } + } + for (const [node, path] of forcedFolds) emissionPaths.set(node, path); + for (const node of denseRegions.keys()) { + const path = resolvePath(node); + if (path !== undefined && !hasCoveringDenseRegion(node, path, denseRegions)) emissionPaths.set(node, path); + } + let maxDepth = 0; + for (const path of emissionPaths.values()) maxDepth = Math.max(maxDepth, path.length); + const buckets: OverlayNode[][] = Array.from({ length: maxDepth + 1 }, () => []); + for (const [node, path] of emissionPaths) buckets[path.length]!.push(node); + const folded = new Set(); + for (const bucket of buckets) { + for (const node of bucket) { + const path = emissionPaths.get(node)!; + if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) continue; + if (forcedFolds.has(node)) { + emitSet(operations, path, cloneNode(node)); + folded.add(node); + continue; + } + if (Array.isArray(nodeBase(node))) emitArrayOperations(node, path, operations, denseRegions.get(node)); + else emitObjectOperations(node, path, operations); + if (operations.length > MAX_DELTA_OPERATIONS) { + locatedDenseArray = undefined; + return [["r", cloneNode(context.root!)]]; + } + } + } + locatedDenseArray = undefined; + return operations; +} + +function emitSimpleObjectOperations(context: OverlayContext): Op[] | undefined { + const nodes: OverlayNode[] = []; + for (const node of context.dirty) { + if (Array.isArray(nodeBase(node)) || hasReservedMutation(node) || hasPlacementAncestor(node)) return undefined; + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) { + if (Array.isArray(nodeBase(parent))) return undefined; + } + const path = resolvePath(node); + if (path === undefined) continue; + for (const segment of path) { + if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) return undefined; + } + nodes.push(node); + if (nodes.length > MAX_SIMPLE_OBJECT_NODES) return undefined; + } + // Stable insertion sort avoids comparator/bucket allocations for the usual + // handful of dirty object nodes. + for (let index = 1; index < nodes.length; index++) { + const node = nodes[index]!; + const depth = node.preparedPath!.length; + let at = index; + while (at > 0 && nodes[at - 1]!.preparedPath!.length > depth) { + nodes[at] = nodes[at - 1]!; + at -= 1; + } + nodes[at] = node; + } + const operations: Op[] = []; + let canMaterializeDirectly = true; + for (const node of nodes) { + if (emitObjectOperations(node, node.preparedPath!, operations)) canMaterializeDirectly = false; + if (operations.length > MAX_DELTA_OPERATIONS) return [["r", cloneNode(context.root!)]]; + } + context.simpleObjectMaterialization = canMaterializeDirectly; + return operations; +} + +let locatedDenseArray: OverlayNode | undefined; +let locatedDenseIndex = 0; + +function locateDenseArrayPosition(node: OverlayNode): boolean { + let child = node; + for (let parent = nodeParent(child); parent !== undefined; parent = nodeParent(child)) { + if (Array.isArray(nodeBase(parent))) { + const overlay = arrayOverlay(parent); + if (child.parentKind !== 1 || overlay.structural) return false; + const sourceIndex = child.parentKey as number; + const valueIndex = overlay.baseOverrides?.get(sourceIndex); + const current = + valueIndex === undefined + ? (nodeBase(parent) as JsonValue[])[sourceIndex] + : storedValue(parent.context, valueIndex); + if (current !== nodeBase(child)) return false; + locatedDenseArray = parent; + locatedDenseIndex = sourceIndex; + return true; + } + child = parent; + } + return false; +} + +function hasCoveringDenseRegion( + node: OverlayNode, + path: Path, + denseRegions: ReadonlyMap, +): boolean { + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) { + const regions = denseRegions.get(parent); + if (regions === undefined) continue; + const parentPath = resolvePath(parent); + if (parentPath === undefined || path.length <= parentPath.length) continue; + let matches = true; + for (let index = 0; index < parentPath.length; index++) { + if (path[index] !== parentPath[index]) { + matches = false; + break; + } + } + if (!matches) continue; + const index = path[parentPath.length]; + if (typeof index === "number" && regionContaining(regions, index)) return true; + } + return false; +} + +function addDenseCandidate(candidates: DenseCandidates, index: number): void { + if (candidates.bits !== undefined) { + candidates.bits[index] = 1; + return; + } + candidates.indices.push(index); + if (candidates.indices.length < 256) return; + candidates.bits = new Uint8Array(candidates.length); + for (const existing of candidates.indices) candidates.bits[existing] = 1; + candidates.indices.length = 0; +} + +function recordDenseArrayPosition(node: OverlayNode, groups: Map): void { + if (!locateDenseArrayPosition(node)) return; + let candidates = groups.get(locatedDenseArray!); + if (candidates === undefined) { + candidates = { indices: [], bits: undefined, length: (nodeBase(locatedDenseArray!) as JsonValue[]).length }; + groups.set(locatedDenseArray!, candidates); + } + addDenseCandidate(candidates, locatedDenseIndex); +} + +function buildDenseRegions(candidates: DenseCandidates): DenseRegion[] { + if (candidates.bits === undefined) return []; + const regions: DenseRegion[] = []; + const bits = candidates.bits; + for (let at = 0; at < bits.length; ) { + while (at < bits.length && bits[at] === 0) at += 1; + if (at === bits.length) break; + const start = at; + let end = at; + let count = 0; + let gap = 0; + while (at < bits.length) { + if (bits[at] !== 0) { + count += 1; + end = at; + gap = 0; + } else if (++gap > 1) break; + at += 1; + } + const length = end - start + 1; + if (count >= 256 && count * 2 >= length) regions.push({ start, length }); + } + return regions; +} + +function regionContaining(regions: readonly DenseRegion[], index: number): boolean { + for (const region of regions) if (index >= region.start && index < region.start + region.length) return true; + return false; +} + +function hasReservedMutation(node: OverlayNode): boolean { + if (node.writeKey !== undefined && RESERVED_SEGMENTS.has(node.writeKey)) return true; + if (node.deleteKey !== undefined && RESERVED_SEGMENTS.has(node.deleteKey)) return true; + if (node.writes !== undefined) { + for (const key of node.writes.keys()) if (RESERVED_SEGMENTS.has(key)) return true; + } + if (node.deletes !== undefined) { + for (const key of node.deletes) if (RESERVED_SEGMENTS.has(key)) return true; + } + return false; +} + +function hasFoldedAncestor(node: OverlayNode, folded: ReadonlySet): boolean { + for (let parent = nodeParent(node); parent !== undefined; parent = nodeParent(parent)) + if (folded.has(parent)) return true; + return false; +} + +function hasPlacementAncestor(node: OverlayNode): boolean { + for (let current: OverlayNode | undefined = node; current !== undefined; current = nodeParent(current)) { + if (nodeParent(current) !== undefined && current.parentPlacement) return true; + } + return false; +} + +function emitObjectWrite(node: OverlayNode, path: Path, operations: Op[], key: string, value: Stored): boolean { + const nextPath = [...path, key] as unknown as NonEmptyPath; + const before = + node.readded?.has(key) || !Object.hasOwn(nodeBase(node), key) + ? undefined + : (nodeBase(node) as Record)[key]; + const after = cloneStored(value, node.context); + const emitted = emitChangedValue(operations, nextPath, before, after); + return isContainer(before) && isContainer(after) && !emitted; +} + +function emitObjectOperations(node: OverlayNode, path: Path, operations: Op[]): boolean { + // Immutable replay must encode delete-and-readd explicitly so string-key + // insertion order matches the draft. + if (node.readded !== undefined) { + for (const key of node.readded) { + if (Object.hasOwn(nodeBase(node), key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); + } + } + let normalizedContainerWrite = false; + if (node.writeKey !== undefined) + normalizedContainerWrite = emitObjectWrite( + node, + path, + operations, + node.writeKey, + objectValue(node, node.writeKey), + ); + if (node.writes !== undefined) { + for (const [key, valueIndex] of node.writes) { + if (operations.length > MAX_DELTA_OPERATIONS) return normalizedContainerWrite; + if (emitObjectWrite(node, path, operations, key, storedValue(node.context, valueIndex))) + normalizedContainerWrite = true; + } + } + if (node.deleteKey !== undefined && Object.hasOwn(nodeBase(node), node.deleteKey)) { + operations.push(["d", [...path, node.deleteKey] as unknown as NonEmptyPath]); + } + if (node.deletes !== undefined) { + for (const key of node.deletes) { + if (operations.length > MAX_DELTA_OPERATIONS) return normalizedContainerWrite; + if (Object.hasOwn(nodeBase(node), key)) operations.push(["d", [...path, key] as unknown as NonEmptyPath]); + } + } + return normalizedContainerWrite; +} + +function buildArrayPlan(node: OverlayNode): ArrayPlan { + const overlay = arrayOverlay(node); + if (overlay.plan !== undefined) return overlay.plan; + const base = nodeBase(node) as JsonValue[]; + const pieces = piecesOf(overlay); + const retained = new Uint8Array(base.length); + const targetBase: number[] = []; + for (const piece of pieces) { + if (piece.kind !== "base") continue; + for (let offset = 0; offset < piece.length; offset++) { + const index = piece.start + piece.step * offset; + retained[index] = 1; + targetBase.push(index); + } + } + const removeRuns: number[] = []; + for (let end = base.length; end > 0; ) { + if (retained[end - 1] !== 0) { + end -= 1; + continue; + } + let start = end - 1; + while (start > 0 && retained[start - 1] === 0) start -= 1; + removeRuns.push(start, end - start); + end = start; + } + const retainedBase: number[] = []; + for (let index = 0; index < retained.length; index++) if (retained[index] !== 0) retainedBase.push(index); + let permutation: number[] | undefined; + if (targetBase.some((value, index) => value !== retainedBase[index])) { + const positions = new Map(retainedBase.map((value, index) => [value, index])); + permutation = targetBase.map((value) => positions.get(value)!); + } + const insertRuns: number[] = []; + let logicalIndex = 0; + for (let pieceIndex = 0; pieceIndex < pieces.length; ) { + const piece = pieces[pieceIndex]!; + if (piece.kind === "base") { + logicalIndex += piece.length; + pieceIndex += 1; + continue; + } + const startPiece = pieceIndex; + while (pieceIndex < pieces.length && pieces[pieceIndex]!.kind === "insert") { + logicalIndex += pieces[pieceIndex]!.length; + pieceIndex += 1; + } + insertRuns.push(logicalIndex - rangeLength(pieces, startPiece, pieceIndex), startPiece, pieceIndex); + } + overlay.plan = { removeRuns, permutation, insertRuns }; + return overlay.plan; +} + +function rangeLength(pieces: readonly Piece[], start: number, end: number): number { + let length = 0; + for (let index = start; index < end; index++) length += pieces[index]!.length; + return length; +} + +function emitArrayOperations( + node: OverlayNode, + path: Path, + operations: Op[], + denseRegions: readonly DenseRegion[] | undefined, +): void { + const overlay = arrayOverlay(node); + const base = nodeBase(node) as JsonValue[]; + if (denseRegions !== undefined) { + for (const region of denseRegions) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + operations.push(["p", path, region.start, region.length, cloneArrayRegion(node, region)]); + } + } + if (overlay.structural) { + const plan = buildArrayPlan(node); + for (let index = 0; index < plan.removeRuns.length; index += 2) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + operations.push(["p", path, plan.removeRuns[index]!, plan.removeRuns[index + 1]!, []]); + } + if (plan.permutation !== undefined) operations.push(["m", path, plan.permutation]); + const pieces = piecesOf(overlay); + for (let run = 0; run < plan.insertRuns.length; run += 3) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + const logicalIndex = plan.insertRuns[run]!; + const items: JsonValue[] = []; + for (let pieceIndex = plan.insertRuns[run + 1]!; pieceIndex < plan.insertRuns[run + 2]!; pieceIndex++) { + const piece = pieces[pieceIndex]! as InsertPiece; + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + operations.push(["p", path, logicalIndex, 0, items]); + } + } + for (const [baseIndex, valueIndex] of overlay.baseOverrides ?? []) { + if (operations.length > MAX_DELTA_OPERATIONS) return; + const index = findEntryIndex(overlay, 1, baseIndex, undefined); + if (index === undefined || (denseRegions !== undefined && regionContaining(denseRegions, index))) continue; + emitChangedValue( + operations, + [...path, index] as unknown as NonEmptyPath, + base[baseIndex], + cloneStored(storedValue(node.context, valueIndex), node.context), + ); + } +} + +function cloneArrayRegion(node: OverlayNode, region: DenseRegion): JsonValue[] { + const overlay = arrayOverlay(node); + const result: JsonValue[] = []; + for (let index = region.start; index < region.start + region.length; index++) { + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + return result; +} + +function emitChangedValue( + operations: Op[], + path: NonEmptyPath, + before: JsonValue | undefined, + after: JsonValue, +): boolean { + if (!isContainer(after) && before === after) return false; + if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false; + if (typeof before === "string" && typeof after === "string") { + // Slice/equality stays on V8's rope fast path; startsWith scans the entire + // accumulated prefix on every append. + if (after.length > before.length && after.slice(0, before.length) === before) { + operations.push(["a", path, after.slice(before.length)]); + return true; + } + const shared = overlap(before, after, 65_536); + if (shared > 0) { + operations.push(["t", path, before.length - shared]); + if (after.length > shared) operations.push(["a", path, after.slice(shared)]); + return true; + } + } + operations.push(["s", path, after]); + return true; +} + +function emitSet(operations: Op[], path: Path, value: JsonValue): void { + if (path.length === 0) operations.push(["r", value]); + else operations.push(["s", path as NonEmptyPath, value]); +} + +function resolvePath(node: OverlayNode): Path | undefined { + if (node.preparedPath !== undefined) return node.preparedPath; + const parent = nodeParent(node); + if (parent === undefined) { + node.preparedPath = []; + return node.preparedPath; + } + const parentPath = resolvePath(parent); + if (parentPath === undefined) return undefined; + if (node.parentKind === 0) { + const key = node.parentKey as string; + if (!objectHas(parent, key) || objectValue(parent, key) !== nodeBase(node)) return undefined; + node.preparedPath = [...parentPath, key]; + return node.preparedPath; + } + const overlay = arrayOverlay(parent); + const index = findEntryIndex(overlay, node.parentKind, node.parentKey as number, node.parentSource); + if (index === undefined) return undefined; + const piece = locatePiece(overlay, index); + if (entryValueAt(parent, piece, node.parentKey as number) !== nodeBase(node)) return undefined; + node.preparedPath = [...parentPath, index]; + return node.preparedPath; +} + +function ensurePieceLocations(overlay: ArrayOverlay): void { + if (overlay.baseLocations !== undefined) return; + overlay.baseLocations = []; + overlay.insertLocations = new Map(); + let logicalStart = 0; + for (const piece of piecesOf(overlay)) { + const last = piece.start + piece.step * (piece.length - 1); + const location = { + piece, + logicalStart, + minimum: Math.min(piece.start, last), + maximum: Math.max(piece.start, last), + }; + if (piece.kind === "base") overlay.baseLocations.push(location); + else { + let locations = overlay.insertLocations.get(piece.source); + if (locations === undefined) { + locations = []; + overlay.insertLocations.set(piece.source, locations); + } + locations.push(location); + } + logicalStart += piece.length; + } + const byMinimum = (left: PieceLocation, right: PieceLocation): number => left.minimum - right.minimum; + overlay.baseLocations.sort(byMinimum); + for (const locations of overlay.insertLocations.values()) locations.sort(byMinimum); +} + +function findEntryIndex( + overlay: ArrayOverlay, + kind: ParentKind, + sourceIndex: number, + source: InsertSource | undefined, +): number | undefined { + if (kind === 1 && !overlay.structural) return sourceIndex; + ensurePieceLocations(overlay); + const locations = kind === 1 ? overlay.baseLocations! : overlay.insertLocations!.get(source!); + if (locations === undefined) return undefined; + let low = 0; + let high = locations.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (locations[middle]!.minimum <= sourceIndex) low = middle + 1; + else high = middle; + } + const location = locations[low - 1]; + if (location === undefined || sourceIndex > location.maximum) return undefined; + const offset = (sourceIndex - location.piece.start) / location.piece.step; + return offset >= 0 && offset < location.piece.length ? location.logicalStart + offset : undefined; +} + +function ensureOperations(context: OverlayContext): Op[] { + if (context.ops !== undefined) return context.ops; + assertReadable(context); + if (context.replacement) { + const base = context.tracker.deref()?.replacementBase(context); + context.replacementNoop = + base !== undefined && equalTrustedJson(base as unknown as JsonValue, nodeBase(context.root!) as JsonValue); + context.ops = context.replacementNoop ? [] : [["r", nodeBase(context.root!)]]; + } else context.ops = emitOperations(context); + return context.ops; +} + +function equalTrustedJson(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 (!equalTrustedJson(left[index]!, other[index]!)) return false; + return true; + } + const other = right as Record; + const keys = Object.keys(left); + if (keys.length !== Object.keys(other).length) return false; + for (const key of keys) if (!Object.hasOwn(other, key) || !equalTrustedJson(left[key]!, other[key]!)) return false; + return true; +} + +function abortContext(context: OverlayContext): void { + if (context.status.value === "aborted" || context.status.value === "consumed" || context.status.value === "stale") + return; + context.status.value = "aborted"; + clearContext(context); +} + +function releaseOverlayReferences(context: OverlayContext): void { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) locatedDenseArray = undefined; + context.overlayReleased = true; + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = undefined; + context.ops = undefined; + context.root = undefined; + context.bases = undefined; + context.stored = undefined; + context.baseValue = undefined; +} + +function clearContext(context: OverlayContext): void { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) locatedDenseArray = undefined; + for (const node of context.nodes) { + if (Array.isArray(node.target)) node.target.length = 0; + Reflect.deleteProperty(node.target, NODE); + node.writes?.clear(); + node.deletes?.clear(); + node.readded?.clear(); + if (node.array?.pieces !== undefined) node.array.pieces.length = 0; + if (node.array !== undefined) { + node.array.root = undefined; + node.array.baseLocations = undefined; + node.array.insertLocations = undefined; + } + node.array?.baseOverrides?.clear(); + node.array?.insertOverrides?.clear(); + node.parentSource = undefined; + node.target = RELEASED; + node.proxy = RELEASED; + node.writeKey = undefined; + node.writeValue = undefined; + node.writes = undefined; + node.deleteKey = undefined; + node.deletes = undefined; + node.readded = undefined; + node.array = undefined; + node.subtreeDirty = undefined; + node.preparedPath = undefined; + } + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = undefined; + context.ops = undefined; + context.root = undefined; + context.bases = undefined; + context.stored = undefined; + context.baseValue = undefined; + context.overlayReleased = true; +} + +function isContainer(value: unknown): value is Container { + return value !== null && typeof value === "object"; } diff --git a/packages/chord/src/delta/value.ts b/packages/chord/src/delta/value.ts deleted file mode 100644 index 1746e61c2..000000000 --- a/packages/chord/src/delta/value.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { JsonValue } from "../types.ts"; - -type JsonContainer = JsonValue[] | Record; - -const isContainer = (value: unknown): value is JsonContainer => typeof value === "object" && value !== null; - -/** Owns and freezes immutable JSON revisions while reusing already-owned subtrees. */ -export class JsonRevisionStore { - readonly #owned = new WeakSet(); - - import(value: T): T { - return this.#clone(value, new Set()) as T; - } - - commit(value: T, transactionOwned: WeakSet): T { - return this.#finish(value as JsonContainer, transactionOwned, new Set(), new WeakSet()) as T; - } - - adopt(value: T): T { - return this.#adopt(value, new Set(), new WeakSet()) as T; - } - - #clone(value: unknown, ancestors: Set): JsonValue { - if (!isContainer(value)) return assertPrimitive(value); - if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - const result = new Array(value.length); - for (let index = 0; index < value.length; index++) { - Object.defineProperty(result, index, { - value: this.#clone(value[index], ancestors), - writable: true, - enumerable: true, - configurable: true, - }); - } - this.#owned.add(result); - return Object.freeze(result) as unknown as JsonValue; - } - assertPlainObject(value); - const result = Object.create(Object.getPrototypeOf(value)) as Record; - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Replicated state objects must contain enumerable data properties"); - } - Object.defineProperty(result, key, { - value: this.#clone(descriptor.value, ancestors), - writable: true, - enumerable: true, - configurable: true, - }); - } - this.#owned.add(result); - return Object.freeze(result) as unknown as JsonValue; - } finally { - ancestors.delete(value); - } - } - - #adopt(value: JsonValue, ancestors: Set, finished: WeakSet): JsonValue { - if (!isContainer(value) || this.#owned.has(value)) return isContainer(value) ? value : assertPrimitive(value); - if (finished.has(value)) return value; - if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - for (let index = 0; index < value.length; index++) { - const child = this.#adopt(value[index]!, ancestors, finished); - if (child !== value[index]) defineValue(value, String(index), child as JsonContainer); - } - } else { - assertPlainObject(value); - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Replicated state objects must contain enumerable data properties"); - } - const child = this.#adopt(descriptor.value as JsonValue, ancestors, finished); - if (child !== descriptor.value && isContainer(child)) defineValue(value, key, child); - } - } - finished.add(value); - this.#owned.add(value); - return Object.freeze(value) as unknown as JsonValue; - } finally { - ancestors.delete(value); - } - } - - #finish( - value: JsonContainer, - transactionOwned: WeakSet, - ancestors: Set, - placements: WeakSet, - ): JsonContainer { - if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles"); - if (placements.has(value)) return this.#clone(value, new Set()) as JsonContainer; - placements.add(value); - if (this.#owned.has(value)) return value; - if (!transactionOwned.has(value)) return this.#clone(value, ancestors) as JsonContainer; - ancestors.add(value); - try { - if (Array.isArray(value)) { - assertDenseArray(value); - for (let index = 0; index < value.length; index++) { - const child = value[index]; - if (!isContainer(child)) { - assertPrimitive(child); - continue; - } - const next = this.#finish(child, transactionOwned, ancestors, placements); - if (next !== child) defineValue(value, String(index), next); - } - } else { - assertPlainObject(value); - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties"); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError("Replicated state objects must contain enumerable data properties"); - } - if (!isContainer(descriptor.value)) { - assertPrimitive(descriptor.value); - continue; - } - const next = this.#finish(descriptor.value, transactionOwned, ancestors, placements); - if (next !== descriptor.value) defineValue(value, key, next); - } - } - this.#owned.add(value); - return Object.freeze(value) as unknown as JsonContainer; - } finally { - ancestors.delete(value); - } - } -} - -function defineValue(target: object, key: string, value: JsonContainer): void { - Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); -} - -function assertPrimitive(value: unknown): null | boolean | number | string { - if (value === null || typeof value === "string" || typeof value === "boolean") return value; - if (typeof value === "number" && Number.isFinite(value)) return value; - throw new TypeError("Replicated state values must be strict JSON"); -} - -function assertPlainObject(value: object): void { - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError("Replicated state containers must be plain objects or arrays"); - } -} - -function assertDenseArray(value: readonly unknown[]): void { - const keys = Reflect.ownKeys(value); - if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) { - throw new TypeError("Replicated state arrays must be dense and contain only indexed entries"); - } - for (let index = 0; index < value.length; index++) { - 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", - ); - } - } -} diff --git a/packages/chord/src/services/state.ts b/packages/chord/src/services/state.ts index 37ee7c77f..395250f79 100644 --- a/packages/chord/src/services/state.ts +++ b/packages/chord/src/services/state.ts @@ -1,6 +1,6 @@ import { BACKGROUND_CONTEXT } from "../context/index.ts"; import { applyImmutable, isBase, type Op, type Prepared, type Tracker, track } from "../delta/index.ts"; -import { JsonRevisionStore } from "../delta/value.ts"; +import { JsonRevisionValidator } from "../delta/revision-validator.ts"; import type { AttachedReplicatedState, Context, @@ -265,7 +265,7 @@ export function attachReplicatedStateSource( export class ReplicatedStateReplica implements ReplicatedState { readonly #listeners = new Set>(); readonly #reportError: (error: Error) => void; - readonly #store = new JsonRevisionStore(); + readonly #validator = new JsonRevisionValidator(); #value: T | undefined; #sequence: number | undefined; @@ -292,7 +292,7 @@ export class ReplicatedStateReplica implements let next: T; try { if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch"); - next = this.#store.adopt(applyImmutable(undefined, ops)); + next = this.#validator.validate(applyImmutable(undefined, ops)); } catch (error) { this.clear(); throw error; @@ -312,7 +312,7 @@ export class ReplicatedStateReplica implements } let next: T; try { - next = this.#store.adopt(applyImmutable(this.#value, ops)); + next = this.#validator.validate(applyImmutable(this.#value, ops)); } catch (error) { this.clear(); throw error; diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts index 70810a8d5..cd887b5da 100644 --- a/packages/chord/src/types.ts +++ b/packages/chord/src/types.ts @@ -41,20 +41,26 @@ export interface ReplicatedStateDelivery { } export interface ReplicatedState { - /** Immutable value, or undefined until hydration. Later updates do not mutate previously returned values. */ + /** + * Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an + * in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values. + */ readonly value: T | undefined; - /** Listener values are immutable and may structurally share unchanged data with other revisions. */ + /** Listener values follow the same immutable ownership contract and may share unchanged revision data. */ subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void; } export interface MutableReplicatedState extends ReplicatedState { readonly value: T; /** - * Atomically publish one synchronous copy-on-write mutation. Draft handles are revoked when the callback returns. - * Async callbacks and non-JSON assignments, including `undefined`, throw without changing the value. + * Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns. + * Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it. */ change(context: Context, mutate: (draft: Draft) => void): void; - /** Atomically replace the complete value with a detached immutable JSON snapshot. */ + /** + * Atomically take immutable ownership of an alias-free strict-JSON replacement. + * The caller must not mutate the transferred root after this call. + */ replace(context: Context, value: T): void; } @@ -263,7 +269,10 @@ export interface FacetEnvironment { provide(service: Service, implementation: NoInfer): void; /** Declare ownership of a multi-instance service and return its deferred spawning capability. */ provideMany(service: Service): ServiceSpawner; - /** Create initialized mutable state suitable for exposing through a service implementation. */ + /** + * Create initialized mutable state by taking immutable ownership of an alias-free strict-JSON root. + * The caller must not mutate `initial` after this call. + */ replicatedState(initial: T): MutableReplicatedState; /** Give the facet ownership of a resource cleanup function. */ own(disposal: () => void | Promise): void; diff --git a/packages/chord/test/delta-astra/benchmark-head.worker.ts b/packages/chord/test/delta-astra/benchmark-head.worker.ts deleted file mode 100644 index f5c06b0ca..000000000 --- a/packages/chord/test/delta-astra/benchmark-head.worker.ts +++ /dev/null @@ -1,142 +0,0 @@ -import assert from "node:assert/strict"; -import { performance } from "node:perf_hooks"; -import { setImmediate } from "node:timers/promises"; -import { diffRevisions } from "../../src/delta/diff.ts"; -import { type Draft, produceWithMetadata } from "../../src/delta/draft.ts"; -import type { Op } from "../../src/delta/index.ts"; -import { JsonRevisionStore } from "../../src/delta/value.ts"; -import type { JsonValue } from "../../src/types.ts"; - -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Row = { id: number; value: number; payload: string }; -type Document = { rows: Row[] }; - -const MiB = 1024 * 1024; -const scenario = process.argv[2] as Scenario; -const size = Number(process.argv[3] ?? 100_000); -const trial = Number(process.argv[4] ?? 0); - -function fixture(count: number): Document { - return { - rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), - }; -} - -function sum(document: Document | Draft): number { - let checksum = 0; - for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; - return checksum; -} - -async function gc(): Promise { - assert.ok(global.gc, "benchmark worker requires --expose-gc"); - for (let index = 0; index < 3; index++) { - await setImmediate(); - global.gc(); - } -} - -await gc(); -const baselineHeap = process.memoryUsage().heapUsed; -let input: Document | undefined = fixture(size); -if (scenario === "sort") input.rows.reverse(); -const store = new JsonRevisionStore(); -const importStart = performance.now(); -let value = store.import(input); -const importMs = performance.now() - importStart; -input = undefined; -await gc(); -const readyHeap = process.memoryUsage().heapUsed; -let peakHeap = readyHeap; -let mutateMs = 0; -let prepareMs = 0; -let serializeMs = 0; -let adoptMs = 0; -let opBytes = 0; -let operationCount = 0; -let checksum = 0; -const queueOperations = Math.min(size, 1_000); - -if (scenario === "committed-read") { - const start = performance.now(); - checksum = sum(value); - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); -} else if (scenario !== "import") { - const draftStart = performance.now(); - const produced = produceWithMetadata(value, (draft) => { - const start = performance.now(); - switch (scenario) { - case "draft-read": - checksum = sum(draft); - break; - case "sparse": - for (let index = 0; index < size; index += 1_000) draft.rows[index]!.value = -index; - checksum = draft.rows.at(-1)!.value; - break; - case "queue": - for (let index = 0; index < queueOperations; index++) { - draft.rows.shift(); - draft.rows.push({ id: size + index, value: index, payload: "queue" }); - } - checksum = draft.rows.length; - break; - case "sort": - draft.rows.sort((left, right) => left.id - right.id); - checksum = draft.rows[0]!.id; - break; - case "dense": - for (let index = 0; index < size; index++) draft.rows[index]!.value = -index; - checksum = draft.rows.at(-1)!.value; - break; - case "unshift": { - const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); - Reflect.apply(draft.rows.unshift, draft.rows, inserted); - checksum = draft.rows.length; - break; - } - } - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - }); - const draftFinalizeMs = performance.now() - draftStart - mutateMs; - const prepareStart = performance.now(); - const next = produced.value === value ? value : store.commit(produced.value, produced.owned); - const operations: readonly Op[] = - next === value ? [] : diffRevisions(value as unknown as JsonValue, next as unknown as JsonValue); - prepareMs = draftFinalizeMs + performance.now() - prepareStart; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const serializeStart = performance.now(); - const serialized = JSON.stringify(operations); - serializeMs = performance.now() - serializeStart; - opBytes = Buffer.byteLength(serialized); - operationCount = operations.length; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const adoptStart = performance.now(); - value = next; - adoptMs = performance.now() - adoptStart; -} - -await gc(); -const retainedHeap = process.memoryUsage().heapUsed; -console.log( - JSON.stringify({ - mode: "head", - scenario, - size, - trial, - queueOperations, - importMs, - mutateMs, - prepareMs, - serializeMs, - adoptMs, - opBytes, - operationCount, - checksum, - readyMiB: (readyHeap - baselineHeap) / MiB, - transientMiB: (peakHeap - readyHeap) / MiB, - retainedMiB: (retainedHeap - baselineHeap) / MiB, - maxRssMiB: process.resourceUsage().maxRSS / 1024, - }), -); diff --git a/packages/chord/test/delta-astra/benchmark.ts b/packages/chord/test/delta-astra/benchmark.ts deleted file mode 100644 index c6cf9af44..000000000 --- a/packages/chord/test/delta-astra/benchmark.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -type Mode = "astra" | "delta" | "head"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Result = { - mode: Mode; - scenario: Scenario; - size: number; - trial: number; - queueOperations: number; - importMs: number; - mutateMs: number; - prepareMs: number; - serializeMs: number; - adoptMs: number; - opBytes: number; - operationCount: number; - checksum: number; - readyMiB: number; - transientMiB: number; - retainedMiB: number; - maxRssMiB: number; -}; -type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; - -const scenarios: readonly Scenario[] = [ - "import", - "committed-read", - "draft-read", - "sparse", - "queue", - "sort", - "dense", - "unshift", -]; -const modes: readonly Mode[] = ["astra", "delta", "head"]; -const quick = process.argv.includes("--quick"); -const size = quick ? 10_000 : 100_000; -const trials = quick ? 1 : 3; -const root = fileURLToPath(new URL("../../../..", import.meta.url)); -const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); -const headRoot = join(tmpdir(), `chord-astra-head-${process.pid}`); -const headFiles = [ - "packages/chord/src/state/diff.ts", - "packages/chord/src/state/draft.ts", - "packages/chord/src/state/value.ts", - "packages/chord/src/delta/index.ts", - "packages/chord/src/types.ts", -] as const; - -function writeHeadFixture(): string { - for (const path of headFiles) { - const destination = join(headRoot, path); - mkdirSync(dirname(destination), { recursive: true }); - writeFileSync(destination, execFileSync("git", ["show", `HEAD:${path}`], { cwd: root })); - } - const worker = join(headRoot, "packages/chord/test/delta-astra/benchmark-head.worker.ts"); - mkdirSync(dirname(worker), { recursive: true }); - const source = readFileSync(new URL("./benchmark-head.worker.ts", import.meta.url), "utf8") - .replaceAll("../../src/delta/diff.ts", "../../src/state/diff.ts") - .replaceAll("../../src/delta/draft.ts", "../../src/state/draft.ts") - .replaceAll("../../src/delta/value.ts", "../../src/state/value.ts"); - writeFileSync(worker, source); - writeFileSync(join(headRoot, "package.json"), JSON.stringify({ type: "module" })); - return worker; -} - -function average(values: readonly number[]): number | null { - return values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; -} - -const headWorker = writeHeadFixture(); -const currentWorker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); -const results: Result[] = []; -const failures: Failure[] = []; -try { - for (let trial = 0; trial < trials; trial++) { - for (const scenario of scenarios) { - for (const mode of modes) { - const worker = mode === "head" ? headWorker : currentWorker; - const args = - mode === "head" - ? [scenario, String(size), String(trial)] - : [mode, scenario, String(size), String(trial)]; - const child = spawnSync(process.execPath, ["--expose-gc", "--max-old-space-size=4096", worker, ...args], { - cwd: mode === "head" ? headRoot : root, - encoding: "utf8", - timeout: 300_000, - maxBuffer: 10 * 1024 * 1024, - }); - if (child.status !== 0) { - const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; - failures.push(failure); - console.log(JSON.stringify(failure)); - continue; - } - const result = JSON.parse(child.stdout) as Result; - results.push(result); - console.log(JSON.stringify(result)); - } - } - } -} finally { - rmSync(headRoot, { recursive: true, force: true }); -} - -const summary = scenarios.flatMap((scenario) => - modes.map((mode) => { - const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); - return { - mode, - scenario, - importMs: average(selected.map((result) => result.importMs)), - mutateMs: average(selected.map((result) => result.mutateMs)), - prepareMs: average(selected.map((result) => result.prepareMs)), - serializeMs: average(selected.map((result) => result.serializeMs)), - adoptMs: average(selected.map((result) => result.adoptMs)), - opBytes: average(selected.map((result) => result.opBytes)), - readyMiB: average(selected.map((result) => result.readyMiB)), - transientMiB: average(selected.map((result) => result.transientMiB)), - retainedMiB: average(selected.map((result) => result.retainedMiB)), - maxRssMiB: average(selected.map((result) => result.maxRssMiB)), - failures: failures.filter((failure) => failure.mode === mode && failure.scenario === scenario).length, - }; - }), -); -const report = { node: process.version, head, size, trials, results, failures, summary }; -const output = join(tmpdir(), "chord-astra-benchmark.json"); -writeFileSync(output, JSON.stringify(report, null, 2)); -console.log(JSON.stringify({ output, node: process.version, head, size, trials, summary })); diff --git a/packages/chord/test/delta-astra/benchmark.worker.ts b/packages/chord/test/delta-astra/benchmark.worker.ts deleted file mode 100644 index 03692966f..000000000 --- a/packages/chord/test/delta-astra/benchmark.worker.ts +++ /dev/null @@ -1,142 +0,0 @@ -import assert from "node:assert/strict"; -import { performance } from "node:perf_hooks"; -import { setImmediate } from "node:timers/promises"; -import { track as trackAstra } from "../../src/delta/astra/index.ts"; -import type { Draft } from "../../src/delta/draft.ts"; -import type { Op } from "../../src/delta/index.ts"; -import { track as trackDelta } from "../../src/delta/tracker.ts"; - -type Mode = "astra" | "delta"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Row = { id: number; value: number; payload: string }; -type Document = { rows: Row[] }; -type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; -type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; -type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; - -const MiB = 1024 * 1024; -const mode = process.argv[2] as Mode; -const scenario = process.argv[3] as Scenario; -const size = Number(process.argv[4] ?? 100_000); -const trial = Number(process.argv[5] ?? 0); - -function fixture(count: number): Document { - return { - rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), - }; -} - -function sum(document: Document | Draft): number { - let checksum = 0; - for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; - return checksum; -} - -async function gc(): Promise { - assert.ok(global.gc, "benchmark worker requires --expose-gc"); - for (let index = 0; index < 3; index++) { - await setImmediate(); - global.gc(); - } -} - -await gc(); -const baselineHeap = process.memoryUsage().heapUsed; -let input: Document | undefined = fixture(size); -if (scenario === "sort") input.rows.reverse(); -const importStart = performance.now(); -const tracker = (mode === "astra" ? trackAstra(input) : trackDelta(input)) as TrackerLike; -const importMs = performance.now() - importStart; -input = undefined; -await gc(); -const readyHeap = process.memoryUsage().heapUsed; -let peakHeap = readyHeap; -let mutateMs = 0; -let prepareMs = 0; -let serializeMs = 0; -let adoptMs = 0; -let opBytes = 0; -let operationCount = 0; -let checksum = 0; -const queueOperations = Math.min(size, 1_000); - -if (scenario === "committed-read") { - const start = performance.now(); - checksum = sum(tracker.value); - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); -} else if (scenario !== "import") { - let change: ChangeLike | undefined = tracker.beginChange(); - const start = performance.now(); - switch (scenario) { - case "draft-read": - checksum = sum(change.state); - break; - case "sparse": - for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "queue": - for (let index = 0; index < queueOperations; index++) { - change.state.rows.shift(); - change.state.rows.push({ id: size + index, value: index, payload: "queue" }); - } - checksum = change.state.rows.length; - break; - case "sort": - change.state.rows.sort((left, right) => left.id - right.id); - checksum = change.state.rows[0]!.id; - break; - case "dense": - for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "unshift": { - const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); - Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); - checksum = change.state.rows.length; - break; - } - } - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const prepareStart = performance.now(); - let prepared: PreparedLike | undefined = change.prepare(); - prepareMs = performance.now() - prepareStart; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const serializeStart = performance.now(); - const serialized = JSON.stringify(prepared.ops); - serializeMs = performance.now() - serializeStart; - opBytes = Buffer.byteLength(serialized); - operationCount = prepared.ops.length; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const adoptStart = performance.now(); - tracker.adopt(prepared); - adoptMs = performance.now() - adoptStart; - change = undefined; - prepared = undefined; -} - -await gc(); -const retainedHeap = process.memoryUsage().heapUsed; -console.log( - JSON.stringify({ - mode, - scenario, - size, - trial, - queueOperations, - importMs, - mutateMs, - prepareMs, - serializeMs, - adoptMs, - opBytes, - operationCount, - checksum, - readyMiB: (readyHeap - baselineHeap) / MiB, - transientMiB: (peakHeap - readyHeap) / MiB, - retainedMiB: (retainedHeap - baselineHeap) / MiB, - maxRssMiB: process.resourceUsage().maxRSS / 1024, - }), -); diff --git a/packages/chord/test/delta-astra/retention.test.ts b/packages/chord/test/delta-astra/retention.test.ts deleted file mode 100644 index 9e9dd3e98..000000000 --- a/packages/chord/test/delta-astra/retention.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -describe("astra overlay retention", () => { - it.each([ - "aborted", - "dropped-prepared", - "settled-change", - "stale-unprepared", - "stale-held", - "held-revoked", - "registry-churn", - ])( - "releases or invalidates %s", - (scenario) => { - const child = spawnSync( - process.execPath, - ["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario], - { encoding: "utf8", timeout: 60_000 }, - ); - expect(child.error, child.stderr).toBeUndefined(); - expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); - }, - 65_000, - ); -}); diff --git a/packages/chord/test/delta-astra/retention.worker.ts b/packages/chord/test/delta-astra/retention.worker.ts deleted file mode 100644 index 54bc91567..000000000 --- a/packages/chord/test/delta-astra/retention.worker.ts +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict"; -import { setImmediate } from "node:timers/promises"; -import { track } from "../../src/delta/astra/index.ts"; - -async function collect(reference: WeakRef): Promise { - assert.ok(global.gc, "worker requires --expose-gc"); - for (let attempt = 0; attempt < 40; attempt++) { - await setImmediate(); - global.gc(); - if (reference.deref() === undefined) return; - } - assert.fail("overlay data is still retained"); -} - -const retainedRevoked: object[] = []; -const retainedSettledChanges: object[] = []; -const retainedStaleChanges: object[] = []; -const scenario = process.argv[2]; -if (scenario === "aborted") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const reference = new WeakRef(change.state.payload.rows); - change.abort(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} else if (scenario === "dropped-prepared") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const makeReference = (): WeakRef => { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const prepared = change.prepare(); - return new WeakRef(prepared.value.payload!.rows); - }; - const reference = makeReference(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} else if (scenario === "settled-change") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const makeReference = (): WeakRef => { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const prepared = change.prepare(); - const reference = new WeakRef(prepared.value.payload!.rows); - retainedSettledChanges.push(change); - return reference; - }; - const reference = makeReference(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} else if (scenario === "stale-unprepared") { - const setup = (): { - tracker: ReturnType>; - reference: WeakRef; - } => { - const initial = { payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }; - const tracker = track(initial); - const reference = new WeakRef(initial.payload.rows); - retainedStaleChanges.push(tracker.beginChange()); - const winner = tracker.prepareReplace({ payload: { rows: [{ value: -1 }] } }); - tracker.adopt(winner); - return { tracker, reference }; - }; - const { tracker, reference } = setup(); - await collect(reference); - assert.deepEqual(tracker.value, { payload: { rows: [{ value: -1 }] } }); - assert.throws(() => (retainedStaleChanges[0] as { state: { payload: unknown } }).state.payload, TypeError); -} else if (scenario === "stale-held") { - const tracker = track({ child: { value: 0 } }); - let prepared = (() => { - const change = tracker.beginChange(); - change.state.child.value = 1; - return change.prepare(); - })(); - const held = prepared.value; - const wrapper = new WeakRef(prepared); - prepared = undefined as never; - await collect(wrapper); - const winner = tracker.prepareReplace({ child: { value: 2 } }); - tracker.adopt(winner); - assert.throws(() => held.child.value, TypeError); -} else if (scenario === "held-revoked") { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const held = change.state.payload; - const child = new WeakRef(change.state.payload.rows); - retainedRevoked.push(held); - change.abort(); - await collect(child); - assert.throws(() => held.rows, TypeError); -} else if (scenario === "registry-churn") { - const tracker = track({ value: 0 }); - for (let index = 0; index < 100_000; index++) { - const change = tracker.beginChange(); - change.state.value = index; - change.abort(); - } - const winner = tracker.prepareReplace({ value: 1 }); - tracker.adopt(winner); - assert.deepEqual(tracker.value, { value: 1 }); -} else { - throw new Error(`unknown scenario: ${scenario}`); -} - -console.log(JSON.stringify({ scenario, passed: true })); diff --git a/packages/chord/test/delta-benchmark/benchmark.ts b/packages/chord/test/delta-benchmark/benchmark.ts new file mode 100644 index 000000000..d63b5e116 --- /dev/null +++ b/packages/chord/test/delta-benchmark/benchmark.ts @@ -0,0 +1,210 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Mode = "tracker"; +type Scenario = + | "import" + | "committed-read" + | "draft-read" + | "sparse" + | "queue" + | "sort" + | "dense" + | "unshift" + | "watch-authority-control" + | "watch-live-borrowed" + | "watch-acquire-shared" + | "watch-acquire-detached" + | "watch-acquire-staggered" + | "watch-lag" + | "watch-catch-up" + | "watch-catch-up-coalesced" + | "watch-live-immutable"; +type Result = { + mode: Mode; + scenario: Scenario; + size: number; + trial: number; + queueOperations: number; + watchProfile: string | null; + watchCount: number; + watchUpdates: number; + queuedBatchReferences: number; + importMs: number; + mutateMs: number; + prepareMs: number; + serializeMs: number; + adoptMs: number; + acquireMs: number; + enqueueMs: number; + deliveryMs: number; + scenarioWorkMs: number; + opBytes: number; + operationCount: number; + checksum: number; + readyMiB: number; + sampledAcquireMiB: number; + sampledTransientMiB: number; + retainedMiB: number; + retainedOverReadyMiB: number; + postAcquireRetainedMiB: number; + postLagRetainedMiB: number; + postDeliveryRetainedMiB: number; + releasedRetainedMiB: number; + maxRssMiB: number; +}; + +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; + +const ordinaryScenarios: readonly Scenario[] = [ + "import", + "committed-read", + "draft-read", + "sparse", + "queue", + "sort", + "dense", + "unshift", +]; +const watchScenarios: readonly Scenario[] = [ + "watch-authority-control", + "watch-live-borrowed", + "watch-acquire-shared", + "watch-acquire-detached", + "watch-acquire-staggered", + "watch-lag", + "watch-catch-up", + "watch-catch-up-coalesced", + "watch-live-immutable", +]; +const modes: readonly Mode[] = ["tracker"]; +const quick = process.argv.includes("--quick"); +const watchOnly = process.argv.includes("--watch-only"); +const ordinaryOnly = process.argv.includes("--ordinary-only"); +if (watchOnly && ordinaryOnly) throw new Error("Choose at most one scenario filter"); +const scenarios = watchOnly + ? watchScenarios + : ordinaryOnly + ? ordinaryScenarios + : [...ordinaryScenarios, ...watchScenarios]; +const size = quick ? 10_000 : 100_000; +const trials = quick ? 1 : 3; +const watchCount = quick ? 4 : 8; +const watchUpdates = quick ? 20 : 100; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of scenarios) { + for (const mode of modes) { + const child = spawnSync( + process.execPath, + [ + "--expose-gc", + "--max-old-space-size=4096", + worker, + mode, + scenario, + String(size), + String(trial), + String(watchCount), + String(watchUpdates), + ], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + } else { + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } + } +} + +const average = (values: readonly number[]): number | null => + values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; +const median = (values: readonly number[]): number | null => { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; +}; +const range = (values: readonly number[]): readonly [number, number] | null => + values.length === 0 ? null : [Math.min(...values), Math.max(...values)]; +const metricNames = [ + "importMs", + "mutateMs", + "prepareMs", + "serializeMs", + "adoptMs", + "acquireMs", + "enqueueMs", + "deliveryMs", + "scenarioWorkMs", + "opBytes", + "operationCount", + "readyMiB", + "sampledAcquireMiB", + "sampledTransientMiB", + "retainedMiB", + "retainedOverReadyMiB", + "postAcquireRetainedMiB", + "postLagRetainedMiB", + "postDeliveryRetainedMiB", + "releasedRetainedMiB", + "maxRssMiB", +] as const satisfies readonly (keyof Result)[]; + +const summary = scenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); + const averages: Record = {}; + const medians: Record = {}; + const ranges: Record = {}; + for (const metric of metricNames) { + const values = selected.map((result) => result[metric]); + averages[metric] = average(values); + medians[metric] = median(values); + ranges[metric] = range(values); + } + return { + mode, + scenario, + watchProfile: selected[0]?.watchProfile ?? null, + watchCount: selected[0]?.watchCount ?? 0, + watchUpdates: selected[0]?.watchUpdates ?? 0, + averages, + medians, + ranges, + }; + }), +); +const output = join(tmpdir(), "chord-delta-benchmark.json"); +writeFileSync( + output, + JSON.stringify( + { node: process.version, size, trials, watchCount, watchUpdates, results, failures, summary }, + null, + 2, + ), +); +console.log( + JSON.stringify({ + output, + node: process.version, + size, + trials, + watchCount, + watchUpdates, + failures: failures.length, + summary, + }), +); diff --git a/packages/chord/test/delta-benchmark/benchmark.worker.ts b/packages/chord/test/delta-benchmark/benchmark.worker.ts new file mode 100644 index 000000000..e9f88bd5c --- /dev/null +++ b/packages/chord/test/delta-benchmark/benchmark.worker.ts @@ -0,0 +1,436 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import type { Draft } from "../../src/delta/draft.ts"; +import { applyImmutable, type Op, track } from "../../src/delta/index.ts"; + +type Mode = "tracker"; +type Scenario = + | "import" + | "committed-read" + | "draft-read" + | "sparse" + | "queue" + | "sort" + | "dense" + | "unshift" + | "watch-authority-control" + | "watch-live-borrowed" + | "watch-acquire-shared" + | "watch-acquire-detached" + | "watch-acquire-staggered" + | "watch-lag" + | "watch-catch-up" + | "watch-catch-up-coalesced" + | "watch-live-immutable"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; +type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; +type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; +type Frame = { readonly sequence: number; readonly ops: readonly Op[] }; +type WatchReplica = { + value: Document; + readonly baseSequence: number; + sequence: number; + readonly queue: Frame[]; +}; + +const MiB = 1024 * 1024; +const MODES = new Set(["tracker"]); +const SCENARIOS = new Set([ + "import", + "committed-read", + "draft-read", + "sparse", + "queue", + "sort", + "dense", + "unshift", + "watch-authority-control", + "watch-live-borrowed", + "watch-acquire-shared", + "watch-acquire-detached", + "watch-acquire-staggered", + "watch-lag", + "watch-catch-up", + "watch-catch-up-coalesced", + "watch-live-immutable", +]); +const WATCH_SCENARIOS = new Set([ + "watch-authority-control", + "watch-live-borrowed", + "watch-acquire-shared", + "watch-acquire-detached", + "watch-acquire-staggered", + "watch-lag", + "watch-catch-up", + "watch-catch-up-coalesced", + "watch-live-immutable", +]); +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +if (!MODES.has(mode)) throw new Error(`Unknown benchmark mode ${mode}`); +if (!SCENARIOS.has(scenario)) throw new Error(`Unknown benchmark scenario ${scenario}`); +const size = Number(process.argv[4] ?? 100_000); +const trial = Number(process.argv[5] ?? 0); +const requestedWatchCount = Number(process.argv[6] ?? 8); +const requestedWatchUpdates = Number(process.argv[7] ?? 100); +if (!Number.isSafeInteger(requestedWatchCount) || requestedWatchCount < 1) throw new RangeError("Invalid watch count"); +if (!Number.isSafeInteger(requestedWatchUpdates) || requestedWatchUpdates < 0) + throw new RangeError("Invalid watch update count"); + +function fixture(count: number): Document { + return { + rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), + }; +} + +function cloneTrusted(value: T): T { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map((child) => cloneTrusted(child)) as T; + const result = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) { + Object.defineProperty(result, key, { + value: cloneTrusted((value as Record)[key]), + writable: true, + enumerable: true, + configurable: true, + }); + } + return result as T; +} + +function sum(document: Document | Draft): number { + let checksum = 0; + for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; + return checksum; +} + +async function gc(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let index = 0; index < 3; index++) { + await setImmediate(); + global.gc(); + } +} + +await gc(); +const baselineHeap = process.memoryUsage().heapUsed; +let input: Document | undefined = fixture(size); +if (scenario === "sort") input.rows.reverse(); +const importStart = performance.now(); +const tracker = track(input) as TrackerLike; +const importMs = performance.now() - importStart; +input = undefined; +await gc(); +const readyHeap = process.memoryUsage().heapUsed; +let peakHeap = readyHeap; +let mutateMs = 0; +let prepareMs = 0; +let serializeMs = 0; +let adoptMs = 0; +let acquireMs = 0; +let enqueueMs = 0; +let deliveryMs = 0; +let opBytes = 0; +let operationCount = 0; +let checksum = 0; +let authoritySequence = 0; +let postAcquireRetainedMiB = 0; +let postLagRetainedMiB = 0; +let postDeliveryRetainedMiB = 0; +let sampledAcquireMiB = 0; +let watchers: WatchReplica[] = []; +let borrowedListeners: Array<(value: Document, sequence: number) => number> = []; +const queueOperations = Math.min(size, 1_000); +const watchCount = WATCH_SCENARIOS.has(scenario) ? requestedWatchCount : 0; +const watchUpdateTarget = WATCH_SCENARIOS.has(scenario) ? requestedWatchUpdates : 0; + +const samplePeak = (): void => { + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +}; + +const retainedFromBaseline = async (): Promise => { + await gc(); + return (process.memoryUsage().heapUsed - baselineHeap) / MiB; +}; + +const createWatch = (value: Document): WatchReplica => ({ + value, + baseSequence: authoritySequence, + sequence: authoritySequence, + queue: [], +}); + +const acquireSharedWatches = (): void => { + const beforeHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + const base = tracker.value; + watchers = Array.from({ length: watchCount }, () => createWatch(base)); + acquireMs += performance.now() - start; + sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB); + for (const watcher of watchers) assert.equal(watcher.value, tracker.value); + samplePeak(); +}; + +const acquireDetachedWatches = (): void => { + const beforeHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + watchers = Array.from({ length: watchCount }, () => createWatch(cloneTrusted(tracker.value))); + acquireMs += performance.now() - start; + sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB); + for (let left = 0; left < watchers.length; left++) { + assert.notEqual(watchers[left]!.value, tracker.value); + for (let right = left + 1; right < watchers.length; right++) { + assert.notEqual(watchers[left]!.value, watchers[right]!.value); + } + } + samplePeak(); +}; + +const acquireOneWatch = (): void => { + const beforeHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + watchers.push(createWatch(tracker.value)); + acquireMs += performance.now() - start; + sampledAcquireMiB = Math.max(sampledAcquireMiB, (process.memoryUsage().heapUsed - beforeHeap) / MiB); + samplePeak(); +}; + +const commitWatchUpdate = (update: number): Frame => { + let change: ChangeLike | undefined = tracker.beginChange(); + const row = ((update + 1) * 997) % size; + const mutateStart = performance.now(); + change.state.rows[row]!.value = -update - 1; + mutateMs += performance.now() - mutateStart; + samplePeak(); + const prepareStart = performance.now(); + let prepared: PreparedLike | undefined = change.prepare(); + prepareMs += performance.now() - prepareStart; + samplePeak(); + const operations = prepared.ops; + operationCount += operations.length; + const adoptStart = performance.now(); + tracker.adopt(prepared); + adoptMs += performance.now() - adoptStart; + samplePeak(); + change = undefined; + prepared = undefined; + authoritySequence += 1; + return { sequence: authoritySequence, ops: operations }; +}; + +const enqueueFrame = (frame: Frame): void => { + const start = performance.now(); + for (const watcher of watchers) { + assert.equal(frame.sequence, watcher.sequence + watcher.queue.length + 1); + watcher.queue.push(frame); + } + enqueueMs += performance.now() - start; +}; + +const applyFrame = (watcher: WatchReplica, frame: Frame): void => { + assert.equal(frame.sequence, watcher.sequence + 1); + watcher.value = applyImmutable(watcher.value, frame.ops); + watcher.sequence = frame.sequence; +}; + +const drainWatchQueues = (measure: boolean): void => { + const start = performance.now(); + for (const watcher of watchers) { + for (const frame of watcher.queue) applyFrame(watcher, frame); + watcher.queue.length = 0; + samplePeak(); + } + if (measure) deliveryMs += performance.now() - start; +}; + +const drainWatchQueuesCoalesced = (): void => { + const start = performance.now(); + for (const watcher of watchers) { + const operations = watcher.queue.flatMap((frame) => frame.ops); + watcher.value = applyImmutable(watcher.value, operations); + watcher.sequence = watcher.queue.at(-1)?.sequence ?? watcher.sequence; + watcher.queue.length = 0; + samplePeak(); + } + deliveryMs += performance.now() - start; +}; + +const assertWatchConvergence = (): void => { + for (const watcher of watchers) { + assert.equal(watcher.sequence, authoritySequence); + assert.deepEqual(watcher.value, tracker.value); + } +}; + +if (scenario === "committed-read") { + const start = performance.now(); + checksum = sum(tracker.value); + mutateMs = performance.now() - start; + samplePeak(); +} else if (scenario === "watch-authority-control") { + for (let update = 0; update < watchUpdateTarget; update++) commitWatchUpdate(update); + postDeliveryRetainedMiB = await retainedFromBaseline(); + checksum = sum(tracker.value); +} else if (scenario === "watch-live-borrowed") { + const start = performance.now(); + borrowedListeners = Array.from({ length: watchCount }, () => (value, sequence) => value.rows[0]!.value + sequence); + for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence); + acquireMs = performance.now() - start; + postAcquireRetainedMiB = await retainedFromBaseline(); + for (let update = 0; update < watchUpdateTarget; update++) { + commitWatchUpdate(update); + const deliveryStart = performance.now(); + for (const listener of borrowedListeners) checksum += listener(tracker.value, authoritySequence); + deliveryMs += performance.now() - deliveryStart; + samplePeak(); + } + postDeliveryRetainedMiB = await retainedFromBaseline(); +} else if (scenario === "watch-acquire-shared" || scenario === "watch-acquire-detached") { + if (scenario === "watch-acquire-shared") acquireSharedWatches(); + else acquireDetachedWatches(); + postAcquireRetainedMiB = await retainedFromBaseline(); + checksum = sum(watchers[0]!.value) + watchers.length; +} else if (scenario === "watch-acquire-staggered") { + for (let watcher = 0; watcher < watchCount; watcher++) { + acquireOneWatch(); + if (watcher + 1 === watchCount) break; + const frame = commitWatchUpdate(watcher); + enqueueFrame(frame); + } + for (let watcher = 0; watcher < watchers.length; watcher++) { + assert.equal(watchers[watcher]!.baseSequence, watcher); + assert.equal(watchers[watcher]!.queue.length, watchCount - watcher - 1); + } + postAcquireRetainedMiB = await retainedFromBaseline(); + drainWatchQueues(false); + assertWatchConvergence(); + postDeliveryRetainedMiB = await retainedFromBaseline(); + checksum = sum(watchers[0]!.value) + sum(watchers.at(-1)!.value); +} else if ( + scenario === "watch-lag" || + scenario === "watch-catch-up" || + scenario === "watch-catch-up-coalesced" || + scenario === "watch-live-immutable" +) { + acquireSharedWatches(); + postAcquireRetainedMiB = await retainedFromBaseline(); + for (let update = 0; update < watchUpdateTarget; update++) { + const frame = commitWatchUpdate(update); + if (scenario === "watch-live-immutable") { + const start = performance.now(); + for (const watcher of watchers) applyFrame(watcher, frame); + deliveryMs += performance.now() - start; + samplePeak(); + } else enqueueFrame(frame); + } + postLagRetainedMiB = await retainedFromBaseline(); + if (scenario === "watch-catch-up-coalesced") drainWatchQueuesCoalesced(); + else if (scenario !== "watch-live-immutable") drainWatchQueues(scenario === "watch-catch-up"); + assertWatchConvergence(); + postDeliveryRetainedMiB = await retainedFromBaseline(); + checksum = sum(watchers[0]!.value) + sum(tracker.value); +} else if (scenario !== "import") { + let change: ChangeLike | undefined = tracker.beginChange(); + const start = performance.now(); + switch (scenario) { + case "draft-read": + checksum = sum(change.state); + break; + case "sparse": + for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "queue": + for (let index = 0; index < queueOperations; index++) { + change.state.rows.shift(); + change.state.rows.push({ id: size + index, value: index, payload: "queue" }); + } + checksum = change.state.rows.length; + break; + case "sort": + change.state.rows.sort((left, right) => left.id - right.id); + checksum = change.state.rows[0]!.id; + break; + case "dense": + for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; + checksum = change.state.rows.at(-1)!.value; + break; + case "unshift": { + const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); + Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); + checksum = change.state.rows.length; + break; + } + default: + throw new Error(`Unhandled benchmark scenario ${scenario}`); + } + mutateMs = performance.now() - start; + samplePeak(); + const prepareStart = performance.now(); + let prepared: PreparedLike | undefined = change.prepare(); + prepareMs = performance.now() - prepareStart; + samplePeak(); + const serializeStart = performance.now(); + const serialized = JSON.stringify(prepared.ops); + serializeMs = performance.now() - serializeStart; + opBytes = Buffer.byteLength(serialized); + operationCount = prepared.ops.length; + samplePeak(); + const adoptStart = performance.now(); + tracker.adopt(prepared); + adoptMs = performance.now() - adoptStart; + change = undefined; + prepared = undefined; +} + +const scenarioWorkMs = mutateMs + prepareMs + serializeMs + adoptMs + acquireMs + enqueueMs + deliveryMs; +samplePeak(); +await gc(); +const retainedHeap = process.memoryUsage().heapUsed; +const retainedMiB = (retainedHeap - baselineHeap) / MiB; +const retainedOverReadyMiB = (retainedHeap - readyHeap) / MiB; +const watcherRoots = watchers.length; +const queuedBatchReferences = watchers.reduce((total, watcher) => total + watcher.queue.length, 0); +const listenerCount = borrowedListeners.length; +watchers = []; +borrowedListeners = []; +await gc(); +const releasedRetainedMiB = (process.memoryUsage().heapUsed - baselineHeap) / MiB; +console.log( + JSON.stringify({ + mode, + scenario, + size, + trial, + queueOperations, + watchProfile: WATCH_SCENARIOS.has(scenario) ? "local-sparse-array-leaf" : null, + watchCount: watcherRoots + listenerCount, + watchUpdates: authoritySequence, + queuedBatchReferences, + importMs, + mutateMs, + prepareMs, + serializeMs, + adoptMs, + acquireMs, + enqueueMs, + deliveryMs, + scenarioWorkMs, + opBytes, + operationCount, + checksum, + readyMiB: (readyHeap - baselineHeap) / MiB, + sampledAcquireMiB, + sampledTransientMiB: (peakHeap - readyHeap) / MiB, + retainedMiB, + retainedOverReadyMiB, + postAcquireRetainedMiB, + postLagRetainedMiB, + postDeliveryRetainedMiB, + releasedRetainedMiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + }), +); diff --git a/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts b/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts new file mode 100644 index 000000000..017b6540a --- /dev/null +++ b/packages/chord/test/delta-benchmark/conversation-view-benchmark.ts @@ -0,0 +1,337 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Provisional Pico5 benchmark schemas. Results are architecture experiments, +// not compatibility commitments for public or persisted ConversationView data. +const PROVISIONAL_SCHEMA = "pico5.conversation-view.provisional.v0"; + +type Mode = "tracker"; +type Scenario = + | "projection-control" + | "normal" + | "fanout" + | "delayed-start-lag" + | "churn-reconnect" + | "overflow-reset" + | "branch-head-replacement"; +type Distribution = { + count: number; + totalMs: number; + medianMs: number; + p95Ms: number; + p99Ms: number; + minMs: number; + maxMs: number; +}; +type PhaseName = + | "mutation" + | "prepare" + | "adopt" + | "synthesize" + | "projectApply" + | "project" + | "enqueue" + | "sessionLine" + | "delivery" + | "opSerialize" + | "resetSerialize" + | "conversationAcquire" + | "documentAcquire" + | "conversationSerialize" + | "documentSerialize" + | "resetClone"; +type Result = { + mode: Mode; + scenario: Scenario; + trial: number; + provisionalSchema: string; + headlineEntries: number; + commitTarget: number; + initialAggregateBytes: number; + conversationWatchCount: number; + documentWatchCount: number; + queueCapacity: number | null; + queueCapacityUnit: "pending-operations"; + deliveryModel: "async-serialized-ops-only-no-consumer-apply"; + conversationSequence: number; + documentSequences: Record; + publishedAggregateBatches: number; + publishedDocumentBatches: number; + conversationRegistrations: number; + documentRegistrations: number; + conversationBaseBytes: number; + documentBaseBytes: number; + noOpCommitCount: number; + unmountedCommitCount: number; + resetCount: number; + resetBytes: number; + deliveryFrameReferences: number; + deliveryOperationReferences: number; + aggregateOperationCount: number; + aggregateOperationBytes: number; + documentOperationCount: number; + documentOperationBytes: number; + peakQueueReferences: number; + peakQueuedOperationReferences: number; + peakPendingOperationsPerWatch: number; + peakUniqueQueuedBatches: number; + finalQueueReferences: number; + finalQueuedOperationReferences: number; + finalUniqueQueuedBatches: number; + phaseDistributions: Record; + authorityReadyMiB: number; + postAcquireRetainedMiB: number; + postWorkRetainedMiB: number; + postVerificationRetainedMiB: number; + releaseHeapMiB: number; + releaseTotalMiB: number; + peakHeapMiB: number; + peakTotalHeapMiB: number; + maxRssMiB: number; + checksum: number; +}; +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; +type NumericResultKey = + | "initialAggregateBytes" + | "conversationSequence" + | "publishedAggregateBatches" + | "publishedDocumentBatches" + | "conversationRegistrations" + | "documentRegistrations" + | "conversationBaseBytes" + | "documentBaseBytes" + | "noOpCommitCount" + | "unmountedCommitCount" + | "resetCount" + | "resetBytes" + | "deliveryFrameReferences" + | "deliveryOperationReferences" + | "aggregateOperationCount" + | "aggregateOperationBytes" + | "documentOperationCount" + | "documentOperationBytes" + | "peakQueueReferences" + | "peakQueuedOperationReferences" + | "peakPendingOperationsPerWatch" + | "peakUniqueQueuedBatches" + | "finalQueueReferences" + | "finalQueuedOperationReferences" + | "finalUniqueQueuedBatches" + | "authorityReadyMiB" + | "postAcquireRetainedMiB" + | "postWorkRetainedMiB" + | "postVerificationRetainedMiB" + | "releaseHeapMiB" + | "releaseTotalMiB" + | "peakHeapMiB" + | "peakTotalHeapMiB" + | "maxRssMiB"; + +type SummaryValue = { median: number | null; range: readonly [number, number] | null }; + +const modes: readonly Mode[] = ["tracker"]; +const scenarios: readonly Scenario[] = [ + "projection-control", + "normal", + "fanout", + "delayed-start-lag", + "churn-reconnect", + "overflow-reset", + "branch-head-replacement", +]; +const phaseNames: readonly PhaseName[] = [ + "mutation", + "prepare", + "adopt", + "synthesize", + "projectApply", + "project", + "enqueue", + "sessionLine", + "delivery", + "opSerialize", + "resetSerialize", + "conversationAcquire", + "documentAcquire", + "conversationSerialize", + "documentSerialize", + "resetClone", +]; +const numericMetrics: readonly NumericResultKey[] = [ + "initialAggregateBytes", + "conversationSequence", + "publishedAggregateBatches", + "publishedDocumentBatches", + "conversationRegistrations", + "documentRegistrations", + "conversationBaseBytes", + "documentBaseBytes", + "noOpCommitCount", + "unmountedCommitCount", + "resetCount", + "resetBytes", + "deliveryFrameReferences", + "deliveryOperationReferences", + "aggregateOperationCount", + "aggregateOperationBytes", + "documentOperationCount", + "documentOperationBytes", + "peakQueueReferences", + "peakQueuedOperationReferences", + "peakPendingOperationsPerWatch", + "peakUniqueQueuedBatches", + "finalQueueReferences", + "finalQueuedOperationReferences", + "finalUniqueQueuedBatches", + "authorityReadyMiB", + "postAcquireRetainedMiB", + "postWorkRetainedMiB", + "postVerificationRetainedMiB", + "releaseHeapMiB", + "releaseTotalMiB", + "peakHeapMiB", + "peakTotalHeapMiB", + "maxRssMiB", +]; + +function optionNumber(name: string, fallback: number): number { + const prefix = `--${name}=`; + const raw = process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length); + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`Invalid --${name}`); + return value; +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; +} + +function range(values: readonly number[]): readonly [number, number] | null { + return values.length === 0 ? null : [Math.min(...values), Math.max(...values)]; +} + +function summarize(values: readonly number[]): SummaryValue { + return { median: median(values), range: range(values) }; +} + +const quick = process.argv.includes("--quick"); +const selectedScenario = process.argv + .find((argument) => argument.startsWith("--scenario=")) + ?.slice("--scenario=".length); +if (selectedScenario !== undefined && !(scenarios as readonly string[]).includes(selectedScenario)) { + throw new Error(`Unknown scenario ${selectedScenario}`); +} +const selectedScenarios = selectedScenario === undefined ? scenarios : [selectedScenario as Scenario]; +const headlineEntries = optionNumber("entries", quick ? 128 : 512); +const commits = optionNumber("commits", quick ? 120 : 1_000); +const trials = optionNumber("trials", quick ? 1 : 3); +const queueOperationCapacity = quick ? 64 : 256; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./conversation-view-benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of selectedScenarios) { + const offset = trial % modes.length; + const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)]; + for (const mode of trialModes) { + const child = spawnSync( + process.execPath, + [ + "--expose-gc", + "--max-old-space-size=4096", + worker, + mode, + scenario, + String(headlineEntries), + String(commits), + String(trial), + String(queueOperationCapacity), + ], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 20 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + continue; + } + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } +} + +const summary = selectedScenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.scenario === scenario && result.mode === mode); + const metrics: Record = {}; + for (const metric of numericMetrics) metrics[metric] = summarize(selected.map((result) => result[metric])); + const phases: Record< + string, + { medianMs: SummaryValue; p95Ms: SummaryValue; p99Ms: SummaryValue; totalMs: SummaryValue } + > = {}; + for (const phase of phaseNames) { + phases[phase] = { + medianMs: summarize(selected.map((result) => result.phaseDistributions[phase].medianMs)), + p95Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p95Ms)), + p99Ms: summarize(selected.map((result) => result.phaseDistributions[phase].p99Ms)), + totalMs: summarize(selected.map((result) => result.phaseDistributions[phase].totalMs)), + }; + } + return { + mode, + scenario, + provisionalSchema: PROVISIONAL_SCHEMA, + conversationWatchCount: selected[0]?.conversationWatchCount ?? 0, + documentWatchCount: selected[0]?.documentWatchCount ?? 0, + queueCapacity: selected[0]?.queueCapacity ?? null, + queueCapacityUnit: "pending-operations", + deliveryModel: "async-serialized-ops-only-no-consumer-apply", + metrics, + phases, + }; + }), +); + +const output = join(tmpdir(), "chord-conversation-view-benchmark.json"); +writeFileSync( + output, + JSON.stringify( + { + node: process.version, + provisionalSchema: PROVISIONAL_SCHEMA, + profile: "local-pico5-provisional-conversation-view", + headlineEntries, + commits, + trials, + queueOperationCapacity, + results, + failures, + summary, + }, + null, + 2, + ), +); +console.log( + JSON.stringify({ + output, + node: process.version, + provisionalSchema: PROVISIONAL_SCHEMA, + headlineEntries, + commits, + trials, + queueOperationCapacity, + failures: failures.length, + summary, + }), +); diff --git a/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts b/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts new file mode 100644 index 000000000..265428321 --- /dev/null +++ b/packages/chord/test/delta-benchmark/conversation-view-benchmark.worker.ts @@ -0,0 +1,1140 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import type { Draft } from "../../src/delta/draft.ts"; +import { applyImmutable, type JsonValue, type NonEmptyPath, type Op, type Path, track } from "../../src/delta/index.ts"; + +// Provisional Pico5 benchmark schemas. They intentionally model architecture and +// workload shape, not a committed public API or persisted wire format. +const PROVISIONAL_SCHEMA = "pico5.conversation-view.provisional.v0"; +const MiB = 1024 * 1024; +const MOUNTED_KEYS = ["config", "inbox", "turn", "preferences", "live"] as const; +const DOCUMENT_WATCH_KEYS = ["config", "inbox", "preferences", "live"] as const; + +type Mode = "tracker"; +type Scenario = + | "projection-control" + | "normal" + | "fanout" + | "delayed-start-lag" + | "churn-reconnect" + | "overflow-reset" + | "branch-head-replacement"; +type MountedKey = (typeof MOUNTED_KEYS)[number]; +type DocumentKey = MountedKey | "audit"; +type JsonObject = Record; +type ConfigDoc = { + schema: string; + model: { provider: string; modelId: string }; + thinkingLevel: string; + selectedTools: string[]; + sections: Array<{ id: string; title: string; instructions: string }>; + tools: Array<{ name: string; description: string; inputSchema: JsonObject }>; + temperature: number; + revision: number; +}; +type InboxItem = { id: number; mode: string; text: string }; +type InboxDoc = { schema: string; items: InboxItem[]; revision: number }; +type TurnDoc = { + schema: string; + stage: string; + message: string; + toolSlots: Array<{ callId: string; name: string; status: string; preview: string }>; + revision: number; +}; +type PreferencesDoc = { + schema: string; + theme: string; + keymap: string; + compactTools: boolean; + fontScale: number; + editorProfile: string; + shortcuts: Record; + revision: number; +}; +type LiveDoc = { + schema: string; + status: string; + tokens: number; + cost: number; + heartbeat: number; + generation: { stage: string; attempt: number; partial: string; retryAt?: number }; + tools: Array<{ callId: string; name: string; status: string; progress: number; output: string }>; +}; +type AuditDoc = { schema: string; commits: number; note: string }; +type TranscriptEntry = { + id: number; + parent: number | null; + kind: "user" | "assistant" | "tool-result" | "application"; + data: JsonObject; +}; +type ConversationView = { + schema: string; + conversation: { id: string; head: number | null }; + docs: Record; + entries: TranscriptEntry[]; +}; +type PreparedLike = { + readonly base: T; + readonly value: T; + readonly ops: readonly Op[]; + abort(): void; +}; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike; abort(): void }; +type TrackerLike = { + readonly value: T; + beginChange(): ChangeLike; + adopt(prepared: PreparedLike): void; +}; +type DeltaFrame = { readonly kind: "delta"; readonly sequence: number; readonly ops: readonly Op[] }; +type WatchType = "conversation" | "document"; +type Watch = { + readonly snapshot: T; + readonly baseSequence: number; + readonly baseHash: string; + deliveredSequence: number; + pendingOperationCount: number; + readonly queue: DeltaFrame[]; + readonly started: boolean; + readonly watchType: WatchType; +}; +type DocumentWatch = Watch & { readonly key: DocumentKey }; +type Distribution = { + count: number; + totalMs: number; + medianMs: number; + p95Ms: number; + p99Ms: number; + minMs: number; + maxMs: number; +}; +type PhaseName = + | "mutation" + | "prepare" + | "adopt" + | "synthesize" + | "projectApply" + | "project" + | "enqueue" + | "sessionLine" + | "delivery" + | "opSerialize" + | "resetSerialize" + | "conversationAcquire" + | "documentAcquire" + | "conversationSerialize" + | "documentSerialize" + | "resetClone"; +type PhaseSamples = Record; +type ScenarioConfig = { + conversationWatchCount: 0 | 1 | 8; + documentWatchCount: 0 | 4 | 16; + started: boolean; + queueCapacity: number | undefined; +}; +type DocMutation = { readonly key: DocumentKey; readonly mutate: (draft: Draft) => void }; +type CommitPlan = { + readonly docs: readonly DocMutation[]; + readonly entryKind?: TranscriptEntry["kind"]; + readonly branch?: boolean; + readonly unmountedOnly: boolean; + readonly expectedConversationNoop: boolean; +}; + +const MODES = new Set(["tracker"]); +const SCENARIOS = new Set([ + "projection-control", + "normal", + "fanout", + "delayed-start-lag", + "churn-reconnect", + "overflow-reset", + "branch-head-replacement", +]); +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +const headlineEntries = Number(process.argv[4] ?? 512); +const commitTarget = Number(process.argv[5] ?? 1_000); +const trial = Number(process.argv[6] ?? 0); +const overflowQueueOperationCapacity = Number(process.argv[7] ?? 256); +if (!MODES.has(mode)) throw new Error(`Unknown mode ${mode}`); +if (!SCENARIOS.has(scenario)) throw new Error(`Unknown scenario ${scenario}`); +if (!Number.isSafeInteger(headlineEntries) || headlineEntries < 1) throw new RangeError("Invalid entry count"); +if (!Number.isSafeInteger(commitTarget) || commitTarget < 1) throw new RangeError("Invalid commit count"); +if (!Number.isSafeInteger(overflowQueueOperationCapacity) || overflowQueueOperationCapacity < 1) { + throw new RangeError("Invalid queue operation capacity"); +} + +const scenarioConfig = (name: Scenario): ScenarioConfig => { + switch (name) { + case "projection-control": + return { conversationWatchCount: 0, documentWatchCount: 0, started: true, queueCapacity: undefined }; + case "normal": + return { conversationWatchCount: 1, documentWatchCount: 4, started: true, queueCapacity: undefined }; + case "fanout": + return { conversationWatchCount: 8, documentWatchCount: 16, started: true, queueCapacity: undefined }; + case "delayed-start-lag": + return { conversationWatchCount: 1, documentWatchCount: 4, started: false, queueCapacity: undefined }; + case "churn-reconnect": + return { conversationWatchCount: 1, documentWatchCount: 4, started: true, queueCapacity: undefined }; + case "overflow-reset": + return { + conversationWatchCount: 1, + documentWatchCount: 4, + started: false, + queueCapacity: overflowQueueOperationCapacity, + }; + case "branch-head-replacement": + return { conversationWatchCount: 1, documentWatchCount: 4, started: false, queueCapacity: undefined }; + } +}; +const config = scenarioConfig(scenario); + +function cloneTrusted(value: T): T { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map((child) => cloneTrusted(child)) as T; + const result = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) { + Object.defineProperty(result, key, { + value: cloneTrusted((value as Record)[key]), + writable: true, + enumerable: true, + configurable: true, + }); + } + return result as T; +} + +function hashJson(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function payloadText(length: number, seed: number): string { + const bytes = Buffer.allocUnsafe(length); + let state = (seed ^ 0x9e3779b9) >>> 0; + for (let index = 0; index < length; index++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + bytes[index] = 32 + ((state >>> 0) % 95); + } + return bytes.toString("ascii"); +} + +function makeEntry(id: number, parent: number | null, kind: TranscriptEntry["kind"]): TranscriptEntry { + switch (kind) { + case "user": + return { + id, + parent, + kind, + data: { role: "user", content: payloadText(2_400 + (id % 600), id), timestamp: id * 10 }, + }; + case "assistant": + return { + id, + parent, + kind, + data: { + role: "assistant", + content: [{ type: "text", text: payloadText(5_500 + (id % 2_000), id) }], + model: "claude-sonnet-provisional", + usage: { input: 2_048 + id, output: 512 + (id % 256), cached: id % 1_024 }, + }, + }; + case "tool-result": + return { + id, + parent, + kind, + data: { + callId: `call-${id}`, + tool: id % 2 === 0 ? "read" : "bash", + status: "completed", + output: payloadText(11_000 + (id % 5_000), id), + }, + }; + case "application": + return { + id, + parent, + kind, + data: { + namespace: "pico5.provisional", + event: "artifact.updated", + payload: payloadText(3_200 + (id % 1_000), id), + }, + }; + } +} + +function entryKindAt(index: number): TranscriptEntry["kind"] { + const cycle: readonly TranscriptEntry["kind"][] = [ + "user", + "assistant", + "assistant", + "tool-result", + "application", + "assistant", + "tool-result", + ]; + return cycle[index % cycle.length]!; +} + +function resolveActiveLineage(records: ReadonlyMap, head: number | null): TranscriptEntry[] { + const reversed: TranscriptEntry[] = []; + const seen = new Set(); + let current = head; + while (current !== null) { + if (seen.has(current)) throw new Error(`Transcript cycle at ${current}`); + seen.add(current); + const entry = records.get(current); + if (entry === undefined) throw new Error(`Missing transcript entry ${current}`); + reversed.push(entry); + current = entry.parent; + } + reversed.reverse(); + return reversed; +} + +function createTracker(value: T): TrackerLike { + return track(value) as unknown as TrackerLike; +} + +function prefixDocumentOperations(key: MountedKey, operations: readonly Op[]): Op[] { + const prefix: Path = ["docs", key]; + return operations.map((operation): Op => { + if (operation[0] === "r") return ["s", prefix as NonEmptyPath, operation[1]]; + const path = [...prefix, ...operation[1]]; + switch (operation[0]) { + case "s": + return ["s", path as unknown as NonEmptyPath, operation[2]]; + case "d": + return ["d", path as unknown as NonEmptyPath]; + case "a": + return ["a", path as unknown as NonEmptyPath, operation[2]]; + case "t": + return ["t", path as unknown as NonEmptyPath, operation[2]]; + case "p": + return ["p", path, operation[2], operation[3], operation[4]]; + case "m": + return ["m", path, operation[2]]; + default: + throw new TypeError(`Unknown operation ${(operation as readonly [unknown])[0]}`); + } + }); +} + +function commonEntryPrefix(left: readonly TranscriptEntry[], right: readonly TranscriptEntry[]): number { + let index = 0; + while (index < left.length && index < right.length && left[index]!.id === right[index]!.id) index += 1; + return index; +} + +function initialDocuments(): Record { + const sections = Array.from({ length: 32 }, (_, index) => ({ + id: `section-${index}`, + title: `Configuration section ${index}`, + instructions: payloadText(1_850 + (index % 5) * 80, 10_000 + index), + })); + const tools = Array.from({ length: 24 }, (_, index) => ({ + name: `tool_${index}`, + description: payloadText(320 + (index % 4) * 40, 20_000 + index), + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: payloadText(120, 30_000 + index) }, + query: { type: "string", description: payloadText(120, 31_000 + index) }, + }, + required: ["path"], + }, + })); + const inboxItems = Array.from( + { length: 16 }, + (_, index): InboxItem => ({ + id: index + 1, + mode: index % 3 === 0 ? "steer" : "followUp", + text: payloadText(960 + (index % 4) * 48, 40_000 + index), + }), + ); + const liveTools = Array.from({ length: 8 }, (_, index) => ({ + callId: `initial-call-${index}`, + name: index % 2 === 0 ? "read" : "bash", + status: index < 2 ? "running" : "waiting", + progress: index < 2 ? 0.25 : 0, + output: payloadText(640 + index * 96, 50_000 + index), + })); + return { + config: { + schema: "pico5.doc.config.provisional.v0", + model: { provider: "anthropic", modelId: "claude-sonnet-provisional" }, + thinkingLevel: "medium", + selectedTools: tools.slice(0, 12).map((tool) => tool.name), + sections, + tools, + temperature: 0.2, + revision: 0, + } as unknown as JsonObject, + inbox: { schema: "pico5.doc.inbox.provisional.v0", items: inboxItems, revision: 0 } as unknown as JsonObject, + turn: { + schema: "pico5.doc.turn.provisional.v0", + stage: "streaming", + message: payloadText(640, 60_000), + toolSlots: [{ callId: "initial-call-0", name: "read", status: "running", preview: "reading" }], + revision: 0, + } as unknown as JsonObject, + preferences: { + schema: "pico5.doc.preferences.provisional.v0", + theme: "dark", + keymap: "default", + compactTools: false, + fontScale: 1, + editorProfile: payloadText(2_400, 70_000), + shortcuts: Object.fromEntries( + Array.from({ length: 24 }, (_, index) => [`command.${index}`, `ctrl+${String.fromCharCode(97 + index)}`]), + ), + revision: 0, + } as unknown as JsonObject, + live: { + schema: "pico5.doc.live.provisional.v0", + status: "streaming", + tokens: 8_192, + cost: 0.024, + heartbeat: 0, + generation: { stage: "streaming", attempt: 1, partial: payloadText(8_192, 80_000) }, + tools: liveTools, + } as unknown as JsonObject, + audit: { schema: "pico5.doc.audit.unmounted.provisional.v0", commits: 0, note: "" } as unknown as JsonObject, + }; +} + +await collect(); +const processBaselineHeap = process.memoryUsage().heapUsed; + +const phaseSamples: PhaseSamples = { + mutation: [], + prepare: [], + adopt: [], + synthesize: [], + projectApply: [], + project: [], + enqueue: [], + sessionLine: [], + delivery: [], + opSerialize: [], + resetSerialize: [], + conversationAcquire: [], + documentAcquire: [], + conversationSerialize: [], + documentSerialize: [], + resetClone: [], +}; +const records = new Map(); +let head: number | null = null; +let nextEntryId = 1; +for (let index = 0; index < headlineEntries; index++) { + const entry = makeEntry(nextEntryId++, head, entryKindAt(index)); + records.set(entry.id, entry); + head = entry.id; +} +const documentTrackers = {} as Record>; +for (const [key, value] of Object.entries(initialDocuments()) as Array<[DocumentKey, JsonObject]>) { + documentTrackers[key] = createTracker(value); +} + +function rebuildExpectedView(): ConversationView { + const docs = {} as Record; + for (const key of MOUNTED_KEYS) docs[key] = cloneTrusted(documentTrackers[key].value); + return { + schema: PROVISIONAL_SCHEMA, + conversation: { id: "conversation-1", head }, + docs, + entries: cloneTrusted(resolveActiveLineage(records, head)), + }; +} + +let aggregate = rebuildExpectedView(); +const initialAggregateBytes = Buffer.byteLength(JSON.stringify(aggregate)); +let conversationSequence = 0; +const documentSequences = Object.fromEntries( + (Object.keys(documentTrackers) as DocumentKey[]).map((key) => [key, 0]), +) as Record; +let conversationWatches: Array> = []; +let documentWatches: DocumentWatch[] = []; +let queuedFrameReferences = 0; +let queuedOperationReferences = 0; +const queuedFrameRefCounts = new Map(); +let peakQueueReferences = 0; +let peakQueuedOperationReferences = 0; +let peakPendingOperationsPerWatch = 0; +let peakUniqueQueuedBatches = 0; +let deliveryFrameReferences = 0; +let deliveryOperationReferences = 0; +let aggregateOperationCount = 0; +let aggregateOperationBytes = 0; +let documentOperationCount = 0; +let documentOperationBytes = 0; +let publishedAggregateBatches = 0; +let publishedDocumentBatches = 0; +let conversationRegistrations = 0; +let documentRegistrations = 0; +let conversationBaseBytes = 0; +let documentBaseBytes = 0; +let resetCount = 0; +let resetBytes = 0; +let noOpCommitCount = 0; +let unmountedCommitCount = 0; +let unmountedPublicationViolations = 0; +let sessionCommitCount = 0; +let peakHeap = process.memoryUsage().heapUsed; +const deferredResetFrames: DeltaFrame[] = []; + +function samplePeak(): void { + peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); +} + +async function collect(): Promise { + assert.ok(global.gc, "benchmark worker requires --expose-gc"); + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + global.gc(); + } +} + +function distribution(values: readonly number[]): Distribution { + if (values.length === 0) return { count: 0, totalMs: 0, medianMs: 0, p95Ms: 0, p99Ms: 0, minMs: 0, maxMs: 0 }; + const ordered = [...values].sort((left, right) => left - right); + return { + count: ordered.length, + totalMs: ordered.reduce((total, value) => total + value, 0), + medianMs: ordered[Math.floor(ordered.length / 2)]!, + p95Ms: ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * 0.95))]!, + p99Ms: ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * 0.99))]!, + minMs: ordered[0]!, + maxMs: ordered.at(-1)!, + }; +} + +function snapshotHash(value: object): string { + return hashJson(value); +} + +function serializeBaseSnapshot(watchType: WatchType, snapshot: object): string { + const phase = watchType === "conversation" ? "conversationSerialize" : "documentSerialize"; + const start = performance.now(); + const encoded = JSON.stringify(snapshot); + phaseSamples[phase].push(performance.now() - start); + const bytes = Buffer.byteLength(encoded); + if (watchType === "conversation") conversationBaseBytes += bytes; + else documentBaseBytes += bytes; + return createHash("sha256").update(encoded).digest("hex"); +} + +function captureSnapshot(value: T, phase: "conversationAcquire" | "documentAcquire"): T { + const start = performance.now(); + const snapshot = value; + phaseSamples[phase].push(performance.now() - start); + samplePeak(); + return snapshot; +} + +function createWatch(value: T, sequence: number, started: boolean, watchType: WatchType): Watch { + const acquirePhase = watchType === "conversation" ? "conversationAcquire" : "documentAcquire"; + const snapshot = captureSnapshot(value, acquirePhase); + if (watchType === "conversation") conversationRegistrations += 1; + else documentRegistrations += 1; + return { + snapshot, + baseSequence: sequence, + baseHash: serializeBaseSnapshot(watchType, snapshot), + deliveredSequence: sequence, + pendingOperationCount: 0, + queue: [], + started, + watchType, + }; +} + +function verifySnapshotUnchanged(watch: Watch): void { + assert.equal(snapshotHash(watch.snapshot), watch.baseHash); +} + +function clearWatchQueue(watch: Watch): void { + queuedFrameReferences -= watch.queue.length; + queuedOperationReferences -= watch.pendingOperationCount; + for (const frame of watch.queue) { + const references = queuedFrameRefCounts.get(frame)! - 1; + if (references === 0) queuedFrameRefCounts.delete(frame); + else queuedFrameRefCounts.set(frame, references); + } + watch.queue.length = 0; + watch.pendingOperationCount = 0; +} + +function retainQueuedFrame(watch: Watch, frame: DeltaFrame): void { + watch.queue.push(frame); + watch.pendingOperationCount += frame.ops.length; + queuedFrameReferences += 1; + queuedOperationReferences += frame.ops.length; + queuedFrameRefCounts.set(frame, (queuedFrameRefCounts.get(frame) ?? 0) + 1); +} + +function resetWatch(watch: Watch, value: T, sequence: number): void { + clearWatchQueue(watch as unknown as Watch); + const cloneStart = performance.now(); + const resetValue = value; + phaseSamples.resetClone.push(performance.now() - cloneStart); + const frame: DeltaFrame = { + kind: "delta", + sequence, + ops: [["r", resetValue as unknown as JsonValue]], + }; + retainQueuedFrame(watch as unknown as Watch, frame); + deferredResetFrames.push(frame); + resetCount += 1; +} + +function enqueueWatch(watch: Watch, frame: DeltaFrame, current: T): void { + const previous = watch.queue.at(-1)?.sequence ?? watch.deliveredSequence; + assert.equal(frame.sequence, previous + 1); + if (config.queueCapacity !== undefined && watch.pendingOperationCount + frame.ops.length > config.queueCapacity) { + resetWatch(watch, current, frame.sequence); + return; + } + retainQueuedFrame(watch as unknown as Watch, frame); +} + +function sampleQueueMetrics(): void { + peakQueueReferences = Math.max(peakQueueReferences, queuedFrameReferences); + peakQueuedOperationReferences = Math.max(peakQueuedOperationReferences, queuedOperationReferences); + peakUniqueQueuedBatches = Math.max(peakUniqueQueuedBatches, queuedFrameRefCounts.size); + for (const watch of conversationWatches) { + peakPendingOperationsPerWatch = Math.max(peakPendingOperationsPerWatch, watch.pendingOperationCount); + } + for (const watch of documentWatches) { + peakPendingOperationsPerWatch = Math.max(peakPendingOperationsPerWatch, watch.pendingOperationCount); + } +} + +function acquireInitialWatches(): void { + for (let index = 0; index < config.conversationWatchCount; index++) { + conversationWatches.push(createWatch(aggregate, conversationSequence, config.started, "conversation")); + } + for (let index = 0; index < config.documentWatchCount; index++) { + const key = DOCUMENT_WATCH_KEYS[index % DOCUMENT_WATCH_KEYS.length]!; + documentWatches.push({ + ...createWatch(documentTrackers[key].value, documentSequences[key], config.started, "document"), + key, + }); + } +} + +function planCommit(index: number): CommitPlan { + const docs: DocMutation[] = []; + const cycle = index % 100; + if (cycle < 70) { + docs.push({ + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + const limit = 4_096 + (index % 8) * 4_096; + const chunk = payloadText(384 + (index % 5) * 64, 100_000 + index); + live.generation.partial = `${live.generation.partial}${chunk}`.slice(-limit); + live.generation.stage = "streaming"; + live.status = "streaming"; + live.tokens += 24 + (index % 17); + live.cost += 0.0002; + live.heartbeat = index; + }, + }); + } else if (cycle < 90) { + docs.push({ + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + const slot = live.tools[index % live.tools.length]!; + slot.status = cycle % 4 === 0 ? "completed" : "running"; + slot.progress = Math.min(1, slot.progress + 0.125); + slot.output = `${slot.output.slice(-2_048)}${payloadText(512, 110_000 + index)}`; + live.heartbeat = index; + }, + }); + } else if (cycle < 94) { + docs.push({ + key: "inbox", + mutate: (draft) => { + const inbox = draft as unknown as Draft; + inbox.items.push({ + id: 1_000_000 + index, + mode: cycle % 2 === 0 ? "steer" : "followUp", + text: payloadText(1_024, 120_000 + index), + }); + if (inbox.items.length > 20) inbox.items.shift(); + inbox.revision += 1; + }, + }); + } else if (cycle < 96) { + docs.push( + { + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + live.status = cycle === 94 ? "admitting" : "ready"; + live.generation.stage = cycle === 94 ? "requesting" : "completed"; + live.heartbeat = index; + }, + }, + { + key: "turn", + mutate: (draft) => { + const turn = draft as unknown as Draft; + turn.stage = cycle === 94 ? "requesting" : "idle"; + turn.message = cycle === 94 ? payloadText(720, 130_000 + index) : ""; + turn.revision += 1; + }, + }, + ); + } else if (cycle === 96) { + docs.push({ + key: "config", + mutate: (draft) => { + const configDoc = draft as unknown as Draft; + configDoc.thinkingLevel = index % 200 === 96 ? "high" : "medium"; + configDoc.temperature = 0.15 + (Math.floor(index / 100) % 4) * 0.05; + const section = configDoc.sections[Math.floor(index / 100) % configDoc.sections.length]!; + section.instructions = payloadText(section.instructions.length, 140_000 + index); + configDoc.revision += 1; + }, + }); + } else if (cycle === 97) { + docs.push( + { + key: "preferences", + mutate: (draft) => { + const preferences = draft as unknown as Draft; + preferences.theme = index % 200 === 97 ? "dim" : "dark"; + preferences.compactTools = !preferences.compactTools; + preferences.editorProfile = payloadText(2_400 + (index % 4) * 256, 150_000 + index); + preferences.revision += 1; + }, + }, + { + key: "turn", + mutate: (draft) => { + const turn = draft as unknown as Draft; + turn.stage = "configured"; + turn.revision += 1; + }, + }, + ); + } else if (cycle === 98) { + docs.push( + { + key: "live", + mutate: (draft) => { + const live = draft as unknown as Draft; + live.status = "retrying"; + live.generation.stage = "retrying"; + live.generation.attempt += 1; + live.generation.retryAt = 1_700_000_000_000 + index * 100; + live.heartbeat = index; + }, + }, + { + key: "turn", + mutate: (draft) => { + const turn = draft as unknown as Draft; + turn.stage = "retrying"; + turn.revision += 1; + }, + }, + ); + } else { + docs.push( + { + key: "audit", + mutate: (draft) => { + const audit = draft as unknown as Draft; + audit.commits += 1; + audit.note = `unmounted-${index}`; + }, + }, + { + key: "config", + mutate: (draft) => { + const configDoc = draft as unknown as Draft; + configDoc.model = { + provider: configDoc.model.provider, + modelId: configDoc.model.modelId, + }; + }, + }, + ); + return { + docs, + unmountedOnly: true, + expectedConversationNoop: true, + }; + } + const settlement = cycle === 94 || cycle === 95; + const branch = scenario === "branch-head-replacement" && cycle === 95 && index >= 100; + return { + docs, + entryKind: settlement ? (cycle === 94 ? "user" : "assistant") : undefined, + branch, + unmountedOnly: false, + expectedConversationNoop: false, + }; +} + +function applyProjectedBatch(publicBatch: readonly Op[]): void { + aggregate = applyImmutable(aggregate, publicBatch); +} + +function enqueuePublishedBatches( + aggregateFrame: DeltaFrame | undefined, + documentFrames: readonly DocumentWatchFrame[], +): void { + const start = performance.now(); + if (aggregateFrame !== undefined) { + for (const watch of conversationWatches) enqueueWatch(watch, aggregateFrame, aggregate); + } + for (const documentFrame of documentFrames) { + for (const watch of documentWatches) { + if (watch.key === documentFrame.key) + enqueueWatch(watch, documentFrame.frame, documentTrackers[watch.key].value); + } + } + sampleQueueMetrics(); + phaseSamples.enqueue.push(performance.now() - start); +} + +type DocumentWatchFrame = { readonly key: DocumentKey; readonly frame: DeltaFrame }; + +function serializePublishedBatches( + aggregateFrame: DeltaFrame | undefined, + documentFrames: readonly DocumentWatchFrame[], +): void { + const start = performance.now(); + if (aggregateFrame !== undefined) { + aggregateOperationBytes += Buffer.byteLength(JSON.stringify(aggregateFrame.ops)); + } + for (const { frame } of documentFrames) documentOperationBytes += Buffer.byteLength(JSON.stringify(frame.ops)); + phaseSamples.opSerialize.push(performance.now() - start); +} + +function finishResetSerialization(): void { + if (deferredResetFrames.length === 0) return; + const start = performance.now(); + for (const frame of deferredResetFrames) resetBytes += Buffer.byteLength(JSON.stringify(frame.ops)); + phaseSamples.resetSerialize.push(performance.now() - start); + deferredResetFrames.length = 0; +} + +function isResetJump(frame: DeltaFrame, previousSequence: number): boolean { + return frame.sequence > previousSequence + 1 && frame.ops.length === 1 && frame.ops[0]![0] === "r"; +} + +async function forwardOpsOnly(frame: DeltaFrame): Promise { + // Models WatchHandle.start's serialized async listener. It forwards operations + // only; it intentionally does not construct or update a consumer-side replica. + await Promise.resolve(); + deliveryFrameReferences += 1; + deliveryOperationReferences += frame.ops.length; +} + +async function drainStartedWatches(): Promise { + const start = performance.now(); + for (const watch of conversationWatches) { + if (!watch.started) continue; + for (const frame of watch.queue) { + assert.ok(frame.sequence === watch.deliveredSequence + 1 || isResetJump(frame, watch.deliveredSequence)); + await forwardOpsOnly(frame); + watch.deliveredSequence = frame.sequence; + } + clearWatchQueue(watch as unknown as Watch); + assert.equal(watch.deliveredSequence, conversationSequence); + } + for (const watch of documentWatches) { + if (!watch.started) continue; + for (const frame of watch.queue) { + assert.ok(frame.sequence === watch.deliveredSequence + 1 || isResetJump(frame, watch.deliveredSequence)); + await forwardOpsOnly(frame); + watch.deliveredSequence = frame.sequence; + } + clearWatchQueue(watch); + assert.equal(watch.deliveredSequence, documentSequences[watch.key]); + } + phaseSamples.delivery.push(performance.now() - start); +} + +async function commit(index: number): Promise { + const plan = planCommit(index); + const conversationBefore = conversationSequence; + const queuedBefore = conversationWatches.reduce((total, watch) => total + watch.queue.length, 0); + const sessionStart = performance.now(); + const mutationStart = performance.now(); + const pending: Array<{ key: DocumentKey; change: ChangeLike }> = []; + for (const mutation of plan.docs) { + const change = documentTrackers[mutation.key].beginChange(); + mutation.mutate(change.state); + pending.push({ key: mutation.key, change }); + } + if (plan.entryKind !== undefined) { + let parent = head; + if (plan.branch) { + const active = resolveActiveLineage(records, head); + parent = active[Math.max(0, active.length - 48)]?.id ?? null; + } + const entry = makeEntry(nextEntryId++, parent, plan.entryKind); + records.set(entry.id, entry); + head = entry.id; + } + phaseSamples.mutation.push(performance.now() - mutationStart); + + const prepareStart = performance.now(); + const prepared = pending.map(({ key, change }) => ({ key, change, prepared: change.prepare() })); + phaseSamples.prepare.push(performance.now() - prepareStart); + + const adoptStart = performance.now(); + for (const item of prepared) documentTrackers[item.key].adopt(item.prepared); + phaseSamples.adopt.push(performance.now() - adoptStart); + + const projectStart = performance.now(); + const synthesizeStart = performance.now(); + const rawAggregateOperations: Op[] = []; + const documentFrames: DocumentWatchFrame[] = []; + for (const item of prepared) { + if (item.prepared.ops.length === 0) continue; + documentSequences[item.key] += 1; + const documentOps = item.prepared.ops; + documentOperationCount += documentOps.length; + publishedDocumentBatches += 1; + documentFrames.push({ + key: item.key, + frame: { kind: "delta", sequence: documentSequences[item.key], ops: documentOps }, + }); + if (item.key !== "audit") rawAggregateOperations.push(...prefixDocumentOperations(item.key, documentOps)); + } + if (plan.entryKind !== undefined) { + const previousEntries = aggregate.entries; + const nextEntries = resolveActiveLineage(records, head); + const common = commonEntryPrefix(previousEntries, nextEntries); + if (common !== previousEntries.length || common !== nextEntries.length) { + rawAggregateOperations.push([ + "p", + ["entries"], + common, + previousEntries.length - common, + cloneTrusted(nextEntries.slice(common)) as unknown as JsonValue[], + ]); + rawAggregateOperations.push(["s", ["conversation", "head"], head]); + } + } + let aggregateFrame: DeltaFrame | undefined; + if (rawAggregateOperations.length > 0) { + conversationSequence += 1; + aggregateOperationCount += rawAggregateOperations.length; + publishedAggregateBatches += 1; + aggregateFrame = { kind: "delta", sequence: conversationSequence, ops: rawAggregateOperations }; + } + phaseSamples.synthesize.push(performance.now() - synthesizeStart); + + const projectApplyStart = performance.now(); + if (aggregateFrame !== undefined) { + // Immutable application may safely share public placements with the projection. + applyProjectedBatch(aggregateFrame.ops); + } + phaseSamples.projectApply.push(performance.now() - projectApplyStart); + phaseSamples.project.push(performance.now() - projectStart); + + enqueuePublishedBatches(aggregateFrame, documentFrames); + phaseSamples.sessionLine.push(performance.now() - sessionStart); + sessionCommitCount += 1; + samplePeak(); + serializePublishedBatches(aggregateFrame, documentFrames); + finishResetSerialization(); + await drainStartedWatches(); + samplePeak(); + + if (plan.expectedConversationNoop) { + noOpCommitCount += 1; + assert.equal(aggregateFrame, undefined); + } + if (plan.unmountedOnly) { + unmountedCommitCount += 1; + const queuedAfter = conversationWatches.reduce((total, watch) => total + watch.queue.length, 0); + if (conversationSequence !== conversationBefore || queuedAfter !== queuedBefore) + unmountedPublicationViolations += 1; + } +} + +function registerConversationWatch(started: boolean): void { + conversationWatches.push(createWatch(aggregate, conversationSequence, started, "conversation")); +} + +function registerDocumentWatch(key: DocumentKey, started: boolean): void { + documentWatches.push({ + ...createWatch(documentTrackers[key].value, documentSequences[key], started, "document"), + key, + }); +} + +function verifyAndDropConversationWatch(watch: Watch): void { + verifySnapshotUnchanged(watch); + if (watch.started) assert.equal(watch.deliveredSequence, conversationSequence); + conversationWatches = conversationWatches.filter((candidate) => candidate !== watch); +} + +function verifyAndDropDocumentWatch(watch: DocumentWatch): void { + verifySnapshotUnchanged(watch); + if (watch.started) assert.equal(watch.deliveredSequence, documentSequences[watch.key]); + documentWatches = documentWatches.filter((candidate) => candidate !== watch); +} + +await collect(); +const baselineHeap = process.memoryUsage().heapUsed; +acquireInitialWatches(); +await collect(); +const readyHeap = process.memoryUsage().heapUsed; +const postAcquireRetainedMiB = (readyHeap - baselineHeap) / MiB; +samplePeak(); + +const churnInterval = Math.max(8, Math.floor(commitTarget / 10)); +for (let index = 0; index < commitTarget; index++) { + await commit(index); + if (scenario === "churn-reconnect" && (index + 1) % churnInterval === 0) { + const current = conversationWatches[0]; + if (current !== undefined) verifyAndDropConversationWatch(current); + registerConversationWatch(true); + const key = DOCUMENT_WATCH_KEYS[Math.floor((index + 1) / churnInterval) % DOCUMENT_WATCH_KEYS.length]!; + const currentDocument = documentWatches.find((watch) => watch.key === key); + if (currentDocument !== undefined) verifyAndDropDocumentWatch(currentDocument); + registerDocumentWatch(key, true); + } +} + +await collect(); +const queuedHeap = process.memoryUsage().heapUsed; +const postWorkRetainedMiB = (queuedHeap - baselineHeap) / MiB; +samplePeak(); + +function replayWatch(watch: Watch, expected: T, expectedSequence: number): void { + verifySnapshotUnchanged(watch); + if (watch.started) { + assert.equal(watch.deliveredSequence, expectedSequence); + return; + } + let value = cloneTrusted(watch.snapshot); + let sequence = watch.baseSequence; + for (const frame of watch.queue) { + assert.ok(frame.sequence === sequence + 1 || isResetJump(frame, sequence)); + value = applyImmutable(value, frame.ops); + sequence = frame.sequence; + } + assert.equal(sequence, expectedSequence); + assert.deepEqual(value, expected); +} + +let expectedView: ConversationView | undefined = rebuildExpectedView(); +assert.deepEqual(aggregate, expectedView); +expectedView = undefined; +for (const watch of conversationWatches) replayWatch(watch, aggregate, conversationSequence); +for (const watch of documentWatches) { + replayWatch(watch, documentTrackers[watch.key].value, documentSequences[watch.key]); +} +assert.equal(unmountedPublicationViolations, 0); +assert.equal(noOpCommitCount, Math.floor(commitTarget / 100)); +assert.equal(unmountedCommitCount, Math.floor(commitTarget / 100)); +assert.equal(sessionCommitCount, commitTarget); + +await collect(); +const verifiedHeap = process.memoryUsage().heapUsed; +const postVerificationRetainedMiB = (verifiedHeap - baselineHeap) / MiB; +const finalQueueReferences = + conversationWatches.reduce((total, watch) => total + watch.queue.length, 0) + + documentWatches.reduce((total, watch) => total + watch.queue.length, 0); +const finalQueuedOperationReferences = + conversationWatches.reduce((total, watch) => total + watch.pendingOperationCount, 0) + + documentWatches.reduce((total, watch) => total + watch.pendingOperationCount, 0); +const finalUniqueQueuedBatches = new Set([ + ...conversationWatches.flatMap((watch) => watch.queue), + ...documentWatches.flatMap((watch) => watch.queue), +]).size; +assert.equal(finalQueueReferences, queuedFrameReferences); +assert.equal(finalQueuedOperationReferences, queuedOperationReferences); +assert.equal(finalUniqueQueuedBatches, queuedFrameRefCounts.size); +assert.equal(deferredResetFrames.length, 0); +for (const watch of conversationWatches) clearWatchQueue(watch as unknown as Watch); +for (const watch of documentWatches) clearWatchQueue(watch); +conversationWatches = []; +documentWatches = []; +assert.equal(queuedFrameReferences, 0); +assert.equal(queuedOperationReferences, 0); +assert.equal(queuedFrameRefCounts.size, 0); +await collect(); +const releasedHeap = process.memoryUsage().heapUsed; +const releaseHeapMiB = (releasedHeap - baselineHeap) / MiB; + +const phaseDistributions = Object.fromEntries( + (Object.keys(phaseSamples) as PhaseName[]).map((name) => [name, distribution(phaseSamples[name])]), +) as Record; +const checksum = + aggregate.entries.length + + Number(aggregate.conversation.head ?? 0) + + conversationSequence + + MOUNTED_KEYS.reduce((total, key) => total + JSON.stringify(aggregate.docs[key]).length, 0); + +console.log( + JSON.stringify({ + mode, + scenario, + trial, + provisionalSchema: PROVISIONAL_SCHEMA, + headlineEntries, + commitTarget, + initialAggregateBytes, + conversationWatchCount: config.conversationWatchCount, + documentWatchCount: config.documentWatchCount, + queueCapacity: config.queueCapacity ?? null, + queueCapacityUnit: "pending-operations", + deliveryModel: "async-serialized-ops-only-no-consumer-apply", + conversationSequence, + documentSequences, + publishedAggregateBatches, + publishedDocumentBatches, + conversationRegistrations, + documentRegistrations, + conversationBaseBytes, + documentBaseBytes, + noOpCommitCount, + unmountedCommitCount, + resetCount, + resetBytes, + deliveryFrameReferences, + deliveryOperationReferences, + aggregateOperationCount, + aggregateOperationBytes, + documentOperationCount, + documentOperationBytes, + peakQueueReferences, + peakQueuedOperationReferences, + peakPendingOperationsPerWatch, + peakUniqueQueuedBatches, + finalQueueReferences, + finalQueuedOperationReferences, + finalUniqueQueuedBatches, + phaseDistributions, + authorityReadyMiB: (baselineHeap - processBaselineHeap) / MiB, + postAcquireRetainedMiB, + postWorkRetainedMiB, + postVerificationRetainedMiB, + releaseHeapMiB, + releaseTotalMiB: (releasedHeap - processBaselineHeap) / MiB, + peakHeapMiB: (peakHeap - baselineHeap) / MiB, + peakTotalHeapMiB: (peakHeap - processBaselineHeap) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + checksum, + }), +); diff --git a/packages/chord/test/delta-benchmark/memory-benchmark.ts b/packages/chord/test/delta-benchmark/memory-benchmark.ts new file mode 100644 index 000000000..4ae74b2da --- /dev/null +++ b/packages/chord/test/delta-benchmark/memory-benchmark.ts @@ -0,0 +1,142 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Mode = "tracker"; +type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches"; +type Result = { + mode: Mode; + scenario: Scenario; + trial: number; + size: number; + updates: number; + watchCount: number; + workMs: number; + acquisitionMs: number; + gcCount: number; + gcMs: number; + readyHeapMiB: number; + readyRssMiB: number; + peakOverReadyHeapMiB: number; + peakOverProcessBaselineHeapMiB: number; + peakOverReadyRssMiB: number; + retainedOverReadyHeapMiB: number; + retainedOverProcessBaselineHeapMiB: number; + retainedOverReadyRssMiB: number; + snapshotRetainedHeapMiB: number; + afterSnapshotReleaseOverReadyHeapMiB: number; + afterSnapshotReleaseOverProcessBaselineHeapMiB: number; + releasedOverProcessBaselineHeapMiB: number; + releasedOverProcessBaselineRssMiB: number; + maxRssMiB: number; + checksum: number; +}; +type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; + +const modes: readonly Mode[] = ["tracker"]; +const scenarios: readonly Scenario[] = ["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"]; +const metrics = [ + "workMs", + "acquisitionMs", + "gcCount", + "gcMs", + "readyHeapMiB", + "readyRssMiB", + "peakOverReadyHeapMiB", + "peakOverProcessBaselineHeapMiB", + "peakOverReadyRssMiB", + "retainedOverReadyHeapMiB", + "retainedOverProcessBaselineHeapMiB", + "retainedOverReadyRssMiB", + "snapshotRetainedHeapMiB", + "afterSnapshotReleaseOverReadyHeapMiB", + "afterSnapshotReleaseOverProcessBaselineHeapMiB", + "releasedOverProcessBaselineHeapMiB", + "releasedOverProcessBaselineRssMiB", + "maxRssMiB", +] as const satisfies readonly (keyof Result)[]; + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; +} + +function range(values: readonly number[]): readonly [number, number] | null { + return values.length === 0 ? null : [Math.min(...values), Math.max(...values)]; +} + +const quick = process.argv.includes("--quick"); +const size = quick ? 10_000 : 100_000; +const updates = quick ? 20 : 100; +const watchCount = quick ? 4 : 8; +const trials = quick ? 1 : 3; +const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const worker = fileURLToPath(new URL("./memory-benchmark.worker.ts", import.meta.url)); +const results: Result[] = []; +const failures: Failure[] = []; + +for (let trial = 0; trial < trials; trial++) { + for (const scenario of scenarios) { + const offset = trial % modes.length; + const trialModes = [...modes.slice(offset), ...modes.slice(0, offset)]; + for (const mode of trialModes) { + const child = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-flush-bytecode", + "--max-old-space-size=4096", + worker, + mode, + scenario, + String(size), + String(updates), + String(watchCount), + String(trial), + ], + { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, + ); + if (child.status !== 0) { + const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; + failures.push(failure); + console.log(JSON.stringify(failure)); + continue; + } + const result = JSON.parse(child.stdout) as Result; + results.push(result); + console.log(JSON.stringify(result)); + } + } +} + +const summary = scenarios.flatMap((scenario) => + modes.map((mode) => { + const selected = results.filter((result) => result.scenario === scenario && result.mode === mode); + const values: Record = {}; + for (const metric of metrics) { + const samples = selected.map((result) => result[metric]); + values[metric] = { median: median(samples), range: range(samples) }; + } + return { scenario, mode, metrics: values }; + }), +); +const output = join(tmpdir(), "chord-delta-memory-benchmark.json"); +writeFileSync( + output, + JSON.stringify({ node: process.version, size, updates, watchCount, trials, results, failures, summary }, null, 2), +); +console.log( + JSON.stringify({ + output, + node: process.version, + size, + updates, + watchCount, + trials, + failures: failures.length, + summary, + }), +); diff --git a/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts b/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts new file mode 100644 index 000000000..df1c64e31 --- /dev/null +++ b/packages/chord/test/delta-benchmark/memory-benchmark.worker.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { PerformanceObserver, performance } from "node:perf_hooks"; +import { setImmediate } from "node:timers/promises"; +import type { Draft } from "../../src/delta/draft.ts"; +import { type Op, track } from "../../src/delta/index.ts"; + +const MiB = 1024 * 1024; + +type Mode = "tracker"; +type Scenario = "idle-control" | "sparse-burst" | "staggered-revisions" | "lagging-watches"; +type Row = { id: number; value: number; payload: string }; +type Document = { rows: Row[] }; +type PreparedLike = { readonly value: Document; readonly ops: readonly Op[] }; +type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; +type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; + +const MODES = new Set(["tracker"]); +const SCENARIOS = new Set(["idle-control", "sparse-burst", "staggered-revisions", "lagging-watches"]); +const mode = process.argv[2] as Mode; +const scenario = process.argv[3] as Scenario; +const size = Number(process.argv[4] ?? 100_000); +const updates = Number(process.argv[5] ?? 100); +const watchCount = Number(process.argv[6] ?? 8); +const trial = Number(process.argv[7] ?? 0); +if (!MODES.has(mode)) throw new Error(`Unknown mode ${mode}`); +if (!SCENARIOS.has(scenario)) throw new Error(`Unknown scenario ${scenario}`); +if (!Number.isSafeInteger(size) || size < 1) throw new RangeError("Invalid size"); +if (!Number.isSafeInteger(updates) || updates < 1) throw new RangeError("Invalid update count"); +if (!Number.isSafeInteger(watchCount) || watchCount < 1) throw new RangeError("Invalid watch count"); + +function fixture(count: number): Document { + return { rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })) }; +} + +async function collect(): Promise { + assert.ok(global.gc, "memory benchmark requires --expose-gc"); + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + global.gc(); + } +} + +function createTracker(value: Document): TrackerLike { + return track(value) as TrackerLike; +} + +function commitUpdate(tracker: TrackerLike, update: number): void { + const change = tracker.beginChange(); + const row = ((update + 1) * 997) % size; + change.state.rows[row]!.value = -update - 1; + const prepared = change.prepare(); + tracker.adopt(prepared); +} + +function acquireSnapshot(tracker: TrackerLike): Document { + return tracker.value; +} + +let warmup: TrackerLike | undefined = createTracker(fixture(16)); +const warmupChange = warmup.beginChange(); +warmupChange.state.rows[0]!.value = -1; +const warmupPrepared = warmupChange.prepare(); +warmup.adopt(warmupPrepared); +warmup = undefined; +await collect(); +const processBaseline = process.memoryUsage(); +let input: Document | undefined = fixture(size); +let tracker: TrackerLike | undefined = createTracker(input); +input = undefined; +await collect(); +const ready = process.memoryUsage(); +let peakHeap = ready.heapUsed; +let peakRss = ready.rss; +let acquisitionMs = 0; +let snapshots: Document[] = []; +let gcCount = 0; +let gcMs = 0; +const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + gcCount += 1; + gcMs += entry.duration; + } +}); +observer.observe({ entryTypes: ["gc"] }); + +const samplePeak = (): void => { + const memory = process.memoryUsage(); + peakHeap = Math.max(peakHeap, memory.heapUsed); + peakRss = Math.max(peakRss, memory.rss); +}; + +const acquire = (): void => { + const start = performance.now(); + snapshots.push(acquireSnapshot(tracker!)); + acquisitionMs += performance.now() - start; + samplePeak(); +}; + +const workStart = performance.now(); +if (scenario === "idle-control") { + for (let update = 0; update < updates; update++) samplePeak(); +} else if (scenario === "sparse-burst") { + for (let update = 0; update < updates; update++) { + commitUpdate(tracker, update); + samplePeak(); + } +} else if (scenario === "staggered-revisions") { + for (let watcher = 0; watcher < watchCount; watcher++) { + acquire(); + if (watcher + 1 < watchCount) commitUpdate(tracker, watcher); + samplePeak(); + } +} else { + for (let watcher = 0; watcher < watchCount; watcher++) acquire(); + for (let update = 0; update < updates; update++) { + commitUpdate(tracker, update); + samplePeak(); + } +} +const workMs = performance.now() - workStart; +for (let attempt = 0; attempt < 3; attempt++) await setImmediate(); +observer.disconnect(); +samplePeak(); +await collect(); +const retained = process.memoryUsage(); +const checksum = tracker.value.rows[0]!.value + tracker.value.rows.at(-1)!.value + snapshots.length; + +snapshots = []; +await collect(); +const afterSnapshotRelease = process.memoryUsage(); +tracker = undefined; +await collect(); +const released = process.memoryUsage(); + +console.log( + JSON.stringify({ + mode, + scenario, + trial, + size, + updates: scenario === "staggered-revisions" ? watchCount - 1 : scenario === "idle-control" ? 0 : updates, + watchCount: scenario === "staggered-revisions" || scenario === "lagging-watches" ? watchCount : 0, + workMs, + acquisitionMs, + gcCount, + gcMs, + readyHeapMiB: (ready.heapUsed - processBaseline.heapUsed) / MiB, + readyRssMiB: (ready.rss - processBaseline.rss) / MiB, + peakOverReadyHeapMiB: (peakHeap - ready.heapUsed) / MiB, + peakOverProcessBaselineHeapMiB: (peakHeap - processBaseline.heapUsed) / MiB, + peakOverReadyRssMiB: (peakRss - ready.rss) / MiB, + retainedOverReadyHeapMiB: (retained.heapUsed - ready.heapUsed) / MiB, + retainedOverProcessBaselineHeapMiB: (retained.heapUsed - processBaseline.heapUsed) / MiB, + retainedOverReadyRssMiB: (retained.rss - ready.rss) / MiB, + snapshotRetainedHeapMiB: (retained.heapUsed - afterSnapshotRelease.heapUsed) / MiB, + afterSnapshotReleaseOverReadyHeapMiB: (afterSnapshotRelease.heapUsed - ready.heapUsed) / MiB, + afterSnapshotReleaseOverProcessBaselineHeapMiB: (afterSnapshotRelease.heapUsed - processBaseline.heapUsed) / MiB, + releasedOverProcessBaselineHeapMiB: (released.heapUsed - processBaseline.heapUsed) / MiB, + releasedOverProcessBaselineRssMiB: (released.rss - processBaseline.rss) / MiB, + maxRssMiB: process.resourceUsage().maxRSS / 1024, + checksum, + }), +); diff --git a/packages/chord/test/delta-clone.test.ts b/packages/chord/test/delta-clone.test.ts index eed45ee77..f0b8b83d0 100644 --- a/packages/chord/test/delta-clone.test.ts +++ b/packages/chord/test/delta-clone.test.ts @@ -2,32 +2,42 @@ import { describe, expect, it } from "vitest"; import { apply, track } from "../src/delta/index.ts"; describe("tracker ownership", () => { - it("deeply detaches and freezes the imported revision", () => { + it("takes immutable ownership of the imported revision in O(1)", () => { const input = { point: { x: 3, y: 7, pressure: 0.1 }, rows: [{ values: [0, false, null, "text", { n: 1 }] }], }; const tracker = track(input); - expect(tracker.value).toEqual(input); - expect(tracker.value).not.toBe(input); - expect(tracker.value.point).not.toBe(input.point); - expect(tracker.value.rows[0]!.values[4]).not.toBe(input.rows[0]!.values[4]); - expect(Object.isFrozen(tracker.value.rows[0]!.values)).toBe(true); - input.point.x = 99; - expect(tracker.value.point.x).toBe(3); + expect(tracker.value).toBe(input); + expect(tracker.value.point).toBe(input.point); + expect(tracker.value.rows[0]!.values[4]).toBe(input.rows[0]!.values[4]); + expect(Object.isFrozen(tracker.value.rows[0]!.values)).toBe(false); }); - it("preserves null prototypes and expands aliases", () => { + it("preserves null prototypes in an alias-free owned root", () => { type Dictionary = { enabled: boolean; child: { n: number } }; const dictionary = Object.assign(Object.create(null) as Dictionary, { enabled: true, child: { n: 1 } }); - const shared = { nested: [{ n: 1 }] }; - const tracker = track({ dictionary, left: shared, right: shared }); + const tracker = track({ dictionary, left: { nested: [{ n: 1 }] }, right: { nested: [{ n: 1 }] } }); expect(Object.getPrototypeOf(tracker.value.dictionary)).toBeNull(); - expect(tracker.value.dictionary).not.toBe(dictionary); + expect(tracker.value.dictionary).toBe(dictionary); expect(tracker.value.left).not.toBe(tracker.value.right); expect(tracker.value.left.nested[0]).not.toBe(tracker.value.right.nested[0]); }); + it("does not traverse a trusted root while taking ownership", () => { + let reads = 0; + const input = Object.defineProperty({}, "untrustedAccessor", { + enumerable: true, + get() { + reads += 1; + throw new Error("root was traversed"); + }, + }); + const tracker = track(input); + expect(tracker.value).toBe(input); + expect(reads).toBe(0); + }); + it("copies assigned and inserted values immediately", () => { const tracker = track({ rows: [] as { nested: { value: number } }[] }); const assigned = { nested: { value: 1 } }; @@ -40,41 +50,4 @@ describe("tracker ownership", () => { const replica = apply(structuredClone(prepared.base), prepared.ops); expect(replica).toEqual(prepared.value); }); - - it("rejects cycles, accessors, classes, and non-finite numbers", () => { - const cyclic: { self?: object } = {}; - cyclic.self = cyclic; - expect(() => track(cyclic)).toThrow(/cycles/); - expect(() => track({ value: Number.NaN })).toThrow(/strict JSON/); - expect(() => track({ value: new Date() })).toThrow(/plain objects/); - const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => 1 }); - expect(() => track(accessor)).toThrow(/data properties/); - }); - - it("rejects indexed accessors and non-enumerable array entries without invoking getters", () => { - let reads = 0; - const accessor: number[] = []; - Object.defineProperty(accessor, "0", { - enumerable: true, - configurable: true, - get() { - reads += 1; - return 1; - }, - }); - expect(() => track({ values: accessor })).toThrow(/data properties/); - expect(reads).toBe(0); - - const hidden: number[] = []; - Object.defineProperty(hidden, "0", { value: 1, enumerable: false, configurable: true, writable: true }); - expect(() => track({ values: hidden })).toThrow(/enumerable/); - - const tracker = track({ values: [] as number[] }); - const change = tracker.beginChange(); - expect(() => { - change.state.values = accessor; - }).toThrow(/data properties/); - expect(reads).toBe(0); - change.abort(); - }); }); diff --git a/packages/chord/test/delta-cow/benchmark.ts b/packages/chord/test/delta-cow/benchmark.ts deleted file mode 100644 index 60ad76322..000000000 --- a/packages/chord/test/delta-cow/benchmark.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -type Mode = "cow" | "delta" | "astra"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Result = { - mode: Mode; - scenario: Scenario; - size: number; - trial: number; - queueOperations: number; - importMs: number; - mutateMs: number; - prepareMs: number; - serializeMs: number; - adoptMs: number; - opBytes: number; - operationCount: number; - checksum: number; - readyMiB: number; - transientMiB: number; - retainedMiB: number; - maxRssMiB: number; -}; - -type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string }; - -const scenarios: readonly Scenario[] = [ - "import", - "committed-read", - "draft-read", - "sparse", - "queue", - "sort", - "dense", - "unshift", -]; -const modes: readonly Mode[] = ["cow", "delta", "astra"]; -const quick = process.argv.includes("--quick"); -const size = quick ? 10_000 : 100_000; -const trials = quick ? 1 : 3; -const root = fileURLToPath(new URL("../../../..", import.meta.url)); -const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url)); -const results: Result[] = []; -const failures: Failure[] = []; - -for (let trial = 0; trial < trials; trial++) { - for (const scenario of scenarios) { - for (const mode of modes) { - const child = spawnSync( - process.execPath, - ["--expose-gc", "--max-old-space-size=4096", worker, mode, scenario, String(size), String(trial)], - { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 }, - ); - if (child.status !== 0) { - const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure; - failures.push(failure); - console.log(JSON.stringify(failure)); - } else { - const result = JSON.parse(child.stdout) as Result; - results.push(result); - console.log(JSON.stringify(result)); - } - } - } -} - -const average = (values: readonly number[]): number | null => - values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; -const summary = scenarios.flatMap((scenario) => - modes.map((mode) => { - const selected = results.filter((result) => result.mode === mode && result.scenario === scenario); - return { - mode, - scenario, - importMs: average(selected.map((result) => result.importMs)), - mutateMs: average(selected.map((result) => result.mutateMs)), - prepareMs: average(selected.map((result) => result.prepareMs)), - serializeMs: average(selected.map((result) => result.serializeMs)), - adoptMs: average(selected.map((result) => result.adoptMs)), - opBytes: average(selected.map((result) => result.opBytes)), - operationCount: average(selected.map((result) => result.operationCount)), - readyMiB: average(selected.map((result) => result.readyMiB)), - transientMiB: average(selected.map((result) => result.transientMiB)), - retainedMiB: average(selected.map((result) => result.retainedMiB)), - maxRssMiB: average(selected.map((result) => result.maxRssMiB)), - }; - }), -); -const output = join(tmpdir(), "chord-cow-benchmark.json"); -writeFileSync(output, JSON.stringify({ node: process.version, size, trials, results, failures, summary }, null, 2)); -console.log(JSON.stringify({ output, node: process.version, size, trials, failures: failures.length, summary })); diff --git a/packages/chord/test/delta-cow/benchmark.worker.ts b/packages/chord/test/delta-cow/benchmark.worker.ts deleted file mode 100644 index 6af1e16bf..000000000 --- a/packages/chord/test/delta-cow/benchmark.worker.ts +++ /dev/null @@ -1,145 +0,0 @@ -import assert from "node:assert/strict"; -import { performance } from "node:perf_hooks"; -import { setImmediate } from "node:timers/promises"; -import { track as trackAstra } from "../../src/delta/astra/index.ts"; -import { track as trackCow } from "../../src/delta/cow/index.ts"; -import type { Draft } from "../../src/delta/draft.ts"; -import type { Op } from "../../src/delta/index.ts"; -import { track as trackDelta } from "../../src/delta/tracker.ts"; - -type Mode = "cow" | "astra" | "delta"; -type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift"; -type Row = { id: number; value: number; payload: string }; -type Document = { rows: Row[] }; -type PreparedLike = { readonly value: Readonly; readonly ops: readonly Op[] }; -type ChangeLike = { readonly state: Draft; prepare(): PreparedLike }; -type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void }; - -const MiB = 1024 * 1024; -const mode = process.argv[2] as Mode; -const scenario = process.argv[3] as Scenario; -const size = Number(process.argv[4] ?? 100_000); -const trial = Number(process.argv[5] ?? 0); - -function fixture(count: number): Document { - return { - rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })), - }; -} - -function sum(document: Document | Draft): number { - let checksum = 0; - for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value; - return checksum; -} - -async function gc(): Promise { - assert.ok(global.gc, "benchmark worker requires --expose-gc"); - for (let index = 0; index < 3; index++) { - await setImmediate(); - global.gc(); - } -} - -await gc(); -const baselineHeap = process.memoryUsage().heapUsed; -let input: Document | undefined = fixture(size); -if (scenario === "sort") input.rows.reverse(); -const importStart = performance.now(); -const tracker = ( - mode === "cow" ? trackCow(input) : mode === "astra" ? trackAstra(input) : trackDelta(input) -) as TrackerLike; -const importMs = performance.now() - importStart; -input = undefined; -await gc(); -const readyHeap = process.memoryUsage().heapUsed; -let peakHeap = readyHeap; -let mutateMs = 0; -let prepareMs = 0; -let serializeMs = 0; -let adoptMs = 0; -let opBytes = 0; -let operationCount = 0; -let checksum = 0; -const queueOperations = Math.min(size, 1_000); - -if (scenario === "committed-read") { - const start = performance.now(); - checksum = sum(tracker.value); - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); -} else if (scenario !== "import") { - let change: ChangeLike | undefined = tracker.beginChange(); - const start = performance.now(); - switch (scenario) { - case "draft-read": - checksum = sum(change.state); - break; - case "sparse": - for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "queue": - for (let index = 0; index < queueOperations; index++) { - change.state.rows.shift(); - change.state.rows.push({ id: size + index, value: index, payload: "queue" }); - } - checksum = change.state.rows.length; - break; - case "sort": - change.state.rows.sort((left, right) => left.id - right.id); - checksum = change.state.rows[0]!.id; - break; - case "dense": - for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index; - checksum = change.state.rows.at(-1)!.value; - break; - case "unshift": { - const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" })); - Reflect.apply(change.state.rows.unshift, change.state.rows, inserted); - checksum = change.state.rows.length; - break; - } - } - mutateMs = performance.now() - start; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const prepareStart = performance.now(); - let prepared: PreparedLike | undefined = change.prepare(); - prepareMs = performance.now() - prepareStart; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const serializeStart = performance.now(); - const serialized = JSON.stringify(prepared.ops); - serializeMs = performance.now() - serializeStart; - opBytes = Buffer.byteLength(serialized); - operationCount = prepared.ops.length; - peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed); - const adoptStart = performance.now(); - tracker.adopt(prepared); - adoptMs = performance.now() - adoptStart; - change = undefined; - prepared = undefined; -} - -await gc(); -const retainedHeap = process.memoryUsage().heapUsed; -console.log( - JSON.stringify({ - mode, - scenario, - size, - trial, - queueOperations, - importMs, - mutateMs, - prepareMs, - serializeMs, - adoptMs, - opBytes, - operationCount, - checksum, - readyMiB: (readyHeap - baselineHeap) / MiB, - transientMiB: (peakHeap - readyHeap) / MiB, - retainedMiB: (retainedHeap - baselineHeap) / MiB, - maxRssMiB: process.resourceUsage().maxRSS / 1024, - }), -); diff --git a/packages/chord/test/delta-cow/core.test.ts b/packages/chord/test/delta-cow/core.test.ts deleted file mode 100644 index 7edb31242..000000000 --- a/packages/chord/test/delta-cow/core.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - apply, - applyImmutable, - assertValidOp, - assertValidWireOp, - decoder, - encoder, - isBase, - type JsonValue, - type Op, - overlap, - track, - UnsafePathError, - type WireOp, -} from "../../src/delta/cow/index.ts"; - -describe("immutable tracker lifecycle", () => { - it("keeps a draft alive across await and adopts only a prepared change", async () => { - const input = { count: 1, nested: { text: "a" }, values: [1] }; - const tracker = track(input); - expect(tracker.value).toBe(input); - - const change = tracker.beginChange(); - change.state.count = 2; - await Promise.resolve(); - change.state.nested.text += "b"; - change.state.values.push(2); - expect(tracker.value).toEqual(input); - - const prepared = change.prepare(); - expect(prepared.base).toBe(tracker.value); - expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); - expect(prepared.ops).toEqual([ - ["s", ["count"], 2], - ["a", ["nested", "text"], "b"], - ["p", ["values"], 1, 0, [2]], - ]); - expect(() => change.state.count).toThrow(TypeError); - expect(tracker.value).toEqual(input); - - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.value); - }); - - it("aborts and revokes without changing the committed value", () => { - const tracker = track({ child: { value: 1 } }); - const change = tracker.beginChange(); - const child = change.state.child; - child.value = 2; - change.abort(); - expect(tracker.value.child.value).toBe(1); - expect(() => child.value).toThrow(TypeError); - expect(() => change.abort()).not.toThrow(); - expect(() => change.prepare()).toThrow(/settled/); - const next = tracker.beginChange(); - next.abort(); - }); - - it("grows arrays with explicit nulls and revokes after preparation", () => { - const tracker = track({ values: [1, 2] as Array }); - const change = tracker.beginChange(); - change.state.values.length = 4; - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([1, 2, null, null]); - expect(() => change.state.values).toThrow(TypeError); - tracker.adopt(prepared); - }); - - it("normalizes a deep no-op to exact previous identity", () => { - const tracker = track({ value: { nested: [1, 2] } }); - const change = tracker.beginChange(); - change.state.value = { nested: [1, 2] }; - const prepared = change.prepare(); - expect(prepared.value).toBe(prepared.base); - expect(prepared.ops).toEqual([]); - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.base); - }); - - it("takes O(1) replacement ownership and applies no-op normalization", () => { - const tracker = track({ nested: { value: 1 } }); - const replacement = { nested: { value: 2 } }; - const prepared = tracker.prepareReplace(replacement); - expect(prepared.value).toBe(replacement); - tracker.adopt(prepared); - - const noOp = tracker.prepareReplace({ nested: { value: 2 } }); - expect(noOp.value).toBe(tracker.value); - expect(noOp.ops).toEqual([]); - }); - - it("detaches operation payloads from candidate placements", () => { - const tracker = track({ rows: [] as { id: number }[] }); - const change = tracker.beginChange(); - change.state.rows.push({ id: 1 }); - const prepared = change.prepare(); - const splice = prepared.ops[0]!; - if (splice[0] !== "p") throw new Error("expected splice"); - expect(splice[4][0]).not.toBe(prepared.value.rows[0]); - tracker.adopt(prepared); - tracker.value.rows[0]!.id = 2; - expect(splice[4]).toEqual([{ id: 1 }]); - }); - - it("rejects foreign, stale, repeated, and overlapping changes", () => { - const first = track({ value: 0 }); - const second = track({ value: 0 }); - const change = first.beginChange(); - expect(() => first.beginChange()).toThrow(/active/); - change.state.value = 1; - const prepared = change.prepare(); - expect(() => second.adopt(prepared)).toThrow(/different tracker/); - first.adopt(prepared); - expect(() => first.adopt(prepared)).toThrow(/already been used/); - - const stale = first.prepareReplace({ value: 2 }); - const winner = first.prepareReplace({ value: 3 }); - first.adopt(winner); - expect(() => first.adopt(stale)).toThrow(/stale/); - expect(() => first.adopt(stale)).toThrow(/stale/); - }); - - it("invalidates a prepared result when its change is aborted", () => { - const tracker = track({ value: 0 }); - const change = tracker.beginChange(); - change.state.value = 1; - const prepared = change.prepare(); - change.abort(); - expect(() => tracker.adopt(prepared)).toThrow(/aborted/); - expect(() => change.abort()).not.toThrow(); - }); - - it("makes competing same-base preparations stale after adopting a no-op", () => { - const tracker = track({ value: { count: 1 } }); - const first = tracker.prepareReplace({ value: { count: 1 } }); - const competing = tracker.prepareReplace({ value: { count: 1 } }); - expect(first.value).toBe(first.base); - expect(competing.value).toBe(competing.base); - tracker.adopt(first); - expect(() => tracker.adopt(first)).toThrow(/already been used/); - expect(() => tracker.adopt(competing)).toThrow(/stale/); - }); - - it("keeps large prepareReplace array edits narrow", () => { - const rows = Array.from({ length: 10_000 }, (_, value) => ({ value, stable: { value } })); - const tracker = track({ rows }); - const replacement = tracker.value.rows.slice(); - replacement[5_000] = { value: -1, stable: replacement[5_000]!.stable }; - const prepared = tracker.prepareReplace({ rows: replacement }); - expect(prepared.ops).toEqual([["s", ["rows", 5_000, "value"], -1]]); - expect(JSON.stringify(prepared.ops).length).toBeLessThan(100); - expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); - }); -}); - -describe("canonical strings", () => { - it("emits append and rolling-window operations", () => { - const tracker = track({ text: "abcdefgh" }); - let change = tracker.beginChange(); - change.state.text += "ij"; - let prepared = change.prepare(); - expect(prepared.ops).toEqual([["a", ["text"], "ij"]]); - tracker.adopt(prepared); - - change = tracker.beginChange(); - change.state.text = `${change.state.text.slice(3)}xyz`; - prepared = change.prepare(); - expect(prepared.ops).toEqual([ - ["t", ["text"], 3], - ["a", ["text"], "xyz"], - ]); - }); - - it("finds bounded overlaps", () => { - expect(overlap("abcdefgh", "defghxyz", 65_536)).toBe(5); - expect(overlap("abcdef", "defghi", 0)).toBe(0); - }); -}); - -describe("operation application and validation", () => { - it("applies mutable and immutable operations", () => { - const operations: Op[] = [ - ["a", ["text"], "b"], - ["p", ["values"], 1, 1, [3, 4]], - ["s", ["nested", "value"], 2], - ]; - const base = { text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } }; - const immutable = applyImmutable(base, operations); - expect(immutable).toEqual({ text: "ab", values: [1, 3, 4], nested: { value: 2 }, stable: { value: 9 } }); - expect(base).toEqual({ text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } }); - expect(immutable.stable).toBe(base.stable); - expect(apply(structuredClone(base), operations)).toEqual(immutable); - }); - - it("supports root replacement, root splice, and permutations", () => { - const base: Op[] = [["r", [1, 2, 3]]]; - expect(isBase(base)).toBe(true); - let value = apply(undefined, base); - value = apply(value, [["p", [], 1, 1, [4]]]); - value = apply(value, [["m", [], [2, 0, 1]]]); - expect(value).toEqual([3, 1, 4]); - }); - - it("rejects unsafe and malformed paths", () => { - expect(() => apply({}, [["s", ["constructor", "prototype", "x"], true]])).toThrow(UnsafePathError); - expect(() => apply({ values: [1] }, [["s", ["values", 3], 2]])).toThrow(UnsafePathError); - expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow(); - expect(() => assertValidOp(["s", "value", 1] as never)).toThrow(); - expect(() => assertValidOp(["m", [], [0, 0]] as never)).toThrow(/bijection/); - }); - - it("validates immutable operations before traversing the target", () => { - let reads = 0; - const target = Object.defineProperty({}, "trap", { - enumerable: true, - get() { - reads += 1; - return {}; - }, - }); - expect(() => applyImmutable(target, [["s", "bad-path", 1] as never])).toThrow(/path/); - expect(reads).toBe(0); - }); - - it("validates decoded and wire vocabularies separately", () => { - expect(() => assertValidOp(["s", ["value"], 1])).not.toThrow(); - expect(() => assertValidOp(["s", 1] as never)).toThrow(); - expect(() => assertValidWireOp(["s", 1])).not.toThrow(); - expect(() => assertValidWireOp(["#", 0, ["value"]])).not.toThrow(); - }); -}); - -describe("codec", () => { - it("interns paths, omits adjacent paths, and round-trips", () => { - const path = ["nested", "text"] as const; - const enc = encoder(); - const dec = decoder(); - const first: Op[] = [ - ["t", path, 1], - ["a", path, "x"], - ]; - expect(dec.decode(enc.encode(first))).toEqual(first); - const second: Op[] = [["a", path, "y"]]; - const wire = enc.encode(second); - expect(wire).toEqual([ - ["#", 0, path], - ["a", 0, "y"], - ]); - expect(dec.decode(wire)).toEqual(second); - }); - - it("resets path dictionaries on a base", () => { - const enc = encoder(); - const path = ["value"] as const; - enc.encode([["s", path, 1]]); - enc.encode([["s", path, 2]]); - expect(enc.encode([["r", { value: 3 }]])).toEqual([["r", { value: 3 }]]); - expect(enc.encode([["s", path, 4]])).toEqual([["s", path, 4]]); - }); - - it("rejects unresolved short forms and unsafe interned paths", () => { - expect(() => decoder().decode([["a", "x"]])).toThrow(); - const wire = [ - ["#", 0, ["__proto__"]], - ["s", 0, true], - ] as unknown as WireOp[]; - expect(() => decoder().decode(wire)).toThrow(UnsafePathError); - }); - - it("omits an adjacent repeated path", () => { - const enc = encoder(); - const path = ["value"] as const; - expect( - enc.encode([ - ["s", path, 1], - ["s", path, 2], - ]), - ).toEqual([ - ["s", path, 1], - ["s", 2], - ]); - }); - - it("interns on second use rather than first", () => { - const enc = encoder(); - const path = ["a", "deep"] as const; - expect(enc.encode([["a", path, "1"]])).toEqual([["a", path, "1"]]); - expect(enc.encode([["a", path, "2"]])).toEqual([ - ["#", 0, path], - ["a", 0, "2"], - ]); - }); - - it("does not collide paths containing null characters", () => { - const operations: Op[] = [ - ["s", ["a\u0000b"], 1], - ["s", ["a", "b"], 2], - ]; - expect(decoder().decode(encoder().encode(operations))).toEqual(operations); - }); - - it("clears decoder ids on a base batch", () => { - const dec = decoder(); - dec.decode([ - ["#", 0, ["a"]], - ["a", 0, "1"], - ]); - dec.decode([["r", { a: "" }]]); - expect(() => dec.decode([["a", 0, "2"]])).toThrow(); - }); - - it("makes batches after a base self-contained", () => { - const enc = encoder(); - const path = ["a", "deep"] as const; - enc.encode([["a", path, "1"]]); - enc.encode([["a", path, "2"]]); - const base = enc.encode([["r", { a: { deep: "x" } }]]); - const after = enc.encode([["a", path, "3"]]); - expect(after).toEqual([["a", path, "3"]]); - const dec = decoder(); - expect(dec.decode(base)).toEqual([["r", { a: { deep: "x" } }]]); - expect(dec.decode(after)).toEqual([["a", path, "3"]]); - }); - - it("round-trips deterministic mixed operation streams", () => { - const batches: Op[][] = Array.from({ length: 100 }, (_, index) => [ - ["s", ["rows", index, "value"], index], - ["a", ["output"], String(index)], - ["p", ["tail"], index, 0, [index]], - ]); - const enc = encoder(); - const dec = decoder(); - expect(batches.map((batch) => dec.decode(enc.encode(batch)))).toEqual(batches); - }); -}); - -describe("immutable application ownership", () => { - it("does not mutate a replacement payload targeted by a later operation", () => { - const replacement = { nested: { value: 1 } }; - const next = applyImmutable<{ nested: { value: number } }>(undefined, [ - ["r", replacement], - ["s", ["nested", "value"], 2], - ]); - expect(replacement.nested.value).toBe(1); - expect(next.nested.value).toBe(2); - }); - - it("adopts a mutable replacement payload rather than copying it", () => { - const batch: Op[] = [["r", { value: 0 }]]; - const first = apply<{ value: number }>(undefined, batch); - const second = apply<{ value: number }>(undefined, batch); - first.value = 1; - expect(second.value).toBe(1); - }); -}); - -describe("path and prototype safety", () => { - it("rejects constructor walks and forbidden interned paths", () => { - expect(() => apply({}, JSON.parse('[["s",["constructor","prototype","gadget"],true]]'))).toThrow(); - expect(({} as Record).gadget).toBeUndefined(); - const wire = [ - ["#", 0, ["__proto__", "w"]], - ["s", 0, true], - ] as unknown as WireOp[]; - expect(() => decoder().decode(wire)).toThrow(); - }); - - it("does not run inherited setters", () => { - Object.defineProperty(Object.prototype, "trap", { - set() { - throw new Error("inherited setter ran"); - }, - configurable: true, - }); - try { - expect(() => apply({}, [["s", ["trap"], 1]])).not.toThrow(); - expect(apply({}, [["s", ["trap"], 1]])).toEqual({ trap: 1 }); - } finally { - delete (Object.prototype as Record).trap; - } - }); - - it("allows reserved names inside values without prototype pollution", () => { - const value = JSON.parse('{"__proto__":{"z":1}}') as JsonValue; - const out = apply({}, [["s", ["value"], value]]); - expect(Object.hasOwn((out as { value: object }).value, "__proto__")).toBe(true); - expect(({} as Record).z).toBeUndefined(); - }); - - it("imports own properties without invoking inherited setters", () => { - const root = {} as Record; - Object.defineProperty(root, "trap", { value: 1, writable: true, enumerable: true, configurable: true }); - Object.defineProperty(Object.prototype, "trap", { - set() { - throw new Error("inherited setter ran"); - }, - configurable: true, - }); - try { - expect(() => track(root)).not.toThrow(); - } finally { - delete (Object.prototype as Record).trap; - } - - let value: { values: number[] } | undefined; - Object.defineProperty(Array.prototype, "0", { - set() { - throw new Error("inherited array setter ran"); - }, - configurable: true, - }); - try { - value = track({ values: [1, 2] }).value; - } finally { - delete (Array.prototype as unknown as Record)[0]; - } - expect(value).toEqual({ values: [1, 2] }); - }); -}); - -describe("array index safety", () => { - it("writes an existing index and appends exactly one past the end", () => { - expect(apply({ values: [1, 2, 3] }, [["s", ["values", 1], 9]])).toEqual({ values: [1, 9, 3] }); - expect(apply({ values: [1, 2, 3] }, [["s", ["values", 3], 9]])).toEqual({ values: [1, 2, 3, 9] }); - }); - - it("rejects gaps, huge indices, and string-spelled indices", () => { - expect(() => apply({ values: [1, 2, 3] }, [["s", ["values", 5], 9]])).toThrow(); - expect(() => apply({ values: [] }, [["s", ["values", 4_294_967_290], 1]])).toThrow(); - expect(() => apply({ values: [1] }, [["s", ["values", "0"], 9]])).toThrow(); - expect(() => apply({ values: ["a"] }, [["a", ["values", "0"], "b"]])).toThrow(); - }); - - it("allows explicit growth values and rejects deletion past the end", () => { - expect(apply({ values: [1] }, [["p", ["values"], 1, 0, [null, null, 9]]])).toEqual({ - values: [1, null, null, 9], - }); - expect(() => apply({ values: [1] }, [["d", ["values", 1]]])).toThrow(); - }); - - it("applies large splice payloads without spreading them at once", () => { - const items = Array(300_000).fill(null); - const result = apply({ values: [] as JsonValue[] }, [["p", ["values"], 0, 0, items]]); - expect(result.values).toHaveLength(items.length); - }); -}); - -describe("operation structure safety", () => { - it("rejects unknown verbs, malformed tuples, paths, and splice payloads", () => { - expect(() => apply({ value: 1 }, [["ZZZ", ["value"], 9] as never])).toThrow(); - expect(() => apply({ values: [1] }, [["p", ["values"], 0, 0, "not-an-array"] as never])).toThrow(); - expect(() => apply({ value: 1 }, [["s", "value", 9] as never])).toThrow(); - expect(() => apply({ value: 1 }, [{ op: "s" } as never])).toThrow(); - expect(() => apply({ value: 1 }, [null as never])).toThrow(); - }); - - it("rejects invalid append and truncation operations", () => { - expect(() => apply({ value: 1 }, [["a", ["missing"], "x"]])).toThrow(); - expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow(); - expect(() => apply({ value: "abc" }, [["t", ["value"], -1]])).toThrow(); - expect(() => decoder().decode([["t", ["value"], -1]])).toThrow(); - }); - - it("clamps splice removal past the end", () => { - expect(apply({ values: [1, 2] }, [["p", ["values"], 0, 1e9, []]])).toEqual({ values: [] }); - }); -}); - -describe("operation assertions", () => { - it("accepts decoded operations and rejects wire-only forms", () => { - for (const operation of [ - ["r", { value: 1 }], - ["s", ["value"], 1], - ["d", ["value"]], - ["a", ["value"], "x"], - ["t", ["value"], 2], - ["p", ["value"], 0, 0, []], - ["m", ["value"], [0]], - ] as never[]) { - expect(() => assertValidOp(operation)).not.toThrow(); - } - for (const wireOnly of [ - ["s", 1], - ["d"], - ["a", "x"], - ["t", 2], - ["p", 0, 0, []], - ["#", 0, ["value"]], - ["s", 0, 1], - ] as never[]) { - expect(() => assertValidOp(wireOnly)).toThrow(); - expect(() => assertValidWireOp(wireOnly)).not.toThrow(); - } - }); - - it("does not recursively inspect operation payloads", () => { - expect(() => assertValidOp(["s", ["value"], new Map()] as never)).not.toThrow(); - expect(() => assertValidWireOp(["r", new Date()] as never)).not.toThrow(); - }); -}); diff --git a/packages/chord/test/delta-cow/cow.test.ts b/packages/chord/test/delta-cow/cow.test.ts deleted file mode 100644 index 13638ee24..000000000 --- a/packages/chord/test/delta-cow/cow.test.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { apply, applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts"; - -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -describe("optimized COW transaction invariants", () => { - it("matches candidate, detached replay, and adopted value", () => { - const tracker = track({ rows: [{ value: 1 }], text: "a" }); - const base = clone(tracker.value); - const change = tracker.beginChange(); - change.state.rows[0]!.value = 2; - change.state.rows.push({ value: 3 }); - change.state.text += "b"; - const prepared = change.prepare(); - const candidate = clone(prepared.value); - const operations = clone(prepared.ops); - expect(apply(base, operations)).toEqual(candidate); - tracker.adopt(prepared); - expect(tracker.value).toEqual(candidate); - }); - - it("clones external and draft placements independently", () => { - const tracker = track({ - source: { value: 1 }, - left: null as { value: number } | null, - right: null as { value: number } | null, - rows: [] as { value: number }[], - }); - const external = { value: 2 }; - const change = tracker.beginChange(); - change.state.left = external; - change.state.right = change.state.source; - change.state.rows.push(external, external, change.state.source); - external.value = 9; - change.state.source.value = 7; - const prepared = change.prepare(); - expect(prepared.value).toEqual({ - source: { value: 7 }, - left: { value: 2 }, - right: { value: 1 }, - rows: [{ value: 2 }, { value: 2 }, { value: 1 }], - }); - expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); - }); - - it("distinguishes raw committed and draft-subtree assignments", () => { - const tracker = track({ - source: { value: 1 }, - rawCopy: null as { value: number } | null, - draftCopy: null as { value: number } | null, - }); - const raw = tracker.value.source; - const change = tracker.beginChange(); - change.state.source.value = 5; - change.state.rawCopy = raw; - change.state.draftCopy = change.state.source; - expect(change.prepare().value).toEqual({ source: { value: 5 }, rawCopy: { value: 1 }, draftCopy: { value: 5 } }); - }); - - it("keeps Prepared.value authoritative and independent from public operations", () => { - const tracker = track({ rows: [] as { value: number }[] }); - const change = tracker.beginChange(); - change.state.rows.push({ value: 1 }); - const prepared = change.prepare(); - const splice = prepared.ops[0]; - if (splice?.[0] !== "p") throw new Error("expected splice"); - expect(splice[4][0]).not.toBe(prepared.value.rows[0]); - (splice[4][0] as { value: number }).value = 99; - expect(prepared.value.rows[0]!.value).toBe(1); - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.value); - expect(tracker.value.rows[0]!.value).toBe(1); - }); - - it("supports a MemoryStorage-style recursive clone", () => { - const storageClone = (value: T): T => { - if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(storageClone) as T; - const output: Record = {}; - for (const key of Object.keys(value)) output[key] = storageClone((value as Record)[key]); - return output as T; - }; - const tracker = track({ rows: [{ value: 1 }] }); - const change = tracker.beginChange(); - change.state.rows[0]!.value = 2; - change.state.rows.push({ value: 3 }); - expect(storageClone(change.prepare().value)).toEqual({ rows: [{ value: 2 }, { value: 3 }] }); - }); - - it("folds reserved-key changes without prototype pollution", () => { - const initial = JSON.parse('{"safe":{"__proto__":{"value":1}}}') as { - safe: { __proto__: { value: number } }; - }; - const tracker = track(initial); - const change = tracker.beginChange(); - change.state.safe.__proto__.value = 2; - const prepared = change.prepare(); - expect(applyImmutable(tracker.value, prepared.ops)).toEqual(prepared.value); - tracker.adopt(prepared); - expect(({} as Record).value).toBeUndefined(); - }); -}); - -describe("logical placement cloning", () => { - it("does not let a detached dirty object handle overwrite its replacement", () => { - const tracker = track({ child: { value: 1 }, stable: { value: 9 } }); - const change = tracker.beginChange(); - const held = change.state.child; - held.value = 2; - change.state.child = { value: 3 }; - held.value = 4; - const prepared = change.prepare(); - expect(prepared.value).toEqual({ child: { value: 3 }, stable: { value: 9 } }); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - expect(tracker.value.child.value).toBe(3); - }); - - it("does not let a detached dirty array handle overwrite its replacement", () => { - const tracker = track({ rows: [{ value: 1 }, { value: 9 }] }); - const change = tracker.beginChange(); - const held = change.state.rows[0]!; - held.value = 2; - change.state.rows[0] = { value: 3 }; - held.value = 4; - const prepared = change.prepare(); - expect(prepared.value).toEqual({ rows: [{ value: 3 }, { value: 9 }] }); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - expect(tracker.value.rows[0]!.value).toBe(3); - }); - - it("copyWithin clones pending dirty descendants with primitive indices", () => { - const tracker = track({ rows: [{ nested: { value: 1 } }, { nested: { value: 2 } }] }); - const change = tracker.beginChange(); - change.state.rows[0]!.nested.value = 9; - change.state.rows.copyWithin(1, 0, 1); - const prepared = change.prepare(); - expect(prepared.value).toEqual({ rows: [{ nested: { value: 9 } }, { nested: { value: 9 } }] }); - expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - }); - - it("keeps diff-aligned moved placements independent in the authoritative candidate", () => { - const tracker = track({ - rows: [ - { id: 0, score: 0, label: "r-0" }, - { id: 1, score: 1 }, - { id: 2, score: 2 }, - { id: 2, score: 2 }, - { id: 200106, score: 16, label: "r-200106" }, - { id: 4, score: 200103 }, - { id: 5, score: 200002 }, - { id: 6, score: 6, label: "r-6" }, - { id: 7, score: 7 }, - { id: 200001, score: 13, label: "r-200001" }, - ], - text: "start-200100-200107", - meta: { count: 0 }, - }); - const base = clone(tracker.value); - const change = tracker.beginChange(); - let held = change.state.rows[5]!; - change.state.rows.unshift({ id: 200200, score: 13 }); - held.score += 1_000; - change.state.meta.count -= 2; - held = change.state.rows[3]!; - change.state.rows.unshift({ id: 200202, score: 15, label: "r-200202" }); - held.score += 1_000; - change.state.text += "-200203"; - change.state.rows[0] = { id: 200204, score: 12 }; - change.state.rows[1] = change.state.rows[2]!; - change.state.text += "-200206"; - change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score); - const prepared = change.prepare(); - expect(prepared.value.rows[0]!.id).toBe(0); - expect(prepared.value.rows[1]!.id).toBe(0); - expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]); - expect(apply(base, clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - - const next = tracker.beginChange(); - const secondScore = next.state.rows[1]!.score; - next.state.rows[0]!.score = 99; - const nextPrepared = next.prepare(); - expect(nextPrepared.value.rows[0]!.score).toBe(99); - expect(nextPrepared.value.rows[1]!.score).toBe(secondScore); - expect(nextPrepared.value.rows[0]).not.toBe(nextPrepared.value.rows[1]); - nextPrepared.abort(); - }); - - it("fuzzes deeply-equal duplicate placements through movement and stable sort", () => { - for (let seed = 1; seed <= 60; seed++) { - const initial = { - rows: Array.from({ length: 20 }, (_, index) => ({ - id: Math.floor(index / 2), - score: Math.floor(index / 2), - label: `row-${Math.floor(index / 2)}`, - })), - }; - const tracker = track(initial); - const expected = clone(initial); - const change = tracker.beginChange(); - const heldIndex = seed % expected.rows.length; - const expectedHeld = expected.rows[heldIndex]!; - const draftHeld = change.state.rows[heldIndex]!; - expected.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` }); - change.state.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` }); - expectedHeld.score += 1_000; - draftHeld.score += 1_000; - expected.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` }); - change.state.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` }); - const source = (seed * 7) % expected.rows.length; - let target = (seed * 11 + 3) % expected.rows.length; - if (target === source) target = (target + 1) % expected.rows.length; - expected.rows[target] = clone(expected.rows[source]!); - change.state.rows[target] = change.state.rows[source]!; - expected.rows.sort((left, right) => left.id - right.id || left.score - right.score); - change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score); - const prepared = change.prepare(); - expect(prepared.value, `candidate seed ${seed}`).toEqual(expected); - expect(new Set(prepared.value.rows).size, `identity seed ${seed}`).toBe(prepared.value.rows.length); - expect(apply(clone(initial), clone(prepared.ops)), `replay seed ${seed}`).toEqual(expected); - prepared.abort(); - } - }); - - it("clones a dirty draft subtree from its source tracker context", () => { - const source = track({ subtree: { nested: { value: 1 }, rows: [{ value: 2 }] } }); - const destination = track({ copy: null as { nested: { value: number }; rows: { value: number }[] } | null }); - const sourceChange = source.beginChange(); - sourceChange.state.subtree.nested.value = 7; - sourceChange.state.subtree.rows[0]!.value = 8; - const destinationChange = destination.beginChange(); - destinationChange.state.copy = sourceChange.state.subtree; - const prepared = destinationChange.prepare(); - expect(prepared.value.copy).toEqual({ nested: { value: 7 }, rows: [{ value: 8 }] }); - expect(apply(clone(destination.value), clone(prepared.ops))).toEqual(prepared.value); - destination.adopt(prepared); - sourceChange.abort(); - }); -}); - -describe("native mutator parity", () => { - const compareNumbers = ( - nativeMutation: (values: number[]) => void, - draftMutation: (values: number[]) => void, - ): void => { - const native = [1, 2, 3]; - nativeMutation(native); - const tracker = track({ values: [1, 2, 3] }); - const change = tracker.beginChange(); - draftMutation(change.state.values); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual(native); - tracker.adopt(prepared); - }; - - it("matches captured-length splice coercion when coercion mutates the array", () => { - compareNumbers( - (values) => { - values.splice( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 1, - 9, - ); - }, - (values) => { - values.splice( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 1, - 9, - ); - }, - ); - }); - - it("keeps reentrant splice shrink results dense strict JSON", () => { - const tracker = track({ values: [1, 2, 3] as Array }); - const change = tracker.beginChange(); - change.state.values.splice( - { - valueOf() { - change.state.values.length = 1; - return 0; - }, - } as unknown as number, - 0, - 9, - ); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([9, 1, null, null]); - expect(Object.keys(prepared.value.values)).toEqual(["0", "1", "2", "3"]); - expect(prepared.value.values.every((value) => value !== undefined)).toBe(true); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - }); - - it("matches fill and copyWithin coercion side effects", () => { - compareNumbers( - (values) => { - values.fill(7, { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number); - }, - (values) => { - values.fill(7, { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number); - }, - ); - compareNumbers( - (values) => { - values.copyWithin( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 0, - 2, - ); - }, - (values) => { - values.copyWithin( - { - valueOf() { - values.push(4); - return 1; - }, - } as unknown as number, - 0, - 2, - ); - }, - ); - }); - - it("matches comparator direct-index and structural writes", () => { - type Row = { rank: number; touches: number }; - const mutate = (values: Row[]): void => { - let changed = false; - values.sort((left, right) => { - left.touches += 1; - right.touches += 1; - if (!changed) { - changed = true; - values[0] = { rank: 9, touches: 0 }; - values.push({ rank: 3, touches: 0 }); - } - return left.rank - right.rank; - }); - }; - const native: Row[] = [ - { rank: 2, touches: 0 }, - { rank: 1, touches: 0 }, - ]; - mutate(native); - const tracker = track({ - values: [ - { rank: 2, touches: 0 }, - { rank: 1, touches: 0 }, - ], - }); - const change = tracker.beginChange(); - mutate(change.state.values); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual(native); - expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value); - tracker.adopt(prepared); - }); - - it("rejects bigint comparator results like native sort", () => { - expect(() => [2, 1].sort(() => 1n as unknown as number)).toThrow(TypeError); - const tracker = track({ values: [2, 1] }); - const change = tracker.beginChange(); - expect(() => change.state.values.sort(() => 1n as unknown as number)).toThrow(TypeError); - change.abort(); - expect(tracker.value.values).toEqual([2, 1]); - }); - - it("forwards borrowed mutators to ordinary generic array receivers", () => { - const tracker = track({ values: [1, 2, 3] }); - const change = tracker.beginChange(); - const receiver = [3, 1, 2]; - expect(Reflect.apply(change.state.values.push, receiver, [4])).toBe(4); - expect(Reflect.apply(change.state.values.splice, receiver, [1, 1, 5])).toEqual([1]); - expect(Reflect.apply(change.state.values.sort, receiver, [(left: number, right: number) => left - right])).toBe( - receiver, - ); - expect(Reflect.apply(change.state.values.fill, receiver, [7, 1, 2])).toBe(receiver); - expect(Reflect.apply(change.state.values.copyWithin, receiver, [2, 0, 1])).toBe(receiver); - expect(receiver).toEqual([2, 7, 2, 5]); - expect(change.state.values).toEqual([1, 2, 3]); - change.abort(); - }); -}); - -describe("batched immutable application", () => { - it("copies shared ancestors once while preserving untouched subtrees", () => { - const base = { - rows: [ - { a: 1, b: 2 }, - { a: 3, b: 4 }, - ], - stable: { value: 9 }, - }; - const operations: Op[] = [ - ["s", ["rows", 0, "a"], 10], - ["s", ["rows", 0, "b"], 20], - ["s", ["rows", 1, "a"], 30], - ]; - const result = applyImmutable(base, operations); - expect(result).toEqual({ - rows: [ - { a: 10, b: 20 }, - { a: 30, b: 4 }, - ], - stable: { value: 9 }, - }); - expect(base).toEqual({ - rows: [ - { a: 1, b: 2 }, - { a: 3, b: 4 }, - ], - stable: { value: 9 }, - }); - expect(result.stable).toBe(base.stable); - }); - - it("does not mutate replacement or insertion payloads targeted later in the batch", () => { - const replacement = { rows: [{ value: 1 }] }; - const result = applyImmutable(undefined, [ - ["r", replacement], - ["s", ["rows", 0, "value"], 2], - ]); - expect(result.rows[0]!.value).toBe(2); - expect(replacement.rows[0]!.value).toBe(1); - }); -}); - -type Item = { id: number; score: number }; -type Document = { rows: Item[]; text: string; meta: { revision: number; label?: string } }; -type MutableDocument = Document | Draft; - -function mutate(document: MutableDocument, choice: number, value: number): void { - const item = (): Item => ({ id: value, score: value % 7 }); - switch (choice) { - case 0: - document.text += `-${value}`; - break; - case 1: - document.rows.push(item()); - break; - case 2: - document.rows.unshift(item()); - break; - case 3: - if (document.rows.length > 0) document.rows.shift(); - break; - case 4: - if (document.rows.length > 0) document.rows.pop(); - break; - case 5: { - const index = document.rows.length === 0 ? 0 : value % (document.rows.length + 1); - document.rows.splice(index, document.rows.length === 0 ? 0 : value % 2, item()); - break; - } - case 6: - document.rows.reverse(); - break; - case 7: - document.rows.sort((left, right) => left.id - right.id); - break; - case 8: - if (document.rows.length > 0) document.rows[value % document.rows.length]!.score = value; - break; - case 9: - document.meta.revision += 1; - document.meta.label = `r-${value}`; - break; - default: - delete document.meta.label; - } -} - -it("converges through randomized multi-operation transactions", () => { - for (let seed = 1; seed <= 30; seed++) { - let randomState = seed; - const random = (): number => { - randomState = (randomState + 0x6d2b79f5) | 0; - let value = Math.imul(randomState ^ (randomState >>> 15), 1 | randomState); - value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; - return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; - }; - const initial: Document = { - rows: Array.from({ length: 4 }, (_, id) => ({ id, score: 0 })), - text: "start", - meta: { revision: 0 }, - }; - const tracker = track(initial); - const expected = clone(initial); - for (let transaction = 0; transaction < 20; transaction++) { - const base = clone(tracker.value); - const change = tracker.beginChange(); - for (let operation = 0; operation < 5; operation++) { - const choice = Math.floor(random() * 11); - const value = seed * 10_000 + transaction * 10 + operation; - mutate(expected, choice, value); - mutate(change.state, choice, value); - } - const prepared = change.prepare(); - const candidate = clone(prepared.value); - expect(candidate, `candidate seed ${seed} transaction ${transaction}`).toEqual(expected); - expect(apply(base, clone(prepared.ops)), `replay seed ${seed} transaction ${transaction}`).toEqual(expected); - tracker.adopt(prepared); - expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(expected); - } - } -}, 30_000); diff --git a/packages/chord/test/delta-cow/draft.test.ts b/packages/chord/test/delta-cow/draft.test.ts deleted file mode 100644 index bbe1584b5..000000000 --- a/packages/chord/test/delta-cow/draft.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyImmutable, type Draft, track } from "../../src/delta/cow/index.ts"; - -describe("copy-on-write change drafts", () => { - it("copies only changed branches", () => { - const tracker = track({ - changed: { count: 1, sibling: { value: "kept" } }, - untouched: { value: 2 }, - }); - const base = tracker.value; - const change = tracker.beginChange(); - const first = change.state.changed; - expect(change.state.changed).toBe(first); - change.state.changed.count = 3; - const prepared = change.prepare(); - expect(prepared.value).not.toBe(base); - expect(prepared.value.changed).not.toBe(base.changed); - expect(prepared.value.changed.sibling).toBe(base.changed.sibling); - expect(prepared.value.untouched).toBe(base.untouched); - }); - - it("supports frozen bases, deletion, and native array mutators", () => { - const tracker = track({ optional: "remove" as string | undefined, values: [3, 1, 2] }); - const change = tracker.beginChange(); - delete change.state.optional; - change.state.values.push(4); - expect(change.state.values.pop()).toBe(4); - change.state.values.unshift(0); - expect(change.state.values.shift()).toBe(0); - expect(change.state.values.splice(1, 1, 5, 4)).toEqual([1]); - change.state.values.sort((left, right) => left - right); - change.state.values.reverse(); - change.state.values.fill(9, 1, 3); - change.state.values.copyWithin(1, 0, 2); - const prepared = change.prepare(); - expect(prepared.value).toEqual({ values: [5, 5, 9, 2] }); - }); - - it("copies inserted draft values without aliasing their original handle", () => { - const tracker = track({ values: [{ value: 1 }, { value: 2 }] }); - const change = tracker.beginChange(); - const held = change.state.values[0]!; - change.state.values.unshift(held); - held.value = 9; - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]); - expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]); - }); - - it("keeps comparator edits when sorting object drafts", () => { - const tracker = track({ - rows: [ - { rank: 2, comparisons: 0 }, - { rank: 1, comparisons: 0 }, - ], - }); - const change = tracker.beginChange(); - change.state.rows.sort((left, right) => { - left.comparisons++; - right.comparisons++; - return left.rank - right.rank; - }); - const prepared = change.prepare(); - expect(prepared.value.rows.map((row) => row.rank)).toEqual([1, 2]); - expect(prepared.value.rows.map((row) => row.comparisons)).toEqual([1, 1]); - }); - - it("drops writes through detached child handles", () => { - const tracker = track({ child: { value: "removed" }, items: [{ value: "removed" }, { value: "kept" }] }); - const change = tracker.beginChange(); - const child = change.state.child; - const shifted = change.state.items.shift(); - delete (change.state as { child?: Draft<{ value: string }>; items: Draft<{ value: string }[]> }).child; - child.value = "detached child"; - if (shifted !== undefined) shifted.value = "detached item"; - expect(change.prepare().value).toEqual({ items: [{ value: "kept" }] }); - }); - - it.each(["unshift", "splice"] as const)("inserts 100,000 items with %s without argument overflow", (method) => { - const tracker = track({ values: [-1] }); - const change = tracker.beginChange(); - const items = Array.from({ length: 100_000 }, (_, value) => value); - if (method === "unshift") Reflect.apply(change.state.values.unshift, change.state.values, items); - else Reflect.apply(change.state.values.splice, change.state.values, [1, 0, ...items]); - const prepared = change.prepare(); - expect(prepared.value.values).toHaveLength(100_001); - expect(prepared.value.values[method === "unshift" ? 0 : 1]).toBe(0); - expect(prepared.value.values[method === "unshift" ? 99_999 : 100_000]).toBe(99_999); - expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); - }); - - it("rejects array holes without mutating the committed base", () => { - const tracker = track({ values: [1, 2] }); - const change = tracker.beginChange(); - expect(() => delete change.state.values[0]).toThrow(/holes/); - change.abort(); - expect(tracker.value.values).toEqual([1, 2]); - }); -}); diff --git a/packages/chord/test/delta-cow/fuzz.test.ts b/packages/chord/test/delta-cow/fuzz.test.ts deleted file mode 100644 index 0020e8976..000000000 --- a/packages/chord/test/delta-cow/fuzz.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, it } from "vitest"; -import { applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts"; - -type Item = { id: number; text: string; score: number }; -type Document = { - items: Item[]; - text: string; - meta: { revision: number; label?: string }; -}; - -type MutableDocument = Document | Draft; - -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -const random = - (seed: number): (() => number) => - () => { - seed = (seed + 0x6d2b79f5) | 0; - let value = Math.imul(seed ^ (seed >>> 15), 1 | seed); - value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; - return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; - }; - -const mutate = (document: MutableDocument, choice: number, value: number): void => { - const item = (): Item => ({ id: value, text: `item-${value}`, score: value % 7 }); - switch (choice) { - case 0: - document.text += `-${value}`; - break; - case 1: - document.text = `${document.text.slice(Math.min(2, document.text.length))}${value}`; - break; - case 2: - document.items.push(item()); - break; - case 3: - document.items.unshift(item()); - break; - case 4: - if (document.items.length > 0) document.items.shift(); - break; - case 5: - if (document.items.length > 0) document.items.pop(); - break; - case 6: { - const index = document.items.length === 0 ? 0 : value % (document.items.length + 1); - document.items.splice(index, document.items.length === 0 ? 0 : value % 2, item()); - break; - } - case 7: - document.items.reverse(); - break; - case 8: - document.items.sort((left, right) => left.id - right.id); - break; - case 9: - if (document.items.length > 0) document.items[value % document.items.length]!.score = value; - break; - case 10: - document.meta.revision += 1; - document.meta.label = `revision-${value}`; - break; - case 11: - delete document.meta.label; - break; - case 12: - if (document.items.length > 1) document.items[1] = clone(document.items[0]!); - break; - default: - for (let index = 0; index < Math.min(2, document.items.length); index++) document.items[index] = item(); - } -}; - -it("converges across randomized prepared revisions", () => { - for (let seed = 1; seed <= 100; seed++) { - const rng = random(seed); - const initial: Document = { - items: Array.from({ length: 4 }, (_, id) => ({ id, text: `item-${id}`, score: 0 })), - text: "start", - meta: { revision: 0 }, - }; - const tracker = track(initial); - const expected = clone(initial); - let replica = clone(tracker.value); - for (let step = 0; step < 100; step++) { - const choice = Math.floor(rng() * 14); - const value = seed * 1_000 + step; - mutate(expected, choice, value); - const change = tracker.beginChange(); - mutate(change.state, choice, value); - const prepared = change.prepare(); - const operations: readonly Op[] = prepared.ops; - replica = applyImmutable(replica, operations); - tracker.adopt(prepared); - expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected); - expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected); - } - } -}); diff --git a/packages/chord/test/delta-cow/value.test.ts b/packages/chord/test/delta-cow/value.test.ts deleted file mode 100644 index a896f5476..000000000 --- a/packages/chord/test/delta-cow/value.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { track } from "../../src/delta/cow/index.ts"; - -describe("trusted immutable revision ownership", () => { - it("takes O(1) ownership of an alias-free strict-JSON root", () => { - const input = { left: { value: 1 }, right: { value: 2 } }; - const tracker = track(input); - expect(tracker.value).toBe(input); - expect(tracker.value.left).toBe(input.left); - }); - - it("copies changed branches while sharing untouched branches", () => { - const tracker = track({ changed: { value: 1 }, retained: { value: 2 } }); - const base = tracker.value; - const change = tracker.beginChange(); - change.state.changed.value = 3; - const prepared = change.prepare(); - expect(prepared.value).not.toBe(base); - expect(prepared.value.changed).not.toBe(base.changed); - expect(prepared.value.retained).toBe(base.retained); - tracker.adopt(prepared); - expect(tracker.value).toBe(prepared.value); - }); - - it("makes repeated placements independent", () => { - const tracker = track({ values: [] as { value: number }[] }); - const change = tracker.beginChange(); - const shared = { value: 1 }; - change.state.values.push(shared, shared); - const prepared = change.prepare(); - expect(prepared.value.values).toEqual([{ value: 1 }, { value: 1 }]); - expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]); - }); -}); diff --git a/packages/chord/test/delta-cow/diff.test.ts b/packages/chord/test/delta-diff.test.ts similarity index 97% rename from packages/chord/test/delta-cow/diff.test.ts rename to packages/chord/test/delta-diff.test.ts index 75d3005e6..495d525a2 100644 --- a/packages/chord/test/delta-cow/diff.test.ts +++ b/packages/chord/test/delta-diff.test.ts @@ -7,8 +7,8 @@ import { diffRevisions, encoder, type Op, -} from "../../src/delta/cow/index.ts"; -import type { JsonValue } from "../../src/types.ts"; +} from "../src/delta/index.ts"; +import type { JsonValue } from "../src/types.ts"; const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => { const operations = diffRevisions(before, after); @@ -257,11 +257,11 @@ describe("immutable revision diff", () => { expect(diffRevisions(before, after)).toEqual([["r", after]]); }); - it("uses normalized array fallback without payload-cost policy", () => { + it("bounds a wide normalized array fallback with a root replacement", () => { const before = { values: Array.from({ length: 40_000 }, () => 0) }; const after = { values: Array.from({ length: 40_000 }, () => 1) }; const operations = diffRevisions(before, after); - expect(operations).toEqual([["p", ["values"], 0, 40_000, after.values]]); + expect(operations).toEqual([["r", after]]); expect(applyImmutable(before, operations)).toEqual(after); }); }); diff --git a/packages/chord/test/delta-retention.test.ts b/packages/chord/test/delta-retention.test.ts deleted file mode 100644 index 695cf36ac..000000000 --- a/packages/chord/test/delta-retention.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -describe("delta change retention across GC jobs", () => { - it.each(["aborted-payload", "unadopted-prepared", "draft-proxies", "settled-lifecycle"])( - "releases %s", - (scenario) => { - const child = spawnSync( - process.execPath, - ["--expose-gc", fileURLToPath(new URL("./delta-retention.worker.ts", import.meta.url)), scenario], - { encoding: "utf8", timeout: 60_000 }, - ); - expect(child.error, child.stderr).toBeUndefined(); - expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); - }, - 65_000, - ); -}); diff --git a/packages/chord/test/delta-retention.worker.ts b/packages/chord/test/delta-retention.worker.ts deleted file mode 100644 index 084e9a254..000000000 --- a/packages/chord/test/delta-retention.worker.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from "node:assert/strict"; -import { setImmediate } from "node:timers/promises"; -import { type Tracker, track } from "../src/delta/index.ts"; - -const retainedSettledObjects: object[] = []; - -async function collect(ref: WeakRef): Promise { - assert.ok(global.gc, "worker requires --expose-gc"); - for (let attempt = 0; attempt < 30; attempt++) { - await setImmediate(); - global.gc(); - if (ref.deref() === undefined) return; - } - assert.fail("released change data is still retained"); -} - -function abortedPayload(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const reference = new WeakRef(change.state.payload.rows); - change.abort(); - return reference; -} - -function unadoptedPrepared(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { - const change = tracker.beginChange(); - change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }; - const prepared = change.prepare(); - return new WeakRef(prepared.value.payload!.rows); -} - -function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef { - const change = tracker.beginChange(); - change.state.payload = { rows: [{ value: 1 }] }; - const reference = new WeakRef(change.state.payload.rows[0]!); - change.abort(); - return reference; -} - -function settledLifecycle(): { tracker: WeakRef; future: WeakRef } { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - const change = tracker.beginChange(); - change.state.payload = { rows: [{ value: 1 }] }; - const retainedPrepared = change.prepare(); - tracker.adopt(retainedPrepared); - retainedSettledObjects.push(change, retainedPrepared); - - const futurePrepared = tracker.prepareReplace({ - payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }, - }); - tracker.adopt(futurePrepared); - return { - tracker: new WeakRef(tracker), - future: new WeakRef(futurePrepared.value.payload!.rows), - }; -} - -const scenario = process.argv[2]; -if (scenario === "settled-lifecycle") { - const references = settledLifecycle(); - await collect(references.tracker); - await collect(references.future); -} else { - const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null }); - let reference: WeakRef; - switch (scenario) { - case "aborted-payload": - reference = abortedPayload(tracker); - break; - case "unadopted-prepared": - reference = unadoptedPrepared(tracker); - break; - case "draft-proxies": - reference = draftProxies(tracker); - break; - default: - throw new Error(`unknown retention scenario: ${scenario}`); - } - await collect(reference); - assert.deepEqual(tracker.value, { payload: null }); -} -console.log(JSON.stringify({ scenario, passed: true })); diff --git a/packages/chord/test/delta-cow/retention.test.ts b/packages/chord/test/delta-tracker/retention.test.ts similarity index 75% rename from packages/chord/test/delta-cow/retention.test.ts rename to packages/chord/test/delta-tracker/retention.test.ts index 9ae8fd9de..29543857d 100644 --- a/packages/chord/test/delta-cow/retention.test.ts +++ b/packages/chord/test/delta-tracker/retention.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -describe("delta change retention across GC jobs", () => { +describe("delta tracker retention across GC jobs", () => { it.each([ "aborted-payload", "unadopted-prepared", @@ -11,6 +11,12 @@ describe("delta change retention across GC jobs", () => { "obsolete-revisions", "retained-settled-prepared", "retained-settled-change", + "retained-settled-proxy", + "retained-large-settled-proxy", + "retained-large-placement-proxies", + "stale-unprepared-change", + "same-job-fast-cleanup", + "same-job-folded-ops-cleanup", "lifecycle-churn", ])( "validates %s retention semantics", diff --git a/packages/chord/test/delta-cow/retention.worker.ts b/packages/chord/test/delta-tracker/retention.worker.ts similarity index 50% rename from packages/chord/test/delta-cow/retention.worker.ts rename to packages/chord/test/delta-tracker/retention.worker.ts index 772377000..a0a80d8d4 100644 --- a/packages/chord/test/delta-cow/retention.worker.ts +++ b/packages/chord/test/delta-tracker/retention.worker.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { setImmediate } from "node:timers/promises"; -import { type Tracker, track } from "../../src/delta/cow/index.ts"; +import { type Tracker, track } from "../../src/delta/index.ts"; const retainedSettledObjects: object[] = []; const retainedLargePrepared: object[] = []; @@ -119,6 +119,147 @@ if (scenario === "settled-lifecycle") { await collect(references.base); await collect(references.value); assert.equal(retainedSettledChanges.length, 1); +} else if (scenario === "retained-settled-proxy") { + const tracker = track({ + child: { value: 0 }, + payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) }, + }); + const obsolete = new WeakRef(tracker.value.payload.rows); + const commit = (): object => { + const change = tracker.beginChange(); + const held = change.state.child; + held.value = 1; + const prepared = change.prepare(); + tracker.adopt(prepared); + return held; + }; + retainedSettledChanges.push(commit()); + const replace = (): void => { + const replacement = tracker.prepareReplace({ child: { value: 2 }, payload: { rows: [{ value: 1 }] } }); + tracker.adopt(replacement); + }; + replace(); + await collect(obsolete); + assert.throws(() => (retainedSettledChanges[0] as { value: number }).value, /settled/); +} else if (scenario === "retained-large-settled-proxy") { + const tracker = track({ rows: Array.from({ length: 100_000 }, (_, value) => ({ value })) }); + const obsolete = new WeakRef(tracker.value.rows); + const commit = (): object => { + const change = tracker.beginChange(); + const held = change.state.rows[0]!; + for (let index = 1; index < 5_000; index++) assert.equal(change.state.rows[index]!.value, index); + held.value = -1; + const prepared = change.prepare(); + tracker.adopt(prepared); + return held; + }; + retainedSettledChanges.push(commit()); + const replace = (): void => { + const replacement = tracker.prepareReplace({ rows: [{ value: 1 }] }); + tracker.adopt(replacement); + }; + replace(); + await collect(obsolete); + assert.throws(() => (retainedSettledChanges[0] as { value: number }).value, /settled/); +} else if (scenario === "retained-large-placement-proxies") { + type Root = { + child: { value: number }; + visited: { value: number }[]; + first: number[] | null; + second: number[] | null; + rows: Array; + }; + const run = (kind: "write" | "writes" | "insert" | "override"): WeakRef => { + const tracker = track({ + child: { value: 0 }, + visited: Array.from({ length: 5_000 }, (_, value) => ({ value })), + first: null, + second: null, + rows: kind === "override" ? [null] : [], + }); + const change = tracker.beginChange(); + for (const value of change.state.visited) assert.equal(value.value >= 0, true); + let held: object; + if (kind === "write" || kind === "writes") { + held = change.state.child; + change.state.first = Array(50_000).fill(1); + if (kind === "writes") change.state.second = Array(50_000).fill(2); + } else { + held = change.state.rows; + if (kind === "insert") change.state.rows.push(Array(50_000).fill(1)); + else change.state.rows[0] = Array(50_000).fill(1); + } + const prepared = change.prepare(); + const payload = kind === "write" || kind === "writes" ? prepared.value.first : prepared.value.rows[0]; + assert.ok(payload); + const reference = new WeakRef(payload); + tracker.adopt(prepared); + const replacement = tracker.prepareReplace({ + child: { value: 1 }, + visited: [], + first: null, + second: null, + rows: [], + }); + tracker.adopt(replacement); + retainedSettledChanges.push(held); + return reference; + }; + const references = [run("write"), run("writes"), run("insert"), run("override")]; + for (const reference of references) await collect(reference); + for (const held of retainedSettledChanges) assert.throws(() => Reflect.get(held, "length"), /settled/); +} else if (scenario === "stale-unprepared-change") { + const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } }); + const stale = tracker.beginChange(); + stale.state.payload.rows[0]!.value = -1; + const obsolete = new WeakRef(tracker.value.payload.rows); + const replace = (): void => { + const winner = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } }); + tracker.adopt(winner); + }; + replace(); + assert.throws(() => stale.prepare(), /settled/); + retainedSettledChanges.push(stale); + await collect(obsolete); + assert.equal(retainedSettledChanges.length, 1); +} else if (scenario === "same-job-fast-cleanup") { + assert.ok(global.gc, "worker requires --expose-gc"); + const tracker = track({ rows: Array.from({ length: 100_000 }, (_, value) => ({ value })) }); + global.gc(); + const readyHeap = process.memoryUsage().heapUsed; + const commit = (value: number): void => { + const change = tracker.beginChange(); + const draft = change.state; + for (let index = 0; index < 4_094; index++) assert.equal(draft.rows[index]!.value, index); + draft.rows.at(-1)!.value = value; + const prepared = change.prepare(); + tracker.adopt(prepared); + }; + for (let value = 0; value < 100; value++) commit(-value - 1); + global.gc(); + const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024); + assert.ok(retainedMiB < 16, `same-job cleanup retained ${retainedMiB.toFixed(2)} MiB`); + assert.equal(tracker.value.rows.at(-1)!.value, -100); +} else if (scenario === "same-job-folded-ops-cleanup") { + assert.ok(global.gc, "worker requires --expose-gc"); + const tracker = track( + Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, 0])) as Record, + ); + global.gc(); + const readyHeap = process.memoryUsage().heapUsed; + const commit = (value: number): void => { + const change = tracker.beginChange(); + for (let index = 0; index < 5_000; index++) change.state[`field${index}`] = value; + const prepared = change.prepare(); + assert.equal(prepared.ops.length, 1); + assert.equal(prepared.ops[0]![0], "r"); + tracker.adopt(prepared); + }; + for (let value = 1; value <= 100; value++) commit(value); + global.gc(); + const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024); + assert.ok(retainedMiB < 8, `same-job folded operations retained ${retainedMiB.toFixed(2)} MiB`); + assert.equal(tracker.value.field0, 100); } else if (scenario === "lifecycle-churn") { const tracker = track({ value: 0 }); for (let index = 0; index < 100_000; index++) { diff --git a/packages/chord/test/delta-astra/astra.test.ts b/packages/chord/test/delta-tracker/tracker.test.ts similarity index 75% rename from packages/chord/test/delta-astra/astra.test.ts rename to packages/chord/test/delta-tracker/tracker.test.ts index 72f6eb855..7b4d7808d 100644 --- a/packages/chord/test/delta-astra/astra.test.ts +++ b/packages/chord/test/delta-tracker/tracker.test.ts @@ -1,7 +1,6 @@ import { isProxy } from "node:util/types"; import { describe, expect, it } from "vitest"; -import { type Draft, type Op, track } from "../../src/delta/astra/index.ts"; -import { apply, decoder, encoder, type JsonValue } from "../../src/delta/index.ts"; +import { apply, type Draft, decoder, encoder, type JsonValue, type Op, track } from "../../src/delta/index.ts"; const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; @@ -9,20 +8,41 @@ function replay(base: T, operations: readonly Op[]): T { return apply(clone(base), clone(operations)); } +function expectAliasFree(value: unknown): void { + const seen = new WeakMap(); + const visit = (current: unknown, path: string): void => { + if (current === null || typeof current !== "object") return; + const previous = seen.get(current); + if (previous !== undefined) throw new Error(`container at ${path} aliases ${previous}`); + seen.set(current, path); + if (Array.isArray(current)) { + for (let index = 0; index < current.length; index++) visit(current[index], `${path}[${index}]`); + return; + } + for (const key of Object.keys(current)) visit((current as Record)[key], `${path}.${key}`); + }; + visit(value, "$root"); +} + function settle(tracker: ReturnType>, mutate: (draft: Draft) => void): T { - const base = clone(tracker.value); + const baseRoot = tracker.value; + const base = clone(baseRoot); const change = tracker.beginChange(); mutate(change.state); const prepared = change.prepare(); const candidate = clone(prepared.value); + expect(isProxy(prepared.value)).toBe(false); expect(replay(base, prepared.ops)).toEqual(candidate); + expect(baseRoot).toEqual(base); tracker.adopt(prepared); + expect(baseRoot).toEqual(base); + expect(tracker.value).toBe(prepared.value); expect(tracker.value).toEqual(candidate); return candidate; } -describe("astra transactional overlay lifecycle", () => { - it("borrows the committed root and leaves it untouched until adopt", () => { +describe("delta tracker transactional overlay lifecycle", () => { + it("materializes an immutable next revision and adopts it by pointer swap", () => { const initial = { count: 1, nested: { text: "a" }, values: [1] }; const tracker = track(initial); expect(tracker.value).toBe(initial); @@ -32,28 +52,24 @@ describe("astra transactional overlay lifecycle", () => { change.state.values.push(2); expect(initial).toEqual({ count: 1, nested: { text: "a" }, values: [1] }); const prepared = change.prepare(); + const next = prepared.value; expect(prepared.baseRevision).toBe(0); expect(prepared.base).toBe(initial); - expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); + expect(next).not.toBe(initial); + expect(isProxy(next)).toBe(false); + expect(next).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); expect(prepared.ops).toEqual([ ["s", ["count"], 2], ["a", ["nested", "text"], "b"], ["p", ["values"], 1, 0, [2]], ]); - expect(() => { - (prepared.value as { count: number }).count = 9; - }).toThrow(/read-only/); + expect(tracker.value).toBe(initial); tracker.adopt(prepared); - expect(initial).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); - expect(prepared.value).toBe(tracker.value); - expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] }); - expect(prepared.base).toBe(prepared.value); + expect(initial).toEqual({ count: 1, nested: { text: "a" }, values: [1] }); + expect(tracker.value).toBe(next); + expect(prepared.value).toBe(next); + expect(prepared.base).toBe(initial); expect(() => change.state.count).toThrow(TypeError); - expect(prepared.ops).toEqual([ - ["s", ["count"], 2], - ["a", ["nested", "text"], "b"], - ["p", ["values"], 1, 0, [2]], - ]); }); it("keeps a transaction live across await and seals it only at prepare", async () => { @@ -66,7 +82,7 @@ describe("astra transactional overlay lifecycle", () => { expect(prepared.value).toEqual({ left: 1, nested: { right: 2 } }); expect(() => { change.state.left = 3; - }).toThrow(/read-only/); + }).toThrow(/settled/); tracker.adopt(prepared); }); @@ -93,12 +109,31 @@ describe("astra transactional overlay lifecycle", () => { const held = secondPrepared.value; tracker.adopt(firstPrepared); expect(tracker.value.value).toBe(1); - expect(() => held.value).toThrow(TypeError); + expect(held.value).toBe(2); expect(() => tracker.adopt(secondPrepared)).toThrow(/stale/); expect(() => tracker.adopt(firstPrepared)).toThrow(/already been used/); }); - it("adopts object edits in place with ordinary fast-layout descriptors", () => { + it("invalidates competing open overlays without changing their base revision", () => { + const initial = { rows: [{ value: 0 }, { value: 1 }] }; + const tracker = track(initial); + const staleChange = tracker.beginChange(); + const held = staleChange.state.rows[1]!; + held.value = 2; + + const winner = tracker.beginChange(); + winner.state.rows[0]!.value = 3; + const prepared = winner.prepare(); + tracker.adopt(prepared); + + expect(initial).toEqual({ rows: [{ value: 0 }, { value: 1 }] }); + expect(tracker.value).toEqual({ rows: [{ value: 3 }, { value: 1 }] }); + expect(() => held.value).toThrow(/settled/); + expect(() => staleChange.prepare()).toThrow(/settled/); + staleChange.abort(); + }); + + it("adopts object edits as ordinary fast-layout immutable revisions", () => { const initial = { first: 1, second: 2 } as { first: number; second?: number; third?: number }; const tracker = track(initial); const change = tracker.beginChange(); @@ -107,9 +142,10 @@ describe("astra transactional overlay lifecycle", () => { change.state.third = 4; const prepared = change.prepare(); tracker.adopt(prepared); - expect(tracker.value).toBe(initial); + expect(tracker.value).not.toBe(initial); + expect(initial).toEqual({ first: 1, second: 2 }); expect(tracker.value).toEqual({ first: 3, third: 4 }); - expect(Object.getOwnPropertyDescriptor(initial, "third")).toEqual({ + expect(Object.getOwnPropertyDescriptor(tracker.value, "third")).toEqual({ value: 4, writable: true, enumerable: true, @@ -137,9 +173,10 @@ describe("astra transactional overlay lifecycle", () => { next.state.value = 2; const nextPrepared = next.prepare(); tracker.adopt(nextPrepared); - // In-place adoption intentionally advances retained references to the mutable committed root. - expect(published).toBe(tracker.value); - expect(prepared.value).toEqual({ value: 2, nested: { count: 1 } }); + // Retained immutable publications remain at their original revision. + expect(published).not.toBe(tracker.value); + expect(prepared.value).toEqual({ value: 1, nested: { count: 1 } }); + expect(tracker.value).toEqual({ value: 2, nested: { count: 1 } }); }); it("keeps replacement base/value structurally compatible and readable after adoption", () => { @@ -148,22 +185,22 @@ describe("astra transactional overlay lifecycle", () => { const tracker = track(original); const prepared = tracker.prepareReplace(replacement); expect(prepared.base).toBe(original); - expect(prepared.value).not.toBe(replacement); + expect(prepared.value).toBe(replacement); tracker.adopt(prepared); expect(prepared.base).toBe(original); expect(prepared.value).toBe(replacement); expect(prepared.value).toBe(tracker.value); }); - it("revokes aborted and stale prepared candidate views", () => { + it("keeps aborted and stale materialized candidates readable", () => { const tracker = track({ value: 0 }); const abortedChange = tracker.beginChange(); abortedChange.state.value = 1; const aborted = abortedChange.prepare(); const abortedView = aborted.value; aborted.abort(); - expect(() => abortedView.value).toThrow(TypeError); - expect(() => aborted.value.value).toThrow(TypeError); + expect(abortedView.value).toBe(1); + expect(aborted.value.value).toBe(1); const loserChange = tracker.beginChange(); loserChange.state.value = 2; @@ -171,8 +208,21 @@ describe("astra transactional overlay lifecycle", () => { const loserView = loser.value; const winner = tracker.prepareReplace({ value: 3 }); tracker.adopt(winner); - expect(() => loserView.value).toThrow(TypeError); - expect(() => loser.value.value).toThrow(TypeError); + expect(loserView.value).toBe(2); + expect(loser.value.value).toBe(2); + }); + + it("lets a settled Change abort its prepared result without retaining its context", () => { + const tracker = track({ value: 0, nested: { value: 1 } }); + const change = tracker.beginChange(); + change.state.value = 1; + const prepared = change.prepare(); + const operations = prepared.ops; + change.abort(); + change.abort(); + expect(prepared.ops).toBe(operations); + expect(prepared.value).toEqual({ value: 1, nested: { value: 1 } }); + expect(() => tracker.adopt(prepared)).toThrow(/aborted/); }); it("rejects foreign and aborted prepared values", () => { @@ -186,26 +236,28 @@ describe("astra transactional overlay lifecycle", () => { }); it("reads deleted own properties as absent", () => { - Object.defineProperty(Object.prototype, "astraInherited", { + Object.defineProperty(Object.prototype, "trackerInherited", { value: 7, writable: true, configurable: true, }); try { - const tracker = track({ value: 1 } as { value?: number; astraInherited?: number }); + const tracker = track({ value: 1 } as { value?: number; trackerInherited?: number }); const change = tracker.beginChange(); delete change.state.value; - change.state.astraInherited = 1; - delete change.state.astraInherited; + change.state.trackerInherited = 1; + delete change.state.trackerInherited; expect(change.state.value).toBeUndefined(); - expect(change.state.astraInherited).toBeUndefined(); + expect(change.state.trackerInherited).toBeUndefined(); const prepared = change.prepare(); expect(prepared.value.value).toBeUndefined(); - expect(prepared.value.astraInherited).toBeUndefined(); + // Plain committed values expose inherited properties through ordinary + // JavaScript reads; strict-JSON membership remains own-properties only. + expect(prepared.value.trackerInherited).toBe(7); tracker.adopt(prepared); expect(Object.keys(tracker.value)).toEqual([]); } finally { - delete (Object.prototype as Record).astraInherited; + delete (Object.prototype as Record).trackerInherited; } }); @@ -252,24 +304,21 @@ describe("astra transactional overlay lifecycle", () => { expect(tracker.value).toBe(initial); }); - it("takes O(1) ownership for replacement and detaches its operation payload", () => { + it("takes O(1) immutable ownership for replacement and its operation payload", () => { const tracker = track({ value: 0, rows: [] as { value: number }[] }); const replacement = { value: 1, rows: [{ value: 2 }] }; const prepared = tracker.prepareReplace(replacement); expect(prepared.base).toBe(tracker.value); - expect(prepared.value.rows).not.toBe(replacement.rows); + expect(prepared.value).toBe(replacement); const operations = prepared.ops; expect(operations).toEqual([["r", replacement]]); - expect((operations[0] as readonly ["r", typeof replacement])[1]).not.toBe(replacement); + expect((operations[0] as readonly ["r", typeof replacement])[1]).toBe(replacement); tracker.adopt(prepared); expect(tracker.value).toBe(replacement); - expect(prepared.value).toBe(replacement); - replacement.rows[0]!.value = 9; - expect(operations).toEqual([["r", { value: 1, rows: [{ value: 2 }] }]]); }); }); -describe("astra policy view", () => { +describe("delta tracker policy view", () => { it("supports native reads, descriptors, keys, iteration, map, and JSON", () => { const tracker = track({ values: [1, 2, 3], object: { a: 1 } as { a: number; b?: number } }); const change = tracker.beginChange(); @@ -286,6 +335,26 @@ describe("astra policy view", () => { change.abort(); }); + it("keeps document keys distinct from object proxy target fields", () => { + const tracker = track({ + object: { + context: 1, + base: 2, + parent: 3, + dirty: 4, + target: 5, + proxy: 6, + }, + }); + const change = tracker.beginChange(); + change.state.object.context = 7; + change.state.object.proxy = 8; + expect(Object.keys(change.state.object)).toEqual(["context", "base", "parent", "dirty", "target", "proxy"]); + const prepared = change.prepare(); + expect(prepared.value.object).toEqual({ context: 7, base: 2, parent: 3, dirty: 4, target: 5, proxy: 8 }); + tracker.adopt(prepared); + }); + it("keeps native coercion side effects and string operation forms", () => { const tracker = track({ text: "abcdefgh", values: [1, 2, 3], marker: 0 }); const change = tracker.beginChange(); @@ -351,6 +420,21 @@ describe("astra policy view", () => { expect(Object.keys(tracker.value.object)).toEqual(["1", "2", "label"]); }); + it("normalizes deeply equal container assignments before direct candidate materialization", () => { + const initial = { child: { a: 1, b: 2 }, count: 0 }; + const tracker = track(initial); + const change = tracker.beginChange(); + change.state.child = { b: 2, a: 1 }; + change.state.count = 1; + const prepared = change.prepare(); + const replayed = replay(initial, prepared.ops); + expect(prepared.ops).toEqual([["s", ["count"], 1]]); + expect(Object.keys(prepared.value.child)).toEqual(["a", "b"]); + expect(Object.keys(replayed.child)).toEqual(["a", "b"]); + expect(prepared.value).toEqual(replayed); + tracker.adopt(prepared); + }); + it("keeps native integer and string ordering across deletion and re-addition", () => { const tracker = track({ object: { 1: "one", 2: "two", first: "a", second: "b" } as Record, @@ -568,7 +652,7 @@ describe("astra policy view", () => { }); }); -describe("astra by-value placements", () => { +describe("delta tracker by-value placements", () => { it("clones property, index, push, unshift, splice, fill, and copyWithin placements", () => { const tracker = track({ property: null as { value: number } | null, @@ -597,6 +681,81 @@ describe("astra by-value placements", () => { tracker.adopt(prepared); }); + it("rejects non-strict JSON placements before changing the draft", () => { + const initial = { + optional: "remove" as string | undefined, + payload: null as JsonValue, + number: 0, + values: [1, 2] as JsonValue[], + }; + const tracker = track(initial); + const change = tracker.beginChange(); + const undefinedValue = undefined as unknown as JsonValue; + + expect(() => { + change.state.values[0] = undefinedValue; + }).toThrow(/strict JSON/); + expect(() => change.state.values.push({ staged: true }, undefinedValue)).toThrow(/strict JSON/); + expect(() => change.state.values.unshift(undefinedValue)).toThrow(/strict JSON/); + expect(() => change.state.values.splice(1, 0, undefinedValue)).toThrow(/strict JSON/); + expect(() => change.state.values.fill(undefinedValue)).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: undefined } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: () => 1 } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: Symbol("invalid") } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = { nested: 1n } as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => { + change.state.number = Number.NaN; + }).toThrow(/strict JSON/); + expect(() => { + change.state.number = Number.POSITIVE_INFINITY; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = new Date() as unknown as JsonValue; + }).toThrow(/plain objects/); + + const sparse: JsonValue[] = new Array(1); + expect(() => { + change.state.payload = sparse; + }).toThrow(/dense plain arrays/); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => { + change.state.payload = cyclic as JsonValue; + }).toThrow(/cycles/); + let accessorReads = 0; + const accessor = Object.defineProperty({}, "value", { + enumerable: true, + get() { + accessorReads += 1; + return 1; + }, + }); + expect(() => { + change.state.payload = accessor as JsonValue; + }).toThrow(/data properties/); + expect(accessorReads).toBe(0); + + expect(change.state).toEqual(initial); + change.state.optional = undefined; + const dictionary = Object.create(null) as Record; + dictionary.valid = true; + change.state.payload = dictionary; + change.state.values.push({ valid: true }); + const prepared = change.prepare(); + expect(prepared.value).toEqual({ payload: { valid: true }, number: 0, values: [1, 2, { valid: true }] }); + expect(Object.getPrototypeOf(prepared.value.payload)).toBeNull(); + expect(replay(initial, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + }); + it("expands repeated source aliases into independent placements", () => { const tracker = track({ left: null as { nested: { value: number } } | null, @@ -622,6 +781,36 @@ describe("astra by-value placements", () => { tracker.adopt(prepared); }); + it("deep-clones draft-sourced placements within and across revisions", () => { + const tracker = track({ + a: { child: { value: 1 } }, + b: null as { child: { value: number } } | null, + rows: [{ child: { value: 1 } }, { child: { value: 2 } }], + }); + const first = tracker.beginChange(); + first.state.b = first.state.a; + first.state.b.child.value = 3; + first.state.rows[1] = first.state.rows[0]!; + const firstPrepared = first.prepare(); + expect(firstPrepared.value.a.child.value).toBe(1); + expect(firstPrepared.value.b?.child.value).toBe(3); + expect(firstPrepared.value.a).not.toBe(firstPrepared.value.b); + expect(firstPrepared.value.a.child).not.toBe(firstPrepared.value.b?.child); + expect(firstPrepared.value.rows[0]).not.toBe(firstPrepared.value.rows[1]); + expect(firstPrepared.value.rows[0]!.child).not.toBe(firstPrepared.value.rows[1]!.child); + expectAliasFree(firstPrepared.value); + tracker.adopt(firstPrepared); + + const base = clone(tracker.value); + const second = tracker.beginChange(); + second.state.rows[1]!.child.value = 4; + const secondPrepared = second.prepare(); + expect(secondPrepared.value.rows.map((row) => row.child.value)).toEqual([1, 4]); + expect(replay(base, secondPrepared.ops)).toEqual(secondPrepared.value); + expectAliasFree(secondPrepared.value); + tracker.adopt(secondPrepared); + }); + it("distinguishes raw committed references from draft references", () => { const tracker = track({ source: { value: 1 }, @@ -684,7 +873,7 @@ describe("astra by-value placements", () => { sparseTracker.adopt(sparsePrepared); }, 30_000); - it("keeps operation payloads detached from candidate and adopted placement mutation", () => { + it("shares immutable operation placements with the materialized candidate", () => { const tracker = track({ rows: [] as { value: number }[] }); const change = tracker.beginChange(); change.state.rows.push({ value: 1 }); @@ -697,13 +886,15 @@ describe("astra by-value placements", () => { expect(isProxy(value)).toBe(false); pending.push(...Object.values(value)); } + const splice = operations[0]; + if (splice?.[0] !== "p") throw new Error("expected splice"); + expect(splice[4][0]).toBe(prepared.value.rows[0]); tracker.adopt(prepared); - tracker.value.rows[0]!.value = 9; - expect(operations).toEqual([["p", ["rows"], 0, 0, [{ value: 1 }]]]); + expect(tracker.value).toBe(prepared.value); }); }); -describe("astra piece arrays", () => { +describe("delta tracker piece arrays", () => { it("keeps adoption independent from mutable detached permutation metadata", () => { const tracker = track({ values: [3, 1, 2] }); const change = tracker.beginChange(); @@ -716,6 +907,31 @@ describe("astra piece arrays", () => { expect(tracker.value.values).toEqual([1, 2, 3]); }); + it("matches native splice with zero or one argument", () => { + const noArguments = track({ values: [1, 2, 3] }); + const noArgumentsChange = noArguments.beginChange(); + expect(Reflect.apply(noArgumentsChange.state.values.splice, noArgumentsChange.state.values, [])).toEqual([]); + const noArgumentsPrepared = noArgumentsChange.prepare(); + expect(noArgumentsPrepared.value.values).toEqual([1, 2, 3]); + expect(noArgumentsPrepared.ops).toEqual([]); + noArguments.adopt(noArgumentsPrepared); + + const oneArgument = track({ values: [1, 2, 3, 4] }); + const oneArgumentChange = oneArgument.beginChange(); + expect(oneArgumentChange.state.values.splice(1)).toEqual([2, 3, 4]); + const oneArgumentPrepared = oneArgumentChange.prepare(); + expect(oneArgumentPrepared.value.values).toEqual([1]); + expect(replay(oneArgument.value, oneArgumentPrepared.ops)).toEqual(oneArgumentPrepared.value); + oneArgument.adopt(oneArgumentPrepared); + + const pastEnd = track({ values: [1, 2, 3] }); + const pastEndChange = pastEnd.beginChange(); + expect(pastEndChange.state.values.splice(3)).toEqual([]); + const pastEndPrepared = pastEndChange.prepare(); + expect(pastEndPrepared.value.values).toEqual([1, 2, 3]); + pastEnd.adopt(pastEndPrepared); + }); + it("supports all structural mutators and native return values", () => { const tracker = track({ values: [3, 1, 2] }); const base = clone(tracker.value); @@ -818,21 +1034,34 @@ describe("astra piece arrays", () => { }); it("normalizes duplicate entries created by structurally reentrant sort callbacks", () => { - const tracker = track({ values: [{ rank: 2 }, { rank: 1 }] }); + const tracker = track({ + values: [ + { rank: 2, edited: 0, nested: { value: 0 } }, + { rank: 1, edited: 0, nested: { value: 0 } }, + ], + }); const base = clone(tracker.value); const change = tracker.beginChange(); let inserted = false; change.state.values.sort((left, right) => { + left.edited = left.rank * 10; + left.nested.value = left.rank * 100; + right.edited = right.rank * 10; + right.nested.value = right.rank * 100; if (!inserted) { inserted = true; - change.state.values.unshift({ rank: 4 }); + change.state.values.unshift({ rank: 4, edited: 0, nested: { value: 0 } }); } return left.rank - right.rank; }); const prepared = change.prepare(); expect(prepared.value.values.map((value) => value.rank)).toEqual([1, 2, 1]); + expect(prepared.value.values.map((value) => value.edited)).toEqual([10, 20, 10]); + expect(prepared.value.values.map((value) => value.nested.value)).toEqual([100, 200, 100]); expect(prepared.value.values[0]).not.toBe(prepared.value.values[2]); + expect(prepared.value.values[0]!.nested).not.toBe(prepared.value.values[2]!.nested); expect(replay(base, prepared.ops)).toEqual(prepared.value); + expectAliasFree(prepared.value); tracker.adopt(prepared); }); @@ -1025,7 +1254,7 @@ describe("astra piece arrays", () => { ); }); -describe("astra randomized transactions", () => { +describe("delta tracker randomized transactions", () => { type Document = { values: { id: number; score: number }[]; text: string; @@ -1091,7 +1320,8 @@ describe("astra randomized transactions", () => { const tracker = track(initial); const expected = clone(initial); for (let transaction = 0; transaction < 25; transaction++) { - const base = clone(tracker.value); + const baseRoot = tracker.value; + const base = clone(baseRoot); const change = tracker.beginChange(); for (let operation = 0; operation < 5; operation++) { const choice = Math.floor(rng() * 11); @@ -1101,16 +1331,43 @@ describe("astra randomized transactions", () => { } const prepared = change.prepare(); const policy = clone(prepared.value); + expect(isProxy(prepared.value)).toBe(false); + expect(baseRoot, `prepare changed base seed ${seed} transaction ${transaction}`).toEqual(base); expect(policy, `policy seed ${seed} transaction ${transaction}`).toEqual(expected); expect(replay(base, prepared.ops), `replay seed ${seed} transaction ${transaction}`).toEqual(policy); tracker.adopt(prepared); + expect(baseRoot, `adopt changed base seed ${seed} transaction ${transaction}`).toEqual(base); + expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toBe(prepared.value); expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(policy); + expectAliasFree(tracker.value); } } }, 30_000); }); -describe("astra security and storage-style cloning", () => { +describe("delta tracker object emission scaling", () => { + it("orders many reverse-depth object edits through the bounded fallback", () => { + type DeepNode = { value: number; next?: DeepNode }; + let initial: DeepNode = { value: 0 }; + for (let depth = 0; depth < 512; depth++) initial = { value: 0, next: initial }; + const tracker = track(initial); + const change = tracker.beginChange(); + const nodes: Array> = []; + let node: Draft | undefined = change.state; + while (node !== undefined) { + nodes.push(node); + node = node.next; + } + for (let index = nodes.length - 1; index >= 0; index--) nodes[index]!.value = index + 1; + const prepared = change.prepare(); + expect(prepared.ops).toHaveLength(nodes.length); + expect(replay(initial, prepared.ops)).toEqual(prepared.value); + tracker.adopt(prepared); + expectAliasFree(tracker.value); + }); +}); + +describe("delta tracker security and storage-style cloning", () => { it("defines own properties without invoking inherited setters", () => { const tracker = track({} as Record); Object.defineProperty(Object.prototype, "trap", { diff --git a/packages/chord/test/delta-traversal.bench.ts b/packages/chord/test/delta-traversal.bench.ts index f50c4971b..04cbb5647 100644 --- a/packages/chord/test/delta-traversal.bench.ts +++ b/packages/chord/test/delta-traversal.bench.ts @@ -94,7 +94,9 @@ function sources() { "../src/delta/tracker.ts", "../src/delta/draft.ts", "../src/delta/diff.ts", - "../src/delta/value.ts", + "../src/delta/revision-validator.ts", + "../src/delta/apply-immutable-batch.ts", + "../src/delta/apply-immutable-trusted.ts", "./delta-traversal.bench.ts", ].map((path) => [ path, diff --git a/packages/chord/test/delta.test.ts b/packages/chord/test/delta.test.ts index 3a2bb32c2..93cf95de6 100644 --- a/packages/chord/test/delta.test.ts +++ b/packages/chord/test/delta.test.ts @@ -19,10 +19,9 @@ describe("immutable tracker lifecycle", () => { it("keeps a draft alive across await and adopts only a prepared change", async () => { const input = { count: 1, nested: { text: "a" }, values: [1] }; const tracker = track(input); - expect(tracker.value).toEqual(input); - expect(tracker.value).not.toBe(input); - expect(Object.isFrozen(tracker.value)).toBe(true); - expect(Object.isFrozen(tracker.value.nested)).toBe(true); + expect(tracker.value).toBe(input); + expect(Object.isFrozen(tracker.value)).toBe(false); + expect(Object.isFrozen(tracker.value.nested)).toBe(false); const change = tracker.beginChange(); change.state.count = 2; @@ -60,16 +59,14 @@ describe("immutable tracker lifecycle", () => { next.abort(); }); - it("releases the tracker and revokes drafts when preparation fails", () => { - const tracker = track({ values: [1, 2] }); + it("grows arrays with explicit nulls and revokes drafts after preparation", () => { + const tracker = track({ values: [1, 2] as Array }); const change = tracker.beginChange(); change.state.values.length = 4; - expect(() => change.prepare()).toThrow(/dense|holes/); + const prepared = change.prepare(); + expect(prepared.value.values).toEqual([1, 2, null, null]); expect(() => change.state.values).toThrow(TypeError); - expect(() => change.abort()).not.toThrow(); - expect(() => change.prepare()).toThrow(/settled/); - const next = tracker.beginChange(); - next.abort(); + tracker.adopt(prepared); }); it("normalizes a deep no-op to exact previous identity", () => { @@ -83,13 +80,12 @@ describe("immutable tracker lifecycle", () => { expect(tracker.value).toBe(prepared.base); }); - it("copies replacement input and applies the same no-op normalization", () => { + it("takes immutable ownership of replacement input and applies no-op normalization", () => { const tracker = track({ nested: { value: 1 } }); const replacement = { nested: { value: 2 } }; const prepared = tracker.prepareReplace(replacement); - replacement.nested.value = 9; - expect(prepared.value.nested.value).toBe(2); - expect(Object.isFrozen(prepared.value.nested)).toBe(true); + expect(prepared.value).toBe(replacement); + expect(prepared.ops).toEqual([["r", replacement]]); tracker.adopt(prepared); const noOp = tracker.prepareReplace({ nested: { value: 2 } }); @@ -97,31 +93,31 @@ describe("immutable tracker lifecycle", () => { expect(noOp.ops).toEqual([]); }); - it("deeply freezes prepared metadata", () => { + it("shares immutable operation placements with the prepared candidate", () => { const tracker = track({ rows: [] as { id: number }[] }); const change = tracker.beginChange(); change.state.rows.push({ id: 1 }); const prepared = change.prepare(); - expect(Object.isFrozen(prepared)).toBe(true); - expect(Object.isFrozen(prepared.ops)).toBe(true); const splice = prepared.ops[0]!; - expect(Object.isFrozen(splice)).toBe(true); if (splice[0] !== "p") throw new Error("expected splice"); - expect(Object.isFrozen(splice[1])).toBe(true); - expect(Object.isFrozen(splice[4])).toBe(true); - expect(Object.isFrozen(splice[4][0])).toBe(true); + expect(splice[4][0]).toBe(prepared.value.rows[0]); + expect(Object.isFrozen(prepared)).toBe(false); + expect(Object.isFrozen(prepared.ops)).toBe(false); }); - it("rejects foreign, stale, repeated, and overlapping changes", () => { + it("rejects foreign, stale, and repeated preparations while allowing competing changes", () => { const first = track({ value: 0 }); const second = track({ value: 0 }); const change = first.beginChange(); - expect(() => first.beginChange()).toThrow(/active/); + const competing = first.beginChange(); change.state.value = 1; + competing.state.value = 2; const prepared = change.prepare(); + const competingPrepared = competing.prepare(); expect(() => second.adopt(prepared)).toThrow(/different tracker/); first.adopt(prepared); expect(() => first.adopt(prepared)).toThrow(/already been used/); + expect(() => first.adopt(competingPrepared)).toThrow(/stale/); const stale = first.prepareReplace({ value: 2 }); const winner = first.prepareReplace({ value: 3 }); @@ -151,14 +147,14 @@ describe("immutable tracker lifecycle", () => { expect(() => tracker.adopt(competing)).toThrow(/stale/); }); - it("keeps large prepareReplace array edits narrow", () => { + it("emits an owned root replacement without traversing large replacement input", () => { const rows = Array.from({ length: 10_000 }, (_, value) => ({ value, stable: { value } })); const tracker = track({ rows }); - const replacement = tracker.value.rows.slice(); - replacement[5_000] = { value: -1, stable: replacement[5_000]!.stable }; - const prepared = tracker.prepareReplace({ rows: replacement }); - expect(prepared.ops).toEqual([["s", ["rows", 5_000, "value"], -1]]); - expect(JSON.stringify(prepared.ops).length).toBeLessThan(100); + const replacement = { rows: tracker.value.rows.slice() }; + replacement.rows[5_000] = { value: -1, stable: replacement.rows[5_000]!.stable }; + const prepared = tracker.prepareReplace(replacement); + expect(prepared.value).toBe(replacement); + expect(prepared.ops).toEqual([["r", replacement]]); expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); }); }); diff --git a/packages/chord/test/services.test.ts b/packages/chord/test/services.test.ts index 46c9331ea..51bc258f7 100644 --- a/packages/chord/test/services.test.ts +++ b/packages/chord/test/services.test.ts @@ -99,8 +99,7 @@ describe("remote services", () => { delivered = value; deliveries.push(delivery.kind); }); - expect(state.value).toEqual(initial); - expect(state.value).not.toBe(initial); + expect(state.value).toBe(initial); expect(delivered).toBe(state.value); const hydrated = delivered; diff --git a/packages/chord/test/state-draft.test.ts b/packages/chord/test/state-draft.test.ts index e8e3fc69c..87075fd79 100644 --- a/packages/chord/test/state-draft.test.ts +++ b/packages/chord/test/state-draft.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { applyImmutable, type Draft, track } from "../src/delta/index.ts"; +import { applyImmutable, type Draft, type JsonValue, track } from "../src/delta/index.ts"; -describe("copy-on-write change drafts", () => { +describe("transactional overlay drafts", () => { it("copies only changed branches", () => { const tracker = track({ changed: { count: 1, sibling: { value: "kept" } }, @@ -89,15 +89,28 @@ describe("copy-on-write change drafts", () => { expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value); }); - it("rejects invalid writes without mutating the committed base", () => { - const tracker = track({ values: [1, 2] }); - let change = tracker.beginChange(); - expect(() => delete change.state.values[0]).toThrow(/dense|holes/); - change.abort(); - change = tracker.beginChange(); + it("rejects non-strict JSON placements without mutating the draft or base", () => { + const tracker = track({ number: 0, payload: null as JsonValue, values: [1, 2] as JsonValue[] }); + const change = tracker.beginChange(); expect(() => { - change.state.values[0] = undefined as unknown as number; - }).toThrow(/undefined/); + change.state.values[0] = undefined as unknown as JsonValue; + }).toThrow(/strict JSON/); + expect(() => change.state.values.push({ nested: undefined } as unknown as JsonValue)).toThrow(/strict JSON/); + expect(() => { + change.state.number = Number.NaN; + }).toThrow(/strict JSON/); + expect(() => { + change.state.payload = new Date() as unknown as JsonValue; + }).toThrow(/plain objects/); + expect(change.state).toEqual(tracker.value); + change.abort(); + expect(tracker.value).toEqual({ number: 0, payload: null, values: [1, 2] }); + }); + + it("rejects array holes without mutating the committed base", () => { + const tracker = track({ values: [1, 2] }); + const change = tracker.beginChange(); + expect(() => delete change.state.values[0]).toThrow(/dense|holes/); change.abort(); expect(tracker.value.values).toEqual([1, 2]); }); diff --git a/packages/chord/test/state-fuzz.test.ts b/packages/chord/test/state-fuzz.test.ts index 46aea678d..7b6540154 100644 --- a/packages/chord/test/state-fuzz.test.ts +++ b/packages/chord/test/state-fuzz.test.ts @@ -12,6 +12,22 @@ type MutableDocument = Document | Draft; const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +function expectAliasFree(value: unknown): void { + const seen = new WeakMap(); + const visit = (current: unknown, path: string): void => { + if (current === null || typeof current !== "object") return; + const previous = seen.get(current); + if (previous !== undefined) throw new Error(`container at ${path} aliases ${previous}`); + seen.set(current, path); + if (Array.isArray(current)) { + for (let index = 0; index < current.length; index++) visit(current[index], `${path}[${index}]`); + return; + } + for (const key of Object.keys(current)) visit((current as Record)[key], `${path}.${key}`); + }; + visit(value, "$root"); +} + const random = (seed: number): (() => number) => () => { @@ -85,16 +101,23 @@ it("converges across randomized prepared revisions", () => { for (let step = 0; step < 100; step++) { const choice = Math.floor(rng() * 14); const value = seed * 1_000 + step; + const baseRoot = tracker.value; + const base = clone(baseRoot); mutate(expected, choice, value); const change = tracker.beginChange(); mutate(change.state, choice, value); const prepared = change.prepare(); - const operations: readonly Op[] = prepared.ops; + const operations = clone(prepared.ops) as readonly Op[]; + expect(baseRoot, `prepare base seed ${seed} step ${step} choice ${choice}`).toEqual(base); + expect(prepared.base).toBe(baseRoot); replica = applyImmutable(replica, operations); + expect(replica, `replay seed ${seed} step ${step} choice ${choice}`).toEqual(prepared.value); tracker.adopt(prepared); + expect(baseRoot, `adopt base seed ${seed} step ${step} choice ${choice}`).toEqual(base); + expect(tracker.value).toBe(prepared.value); expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected); expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected); - expect(Object.isFrozen(tracker.value)).toBe(true); + expectAliasFree(tracker.value); } } }); diff --git a/packages/chord/test/state-value.test.ts b/packages/chord/test/state-value.test.ts index 202deb590..50b10301f 100644 --- a/packages/chord/test/state-value.test.ts +++ b/packages/chord/test/state-value.test.ts @@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest"; import { track } from "../src/delta/index.ts"; describe("immutable revision ownership", () => { - it("imports detached frozen JSON and expands aliases", () => { - const shared = { value: 1 }; - const input = { left: shared, right: shared }; + it("takes ownership of an alias-free mutable JSON root without freezing", () => { + const input = { left: { value: 1 }, right: { value: 1 } }; const tracker = track(input); - expect(tracker.value).toEqual(input); - expect(tracker.value).not.toBe(input); + expect(tracker.value).toBe(input); expect(tracker.value.left).not.toBe(tracker.value.right); - expect(Object.isFrozen(tracker.value)).toBe(true); - expect(Object.isFrozen(tracker.value.left)).toBe(true); + expect(Object.isFrozen(tracker.value)).toBe(false); + expect(Object.isFrozen(tracker.value.left)).toBe(false); }); it("commits transaction copies in place while sharing unchanged branches", () => { const tracker = track({ changed: { value: 1 }, retained: { value: 2 } }); const base = tracker.value; + const baseSnapshot = structuredClone(base); const change = tracker.beginChange(); change.state.changed.value = 3; const prepared = change.prepare(); expect(prepared.value.changed).not.toBe(base.changed); expect(prepared.value.retained).toBe(base.retained); - expect(Object.isFrozen(prepared.value.changed)).toBe(true); + expect(Object.isFrozen(prepared.value.changed)).toBe(false); tracker.adopt(prepared); + expect(base).toEqual(baseSnapshot); expect(tracker.value).toBe(prepared.value); }); diff --git a/packages/chord/test/state.test.ts b/packages/chord/test/state.test.ts index a604083c2..d175db524 100644 --- a/packages/chord/test/state.test.ts +++ b/packages/chord/test/state.test.ts @@ -27,7 +27,7 @@ describe("transactional replicated state", () => { expect(state.value).not.toBe(previous); expect(state.value.changed).not.toBe(previous.changed); expect(state.value.retained).toBe(previous.retained); - expect(Object.isFrozen(state.value)).toBe(true); + expect(Object.isFrozen(state.value)).toBe(false); expect(deliveries).toEqual([0, 1]); }); @@ -140,25 +140,16 @@ describe("transactional replicated state", () => { expect(state.value.left).not.toBe(state.value.right); }); - it("expands reused owned subtrees into independent placements", () => { + it("takes immutable ownership of alias-free replacements", () => { const state = replicatedState({ left: { value: 1 }, right: { value: 2 } }); - state.replace(BACKGROUND_CONTEXT, { left: state.value.left, right: state.value.left }); + const replacement = { left: { ...state.value.left }, right: { ...state.value.left } }; + state.replace(BACKGROUND_CONTEXT, replacement); + expect(state.value).toBe(replacement); expect(state.value.left).not.toBe(state.value.right); state.change(BACKGROUND_CONTEXT, (draft) => { draft.left.value = 9; }); expect(state.value).toEqual({ left: { value: 9 }, right: { value: 1 } }); - - const overlapping = replicatedState({ parent: { child: { value: 1 } }, other: { value: 2 } }); - overlapping.replace(BACKGROUND_CONTEXT, { - parent: overlapping.value.parent, - other: overlapping.value.parent.child, - }); - expect(overlapping.value.parent.child).not.toBe(overlapping.value.other); - overlapping.change(BACKGROUND_CONTEXT, (draft) => { - draft.other.value = 9; - }); - expect(overlapping.value).toEqual({ parent: { child: { value: 1 } }, other: { value: 9 } }); }); it("preserves compact string, splice, and permutation operations", () => { @@ -185,6 +176,24 @@ describe("transactional replicated state", () => { ]); }); + it("validates replica revisions without freezing shared immutable payloads", () => { + const replica = new ReplicatedStateReplica<{ rows: { value: number }[] }>(() => {}); + const initial = { rows: [{ value: 1 }] }; + replica.hydrate(0, [["r", initial]], BACKGROUND_CONTEXT); + expect(replica.value).toBe(initial); + expect(Object.isFrozen(initial)).toBe(false); + expect(Object.isFrozen(initial.rows[0])).toBe(false); + + const inserted = { value: 2 }; + replica.update(1, [["p", ["rows"], 1, 0, [inserted]]], BACKGROUND_CONTEXT); + expect(replica.value).not.toBe(initial); + expect(replica.value?.rows).toEqual([{ value: 1 }, { value: 2 }]); + expect(replica.value?.rows[0]).toBe(initial.rows[0]); + expect(replica.value?.rows[1]).toBe(inserted); + expect(initial.rows).toHaveLength(1); + expect(Object.isFrozen(inserted)).toBe(false); + }); + it("clears a replica when an adopted update is invalid", () => { const errors: Error[] = []; const replica = new ReplicatedStateReplica<{ value: number }>((error) => errors.push(error)); @@ -207,7 +216,7 @@ describe("transactional replicated state", () => { expect(state.value).toBe(previous); state.replace(BACKGROUND_CONTEXT, { ...state.value, value: { nested: 2 } }); expect(state.value).toEqual({ value: { nested: 2 }, retained: { nested: 2 } }); - expect(state.value.retained).not.toBe(previous.retained); + expect(state.value.retained).toBe(previous.retained); }); }); diff --git a/packages/durable/docs/chord-delta-findings.md b/packages/durable/docs/chord-delta-findings.md index 3654a2b51..3f6a88558 100644 --- a/packages/durable/docs/chord-delta-findings.md +++ b/packages/durable/docs/chord-delta-findings.md @@ -323,7 +323,7 @@ implementation or reproduces the historical graph/mutation comparison. Targeted tests from `packages/chord`: ```sh -node ../../node_modules/vitest/dist/cli.js --run test/delta.test.ts test/delta-clone.test.ts test/delta-retention.test.ts test/services.test.ts test/service-wire.test.ts test/facets.test.ts +node ../../node_modules/vitest/dist/cli.js --run test/delta.test.ts test/delta-clone.test.ts test/delta-tracker/retention.test.ts test/services.test.ts test/service-wire.test.ts test/facets.test.ts ``` Historical source identifiers: