mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): generate deltas at flush
This commit is contained in:
parent
f7079d5628
commit
9af45be821
5 changed files with 1025 additions and 408 deletions
|
|
@ -36,9 +36,10 @@ The design has a few connected pieces:
|
|||
ordered updates, and become unready on disconnect or replacement until they
|
||||
are rehydrated.
|
||||
|
||||
- **Delta tracking** records mutations to plain JSON as compact operations. It
|
||||
preserves append, front-truncation, and array-splice intent, supports durable
|
||||
base batches, and validates untrusted operations before applying them.
|
||||
- **Delta tracking** derives compact operations from tracked plain JSON at
|
||||
flush time. It preserves string append/front-truncation and array-append
|
||||
behavior without retaining mutation history, supports durable base batches,
|
||||
and validates untrusted operations before applying them.
|
||||
|
||||
- **Remote service sources** advertise services available outside a facet host
|
||||
and open bindings for the services its facets require. Bindings carry logical
|
||||
|
|
@ -80,6 +81,9 @@ const replica = apply({ output: "", count: 0 }, ops);
|
|||
The first flush is always a complete base batch. Later flushes contain path-based
|
||||
changes. String assignments preserve pure appends and rolling-window movement as
|
||||
append and front-truncate operations; unrelated rewrites fall back to a set.
|
||||
Values inserted into tracked state become tracker-owned and must subsequently be
|
||||
mutated only through `state`. See the [Delta guide](src/delta/README.md) for
|
||||
mutation, array, lifecycle, and consumer-ownership rules.
|
||||
|
||||
## Bundling and loading facets
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
"README.md",
|
||||
"src/delta/README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
|
|
|
|||
218
packages/chord/src/delta/README.md
Normal file
218
packages/chord/src/delta/README.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Chord Delta
|
||||
|
||||
Chord Delta synchronizes one mutable JSON value from an authoritative producer
|
||||
to an ordered replica. It is available from `@earendil-works/chord/delta`.
|
||||
|
||||
```ts
|
||||
import { apply, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const tracker = track({ output: "", entries: [] as string[] });
|
||||
let replica = apply(undefined, tracker.flush());
|
||||
|
||||
tracker.state.output += "done\n";
|
||||
tracker.state.entries.push("result");
|
||||
replica = apply(replica, tracker.flush());
|
||||
```
|
||||
|
||||
The first `flush()` returns a complete snapshot. Each later `flush()` returns the
|
||||
changes needed to transform the previously published value into the current
|
||||
value. `flush()` returns `[]` when the value has not changed.
|
||||
|
||||
## Producing changes
|
||||
|
||||
Read and mutate `tracker.state` as a normal object:
|
||||
|
||||
```ts
|
||||
tracker.state.status = "running";
|
||||
tracker.state.settings.theme = "dark";
|
||||
tracker.state.messages.push(message);
|
||||
delete tracker.state.retry;
|
||||
```
|
||||
|
||||
Only the value at flush time is published. Intermediate assignments are not:
|
||||
|
||||
```ts
|
||||
tracker.state.status = "starting";
|
||||
tracker.state.status = "running";
|
||||
tracker.flush(); // publishes only "running"
|
||||
```
|
||||
|
||||
Replacing an object or array is also valid. Delta compares its properties and
|
||||
elements with the previously published value:
|
||||
|
||||
```ts
|
||||
tracker.state.settings = {
|
||||
...plainSettings,
|
||||
theme: "dark",
|
||||
};
|
||||
```
|
||||
|
||||
Unchanged properties produce no operations. Changed nested strings and arrays
|
||||
still use the compact forms described below.
|
||||
|
||||
### Strings
|
||||
|
||||
Appending text produces an append operation:
|
||||
|
||||
```ts
|
||||
tracker.state.output += "next line\n";
|
||||
```
|
||||
|
||||
Moving a bounded text window forward produces a front-truncate followed by an
|
||||
append when the old suffix exactly matches the new prefix:
|
||||
|
||||
```ts
|
||||
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
|
||||
```
|
||||
|
||||
An unrelated replacement produces a normal set operation. Overlap is always
|
||||
verified; Delta never applies a guessed string match.
|
||||
|
||||
### Arrays
|
||||
|
||||
Use normal array methods:
|
||||
|
||||
```ts
|
||||
tracker.state.messages.push(first);
|
||||
tracker.state.messages.push(second);
|
||||
tracker.state.messages.splice(3, 1, replacement);
|
||||
```
|
||||
|
||||
All `push()` calls before one flush are published as one tail splice. Changes to
|
||||
older elements remain separate, regardless of whether they happen before or
|
||||
after the pushes. Changes to newly pushed elements are included in the pushed
|
||||
values.
|
||||
|
||||
Front or middle insertion, removal, sorting, reversing, `fill()`, and
|
||||
`copyWithin()` are supported. These operations can require comparison or
|
||||
replacement of a larger array region than a tail append.
|
||||
|
||||
Sparse arrays are not supported. Writing beyond the next index throws. Increasing
|
||||
`length` creates explicit `null` elements; decreasing it removes elements.
|
||||
|
||||
`fill(object)` copies the object independently into each affected position.
|
||||
`copyWithin()` likewise separates duplicated objects. This keeps tracked state a
|
||||
tree rather than creating shared mutable array elements.
|
||||
|
||||
### Optional properties
|
||||
|
||||
Optional object properties use absence. They do not require `null`:
|
||||
|
||||
```ts
|
||||
type Settings = { label?: string; count: number };
|
||||
const tracker = track<Settings>({ count: 0 });
|
||||
|
||||
tracker.state.label = "active";
|
||||
tracker.state.label = undefined; // deletes label
|
||||
// `delete tracker.state.label` is equivalent
|
||||
```
|
||||
|
||||
`undefined` is accepted only as assignment syntax for deleting an object
|
||||
property. It is not a JSON value. Initial and assigned objects cannot contain own
|
||||
`undefined` values, and array elements cannot be `undefined`. Use `null` when an
|
||||
array position or explicit empty value must remain present.
|
||||
|
||||
## State ownership
|
||||
|
||||
The object passed to `track()` becomes tracker-owned. The same applies to objects
|
||||
later assigned into state or inserted into arrays.
|
||||
|
||||
After insertion, retained references may be read or reused, but must not be
|
||||
mutated directly:
|
||||
|
||||
```ts
|
||||
const item = { status: "new" };
|
||||
tracker.state.item = item;
|
||||
|
||||
tracker.state.item.status = "ready"; // tracked
|
||||
item.status = "broken"; // unsupported: bypasses tracking
|
||||
```
|
||||
|
||||
Perform mutations through `tracker.state`. Do not put a proxy read from
|
||||
`tracker.state` back into tracked state. Mutate it in place or construct a
|
||||
replacement from plain data.
|
||||
|
||||
Tracked state must be a mutable JSON tree:
|
||||
|
||||
- strings, booleans, finite numbers, `null`, arrays, and plain objects;
|
||||
- no cycles or one mutable object stored at multiple locations;
|
||||
- no sparse arrays, accessors, frozen objects, symbols, classes, functions,
|
||||
`Map`, or `Set`.
|
||||
|
||||
Do not keep a child proxy across an array operation that changes indices. Read
|
||||
the child again from its new index.
|
||||
|
||||
## Operation formats
|
||||
|
||||
Delta has two operation formats.
|
||||
|
||||
### `Op`: decoded operations
|
||||
|
||||
`track().flush()` returns `Op[]`, and `apply()` accepts `Op[]`. Every path is an
|
||||
inline array.
|
||||
|
||||
| Tuple | Meaning |
|
||||
| --- | --- |
|
||||
| `["r", value]` | Replace the complete value. |
|
||||
| `["s", path, value]` | Set a property or array element. |
|
||||
| `["d", path]` | Delete an object property. |
|
||||
| `["a", path, text]` | Append to a string. |
|
||||
| `["t", path, count]` | Remove UTF-16 code units from a string's front. |
|
||||
| `["p", path, index, remove, items]` | Splice an array. |
|
||||
|
||||
### `WireOp`: encoded operations
|
||||
|
||||
`WireOp[]` is the transport and storage form. `encoder()` replaces repeated
|
||||
paths with numeric IDs and may omit a path when it equals the preceding path in
|
||||
the same batch. `decoder()` restores inline paths.
|
||||
|
||||
```ts
|
||||
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const tracker = track({ output: "" });
|
||||
const enc = encoder();
|
||||
const dec = decoder();
|
||||
let replica: { output: string } | undefined;
|
||||
|
||||
const send = () => {
|
||||
const wire = enc.encode(tracker.flush()); // Op[] -> WireOp[]
|
||||
replica = apply(replica, dec.decode(wire)); // WireOp[] -> Op[]
|
||||
};
|
||||
```
|
||||
|
||||
Use one encoder and decoder per ordered stream. Numeric path IDs remain valid
|
||||
across batches. Omitted paths never carry across a batch boundary. A complete
|
||||
snapshot resets both path dictionaries, allowing replay to start there. Do not
|
||||
pass `WireOp[]` directly to `apply()`.
|
||||
|
||||
## Tracker lifecycle
|
||||
|
||||
```ts
|
||||
tracker.flush(); // publish changes since the previous flush
|
||||
tracker.rebase(); // make the next flush a complete snapshot
|
||||
tracker.discard(); // accept current changes without publishing them
|
||||
tracker.state = replacement; // replace the root; next flush is complete
|
||||
```
|
||||
|
||||
`discard()` intentionally prevents current changes from reaching existing
|
||||
replicas. Use it only when those replicas do not need the discarded changes.
|
||||
|
||||
`apply()` adopts object and array payloads from its input batch. Do not freeze a
|
||||
batch before applying it, and do not apply one in-memory batch to multiple
|
||||
replicas unless each replica receives its own clone. A serialized and decoded
|
||||
batch is already detached.
|
||||
|
||||
## 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. State must have tree structure rather than
|
||||
aliases or cycles.
|
||||
- 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 than a
|
||||
tail append.
|
||||
- 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.
|
||||
|
|
@ -3,7 +3,7 @@ import type { JsonValue } from "../types.ts";
|
|||
export type { JsonValue } from "../types.ts";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// chord/delta — intent-recording change tracking over plain JSON.
|
||||
// chord/delta — flush-time change tracking over plain JSON.
|
||||
//
|
||||
// Depends on nothing else in the harness. Session storage, the runtime and the
|
||||
// facet host consume it; keep the arrows pointing that way.
|
||||
|
|
@ -22,7 +22,7 @@ export type PathRef<P extends Path = Path> = P | number;
|
|||
* `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target
|
||||
* the root: the type forbids it. `p` may, and only because a tracked value can
|
||||
* itself be an array — but a `p` that replaces its entire target is normalised to
|
||||
* `r`/`s` at record time, so a root `p` is always a partial modification.
|
||||
* `r`/`s` at flush time, so a root `p` is always a partial modification.
|
||||
*
|
||||
* `Op` knows nothing about the path dictionary. Interning, id references and
|
||||
* omitted paths live in `WireOp` and exist only between `encode` and `decode`.
|
||||
|
|
@ -107,41 +107,24 @@ export function overlap(a: string, b: string, scan: number, probe = 64, maxCandi
|
|||
|
||||
export interface TrackerOptions {
|
||||
maxOverlapScan?: number;
|
||||
coalesce?: boolean;
|
||||
}
|
||||
|
||||
export interface Tracker<T extends object> {
|
||||
/**
|
||||
* Mutate this. Plain TS: assignment, push, splice, delete, all of it.
|
||||
*
|
||||
* Assigning to it replaces the whole value: emits `["r", next]` and discards
|
||||
* ops recorded before it, which describe a value that no longer exists.
|
||||
*
|
||||
* `tracker.state = tracker.target` is therefore a checkpoint — it forces a base
|
||||
* batch without changing anything. Discarding the pending ops is correct
|
||||
* because the proxy mutates the target directly, so the value already carries
|
||||
* them.
|
||||
* The tracked value. Mutate and read state only through this proxy. Values
|
||||
* inserted into it are adopted: callers may retain read-only references, but
|
||||
* must not mutate them outside this proxy.
|
||||
*/
|
||||
state: T;
|
||||
/** The untracked backing object. Reads only — writing here emits nothing. */
|
||||
readonly target: T;
|
||||
flush(): Op[];
|
||||
/**
|
||||
* Clear pending ops and make the next flush a base batch, without changing
|
||||
* the value.
|
||||
*
|
||||
* Recovery replays from the last base batch, so a producer that wants "at most
|
||||
* N batches to replay" calls this every N. The pending ops are already
|
||||
* reflected in the value — the proxy mutates the target directly — so
|
||||
* discarding them loses nothing.
|
||||
*/
|
||||
/** Make the next flush a complete base batch without changing the value. */
|
||||
rebase(): void;
|
||||
/** Discard without emitting. */
|
||||
/** Accept pending mutations locally without emitting them. */
|
||||
discard(): void;
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
|
||||
const isObj = (v: unknown): v is object => v !== null && typeof v === "object";
|
||||
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
|
||||
const cloneJson = <T extends JsonValue>(value: T): T => {
|
||||
if (!isObj(value)) return value;
|
||||
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
|
||||
|
|
@ -159,220 +142,526 @@ const cloneJson = <T extends JsonValue>(value: T): T => {
|
|||
}
|
||||
return result as T;
|
||||
};
|
||||
|
||||
const INDEX = /^(?:0|[1-9]\d*)$/;
|
||||
const norm = (t: object, k: string | symbol): Seg | symbol =>
|
||||
typeof k === "symbol" ? k : Array.isArray(t) && INDEX.test(k) ? Number(k) : k;
|
||||
const toIntegerOrInfinity = (value: unknown): number => {
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number) || number === 0) return 0;
|
||||
return Number.isFinite(number) ? Math.trunc(number) : number;
|
||||
};
|
||||
const norm = (target: object, key: string | symbol): Seg | symbol =>
|
||||
typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key;
|
||||
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
|
||||
const TRACKED_PROXIES = new WeakSet<object>();
|
||||
const MISSING = Symbol("missing");
|
||||
type MaybeJson = JsonValue | typeof MISSING;
|
||||
type ArrayDirty = { kind: "append"; start: number } | { kind: "diff" } | { kind: "replace" };
|
||||
type DirtyNode = { valueDirty?: true; array?: ArrayDirty; children: Map<Seg, DirtyNode> };
|
||||
|
||||
const dirtyNode = (): DirtyNode => ({ children: new Map() });
|
||||
|
||||
const spliceItems = (target: unknown[], index: number, remove: number, items: JsonValue[]): JsonValue[] => {
|
||||
const removed = Reflect.apply(Array.prototype.splice, target, [index, remove]) as JsonValue[];
|
||||
const chunkSize = 10_000;
|
||||
for (let offset = 0; offset < items.length; offset += chunkSize) {
|
||||
Reflect.apply(Array.prototype.splice, target, [index + offset, 0, ...items.slice(offset, offset + chunkSize)]);
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
const jsonEqual = (left: JsonValue, right: JsonValue): boolean => {
|
||||
if (left === right) return true;
|
||||
if (!isObj(left) || !isObj(right) || Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (!jsonEqual(left[index]!, right[index]!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const leftObject = left as Record<string, JsonValue>;
|
||||
const rightObject = right as Record<string, JsonValue>;
|
||||
const leftKeys = Object.keys(leftObject);
|
||||
const rightKeys = Object.keys(rightObject);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
for (const key of leftKeys) {
|
||||
if (!Object.hasOwn(rightObject, key) || !jsonEqual(leftObject[key]!, rightObject[key]!)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const ownValue = (value: JsonValue, segment: Seg): MaybeJson => {
|
||||
if (!isObj(value) || !Object.hasOwn(value, segment)) return MISSING;
|
||||
return (value as Record<Seg, JsonValue>)[segment]!;
|
||||
};
|
||||
|
||||
const emitSet = (path: Path, value: JsonValue, out: Op[]): void => {
|
||||
const snapshot = cloneJson(value);
|
||||
if (path.length === 0) out.push(["r", snapshot]);
|
||||
else out.push(["s", [...path] as unknown as NonEmptyPath, snapshot]);
|
||||
};
|
||||
|
||||
const emitDelete = (path: Path, out: Op[]): void => {
|
||||
if (path.length === 0) throw new TypeError("the tracked root cannot be deleted");
|
||||
out.push(["d", [...path] as unknown as NonEmptyPath]);
|
||||
};
|
||||
|
||||
const diffString = (before: string, after: string, path: Path, scan: number, out: Op[]): void => {
|
||||
if (before === after) return;
|
||||
if (path.length === 0) {
|
||||
emitSet(path, after, out);
|
||||
return;
|
||||
}
|
||||
const at = [...path] as unknown as NonEmptyPath;
|
||||
if (after.length > before.length && after.startsWith(before)) {
|
||||
out.push(["a", at, after.slice(before.length)]);
|
||||
return;
|
||||
}
|
||||
const shared = overlap(before, after, scan);
|
||||
if (shared === 0) {
|
||||
out.push(["s", at, after]);
|
||||
return;
|
||||
}
|
||||
out.push(["t", at, before.length - shared]);
|
||||
if (after.length > shared) out.push(["a", at, after.slice(shared)]);
|
||||
};
|
||||
|
||||
const diffValue = (before: MaybeJson, after: MaybeJson, path: Path, scan: number, out: Op[]): void => {
|
||||
if (before === MISSING) {
|
||||
if (after !== MISSING) emitSet(path, after, out);
|
||||
return;
|
||||
}
|
||||
if (after === MISSING) {
|
||||
emitDelete(path, out);
|
||||
return;
|
||||
}
|
||||
if (before === after) return;
|
||||
if (typeof before === "string" && typeof after === "string") {
|
||||
diffString(before, after, path, scan, out);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
diffArray(before, after, path, scan, out);
|
||||
return;
|
||||
}
|
||||
if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) {
|
||||
diffObject(before as Record<string, JsonValue>, after as Record<string, JsonValue>, path, scan, out);
|
||||
return;
|
||||
}
|
||||
emitSet(path, after, out);
|
||||
};
|
||||
|
||||
function diffObject(
|
||||
before: Record<string, JsonValue>,
|
||||
after: Record<string, JsonValue>,
|
||||
path: Path,
|
||||
scan: number,
|
||||
out: Op[],
|
||||
): void {
|
||||
if (path.length > 0 && [...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) {
|
||||
emitSet(path, after, out);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(after)) {
|
||||
diffValue(Object.hasOwn(before, key) ? before[key]! : MISSING, after[key]!, [...path, key], scan, out);
|
||||
}
|
||||
for (const key of Object.keys(before)) {
|
||||
if (!Object.hasOwn(after, key)) emitDelete([...path, key], out);
|
||||
}
|
||||
}
|
||||
|
||||
function diffArray(before: JsonValue[], after: JsonValue[], path: Path, scan: number, out: Op[]): void {
|
||||
if (before.length === after.length) {
|
||||
for (let index = 0; index < after.length; index++) {
|
||||
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = 0;
|
||||
while (prefix < before.length && prefix < after.length && jsonEqual(before[prefix]!, after[prefix]!)) prefix++;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < before.length - prefix &&
|
||||
suffix < after.length - prefix &&
|
||||
jsonEqual(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!)
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
const shorter = Math.min(before.length, after.length);
|
||||
if (prefix + suffix === shorter) {
|
||||
const remove = before.length - prefix - suffix;
|
||||
const items = after.slice(prefix, after.length - suffix);
|
||||
if (prefix === 0 && remove === before.length) emitSet(path, after, out);
|
||||
else out.push(["p", [...path], prefix, remove, cloneJson(items)]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Structural movement combined with retained-index edits has no unique
|
||||
// alignment. Preserve the retained index deltas and express only the tail
|
||||
// length change structurally. It may be broader than the producer's intent,
|
||||
// but never degrades those edits to a whole-array replacement.
|
||||
for (let index = 0; index < shorter; index++) {
|
||||
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
|
||||
}
|
||||
if (after.length > before.length) {
|
||||
out.push(["p", [...path], before.length, 0, cloneJson(after.slice(before.length))]);
|
||||
} else if (before.length > after.length) {
|
||||
if (after.length === 0) emitSet(path, after, out);
|
||||
else out.push(["p", [...path], after.length, before.length - after.length, []]);
|
||||
}
|
||||
}
|
||||
|
||||
const walkDirty = (before: JsonValue, after: JsonValue, node: DirtyNode, path: Path, scan: number, out: Op[]): void => {
|
||||
if (node.valueDirty) {
|
||||
diffValue(before, after, path, scan, out);
|
||||
return;
|
||||
}
|
||||
if (node.array !== undefined) {
|
||||
if (node.array.kind === "replace") {
|
||||
if (!jsonEqual(before, after)) emitSet(path, after, out);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(before) || !Array.isArray(after) || node.array.kind === "diff") {
|
||||
diffValue(before, after, path, scan, out);
|
||||
return;
|
||||
}
|
||||
const start = node.array.start;
|
||||
if (before.length !== start || after.length < start) {
|
||||
diffValue(before, after, path, scan, out);
|
||||
return;
|
||||
}
|
||||
for (const [segment, child] of node.children) {
|
||||
if (typeof segment !== "number" || segment >= start) continue;
|
||||
const previous = ownValue(before, segment);
|
||||
const current = ownValue(after, segment);
|
||||
if (previous === MISSING || current === MISSING || child.valueDirty) {
|
||||
diffValue(previous, current, [...path, segment], scan, out);
|
||||
} else if (isObj(previous) && isObj(current)) {
|
||||
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
|
||||
} else {
|
||||
diffValue(previous, current, [...path, segment], scan, out);
|
||||
}
|
||||
}
|
||||
const items = after.slice(start);
|
||||
if (items.length > 0) out.push(["p", [...path], start, 0, cloneJson(items)]);
|
||||
return;
|
||||
}
|
||||
for (const [segment, child] of node.children) {
|
||||
const previous = ownValue(before, segment);
|
||||
const current = ownValue(after, segment);
|
||||
if (previous === MISSING || current === MISSING || child.valueDirty) {
|
||||
diffValue(previous, current, [...path, segment], scan, out);
|
||||
continue;
|
||||
}
|
||||
if (!isObj(previous) || !isObj(current)) {
|
||||
diffValue(previous, current, [...path, segment], scan, out);
|
||||
continue;
|
||||
}
|
||||
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
|
||||
}
|
||||
};
|
||||
|
||||
const cloneOp = (op: Op): Op => {
|
||||
switch (op[0]) {
|
||||
case "r":
|
||||
return ["r", cloneJson(op[1])];
|
||||
case "s":
|
||||
return ["s", op[1], cloneJson(op[2])];
|
||||
case "p":
|
||||
return ["p", op[1], op[2], op[3], cloneJson(op[4])];
|
||||
default:
|
||||
return op;
|
||||
}
|
||||
};
|
||||
|
||||
export function track<T extends object>(root: T, options: TrackerOptions = {}): Tracker<T> {
|
||||
validateJsonValue(root);
|
||||
const scan = options.maxOverlapScan ?? 65_536;
|
||||
const doCoalesce = options.coalesce ?? true;
|
||||
let pending = dirtyNode();
|
||||
let hasPending = false;
|
||||
let baseline: JsonValue | undefined;
|
||||
let forceBase = true;
|
||||
|
||||
let ops: Op[] = [];
|
||||
|
||||
const emit = (op: Op) => {
|
||||
ops.push(op);
|
||||
const clearPending = (): void => {
|
||||
pending = dirtyNode();
|
||||
hasPending = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject reserved keys at record time, not just on apply.
|
||||
*
|
||||
* `JSON.parse('{"__proto__":{}}')` yields an OWN property, so a producer can
|
||||
* hold state containing one without doing anything unusual. The tracker would
|
||||
* then emit a path the applier refuses — state that cannot be replicated. Fail
|
||||
* where the mistake is, at the write.
|
||||
*/
|
||||
const guard = (seg: Seg | symbol): Seg => {
|
||||
if (typeof seg === "symbol") throw new UnsafePathError(String(seg));
|
||||
if (typeof seg === "string" && RESERVED_SEGMENTS.has(seg)) throw new UnsafePathError(seg);
|
||||
return seg;
|
||||
const ensureNode = (path: Path): DirtyNode | undefined => {
|
||||
hasPending = true;
|
||||
let node = pending;
|
||||
for (const segment of path) {
|
||||
if (node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") return undefined;
|
||||
let child = node.children.get(segment);
|
||||
if (child === undefined) child = dirtyNode();
|
||||
else node.children.delete(segment);
|
||||
node.children.set(segment, child);
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
/**
|
||||
* A splice covering the whole target is a replacement written the long way.
|
||||
* Only the interception knows the pre-splice length, so this is a record-time
|
||||
* rewrite, not something flush can recover.
|
||||
*/
|
||||
const spliceOrReplace = (path: Path, before: number, index: number, remove: number, items: JsonValue[]) => {
|
||||
if (index === 0 && remove === before) {
|
||||
if (path.length === 0) emit(["r", items]);
|
||||
else emit(["s", path as NonEmptyPath, items]);
|
||||
const findNode = (path: Path): DirtyNode | undefined => {
|
||||
let node = pending;
|
||||
for (const segment of path) {
|
||||
const child = node.children.get(segment);
|
||||
if (child === undefined) return undefined;
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const markValue = (path: Path): void => {
|
||||
const node = ensureNode(path);
|
||||
if (node === undefined) return;
|
||||
node.valueDirty = true;
|
||||
node.array = undefined;
|
||||
node.children.clear();
|
||||
};
|
||||
|
||||
const markArrayAppend = (path: Path, start: number): void => {
|
||||
const node = ensureNode(path);
|
||||
if (node === undefined || node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") {
|
||||
return;
|
||||
}
|
||||
emit(["p", [...path], index, remove, items]);
|
||||
if (node.array === undefined) node.array = { kind: "append", start };
|
||||
};
|
||||
|
||||
const markArrayDiff = (path: Path): void => {
|
||||
const node = ensureNode(path);
|
||||
if (node === undefined || node.valueDirty || node.array?.kind === "replace") return;
|
||||
node.array = { kind: "diff" };
|
||||
node.children.clear();
|
||||
};
|
||||
|
||||
const markArrayReplace = (path: Path): void => {
|
||||
const node = ensureNode(path);
|
||||
if (node === undefined || node.valueDirty) return;
|
||||
node.array = { kind: "replace" };
|
||||
node.children.clear();
|
||||
};
|
||||
|
||||
const appendStart = (path: Path): number | undefined => {
|
||||
const array = findNode(path)?.array;
|
||||
return array?.kind === "append" ? array.start : undefined;
|
||||
};
|
||||
|
||||
const guard = (segment: Seg | symbol): Seg => {
|
||||
if (typeof segment === "symbol") throw new UnsafePathError(String(segment));
|
||||
if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) throw new UnsafePathError(segment);
|
||||
return segment;
|
||||
};
|
||||
|
||||
const assertNoTrackedProxy = (value: unknown, seen = new Set<object>()): void => {
|
||||
if (!isObj(value) || seen.has(value)) return;
|
||||
if (TRACKED_PROXIES.has(value)) throw new TypeError("tracked proxies cannot be assigned as values");
|
||||
if (TRACKED_PROXIES.has(value)) {
|
||||
throw new TypeError(
|
||||
"value contains tracked state; build replacements from plain data, not from state proxies",
|
||||
);
|
||||
}
|
||||
seen.add(value);
|
||||
for (const child of Array.isArray(value) ? value : Object.values(value)) assertNoTrackedProxy(child, seen);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor !== undefined && "value" in descriptor) assertNoTrackedProxy(descriptor.value, seen);
|
||||
}
|
||||
};
|
||||
|
||||
const wrap = <V extends object>(obj: V, path: Path): V => {
|
||||
const adoptItems = (values: readonly unknown[]): JsonValue[] => {
|
||||
assertNoTrackedProxy(values);
|
||||
assertJsonValue(values);
|
||||
return values as JsonValue[];
|
||||
};
|
||||
|
||||
const integer = (value: unknown): number => {
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number) || number === 0) return 0;
|
||||
return Number.isFinite(number) ? Math.trunc(number) : number;
|
||||
};
|
||||
|
||||
const spliceRange = (length: number, args: readonly unknown[]): { index: number; remove: number } => {
|
||||
const rawStart = args.length === 0 ? 0 : integer(args[0]);
|
||||
const index = rawStart < 0 ? Math.max(0, length + rawStart) : Math.min(rawStart, length);
|
||||
const remove =
|
||||
args.length === 0
|
||||
? 0
|
||||
: args.length === 1
|
||||
? length - index
|
||||
: Math.max(0, Math.min(integer(args[1]), length - index));
|
||||
return { index, remove };
|
||||
};
|
||||
|
||||
const wrap = <V extends object>(object: V, path: Path, blockedSegment?: Seg): V => {
|
||||
const childProxies = new Map<string | symbol, { target: object; proxy: object }>();
|
||||
const proxy = new Proxy(obj, {
|
||||
get(t, k, r) {
|
||||
if (Array.isArray(t) && typeof k === "string" && MUTATORS.has(k)) {
|
||||
const proxy = new Proxy(object, {
|
||||
get(target, key, receiver) {
|
||||
if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) {
|
||||
return (...args: unknown[]) => {
|
||||
const before = t.length;
|
||||
const cloneItems = (values: readonly unknown[]): JsonValue[] => {
|
||||
for (const value of values) {
|
||||
assertNoTrackedProxy(value);
|
||||
assertJsonValue(value);
|
||||
}
|
||||
return values.map((value) => cloneJson(value as JsonValue));
|
||||
};
|
||||
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
|
||||
const before = target.length;
|
||||
let result: unknown;
|
||||
switch (k) {
|
||||
switch (key) {
|
||||
case "push": {
|
||||
const items = cloneItems(args);
|
||||
result = Reflect.apply(Array.prototype.push, t, items);
|
||||
spliceOrReplace(path, before, before, 0, cloneJson(items));
|
||||
const items = adoptItems(args);
|
||||
if (items.length > 0) markArrayAppend(path, before);
|
||||
spliceItems(target, before, 0, items);
|
||||
result = target.length;
|
||||
break;
|
||||
}
|
||||
case "unshift": {
|
||||
const items = cloneItems(args);
|
||||
result = Reflect.apply(Array.prototype.unshift, t, items);
|
||||
spliceOrReplace(path, before, 0, 0, cloneJson(items));
|
||||
const items = adoptItems(args);
|
||||
if (items.length > 0) markArrayDiff(path);
|
||||
spliceItems(target, 0, 0, items);
|
||||
result = target.length;
|
||||
break;
|
||||
}
|
||||
case "pop":
|
||||
result = Reflect.apply(Array.prototype.pop, t, args);
|
||||
if (before > 0) spliceOrReplace(path, before, before - 1, 1, []);
|
||||
if (before > 0) {
|
||||
const start = appendStart(path);
|
||||
if (start === undefined || before - 1 < start) markArrayDiff(path);
|
||||
}
|
||||
result = Reflect.apply(Array.prototype.pop, target, args);
|
||||
break;
|
||||
case "shift":
|
||||
result = Reflect.apply(Array.prototype.shift, t, args);
|
||||
if (before > 0) spliceOrReplace(path, before, 0, 1, []);
|
||||
if (before > 0) markArrayDiff(path);
|
||||
result = Reflect.apply(Array.prototype.shift, target, args);
|
||||
break;
|
||||
case "splice": {
|
||||
const items = cloneItems(args.slice(2));
|
||||
const rawStart = args.length === 0 ? 0 : toIntegerOrInfinity(args[0]);
|
||||
const index = rawStart < 0 ? Math.max(0, before + rawStart) : Math.min(rawStart, before);
|
||||
const remove =
|
||||
args.length === 0
|
||||
? 0
|
||||
: args.length === 1
|
||||
? before - index
|
||||
: Math.max(0, Math.min(toIntegerOrInfinity(args[1]), before - index));
|
||||
result = Reflect.apply(Array.prototype.splice, t, [index, remove, ...items]);
|
||||
spliceOrReplace(path, before, index, remove, cloneJson(items));
|
||||
const items = adoptItems(args.slice(2));
|
||||
const { index, remove } = spliceRange(before, args);
|
||||
if (remove > 0 || items.length > 0) {
|
||||
const start = appendStart(path);
|
||||
if (index === 0 && remove === before) markArrayReplace(path);
|
||||
else if (start !== undefined && index >= start) {
|
||||
// The final append payload includes all tail edits.
|
||||
} else if (index === before && remove === 0) markArrayAppend(path, before);
|
||||
else markArrayDiff(path);
|
||||
}
|
||||
result = spliceItems(target, index, remove, items);
|
||||
break;
|
||||
}
|
||||
// sort/reverse/fill/copyWithin permute wholesale; intent is not a splice.
|
||||
default: {
|
||||
let methodArgs = args;
|
||||
let fillValue: JsonValue | undefined;
|
||||
if (k === "fill") {
|
||||
if (key === "fill") {
|
||||
assertNoTrackedProxy(args[0]);
|
||||
assertJsonValue(args[0]);
|
||||
fillValue = cloneJson(args[0]);
|
||||
methodArgs = [fillValue, ...args.slice(1)];
|
||||
}
|
||||
result = Reflect.apply(Array.prototype[k as "sort"], t, methodArgs);
|
||||
markArrayDiff(path);
|
||||
result = Reflect.apply(Array.prototype[key as "sort"], target, methodArgs);
|
||||
if (isObj(fillValue)) {
|
||||
for (let index = 0; index < t.length; index++) {
|
||||
if (t[index] === fillValue) t[index] = cloneJson(fillValue as JsonValue);
|
||||
for (let index = 0; index < target.length; index++) {
|
||||
if (target[index] === fillValue) target[index] = cloneJson(fillValue as JsonValue);
|
||||
}
|
||||
} else if (k === "copyWithin") {
|
||||
for (let index = 0; index < t.length; index++) {
|
||||
if (isObj(t[index])) t[index] = cloneJson(t[index] as JsonValue);
|
||||
} else if (key === "copyWithin") {
|
||||
for (let index = 0; index < target.length; index++) {
|
||||
if (isObj(target[index])) target[index] = cloneJson(target[index] as JsonValue);
|
||||
}
|
||||
}
|
||||
assertJsonValue(t);
|
||||
if (path.length === 0) emit(["r", cloneJson(t as JsonValue)]);
|
||||
else emit(["s", path as NonEmptyPath, cloneJson(t as JsonValue)]);
|
||||
assertJsonValue(target);
|
||||
}
|
||||
}
|
||||
if (k === "pop") childProxies.delete(String(before - 1));
|
||||
else if (k !== "push") childProxies.clear();
|
||||
return k === "sort" || k === "reverse" || k === "fill" || k === "copyWithin" ? proxy : result;
|
||||
if (key === "pop") childProxies.delete(String(before - 1));
|
||||
else if (key !== "push") childProxies.clear();
|
||||
return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? proxy : result;
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(t, k, r);
|
||||
const value = Reflect.get(target, key, receiver);
|
||||
if (!isObj(value)) return value;
|
||||
const cached = childProxies.get(k);
|
||||
const cached = childProxies.get(key);
|
||||
if (cached?.target === value) return cached.proxy;
|
||||
const child = wrap(value, [...path, guard(norm(t, k))]);
|
||||
childProxies.set(k, { target: value, proxy: child });
|
||||
const rawSegment = norm(target, key);
|
||||
let segment: Seg;
|
||||
let childBlocked = blockedSegment;
|
||||
if (blockedSegment !== undefined) {
|
||||
if (typeof rawSegment === "symbol") throw new UnsafePathError(String(rawSegment));
|
||||
segment = rawSegment;
|
||||
} else if (
|
||||
typeof rawSegment === "string" &&
|
||||
RESERVED_SEGMENTS.has(rawSegment) &&
|
||||
Object.hasOwn(target, key)
|
||||
) {
|
||||
segment = rawSegment;
|
||||
childBlocked = rawSegment;
|
||||
} else segment = guard(rawSegment);
|
||||
const child = wrap(value, [...path, segment], childBlocked);
|
||||
childProxies.set(key, { target: value, proxy: child });
|
||||
return child;
|
||||
},
|
||||
|
||||
set(t, k, v, r) {
|
||||
// `length` is a real mutation but not a document location.
|
||||
if (Array.isArray(t) && k === "length") {
|
||||
const before = t.length;
|
||||
const next = Number(v);
|
||||
if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) return Reflect.set(t, k, v, r);
|
||||
set(target, key, value) {
|
||||
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
|
||||
if (Array.isArray(target) && key === "length") {
|
||||
const before = target.length;
|
||||
const next = Number(value);
|
||||
if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) {
|
||||
return Reflect.set(target, key, value);
|
||||
}
|
||||
if (next < before) {
|
||||
Reflect.set(t, k, next, r);
|
||||
const start = appendStart(path);
|
||||
if (next === 0) markArrayReplace(path);
|
||||
else if (start === undefined || next < start) markArrayDiff(path);
|
||||
Reflect.set(target, key, next);
|
||||
childProxies.clear();
|
||||
spliceOrReplace(path, before, next, before - next, []);
|
||||
} else if (next > before) {
|
||||
const items = Array<JsonValue>(next - before).fill(null);
|
||||
t.length = next;
|
||||
t.fill(null, before);
|
||||
emit(["p", [...path], before, 0, items]);
|
||||
markArrayAppend(path, before);
|
||||
target.length = next;
|
||||
target.fill(null, before);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const segment = guard(norm(t, k));
|
||||
if (Array.isArray(t)) {
|
||||
const segment = guard(norm(target, key));
|
||||
if (Array.isArray(target)) {
|
||||
if (typeof segment !== "number") throw new UnsafePathError(segment);
|
||||
if (segment > t.length) throw new UnsafePathError(segment);
|
||||
if (segment > target.length) throw new UnsafePathError(segment);
|
||||
}
|
||||
const at = [...path, segment] as unknown as NonEmptyPath;
|
||||
|
||||
if (v === undefined) {
|
||||
// JSON has no undefined, and deleting an array element would create a hole.
|
||||
if (Array.isArray(t)) throw new TypeError("undefined would create a sparse array; use splice instead");
|
||||
emit(["d", at]);
|
||||
childProxies.delete(k);
|
||||
return Reflect.deleteProperty(t, k);
|
||||
if (value === undefined) {
|
||||
if (Array.isArray(target)) {
|
||||
throw new TypeError("undefined would create a sparse array; use splice instead");
|
||||
}
|
||||
markValue(at);
|
||||
childProxies.delete(key);
|
||||
return Reflect.deleteProperty(target, key);
|
||||
}
|
||||
|
||||
const prev = (t as Record<string | symbol, unknown>)[k];
|
||||
if (typeof prev === "string" && typeof v === "string") {
|
||||
if (prev === v) return true;
|
||||
if (v.length > prev.length && v.startsWith(prev)) {
|
||||
emit(["a", at, v.slice(prev.length)]); // fast path, no probe
|
||||
} else {
|
||||
const ov = overlap(prev, v, scan);
|
||||
if (ov === 0) emit(["s", at, v]);
|
||||
else {
|
||||
emit(["t", at, prev.length - ov]);
|
||||
if (v.length > ov) emit(["a", at, v.slice(ov)]);
|
||||
}
|
||||
const previous = (target as Record<string | symbol, unknown>)[key];
|
||||
if (previous === value) return true;
|
||||
const cached = childProxies.get(key);
|
||||
if (cached !== undefined && cached.target === previous && cached.proxy === value) return true;
|
||||
assertNoTrackedProxy(value);
|
||||
assertJsonValue(value);
|
||||
if (Array.isArray(target)) {
|
||||
const index = segment as number;
|
||||
if (index === target.length) markArrayAppend(path, target.length);
|
||||
else {
|
||||
const start = appendStart(path);
|
||||
if (start === undefined || index < start) markValue(at);
|
||||
}
|
||||
} else {
|
||||
assertNoTrackedProxy(v);
|
||||
assertJsonValue(v);
|
||||
const stored = cloneJson(v);
|
||||
emit(["s", at, cloneJson(stored)]);
|
||||
childProxies.delete(k);
|
||||
return Reflect.set(t, k, stored, r);
|
||||
}
|
||||
childProxies.delete(k);
|
||||
return Reflect.set(t, k, v, r);
|
||||
} else markValue(at);
|
||||
childProxies.delete(key);
|
||||
return Reflect.set(target, key, value);
|
||||
},
|
||||
|
||||
deleteProperty(t, k) {
|
||||
const segment = guard(norm(t, k));
|
||||
if (Array.isArray(t)) {
|
||||
deleteProperty(target, key) {
|
||||
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
|
||||
const segment = guard(norm(target, key));
|
||||
if (Array.isArray(target)) {
|
||||
if (typeof segment !== "number") throw new UnsafePathError(segment);
|
||||
throw new TypeError("delete would create a sparse array; use splice instead");
|
||||
}
|
||||
emit(["d", [...path, segment] as unknown as NonEmptyPath]);
|
||||
childProxies.delete(k);
|
||||
return Reflect.deleteProperty(t, k);
|
||||
markValue([...path, segment]);
|
||||
childProxies.delete(key);
|
||||
return Reflect.deleteProperty(target, key);
|
||||
},
|
||||
|
||||
defineProperty() {
|
||||
throw new TypeError("defineProperty is not supported on tracked state; use assignment");
|
||||
},
|
||||
setPrototypeOf() {
|
||||
throw new TypeError("setPrototypeOf is not supported on tracked state");
|
||||
},
|
||||
preventExtensions() {
|
||||
throw new TypeError("preventExtensions is not supported on tracked state");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -381,195 +670,55 @@ export function track<T extends object>(root: T, options: TrackerOptions = {}):
|
|||
};
|
||||
|
||||
let state = wrap(root, []);
|
||||
// The first flush is always a base batch. A consumer starts with nothing, so
|
||||
// a stream that opens with deltas has nothing to apply them to — and that
|
||||
// fails at runtime, in the consumer, rather than where the mistake is.
|
||||
let forceBase = true;
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
// A setter, so the obvious thing works. Without it `tracker.state = next`
|
||||
// silently swaps the proxy for a plain object and stops tracking.
|
||||
set state(next: T) {
|
||||
if (next === state) {
|
||||
ops = [];
|
||||
clearPending();
|
||||
forceBase = true;
|
||||
return;
|
||||
}
|
||||
if (TRACKED_PROXIES.has(next)) throw new TypeError("tracker state cannot be replaced with a tracked proxy");
|
||||
if (TRACKED_PROXIES.has(next)) throw new TypeError("tracker state cannot be replaced with tracked state");
|
||||
assertNoTrackedProxy(next);
|
||||
validateJsonValue(next);
|
||||
ops = [];
|
||||
clearPending();
|
||||
root = next;
|
||||
state = wrap(root, []);
|
||||
baseline = undefined;
|
||||
forceBase = true;
|
||||
},
|
||||
get target() {
|
||||
return root;
|
||||
},
|
||||
|
||||
// Same effect as assigning the current value back, minus the re-wrap: the
|
||||
// value has not changed, so the proxy cache is still valid.
|
||||
rebase() {
|
||||
ops = [];
|
||||
clearPending();
|
||||
forceBase = true;
|
||||
},
|
||||
|
||||
get dirty() {
|
||||
return forceBase || ops.length > 0;
|
||||
return forceBase || hasPending;
|
||||
},
|
||||
discard() {
|
||||
ops = [];
|
||||
baseline = cloneJson(root as unknown as JsonValue);
|
||||
clearPending();
|
||||
},
|
||||
flush() {
|
||||
if (forceBase) {
|
||||
const value = cloneJson(root as unknown as JsonValue);
|
||||
baseline = cloneJson(root as unknown as JsonValue);
|
||||
forceBase = false;
|
||||
ops = [];
|
||||
return [["r", cloneJson(root as unknown as JsonValue)]];
|
||||
clearPending();
|
||||
return [["r", value]];
|
||||
}
|
||||
if (ops.length === 0) return [];
|
||||
let out = ops;
|
||||
ops = [];
|
||||
|
||||
// Everything before a replacement is dead.
|
||||
let lastReplace = -1;
|
||||
for (let index = out.length - 1; index >= 0; index--) {
|
||||
if (!isReplace(out[index]!)) continue;
|
||||
lastReplace = index;
|
||||
break;
|
||||
}
|
||||
if (lastReplace > 0) out = out.slice(lastReplace);
|
||||
|
||||
out = dropDead(out);
|
||||
return doCoalesce ? coalesce(out) : out;
|
||||
if (!hasPending || baseline === undefined) return [];
|
||||
const out: Op[] = [];
|
||||
walkDirty(baseline, root as unknown as JsonValue, pending, [], scan, out);
|
||||
clearPending();
|
||||
if (out.length > 0) baseline = apply(baseline, out.map(cloneOp));
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const samePath = (a: Op, b: Op): boolean => {
|
||||
const left = pathOf(a);
|
||||
const right = pathOf(b);
|
||||
if (left === undefined || right === undefined || left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
function pathOf(op: Op): Path | undefined {
|
||||
return op[0] === "r" ? undefined : op[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop ops that a later op makes unreachable.
|
||||
*
|
||||
* Ops apply in order, so an op is dead if a later one overwrites the whole
|
||||
* subtree it lives in. Only `s`, `d` and `r` do that — `a`, `t` and `p` modify
|
||||
* what is already there, so they never dominate.
|
||||
*
|
||||
* O(n * d): walk backwards keeping a map of overwritten paths, and for each op
|
||||
* test its own prefixes against that map. `d` is the path depth, which is small
|
||||
* and bounded. The obvious formulation — compare each op against every
|
||||
* dominator — is O(n^2 * d) and degrades on exactly the wide-flush input this
|
||||
* pass exists to clean up. Measured: 12-14 microseconds per op, flat from 100 to
|
||||
* 5000 ops, and 10001 redundant writes collapse to 2 emitted ops.
|
||||
*
|
||||
* OBJECT KEY ORDER IS NOT PRESERVED. A producer that does
|
||||
* `set c; set a; delete c; set c` leaves `c` last, because deleting and
|
||||
* reinserting moves it. The replica receives only the surviving `set c`, applies
|
||||
* it to a base where `c` never existed, and `c` lands first. Values are
|
||||
* identical — verified over 1823 randomised sequences with zero value
|
||||
* mismatches — but the serialised forms can differ.
|
||||
*
|
||||
* This is fine because nothing compares a replica against its producer: the
|
||||
* consumer holds the state and nobody diffs it back. The one visible case is a
|
||||
* consumer that iterates keys for display, where two views could order rows
|
||||
* differently. It requires a delete-and-recreate of the same key within one
|
||||
* flush. Do not hash or content-address a replicated value.
|
||||
*/
|
||||
function dropDead(ops: Op[]): Op[] {
|
||||
type Overwrite = { kind: "s" | "d"; path: Path };
|
||||
const overwritten = new Map<string, Overwrite>();
|
||||
const kept: Op[] = [];
|
||||
const rootKey = JSON.stringify([]);
|
||||
|
||||
for (let i = ops.length - 1; i >= 0; i--) {
|
||||
const op = ops[i]!;
|
||||
if (op[0] === "r") {
|
||||
kept.push(op);
|
||||
overwritten.clear();
|
||||
overwritten.set(rootKey, { kind: "s", path: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = opPath(op);
|
||||
if (path === undefined) {
|
||||
kept.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dead = overwritten.has(rootKey);
|
||||
for (let length = 1; length <= path.length && !dead; length++) {
|
||||
const overwrite = overwritten.get(JSON.stringify(path.slice(0, length)));
|
||||
if (overwrite === undefined) continue;
|
||||
// A `d` at exactly a later `s`'s path is NOT dead. Dropping it leaves the
|
||||
// key in its original slot, while the producer's delete-then-set moved it
|
||||
// to the end: same value, different serialisation.
|
||||
if (length === path.length && op[0] === "d" && overwrite.kind === "s") break;
|
||||
dead = true;
|
||||
}
|
||||
if (dead) continue;
|
||||
|
||||
kept.push(op);
|
||||
if (op[0] === "p") {
|
||||
// A splice can reindex every descendant, so path equality below it cannot
|
||||
// prove that a later overwrite dominates an earlier operation.
|
||||
for (const [key, overwrite] of overwritten) {
|
||||
if (
|
||||
overwrite.path.length > path.length &&
|
||||
path.every((segment, index) => overwrite.path[index] === segment)
|
||||
) {
|
||||
overwritten.delete(key);
|
||||
}
|
||||
}
|
||||
} else if (op[0] === "s" || op[0] === "d") {
|
||||
overwritten.set(JSON.stringify(path), { kind: op[0], path });
|
||||
}
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
|
||||
/** Total, because `Op` paths are always inline. */
|
||||
function opPath(op: Op): Path | undefined {
|
||||
return op[0] === "r" ? undefined : op[1];
|
||||
}
|
||||
|
||||
/** Merge adjacent ops describing the same path. Lossless. */
|
||||
function coalesce(ops: Op[]): Op[] {
|
||||
const out: Op[] = [];
|
||||
for (const op of ops) {
|
||||
const last = out[out.length - 1];
|
||||
if (last !== undefined && samePath(last, op)) {
|
||||
if (last[0] === "a" && op[0] === "a") {
|
||||
out[out.length - 1] = ["a", last[1], `${last[2]}${op[2]}`];
|
||||
continue;
|
||||
}
|
||||
if (last[0] === "s" && op[0] === "s") {
|
||||
// later write wins
|
||||
out[out.length - 1] = op;
|
||||
continue;
|
||||
}
|
||||
if (last[0] === "s" && op[0] === "a" && typeof last[2] === "string") {
|
||||
out[out.length - 1] = ["s", last[1], `${last[2]}${op[2]}`];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(op);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Path safety ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -613,18 +762,38 @@ export function assertJsonValue(value: unknown): asserts value is JsonValue {
|
|||
if (type !== "object") throw new TypeError(`${type} is not a JsonValue`);
|
||||
if (seen.has(candidate)) throw new TypeError("shared or cyclic object is not JSON state");
|
||||
seen.add(candidate);
|
||||
if (!Object.isExtensible(candidate)) throw new TypeError("non-extensible object is not mutable JSON state");
|
||||
if (Object.getOwnPropertySymbols(candidate).length > 0) throw new TypeError("symbol keys are not JSON");
|
||||
|
||||
const visitProperty = (key: string): void => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
||||
if (descriptor === undefined) throw new TypeError("sparse arrays are not JSON state");
|
||||
if (!("value" in descriptor)) throw new TypeError("accessor properties are not JSON state");
|
||||
if (!descriptor.enumerable || !descriptor.writable || !descriptor.configurable) {
|
||||
throw new TypeError("JSON state properties must be enumerable, writable, and configurable");
|
||||
}
|
||||
visit(descriptor.value, seen);
|
||||
};
|
||||
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const item of candidate) visit(item, seen);
|
||||
} else {
|
||||
const prototype = Object.getPrototypeOf(candidate);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("non-plain object is not a JsonValue");
|
||||
if (Object.getOwnPropertyDescriptor(candidate, "length")?.writable !== true) {
|
||||
throw new TypeError("array length must be writable");
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(candidate).length > 0) {
|
||||
throw new TypeError("symbol keys are not JSON");
|
||||
for (const key of Object.getOwnPropertyNames(candidate)) {
|
||||
if (key === "length") continue;
|
||||
if (!INDEX.test(key) || Number(key) >= candidate.length) {
|
||||
throw new TypeError("arrays cannot have non-index properties");
|
||||
}
|
||||
}
|
||||
for (const item of Object.values(candidate as object)) visit(item, seen);
|
||||
for (let index = 0; index < candidate.length; index++) visitProperty(String(index));
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(candidate);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("non-plain object is not a JsonValue");
|
||||
}
|
||||
for (const key of Object.getOwnPropertyNames(candidate)) visitProperty(key);
|
||||
};
|
||||
visit(value, new Set());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,20 @@ describe("tracker: intent", () => {
|
|||
expect(t.flush()).toEqual([["d", ["a"]]]);
|
||||
});
|
||||
|
||||
it("uses absence and delete for optional object properties", () => {
|
||||
type Settings = { something?: string; foo: number };
|
||||
const t = track<Settings>({ foo: 1 });
|
||||
let replica = apply<Settings>(undefined, t.flush());
|
||||
expect(t.state.something).toBeUndefined();
|
||||
t.state.something = "enabled";
|
||||
replica = apply(replica, t.flush());
|
||||
expect(replica).toEqual({ foo: 1, something: "enabled" });
|
||||
t.state.something = undefined;
|
||||
const ops = t.flush();
|
||||
expect(ops).toEqual([["d", ["something"]]]);
|
||||
expect(apply(replica, ops)).toEqual({ foo: 1 });
|
||||
});
|
||||
|
||||
it("round-trips interleaved writes", () => {
|
||||
const initial = { out: "x".repeat(500), total: 0 };
|
||||
const t = track(structuredClone(initial));
|
||||
|
|
@ -82,7 +96,41 @@ describe("tracker: intent", () => {
|
|||
t.state.total += 10;
|
||||
}
|
||||
const ops = t.flush();
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.target);
|
||||
expect(ops.length).toBeLessThanOrEqual(3);
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("deep-diffs reassigned objects", () => {
|
||||
const initial = { message: { content: [{ text: "hello" }], count: 0 } };
|
||||
const t = track(structuredClone(initial));
|
||||
t.flush();
|
||||
t.state.message = { content: [{ text: "hello world" }], count: 1 };
|
||||
expect(t.flush()).toEqual([
|
||||
["a", ["message", "content", 0, "text"], " world"],
|
||||
["s", ["message", "count"], 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("deep-diffs retained array edits combined with append in a replacement", () => {
|
||||
const initial = { view: { messages: [{ text: "a" }, { text: "b" }] } };
|
||||
const t = track(structuredClone(initial));
|
||||
t.flush();
|
||||
t.state.view = { messages: [{ text: "ax" }, { text: "b" }, { text: "c" }] };
|
||||
const ops = t.flush();
|
||||
expect(ops).toEqual([
|
||||
["a", ["view", "messages", 0, "text"], "x"],
|
||||
["p", ["view", "messages"], 2, 0, [{ text: "c" }]],
|
||||
]);
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("emits nothing when a replacement is deeply equal", () => {
|
||||
const t = track({ value: { nested: [1, { text: "same" }] } });
|
||||
t.flush();
|
||||
t.state.value = { nested: [1, { text: "same" }] };
|
||||
expect(t.dirty).toBe(true);
|
||||
expect(t.flush()).toEqual([]);
|
||||
expect(t.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates a pending child when its parent is overwritten", () => {
|
||||
|
|
@ -91,7 +139,7 @@ describe("tracker: intent", () => {
|
|||
t.flush();
|
||||
t.state.a.b = 99;
|
||||
t.state.a = { c: 2 };
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -126,8 +174,8 @@ describe("tracker: root ops", () => {
|
|||
const t = track(structuredClone(initial));
|
||||
t.flush();
|
||||
(t.state.xs.splice as (...args: never[]) => number[])();
|
||||
expect(t.target).toEqual(initial);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(t.state).toEqual(initial);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("normalises splice arguments to integers", () => {
|
||||
|
|
@ -142,7 +190,7 @@ describe("tracker: root ops", () => {
|
|||
expect(Number.isInteger(op[2])).toBe(true);
|
||||
expect(Number.isInteger(op[3])).toBe(true);
|
||||
}
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("normalises length = 0 on the root", () => {
|
||||
|
|
@ -160,7 +208,7 @@ describe("tracker: root ops", () => {
|
|||
t.flush();
|
||||
for (let i = 2; i <= 100; i++) t.state.xs.push(i);
|
||||
const ops = t.flush();
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("does not fold a child path across a parent splice", () => {
|
||||
|
|
@ -170,7 +218,7 @@ describe("tracker: root ops", () => {
|
|||
t.state.xs[0] += "x";
|
||||
t.state.xs.shift();
|
||||
t.state.xs[0] += "y";
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("drops child ops when repeated parent splices collapse to a snapshot", () => {
|
||||
|
|
@ -180,7 +228,7 @@ describe("tracker: root ops", () => {
|
|||
t.state.xs.push("q");
|
||||
t.state.xs[0] += "cd";
|
||||
t.state.xs.push("z");
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("does not let a post-splice set dominate an earlier element append", () => {
|
||||
|
|
@ -190,7 +238,7 @@ describe("tracker: root ops", () => {
|
|||
t.state.xs[0] += "x";
|
||||
t.state.xs.unshift("q");
|
||||
t.state.xs[0] = "Z";
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("does not discard a nested append through a reindexed set", () => {
|
||||
|
|
@ -200,7 +248,7 @@ describe("tracker: root ops", () => {
|
|||
(t.state.xs[0] as { k: string }).k += "x";
|
||||
t.state.xs.unshift(9);
|
||||
t.state.xs[0] = 7;
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("preserves element operations across middle-array insertions", () => {
|
||||
|
|
@ -210,7 +258,7 @@ describe("tracker: root ops", () => {
|
|||
t.state.xs[1] += "x";
|
||||
t.state.xs.splice(1, 0, "inserted");
|
||||
t.state.xs[1] = "changed";
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("round-trips deterministic element mutations across reindexing splices", () => {
|
||||
|
|
@ -246,7 +294,7 @@ describe("tracker: root ops", () => {
|
|||
}
|
||||
if (t.state.xs.length > 10) t.state.xs.shift();
|
||||
}
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -255,7 +303,7 @@ describe("tracker: root ops", () => {
|
|||
const t = track(structuredClone(initial));
|
||||
t.flush();
|
||||
t.state.xs.sort().push(4);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("detaches objects supplied to fill", () => {
|
||||
|
|
@ -264,18 +312,77 @@ describe("tracker: root ops", () => {
|
|||
t.flush();
|
||||
t.state.xs.fill(supplied);
|
||||
supplied.value = 2;
|
||||
expect(t.target.xs).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(t.target.xs[0]).not.toBe(t.target.xs[1]);
|
||||
expect(apply({ xs: [{ value: 0 }, { value: 0 }] }, t.flush())).toEqual(t.target);
|
||||
expect(t.state.xs).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(t.state.xs[0]).not.toBe(t.state.xs[1]);
|
||||
expect(apply({ xs: [{ value: 0 }, { value: 0 }] }, t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("keeps copyWithin results as a JSON tree", () => {
|
||||
const t = track({ xs: [{ value: 1 }, { value: 2 }] });
|
||||
t.flush();
|
||||
t.state.xs.copyWithin(1, 0, 1);
|
||||
expect(t.target.xs).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(t.target.xs[0]).not.toBe(t.target.xs[1]);
|
||||
expect(apply({ xs: [{ value: 1 }, { value: 2 }] }, t.flush())).toEqual(t.target);
|
||||
expect(t.state.xs).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(t.state.xs[0]).not.toBe(t.state.xs[1]);
|
||||
expect(apply({ xs: [{ value: 1 }, { value: 2 }] }, t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("combines retained element edits with one append", () => {
|
||||
const initial = { messages: [{ text: "a" }, { text: "b" }], flag: 0 };
|
||||
const t = track(structuredClone(initial));
|
||||
t.flush();
|
||||
t.state.messages[0]!.text += "x";
|
||||
t.state.flag = 1;
|
||||
t.state.messages.push({ text: "c" });
|
||||
t.state.messages[1]!.text += "y";
|
||||
t.state.messages[2]!.text += "z";
|
||||
const ops = t.flush();
|
||||
expect(ops.filter((op) => op[0] === "p")).toEqual([["p", ["messages"], 2, 0, [{ text: "cz" }]]]);
|
||||
expect(ops.some((op) => op[0] === "s" && op[1].length === 1 && op[1][0] === "messages")).toBe(false);
|
||||
expect(ops.some((op) => op[0] !== "r" && op[1].includes(2))).toBe(false);
|
||||
expect(apply(structuredClone(initial), ops)).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("collapses many pushes into one append", () => {
|
||||
const t = track({ xs: [1] });
|
||||
t.flush();
|
||||
for (let value = 2; value <= 100; value++) t.state.xs.push(value);
|
||||
expect(t.flush()).toEqual([["p", ["xs"], 1, 0, Array.from({ length: 99 }, (_, index) => index + 2)]]);
|
||||
});
|
||||
|
||||
it("keeps direct tail writes and length growth in append mode", () => {
|
||||
const t = track({ xs: [1] as JsonValue[] });
|
||||
t.flush();
|
||||
t.state.xs[1] = { value: 2 };
|
||||
(t.state.xs[1] as { value: number }).value = 3;
|
||||
t.state.xs.length = 4;
|
||||
expect(t.flush()).toEqual([["p", ["xs"], 1, 0, [{ value: 3 }, null, null]]]);
|
||||
});
|
||||
|
||||
it("keeps tail-only splices in append mode", () => {
|
||||
const t = track({ xs: [{ value: 1 }] });
|
||||
t.flush();
|
||||
t.state.xs.push({ value: 2 }, { value: 3 });
|
||||
t.state.xs.splice(1, 1, { value: 4 });
|
||||
t.state.xs[2]!.value = 5;
|
||||
expect(t.flush()).toEqual([["p", ["xs"], 1, 0, [{ value: 4 }, { value: 5 }]]]);
|
||||
});
|
||||
|
||||
it("forgets append-tail mutations that cancel out", () => {
|
||||
const t = track({ xs: [1] });
|
||||
t.flush();
|
||||
t.state.xs.push(2);
|
||||
t.state.xs.push(3);
|
||||
t.state.xs.pop();
|
||||
t.state.xs.pop();
|
||||
expect(t.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts large append argument lists without spreading them internally", () => {
|
||||
const t = track({ xs: [] as JsonValue[] });
|
||||
t.flush();
|
||||
const items = Array<JsonValue>(100_000).fill(null);
|
||||
expect(Reflect.apply(t.state.xs.push, t.state.xs, items)).toBe(items.length);
|
||||
expect(t.flush()).toEqual([["p", ["xs"], 0, 0, items]]);
|
||||
});
|
||||
|
||||
it("grows arrays with explicit null values", () => {
|
||||
|
|
@ -283,8 +390,8 @@ describe("tracker: root ops", () => {
|
|||
const t = track(structuredClone(initial));
|
||||
t.flush();
|
||||
t.state.xs.length = 4;
|
||||
expect(t.target.xs).toEqual([1, null, null, null]);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.target);
|
||||
expect(t.state.xs).toEqual([1, null, null, null]);
|
||||
expect(apply(structuredClone(initial), t.flush())).toEqual(t.state);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -300,7 +407,7 @@ describe("replacing the whole value", () => {
|
|||
const ops = t.flush();
|
||||
expect(ops).toEqual([["r", { r: 9, s: "new" }]]);
|
||||
expect(isBase(ops)).toBe(true);
|
||||
expect(apply({}, ops)).toEqual(t.target);
|
||||
expect(apply({}, ops)).toEqual(t.state);
|
||||
|
||||
t.state.r = 10; // the new value is tracked
|
||||
expect(t.flush()).toEqual([["s", ["r"], 10]]);
|
||||
|
|
@ -324,6 +431,14 @@ describe("replacing the whole value", () => {
|
|||
}).toThrow();
|
||||
});
|
||||
|
||||
it("allows a tracked child self-assignment as a no-op", () => {
|
||||
const t = track({ child: { value: 1 } });
|
||||
t.flush();
|
||||
const child = t.state.child;
|
||||
t.state.child = child;
|
||||
expect(t.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects assigning a nested tracked proxy into state", () => {
|
||||
const t = track<{
|
||||
left: Record<string, number>;
|
||||
|
|
@ -334,16 +449,16 @@ describe("replacing the whole value", () => {
|
|||
t.flush();
|
||||
expect(() => {
|
||||
t.state.right = t.state.left;
|
||||
}).toThrow(/tracked prox/);
|
||||
}).toThrow(/state prox/);
|
||||
expect(() => {
|
||||
t.state.wrapped = { inner: t.state.left };
|
||||
}).toThrow(/tracked prox/);
|
||||
}).toThrow(/state prox/);
|
||||
t.state.items = [];
|
||||
expect(() => t.state.items?.push(t.state.left)).toThrow(/tracked prox/);
|
||||
expect(() => t.state.items?.push(t.state.left)).toThrow(/state prox/);
|
||||
const other = track({ value: 2 });
|
||||
expect(() => {
|
||||
t.state.wrapped = { inner: other.state as unknown as Record<string, number> };
|
||||
}).toThrow(/tracked prox/);
|
||||
}).toThrow(/state prox/);
|
||||
});
|
||||
|
||||
it("assigning to state discards ops recorded before it", () => {
|
||||
|
|
@ -373,7 +488,7 @@ describe("replacing the whole value", () => {
|
|||
t.rebase();
|
||||
const ops = t.flush();
|
||||
expect(isBase(ops)).toBe(true);
|
||||
expect(apply({}, ops)).toEqual(t.target);
|
||||
expect(apply({}, ops)).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("rebase() applies once, not to every later flush", () => {
|
||||
|
|
@ -415,14 +530,16 @@ describe("the first flush", () => {
|
|||
expect(t.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discard clears pending deltas", () => {
|
||||
const t = track({ x: 0 });
|
||||
it("discard accepts pending changes into the local baseline", () => {
|
||||
const t = track({ x: 0, y: 0 });
|
||||
t.flush();
|
||||
t.state.x = 1;
|
||||
expect(t.dirty).toBe(true);
|
||||
t.discard();
|
||||
expect(t.dirty).toBe(false);
|
||||
expect(t.flush()).toEqual([]);
|
||||
t.state.y = 1;
|
||||
expect(t.flush()).toEqual([["s", ["y"], 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -450,16 +567,14 @@ describe("apply and fan-out", () => {
|
|||
expect(replica.x).toBe(0);
|
||||
});
|
||||
|
||||
it("does not preserve aliases assigned to separate paths", () => {
|
||||
const t = track<{ a?: { value: number }; b?: { value: number } }>({});
|
||||
const replica = apply<{ a?: { value: number }; b?: { value: number } }>(undefined, t.flush());
|
||||
const shared = { value: 0 };
|
||||
t.state.a = shared;
|
||||
t.state.b = shared;
|
||||
t.state.a.value = 1;
|
||||
const next = apply(replica, t.flush());
|
||||
expect(next).toEqual(t.target);
|
||||
expect(t.target.b?.value).toBe(0);
|
||||
it("adopts assigned values without cloning them", () => {
|
||||
const t = track<{ item?: { value: number } }>({});
|
||||
const replica = apply<{ item?: { value: number } }>(undefined, t.flush());
|
||||
const supplied = { value: 0 };
|
||||
t.state.item = supplied;
|
||||
t.state.item.value = 1;
|
||||
expect(supplied.value).toBe(1);
|
||||
expect(apply(replica, t.flush())).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("does not alias pushed values with an in-process consumer", () => {
|
||||
|
|
@ -468,7 +583,7 @@ describe("apply and fan-out", () => {
|
|||
t.state.xs.push({ value: 1 });
|
||||
const next = apply(replica, t.flush());
|
||||
next.xs[0]!.value = 2;
|
||||
expect(t.target.xs[0]!.value).toBe(1);
|
||||
expect(t.state.xs[0]!.value).toBe(1);
|
||||
});
|
||||
|
||||
it("folds a whole stream without a base-batch branch", () => {
|
||||
|
|
@ -487,13 +602,13 @@ describe("apply and fan-out", () => {
|
|||
send();
|
||||
t.state.x = 101;
|
||||
send();
|
||||
expect(replica).toEqual(t.target);
|
||||
expect(replica).toEqual(t.state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dead-op elimination", () => {
|
||||
// Ops apply in order, so an op is dead if a later one overwrites the whole
|
||||
// subtree it lives in. Only s/d/r dominate; a/t/p modify what is there.
|
||||
describe("flush-time minimization", () => {
|
||||
// Mutations only mark dirty paths. Flush compares the last published baseline
|
||||
// with the current value, so repeated writes never accumulate pending ops.
|
||||
type DeadOpState = { a: Record<string, JsonValue>; x?: number; y?: number };
|
||||
const run = (mutate: (state: DeadOpState) => void, init: DeadOpState = { a: { b: 1 }, x: 1, y: 2 }) => {
|
||||
const t = track(structuredClone(init));
|
||||
|
|
@ -531,19 +646,19 @@ describe("dead-op elimination", () => {
|
|||
s.a.b = 99;
|
||||
s.a = { c: 5 };
|
||||
}),
|
||||
).toEqual([["s", ["a"], { c: 5 }]]);
|
||||
).toEqual([
|
||||
["s", ["a", "c"], 5],
|
||||
["d", ["a", "b"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a child write that follows the parent replacement", () => {
|
||||
it("folds a child write into its pending parent replacement", () => {
|
||||
expect(
|
||||
run((s) => {
|
||||
s.a = { b: 1 };
|
||||
s.a.b = 7;
|
||||
}),
|
||||
).toEqual([
|
||||
["s", ["a"], { b: 1 }],
|
||||
["s", ["a", "b"], 7],
|
||||
]);
|
||||
).toEqual([["s", ["a", "b"], 7]]);
|
||||
});
|
||||
|
||||
it("collapses set-then-delete to the delete", () => {
|
||||
|
|
@ -555,30 +670,23 @@ describe("dead-op elimination", () => {
|
|||
).toEqual([["d", ["x"]]]);
|
||||
});
|
||||
|
||||
it("keeps delete-then-set, to preserve key position", () => {
|
||||
// Dropping the delete would leave the key in its original slot while the
|
||||
// producer moved it to the end. Same value, different serialisation.
|
||||
it("collapses delete-then-set to the final value", () => {
|
||||
// Delta replication preserves JSON values, not object insertion order.
|
||||
expect(
|
||||
run((s) => {
|
||||
delete s.x;
|
||||
s.x = 5;
|
||||
}),
|
||||
).toEqual([
|
||||
["d", ["x"]],
|
||||
["s", ["x"], 5],
|
||||
]);
|
||||
).toEqual([["s", ["x"], 5]]);
|
||||
});
|
||||
|
||||
it("keeps the delete when a later append folds into the set", () => {
|
||||
it("derives an append after delete-then-recreate", () => {
|
||||
const t = track<{ x?: string; y: number }>({ x: "", y: 1 });
|
||||
t.flush();
|
||||
delete t.state.x;
|
||||
t.state.x = "ab";
|
||||
t.state.x += "cd";
|
||||
expect(t.flush()).toEqual([
|
||||
["d", ["x"]],
|
||||
["s", ["x"], "abcd"],
|
||||
]);
|
||||
expect(t.flush()).toEqual([["a", ["x"], "abcd"]]);
|
||||
});
|
||||
|
||||
it("is linear in the number of ops", () => {
|
||||
|
|
@ -608,7 +716,9 @@ describe("dead-op elimination", () => {
|
|||
t.state.a.b = i;
|
||||
}
|
||||
t.state.a = { done: true };
|
||||
expect(t.flush()).toHaveLength(2);
|
||||
const ops = t.flush();
|
||||
expect(ops).toHaveLength(3);
|
||||
expect(apply({ a: { b: 1 }, x: 1 }, ops)).toEqual(t.state);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -668,10 +778,14 @@ describe("flush", () => {
|
|||
expect(out).toBeDefined();
|
||||
});
|
||||
|
||||
it("clones reserved value keys without invoking prototype setters", () => {
|
||||
it("clones and reads reserved value keys without invoking prototype setters", () => {
|
||||
const t = track({ value: JSON.parse('{"__proto__":{"z":1}}') as JsonValue });
|
||||
const out = apply<{ value: JsonValue }>(undefined, t.flush());
|
||||
expect(Object.hasOwn(out.value as object, "__proto__")).toBe(true);
|
||||
expect(JSON.parse(JSON.stringify(t.state))).toEqual({ value: JSON.parse('{"__proto__":{"z":1}}') });
|
||||
expect(() => {
|
||||
((t.state.value as Record<string, JsonValue>).__proto__ as Record<string, JsonValue>).z = 2;
|
||||
}).toThrow(/unsafe path/);
|
||||
expect(({} as Record<string, unknown>).z).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -703,7 +817,7 @@ describe("safety: array indices", () => {
|
|||
expect(() => {
|
||||
t.state.xs[5] = 9;
|
||||
}).toThrow();
|
||||
expect(t.target.xs).toEqual([1, 2, 3]);
|
||||
expect(t.state.xs).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("rejects array deletes at the producer", () => {
|
||||
const t = track({ xs: [1, 2, 3] });
|
||||
|
|
@ -711,7 +825,7 @@ describe("safety: array indices", () => {
|
|||
expect(() => {
|
||||
delete t.state.xs[1];
|
||||
}).toThrow();
|
||||
expect(t.target.xs).toEqual([1, 2, 3]);
|
||||
expect(t.state.xs).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("rejects undefined array elements at the producer", () => {
|
||||
const t = track<{ xs: (number | undefined)[] }>({ xs: [1, 2, 3] });
|
||||
|
|
@ -719,7 +833,7 @@ describe("safety: array indices", () => {
|
|||
expect(() => {
|
||||
t.state.xs[1] = undefined;
|
||||
}).toThrow();
|
||||
expect(t.target.xs).toEqual([1, 2, 3]);
|
||||
expect(t.state.xs).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("allows explicit growth with nulls", () => {
|
||||
expect(apply({ xs: [1] }, [["p", ["xs"], 1, 0, [null, null, 9]]])).toEqual({ xs: [1, null, null, 9] });
|
||||
|
|
@ -803,15 +917,54 @@ describe("safety: what a facet can put on the wire", () => {
|
|||
const t = track<{ xs: JsonValue[] }>({ xs: [] });
|
||||
t.flush();
|
||||
expect(() => t.state.xs.push(new Map() as unknown as JsonValue)).toThrow();
|
||||
expect(t.target.xs).toEqual([]);
|
||||
expect(t.state.xs).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows a getter on state, recording the computed result", () => {
|
||||
it("rejects duplicate objects inserted in one array call", () => {
|
||||
const t = track<{ xs: JsonValue[] }>({ xs: [] });
|
||||
t.flush();
|
||||
const shared = { value: 1 };
|
||||
expect(() => t.state.xs.push(shared, shared)).toThrow(/shared/);
|
||||
expect(t.state.xs).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects accessors without invoking them", () => {
|
||||
const t = track<Record<string, unknown>>({});
|
||||
t.flush();
|
||||
let reads = 0;
|
||||
const supplied = Object.defineProperty({}, "value", {
|
||||
get() {
|
||||
reads++;
|
||||
return 1;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(() => {
|
||||
t.state.value = supplied;
|
||||
}).toThrow(/accessor/);
|
||||
expect(reads).toBe(0);
|
||||
expect(t.state).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects frozen and otherwise non-extensible values", () => {
|
||||
const t = track<Record<string, unknown>>({});
|
||||
t.flush();
|
||||
expect(() => {
|
||||
t.state.frozen = Object.freeze({ nested: { value: 1 } });
|
||||
}).toThrow(/non-extensible/);
|
||||
expect(() => track(Object.freeze({ value: 1 }))).toThrow(/non-extensible/);
|
||||
expect(t.state).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects descriptor and object-shape bypasses", () => {
|
||||
const t = track<Record<string, unknown>>({ pad: PAD });
|
||||
t.flush(); // drain the base batch
|
||||
Object.defineProperty(t.target, "g", { get: () => 42, enumerable: true });
|
||||
t.state.x = 1;
|
||||
expect(t.flush()).toEqual([["s", ["x"], 1]]);
|
||||
t.flush();
|
||||
expect(() => Object.defineProperty(t.state, "g", { value: 42, enumerable: true })).toThrow(/assignment/);
|
||||
expect(() => Object.setPrototypeOf(t.state, null)).toThrow(/setPrototypeOf/);
|
||||
expect(() => Object.preventExtensions(t.state)).toThrow(/preventExtensions/);
|
||||
expect(t.state).toEqual({ pad: PAD });
|
||||
expect(t.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a value that merely looks like an op", () => {
|
||||
|
|
@ -979,7 +1132,7 @@ describe("codec: path interning and arity omission", () => {
|
|||
const ops = dec.decode(w);
|
||||
replica = replica === undefined ? structuredClone((ops[0] as ["r", unknown])[1]) : apply(replica, ops);
|
||||
}
|
||||
expect(replica).toEqual(t.target);
|
||||
expect(replica).toEqual(t.state);
|
||||
});
|
||||
|
||||
it("round-trips random streams", () => {
|
||||
|
|
@ -1007,6 +1160,78 @@ describe("codec: path interning and arity omission", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("property: flush-time tracking", () => {
|
||||
it("converges across mixed nested writes, replacements, and array mutations", () => {
|
||||
type State = { rows: { text: string; count: number }[]; meta: { revision: number } };
|
||||
let seed = 0x5eed1234;
|
||||
const next = (): number => {
|
||||
seed = (seed * 1_664_525 + 1_013_904_223) >>> 0;
|
||||
return seed;
|
||||
};
|
||||
for (let round = 0; round < 100; round++) {
|
||||
const initial: State = {
|
||||
rows: [
|
||||
{ text: "a", count: 0 },
|
||||
{ text: "b", count: 0 },
|
||||
],
|
||||
meta: { revision: 0 },
|
||||
};
|
||||
const tracker = track(structuredClone(initial));
|
||||
const enc = encoder();
|
||||
const dec = decoder();
|
||||
let replica = apply<State>(undefined, dec.decode(enc.encode(tracker.flush())));
|
||||
for (let step = 0; step < 60; step++) {
|
||||
const rows = tracker.state.rows;
|
||||
switch (next() % 10) {
|
||||
case 0:
|
||||
if (rows.length > 0) rows[next() % rows.length]!.text += String.fromCharCode(97 + (next() % 26));
|
||||
break;
|
||||
case 1:
|
||||
if (rows.length > 0) rows[next() % rows.length]!.count++;
|
||||
break;
|
||||
case 2:
|
||||
rows.push({ text: `tail-${round}-${step}`, count: step });
|
||||
break;
|
||||
case 3:
|
||||
if (rows.length > 0) rows.pop();
|
||||
break;
|
||||
case 4:
|
||||
rows.unshift({ text: `head-${round}-${step}`, count: step });
|
||||
break;
|
||||
case 5:
|
||||
if (rows.length > 0) rows.shift();
|
||||
break;
|
||||
case 6: {
|
||||
const index = next() % (rows.length + 1);
|
||||
const remove = rows.length > 0 && next() % 2 === 0 ? 1 : 0;
|
||||
rows.splice(index, remove, { text: `mid-${round}-${step}`, count: step });
|
||||
break;
|
||||
}
|
||||
case 7: {
|
||||
const replacement = JSON.parse(JSON.stringify(rows)) as State["rows"];
|
||||
if (replacement.length > 0) replacement[0]!.text += "r";
|
||||
if (next() % 2 === 0) replacement.push({ text: "replacement-tail", count: step });
|
||||
tracker.state.rows = replacement;
|
||||
break;
|
||||
}
|
||||
case 8:
|
||||
tracker.state.meta = { revision: tracker.state.meta.revision + 1 };
|
||||
break;
|
||||
default:
|
||||
if (rows.length > 1) rows.reverse();
|
||||
}
|
||||
if (tracker.state.rows.length > 12) tracker.state.rows.splice(0, tracker.state.rows.length - 12);
|
||||
if (next() % 5 === 0) {
|
||||
replica = apply(replica, dec.decode(enc.encode(tracker.flush())));
|
||||
expect(replica).toEqual(tracker.state);
|
||||
}
|
||||
}
|
||||
replica = apply(replica, dec.decode(enc.encode(tracker.flush())));
|
||||
expect(replica).toEqual(tracker.state);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: random round-trip", () => {
|
||||
// 3000 random sequences found the root-splice hole on the first run, before any
|
||||
// hand-written case did.
|
||||
|
|
@ -1035,7 +1260,7 @@ describe("property: random round-trip", () => {
|
|||
for (const k of Object.keys(s)) if (!(k in next)) delete s[k];
|
||||
for (const [k, v] of Object.entries(next)) s[k] = v;
|
||||
} else continue;
|
||||
expect(apply(structuredClone(base), t.flush())).toEqual(t.target);
|
||||
expect(apply(structuredClone(base), t.flush())).toEqual(t.state);
|
||||
checked++;
|
||||
}
|
||||
// Most random pairs are shape-incompatible and skipped; this only guards
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue