mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): add optimized delta engine prototypes
This commit is contained in:
parent
9a139c62bf
commit
4bc1a2fe53
24 changed files with 7322 additions and 0 deletions
24
packages/chord/src/delta/astra/README.md
Normal file
24
packages/chord/src/delta/astra/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Astra trusted contract
|
||||
|
||||
Astra is an exact overlay tracker for trusted mutable live state. `track()` and
|
||||
`prepareReplace()` take ownership of their roots in O(1); callers must not mutate
|
||||
transferred roots except through an active Astra draft. Successful adoption
|
||||
mutates the committed root in place, so previously retained committed references
|
||||
observe later transactions.
|
||||
|
||||
Inputs and placements must already be alias-free strict JSON trees: plain objects,
|
||||
dense arrays, strings, booleans, finite numbers, and `null`. Cycles, accessors,
|
||||
proxies, symbols, classes, sparse arrays, `undefined`, and non-finite numbers are
|
||||
outside the contract. Astra deliberately does not validate or freeze complete
|
||||
input trees.
|
||||
|
||||
Supported draft operations use ordinary property access, primitive array indices,
|
||||
and non-reentrant array callbacks. The following are outside the trusted contract:
|
||||
|
||||
- coercion callbacks that mutate the draft while an array method is converting an argument;
|
||||
- mutations or `prepare()` calls from inside a `sort()` comparator;
|
||||
- bigint or object-valued comparator results requiring coercion;
|
||||
- non-primitive array indices.
|
||||
|
||||
These exclusions avoid callback-order and coercion machinery on the overlay hot
|
||||
paths. Behavior for excluded operations is unspecified.
|
||||
2005
packages/chord/src/delta/astra/index.ts
Normal file
2005
packages/chord/src/delta/astra/index.ts
Normal file
File diff suppressed because it is too large
Load diff
12
packages/chord/src/delta/cow/README.md
Normal file
12
packages/chord/src/delta/cow/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Trusted COW contract
|
||||
|
||||
This variant provides immutable copy-on-write revisions without recursive input validation or deep freezing.
|
||||
|
||||
- `track(initial)` and `prepareReplace(value)` take ownership. Inputs are alias-free, acyclic strict JSON made from dense arrays, plain data objects, finite numbers, strings, booleans, and `null`. Accessors, custom mutation hooks, symbols, classes, sparse arrays, and external proxies are outside the contract.
|
||||
- Do not mutate transferred roots, `tracker.value`, `Prepared.base`, `Prepared.value`, or `Prepared.ops` externally. `Prepared.value` and `Prepared.ops` are independent immutable ownership transfers.
|
||||
- Values assigned through a draft are cloned by value for each placement. Mutate transaction state only through its draft handles.
|
||||
- Every change must settle exactly once through `prepare()` followed by `adopt()`, or through `abort()`. Draft handles are invalid after preparation or abortion.
|
||||
- Array indices and mutator index arguments must be primitive numbers. Objects with coercion callbacks and mutations caused during argument coercion are outside the contract.
|
||||
- Sort comparators must be synchronous and pure: they return ordinary numbers and do not mutate drafts, prepare transactions, or start nested operations.
|
||||
|
||||
Violating this contract is programmer error and may not be detected at runtime.
|
||||
71
packages/chord/src/delta/cow/apply-immutable.ts
Normal file
71
packages/chord/src/delta/cow/apply-immutable.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { JsonValue } from "../../types.ts";
|
||||
import {
|
||||
apply as applyMutable,
|
||||
assertValidOp,
|
||||
type Op,
|
||||
type Path,
|
||||
PathError,
|
||||
type Seg,
|
||||
UnsafePathError,
|
||||
} from "../index.ts";
|
||||
|
||||
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
|
||||
|
||||
/** Apply one batch while copying each touched container at most once. */
|
||||
export function applyImmutable<T>(target: T | undefined, operations: readonly Op[]): T {
|
||||
let root = target as unknown as JsonValue;
|
||||
const owned = new WeakSet<object>();
|
||||
for (const operation of operations) {
|
||||
assertValidOp(operation);
|
||||
if (operation[0] === "r") {
|
||||
root = operation[1];
|
||||
continue;
|
||||
}
|
||||
const path = operation[0] === "p" || operation[0] === "m" ? operation[1] : operation[1].slice(0, -1);
|
||||
root = copyPath(root, path, owned);
|
||||
root = applyMutable(root, [operation]);
|
||||
}
|
||||
return root as unknown as T;
|
||||
}
|
||||
|
||||
function copyPath(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
|
||||
if (!isContainer(root)) throw new PathError(path);
|
||||
let copiedRoot = root;
|
||||
if (!owned.has(root)) {
|
||||
copiedRoot = shallowCopy(root);
|
||||
owned.add(copiedRoot);
|
||||
}
|
||||
let destination = copiedRoot as JsonContainer;
|
||||
for (const segment of path) {
|
||||
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
|
||||
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
|
||||
let child = (destination as Record<Seg, JsonValue>)[segment]!;
|
||||
if (!isContainer(child)) throw new PathError(path);
|
||||
if (!owned.has(child)) {
|
||||
const copy = shallowCopy(child);
|
||||
defineData(destination, segment, copy);
|
||||
owned.add(copy);
|
||||
child = copy;
|
||||
}
|
||||
destination = child;
|
||||
}
|
||||
return copiedRoot;
|
||||
}
|
||||
|
||||
function shallowCopy(value: JsonContainer): JsonContainer {
|
||||
if (Array.isArray(value)) return Array.from(value);
|
||||
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
|
||||
string,
|
||||
JsonValue
|
||||
>;
|
||||
for (const key of Object.keys(value)) defineData(result, key, value[key]!);
|
||||
return result;
|
||||
}
|
||||
|
||||
function defineData(target: object, key: PropertyKey, value: JsonValue): void {
|
||||
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
|
||||
}
|
||||
|
||||
function isContainer(value: unknown): value is JsonContainer {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
562
packages/chord/src/delta/cow/diff.ts
Normal file
562
packages/chord/src/delta/cow/diff.ts
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import type { JsonValue } from "../../types.ts";
|
||||
import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "../index.ts";
|
||||
import type { DiffHints } from "./draft.ts";
|
||||
|
||||
const DEFAULT_OVERLAP_SCAN = 65_536;
|
||||
const MAX_DELTA_OPERATIONS = 4_096;
|
||||
const overflowedBatches = new WeakSet<Op[]>();
|
||||
|
||||
const emitOperation = (operations: Op[], operation: Op): void => {
|
||||
if (overflowedBatches.has(operations)) return;
|
||||
if (operations.length >= MAX_DELTA_OPERATIONS) {
|
||||
overflowedBatches.add(operations);
|
||||
return;
|
||||
}
|
||||
operations.push(operation);
|
||||
};
|
||||
|
||||
const isContainer = (value: JsonValue): value is JsonValue[] | Record<string, JsonValue> =>
|
||||
value !== null && typeof value === "object";
|
||||
|
||||
const emitSet = (path: Path, value: JsonValue, operations: Op[]): void => {
|
||||
if (path.length === 0) emitOperation(operations, ["r", value]);
|
||||
else emitOperation(operations, ["s", path as NonEmptyPath, value]);
|
||||
};
|
||||
|
||||
const equalJson = (left: JsonValue, right: JsonValue): boolean => {
|
||||
if (left === right) return true;
|
||||
if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (Array.isArray(left)) {
|
||||
const other = right as JsonValue[];
|
||||
if (left.length !== other.length) return false;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (!equalJson(left[index]!, other[index]!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const other = right as Record<string, JsonValue>;
|
||||
const keys = Object.keys(left);
|
||||
if (keys.length !== Object.keys(other).length) return false;
|
||||
for (const key of keys) {
|
||||
if (!Object.hasOwn(other, key) || !equalJson(left[key]!, other[key]!)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): number[] | undefined => {
|
||||
if (before.length !== after.length) return undefined;
|
||||
// Cheap native probes before building a map: shifted/appended arrays fail here.
|
||||
if (before.indexOf(after[after.length - 1]!) === -1 || before.indexOf(after[0]!) === -1) return undefined;
|
||||
// A single index per value; duplicates upgrade to an index list.
|
||||
const positions = new Map<JsonValue, number | number[]>();
|
||||
for (let index = 0; index < before.length; index++) {
|
||||
const value = before[index]!;
|
||||
const entry = positions.get(value);
|
||||
if (entry === undefined) positions.set(value, index);
|
||||
else if (typeof entry === "number") positions.set(value, [entry, index]);
|
||||
else entry.push(index);
|
||||
}
|
||||
const used = new Map<JsonValue, number>();
|
||||
const result = new Array<number>(after.length);
|
||||
for (let index = 0; index < after.length; index++) {
|
||||
const value = after[index]!;
|
||||
const entry = positions.get(value);
|
||||
if (entry === undefined) return undefined;
|
||||
if (typeof entry === "number") {
|
||||
if (used.has(value)) return undefined;
|
||||
used.set(value, 1);
|
||||
result[index] = entry;
|
||||
continue;
|
||||
}
|
||||
const taken = used.get(value) ?? 0;
|
||||
if (taken === entry.length) return undefined;
|
||||
used.set(value, taken + 1);
|
||||
result[index] = entry[taken]!;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => {
|
||||
if (before === after) return;
|
||||
if (after.length > before.length && after.slice(0, before.length) === before) {
|
||||
emitOperation(operations, ["a", path, after.slice(before.length)]);
|
||||
return;
|
||||
}
|
||||
const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN);
|
||||
if (shared === 0) {
|
||||
emitOperation(operations, ["s", path, after]);
|
||||
return;
|
||||
}
|
||||
emitOperation(operations, ["t", path, before.length - shared]);
|
||||
if (after.length > shared) emitOperation(operations, ["a", path, after.slice(shared)]);
|
||||
};
|
||||
|
||||
type ArrayMatch = readonly [before: number, after: number];
|
||||
|
||||
type MatchCandidate = {
|
||||
before: number;
|
||||
after: number;
|
||||
previous: number;
|
||||
};
|
||||
|
||||
const MAX_IDENTITY_CANDIDATES = 200_000;
|
||||
const MAX_SEMANTIC_CELLS = 65_536;
|
||||
|
||||
const sameValue = (left: JsonValue, right: JsonValue): boolean => left === right || equalJson(left, right);
|
||||
|
||||
const lcsMatches = (
|
||||
before: readonly JsonValue[],
|
||||
after: readonly JsonValue[],
|
||||
equal: (left: JsonValue, right: JsonValue) => boolean,
|
||||
maxCells: number,
|
||||
): ArrayMatch[] | undefined => {
|
||||
if (before.length === 0 || after.length === 0) return [];
|
||||
if (before.length * after.length > maxCells) return undefined;
|
||||
const width = after.length + 1;
|
||||
const lengths = new Uint32Array((before.length + 1) * width);
|
||||
for (let left = before.length - 1; left >= 0; left--) {
|
||||
for (let right = after.length - 1; right >= 0; right--) {
|
||||
const at = left * width + right;
|
||||
lengths[at] = equal(before[left]!, after[right]!)
|
||||
? lengths[(left + 1) * width + right + 1]! + 1
|
||||
: Math.max(lengths[(left + 1) * width + right]!, lengths[left * width + right + 1]!);
|
||||
}
|
||||
}
|
||||
const matches: ArrayMatch[] = [];
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
while (left < before.length && right < after.length) {
|
||||
if (
|
||||
equal(before[left]!, after[right]!) &&
|
||||
lengths[left * width + right] === lengths[(left + 1) * width + right + 1]! + 1
|
||||
) {
|
||||
matches.push([left++, right++]);
|
||||
} else if (lengths[(left + 1) * width + right]! >= lengths[left * width + right + 1]!) left += 1;
|
||||
else right += 1;
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const semanticallyAligned = (left: JsonValue, right: JsonValue): boolean => {
|
||||
if (sameValue(left, right)) return true;
|
||||
if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.some((value, index) => isContainer(value) && value === right[index]);
|
||||
}
|
||||
const leftObject = left as Record<string, JsonValue>;
|
||||
const rightObject = right as Record<string, JsonValue>;
|
||||
for (const key of Object.keys(leftObject)) {
|
||||
const value = leftObject[key];
|
||||
if (isContainer(value) && Object.hasOwn(rightObject, key) && value === rightObject[key]) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const indexOfIdentity = (values: readonly JsonValue[], value: JsonValue, start: number, end: number): number => {
|
||||
if (!isContainer(value)) return -1; // primitives are not identity anchors
|
||||
const at = values.indexOf(value, start);
|
||||
return at === -1 || at >= end ? -1 : at;
|
||||
};
|
||||
|
||||
const lowerBound = (values: readonly number[], value: number): number => {
|
||||
let low = 0;
|
||||
let high = values.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (values[middle]! < value) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
};
|
||||
|
||||
const identitySubsequence = (
|
||||
before: readonly JsonValue[],
|
||||
beforeStart: number,
|
||||
beforeEnd: number,
|
||||
after: readonly JsonValue[],
|
||||
afterStart: number,
|
||||
afterEnd: number,
|
||||
): ArrayMatch[] | undefined => {
|
||||
const beforeCount = beforeEnd - beforeStart;
|
||||
const afterCount = afterEnd - afterStart;
|
||||
const matches: ArrayMatch[] = [];
|
||||
if (afterCount < beforeCount) {
|
||||
let beforeIndex = beforeStart;
|
||||
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
|
||||
while (beforeIndex < beforeEnd && before[beforeIndex] !== after[afterIndex]) beforeIndex += 1;
|
||||
if (beforeIndex === beforeEnd) return undefined;
|
||||
matches.push([beforeIndex++, afterIndex]);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
if (beforeCount < afterCount) {
|
||||
let afterIndex = afterStart;
|
||||
for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex++) {
|
||||
while (afterIndex < afterEnd && before[beforeIndex] !== after[afterIndex]) afterIndex += 1;
|
||||
if (afterIndex === afterEnd) return undefined;
|
||||
matches.push([beforeIndex, afterIndex++]);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const greedyIdentityAnchors = (
|
||||
positions: ReadonlyMap<JsonValue, readonly number[]>,
|
||||
after: readonly JsonValue[],
|
||||
afterStart: number,
|
||||
afterEnd: number,
|
||||
): ArrayMatch[] => {
|
||||
const matches: ArrayMatch[] = [];
|
||||
let previous = -1;
|
||||
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
|
||||
const candidates = positions.get(after[afterIndex]!);
|
||||
if (candidates === undefined) continue;
|
||||
const at = lowerBound(candidates, previous + 1);
|
||||
const beforeIndex = candidates[at];
|
||||
if (beforeIndex === undefined) continue;
|
||||
matches.push([beforeIndex, afterIndex]);
|
||||
previous = beforeIndex;
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const identityAnchors = (
|
||||
before: readonly JsonValue[],
|
||||
beforeStart: number,
|
||||
beforeEnd: number,
|
||||
after: readonly JsonValue[],
|
||||
afterStart: number,
|
||||
afterEnd: number,
|
||||
): ArrayMatch[] => {
|
||||
const positions = new Map<JsonValue, number[]>();
|
||||
for (let index = beforeStart; index < beforeEnd; index++) {
|
||||
const value = before[index]!;
|
||||
const existing = positions.get(value);
|
||||
if (existing === undefined) positions.set(value, [index]);
|
||||
else existing.push(index);
|
||||
}
|
||||
let candidateCount = 0;
|
||||
for (let index = afterStart; index < afterEnd; index++) {
|
||||
candidateCount += positions.get(after[index]!)?.length ?? 0;
|
||||
if (candidateCount > MAX_IDENTITY_CANDIDATES) {
|
||||
return greedyIdentityAnchors(positions, after, afterStart, afterEnd);
|
||||
}
|
||||
}
|
||||
if (candidateCount === 0) return [];
|
||||
|
||||
const candidates: MatchCandidate[] = [];
|
||||
const tails: number[] = [];
|
||||
const tailValues: number[] = [];
|
||||
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
|
||||
const beforePositions = positions.get(after[afterIndex]!);
|
||||
if (beforePositions === undefined) continue;
|
||||
for (let index = beforePositions.length - 1; index >= 0; index--) {
|
||||
const beforeIndex = beforePositions[index]!;
|
||||
const at = lowerBound(tailValues, beforeIndex);
|
||||
const candidateIndex = candidates.length;
|
||||
candidates.push({ before: beforeIndex, after: afterIndex, previous: at === 0 ? -1 : tails[at - 1]! });
|
||||
tails[at] = candidateIndex;
|
||||
tailValues[at] = beforeIndex;
|
||||
}
|
||||
}
|
||||
const matches: ArrayMatch[] = [];
|
||||
let candidateIndex = tails[tails.length - 1] ?? -1;
|
||||
while (candidateIndex >= 0) {
|
||||
const candidate = candidates[candidateIndex]!;
|
||||
matches.push([candidate.before, candidate.after]);
|
||||
candidateIndex = candidate.previous;
|
||||
}
|
||||
matches.reverse();
|
||||
return matches;
|
||||
};
|
||||
|
||||
const processArrayMatches = (
|
||||
before: JsonValue[],
|
||||
after: JsonValue[],
|
||||
path: Path,
|
||||
operations: Op[],
|
||||
hints: DiffHints,
|
||||
beforeStart: number,
|
||||
beforeEnd: number,
|
||||
afterStart: number,
|
||||
afterEnd: number,
|
||||
outputStart: number,
|
||||
matches: readonly ArrayMatch[],
|
||||
): void => {
|
||||
let beforeAt = beforeStart;
|
||||
let afterAt = afterStart;
|
||||
let outputAt = outputStart;
|
||||
for (const [beforeMatch, afterMatch] of matches) {
|
||||
if (overflowedBatches.has(operations)) return;
|
||||
diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeMatch, afterAt, afterMatch, outputAt);
|
||||
outputAt += afterMatch - afterAt;
|
||||
if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) {
|
||||
diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations, hints);
|
||||
}
|
||||
outputAt += 1;
|
||||
beforeAt = beforeMatch + 1;
|
||||
afterAt = afterMatch + 1;
|
||||
}
|
||||
if (!overflowedBatches.has(operations)) {
|
||||
diffArrayRegion(before, after, path, operations, hints, beforeAt, beforeEnd, afterAt, afterEnd, outputAt);
|
||||
}
|
||||
};
|
||||
|
||||
function diffArrayRegion(
|
||||
before: JsonValue[],
|
||||
after: JsonValue[],
|
||||
path: Path,
|
||||
operations: Op[],
|
||||
hints: DiffHints,
|
||||
beforeStart: number,
|
||||
beforeEnd: number,
|
||||
afterStart: number,
|
||||
afterEnd: number,
|
||||
outputStart: number,
|
||||
): void {
|
||||
if (overflowedBatches.has(operations)) return;
|
||||
while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeStart]!, after[afterStart]!)) {
|
||||
beforeStart += 1;
|
||||
afterStart += 1;
|
||||
outputStart += 1;
|
||||
}
|
||||
while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeEnd - 1]!, after[afterEnd - 1]!)) {
|
||||
beforeEnd -= 1;
|
||||
afterEnd -= 1;
|
||||
}
|
||||
const beforeCount = beforeEnd - beforeStart;
|
||||
const afterCount = afterEnd - afterStart;
|
||||
if (beforeCount === 0 && afterCount === 0) return;
|
||||
if (beforeCount === 0 || afterCount === 0) {
|
||||
emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (beforeCount === afterCount) {
|
||||
// Streaming positional alignment: equivalent to collecting positional
|
||||
// matches and diffing the gaps between them, without a tuple per match.
|
||||
let matched = false;
|
||||
let gapStart = -1;
|
||||
for (let offset = 0; offset <= beforeCount; offset++) {
|
||||
const same = offset < beforeCount && sameValue(before[beforeStart + offset]!, after[afterStart + offset]!);
|
||||
if (same) {
|
||||
matched = true;
|
||||
if (gapStart !== -1) {
|
||||
if (overflowedBatches.has(operations)) return;
|
||||
diffArrayRegion(
|
||||
before,
|
||||
after,
|
||||
path,
|
||||
operations,
|
||||
hints,
|
||||
beforeStart + gapStart,
|
||||
beforeStart + offset,
|
||||
afterStart + gapStart,
|
||||
afterStart + offset,
|
||||
outputStart + gapStart,
|
||||
);
|
||||
gapStart = -1;
|
||||
}
|
||||
} else if (gapStart === -1) gapStart = offset;
|
||||
}
|
||||
if (matched) {
|
||||
if (gapStart !== -1 && !overflowedBatches.has(operations)) {
|
||||
diffArrayRegion(
|
||||
before,
|
||||
after,
|
||||
path,
|
||||
operations,
|
||||
hints,
|
||||
beforeStart + gapStart,
|
||||
beforeEnd,
|
||||
afterStart + gapStart,
|
||||
afterEnd,
|
||||
outputStart + gapStart,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Identity run fast paths (queue shift+push, unshift+pop). The run must be
|
||||
// longer than both remainders so it is the unique longest anchor chain; the
|
||||
// output then equals the LIS alignment below without a map or match list.
|
||||
{
|
||||
const k = indexOfIdentity(before, after[afterStart]!, beforeStart, beforeEnd);
|
||||
if (k !== -1) {
|
||||
let run = 0;
|
||||
const limit = Math.min(beforeEnd - k, afterCount);
|
||||
// Container-only runs: object identity is unique, so this run is the unique longest anchor chain.
|
||||
while (run < limit && before[k + run] === after[afterStart + run] && isContainer(before[k + run]!)) run += 1;
|
||||
if (k + run === beforeEnd && run > k - beforeStart && run > afterCount - run) {
|
||||
if (k > beforeStart) emitOperation(operations, ["p", path, outputStart, k - beforeStart, []]);
|
||||
if (run < afterCount) {
|
||||
emitOperation(operations, ["p", path, outputStart + run, 0, after.slice(afterStart + run, afterEnd)]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const j = indexOfIdentity(after, before[beforeStart]!, afterStart, afterEnd);
|
||||
if (j !== -1) {
|
||||
let run = 0;
|
||||
const limit = Math.min(afterEnd - j, beforeCount);
|
||||
while (run < limit && before[beforeStart + run] === after[j + run] && isContainer(after[j + run]!)) run += 1;
|
||||
if (j + run === afterEnd && run > j - afterStart && run > beforeCount - run) {
|
||||
const inserted = j - afterStart;
|
||||
if (inserted > 0) emitOperation(operations, ["p", path, outputStart, 0, after.slice(afterStart, j)]);
|
||||
if (run < beforeCount) {
|
||||
emitOperation(operations, ["p", path, outputStart + inserted + run, beforeCount - run, []]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (beforeCount === 1 && afterCount === 1) {
|
||||
// Every alignment strategy below ends in diffValue for a 1x1 region.
|
||||
diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints);
|
||||
return;
|
||||
}
|
||||
|
||||
const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd);
|
||||
if (subsequence !== undefined && subsequence.length > 0) {
|
||||
processArrayMatches(
|
||||
before,
|
||||
after,
|
||||
path,
|
||||
operations,
|
||||
hints,
|
||||
beforeStart,
|
||||
beforeEnd,
|
||||
afterStart,
|
||||
afterEnd,
|
||||
outputStart,
|
||||
subsequence,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = identityAnchors(before, beforeStart, beforeEnd, after, afterStart, afterEnd);
|
||||
if (identity.length > 0) {
|
||||
processArrayMatches(
|
||||
before,
|
||||
after,
|
||||
path,
|
||||
operations,
|
||||
hints,
|
||||
beforeStart,
|
||||
beforeEnd,
|
||||
afterStart,
|
||||
afterEnd,
|
||||
outputStart,
|
||||
identity,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const semantic = lcsMatches(
|
||||
before.slice(beforeStart, beforeEnd),
|
||||
after.slice(afterStart, afterEnd),
|
||||
semanticallyAligned,
|
||||
MAX_SEMANTIC_CELLS,
|
||||
);
|
||||
if (semantic !== undefined && semantic.length > 0) {
|
||||
const absolute = semantic.map(
|
||||
([beforeIndex, afterIndex]) => [beforeStart + beforeIndex, afterStart + afterIndex] as const,
|
||||
);
|
||||
processArrayMatches(
|
||||
before,
|
||||
after,
|
||||
path,
|
||||
operations,
|
||||
hints,
|
||||
beforeStart,
|
||||
beforeEnd,
|
||||
afterStart,
|
||||
afterEnd,
|
||||
outputStart,
|
||||
absolute,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (beforeCount === 1 && afterCount === 1) {
|
||||
diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations, hints);
|
||||
return;
|
||||
}
|
||||
emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]);
|
||||
}
|
||||
|
||||
const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[], hints: DiffHints): void => {
|
||||
if (before === after) return;
|
||||
const hint = hints.get(after);
|
||||
if (hint?.base === before && hint.keys.length < 2_048) {
|
||||
for (const index of hint.keys) diffValue(before[index]!, after[index]!, [...path, index], operations, hints);
|
||||
return;
|
||||
}
|
||||
if (equalJson(before, after)) return;
|
||||
if (
|
||||
before.length === after.length &&
|
||||
before.length > 1 &&
|
||||
!sameValue(before[0]!, after[0]!) &&
|
||||
!sameValue(before[before.length - 1]!, after[after.length - 1]!)
|
||||
) {
|
||||
const order = permutation(before, after);
|
||||
if (order !== undefined) {
|
||||
emitOperation(operations, ["m", path, order]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
diffArrayRegion(before, after, path, operations, hints, 0, before.length, 0, after.length, 0);
|
||||
};
|
||||
|
||||
const diffObject = (
|
||||
before: Record<string, JsonValue>,
|
||||
after: Record<string, JsonValue>,
|
||||
path: Path,
|
||||
operations: Op[],
|
||||
hints: DiffHints,
|
||||
): void => {
|
||||
const beforeKeys = Object.keys(before);
|
||||
const afterKeys = Object.keys(after);
|
||||
if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) {
|
||||
if (!equalJson(before, after)) emitSet(path, after, operations);
|
||||
return;
|
||||
}
|
||||
for (const key of afterKeys) {
|
||||
if (overflowedBatches.has(operations)) return;
|
||||
if (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations, hints);
|
||||
else emitSet([...path, key], after[key]!, operations);
|
||||
}
|
||||
for (const key of beforeKeys) {
|
||||
if (overflowedBatches.has(operations)) return;
|
||||
if (!Object.hasOwn(after, key)) emitOperation(operations, ["d", [...path, key] as unknown as NonEmptyPath]);
|
||||
}
|
||||
};
|
||||
|
||||
const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[], hints: DiffHints): void => {
|
||||
if (before === after || overflowedBatches.has(operations)) return;
|
||||
if (typeof before === "string" && typeof after === "string" && path.length > 0) {
|
||||
emitString(before, after, path as NonEmptyPath, operations);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
diffArray(before, after, path, operations, hints);
|
||||
return;
|
||||
}
|
||||
if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) {
|
||||
diffObject(before, after, path, operations, hints);
|
||||
return;
|
||||
}
|
||||
emitSet(path, after, operations);
|
||||
};
|
||||
|
||||
/** Compute a normalized batch, with deterministic operation-count safety folding only. */
|
||||
export function diffRevisions(before: JsonValue, after: JsonValue, hints: DiffHints = new WeakMap()): Op[] {
|
||||
const operations: Op[] = [];
|
||||
diffValue(before, after, [], operations, hints);
|
||||
if (overflowedBatches.has(operations)) return [["r", after]];
|
||||
return operations;
|
||||
}
|
||||
717
packages/chord/src/delta/cow/draft.ts
Normal file
717
packages/chord/src/delta/cow/draft.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
import type { JsonValue } from "../../types.ts";
|
||||
|
||||
/** Mutable transaction-scoped view of a JSON value, preserving tuple positions. */
|
||||
export type Draft<T, Depth extends readonly unknown[] = []> = Depth["length"] extends 8
|
||||
? T
|
||||
: T extends null | boolean | number | string
|
||||
? T
|
||||
: T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends object
|
||||
? { -readonly [Key in keyof T]: Draft<T[Key], [...Depth, unknown]> }
|
||||
: T;
|
||||
|
||||
type Container = Record<string, unknown> | unknown[];
|
||||
type ChildKey = string | number;
|
||||
|
||||
export type ArrayDiffHint = {
|
||||
readonly base: JsonValue[];
|
||||
readonly keys: readonly number[];
|
||||
};
|
||||
|
||||
export type DiffHints = WeakMap<object, ArrayDiffHint>;
|
||||
|
||||
export type ProduceMetadata<T> = {
|
||||
value: T;
|
||||
hints: DiffHints;
|
||||
};
|
||||
|
||||
type DraftContext = {
|
||||
active: boolean;
|
||||
states: WeakMap<object, DraftState>;
|
||||
created: DraftState[];
|
||||
hints: DiffHints;
|
||||
};
|
||||
|
||||
type ChildIndex = DraftState | Map<ChildKey, DraftState>;
|
||||
type ChangedIndex = number | Set<number>;
|
||||
|
||||
class DraftState {
|
||||
context: DraftContext;
|
||||
base: Container;
|
||||
copy: Container | undefined;
|
||||
parent: DraftState | undefined;
|
||||
key: ChildKey | undefined;
|
||||
proxy: object;
|
||||
target: object;
|
||||
dirty = false;
|
||||
structural = false;
|
||||
children: ChildIndex | undefined;
|
||||
changedIndices: ChangedIndex | undefined;
|
||||
finalized: Container | undefined;
|
||||
|
||||
constructor(context: DraftContext, base: Container, parent: DraftState | undefined, key: ChildKey | undefined) {
|
||||
this.context = context;
|
||||
this.base = base;
|
||||
this.copy = undefined;
|
||||
this.parent = parent;
|
||||
this.key = key;
|
||||
this.proxy = this;
|
||||
this.target = this;
|
||||
this.children = undefined;
|
||||
this.changedIndices = undefined;
|
||||
this.finalized = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const DRAFT_STATE = Symbol("cow draft state");
|
||||
const RELEASED: Record<string, never> = {};
|
||||
const MAX_NATIVE_ARRAY_INSERT_ITEMS = 10_000;
|
||||
const ARRAY_MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
|
||||
|
||||
export type DraftTransaction<T extends object> = {
|
||||
readonly state: Draft<T>;
|
||||
finish(): ProduceMetadata<T>;
|
||||
abort(): void;
|
||||
};
|
||||
|
||||
export function createDraftTransaction<T extends object>(base: T): DraftTransaction<T> {
|
||||
const context: DraftContext = {
|
||||
active: true,
|
||||
states: new WeakMap(),
|
||||
created: [],
|
||||
hints: new WeakMap(),
|
||||
};
|
||||
const root = stateFor(context, base as Container, undefined, undefined);
|
||||
const release = (): void => {
|
||||
assertActive(context);
|
||||
context.active = false;
|
||||
for (const state of context.created) {
|
||||
state.base = RELEASED;
|
||||
state.copy = undefined;
|
||||
state.parent = undefined;
|
||||
state.key = undefined;
|
||||
state.proxy = RELEASED;
|
||||
state.target = RELEASED;
|
||||
state.children = undefined;
|
||||
state.changedIndices = undefined;
|
||||
state.finalized = undefined;
|
||||
}
|
||||
context.created.length = 0;
|
||||
context.states = new WeakMap();
|
||||
};
|
||||
return {
|
||||
state: root.proxy as Draft<T>,
|
||||
finish() {
|
||||
assertActive(context);
|
||||
try {
|
||||
return { value: finalize(root) as T, hints: context.hints };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
abort: release,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compatibility helper for synchronous COW recipes. */
|
||||
export function produceWithMetadata<T extends object>(base: T, recipe: (draft: Draft<T>) => void): ProduceMetadata<T> {
|
||||
const transaction = createDraftTransaction(base);
|
||||
try {
|
||||
const outcome = (recipe as (draft: Draft<T>) => unknown)(transaction.state);
|
||||
if (isPromiseLike(outcome)) {
|
||||
void Promise.resolve(outcome).catch(() => undefined);
|
||||
throw new TypeError("Replicated state change callbacks must be synchronous");
|
||||
}
|
||||
return transaction.finish();
|
||||
} catch (error) {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// finish() already released the transaction.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function produce<T extends object>(base: T, recipe: (draft: Draft<T>) => void): T {
|
||||
return produceWithMetadata(base, recipe).value;
|
||||
}
|
||||
|
||||
function stateFor(
|
||||
context: DraftContext,
|
||||
base: Container,
|
||||
parent: DraftState | undefined,
|
||||
key: ChildKey | undefined,
|
||||
): DraftState {
|
||||
const existing = context.states.get(base);
|
||||
if (existing !== undefined) return existing;
|
||||
const state = new DraftState(context, base, parent, key);
|
||||
const target: object = Array.isArray(base) ? [] : state;
|
||||
if (Array.isArray(base)) {
|
||||
(target as unknown[]).length = base.length;
|
||||
Object.defineProperty(target, DRAFT_STATE, { value: state, configurable: true });
|
||||
}
|
||||
state.target = target;
|
||||
state.proxy = new Proxy(target, SHARED_HANDLER);
|
||||
context.states.set(base, state);
|
||||
context.created.push(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
const SHARED_HANDLER: ProxyHandler<object> = {
|
||||
deleteProperty(target, property): boolean {
|
||||
const state = targetState(target);
|
||||
assertWritable(state, property);
|
||||
if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported");
|
||||
if (Array.isArray(state.base)) throw new TypeError("Draft arrays cannot contain holes");
|
||||
return deleteObjectProperty(state, property);
|
||||
},
|
||||
defineProperty(target): never {
|
||||
assertActive(targetState(target).context);
|
||||
throw new TypeError("Defining draft properties is not supported");
|
||||
},
|
||||
get(target, property): unknown {
|
||||
const state = targetState(target);
|
||||
assertActive(state.context);
|
||||
if (property === DRAFT_STATE) return state;
|
||||
if (Array.isArray(state.base) && typeof property === "string" && ARRAY_MUTATORS.has(property)) {
|
||||
return MUTATORS[property as keyof typeof MUTATORS];
|
||||
}
|
||||
const value = Reflect.get(currentValue(state), property, state.proxy);
|
||||
return draftValue(state.context, value, state, childKey(property));
|
||||
},
|
||||
getOwnPropertyDescriptor(target, property): PropertyDescriptor | undefined {
|
||||
const state = targetState(target);
|
||||
assertActive(state.context);
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(currentValue(state), property);
|
||||
if (descriptor === undefined) return undefined;
|
||||
if (Array.isArray(state.base) && property === "length") {
|
||||
syncArrayTarget(state);
|
||||
return Reflect.getOwnPropertyDescriptor(state.target, property);
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: descriptor.enumerable,
|
||||
writable: true,
|
||||
value:
|
||||
"value" in descriptor ? draftValue(state.context, descriptor.value, state, childKey(property)) : undefined,
|
||||
};
|
||||
},
|
||||
getPrototypeOf(target): object | null {
|
||||
const state = targetState(target);
|
||||
assertActive(state.context);
|
||||
return Object.getPrototypeOf(state.base);
|
||||
},
|
||||
has(target, property): boolean {
|
||||
const state = targetState(target);
|
||||
assertActive(state.context);
|
||||
return Reflect.has(currentValue(state), property);
|
||||
},
|
||||
isExtensible(target): boolean {
|
||||
assertActive(targetState(target).context);
|
||||
return true;
|
||||
},
|
||||
ownKeys(target): ArrayLike<string | symbol> {
|
||||
const state = targetState(target);
|
||||
assertActive(state.context);
|
||||
return Reflect.ownKeys(currentValue(state));
|
||||
},
|
||||
preventExtensions(target): never {
|
||||
assertActive(targetState(target).context);
|
||||
throw new TypeError("Drafts cannot be made non-extensible");
|
||||
},
|
||||
set(target, property, value): boolean {
|
||||
const state = targetState(target);
|
||||
assertWritable(state, property);
|
||||
return writeProperty(state, property, value);
|
||||
},
|
||||
setPrototypeOf(target): never {
|
||||
assertActive(targetState(target).context);
|
||||
throw new TypeError("Changing a draft prototype is not supported");
|
||||
},
|
||||
};
|
||||
|
||||
function targetState(target: object): DraftState {
|
||||
return Array.isArray(target)
|
||||
? (target as unknown as Record<symbol, DraftState>)[DRAFT_STATE]!
|
||||
: (target as DraftState);
|
||||
}
|
||||
|
||||
function childKey(property: PropertyKey): ChildKey | undefined {
|
||||
if (typeof property === "symbol") return undefined;
|
||||
const index = arrayIndex(property);
|
||||
return index ?? property;
|
||||
}
|
||||
|
||||
function draftState(value: unknown): DraftState | undefined {
|
||||
if (!isContainer(value)) return undefined;
|
||||
return (value as Record<symbol, DraftState | undefined>)[DRAFT_STATE];
|
||||
}
|
||||
|
||||
function draftValue(context: DraftContext, value: unknown, parent?: DraftState, key?: ChildKey): unknown {
|
||||
if (!isContainer(value)) return value;
|
||||
const existingDraft = draftState(value);
|
||||
if (existingDraft !== undefined) return value;
|
||||
return stateFor(context, value, parent, key).proxy;
|
||||
}
|
||||
|
||||
function currentValue(state: DraftState): Container {
|
||||
assertActive(state.context);
|
||||
return state.copy ?? state.base;
|
||||
}
|
||||
|
||||
function ensureCopy(state: DraftState): Container {
|
||||
markChanged(state);
|
||||
if (state.copy !== undefined) return state.copy;
|
||||
state.copy = shallowCopy(state.base);
|
||||
return state.copy;
|
||||
}
|
||||
|
||||
function shallowCopy(value: Container): Container {
|
||||
if (Array.isArray(value)) return Array.from(value);
|
||||
if (Object.getPrototypeOf(value) === null)
|
||||
return Object.assign(Object.create(null) as Record<string, unknown>, value);
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function markChanged(state: DraftState): void {
|
||||
let current: DraftState | undefined = state;
|
||||
while (current !== undefined && !current.dirty) {
|
||||
current.dirty = true;
|
||||
const parent: DraftState | undefined = current.parent;
|
||||
if (parent !== undefined && current.key !== undefined) registerChild(parent, current.key, current);
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function registerChild(parent: DraftState, key: ChildKey, child: DraftState): void {
|
||||
const children = parent.children;
|
||||
if (children === undefined) parent.children = child;
|
||||
else if (children instanceof Map) children.set(key, child);
|
||||
else if (children !== child)
|
||||
parent.children = new Map([
|
||||
[children.key!, children],
|
||||
[key, child],
|
||||
]);
|
||||
if (Array.isArray(parent.base) && typeof key === "number") markArrayIndex(parent, key);
|
||||
}
|
||||
|
||||
function markArrayIndex(state: DraftState, index: number): void {
|
||||
const changed = state.changedIndices;
|
||||
if (changed === undefined) state.changedIndices = index;
|
||||
else if (changed instanceof Set) changed.add(index);
|
||||
else if (changed !== index) state.changedIndices = new Set([changed, index]);
|
||||
}
|
||||
|
||||
function writeProperty(state: DraftState, property: string | symbol, supplied: unknown): boolean {
|
||||
if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported");
|
||||
if (Array.isArray(state.base)) {
|
||||
if (property === "length") return writeArrayLength(state, supplied);
|
||||
const index = arrayIndex(property);
|
||||
if (index === undefined) throw new TypeError("Only array indices and length can be written");
|
||||
const current = currentValue(state) as unknown[];
|
||||
if (index > current.length) throw new TypeError("Draft arrays cannot contain holes");
|
||||
const stored = clonePlacement(supplied, state.context);
|
||||
if (index < current.length && Object.is(current[index], stored)) return true;
|
||||
if (index < current.length) detachCurrentChild(state, current[index]);
|
||||
const copy = ensureCopy(state) as unknown[];
|
||||
defineData(copy, String(index), stored);
|
||||
if (index === current.length) state.structural = true;
|
||||
else markArrayIndex(state, index);
|
||||
syncArrayTarget(state);
|
||||
return true;
|
||||
}
|
||||
if (supplied === undefined) return deleteObjectProperty(state, property);
|
||||
const stored = clonePlacement(supplied, state.context);
|
||||
const current = currentValue(state) as Record<string, unknown>;
|
||||
if (Object.hasOwn(current, property) && !isContainer(stored) && Object.is(current[property], stored)) return true;
|
||||
if (Object.hasOwn(current, property)) detachCurrentChild(state, current[property]);
|
||||
const copy = ensureCopy(state) as Record<string, unknown>;
|
||||
defineData(copy, property, stored);
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeArrayLength(state: DraftState, supplied: unknown): boolean {
|
||||
const probe: unknown[] = [];
|
||||
Reflect.set(probe, "length", supplied);
|
||||
const next = probe.length;
|
||||
const current = (currentValue(state) as unknown[]).length;
|
||||
if (next === current) return true;
|
||||
const copy = ensureCopy(state) as unknown[];
|
||||
if (next < current) copy.length = next;
|
||||
else {
|
||||
copy.length = next;
|
||||
for (let index = current; index < next; index++) copy[index] = null;
|
||||
}
|
||||
state.structural = true;
|
||||
syncArrayTarget(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteObjectProperty(state: DraftState, property: string): boolean {
|
||||
const current = currentValue(state);
|
||||
if (!Object.hasOwn(current, property)) return true;
|
||||
detachCurrentChild(state, (current as Record<string, unknown>)[property]);
|
||||
Reflect.deleteProperty(ensureCopy(state), property);
|
||||
return true;
|
||||
}
|
||||
|
||||
function detachCurrentChild(parent: DraftState, value: unknown): void {
|
||||
if (!isContainer(value)) return;
|
||||
const child = draftState(value) ?? parent.context.states.get(value);
|
||||
if (child?.parent !== parent) return;
|
||||
const children = parent.children;
|
||||
if (children === child) parent.children = undefined;
|
||||
else if (children instanceof Map && child.key !== undefined && children.get(child.key) === child) {
|
||||
children.delete(child.key);
|
||||
}
|
||||
child.parent = undefined;
|
||||
child.key = undefined;
|
||||
}
|
||||
|
||||
function arrayIndex(property: PropertyKey): number | undefined {
|
||||
if (typeof property !== "string" || property === "") return undefined;
|
||||
const index = Number(property);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= 4_294_967_295 || String(index) !== property) return undefined;
|
||||
return index;
|
||||
}
|
||||
|
||||
function mutatorState(receiver: unknown): DraftState | undefined {
|
||||
if (!isContainer(receiver)) return undefined;
|
||||
return draftState(receiver);
|
||||
}
|
||||
|
||||
const MUTATORS = {
|
||||
push(this: unknown, ...args: unknown[]): number {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.push, this, args) as number;
|
||||
assertActive(state.context);
|
||||
if (args.length === 0) return (currentValue(state) as unknown[]).length;
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
for (const item of cloneItems(args, state.context)) defineData(target, String(target.length), item);
|
||||
state.structural = true;
|
||||
syncArrayTarget(state);
|
||||
return target.length;
|
||||
},
|
||||
pop(this: unknown): unknown {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.pop, this, []);
|
||||
assertActive(state.context);
|
||||
const current = currentValue(state) as unknown[];
|
||||
if (current.length === 0) return undefined;
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
const value = target[target.length - 1];
|
||||
target.length -= 1;
|
||||
state.structural = true;
|
||||
syncArrayTarget(state);
|
||||
return draftValue(state.context, value, state, target.length);
|
||||
},
|
||||
shift(this: unknown): unknown {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.shift, this, []);
|
||||
assertActive(state.context);
|
||||
const current = currentValue(state) as unknown[];
|
||||
if (current.length === 0) return undefined;
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
const value = Reflect.apply(Array.prototype.shift, target, []);
|
||||
state.structural = true;
|
||||
syncArrayTarget(state);
|
||||
return draftValue(state.context, value, state, 0);
|
||||
},
|
||||
unshift(this: unknown, ...args: unknown[]): number {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.unshift, this, args) as number;
|
||||
assertActive(state.context);
|
||||
if (args.length === 0) return (currentValue(state) as unknown[]).length;
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
const items = cloneItems(args, state.context);
|
||||
insertChunked(target, 0, items);
|
||||
state.structural = true;
|
||||
syncArrayTarget(state);
|
||||
return target.length;
|
||||
},
|
||||
splice(this: unknown, ...args: unknown[]): unknown[] {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.splice, this, args) as unknown[];
|
||||
assertActive(state.context);
|
||||
const length = (currentValue(state) as unknown[]).length;
|
||||
const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length);
|
||||
const remove =
|
||||
args.length === 0
|
||||
? 0
|
||||
: args.length === 1
|
||||
? length - start
|
||||
: Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start);
|
||||
const inputs = cloneItems(args.slice(2), state.context);
|
||||
if (remove === 0 && inputs.length === 0) return [];
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
const removed = spliceCapturedLength(target, start, remove, inputs, length);
|
||||
state.structural = true;
|
||||
syncArrayTarget(state);
|
||||
return removed.map((value, offset) => draftValue(state.context, value, state, start + offset));
|
||||
},
|
||||
sort(this: unknown, comparator?: unknown): unknown {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.sort, this, [comparator]);
|
||||
assertActive(state.context);
|
||||
if (comparator !== undefined && typeof comparator !== "function")
|
||||
throw new TypeError("Comparator must be a function");
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
state.structural = true;
|
||||
Reflect.apply(Array.prototype.sort, target, [
|
||||
(left: unknown, right: unknown): number => {
|
||||
const draftedLeft = draftValue(state.context, left, state);
|
||||
const draftedRight = draftValue(state.context, right, state);
|
||||
if (typeof comparator === "function") {
|
||||
// Array.prototype.sort performs ToNumber on the return value. Returning
|
||||
// it directly preserves native bigint TypeError behavior.
|
||||
return Reflect.apply(comparator, undefined, [draftedLeft, draftedRight]) as number;
|
||||
}
|
||||
const leftString = String(draftedLeft);
|
||||
const rightString = String(draftedRight);
|
||||
return leftString < rightString ? -1 : leftString > rightString ? 1 : 0;
|
||||
},
|
||||
]);
|
||||
syncArrayTarget(state);
|
||||
return state.proxy;
|
||||
},
|
||||
reverse(this: unknown): unknown {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.reverse, this, []);
|
||||
assertActive(state.context);
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
if (target.length > 1) {
|
||||
Reflect.apply(Array.prototype.reverse, target, []);
|
||||
state.structural = true;
|
||||
}
|
||||
return state.proxy;
|
||||
},
|
||||
fill(this: unknown, supplied: unknown, startArg?: unknown, endArg?: unknown): unknown {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]);
|
||||
assertActive(state.context);
|
||||
const length = (currentValue(state) as unknown[]).length;
|
||||
const start = startArg === undefined ? 0 : clampIndex(toIntegerOrInfinity(startArg), length);
|
||||
const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length);
|
||||
if (end <= start) return state.proxy;
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
for (let index = start; index < end; index++) target[index] = clonePlacement(supplied, state.context);
|
||||
state.structural = true;
|
||||
return state.proxy;
|
||||
},
|
||||
copyWithin(this: unknown, targetArg: unknown, startArg: unknown, endArg?: unknown): unknown {
|
||||
const state = mutatorState(this);
|
||||
if (state === undefined) return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]);
|
||||
assertActive(state.context);
|
||||
const length = (currentValue(state) as unknown[]).length;
|
||||
const targetIndex = clampIndex(toIntegerOrInfinity(targetArg), length);
|
||||
const start = clampIndex(toIntegerOrInfinity(startArg), length);
|
||||
const end = endArg === undefined ? length : clampIndex(toIntegerOrInfinity(endArg), length);
|
||||
const count = Math.min(Math.max(end - start, 0), length - targetIndex);
|
||||
if (count === 0) return state.proxy;
|
||||
const source = (currentValue(state) as unknown[]).slice(start, start + count);
|
||||
const target = ensureCopy(state) as unknown[];
|
||||
for (let offset = 0; offset < count; offset++)
|
||||
target[targetIndex + offset] = cloneLogical(source[offset], state.context);
|
||||
state.structural = true;
|
||||
return state.proxy;
|
||||
},
|
||||
};
|
||||
|
||||
function cloneItems(values: readonly unknown[], context: DraftContext): unknown[] {
|
||||
return values.map((value) => clonePlacement(value, context));
|
||||
}
|
||||
|
||||
function clonePlacement(value: unknown, _context: DraftContext): unknown {
|
||||
if (!isContainer(value)) return value;
|
||||
const state = draftState(value);
|
||||
return state === undefined ? cloneJson(value) : cloneState(state);
|
||||
}
|
||||
|
||||
/** Fast trusted strict-JSON clone. Inputs are caller-guaranteed plain, dense, acyclic trees. */
|
||||
export function cloneJson<T>(value: T): T {
|
||||
if (!isContainer(value)) return value;
|
||||
if (Array.isArray(value)) return value.map((child) => cloneJson(child)) as T;
|
||||
const result = Object.create(Object.getPrototypeOf(value)) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value)) defineData(result, key, cloneJson((value as Record<string, unknown>)[key]));
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function cloneState(state: DraftState): Container {
|
||||
assertActive(state.context);
|
||||
const context = state.context;
|
||||
const current = state.copy ?? state.base;
|
||||
if (Array.isArray(current)) {
|
||||
const result = new Array<unknown>(current.length);
|
||||
for (let index = 0; index < current.length; index++) result[index] = cloneLogical(current[index], context);
|
||||
return result;
|
||||
}
|
||||
const result = Object.create(Object.getPrototypeOf(current)) as Record<string, unknown>;
|
||||
for (const key of Object.keys(current)) defineData(result, key, cloneLogical(current[key], context));
|
||||
return result;
|
||||
}
|
||||
|
||||
function cloneLogical(value: unknown, context: DraftContext): unknown {
|
||||
if (!isContainer(value)) return value;
|
||||
const proxyState = draftState(value);
|
||||
if (proxyState !== undefined) return cloneState(proxyState);
|
||||
const state = context.states.get(value);
|
||||
return state === undefined ? cloneJson(value) : cloneState(state);
|
||||
}
|
||||
|
||||
function finalize(state: DraftState): Container {
|
||||
if (state.finalized !== undefined) return state.finalized;
|
||||
if (!state.dirty) return state.base;
|
||||
const current = state.copy ?? state.base;
|
||||
let result = current;
|
||||
if (Array.isArray(current) && state.structural) {
|
||||
for (let index = 0; index < current.length; index++) {
|
||||
const value = current[index];
|
||||
if (!isContainer(value)) continue;
|
||||
const child = draftState(value) ?? state.context.states.get(value);
|
||||
if (child === undefined || !child.dirty) continue;
|
||||
const next = finalize(child);
|
||||
if (next === value) continue;
|
||||
if (result === state.base) result = shallowCopy(state.base);
|
||||
(result as unknown[])[index] = next;
|
||||
}
|
||||
} else {
|
||||
forEachDirtyChild(state, (key, child) => {
|
||||
const value = (current as Record<ChildKey, unknown>)[key];
|
||||
if (value !== child.base && draftState(value) !== child) return;
|
||||
const next = finalize(child);
|
||||
if (next === value) return;
|
||||
if (result === state.base) result = shallowCopy(state.base);
|
||||
defineData(result, String(key), next);
|
||||
if (Array.isArray(current) && typeof key === "number") markArrayIndex(state, key);
|
||||
});
|
||||
}
|
||||
if (result !== state.base && shallowEqual(state.base, result)) result = state.base;
|
||||
state.finalized = result;
|
||||
if (
|
||||
Array.isArray(state.base) &&
|
||||
Array.isArray(result) &&
|
||||
result !== state.base &&
|
||||
!state.structural &&
|
||||
state.base.length === result.length
|
||||
) {
|
||||
const base = state.base as unknown[];
|
||||
const output = result as unknown[];
|
||||
const changed = changedIndexList(state);
|
||||
if (changed !== undefined) {
|
||||
const keys = changed.filter((index) => index < base.length && index < output.length);
|
||||
state.context.hints.set(result, { base: state.base as JsonValue[], keys });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function forEachDirtyChild(state: DraftState, visit: (key: ChildKey, child: DraftState) => void): void {
|
||||
const children = state.children;
|
||||
if (children === undefined) return;
|
||||
if (children instanceof Map) {
|
||||
for (const [key, child] of children) if (child.dirty) visit(key, child);
|
||||
} else if (children.dirty && children.key !== undefined) visit(children.key, children);
|
||||
}
|
||||
|
||||
function changedIndexList(state: DraftState): number[] | undefined {
|
||||
const changed = state.changedIndices;
|
||||
if (changed === undefined) return [];
|
||||
// The differ only consumes narrow hints. Avoid materializing and sorting a
|
||||
// dense set that it would immediately discard.
|
||||
if (changed instanceof Set) {
|
||||
if (changed.size >= 2_048) return undefined;
|
||||
return [...changed].sort((left, right) => left - right);
|
||||
}
|
||||
return [changed];
|
||||
}
|
||||
|
||||
function shallowEqual(left: Container, right: Container): boolean {
|
||||
if (Array.isArray(left) !== Array.isArray(right)) return false;
|
||||
if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) return false;
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
for (const key of leftKeys) {
|
||||
if (!Object.hasOwn(right, key) || !Object.is(leftRecord[key], rightRecord[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function defineData(target: object, key: PropertyKey, value: unknown): void {
|
||||
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
|
||||
}
|
||||
|
||||
function spliceCapturedLength(
|
||||
target: unknown[],
|
||||
start: number,
|
||||
remove: number,
|
||||
items: readonly unknown[],
|
||||
capturedLength: number,
|
||||
): unknown[] {
|
||||
const removed = new Array<unknown>(remove);
|
||||
for (let offset = 0; offset < remove; offset++) {
|
||||
defineData(removed, String(offset), denseArrayValue(target, start + offset));
|
||||
}
|
||||
if (items.length < remove) {
|
||||
for (let source = start + remove; source < capturedLength; source++) {
|
||||
defineData(target, String(source - remove + items.length), denseArrayValue(target, source));
|
||||
}
|
||||
} else if (items.length > remove) {
|
||||
for (let source = capturedLength - 1; source >= start + remove; source--) {
|
||||
defineData(target, String(source - remove + items.length), denseArrayValue(target, source));
|
||||
}
|
||||
}
|
||||
for (let offset = 0; offset < items.length; offset++) defineData(target, String(start + offset), items[offset]);
|
||||
target.length = capturedLength - remove + items.length;
|
||||
return removed;
|
||||
}
|
||||
|
||||
function denseArrayValue(target: readonly unknown[], index: number): unknown {
|
||||
return index < target.length && Object.hasOwn(target, index) && target[index] !== undefined ? target[index] : null;
|
||||
}
|
||||
|
||||
function insertChunked(target: unknown[], index: number, values: readonly unknown[]): void {
|
||||
for (let offset = 0; offset < values.length; offset += MAX_NATIVE_ARRAY_INSERT_ITEMS) {
|
||||
target.splice(index + offset, 0, ...values.slice(offset, offset + MAX_NATIVE_ARRAY_INSERT_ITEMS));
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegerOrInfinity(value: unknown): number {
|
||||
const number = +(value as number);
|
||||
if (Number.isNaN(number) || number === 0) return 0;
|
||||
return Number.isFinite(number) ? Math.trunc(number) : number;
|
||||
}
|
||||
|
||||
function clampIndex(value: number, length: number): number {
|
||||
if (value === Number.NEGATIVE_INFINITY) return 0;
|
||||
if (value < 0) return Math.max(length + value, 0);
|
||||
return Math.min(value, length);
|
||||
}
|
||||
|
||||
function syncArrayTarget(state: DraftState): void {
|
||||
(state.target as unknown[]).length = ((state.copy ?? state.base) as unknown[]).length;
|
||||
}
|
||||
|
||||
function assertWritable(state: DraftState, property: PropertyKey): void {
|
||||
assertActive(state.context);
|
||||
if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported");
|
||||
}
|
||||
|
||||
function assertActive(context: DraftContext): void {
|
||||
if (!context.active) throw new TypeError("Cannot use a draft outside its change callback");
|
||||
}
|
||||
|
||||
function isContainer(value: unknown): value is Container {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
return (
|
||||
((typeof value === "object" && value !== null) || typeof value === "function") &&
|
||||
typeof (value as { then?: unknown }).then === "function"
|
||||
);
|
||||
}
|
||||
20
packages/chord/src/delta/cow/index.ts
Normal file
20
packages/chord/src/delta/cow/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export type { JsonValue, NonEmptyPath, Op, Path, PathRef, Seg, WireOp } from "../index.ts";
|
||||
export {
|
||||
apply,
|
||||
assertSafePath,
|
||||
assertValidOp,
|
||||
assertValidWireOp,
|
||||
decoder,
|
||||
encoder,
|
||||
isBase,
|
||||
isReplace,
|
||||
overlap,
|
||||
PathError,
|
||||
RESERVED_SEGMENTS,
|
||||
UnsafePathError,
|
||||
} from "../index.ts";
|
||||
export { applyImmutable } from "./apply-immutable.ts";
|
||||
export { diffRevisions } from "./diff.ts";
|
||||
export type { Draft } from "./draft.ts";
|
||||
export type { Change, Prepared, Tracker } from "./tracker.ts";
|
||||
export { track } from "./tracker.ts";
|
||||
225
packages/chord/src/delta/cow/tracker.ts
Normal file
225
packages/chord/src/delta/cow/tracker.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import type { JsonValue } from "../../types.ts";
|
||||
import type { Op } from "../index.ts";
|
||||
import { applyImmutable } from "./apply-immutable.ts";
|
||||
import { diffRevisions } from "./diff.ts";
|
||||
import { cloneJson, createDraftTransaction, type Draft, type DraftTransaction, type ProduceMetadata } from "./draft.ts";
|
||||
|
||||
export interface Prepared<T extends object> {
|
||||
readonly base: T;
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
export interface Change<T extends object> {
|
||||
readonly state: Draft<T>;
|
||||
prepare(): Prepared<T>;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
export interface Tracker<T extends object> {
|
||||
readonly value: T;
|
||||
readonly revision: number;
|
||||
beginChange(): Change<T>;
|
||||
prepareReplace(value: T): Prepared<T>;
|
||||
adopt(prepared: Prepared<T>): void;
|
||||
}
|
||||
|
||||
type PreparedMetadata = {
|
||||
readonly owner: object;
|
||||
readonly revision: number;
|
||||
status: "prepared" | "consumed" | "aborted";
|
||||
};
|
||||
|
||||
const PREPARED = new WeakMap<object, PreparedMetadata>();
|
||||
|
||||
class PreparedImpl<T extends object> implements Prepared<T> {
|
||||
readonly base: T;
|
||||
readonly value: T;
|
||||
readonly ops: readonly Op[];
|
||||
|
||||
constructor(metadata: PreparedMetadata, base: T, value: T, ops: readonly Op[]) {
|
||||
this.base = base;
|
||||
this.value = value;
|
||||
this.ops = ops;
|
||||
PREPARED.set(this, metadata);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
const metadata = PREPARED.get(this)!;
|
||||
if (metadata.status === "prepared") metadata.status = "aborted";
|
||||
}
|
||||
}
|
||||
|
||||
class ChangeImpl<T extends object> implements Change<T> {
|
||||
readonly state: Draft<T>;
|
||||
#tracker: TrackerImpl<T> | undefined;
|
||||
#transaction: DraftTransaction<T> | undefined;
|
||||
#preparedMetadata: PreparedMetadata | undefined;
|
||||
#status: "open" | "prepared" | "aborted" = "open";
|
||||
|
||||
constructor(tracker: TrackerImpl<T>, transaction: DraftTransaction<T>) {
|
||||
this.#tracker = tracker;
|
||||
this.#transaction = transaction;
|
||||
this.state = transaction.state;
|
||||
}
|
||||
|
||||
prepare(): Prepared<T> {
|
||||
if (this.#status !== "open") throw new Error("Change has already been settled");
|
||||
const tracker = this.#tracker!;
|
||||
const transaction = this.#transaction!;
|
||||
try {
|
||||
const prepared = tracker.prepareDraft(transaction);
|
||||
this.#preparedMetadata = PREPARED.get(prepared)!;
|
||||
this.#status = "prepared";
|
||||
return prepared;
|
||||
} catch (error) {
|
||||
this.#status = "aborted";
|
||||
throw error;
|
||||
} finally {
|
||||
this.#tracker = undefined;
|
||||
this.#transaction = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
if (this.#status === "aborted") return;
|
||||
if (this.#status === "prepared") {
|
||||
if (this.#preparedMetadata?.status === "prepared") this.#preparedMetadata.status = "aborted";
|
||||
this.#preparedMetadata = undefined;
|
||||
this.#status = "aborted";
|
||||
return;
|
||||
}
|
||||
this.#status = "aborted";
|
||||
const tracker = this.#tracker!;
|
||||
const transaction = this.#transaction!;
|
||||
this.#tracker = undefined;
|
||||
this.#transaction = undefined;
|
||||
try {
|
||||
transaction.abort();
|
||||
} finally {
|
||||
tracker.releaseChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TrackerImpl<T extends object> implements Tracker<T> {
|
||||
readonly #owner = {};
|
||||
#value: T;
|
||||
#revision = 0;
|
||||
#changing = false;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.#value = initial;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
get revision(): number {
|
||||
return this.#revision;
|
||||
}
|
||||
|
||||
beginChange(): Change<T> {
|
||||
this.#reserveChange();
|
||||
try {
|
||||
return new ChangeImpl(this, createDraftTransaction(this.#value));
|
||||
} catch (error) {
|
||||
this.#changing = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
prepareReplace(value: T): Prepared<T> {
|
||||
this.#reserveChange();
|
||||
try {
|
||||
return this.#prepare({ value, hints: new WeakMap() }, true);
|
||||
} finally {
|
||||
this.#changing = false;
|
||||
}
|
||||
}
|
||||
|
||||
adopt(prepared: Prepared<T>): void {
|
||||
const metadata = PREPARED.get(prepared as object);
|
||||
if (metadata?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker");
|
||||
if (metadata.status === "consumed") throw new Error("Prepared change has already been used");
|
||||
if (metadata.status === "aborted") throw new Error("Prepared change has been aborted");
|
||||
if (this.#changing) throw new Error("Cannot adopt while a change is active");
|
||||
if (metadata.revision !== this.#revision || this.#value !== prepared.base)
|
||||
throw new Error("Prepared change is stale");
|
||||
metadata.status = "consumed";
|
||||
this.#value = prepared.value;
|
||||
this.#revision += 1;
|
||||
}
|
||||
|
||||
prepareDraft(transaction: DraftTransaction<T>): PreparedImpl<T> {
|
||||
try {
|
||||
return this.#prepare(transaction.finish());
|
||||
} finally {
|
||||
this.#changing = false;
|
||||
}
|
||||
}
|
||||
|
||||
releaseChange(): void {
|
||||
this.#changing = false;
|
||||
}
|
||||
|
||||
#reserveChange(): void {
|
||||
if (this.#changing) throw new Error("Tracker already has an active change");
|
||||
this.#changing = true;
|
||||
}
|
||||
|
||||
#prepare(produced: ProduceMetadata<T>, replacement = false): PreparedImpl<T> {
|
||||
const base = this.#value;
|
||||
const operations = diffRevisions(
|
||||
base as unknown as JsonValue,
|
||||
produced.value as unknown as JsonValue,
|
||||
produced.hints,
|
||||
);
|
||||
if (operations.length === 0) {
|
||||
return new PreparedImpl({ owner: this.#owner, revision: this.#revision, status: "prepared" }, base, base, []);
|
||||
}
|
||||
// Trusted ownership rule: value and public ops are independent immutable
|
||||
// transfers. Draft revisions are reconstructed from a private detached batch:
|
||||
// differ payloads may reference retained base objects, so applying them directly
|
||||
// could introduce aliases. Replacements are already caller-transferred roots.
|
||||
// No freeze or validation runs.
|
||||
const value = replacement ? produced.value : applyImmutable(base, detachOperations(operations));
|
||||
return new PreparedImpl(
|
||||
{ owner: this.#owner, revision: this.#revision, status: "prepared" },
|
||||
base,
|
||||
value,
|
||||
detachOperations(operations),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Take O(1) ownership of an alias-free mutable strict-JSON root. */
|
||||
export function track<T extends object>(initial: T): Tracker<T> {
|
||||
return new TrackerImpl(initial);
|
||||
}
|
||||
|
||||
function detachOperations(operations: readonly Op[]): Op[] {
|
||||
return operations.map((operation): Op => {
|
||||
switch (operation[0]) {
|
||||
case "r":
|
||||
return ["r", cloneJson(operation[1])];
|
||||
case "s":
|
||||
return ["s", operation[1], cloneJson(operation[2])];
|
||||
case "p":
|
||||
return ["p", operation[1], operation[2], operation[3], operation[4].map((value) => cloneJson(value))];
|
||||
case "m":
|
||||
return ["m", operation[1], operation[2].slice()];
|
||||
case "d":
|
||||
return operation;
|
||||
case "a":
|
||||
return operation;
|
||||
case "t":
|
||||
return operation;
|
||||
default:
|
||||
throw new TypeError(`Unknown operation ${(operation as readonly [unknown])[0]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
1168
packages/chord/test/delta-astra/astra.test.ts
Normal file
1168
packages/chord/test/delta-astra/astra.test.ts
Normal file
File diff suppressed because it is too large
Load diff
142
packages/chord/test/delta-astra/benchmark-head.worker.ts
Normal file
142
packages/chord/test/delta-astra/benchmark-head.worker.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { diffRevisions } from "../../src/delta/diff.ts";
|
||||
import { type Draft, produceWithMetadata } from "../../src/delta/draft.ts";
|
||||
import type { Op } from "../../src/delta/index.ts";
|
||||
import { JsonRevisionStore } from "../../src/delta/value.ts";
|
||||
import type { JsonValue } from "../../src/types.ts";
|
||||
|
||||
type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const scenario = process.argv[2] as Scenario;
|
||||
const size = Number(process.argv[3] ?? 100_000);
|
||||
const trial = Number(process.argv[4] ?? 0);
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })),
|
||||
};
|
||||
}
|
||||
|
||||
function sum(document: Document | Draft<Document>): number {
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value;
|
||||
return checksum;
|
||||
}
|
||||
|
||||
async function gc(): Promise<void> {
|
||||
assert.ok(global.gc, "benchmark worker requires --expose-gc");
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
await gc();
|
||||
const baselineHeap = process.memoryUsage().heapUsed;
|
||||
let input: Document | undefined = fixture(size);
|
||||
if (scenario === "sort") input.rows.reverse();
|
||||
const store = new JsonRevisionStore();
|
||||
const importStart = performance.now();
|
||||
let value = store.import(input);
|
||||
const importMs = performance.now() - importStart;
|
||||
input = undefined;
|
||||
await gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
let peakHeap = readyHeap;
|
||||
let mutateMs = 0;
|
||||
let prepareMs = 0;
|
||||
let serializeMs = 0;
|
||||
let adoptMs = 0;
|
||||
let opBytes = 0;
|
||||
let operationCount = 0;
|
||||
let checksum = 0;
|
||||
const queueOperations = Math.min(size, 1_000);
|
||||
|
||||
if (scenario === "committed-read") {
|
||||
const start = performance.now();
|
||||
checksum = sum(value);
|
||||
mutateMs = performance.now() - start;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
} else if (scenario !== "import") {
|
||||
const draftStart = performance.now();
|
||||
const produced = produceWithMetadata(value, (draft) => {
|
||||
const start = performance.now();
|
||||
switch (scenario) {
|
||||
case "draft-read":
|
||||
checksum = sum(draft);
|
||||
break;
|
||||
case "sparse":
|
||||
for (let index = 0; index < size; index += 1_000) draft.rows[index]!.value = -index;
|
||||
checksum = draft.rows.at(-1)!.value;
|
||||
break;
|
||||
case "queue":
|
||||
for (let index = 0; index < queueOperations; index++) {
|
||||
draft.rows.shift();
|
||||
draft.rows.push({ id: size + index, value: index, payload: "queue" });
|
||||
}
|
||||
checksum = draft.rows.length;
|
||||
break;
|
||||
case "sort":
|
||||
draft.rows.sort((left, right) => left.id - right.id);
|
||||
checksum = draft.rows[0]!.id;
|
||||
break;
|
||||
case "dense":
|
||||
for (let index = 0; index < size; index++) draft.rows[index]!.value = -index;
|
||||
checksum = draft.rows.at(-1)!.value;
|
||||
break;
|
||||
case "unshift": {
|
||||
const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" }));
|
||||
Reflect.apply(draft.rows.unshift, draft.rows, inserted);
|
||||
checksum = draft.rows.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mutateMs = performance.now() - start;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
});
|
||||
const draftFinalizeMs = performance.now() - draftStart - mutateMs;
|
||||
const prepareStart = performance.now();
|
||||
const next = produced.value === value ? value : store.commit(produced.value, produced.owned);
|
||||
const operations: readonly Op[] =
|
||||
next === value ? [] : diffRevisions(value as unknown as JsonValue, next as unknown as JsonValue);
|
||||
prepareMs = draftFinalizeMs + performance.now() - prepareStart;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const serializeStart = performance.now();
|
||||
const serialized = JSON.stringify(operations);
|
||||
serializeMs = performance.now() - serializeStart;
|
||||
opBytes = Buffer.byteLength(serialized);
|
||||
operationCount = operations.length;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const adoptStart = performance.now();
|
||||
value = next;
|
||||
adoptMs = performance.now() - adoptStart;
|
||||
}
|
||||
|
||||
await gc();
|
||||
const retainedHeap = process.memoryUsage().heapUsed;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode: "head",
|
||||
scenario,
|
||||
size,
|
||||
trial,
|
||||
queueOperations,
|
||||
importMs,
|
||||
mutateMs,
|
||||
prepareMs,
|
||||
serializeMs,
|
||||
adoptMs,
|
||||
opBytes,
|
||||
operationCount,
|
||||
checksum,
|
||||
readyMiB: (readyHeap - baselineHeap) / MiB,
|
||||
transientMiB: (peakHeap - readyHeap) / MiB,
|
||||
retainedMiB: (retainedHeap - baselineHeap) / MiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
}),
|
||||
);
|
||||
134
packages/chord/test/delta-astra/benchmark.ts
Normal file
134
packages/chord/test/delta-astra/benchmark.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Mode = "astra" | "delta" | "head";
|
||||
type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
size: number;
|
||||
trial: number;
|
||||
queueOperations: number;
|
||||
importMs: number;
|
||||
mutateMs: number;
|
||||
prepareMs: number;
|
||||
serializeMs: number;
|
||||
adoptMs: number;
|
||||
opBytes: number;
|
||||
operationCount: number;
|
||||
checksum: number;
|
||||
readyMiB: number;
|
||||
transientMiB: number;
|
||||
retainedMiB: number;
|
||||
maxRssMiB: number;
|
||||
};
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
|
||||
const scenarios: readonly Scenario[] = [
|
||||
"import",
|
||||
"committed-read",
|
||||
"draft-read",
|
||||
"sparse",
|
||||
"queue",
|
||||
"sort",
|
||||
"dense",
|
||||
"unshift",
|
||||
];
|
||||
const modes: readonly Mode[] = ["astra", "delta", "head"];
|
||||
const quick = process.argv.includes("--quick");
|
||||
const size = quick ? 10_000 : 100_000;
|
||||
const trials = quick ? 1 : 3;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
||||
const headRoot = join(tmpdir(), `chord-astra-head-${process.pid}`);
|
||||
const headFiles = [
|
||||
"packages/chord/src/state/diff.ts",
|
||||
"packages/chord/src/state/draft.ts",
|
||||
"packages/chord/src/state/value.ts",
|
||||
"packages/chord/src/delta/index.ts",
|
||||
"packages/chord/src/types.ts",
|
||||
] as const;
|
||||
|
||||
function writeHeadFixture(): string {
|
||||
for (const path of headFiles) {
|
||||
const destination = join(headRoot, path);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
writeFileSync(destination, execFileSync("git", ["show", `HEAD:${path}`], { cwd: root }));
|
||||
}
|
||||
const worker = join(headRoot, "packages/chord/test/delta-astra/benchmark-head.worker.ts");
|
||||
mkdirSync(dirname(worker), { recursive: true });
|
||||
const source = readFileSync(new URL("./benchmark-head.worker.ts", import.meta.url), "utf8")
|
||||
.replaceAll("../../src/delta/diff.ts", "../../src/state/diff.ts")
|
||||
.replaceAll("../../src/delta/draft.ts", "../../src/state/draft.ts")
|
||||
.replaceAll("../../src/delta/value.ts", "../../src/state/value.ts");
|
||||
writeFileSync(worker, source);
|
||||
writeFileSync(join(headRoot, "package.json"), JSON.stringify({ type: "module" }));
|
||||
return worker;
|
||||
}
|
||||
|
||||
function average(values: readonly number[]): number | null {
|
||||
return values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length;
|
||||
}
|
||||
|
||||
const headWorker = writeHeadFixture();
|
||||
const currentWorker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
try {
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of scenarios) {
|
||||
for (const mode of modes) {
|
||||
const worker = mode === "head" ? headWorker : currentWorker;
|
||||
const args =
|
||||
mode === "head"
|
||||
? [scenario, String(size), String(trial)]
|
||||
: [mode, scenario, String(size), String(trial)];
|
||||
const child = spawnSync(process.execPath, ["--expose-gc", "--max-old-space-size=4096", worker, ...args], {
|
||||
cwd: mode === "head" ? headRoot : root,
|
||||
encoding: "utf8",
|
||||
timeout: 300_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
continue;
|
||||
}
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(headRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const summary = scenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.mode === mode && result.scenario === scenario);
|
||||
return {
|
||||
mode,
|
||||
scenario,
|
||||
importMs: average(selected.map((result) => result.importMs)),
|
||||
mutateMs: average(selected.map((result) => result.mutateMs)),
|
||||
prepareMs: average(selected.map((result) => result.prepareMs)),
|
||||
serializeMs: average(selected.map((result) => result.serializeMs)),
|
||||
adoptMs: average(selected.map((result) => result.adoptMs)),
|
||||
opBytes: average(selected.map((result) => result.opBytes)),
|
||||
readyMiB: average(selected.map((result) => result.readyMiB)),
|
||||
transientMiB: average(selected.map((result) => result.transientMiB)),
|
||||
retainedMiB: average(selected.map((result) => result.retainedMiB)),
|
||||
maxRssMiB: average(selected.map((result) => result.maxRssMiB)),
|
||||
failures: failures.filter((failure) => failure.mode === mode && failure.scenario === scenario).length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const report = { node: process.version, head, size, trials, results, failures, summary };
|
||||
const output = join(tmpdir(), "chord-astra-benchmark.json");
|
||||
writeFileSync(output, JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ output, node: process.version, head, size, trials, summary }));
|
||||
142
packages/chord/test/delta-astra/benchmark.worker.ts
Normal file
142
packages/chord/test/delta-astra/benchmark.worker.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { track as trackAstra } from "../../src/delta/astra/index.ts";
|
||||
import type { Draft } from "../../src/delta/draft.ts";
|
||||
import type { Op } from "../../src/delta/index.ts";
|
||||
import { track as trackDelta } from "../../src/delta/tracker.ts";
|
||||
|
||||
type Mode = "astra" | "delta";
|
||||
type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
type PreparedLike = { readonly value: Readonly<Document>; readonly ops: readonly Op[] };
|
||||
type ChangeLike = { readonly state: Draft<Document>; prepare(): PreparedLike };
|
||||
type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void };
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const mode = process.argv[2] as Mode;
|
||||
const scenario = process.argv[3] as Scenario;
|
||||
const size = Number(process.argv[4] ?? 100_000);
|
||||
const trial = Number(process.argv[5] ?? 0);
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })),
|
||||
};
|
||||
}
|
||||
|
||||
function sum(document: Document | Draft<Document>): number {
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value;
|
||||
return checksum;
|
||||
}
|
||||
|
||||
async function gc(): Promise<void> {
|
||||
assert.ok(global.gc, "benchmark worker requires --expose-gc");
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
await gc();
|
||||
const baselineHeap = process.memoryUsage().heapUsed;
|
||||
let input: Document | undefined = fixture(size);
|
||||
if (scenario === "sort") input.rows.reverse();
|
||||
const importStart = performance.now();
|
||||
const tracker = (mode === "astra" ? trackAstra(input) : trackDelta(input)) as TrackerLike;
|
||||
const importMs = performance.now() - importStart;
|
||||
input = undefined;
|
||||
await gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
let peakHeap = readyHeap;
|
||||
let mutateMs = 0;
|
||||
let prepareMs = 0;
|
||||
let serializeMs = 0;
|
||||
let adoptMs = 0;
|
||||
let opBytes = 0;
|
||||
let operationCount = 0;
|
||||
let checksum = 0;
|
||||
const queueOperations = Math.min(size, 1_000);
|
||||
|
||||
if (scenario === "committed-read") {
|
||||
const start = performance.now();
|
||||
checksum = sum(tracker.value);
|
||||
mutateMs = performance.now() - start;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
} else if (scenario !== "import") {
|
||||
let change: ChangeLike | undefined = tracker.beginChange();
|
||||
const start = performance.now();
|
||||
switch (scenario) {
|
||||
case "draft-read":
|
||||
checksum = sum(change.state);
|
||||
break;
|
||||
case "sparse":
|
||||
for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "queue":
|
||||
for (let index = 0; index < queueOperations; index++) {
|
||||
change.state.rows.shift();
|
||||
change.state.rows.push({ id: size + index, value: index, payload: "queue" });
|
||||
}
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
case "sort":
|
||||
change.state.rows.sort((left, right) => left.id - right.id);
|
||||
checksum = change.state.rows[0]!.id;
|
||||
break;
|
||||
case "dense":
|
||||
for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "unshift": {
|
||||
const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" }));
|
||||
Reflect.apply(change.state.rows.unshift, change.state.rows, inserted);
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mutateMs = performance.now() - start;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const prepareStart = performance.now();
|
||||
let prepared: PreparedLike | undefined = change.prepare();
|
||||
prepareMs = performance.now() - prepareStart;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const serializeStart = performance.now();
|
||||
const serialized = JSON.stringify(prepared.ops);
|
||||
serializeMs = performance.now() - serializeStart;
|
||||
opBytes = Buffer.byteLength(serialized);
|
||||
operationCount = prepared.ops.length;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const adoptStart = performance.now();
|
||||
tracker.adopt(prepared);
|
||||
adoptMs = performance.now() - adoptStart;
|
||||
change = undefined;
|
||||
prepared = undefined;
|
||||
}
|
||||
|
||||
await gc();
|
||||
const retainedHeap = process.memoryUsage().heapUsed;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
scenario,
|
||||
size,
|
||||
trial,
|
||||
queueOperations,
|
||||
importMs,
|
||||
mutateMs,
|
||||
prepareMs,
|
||||
serializeMs,
|
||||
adoptMs,
|
||||
opBytes,
|
||||
operationCount,
|
||||
checksum,
|
||||
readyMiB: (readyHeap - baselineHeap) / MiB,
|
||||
transientMiB: (peakHeap - readyHeap) / MiB,
|
||||
retainedMiB: (retainedHeap - baselineHeap) / MiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
}),
|
||||
);
|
||||
27
packages/chord/test/delta-astra/retention.test.ts
Normal file
27
packages/chord/test/delta-astra/retention.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("astra overlay retention", () => {
|
||||
it.each([
|
||||
"aborted",
|
||||
"dropped-prepared",
|
||||
"settled-change",
|
||||
"stale-unprepared",
|
||||
"stale-held",
|
||||
"held-revoked",
|
||||
"registry-churn",
|
||||
])(
|
||||
"releases or invalidates %s",
|
||||
(scenario) => {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario],
|
||||
{ encoding: "utf8", timeout: 60_000 },
|
||||
);
|
||||
expect(child.error, child.stderr).toBeUndefined();
|
||||
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
|
||||
},
|
||||
65_000,
|
||||
);
|
||||
});
|
||||
106
packages/chord/test/delta-astra/retention.worker.ts
Normal file
106
packages/chord/test/delta-astra/retention.worker.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { track } from "../../src/delta/astra/index.ts";
|
||||
|
||||
async function collect(reference: WeakRef<object>): Promise<void> {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
if (reference.deref() === undefined) return;
|
||||
}
|
||||
assert.fail("overlay data is still retained");
|
||||
}
|
||||
|
||||
const retainedRevoked: object[] = [];
|
||||
const retainedSettledChanges: object[] = [];
|
||||
const retainedStaleChanges: object[] = [];
|
||||
const scenario = process.argv[2];
|
||||
if (scenario === "aborted") {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const reference = new WeakRef(change.state.payload.rows);
|
||||
change.abort();
|
||||
await collect(reference);
|
||||
assert.deepEqual(tracker.value, { payload: null });
|
||||
} else if (scenario === "dropped-prepared") {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
const makeReference = (): WeakRef<object> => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const prepared = change.prepare();
|
||||
return new WeakRef(prepared.value.payload!.rows);
|
||||
};
|
||||
const reference = makeReference();
|
||||
await collect(reference);
|
||||
assert.deepEqual(tracker.value, { payload: null });
|
||||
} else if (scenario === "settled-change") {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
const makeReference = (): WeakRef<object> => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const prepared = change.prepare();
|
||||
const reference = new WeakRef(prepared.value.payload!.rows);
|
||||
retainedSettledChanges.push(change);
|
||||
return reference;
|
||||
};
|
||||
const reference = makeReference();
|
||||
await collect(reference);
|
||||
assert.deepEqual(tracker.value, { payload: null });
|
||||
} else if (scenario === "stale-unprepared") {
|
||||
const setup = (): {
|
||||
tracker: ReturnType<typeof track<{ payload: { rows: { value: number }[] } }>>;
|
||||
reference: WeakRef<object>;
|
||||
} => {
|
||||
const initial = { payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } };
|
||||
const tracker = track(initial);
|
||||
const reference = new WeakRef(initial.payload.rows);
|
||||
retainedStaleChanges.push(tracker.beginChange());
|
||||
const winner = tracker.prepareReplace({ payload: { rows: [{ value: -1 }] } });
|
||||
tracker.adopt(winner);
|
||||
return { tracker, reference };
|
||||
};
|
||||
const { tracker, reference } = setup();
|
||||
await collect(reference);
|
||||
assert.deepEqual(tracker.value, { payload: { rows: [{ value: -1 }] } });
|
||||
assert.throws(() => (retainedStaleChanges[0] as { state: { payload: unknown } }).state.payload, TypeError);
|
||||
} else if (scenario === "stale-held") {
|
||||
const tracker = track({ child: { value: 0 } });
|
||||
let prepared = (() => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.child.value = 1;
|
||||
return change.prepare();
|
||||
})();
|
||||
const held = prepared.value;
|
||||
const wrapper = new WeakRef(prepared);
|
||||
prepared = undefined as never;
|
||||
await collect(wrapper);
|
||||
const winner = tracker.prepareReplace({ child: { value: 2 } });
|
||||
tracker.adopt(winner);
|
||||
assert.throws(() => held.child.value, TypeError);
|
||||
} else if (scenario === "held-revoked") {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const held = change.state.payload;
|
||||
const child = new WeakRef(change.state.payload.rows);
|
||||
retainedRevoked.push(held);
|
||||
change.abort();
|
||||
await collect(child);
|
||||
assert.throws(() => held.rows, TypeError);
|
||||
} else if (scenario === "registry-churn") {
|
||||
const tracker = track({ value: 0 });
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
const change = tracker.beginChange();
|
||||
change.state.value = index;
|
||||
change.abort();
|
||||
}
|
||||
const winner = tracker.prepareReplace({ value: 1 });
|
||||
tracker.adopt(winner);
|
||||
assert.deepEqual(tracker.value, { value: 1 });
|
||||
} else {
|
||||
throw new Error(`unknown scenario: ${scenario}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ scenario, passed: true }));
|
||||
95
packages/chord/test/delta-cow/benchmark.ts
Normal file
95
packages/chord/test/delta-cow/benchmark.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Mode = "cow" | "delta" | "astra";
|
||||
type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift";
|
||||
type Result = {
|
||||
mode: Mode;
|
||||
scenario: Scenario;
|
||||
size: number;
|
||||
trial: number;
|
||||
queueOperations: number;
|
||||
importMs: number;
|
||||
mutateMs: number;
|
||||
prepareMs: number;
|
||||
serializeMs: number;
|
||||
adoptMs: number;
|
||||
opBytes: number;
|
||||
operationCount: number;
|
||||
checksum: number;
|
||||
readyMiB: number;
|
||||
transientMiB: number;
|
||||
retainedMiB: number;
|
||||
maxRssMiB: number;
|
||||
};
|
||||
|
||||
type Failure = { mode: Mode; scenario: Scenario; trial: number; stderr: string };
|
||||
|
||||
const scenarios: readonly Scenario[] = [
|
||||
"import",
|
||||
"committed-read",
|
||||
"draft-read",
|
||||
"sparse",
|
||||
"queue",
|
||||
"sort",
|
||||
"dense",
|
||||
"unshift",
|
||||
];
|
||||
const modes: readonly Mode[] = ["cow", "delta", "astra"];
|
||||
const quick = process.argv.includes("--quick");
|
||||
const size = quick ? 10_000 : 100_000;
|
||||
const trials = quick ? 1 : 3;
|
||||
const root = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const worker = fileURLToPath(new URL("./benchmark.worker.ts", import.meta.url));
|
||||
const results: Result[] = [];
|
||||
const failures: Failure[] = [];
|
||||
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
for (const scenario of scenarios) {
|
||||
for (const mode of modes) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
["--expose-gc", "--max-old-space-size=4096", worker, mode, scenario, String(size), String(trial)],
|
||||
{ cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
if (child.status !== 0) {
|
||||
const failure = { mode, scenario, trial, stderr: child.stderr } satisfies Failure;
|
||||
failures.push(failure);
|
||||
console.log(JSON.stringify(failure));
|
||||
} else {
|
||||
const result = JSON.parse(child.stdout) as Result;
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const average = (values: readonly number[]): number | null =>
|
||||
values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length;
|
||||
const summary = scenarios.flatMap((scenario) =>
|
||||
modes.map((mode) => {
|
||||
const selected = results.filter((result) => result.mode === mode && result.scenario === scenario);
|
||||
return {
|
||||
mode,
|
||||
scenario,
|
||||
importMs: average(selected.map((result) => result.importMs)),
|
||||
mutateMs: average(selected.map((result) => result.mutateMs)),
|
||||
prepareMs: average(selected.map((result) => result.prepareMs)),
|
||||
serializeMs: average(selected.map((result) => result.serializeMs)),
|
||||
adoptMs: average(selected.map((result) => result.adoptMs)),
|
||||
opBytes: average(selected.map((result) => result.opBytes)),
|
||||
operationCount: average(selected.map((result) => result.operationCount)),
|
||||
readyMiB: average(selected.map((result) => result.readyMiB)),
|
||||
transientMiB: average(selected.map((result) => result.transientMiB)),
|
||||
retainedMiB: average(selected.map((result) => result.retainedMiB)),
|
||||
maxRssMiB: average(selected.map((result) => result.maxRssMiB)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const output = join(tmpdir(), "chord-cow-benchmark.json");
|
||||
writeFileSync(output, JSON.stringify({ node: process.version, size, trials, results, failures, summary }, null, 2));
|
||||
console.log(JSON.stringify({ output, node: process.version, size, trials, failures: failures.length, summary }));
|
||||
145
packages/chord/test/delta-cow/benchmark.worker.ts
Normal file
145
packages/chord/test/delta-cow/benchmark.worker.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { track as trackAstra } from "../../src/delta/astra/index.ts";
|
||||
import { track as trackCow } from "../../src/delta/cow/index.ts";
|
||||
import type { Draft } from "../../src/delta/draft.ts";
|
||||
import type { Op } from "../../src/delta/index.ts";
|
||||
import { track as trackDelta } from "../../src/delta/tracker.ts";
|
||||
|
||||
type Mode = "cow" | "astra" | "delta";
|
||||
type Scenario = "import" | "committed-read" | "draft-read" | "sparse" | "queue" | "sort" | "dense" | "unshift";
|
||||
type Row = { id: number; value: number; payload: string };
|
||||
type Document = { rows: Row[] };
|
||||
type PreparedLike = { readonly value: Readonly<Document>; readonly ops: readonly Op[] };
|
||||
type ChangeLike = { readonly state: Draft<Document>; prepare(): PreparedLike };
|
||||
type TrackerLike = { readonly value: Document; beginChange(): ChangeLike; adopt(prepared: PreparedLike): void };
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const mode = process.argv[2] as Mode;
|
||||
const scenario = process.argv[3] as Scenario;
|
||||
const size = Number(process.argv[4] ?? 100_000);
|
||||
const trial = Number(process.argv[5] ?? 0);
|
||||
|
||||
function fixture(count: number): Document {
|
||||
return {
|
||||
rows: Array.from({ length: count }, (_, id) => ({ id, value: id, payload: `row-${id}` })),
|
||||
};
|
||||
}
|
||||
|
||||
function sum(document: Document | Draft<Document>): number {
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < document.rows.length; index++) checksum += document.rows[index]!.value;
|
||||
return checksum;
|
||||
}
|
||||
|
||||
async function gc(): Promise<void> {
|
||||
assert.ok(global.gc, "benchmark worker requires --expose-gc");
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
await gc();
|
||||
const baselineHeap = process.memoryUsage().heapUsed;
|
||||
let input: Document | undefined = fixture(size);
|
||||
if (scenario === "sort") input.rows.reverse();
|
||||
const importStart = performance.now();
|
||||
const tracker = (
|
||||
mode === "cow" ? trackCow(input) : mode === "astra" ? trackAstra(input) : trackDelta(input)
|
||||
) as TrackerLike;
|
||||
const importMs = performance.now() - importStart;
|
||||
input = undefined;
|
||||
await gc();
|
||||
const readyHeap = process.memoryUsage().heapUsed;
|
||||
let peakHeap = readyHeap;
|
||||
let mutateMs = 0;
|
||||
let prepareMs = 0;
|
||||
let serializeMs = 0;
|
||||
let adoptMs = 0;
|
||||
let opBytes = 0;
|
||||
let operationCount = 0;
|
||||
let checksum = 0;
|
||||
const queueOperations = Math.min(size, 1_000);
|
||||
|
||||
if (scenario === "committed-read") {
|
||||
const start = performance.now();
|
||||
checksum = sum(tracker.value);
|
||||
mutateMs = performance.now() - start;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
} else if (scenario !== "import") {
|
||||
let change: ChangeLike | undefined = tracker.beginChange();
|
||||
const start = performance.now();
|
||||
switch (scenario) {
|
||||
case "draft-read":
|
||||
checksum = sum(change.state);
|
||||
break;
|
||||
case "sparse":
|
||||
for (let index = 0; index < size; index += 1_000) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "queue":
|
||||
for (let index = 0; index < queueOperations; index++) {
|
||||
change.state.rows.shift();
|
||||
change.state.rows.push({ id: size + index, value: index, payload: "queue" });
|
||||
}
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
case "sort":
|
||||
change.state.rows.sort((left, right) => left.id - right.id);
|
||||
checksum = change.state.rows[0]!.id;
|
||||
break;
|
||||
case "dense":
|
||||
for (let index = 0; index < size; index++) change.state.rows[index]!.value = -index;
|
||||
checksum = change.state.rows.at(-1)!.value;
|
||||
break;
|
||||
case "unshift": {
|
||||
const inserted = Array.from({ length: 100_000 }, (_, id) => ({ id: -id, value: id, payload: "inserted" }));
|
||||
Reflect.apply(change.state.rows.unshift, change.state.rows, inserted);
|
||||
checksum = change.state.rows.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mutateMs = performance.now() - start;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const prepareStart = performance.now();
|
||||
let prepared: PreparedLike | undefined = change.prepare();
|
||||
prepareMs = performance.now() - prepareStart;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const serializeStart = performance.now();
|
||||
const serialized = JSON.stringify(prepared.ops);
|
||||
serializeMs = performance.now() - serializeStart;
|
||||
opBytes = Buffer.byteLength(serialized);
|
||||
operationCount = prepared.ops.length;
|
||||
peakHeap = Math.max(peakHeap, process.memoryUsage().heapUsed);
|
||||
const adoptStart = performance.now();
|
||||
tracker.adopt(prepared);
|
||||
adoptMs = performance.now() - adoptStart;
|
||||
change = undefined;
|
||||
prepared = undefined;
|
||||
}
|
||||
|
||||
await gc();
|
||||
const retainedHeap = process.memoryUsage().heapUsed;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
scenario,
|
||||
size,
|
||||
trial,
|
||||
queueOperations,
|
||||
importMs,
|
||||
mutateMs,
|
||||
prepareMs,
|
||||
serializeMs,
|
||||
adoptMs,
|
||||
opBytes,
|
||||
operationCount,
|
||||
checksum,
|
||||
readyMiB: (readyHeap - baselineHeap) / MiB,
|
||||
transientMiB: (peakHeap - readyHeap) / MiB,
|
||||
retainedMiB: (retainedHeap - baselineHeap) / MiB,
|
||||
maxRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
}),
|
||||
);
|
||||
501
packages/chord/test/delta-cow/core.test.ts
Normal file
501
packages/chord/test/delta-cow/core.test.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
apply,
|
||||
applyImmutable,
|
||||
assertValidOp,
|
||||
assertValidWireOp,
|
||||
decoder,
|
||||
encoder,
|
||||
isBase,
|
||||
type JsonValue,
|
||||
type Op,
|
||||
overlap,
|
||||
track,
|
||||
UnsafePathError,
|
||||
type WireOp,
|
||||
} from "../../src/delta/cow/index.ts";
|
||||
|
||||
describe("immutable tracker lifecycle", () => {
|
||||
it("keeps a draft alive across await and adopts only a prepared change", async () => {
|
||||
const input = { count: 1, nested: { text: "a" }, values: [1] };
|
||||
const tracker = track(input);
|
||||
expect(tracker.value).toBe(input);
|
||||
|
||||
const change = tracker.beginChange();
|
||||
change.state.count = 2;
|
||||
await Promise.resolve();
|
||||
change.state.nested.text += "b";
|
||||
change.state.values.push(2);
|
||||
expect(tracker.value).toEqual(input);
|
||||
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.base).toBe(tracker.value);
|
||||
expect(prepared.value).toEqual({ count: 2, nested: { text: "ab" }, values: [1, 2] });
|
||||
expect(prepared.ops).toEqual([
|
||||
["s", ["count"], 2],
|
||||
["a", ["nested", "text"], "b"],
|
||||
["p", ["values"], 1, 0, [2]],
|
||||
]);
|
||||
expect(() => change.state.count).toThrow(TypeError);
|
||||
expect(tracker.value).toEqual(input);
|
||||
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value).toBe(prepared.value);
|
||||
});
|
||||
|
||||
it("aborts and revokes without changing the committed value", () => {
|
||||
const tracker = track({ child: { value: 1 } });
|
||||
const change = tracker.beginChange();
|
||||
const child = change.state.child;
|
||||
child.value = 2;
|
||||
change.abort();
|
||||
expect(tracker.value.child.value).toBe(1);
|
||||
expect(() => child.value).toThrow(TypeError);
|
||||
expect(() => change.abort()).not.toThrow();
|
||||
expect(() => change.prepare()).toThrow(/settled/);
|
||||
const next = tracker.beginChange();
|
||||
next.abort();
|
||||
});
|
||||
|
||||
it("grows arrays with explicit nulls and revokes after preparation", () => {
|
||||
const tracker = track({ values: [1, 2] as Array<number | null> });
|
||||
const change = tracker.beginChange();
|
||||
change.state.values.length = 4;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual([1, 2, null, null]);
|
||||
expect(() => change.state.values).toThrow(TypeError);
|
||||
tracker.adopt(prepared);
|
||||
});
|
||||
|
||||
it("normalizes a deep no-op to exact previous identity", () => {
|
||||
const tracker = track({ value: { nested: [1, 2] } });
|
||||
const change = tracker.beginChange();
|
||||
change.state.value = { nested: [1, 2] };
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toBe(prepared.base);
|
||||
expect(prepared.ops).toEqual([]);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value).toBe(prepared.base);
|
||||
});
|
||||
|
||||
it("takes O(1) replacement ownership and applies no-op normalization", () => {
|
||||
const tracker = track({ nested: { value: 1 } });
|
||||
const replacement = { nested: { value: 2 } };
|
||||
const prepared = tracker.prepareReplace(replacement);
|
||||
expect(prepared.value).toBe(replacement);
|
||||
tracker.adopt(prepared);
|
||||
|
||||
const noOp = tracker.prepareReplace({ nested: { value: 2 } });
|
||||
expect(noOp.value).toBe(tracker.value);
|
||||
expect(noOp.ops).toEqual([]);
|
||||
});
|
||||
|
||||
it("detaches operation payloads from candidate placements", () => {
|
||||
const tracker = track({ rows: [] as { id: number }[] });
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows.push({ id: 1 });
|
||||
const prepared = change.prepare();
|
||||
const splice = prepared.ops[0]!;
|
||||
if (splice[0] !== "p") throw new Error("expected splice");
|
||||
expect(splice[4][0]).not.toBe(prepared.value.rows[0]);
|
||||
tracker.adopt(prepared);
|
||||
tracker.value.rows[0]!.id = 2;
|
||||
expect(splice[4]).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it("rejects foreign, stale, repeated, and overlapping changes", () => {
|
||||
const first = track({ value: 0 });
|
||||
const second = track({ value: 0 });
|
||||
const change = first.beginChange();
|
||||
expect(() => first.beginChange()).toThrow(/active/);
|
||||
change.state.value = 1;
|
||||
const prepared = change.prepare();
|
||||
expect(() => second.adopt(prepared)).toThrow(/different tracker/);
|
||||
first.adopt(prepared);
|
||||
expect(() => first.adopt(prepared)).toThrow(/already been used/);
|
||||
|
||||
const stale = first.prepareReplace({ value: 2 });
|
||||
const winner = first.prepareReplace({ value: 3 });
|
||||
first.adopt(winner);
|
||||
expect(() => first.adopt(stale)).toThrow(/stale/);
|
||||
expect(() => first.adopt(stale)).toThrow(/stale/);
|
||||
});
|
||||
|
||||
it("invalidates a prepared result when its change is aborted", () => {
|
||||
const tracker = track({ value: 0 });
|
||||
const change = tracker.beginChange();
|
||||
change.state.value = 1;
|
||||
const prepared = change.prepare();
|
||||
change.abort();
|
||||
expect(() => tracker.adopt(prepared)).toThrow(/aborted/);
|
||||
expect(() => change.abort()).not.toThrow();
|
||||
});
|
||||
|
||||
it("makes competing same-base preparations stale after adopting a no-op", () => {
|
||||
const tracker = track({ value: { count: 1 } });
|
||||
const first = tracker.prepareReplace({ value: { count: 1 } });
|
||||
const competing = tracker.prepareReplace({ value: { count: 1 } });
|
||||
expect(first.value).toBe(first.base);
|
||||
expect(competing.value).toBe(competing.base);
|
||||
tracker.adopt(first);
|
||||
expect(() => tracker.adopt(first)).toThrow(/already been used/);
|
||||
expect(() => tracker.adopt(competing)).toThrow(/stale/);
|
||||
});
|
||||
|
||||
it("keeps large prepareReplace array edits narrow", () => {
|
||||
const rows = Array.from({ length: 10_000 }, (_, value) => ({ value, stable: { value } }));
|
||||
const tracker = track({ rows });
|
||||
const replacement = tracker.value.rows.slice();
|
||||
replacement[5_000] = { value: -1, stable: replacement[5_000]!.stable };
|
||||
const prepared = tracker.prepareReplace({ rows: replacement });
|
||||
expect(prepared.ops).toEqual([["s", ["rows", 5_000, "value"], -1]]);
|
||||
expect(JSON.stringify(prepared.ops).length).toBeLessThan(100);
|
||||
expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical strings", () => {
|
||||
it("emits append and rolling-window operations", () => {
|
||||
const tracker = track({ text: "abcdefgh" });
|
||||
let change = tracker.beginChange();
|
||||
change.state.text += "ij";
|
||||
let prepared = change.prepare();
|
||||
expect(prepared.ops).toEqual([["a", ["text"], "ij"]]);
|
||||
tracker.adopt(prepared);
|
||||
|
||||
change = tracker.beginChange();
|
||||
change.state.text = `${change.state.text.slice(3)}xyz`;
|
||||
prepared = change.prepare();
|
||||
expect(prepared.ops).toEqual([
|
||||
["t", ["text"], 3],
|
||||
["a", ["text"], "xyz"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds bounded overlaps", () => {
|
||||
expect(overlap("abcdefgh", "defghxyz", 65_536)).toBe(5);
|
||||
expect(overlap("abcdef", "defghi", 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("operation application and validation", () => {
|
||||
it("applies mutable and immutable operations", () => {
|
||||
const operations: Op[] = [
|
||||
["a", ["text"], "b"],
|
||||
["p", ["values"], 1, 1, [3, 4]],
|
||||
["s", ["nested", "value"], 2],
|
||||
];
|
||||
const base = { text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } };
|
||||
const immutable = applyImmutable(base, operations);
|
||||
expect(immutable).toEqual({ text: "ab", values: [1, 3, 4], nested: { value: 2 }, stable: { value: 9 } });
|
||||
expect(base).toEqual({ text: "a", values: [1, 2], nested: { value: 1 }, stable: { value: 9 } });
|
||||
expect(immutable.stable).toBe(base.stable);
|
||||
expect(apply(structuredClone(base), operations)).toEqual(immutable);
|
||||
});
|
||||
|
||||
it("supports root replacement, root splice, and permutations", () => {
|
||||
const base: Op[] = [["r", [1, 2, 3]]];
|
||||
expect(isBase(base)).toBe(true);
|
||||
let value = apply<number[]>(undefined, base);
|
||||
value = apply(value, [["p", [], 1, 1, [4]]]);
|
||||
value = apply(value, [["m", [], [2, 0, 1]]]);
|
||||
expect(value).toEqual([3, 1, 4]);
|
||||
});
|
||||
|
||||
it("rejects unsafe and malformed paths", () => {
|
||||
expect(() => apply({}, [["s", ["constructor", "prototype", "x"], true]])).toThrow(UnsafePathError);
|
||||
expect(() => apply({ values: [1] }, [["s", ["values", 3], 2]])).toThrow(UnsafePathError);
|
||||
expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow();
|
||||
expect(() => assertValidOp(["s", "value", 1] as never)).toThrow();
|
||||
expect(() => assertValidOp(["m", [], [0, 0]] as never)).toThrow(/bijection/);
|
||||
});
|
||||
|
||||
it("validates immutable operations before traversing the target", () => {
|
||||
let reads = 0;
|
||||
const target = Object.defineProperty({}, "trap", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
expect(() => applyImmutable(target, [["s", "bad-path", 1] as never])).toThrow(/path/);
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
it("validates decoded and wire vocabularies separately", () => {
|
||||
expect(() => assertValidOp(["s", ["value"], 1])).not.toThrow();
|
||||
expect(() => assertValidOp(["s", 1] as never)).toThrow();
|
||||
expect(() => assertValidWireOp(["s", 1])).not.toThrow();
|
||||
expect(() => assertValidWireOp(["#", 0, ["value"]])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("codec", () => {
|
||||
it("interns paths, omits adjacent paths, and round-trips", () => {
|
||||
const path = ["nested", "text"] as const;
|
||||
const enc = encoder();
|
||||
const dec = decoder();
|
||||
const first: Op[] = [
|
||||
["t", path, 1],
|
||||
["a", path, "x"],
|
||||
];
|
||||
expect(dec.decode(enc.encode(first))).toEqual(first);
|
||||
const second: Op[] = [["a", path, "y"]];
|
||||
const wire = enc.encode(second);
|
||||
expect(wire).toEqual([
|
||||
["#", 0, path],
|
||||
["a", 0, "y"],
|
||||
]);
|
||||
expect(dec.decode(wire)).toEqual(second);
|
||||
});
|
||||
|
||||
it("resets path dictionaries on a base", () => {
|
||||
const enc = encoder();
|
||||
const path = ["value"] as const;
|
||||
enc.encode([["s", path, 1]]);
|
||||
enc.encode([["s", path, 2]]);
|
||||
expect(enc.encode([["r", { value: 3 }]])).toEqual([["r", { value: 3 }]]);
|
||||
expect(enc.encode([["s", path, 4]])).toEqual([["s", path, 4]]);
|
||||
});
|
||||
|
||||
it("rejects unresolved short forms and unsafe interned paths", () => {
|
||||
expect(() => decoder().decode([["a", "x"]])).toThrow();
|
||||
const wire = [
|
||||
["#", 0, ["__proto__"]],
|
||||
["s", 0, true],
|
||||
] as unknown as WireOp[];
|
||||
expect(() => decoder().decode(wire)).toThrow(UnsafePathError);
|
||||
});
|
||||
|
||||
it("omits an adjacent repeated path", () => {
|
||||
const enc = encoder();
|
||||
const path = ["value"] as const;
|
||||
expect(
|
||||
enc.encode([
|
||||
["s", path, 1],
|
||||
["s", path, 2],
|
||||
]),
|
||||
).toEqual([
|
||||
["s", path, 1],
|
||||
["s", 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("interns on second use rather than first", () => {
|
||||
const enc = encoder();
|
||||
const path = ["a", "deep"] as const;
|
||||
expect(enc.encode([["a", path, "1"]])).toEqual([["a", path, "1"]]);
|
||||
expect(enc.encode([["a", path, "2"]])).toEqual([
|
||||
["#", 0, path],
|
||||
["a", 0, "2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not collide paths containing null characters", () => {
|
||||
const operations: Op[] = [
|
||||
["s", ["a\u0000b"], 1],
|
||||
["s", ["a", "b"], 2],
|
||||
];
|
||||
expect(decoder().decode(encoder().encode(operations))).toEqual(operations);
|
||||
});
|
||||
|
||||
it("clears decoder ids on a base batch", () => {
|
||||
const dec = decoder();
|
||||
dec.decode([
|
||||
["#", 0, ["a"]],
|
||||
["a", 0, "1"],
|
||||
]);
|
||||
dec.decode([["r", { a: "" }]]);
|
||||
expect(() => dec.decode([["a", 0, "2"]])).toThrow();
|
||||
});
|
||||
|
||||
it("makes batches after a base self-contained", () => {
|
||||
const enc = encoder();
|
||||
const path = ["a", "deep"] as const;
|
||||
enc.encode([["a", path, "1"]]);
|
||||
enc.encode([["a", path, "2"]]);
|
||||
const base = enc.encode([["r", { a: { deep: "x" } }]]);
|
||||
const after = enc.encode([["a", path, "3"]]);
|
||||
expect(after).toEqual([["a", path, "3"]]);
|
||||
const dec = decoder();
|
||||
expect(dec.decode(base)).toEqual([["r", { a: { deep: "x" } }]]);
|
||||
expect(dec.decode(after)).toEqual([["a", path, "3"]]);
|
||||
});
|
||||
|
||||
it("round-trips deterministic mixed operation streams", () => {
|
||||
const batches: Op[][] = Array.from({ length: 100 }, (_, index) => [
|
||||
["s", ["rows", index, "value"], index],
|
||||
["a", ["output"], String(index)],
|
||||
["p", ["tail"], index, 0, [index]],
|
||||
]);
|
||||
const enc = encoder();
|
||||
const dec = decoder();
|
||||
expect(batches.map((batch) => dec.decode(enc.encode(batch)))).toEqual(batches);
|
||||
});
|
||||
});
|
||||
|
||||
describe("immutable application ownership", () => {
|
||||
it("does not mutate a replacement payload targeted by a later operation", () => {
|
||||
const replacement = { nested: { value: 1 } };
|
||||
const next = applyImmutable<{ nested: { value: number } }>(undefined, [
|
||||
["r", replacement],
|
||||
["s", ["nested", "value"], 2],
|
||||
]);
|
||||
expect(replacement.nested.value).toBe(1);
|
||||
expect(next.nested.value).toBe(2);
|
||||
});
|
||||
|
||||
it("adopts a mutable replacement payload rather than copying it", () => {
|
||||
const batch: Op[] = [["r", { value: 0 }]];
|
||||
const first = apply<{ value: number }>(undefined, batch);
|
||||
const second = apply<{ value: number }>(undefined, batch);
|
||||
first.value = 1;
|
||||
expect(second.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path and prototype safety", () => {
|
||||
it("rejects constructor walks and forbidden interned paths", () => {
|
||||
expect(() => apply({}, JSON.parse('[["s",["constructor","prototype","gadget"],true]]'))).toThrow();
|
||||
expect(({} as Record<string, unknown>).gadget).toBeUndefined();
|
||||
const wire = [
|
||||
["#", 0, ["__proto__", "w"]],
|
||||
["s", 0, true],
|
||||
] as unknown as WireOp[];
|
||||
expect(() => decoder().decode(wire)).toThrow();
|
||||
});
|
||||
|
||||
it("does not run inherited setters", () => {
|
||||
Object.defineProperty(Object.prototype, "trap", {
|
||||
set() {
|
||||
throw new Error("inherited setter ran");
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
expect(() => apply({}, [["s", ["trap"], 1]])).not.toThrow();
|
||||
expect(apply({}, [["s", ["trap"], 1]])).toEqual({ trap: 1 });
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>).trap;
|
||||
}
|
||||
});
|
||||
|
||||
it("allows reserved names inside values without prototype pollution", () => {
|
||||
const value = JSON.parse('{"__proto__":{"z":1}}') as JsonValue;
|
||||
const out = apply({}, [["s", ["value"], value]]);
|
||||
expect(Object.hasOwn((out as { value: object }).value, "__proto__")).toBe(true);
|
||||
expect(({} as Record<string, unknown>).z).toBeUndefined();
|
||||
});
|
||||
|
||||
it("imports own properties without invoking inherited setters", () => {
|
||||
const root = {} as Record<string, JsonValue>;
|
||||
Object.defineProperty(root, "trap", { value: 1, writable: true, enumerable: true, configurable: true });
|
||||
Object.defineProperty(Object.prototype, "trap", {
|
||||
set() {
|
||||
throw new Error("inherited setter ran");
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
expect(() => track(root)).not.toThrow();
|
||||
} finally {
|
||||
delete (Object.prototype as Record<string, unknown>).trap;
|
||||
}
|
||||
|
||||
let value: { values: number[] } | undefined;
|
||||
Object.defineProperty(Array.prototype, "0", {
|
||||
set() {
|
||||
throw new Error("inherited array setter ran");
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
value = track({ values: [1, 2] }).value;
|
||||
} finally {
|
||||
delete (Array.prototype as unknown as Record<number, unknown>)[0];
|
||||
}
|
||||
expect(value).toEqual({ values: [1, 2] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("array index safety", () => {
|
||||
it("writes an existing index and appends exactly one past the end", () => {
|
||||
expect(apply({ values: [1, 2, 3] }, [["s", ["values", 1], 9]])).toEqual({ values: [1, 9, 3] });
|
||||
expect(apply({ values: [1, 2, 3] }, [["s", ["values", 3], 9]])).toEqual({ values: [1, 2, 3, 9] });
|
||||
});
|
||||
|
||||
it("rejects gaps, huge indices, and string-spelled indices", () => {
|
||||
expect(() => apply({ values: [1, 2, 3] }, [["s", ["values", 5], 9]])).toThrow();
|
||||
expect(() => apply({ values: [] }, [["s", ["values", 4_294_967_290], 1]])).toThrow();
|
||||
expect(() => apply({ values: [1] }, [["s", ["values", "0"], 9]])).toThrow();
|
||||
expect(() => apply({ values: ["a"] }, [["a", ["values", "0"], "b"]])).toThrow();
|
||||
});
|
||||
|
||||
it("allows explicit growth values and rejects deletion past the end", () => {
|
||||
expect(apply({ values: [1] }, [["p", ["values"], 1, 0, [null, null, 9]]])).toEqual({
|
||||
values: [1, null, null, 9],
|
||||
});
|
||||
expect(() => apply({ values: [1] }, [["d", ["values", 1]]])).toThrow();
|
||||
});
|
||||
|
||||
it("applies large splice payloads without spreading them at once", () => {
|
||||
const items = Array<JsonValue>(300_000).fill(null);
|
||||
const result = apply({ values: [] as JsonValue[] }, [["p", ["values"], 0, 0, items]]);
|
||||
expect(result.values).toHaveLength(items.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("operation structure safety", () => {
|
||||
it("rejects unknown verbs, malformed tuples, paths, and splice payloads", () => {
|
||||
expect(() => apply({ value: 1 }, [["ZZZ", ["value"], 9] as never])).toThrow();
|
||||
expect(() => apply({ values: [1] }, [["p", ["values"], 0, 0, "not-an-array"] as never])).toThrow();
|
||||
expect(() => apply({ value: 1 }, [["s", "value", 9] as never])).toThrow();
|
||||
expect(() => apply({ value: 1 }, [{ op: "s" } as never])).toThrow();
|
||||
expect(() => apply({ value: 1 }, [null as never])).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid append and truncation operations", () => {
|
||||
expect(() => apply({ value: 1 }, [["a", ["missing"], "x"]])).toThrow();
|
||||
expect(() => apply({ value: 1 }, [["a", ["value"], "x"]])).toThrow();
|
||||
expect(() => apply({ value: "abc" }, [["t", ["value"], -1]])).toThrow();
|
||||
expect(() => decoder().decode([["t", ["value"], -1]])).toThrow();
|
||||
});
|
||||
|
||||
it("clamps splice removal past the end", () => {
|
||||
expect(apply({ values: [1, 2] }, [["p", ["values"], 0, 1e9, []]])).toEqual({ values: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("operation assertions", () => {
|
||||
it("accepts decoded operations and rejects wire-only forms", () => {
|
||||
for (const operation of [
|
||||
["r", { value: 1 }],
|
||||
["s", ["value"], 1],
|
||||
["d", ["value"]],
|
||||
["a", ["value"], "x"],
|
||||
["t", ["value"], 2],
|
||||
["p", ["value"], 0, 0, []],
|
||||
["m", ["value"], [0]],
|
||||
] as never[]) {
|
||||
expect(() => assertValidOp(operation)).not.toThrow();
|
||||
}
|
||||
for (const wireOnly of [
|
||||
["s", 1],
|
||||
["d"],
|
||||
["a", "x"],
|
||||
["t", 2],
|
||||
["p", 0, 0, []],
|
||||
["#", 0, ["value"]],
|
||||
["s", 0, 1],
|
||||
] as never[]) {
|
||||
expect(() => assertValidOp(wireOnly)).toThrow();
|
||||
expect(() => assertValidWireOp(wireOnly)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not recursively inspect operation payloads", () => {
|
||||
expect(() => assertValidOp(["s", ["value"], new Map()] as never)).not.toThrow();
|
||||
expect(() => assertValidWireOp(["r", new Date()] as never)).not.toThrow();
|
||||
});
|
||||
});
|
||||
538
packages/chord/test/delta-cow/cow.test.ts
Normal file
538
packages/chord/test/delta-cow/cow.test.ts
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { apply, applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts";
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
describe("optimized COW transaction invariants", () => {
|
||||
it("matches candidate, detached replay, and adopted value", () => {
|
||||
const tracker = track({ rows: [{ value: 1 }], text: "a" });
|
||||
const base = clone(tracker.value);
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows[0]!.value = 2;
|
||||
change.state.rows.push({ value: 3 });
|
||||
change.state.text += "b";
|
||||
const prepared = change.prepare();
|
||||
const candidate = clone(prepared.value);
|
||||
const operations = clone(prepared.ops);
|
||||
expect(apply(base, operations)).toEqual(candidate);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value).toEqual(candidate);
|
||||
});
|
||||
|
||||
it("clones external and draft placements independently", () => {
|
||||
const tracker = track({
|
||||
source: { value: 1 },
|
||||
left: null as { value: number } | null,
|
||||
right: null as { value: number } | null,
|
||||
rows: [] as { value: number }[],
|
||||
});
|
||||
const external = { value: 2 };
|
||||
const change = tracker.beginChange();
|
||||
change.state.left = external;
|
||||
change.state.right = change.state.source;
|
||||
change.state.rows.push(external, external, change.state.source);
|
||||
external.value = 9;
|
||||
change.state.source.value = 7;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toEqual({
|
||||
source: { value: 7 },
|
||||
left: { value: 2 },
|
||||
right: { value: 1 },
|
||||
rows: [{ value: 2 }, { value: 2 }, { value: 1 }],
|
||||
});
|
||||
expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]);
|
||||
});
|
||||
|
||||
it("distinguishes raw committed and draft-subtree assignments", () => {
|
||||
const tracker = track({
|
||||
source: { value: 1 },
|
||||
rawCopy: null as { value: number } | null,
|
||||
draftCopy: null as { value: number } | null,
|
||||
});
|
||||
const raw = tracker.value.source;
|
||||
const change = tracker.beginChange();
|
||||
change.state.source.value = 5;
|
||||
change.state.rawCopy = raw;
|
||||
change.state.draftCopy = change.state.source;
|
||||
expect(change.prepare().value).toEqual({ source: { value: 5 }, rawCopy: { value: 1 }, draftCopy: { value: 5 } });
|
||||
});
|
||||
|
||||
it("keeps Prepared.value authoritative and independent from public operations", () => {
|
||||
const tracker = track({ rows: [] as { value: number }[] });
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows.push({ value: 1 });
|
||||
const prepared = change.prepare();
|
||||
const splice = prepared.ops[0];
|
||||
if (splice?.[0] !== "p") throw new Error("expected splice");
|
||||
expect(splice[4][0]).not.toBe(prepared.value.rows[0]);
|
||||
(splice[4][0] as { value: number }).value = 99;
|
||||
expect(prepared.value.rows[0]!.value).toBe(1);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value).toBe(prepared.value);
|
||||
expect(tracker.value.rows[0]!.value).toBe(1);
|
||||
});
|
||||
|
||||
it("supports a MemoryStorage-style recursive clone", () => {
|
||||
const storageClone = <T>(value: T): T => {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(storageClone) as T;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value)) output[key] = storageClone((value as Record<string, unknown>)[key]);
|
||||
return output as T;
|
||||
};
|
||||
const tracker = track({ rows: [{ value: 1 }] });
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows[0]!.value = 2;
|
||||
change.state.rows.push({ value: 3 });
|
||||
expect(storageClone(change.prepare().value)).toEqual({ rows: [{ value: 2 }, { value: 3 }] });
|
||||
});
|
||||
|
||||
it("folds reserved-key changes without prototype pollution", () => {
|
||||
const initial = JSON.parse('{"safe":{"__proto__":{"value":1}}}') as {
|
||||
safe: { __proto__: { value: number } };
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const change = tracker.beginChange();
|
||||
change.state.safe.__proto__.value = 2;
|
||||
const prepared = change.prepare();
|
||||
expect(applyImmutable(tracker.value, prepared.ops)).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
expect(({} as Record<string, unknown>).value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logical placement cloning", () => {
|
||||
it("does not let a detached dirty object handle overwrite its replacement", () => {
|
||||
const tracker = track({ child: { value: 1 }, stable: { value: 9 } });
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.child;
|
||||
held.value = 2;
|
||||
change.state.child = { value: 3 };
|
||||
held.value = 4;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toEqual({ child: { value: 3 }, stable: { value: 9 } });
|
||||
expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value.child.value).toBe(3);
|
||||
});
|
||||
|
||||
it("does not let a detached dirty array handle overwrite its replacement", () => {
|
||||
const tracker = track({ rows: [{ value: 1 }, { value: 9 }] });
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.rows[0]!;
|
||||
held.value = 2;
|
||||
change.state.rows[0] = { value: 3 };
|
||||
held.value = 4;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toEqual({ rows: [{ value: 3 }, { value: 9 }] });
|
||||
expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value.rows[0]!.value).toBe(3);
|
||||
});
|
||||
|
||||
it("copyWithin clones pending dirty descendants with primitive indices", () => {
|
||||
const tracker = track({ rows: [{ nested: { value: 1 } }, { nested: { value: 2 } }] });
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows[0]!.nested.value = 9;
|
||||
change.state.rows.copyWithin(1, 0, 1);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toEqual({ rows: [{ nested: { value: 9 } }, { nested: { value: 9 } }] });
|
||||
expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]);
|
||||
expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
});
|
||||
|
||||
it("keeps diff-aligned moved placements independent in the authoritative candidate", () => {
|
||||
const tracker = track({
|
||||
rows: [
|
||||
{ id: 0, score: 0, label: "r-0" },
|
||||
{ id: 1, score: 1 },
|
||||
{ id: 2, score: 2 },
|
||||
{ id: 2, score: 2 },
|
||||
{ id: 200106, score: 16, label: "r-200106" },
|
||||
{ id: 4, score: 200103 },
|
||||
{ id: 5, score: 200002 },
|
||||
{ id: 6, score: 6, label: "r-6" },
|
||||
{ id: 7, score: 7 },
|
||||
{ id: 200001, score: 13, label: "r-200001" },
|
||||
],
|
||||
text: "start-200100-200107",
|
||||
meta: { count: 0 },
|
||||
});
|
||||
const base = clone(tracker.value);
|
||||
const change = tracker.beginChange();
|
||||
let held = change.state.rows[5]!;
|
||||
change.state.rows.unshift({ id: 200200, score: 13 });
|
||||
held.score += 1_000;
|
||||
change.state.meta.count -= 2;
|
||||
held = change.state.rows[3]!;
|
||||
change.state.rows.unshift({ id: 200202, score: 15, label: "r-200202" });
|
||||
held.score += 1_000;
|
||||
change.state.text += "-200203";
|
||||
change.state.rows[0] = { id: 200204, score: 12 };
|
||||
change.state.rows[1] = change.state.rows[2]!;
|
||||
change.state.text += "-200206";
|
||||
change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.rows[0]!.id).toBe(0);
|
||||
expect(prepared.value.rows[1]!.id).toBe(0);
|
||||
expect(prepared.value.rows[0]).not.toBe(prepared.value.rows[1]);
|
||||
expect(apply(base, clone(prepared.ops))).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
|
||||
const next = tracker.beginChange();
|
||||
const secondScore = next.state.rows[1]!.score;
|
||||
next.state.rows[0]!.score = 99;
|
||||
const nextPrepared = next.prepare();
|
||||
expect(nextPrepared.value.rows[0]!.score).toBe(99);
|
||||
expect(nextPrepared.value.rows[1]!.score).toBe(secondScore);
|
||||
expect(nextPrepared.value.rows[0]).not.toBe(nextPrepared.value.rows[1]);
|
||||
nextPrepared.abort();
|
||||
});
|
||||
|
||||
it("fuzzes deeply-equal duplicate placements through movement and stable sort", () => {
|
||||
for (let seed = 1; seed <= 60; seed++) {
|
||||
const initial = {
|
||||
rows: Array.from({ length: 20 }, (_, index) => ({
|
||||
id: Math.floor(index / 2),
|
||||
score: Math.floor(index / 2),
|
||||
label: `row-${Math.floor(index / 2)}`,
|
||||
})),
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const expected = clone(initial);
|
||||
const change = tracker.beginChange();
|
||||
const heldIndex = seed % expected.rows.length;
|
||||
const expectedHeld = expected.rows[heldIndex]!;
|
||||
const draftHeld = change.state.rows[heldIndex]!;
|
||||
expected.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` });
|
||||
change.state.rows.unshift({ id: 100 + seed, score: seed, label: `front-${seed}` });
|
||||
expectedHeld.score += 1_000;
|
||||
draftHeld.score += 1_000;
|
||||
expected.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` });
|
||||
change.state.rows.unshift({ id: 200 + seed, score: seed, label: `front2-${seed}` });
|
||||
const source = (seed * 7) % expected.rows.length;
|
||||
let target = (seed * 11 + 3) % expected.rows.length;
|
||||
if (target === source) target = (target + 1) % expected.rows.length;
|
||||
expected.rows[target] = clone(expected.rows[source]!);
|
||||
change.state.rows[target] = change.state.rows[source]!;
|
||||
expected.rows.sort((left, right) => left.id - right.id || left.score - right.score);
|
||||
change.state.rows.sort((left, right) => left.id - right.id || left.score - right.score);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value, `candidate seed ${seed}`).toEqual(expected);
|
||||
expect(new Set(prepared.value.rows).size, `identity seed ${seed}`).toBe(prepared.value.rows.length);
|
||||
expect(apply(clone(initial), clone(prepared.ops)), `replay seed ${seed}`).toEqual(expected);
|
||||
prepared.abort();
|
||||
}
|
||||
});
|
||||
|
||||
it("clones a dirty draft subtree from its source tracker context", () => {
|
||||
const source = track({ subtree: { nested: { value: 1 }, rows: [{ value: 2 }] } });
|
||||
const destination = track({ copy: null as { nested: { value: number }; rows: { value: number }[] } | null });
|
||||
const sourceChange = source.beginChange();
|
||||
sourceChange.state.subtree.nested.value = 7;
|
||||
sourceChange.state.subtree.rows[0]!.value = 8;
|
||||
const destinationChange = destination.beginChange();
|
||||
destinationChange.state.copy = sourceChange.state.subtree;
|
||||
const prepared = destinationChange.prepare();
|
||||
expect(prepared.value.copy).toEqual({ nested: { value: 7 }, rows: [{ value: 8 }] });
|
||||
expect(apply(clone(destination.value), clone(prepared.ops))).toEqual(prepared.value);
|
||||
destination.adopt(prepared);
|
||||
sourceChange.abort();
|
||||
});
|
||||
});
|
||||
|
||||
describe("native mutator parity", () => {
|
||||
const compareNumbers = (
|
||||
nativeMutation: (values: number[]) => void,
|
||||
draftMutation: (values: number[]) => void,
|
||||
): void => {
|
||||
const native = [1, 2, 3];
|
||||
nativeMutation(native);
|
||||
const tracker = track({ values: [1, 2, 3] });
|
||||
const change = tracker.beginChange();
|
||||
draftMutation(change.state.values);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual(native);
|
||||
tracker.adopt(prepared);
|
||||
};
|
||||
|
||||
it("matches captured-length splice coercion when coercion mutates the array", () => {
|
||||
compareNumbers(
|
||||
(values) => {
|
||||
values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
values.push(4);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number,
|
||||
1,
|
||||
9,
|
||||
);
|
||||
},
|
||||
(values) => {
|
||||
values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
values.push(4);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number,
|
||||
1,
|
||||
9,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps reentrant splice shrink results dense strict JSON", () => {
|
||||
const tracker = track({ values: [1, 2, 3] as Array<number | null> });
|
||||
const change = tracker.beginChange();
|
||||
change.state.values.splice(
|
||||
{
|
||||
valueOf() {
|
||||
change.state.values.length = 1;
|
||||
return 0;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
9,
|
||||
);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual([9, 1, null, null]);
|
||||
expect(Object.keys(prepared.value.values)).toEqual(["0", "1", "2", "3"]);
|
||||
expect(prepared.value.values.every((value) => value !== undefined)).toBe(true);
|
||||
expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
});
|
||||
|
||||
it("matches fill and copyWithin coercion side effects", () => {
|
||||
compareNumbers(
|
||||
(values) => {
|
||||
values.fill(7, {
|
||||
valueOf() {
|
||||
values.push(4);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number);
|
||||
},
|
||||
(values) => {
|
||||
values.fill(7, {
|
||||
valueOf() {
|
||||
values.push(4);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number);
|
||||
},
|
||||
);
|
||||
compareNumbers(
|
||||
(values) => {
|
||||
values.copyWithin(
|
||||
{
|
||||
valueOf() {
|
||||
values.push(4);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
2,
|
||||
);
|
||||
},
|
||||
(values) => {
|
||||
values.copyWithin(
|
||||
{
|
||||
valueOf() {
|
||||
values.push(4);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as number,
|
||||
0,
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("matches comparator direct-index and structural writes", () => {
|
||||
type Row = { rank: number; touches: number };
|
||||
const mutate = (values: Row[]): void => {
|
||||
let changed = false;
|
||||
values.sort((left, right) => {
|
||||
left.touches += 1;
|
||||
right.touches += 1;
|
||||
if (!changed) {
|
||||
changed = true;
|
||||
values[0] = { rank: 9, touches: 0 };
|
||||
values.push({ rank: 3, touches: 0 });
|
||||
}
|
||||
return left.rank - right.rank;
|
||||
});
|
||||
};
|
||||
const native: Row[] = [
|
||||
{ rank: 2, touches: 0 },
|
||||
{ rank: 1, touches: 0 },
|
||||
];
|
||||
mutate(native);
|
||||
const tracker = track({
|
||||
values: [
|
||||
{ rank: 2, touches: 0 },
|
||||
{ rank: 1, touches: 0 },
|
||||
],
|
||||
});
|
||||
const change = tracker.beginChange();
|
||||
mutate(change.state.values);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual(native);
|
||||
expect(apply(clone(tracker.value), clone(prepared.ops))).toEqual(prepared.value);
|
||||
tracker.adopt(prepared);
|
||||
});
|
||||
|
||||
it("rejects bigint comparator results like native sort", () => {
|
||||
expect(() => [2, 1].sort(() => 1n as unknown as number)).toThrow(TypeError);
|
||||
const tracker = track({ values: [2, 1] });
|
||||
const change = tracker.beginChange();
|
||||
expect(() => change.state.values.sort(() => 1n as unknown as number)).toThrow(TypeError);
|
||||
change.abort();
|
||||
expect(tracker.value.values).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it("forwards borrowed mutators to ordinary generic array receivers", () => {
|
||||
const tracker = track({ values: [1, 2, 3] });
|
||||
const change = tracker.beginChange();
|
||||
const receiver = [3, 1, 2];
|
||||
expect(Reflect.apply(change.state.values.push, receiver, [4])).toBe(4);
|
||||
expect(Reflect.apply(change.state.values.splice, receiver, [1, 1, 5])).toEqual([1]);
|
||||
expect(Reflect.apply(change.state.values.sort, receiver, [(left: number, right: number) => left - right])).toBe(
|
||||
receiver,
|
||||
);
|
||||
expect(Reflect.apply(change.state.values.fill, receiver, [7, 1, 2])).toBe(receiver);
|
||||
expect(Reflect.apply(change.state.values.copyWithin, receiver, [2, 0, 1])).toBe(receiver);
|
||||
expect(receiver).toEqual([2, 7, 2, 5]);
|
||||
expect(change.state.values).toEqual([1, 2, 3]);
|
||||
change.abort();
|
||||
});
|
||||
});
|
||||
|
||||
describe("batched immutable application", () => {
|
||||
it("copies shared ancestors once while preserving untouched subtrees", () => {
|
||||
const base = {
|
||||
rows: [
|
||||
{ a: 1, b: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
],
|
||||
stable: { value: 9 },
|
||||
};
|
||||
const operations: Op[] = [
|
||||
["s", ["rows", 0, "a"], 10],
|
||||
["s", ["rows", 0, "b"], 20],
|
||||
["s", ["rows", 1, "a"], 30],
|
||||
];
|
||||
const result = applyImmutable(base, operations);
|
||||
expect(result).toEqual({
|
||||
rows: [
|
||||
{ a: 10, b: 20 },
|
||||
{ a: 30, b: 4 },
|
||||
],
|
||||
stable: { value: 9 },
|
||||
});
|
||||
expect(base).toEqual({
|
||||
rows: [
|
||||
{ a: 1, b: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
],
|
||||
stable: { value: 9 },
|
||||
});
|
||||
expect(result.stable).toBe(base.stable);
|
||||
});
|
||||
|
||||
it("does not mutate replacement or insertion payloads targeted later in the batch", () => {
|
||||
const replacement = { rows: [{ value: 1 }] };
|
||||
const result = applyImmutable<typeof replacement>(undefined, [
|
||||
["r", replacement],
|
||||
["s", ["rows", 0, "value"], 2],
|
||||
]);
|
||||
expect(result.rows[0]!.value).toBe(2);
|
||||
expect(replacement.rows[0]!.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
type Item = { id: number; score: number };
|
||||
type Document = { rows: Item[]; text: string; meta: { revision: number; label?: string } };
|
||||
type MutableDocument = Document | Draft<Document>;
|
||||
|
||||
function mutate(document: MutableDocument, choice: number, value: number): void {
|
||||
const item = (): Item => ({ id: value, score: value % 7 });
|
||||
switch (choice) {
|
||||
case 0:
|
||||
document.text += `-${value}`;
|
||||
break;
|
||||
case 1:
|
||||
document.rows.push(item());
|
||||
break;
|
||||
case 2:
|
||||
document.rows.unshift(item());
|
||||
break;
|
||||
case 3:
|
||||
if (document.rows.length > 0) document.rows.shift();
|
||||
break;
|
||||
case 4:
|
||||
if (document.rows.length > 0) document.rows.pop();
|
||||
break;
|
||||
case 5: {
|
||||
const index = document.rows.length === 0 ? 0 : value % (document.rows.length + 1);
|
||||
document.rows.splice(index, document.rows.length === 0 ? 0 : value % 2, item());
|
||||
break;
|
||||
}
|
||||
case 6:
|
||||
document.rows.reverse();
|
||||
break;
|
||||
case 7:
|
||||
document.rows.sort((left, right) => left.id - right.id);
|
||||
break;
|
||||
case 8:
|
||||
if (document.rows.length > 0) document.rows[value % document.rows.length]!.score = value;
|
||||
break;
|
||||
case 9:
|
||||
document.meta.revision += 1;
|
||||
document.meta.label = `r-${value}`;
|
||||
break;
|
||||
default:
|
||||
delete document.meta.label;
|
||||
}
|
||||
}
|
||||
|
||||
it("converges through randomized multi-operation transactions", () => {
|
||||
for (let seed = 1; seed <= 30; seed++) {
|
||||
let randomState = seed;
|
||||
const random = (): number => {
|
||||
randomState = (randomState + 0x6d2b79f5) | 0;
|
||||
let value = Math.imul(randomState ^ (randomState >>> 15), 1 | randomState);
|
||||
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
const initial: Document = {
|
||||
rows: Array.from({ length: 4 }, (_, id) => ({ id, score: 0 })),
|
||||
text: "start",
|
||||
meta: { revision: 0 },
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const expected = clone(initial);
|
||||
for (let transaction = 0; transaction < 20; transaction++) {
|
||||
const base = clone(tracker.value);
|
||||
const change = tracker.beginChange();
|
||||
for (let operation = 0; operation < 5; operation++) {
|
||||
const choice = Math.floor(random() * 11);
|
||||
const value = seed * 10_000 + transaction * 10 + operation;
|
||||
mutate(expected, choice, value);
|
||||
mutate(change.state, choice, value);
|
||||
}
|
||||
const prepared = change.prepare();
|
||||
const candidate = clone(prepared.value);
|
||||
expect(candidate, `candidate seed ${seed} transaction ${transaction}`).toEqual(expected);
|
||||
expect(apply(base, clone(prepared.ops)), `replay seed ${seed} transaction ${transaction}`).toEqual(expected);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value, `adopt seed ${seed} transaction ${transaction}`).toEqual(expected);
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
267
packages/chord/test/delta-cow/diff.test.ts
Normal file
267
packages/chord/test/delta-cow/diff.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyImmutable,
|
||||
assertValidOp,
|
||||
assertValidWireOp,
|
||||
decoder,
|
||||
diffRevisions,
|
||||
encoder,
|
||||
type Op,
|
||||
} from "../../src/delta/cow/index.ts";
|
||||
import type { JsonValue } from "../../src/types.ts";
|
||||
|
||||
const expectDiff = (before: JsonValue, after: JsonValue, expected: unknown): void => {
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual(expected);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
};
|
||||
|
||||
describe("immutable revision diff", () => {
|
||||
it("emits sets and deletes", () => {
|
||||
expectDiff({ keep: 1, change: 1, remove: true }, { keep: 1, change: 2, add: 3 }, [
|
||||
["s", ["change"], 2],
|
||||
["s", ["add"], 3],
|
||||
["d", ["remove"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits string append and front truncation", () => {
|
||||
expectDiff({ text: "hello" }, { text: "hello world" }, [["a", ["text"], " world"]]);
|
||||
expectDiff({ text: "hello world" }, { text: "world" }, [["t", ["text"], 6]]);
|
||||
expectDiff({ text: "abcdefgh" }, { text: "defghxyz" }, [
|
||||
["t", ["text"], 3],
|
||||
["a", ["text"], "xyz"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents array insertion, removal, and shift with splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b] }, { values: [a, c, b] }, [["p", ["values"], 1, 0, [c]]]);
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c] }, [["p", ["values"], 0, 1, []]]);
|
||||
});
|
||||
|
||||
it("collapses a same-length queue update to two splices", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
const d = { id: "d" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [b, c, d] }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [d]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a permutation for a pure reorder", () => {
|
||||
const a = { id: "a" };
|
||||
const b = { id: "b" };
|
||||
const c = { id: "c" };
|
||||
expectDiff({ values: [a, b, c] }, { values: [c, a, b] }, [["m", ["values"], [2, 0, 1]]]);
|
||||
});
|
||||
|
||||
it("normalizes reordered distinct deeply-equal objects to a no-op", () => {
|
||||
const first = { nested: { value: 1 } };
|
||||
const second = { nested: { value: 1 } };
|
||||
expect(first).not.toBe(second);
|
||||
expect(diffRevisions({ values: [first, second] }, { values: [second, first] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates and encodes permutations", () => {
|
||||
const operations: Op[] = [
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", ["values"], [1, 2, 0]],
|
||||
];
|
||||
for (const operation of operations) assertValidOp(operation);
|
||||
const wire = encoder().encode(operations);
|
||||
expect(wire).toEqual([
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["m", [1, 2, 0]],
|
||||
]);
|
||||
for (const operation of wire) assertValidWireOp(operation);
|
||||
expect(decoder().decode(wire)).toEqual(operations);
|
||||
expect(() => assertValidOp(["m", ["values"], [0, 0]])).toThrow(/bijection/);
|
||||
});
|
||||
|
||||
it("emits nothing for deeply equal reconstructed values", () => {
|
||||
expect(diffRevisions({ value: { nested: [1, 2] } }, { value: { nested: [1, 2] } })).toEqual([]);
|
||||
expect(diffRevisions({ values: [{ id: 1 }, { id: 2 }] }, { values: [{ id: 1 }, { id: 2 }] })).toEqual([]);
|
||||
expect(diffRevisions({ values: [true, true, true] }, { values: [true, true, true] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a leaf edit inside a reconstructed array narrow", () => {
|
||||
expectDiff(
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "two" },
|
||||
],
|
||||
},
|
||||
{
|
||||
values: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "changed" },
|
||||
],
|
||||
},
|
||||
[["s", ["values", 1, "label"], "changed"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("emits payload-free splices for scattered removals", () => {
|
||||
const [a, b, c, d, e] = ["a", "b", "c", "d", "e"].map((id) => ({ id }));
|
||||
expectDiff({ values: [a, b, c, d, e] }, { values: [a, c, e] }, [
|
||||
["p", ["values"], 1, 1, []],
|
||||
["p", ["values"], 2, 1, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["front", [1, 2, 3, 4], [2, 3, 4], [["p", ["values"], 0, 1, []]]],
|
||||
["tail", [1, 2, 3, 4], [1, 2, 3], [["p", ["values"], 3, 1, []]]],
|
||||
["middle", [1, 2, 3, 4], [1, 3, 4], [["p", ["values"], 1, 1, []]]],
|
||||
["all", [1, 2, 3, 4], [], [["p", ["values"], 0, 4, []]]],
|
||||
["none", [1, 2, 3, 4], [1, 2, 3, 4], []],
|
||||
] as const)("encodes %s removal canonically", (_name, before, after, expected) => {
|
||||
expectDiff({ values: [...before] }, { values: [...after] }, expected);
|
||||
});
|
||||
|
||||
it("does not field-diff unrelated shifted objects with common fields", () => {
|
||||
const before = [1, 2, 3].map((value) => ({ type: "row", value }));
|
||||
const after = [2, 3, 4].map((value) => ({ type: "row", value }));
|
||||
expectDiff({ values: before }, { values: after }, [
|
||||
["p", ["values"], 0, 1, []],
|
||||
["p", ["values"], 2, 0, [{ type: "row", value: 4 }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat coincidental id or key fields as structural identity", () => {
|
||||
const left = { value: "left" };
|
||||
const right = { value: "right" };
|
||||
const before = [left, { id: 1, key: "a", value: "first" }, { id: 2, key: "b", value: "second" }, right];
|
||||
const replacements = [
|
||||
{ id: 2, key: "b", value: "edited-second" },
|
||||
{ id: 1, key: "a", value: "edited-first" },
|
||||
];
|
||||
const after = [left, ...replacements, right];
|
||||
expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]);
|
||||
});
|
||||
|
||||
it("combines removals, append, and a shared-subtree survivor edit", () => {
|
||||
const a = { id: "a", stable: {}, detail: { text: "a" } };
|
||||
const b = { id: "b", stable: {}, detail: { text: "b" } };
|
||||
const c = { id: "c", stable: {}, detail: { text: "c" } };
|
||||
const d = { id: "d", stable: {}, detail: { text: "d" } };
|
||||
const changedC = { id: "c", stable: c.stable, detail: { text: "changed" } };
|
||||
const appended = { id: "e", stable: {}, detail: { text: "e" } };
|
||||
expectDiff({ values: [a, b, c, d] }, { values: [a, changedC, d, appended] }, [
|
||||
["p", ["values"], 1, 1, []],
|
||||
["a", ["values", 1, "detail", "text"], "hanged"],
|
||||
["p", ["values"], 3, 0, [appended]],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([256 * 1024, 1024 * 1024])("does not retain a %i-byte removed-neighbor payload", (size) => {
|
||||
const payload = "x".repeat(size);
|
||||
const retained = [
|
||||
{ id: "a", payload },
|
||||
{ id: "b", payload },
|
||||
{ id: "c", payload },
|
||||
{ id: "d", payload },
|
||||
{ id: "e", payload },
|
||||
];
|
||||
const before = Object.freeze({ values: Object.freeze(retained.slice()) });
|
||||
const after = Object.freeze({ values: Object.freeze([retained[0]!, retained[2]!, retained[4]!]) });
|
||||
const operations = diffRevisions(before as unknown as JsonValue, after as unknown as JsonValue);
|
||||
expect(operations).toEqual([
|
||||
["p", ["values"], 1, 1, []],
|
||||
["p", ["values"], 2, 1, []],
|
||||
]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(100);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
expect(before.values).toEqual(retained);
|
||||
});
|
||||
|
||||
it.each([1_001, 10_000])("keeps push, pop, and middle removal narrow at %,i items", (size) => {
|
||||
const values = Array.from({ length: size }, (_, value) => ({ value }));
|
||||
const appended = { value: size };
|
||||
expectDiff({ values }, { values: [...values, appended] }, [["p", ["values"], size, 0, [appended]]]);
|
||||
expectDiff({ values }, { values: values.slice(0, -1) }, [["p", ["values"], size - 1, 1, []]]);
|
||||
const middle = Math.floor(size / 2);
|
||||
expectDiff({ values }, { values: [...values.slice(0, middle), ...values.slice(middle + 1)] }, [
|
||||
["p", ["values"], middle, 1, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps forty-thousand-row sparse edits narrow", () => {
|
||||
const values = Array.from({ length: 40_000 }, (_, value) => ({ value, stable: { value } }));
|
||||
const after = values.slice();
|
||||
const changed: number[] = [];
|
||||
for (let index = 100; index < after.length; index += 400) {
|
||||
after[index] = { value: -index, stable: values[index]!.stable };
|
||||
changed.push(index);
|
||||
}
|
||||
const operations = diffRevisions({ values }, { values: after });
|
||||
expect(operations).toEqual(changed.map((index) => ["s", ["values", index, "value"], -index]));
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(7_500);
|
||||
expect(applyImmutable({ values }, operations)).toEqual({ values: after });
|
||||
});
|
||||
|
||||
it("keeps a reconstructed large-array leaf edit narrow", () => {
|
||||
const before = Array.from({ length: 1_000 }, (_, value) => ({ value, label: `row-${value}` }));
|
||||
const after = structuredClone(before);
|
||||
after[700]!.label = "changed";
|
||||
const operations = diffRevisions({ values: before }, { values: after });
|
||||
expect(operations).toEqual([["s", ["values", 700, "label"], "changed"]]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(100);
|
||||
expect(applyImmutable({ values: before }, operations)).toEqual({ values: after });
|
||||
});
|
||||
|
||||
it("splices an ambiguous equal-count moved-and-edited gap", () => {
|
||||
const left = { value: "left" };
|
||||
const right = { value: "right" };
|
||||
const before = [left, { id: 1, value: "a" }, { id: 2, value: "b" }, right];
|
||||
const replacements = [
|
||||
{ id: 2, value: "edited" },
|
||||
{ id: 1, value: "also-edited" },
|
||||
];
|
||||
const after = [left, ...replacements, right];
|
||||
expectDiff({ values: before }, { values: after }, [["p", ["values"], 1, 2, replacements]]);
|
||||
});
|
||||
|
||||
it("keeps a large rotation payload-free", () => {
|
||||
const values = Array.from({ length: 10_000 }, (_, value) => ({ value }));
|
||||
const rotated = [...values.slice(1_000), ...values.slice(0, 1_000)];
|
||||
const operations = diffRevisions({ values }, { values: rotated });
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations[0]?.[0]).toBe("m");
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(60_000);
|
||||
expect(applyImmutable({ values }, operations)).toEqual({ values: rotated });
|
||||
});
|
||||
|
||||
it("encodes five hundred unshifts without snapshotting retained rows", () => {
|
||||
const retained = Array.from({ length: 10_000 }, (_, value) => ({ value, payload: "x".repeat(100) }));
|
||||
const inserted = Array.from({ length: 500 }, (_, value) => ({ value: -value - 1 }));
|
||||
const operations = diffRevisions({ values: retained }, { values: [...inserted, ...retained] });
|
||||
expect(operations).toEqual([["p", ["values"], 0, 0, inserted]]);
|
||||
expect(JSON.stringify(operations).length).toBeLessThan(20_000);
|
||||
expect(applyImmutable({ values: retained }, operations)).toEqual({ values: [...inserted, ...retained] });
|
||||
});
|
||||
|
||||
it("bounds wide-object operation emission with a root replacement", () => {
|
||||
const before: Record<string, JsonValue> = {};
|
||||
const after: Record<string, JsonValue> = {};
|
||||
for (let index = 0; index < 20_000; index++) {
|
||||
before[`field${index}`] = 0;
|
||||
after[`field${index}`] = 1;
|
||||
}
|
||||
expect(diffRevisions(before, after)).toEqual([["r", after]]);
|
||||
});
|
||||
|
||||
it("uses normalized array fallback without payload-cost policy", () => {
|
||||
const before = { values: Array.from({ length: 40_000 }, () => 0) };
|
||||
const after = { values: Array.from({ length: 40_000 }, () => 1) };
|
||||
const operations = diffRevisions(before, after);
|
||||
expect(operations).toEqual([["p", ["values"], 0, 40_000, after.values]]);
|
||||
expect(applyImmutable(before, operations)).toEqual(after);
|
||||
});
|
||||
});
|
||||
99
packages/chord/test/delta-cow/draft.test.ts
Normal file
99
packages/chord/test/delta-cow/draft.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { applyImmutable, type Draft, track } from "../../src/delta/cow/index.ts";
|
||||
|
||||
describe("copy-on-write change drafts", () => {
|
||||
it("copies only changed branches", () => {
|
||||
const tracker = track({
|
||||
changed: { count: 1, sibling: { value: "kept" } },
|
||||
untouched: { value: 2 },
|
||||
});
|
||||
const base = tracker.value;
|
||||
const change = tracker.beginChange();
|
||||
const first = change.state.changed;
|
||||
expect(change.state.changed).toBe(first);
|
||||
change.state.changed.count = 3;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).not.toBe(base);
|
||||
expect(prepared.value.changed).not.toBe(base.changed);
|
||||
expect(prepared.value.changed.sibling).toBe(base.changed.sibling);
|
||||
expect(prepared.value.untouched).toBe(base.untouched);
|
||||
});
|
||||
|
||||
it("supports frozen bases, deletion, and native array mutators", () => {
|
||||
const tracker = track({ optional: "remove" as string | undefined, values: [3, 1, 2] });
|
||||
const change = tracker.beginChange();
|
||||
delete change.state.optional;
|
||||
change.state.values.push(4);
|
||||
expect(change.state.values.pop()).toBe(4);
|
||||
change.state.values.unshift(0);
|
||||
expect(change.state.values.shift()).toBe(0);
|
||||
expect(change.state.values.splice(1, 1, 5, 4)).toEqual([1]);
|
||||
change.state.values.sort((left, right) => left - right);
|
||||
change.state.values.reverse();
|
||||
change.state.values.fill(9, 1, 3);
|
||||
change.state.values.copyWithin(1, 0, 2);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).toEqual({ values: [5, 5, 9, 2] });
|
||||
});
|
||||
|
||||
it("copies inserted draft values without aliasing their original handle", () => {
|
||||
const tracker = track({ values: [{ value: 1 }, { value: 2 }] });
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.values[0]!;
|
||||
change.state.values.unshift(held);
|
||||
held.value = 9;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual([{ value: 1 }, { value: 9 }, { value: 2 }]);
|
||||
expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]);
|
||||
});
|
||||
|
||||
it("keeps comparator edits when sorting object drafts", () => {
|
||||
const tracker = track({
|
||||
rows: [
|
||||
{ rank: 2, comparisons: 0 },
|
||||
{ rank: 1, comparisons: 0 },
|
||||
],
|
||||
});
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows.sort((left, right) => {
|
||||
left.comparisons++;
|
||||
right.comparisons++;
|
||||
return left.rank - right.rank;
|
||||
});
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.rows.map((row) => row.rank)).toEqual([1, 2]);
|
||||
expect(prepared.value.rows.map((row) => row.comparisons)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("drops writes through detached child handles", () => {
|
||||
const tracker = track({ child: { value: "removed" }, items: [{ value: "removed" }, { value: "kept" }] });
|
||||
const change = tracker.beginChange();
|
||||
const child = change.state.child;
|
||||
const shifted = change.state.items.shift();
|
||||
delete (change.state as { child?: Draft<{ value: string }>; items: Draft<{ value: string }[]> }).child;
|
||||
child.value = "detached child";
|
||||
if (shifted !== undefined) shifted.value = "detached item";
|
||||
expect(change.prepare().value).toEqual({ items: [{ value: "kept" }] });
|
||||
});
|
||||
|
||||
it.each(["unshift", "splice"] as const)("inserts 100,000 items with %s without argument overflow", (method) => {
|
||||
const tracker = track({ values: [-1] });
|
||||
const change = tracker.beginChange();
|
||||
const items = Array.from({ length: 100_000 }, (_, value) => value);
|
||||
if (method === "unshift") Reflect.apply(change.state.values.unshift, change.state.values, items);
|
||||
else Reflect.apply(change.state.values.splice, change.state.values, [1, 0, ...items]);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toHaveLength(100_001);
|
||||
expect(prepared.value.values[method === "unshift" ? 0 : 1]).toBe(0);
|
||||
expect(prepared.value.values[method === "unshift" ? 99_999 : 100_000]).toBe(99_999);
|
||||
expect(applyImmutable(prepared.base, prepared.ops)).toEqual(prepared.value);
|
||||
});
|
||||
|
||||
it("rejects array holes without mutating the committed base", () => {
|
||||
const tracker = track({ values: [1, 2] });
|
||||
const change = tracker.beginChange();
|
||||
expect(() => delete change.state.values[0]).toThrow(/holes/);
|
||||
change.abort();
|
||||
expect(tracker.value.values).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
99
packages/chord/test/delta-cow/fuzz.test.ts
Normal file
99
packages/chord/test/delta-cow/fuzz.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { applyImmutable, type Draft, type Op, track } from "../../src/delta/cow/index.ts";
|
||||
|
||||
type Item = { id: number; text: string; score: number };
|
||||
type Document = {
|
||||
items: Item[];
|
||||
text: string;
|
||||
meta: { revision: number; label?: string };
|
||||
};
|
||||
|
||||
type MutableDocument = Document | Draft<Document>;
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const random =
|
||||
(seed: number): (() => number) =>
|
||||
() => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
|
||||
const mutate = (document: MutableDocument, choice: number, value: number): void => {
|
||||
const item = (): Item => ({ id: value, text: `item-${value}`, score: value % 7 });
|
||||
switch (choice) {
|
||||
case 0:
|
||||
document.text += `-${value}`;
|
||||
break;
|
||||
case 1:
|
||||
document.text = `${document.text.slice(Math.min(2, document.text.length))}${value}`;
|
||||
break;
|
||||
case 2:
|
||||
document.items.push(item());
|
||||
break;
|
||||
case 3:
|
||||
document.items.unshift(item());
|
||||
break;
|
||||
case 4:
|
||||
if (document.items.length > 0) document.items.shift();
|
||||
break;
|
||||
case 5:
|
||||
if (document.items.length > 0) document.items.pop();
|
||||
break;
|
||||
case 6: {
|
||||
const index = document.items.length === 0 ? 0 : value % (document.items.length + 1);
|
||||
document.items.splice(index, document.items.length === 0 ? 0 : value % 2, item());
|
||||
break;
|
||||
}
|
||||
case 7:
|
||||
document.items.reverse();
|
||||
break;
|
||||
case 8:
|
||||
document.items.sort((left, right) => left.id - right.id);
|
||||
break;
|
||||
case 9:
|
||||
if (document.items.length > 0) document.items[value % document.items.length]!.score = value;
|
||||
break;
|
||||
case 10:
|
||||
document.meta.revision += 1;
|
||||
document.meta.label = `revision-${value}`;
|
||||
break;
|
||||
case 11:
|
||||
delete document.meta.label;
|
||||
break;
|
||||
case 12:
|
||||
if (document.items.length > 1) document.items[1] = clone(document.items[0]!);
|
||||
break;
|
||||
default:
|
||||
for (let index = 0; index < Math.min(2, document.items.length); index++) document.items[index] = item();
|
||||
}
|
||||
};
|
||||
|
||||
it("converges across randomized prepared revisions", () => {
|
||||
for (let seed = 1; seed <= 100; seed++) {
|
||||
const rng = random(seed);
|
||||
const initial: Document = {
|
||||
items: Array.from({ length: 4 }, (_, id) => ({ id, text: `item-${id}`, score: 0 })),
|
||||
text: "start",
|
||||
meta: { revision: 0 },
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const expected = clone(initial);
|
||||
let replica = clone(tracker.value);
|
||||
for (let step = 0; step < 100; step++) {
|
||||
const choice = Math.floor(rng() * 14);
|
||||
const value = seed * 1_000 + step;
|
||||
mutate(expected, choice, value);
|
||||
const change = tracker.beginChange();
|
||||
mutate(change.state, choice, value);
|
||||
const prepared = change.prepare();
|
||||
const operations: readonly Op[] = prepared.ops;
|
||||
replica = applyImmutable(replica, operations);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value, `state seed ${seed} step ${step} choice ${choice}`).toEqual(expected);
|
||||
expect(replica, `replica seed ${seed} step ${step} choice ${choice}`).toEqual(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
28
packages/chord/test/delta-cow/retention.test.ts
Normal file
28
packages/chord/test/delta-cow/retention.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("delta change retention across GC jobs", () => {
|
||||
it.each([
|
||||
"aborted-payload",
|
||||
"unadopted-prepared",
|
||||
"draft-proxies",
|
||||
"settled-lifecycle",
|
||||
"obsolete-revisions",
|
||||
"retained-settled-prepared",
|
||||
"retained-settled-change",
|
||||
"lifecycle-churn",
|
||||
])(
|
||||
"validates %s retention semantics",
|
||||
(scenario) => {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
["--expose-gc", fileURLToPath(new URL("./retention.worker.ts", import.meta.url)), scenario],
|
||||
{ encoding: "utf8", timeout: 60_000 },
|
||||
);
|
||||
expect(child.error, child.stderr).toBeUndefined();
|
||||
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
|
||||
},
|
||||
65_000,
|
||||
);
|
||||
});
|
||||
161
packages/chord/test/delta-cow/retention.worker.ts
Normal file
161
packages/chord/test/delta-cow/retention.worker.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { type Tracker, track } from "../../src/delta/cow/index.ts";
|
||||
|
||||
const retainedSettledObjects: object[] = [];
|
||||
const retainedLargePrepared: object[] = [];
|
||||
const retainedSettledChanges: object[] = [];
|
||||
|
||||
async function collect(ref: WeakRef<object>): Promise<void> {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
if (ref.deref() === undefined) return;
|
||||
}
|
||||
assert.fail("released change data is still retained");
|
||||
}
|
||||
|
||||
function abortedPayload(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef<object> {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const reference = new WeakRef(change.state.payload.rows);
|
||||
change.abort();
|
||||
return reference;
|
||||
}
|
||||
|
||||
function unadoptedPrepared(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef<object> {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
const prepared = change.prepare();
|
||||
return new WeakRef(prepared.value.payload!.rows);
|
||||
}
|
||||
|
||||
function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] } | null }>): WeakRef<object> {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: [{ value: 1 }] };
|
||||
const reference = new WeakRef(change.state.payload.rows[0]!);
|
||||
change.abort();
|
||||
return reference;
|
||||
}
|
||||
|
||||
async function assertRetained(reference: WeakRef<object>): Promise<void> {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
assert.notEqual(reference.deref(), undefined, "retained Prepared must retain its immutable revisions");
|
||||
}
|
||||
|
||||
function settledLifecycle(): { tracker: WeakRef<object>; future: WeakRef<object> } {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: [{ value: 1 }] };
|
||||
const retainedPrepared = change.prepare();
|
||||
tracker.adopt(retainedPrepared);
|
||||
retainedSettledObjects.push(change, retainedPrepared);
|
||||
|
||||
const futurePrepared = tracker.prepareReplace({
|
||||
payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) },
|
||||
});
|
||||
tracker.adopt(futurePrepared);
|
||||
return {
|
||||
tracker: new WeakRef(tracker),
|
||||
future: new WeakRef(futurePrepared.value.payload!.rows),
|
||||
};
|
||||
}
|
||||
|
||||
const scenario = process.argv[2];
|
||||
if (scenario === "settled-lifecycle") {
|
||||
const references = settledLifecycle();
|
||||
await collect(references.tracker);
|
||||
await collect(references.future);
|
||||
} else if (scenario === "retained-settled-prepared") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const retain = (): { base: WeakRef<object>; value: WeakRef<object> } => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload.rows[0]!.value = -1;
|
||||
const prepared = change.prepare();
|
||||
const references = {
|
||||
base: new WeakRef(prepared.base.payload.rows),
|
||||
value: new WeakRef(prepared.value.payload.rows),
|
||||
};
|
||||
tracker.adopt(prepared);
|
||||
retainedLargePrepared.push(prepared);
|
||||
return references;
|
||||
};
|
||||
const references = retain();
|
||||
const replace = (): void => {
|
||||
const replacement = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } });
|
||||
tracker.adopt(replacement);
|
||||
};
|
||||
replace();
|
||||
await assertRetained(references.base);
|
||||
await assertRetained(references.value);
|
||||
retainedLargePrepared.length = 0;
|
||||
await collect(references.base);
|
||||
await collect(references.value);
|
||||
} else if (scenario === "retained-settled-change") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const settleChange = (): { base: WeakRef<object>; value: WeakRef<object> } => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload.rows[0]!.value = -1;
|
||||
const prepared = change.prepare();
|
||||
const references = {
|
||||
base: new WeakRef(prepared.base.payload.rows),
|
||||
value: new WeakRef(prepared.value.payload.rows),
|
||||
};
|
||||
tracker.adopt(prepared);
|
||||
retainedSettledChanges.push(change);
|
||||
return references;
|
||||
};
|
||||
const references = settleChange();
|
||||
const replace = (): void => {
|
||||
const replacement = tracker.prepareReplace({ payload: { rows: [{ value: 1 }] } });
|
||||
tracker.adopt(replacement);
|
||||
};
|
||||
replace();
|
||||
await collect(references.base);
|
||||
await collect(references.value);
|
||||
assert.equal(retainedSettledChanges.length, 1);
|
||||
} else if (scenario === "lifecycle-churn") {
|
||||
const tracker = track({ value: 0 });
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
const change = tracker.beginChange();
|
||||
change.state.value = index;
|
||||
change.abort();
|
||||
}
|
||||
const prepared = tracker.prepareReplace({ value: 1 });
|
||||
tracker.adopt(prepared);
|
||||
assert.deepEqual(tracker.value, { value: 1 });
|
||||
} else if (scenario === "obsolete-revisions") {
|
||||
const tracker = track({ payload: { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) } });
|
||||
const oldRoot = new WeakRef(tracker.value.payload.rows);
|
||||
const replace = (value: number): void => {
|
||||
const prepared = tracker.prepareReplace({ payload: { rows: [{ value }] } });
|
||||
tracker.adopt(prepared);
|
||||
};
|
||||
for (let value = 0; value < 100; value++) replace(value);
|
||||
await collect(oldRoot);
|
||||
assert.deepEqual(tracker.value, { payload: { rows: [{ value: 99 }] } });
|
||||
} else {
|
||||
const tracker = track<{ payload: { rows: { value: number }[] } | null }>({ payload: null });
|
||||
let reference: WeakRef<object>;
|
||||
switch (scenario) {
|
||||
case "aborted-payload":
|
||||
reference = abortedPayload(tracker);
|
||||
break;
|
||||
case "unadopted-prepared":
|
||||
reference = unadoptedPrepared(tracker);
|
||||
break;
|
||||
case "draft-proxies":
|
||||
reference = draftProxies(tracker);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown retention scenario: ${scenario}`);
|
||||
}
|
||||
await collect(reference);
|
||||
assert.deepEqual(tracker.value, { payload: null });
|
||||
}
|
||||
console.log(JSON.stringify({ scenario, passed: true }));
|
||||
34
packages/chord/test/delta-cow/value.test.ts
Normal file
34
packages/chord/test/delta-cow/value.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { track } from "../../src/delta/cow/index.ts";
|
||||
|
||||
describe("trusted immutable revision ownership", () => {
|
||||
it("takes O(1) ownership of an alias-free strict-JSON root", () => {
|
||||
const input = { left: { value: 1 }, right: { value: 2 } };
|
||||
const tracker = track(input);
|
||||
expect(tracker.value).toBe(input);
|
||||
expect(tracker.value.left).toBe(input.left);
|
||||
});
|
||||
|
||||
it("copies changed branches while sharing untouched branches", () => {
|
||||
const tracker = track({ changed: { value: 1 }, retained: { value: 2 } });
|
||||
const base = tracker.value;
|
||||
const change = tracker.beginChange();
|
||||
change.state.changed.value = 3;
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value).not.toBe(base);
|
||||
expect(prepared.value.changed).not.toBe(base.changed);
|
||||
expect(prepared.value.retained).toBe(base.retained);
|
||||
tracker.adopt(prepared);
|
||||
expect(tracker.value).toBe(prepared.value);
|
||||
});
|
||||
|
||||
it("makes repeated placements independent", () => {
|
||||
const tracker = track({ values: [] as { value: number }[] });
|
||||
const change = tracker.beginChange();
|
||||
const shared = { value: 1 };
|
||||
change.state.values.push(shared, shared);
|
||||
const prepared = change.prepare();
|
||||
expect(prepared.value.values).toEqual([{ value: 1 }, { value: 1 }]);
|
||||
expect(prepared.value.values[0]).not.toBe(prepared.value.values[1]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue