Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
739 lines
26 KiB
TypeScript
739 lines
26 KiB
TypeScript
import type { JsonValue } from "../json.ts";
|
|
|
|
export type { JsonValue } from "../json.ts";
|
|
|
|
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// chord/delta — immutable revision tracking and operations over plain JSON.
|
|
//
|
|
// Depends on nothing else in the harness. Session storage, the runtime and the
|
|
// facet host consume it; keep the arrows pointing that way.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type Seg = string | number;
|
|
export type Path = readonly Seg[];
|
|
export type NonEmptyPath = readonly [Seg, ...Seg[]];
|
|
|
|
/** A path inline, or an id assigned by the encoder on second use. */
|
|
export type PathRef<P extends Path = Path> = P | number;
|
|
|
|
/**
|
|
* Tuples are the form — in memory, on the wire, on disk.
|
|
*
|
|
* `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target
|
|
* the root: the type forbids it. `p` and `m` may, because a tracked value can
|
|
* itself be an array. Operation shape is not canonical: an array may be emptied by either
|
|
* a replacement or a root splice.
|
|
*
|
|
* `Op` knows nothing about the path dictionary. Interning, id references and
|
|
* omitted paths live in `WireOp` and exist only between `encode` and `decode`.
|
|
*/
|
|
export type Op =
|
|
| readonly ["r", JsonValue]
|
|
| readonly ["s", NonEmptyPath, JsonValue]
|
|
| readonly ["d", NonEmptyPath]
|
|
| readonly ["a", NonEmptyPath, string]
|
|
| readonly ["t", NonEmptyPath, number]
|
|
| readonly ["p", Path, number, number, JsonValue[]]
|
|
/** Reorder an array in place: `new[i] = old[permutation[i]]`. */
|
|
| readonly ["m", Path, number[]];
|
|
|
|
/**
|
|
* What crosses a boundary. Adds two compressions and nothing else:
|
|
*
|
|
* ["#", id, path] defines an id, emitted on a path's SECOND use
|
|
* a numeric PathRef references a previously defined id
|
|
* a shortened tuple reuses the previous op's path; arity disambiguates
|
|
*
|
|
* ["r", value] carries no path, so it encodes to itself — which is why isBase
|
|
* works unchanged on either vocabulary.
|
|
*/
|
|
export type WireOp =
|
|
| readonly ["r", JsonValue]
|
|
| readonly ["s", PathRef<NonEmptyPath>, JsonValue]
|
|
| readonly ["s", JsonValue]
|
|
| readonly ["d", PathRef<NonEmptyPath>]
|
|
| readonly ["d"]
|
|
| readonly ["a", PathRef<NonEmptyPath>, string]
|
|
| readonly ["a", string]
|
|
| readonly ["t", PathRef<NonEmptyPath>, number]
|
|
| readonly ["t", number]
|
|
| readonly ["p", PathRef, number, number, JsonValue[]]
|
|
| readonly ["p", number, number, JsonValue[]]
|
|
| readonly ["m", PathRef, number[]]
|
|
| readonly ["m", number[]]
|
|
| readonly ["#", number, Path];
|
|
|
|
// ─── Classification ──────────────────────────────────────────────────────────
|
|
|
|
export const isReplace = (op: Op | WireOp): boolean => op[0] === "r";
|
|
|
|
/**
|
|
* A batch begins with a replacement. Flush guarantees `r` is at index 0 or absent,
|
|
* so this is exact rather than a heuristic.
|
|
*/
|
|
export const isBase = (ops: readonly (Op | WireOp)[]): boolean => ops.length > 0 && ops[0]![0] === "r";
|
|
|
|
// ─── Overlap ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Longest suffix of `a` that is a prefix of `b`. Probes with indexOf and verifies
|
|
* exact substring equality, so the hot loops are native. A hand-written KMP is
|
|
* asymptotically equivalent and much slower in practice.
|
|
*
|
|
* Always correct: the returned n satisfies a.slice(a.length - n) === b.slice(0, n).
|
|
*/
|
|
export function overlap(a: string, b: string, scan: number, probe = 64, maxCandidates = 8): number {
|
|
if (a.length === 0 || b.length === 0 || scan === 0) return 0;
|
|
const tail = a.length > scan ? a.slice(a.length - scan) : a;
|
|
|
|
// A probe of length h can only find overlaps of at least h — the head must
|
|
// actually occur in `a`. So try a long head first (few candidates, and it
|
|
// catches the large overlaps a rolling window produces), then fall back to one
|
|
// character, which finds any overlap at the cost of more candidates.
|
|
//
|
|
// Candidates are bounded because repetitive output — a build log, or any run of
|
|
// one character — makes a long head match at thousands of positions. Giving up
|
|
// returns 0, which emits a set: larger, never wrong.
|
|
for (const h of [Math.min(probe, b.length), 1]) {
|
|
const head = b.slice(0, h);
|
|
let tried = 0;
|
|
for (let k = tail.indexOf(head); k !== -1; k = tail.indexOf(head, k + 1)) {
|
|
if (++tried > maxCandidates) break;
|
|
const n = tail.length - k;
|
|
if (n <= b.length && tail.slice(k) === b.slice(0, n)) return n;
|
|
}
|
|
if (h === 1) break;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// ─── Immutable revision tracking ─────────────────────────────────────────────
|
|
|
|
export { diffRevisions } from "./diff.ts";
|
|
export type { Draft } from "./draft.ts";
|
|
export { type Change, type Prepared, type Tracker, track } from "./tracker.ts";
|
|
|
|
// ─── Path safety ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Segments that reach the prototype chain.
|
|
*
|
|
* `JSON.parse` is safe on its own — it makes `__proto__` an own property. What is
|
|
* not safe is `parent[key] = value`, which is exactly what an applier does, and
|
|
* paths are data: `["s", ["__proto__", "isAdmin"], true]` pollutes
|
|
* `Object.prototype` for the whole process.
|
|
*
|
|
* Ops arrive from a facet, a plugin compartment, or a tool whose details may echo
|
|
* model output, so none of it is trusted input.
|
|
*/
|
|
export const RESERVED_SEGMENTS: ReadonlySet<string> = new Set(["__proto__", "constructor", "prototype"]);
|
|
|
|
export class UnsafePathError extends Error {
|
|
// Not a parameter property: Node's --experimental-strip-types rejects those,
|
|
// and these files are meant to run under it directly.
|
|
readonly segment: Seg;
|
|
constructor(segment: Seg) {
|
|
super(`unsafe path segment: ${String(segment)}`);
|
|
this.segment = segment;
|
|
this.name = "UnsafePathError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verb, arity and payload shape for a **decoded** op: paths inline, no `#`, no
|
|
* short forms. `apply` uses this.
|
|
*
|
|
* Validating `Op` against the wire grammar would be laxer than the type: a
|
|
* two-element `["s", value]` would pass, and `apply` would then read the value as
|
|
* a path. Each vocabulary gets the validator that matches it.
|
|
*/
|
|
export function assertValidOp(op: unknown): asserts op is Op {
|
|
if (!Array.isArray(op) || op.length === 0) throw new TypeError("op is not a tuple");
|
|
switch (op[0]) {
|
|
case "r":
|
|
if (op.length !== 2) throw new TypeError("r arity");
|
|
return;
|
|
case "s":
|
|
if (op.length !== 3) throw new TypeError("s arity");
|
|
assertPathArg(op[1], true);
|
|
return;
|
|
case "d":
|
|
if (op.length !== 2) throw new TypeError("d arity");
|
|
assertPathArg(op[1], true);
|
|
return;
|
|
case "a":
|
|
if (op.length !== 3 || typeof op[2] !== "string") throw new TypeError("a shape");
|
|
assertPathArg(op[1], true);
|
|
return;
|
|
case "t":
|
|
if (op.length !== 3 || !Number.isInteger(op[2]) || op[2] < 0) throw new TypeError("t shape");
|
|
assertPathArg(op[1], true);
|
|
return;
|
|
case "p": {
|
|
if (op.length !== 5) throw new TypeError("p arity");
|
|
assertPathArg(op[1]);
|
|
if (!Number.isInteger(op[2]) || op[2] < 0) throw new TypeError("p index");
|
|
if (!Number.isInteger(op[3]) || op[3] < 0) throw new TypeError("p remove");
|
|
if (!Array.isArray(op[4])) throw new TypeError("p items");
|
|
return;
|
|
}
|
|
case "m":
|
|
if (op.length !== 3) throw new TypeError("m arity");
|
|
assertPathArg(op[1]);
|
|
assertPermutation(op[2]);
|
|
return;
|
|
// Silently skipping an unknown verb is how a newer producer's op vanishes.
|
|
default:
|
|
throw new TypeError(`unknown op verb: ${String(op[0])}`);
|
|
}
|
|
}
|
|
|
|
function assertPathArg(p: unknown, nonEmpty = false): void {
|
|
if (!Array.isArray(p)) throw new TypeError("path is not an array");
|
|
if (nonEmpty && p.length === 0) throw new TypeError("path is empty");
|
|
assertSafePath(p as Path);
|
|
}
|
|
|
|
function assertPermutation(value: unknown): asserts value is number[] {
|
|
if (!Array.isArray(value)) throw new TypeError("m permutation is not an array");
|
|
const seen = new Uint8Array(value.length);
|
|
for (const index of value) {
|
|
if (!Number.isInteger(index) || index < 0 || index >= value.length || seen[index] !== 0) {
|
|
throw new TypeError("m permutation is not a bijection");
|
|
}
|
|
seen[index] = 1;
|
|
}
|
|
}
|
|
|
|
/** The same, for the wire grammar: ids and short forms are legal here. */
|
|
export function assertValidWireOp(op: unknown): asserts op is WireOp {
|
|
if (!Array.isArray(op) || op.length === 0) throw new TypeError("op is not a tuple");
|
|
const [verb] = op as unknown[];
|
|
const okRef = (r: unknown): void => {
|
|
if (typeof r === "number") {
|
|
if (!Number.isInteger(r) || r < 0) throw new TypeError("bad path id");
|
|
return;
|
|
}
|
|
// A string is not a path. Unchecked, `"a".slice(0, -1)` is `""`, so it
|
|
// resolves to the ROOT and writes there — a path that is not a path, accepted.
|
|
if (!Array.isArray(r)) throw new TypeError("path is not an array");
|
|
assertSafePath(r as Path);
|
|
};
|
|
switch (verb) {
|
|
case "r":
|
|
if (op.length !== 2) throw new TypeError("r arity");
|
|
return;
|
|
case "s":
|
|
if (op.length === 3) okRef(op[1]);
|
|
else if (op.length !== 2) throw new TypeError("s arity");
|
|
return;
|
|
case "d":
|
|
if (op.length === 2) okRef(op[1]);
|
|
else if (op.length !== 1) throw new TypeError("d arity");
|
|
return;
|
|
case "a":
|
|
if (op.length === 3) {
|
|
okRef(op[1]);
|
|
if (typeof op[2] !== "string") throw new TypeError("a value");
|
|
} else if (op.length === 2) {
|
|
if (typeof op[1] !== "string") throw new TypeError("a value");
|
|
} else throw new TypeError("a arity");
|
|
return;
|
|
case "t":
|
|
if (op.length === 3) {
|
|
okRef(op[1]);
|
|
if (!Number.isInteger(op[2]) || (op[2] as number) < 0) throw new TypeError("t count");
|
|
} else if (op.length === 2) {
|
|
if (!Number.isInteger(op[1]) || (op[1] as number) < 0) throw new TypeError("t count");
|
|
} else throw new TypeError("t arity");
|
|
return;
|
|
case "p": {
|
|
const [i, r, items] = op.length === 5 ? [op[2], op[3], op[4]] : op.length === 4 ? [op[1], op[2], op[3]] : [];
|
|
if (items === undefined) throw new TypeError("p arity");
|
|
if (op.length === 5) okRef(op[1]);
|
|
if (!Number.isInteger(i) || (i as number) < 0) throw new TypeError("p index");
|
|
if (!Number.isInteger(r) || (r as number) < 0) throw new TypeError("p remove");
|
|
if (!Array.isArray(items)) throw new TypeError("p items");
|
|
return;
|
|
}
|
|
case "m":
|
|
if (op.length === 3) okRef(op[1]);
|
|
else if (op.length !== 2) throw new TypeError("m arity");
|
|
assertPermutation(op[op.length - 1]);
|
|
return;
|
|
case "#": {
|
|
if (op.length !== 3 || !Number.isInteger(op[1]) || (op[1] as number) < 0 || !Array.isArray(op[2])) {
|
|
throw new TypeError("# shape");
|
|
}
|
|
assertSafePath(op[2] as Path);
|
|
return;
|
|
}
|
|
// Silently skipping an unknown verb is how a newer producer's op vanishes.
|
|
default:
|
|
throw new TypeError(`unknown op verb: ${String(verb)}`);
|
|
}
|
|
}
|
|
|
|
export function assertSafePath(path: Path): void {
|
|
for (const seg of path) {
|
|
if (typeof seg === "string") {
|
|
if (RESERVED_SEGMENTS.has(seg)) throw new UnsafePathError(seg);
|
|
} else if (!Number.isInteger(seg) || seg < 0) {
|
|
throw new UnsafePathError(seg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An index may address an existing element or append exactly one past the end.
|
|
*
|
|
* This is not an arbitrary cap — it is what keeps the value a `JsonValue`. A
|
|
* sparse array does not survive a JSON round trip: holes serialise to `null` and
|
|
* return as real properties, so `arr[7] = x` on a length-3 array already produces
|
|
* state a replica cannot match. Rejecting the write is more honest than silently
|
|
* diverging.
|
|
*
|
|
* It also removes the denial of service it would otherwise permit:
|
|
* `["s", ["xs", 4294967290], 1]` allocates a 4.29-billion-entry array from one op.
|
|
* Growth stays possible and stays proportional — the tracker already emits
|
|
* `arr.length = n` as a splice of explicit nulls, whose op size grows with the
|
|
* gap, so a large growth costs a large op rather than a small one.
|
|
*/
|
|
function assertIndexInRange(parent: readonly unknown[], index: number): void {
|
|
if (index > parent.length) throw new UnsafePathError(index);
|
|
}
|
|
|
|
// ─── Applier ─────────────────────────────────────────────────────────────────
|
|
|
|
export class PathError extends Error {
|
|
readonly path: Path | number;
|
|
constructor(path: Path | number) {
|
|
super(`unresolvable path: ${JSON.stringify(path)}`);
|
|
this.path = path;
|
|
this.name = "PathError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply ops to a plain mutable value. Returns the value, because `r` replaces it
|
|
* outright and cannot be done in place.
|
|
*
|
|
* Takes decoded ops. Path ids and omitted paths are a wire concern — run
|
|
* `decode` first if the ops came from a boundary.
|
|
*/
|
|
export function apply<T>(target: T | undefined, ops: readonly Op[]): T {
|
|
return applyOps(target, ops);
|
|
}
|
|
|
|
function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
|
|
let root = target as unknown as JsonValue;
|
|
|
|
for (const op of ops) {
|
|
assertValidOp(op);
|
|
if (op[0] === "r") {
|
|
// Adopted, not copied. The consumer owns the batch it was handed.
|
|
//
|
|
// Fanning one batch out to several consumers in-process therefore makes
|
|
// their replicas alias each other. That is an ownership rule, not a
|
|
// defect: copy the batch at the fan-out point, or let each consumer
|
|
// decode its own. A batch that crosses a real boundary is already
|
|
// distinct, because serialisation produces fresh objects.
|
|
root = op[1];
|
|
continue;
|
|
}
|
|
|
|
const path = op[1];
|
|
assertSafePath(path);
|
|
|
|
if (op[0] === "p") {
|
|
const target_ = path.length === 0 ? root : resolve(root, path);
|
|
if (!Array.isArray(target_)) throw new PathError(path);
|
|
target_.splice(op[2], op[3]);
|
|
const chunkSize = 10_000;
|
|
for (let offset = 0; offset < op[4].length; offset += chunkSize) {
|
|
target_.splice(op[2] + offset, 0, ...op[4].slice(offset, offset + chunkSize));
|
|
}
|
|
continue;
|
|
}
|
|
if (op[0] === "m") {
|
|
const target_ = path.length === 0 ? root : resolve(root, path);
|
|
if (!Array.isArray(target_) || target_.length !== op[2].length) throw new PathError(path);
|
|
const previous = target_.slice();
|
|
for (let index = 0; index < op[2].length; index++) target_[index] = previous[op[2][index]!]!;
|
|
continue;
|
|
}
|
|
|
|
// s/d/a/t can never target the root — the type forbids it.
|
|
const parent = resolve(root, path.slice(0, -1)) as Record<Seg, JsonValue>;
|
|
const key = path[path.length - 1]!;
|
|
if (Array.isArray(parent)) {
|
|
if (typeof key !== "number") throw new UnsafePathError(key);
|
|
assertIndexInRange(parent, key);
|
|
}
|
|
// defineProperty rather than assignment: a setter inherited from the prototype
|
|
// chain would otherwise run on write.
|
|
const write = (value: JsonValue) => {
|
|
Object.defineProperty(parent, key, { value, writable: true, enumerable: true, configurable: true });
|
|
};
|
|
const read = (): unknown => (Object.hasOwn(parent, key) ? parent[key] : undefined);
|
|
switch (op[0]) {
|
|
case "s":
|
|
write(op[2]);
|
|
break;
|
|
case "d":
|
|
if (Array.isArray(parent)) {
|
|
if (typeof key !== "number" || key >= parent.length) throw new PathError(path);
|
|
(parent as unknown as JsonValue[]).splice(key, 1);
|
|
} else delete parent[key];
|
|
break;
|
|
case "a": {
|
|
const current = read();
|
|
if (typeof current !== "string") throw new PathError(path);
|
|
write(`${current}${op[2]}`);
|
|
break;
|
|
}
|
|
case "t": {
|
|
const current = read();
|
|
if (typeof current !== "string") throw new PathError(path);
|
|
write(current.slice(op[2]));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return root as unknown as T;
|
|
}
|
|
|
|
/** Apply one decoded operation batch without mutating the previous immutable value. */
|
|
export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {
|
|
return applyImmutableBatches(target, [ops]);
|
|
}
|
|
|
|
/**
|
|
* Apply decoded operation batches as one final-result-only replay.
|
|
*
|
|
* Containers copied for an earlier batch may be mutated while applying a later
|
|
* batch, so this deliberately exposes no intermediate revisions.
|
|
*/
|
|
export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T {
|
|
let root = target as unknown as JsonValue;
|
|
const owned = new WeakSet<object>();
|
|
for (const ops of batches) {
|
|
for (const op of ops) {
|
|
assertValidOp(op);
|
|
if (op[0] === "r") {
|
|
root = op[1];
|
|
continue;
|
|
}
|
|
if (op[0] === "p") {
|
|
const appended = appendCopy(root, op, owned);
|
|
if (appended !== undefined) {
|
|
root = appended;
|
|
continue;
|
|
}
|
|
}
|
|
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned);
|
|
root = applyOps(root, [op]);
|
|
}
|
|
}
|
|
return root as unknown as T;
|
|
}
|
|
|
|
/**
|
|
* An append to an array not yet copied in this replay, built as one copy with the items (`concat`) instead of a copy that
|
|
* the insert then grows, which copies it again. `undefined` for any other splice and for any path that is not a plain
|
|
* walk over own properties, which takes the general path and so fails with its errors.
|
|
*/
|
|
function appendCopy(root: JsonValue, op: Extract<Op, { 0: "p" }>, owned: WeakSet<object>): JsonValue | undefined {
|
|
const [, path, index, deleteCount, items] = op;
|
|
if (deleteCount !== 0) return undefined;
|
|
let target: JsonValue = root;
|
|
for (const segment of path) {
|
|
const valid =
|
|
typeof segment === "string"
|
|
? !RESERVED_SEGMENTS.has(segment) && isObj(target) && !Array.isArray(target)
|
|
: Number.isInteger(segment) && segment >= 0 && Array.isArray(target);
|
|
if (!valid || !Object.hasOwn(target as object, segment)) return undefined;
|
|
target = (target as Record<Seg, JsonValue>)[segment]!;
|
|
}
|
|
if (!Array.isArray(target) || owned.has(target) || index !== target.length) return undefined;
|
|
const appended = target.concat(items);
|
|
owned.add(appended);
|
|
if (path.length === 0) return appended;
|
|
const parentPath = path.slice(0, -1);
|
|
const copiedRoot = copyContainers(root, parentPath, owned);
|
|
Object.defineProperty(resolve(copiedRoot, parentPath), path[path.length - 1]!, {
|
|
value: appended,
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
return copiedRoot;
|
|
}
|
|
|
|
function copyContainers(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
|
|
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
|
|
if (Array.isArray(value)) return value.slice();
|
|
if (!isObj(value)) throw new PathError(path);
|
|
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
|
|
string,
|
|
JsonValue
|
|
>;
|
|
for (const key of Object.keys(value)) {
|
|
Object.defineProperty(result, key, {
|
|
value: (value as Record<string, JsonValue>)[key],
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
if (!isObj(root)) throw new PathError(path);
|
|
let copiedRoot: JsonValue[] | Record<string, JsonValue>;
|
|
if (owned.has(root)) copiedRoot = root as JsonValue[] | Record<string, JsonValue>;
|
|
else {
|
|
copiedRoot = copy(root);
|
|
owned.add(copiedRoot);
|
|
}
|
|
let destination = copiedRoot;
|
|
for (const segment of path) {
|
|
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
|
|
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
|
|
const child = (destination as Record<Seg, JsonValue>)[segment]!;
|
|
if (!isObj(child)) throw new PathError(path);
|
|
if (owned.has(child)) {
|
|
destination = child as JsonValue[] | Record<string, JsonValue>;
|
|
continue;
|
|
}
|
|
const copiedChild = copy(child);
|
|
Object.defineProperty(destination, segment, {
|
|
value: copiedChild,
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
owned.add(copiedChild);
|
|
destination = copiedChild;
|
|
}
|
|
return copiedRoot;
|
|
}
|
|
|
|
function resolveValue(root: JsonValue, path: Path): JsonValue {
|
|
let node: JsonValue = root;
|
|
for (const seg of path) {
|
|
if (!isObj(node)) throw new PathError(path);
|
|
if (Array.isArray(node) && typeof seg !== "number") throw new UnsafePathError(seg);
|
|
// Own properties only: an inherited getter must not run, and a walk must not
|
|
// escape the value into the prototype chain.
|
|
if (!Object.hasOwn(node, seg as PropertyKey)) throw new PathError(path);
|
|
node = (node as Record<Seg, JsonValue>)[seg]!;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function resolve(root: JsonValue, path: Path): JsonValue {
|
|
const node = resolveValue(root, path);
|
|
if (!isObj(node)) throw new PathError(path);
|
|
return node;
|
|
}
|
|
|
|
// ─── Codec ───────────────────────────────────────────────────────────────────
|
|
//
|
|
// Path interning and arity omission live between the tracker and a boundary;
|
|
// `Op` and `apply` know nothing about them.
|
|
//
|
|
// Consecutive batches of one encoder may be concatenated into one batch (links
|
|
// coalesce waiting state this way): only path ids span batches, and every batch
|
|
// starts with an explicit path.
|
|
//
|
|
// ONE PAIR PER INDEPENDENT STATE STREAM. Every decoder must observe exactly the
|
|
// batches encoded by its matching encoder, beginning with that state's base.
|
|
// Sharing a transport connection does not make separately hydrated states one
|
|
// stream.
|
|
|
|
const pathKey = (path: Path): string => JSON.stringify(path);
|
|
|
|
export interface Encoder {
|
|
encode(ops: readonly Op[]): WireOp[];
|
|
}
|
|
|
|
/**
|
|
* Intern on SECOND use. A definition costs more than the path it replaces, so
|
|
* interning on first use loses on the many paths written exactly once.
|
|
*/
|
|
export function encoder(): Encoder {
|
|
const seen = new Set<string>();
|
|
const ids = new Map<string, number>();
|
|
let nextId = 0;
|
|
let previous: string | undefined; // last path in THIS batch
|
|
|
|
return {
|
|
encode(ops) {
|
|
// Arity omission is scoped to a batch. Letting it span batches would make
|
|
// a batch's first op depend on the previous batch's last one, so a reader
|
|
// that skips or reorders a batch decodes into the wrong path. Ids are the
|
|
// only cross-batch state, and the dictionary makes those explicit.
|
|
previous = undefined;
|
|
const out: WireOp[] = [];
|
|
for (const op of ops) {
|
|
if (op[0] === "r") {
|
|
out.push(op);
|
|
// A base batch is a RECOVERY POINT: a reader replays from the last one
|
|
// with a fresh decoder. So everything after it must be self-contained.
|
|
// Keeping ids across a replacement emits references to definitions the
|
|
// reader never saw — recovery fails with an unresolvable path id.
|
|
seen.clear();
|
|
ids.clear();
|
|
nextId = 0;
|
|
previous = undefined;
|
|
continue;
|
|
}
|
|
const path = op[1];
|
|
const key = pathKey(path);
|
|
|
|
// Same path as the previous op: drop the ref entirely.
|
|
if (key === previous) {
|
|
switch (op[0]) {
|
|
case "s":
|
|
out.push(["s", op[2]]);
|
|
break;
|
|
case "d":
|
|
out.push(["d"]);
|
|
break;
|
|
case "a":
|
|
out.push(["a", op[2]]);
|
|
break;
|
|
case "t":
|
|
out.push(["t", op[2]]);
|
|
break;
|
|
case "p":
|
|
out.push(["p", op[2], op[3], op[4]]);
|
|
break;
|
|
case "m":
|
|
out.push(["m", op[2]]);
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let ref: PathRef = path;
|
|
const existing = ids.get(key);
|
|
if (existing !== undefined) {
|
|
ref = existing;
|
|
} else if (seen.has(key)) {
|
|
const id = nextId++;
|
|
ids.set(key, id);
|
|
out.push(["#", id, path]); // second use: define, then reference
|
|
ref = id;
|
|
} else {
|
|
seen.add(key); // first use: inline
|
|
}
|
|
|
|
switch (op[0]) {
|
|
case "s":
|
|
out.push(["s", ref as PathRef<NonEmptyPath>, op[2]]);
|
|
break;
|
|
case "d":
|
|
out.push(["d", ref as PathRef<NonEmptyPath>]);
|
|
break;
|
|
case "a":
|
|
out.push(["a", ref as PathRef<NonEmptyPath>, op[2]]);
|
|
break;
|
|
case "t":
|
|
out.push(["t", ref as PathRef<NonEmptyPath>, op[2]]);
|
|
break;
|
|
case "p":
|
|
out.push(["p", ref, op[2], op[3], op[4]]);
|
|
break;
|
|
case "m":
|
|
out.push(["m", ref, op[2]]);
|
|
break;
|
|
}
|
|
previous = key;
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface Decoder {
|
|
/** Ops as received: each is validated (`assertValidWireOp`) before use. */
|
|
decode(wire: readonly unknown[]): Op[];
|
|
}
|
|
|
|
export function decoder(): Decoder {
|
|
const paths = new Map<number, Path>();
|
|
|
|
return {
|
|
decode(wire) {
|
|
let previous: Path | undefined; // scoped to the batch, as in encode
|
|
const out: Op[] = [];
|
|
for (const op of wire) {
|
|
assertValidWireOp(op);
|
|
if (op[0] === "#") {
|
|
assertSafePath(op[2]);
|
|
paths.set(op[1], op[2]);
|
|
continue;
|
|
}
|
|
if (op[0] === "r") {
|
|
out.push(op);
|
|
paths.clear();
|
|
previous = undefined;
|
|
continue;
|
|
}
|
|
|
|
// Arity tells us whether a ref is present: the short forms omit it.
|
|
const short =
|
|
(op[0] === "d" && op.length === 1) ||
|
|
(op[0] !== "d" && op[0] !== "p" && op.length === 2) ||
|
|
(op[0] === "p" && op.length === 4);
|
|
|
|
let path: Path;
|
|
if (short) {
|
|
if (previous === undefined) throw new PathError([]);
|
|
path = previous;
|
|
} else {
|
|
const ref = op[1] as PathRef;
|
|
if (typeof ref === "number") {
|
|
const resolved = paths.get(ref);
|
|
if (resolved === undefined) throw new PathError(ref);
|
|
path = resolved;
|
|
} else {
|
|
path = ref;
|
|
}
|
|
previous = path;
|
|
}
|
|
|
|
if (op[0] !== "p" && op[0] !== "m" && path.length === 0) throw new PathError(path);
|
|
switch (op[0]) {
|
|
case "s":
|
|
out.push(["s", path as NonEmptyPath, (short ? op[1] : op[2]) as JsonValue]);
|
|
break;
|
|
case "d":
|
|
out.push(["d", path as NonEmptyPath]);
|
|
break;
|
|
case "a":
|
|
out.push(["a", path as NonEmptyPath, (short ? op[1] : op[2]) as string]);
|
|
break;
|
|
case "t":
|
|
out.push(["t", path as NonEmptyPath, (short ? op[1] : op[2]) as number]);
|
|
break;
|
|
case "p": {
|
|
const [i, r, items] = short
|
|
? [op[1] as number, op[2] as number, op[3] as JsonValue[]]
|
|
: [op[2] as number, op[3] as number, op[4] as JsonValue[]];
|
|
out.push(["p", path, i, r, items]);
|
|
break;
|
|
}
|
|
case "m":
|
|
out.push(["m", path, (short ? op[1] : op[2]) as number[]]);
|
|
break;
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|