mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): optimize immutable batch application
This commit is contained in:
parent
b2bd111f2d
commit
9e70c3d505
6 changed files with 329 additions and 93 deletions
|
|
@ -7,7 +7,7 @@ Immutability is an ownership contract. Nothing is frozen or defensively copied,
|
|||
so an illegal mutation is not detected. It silently corrupts state.
|
||||
|
||||
```ts
|
||||
import { applyImmutable, track } from "@earendil-works/chord/delta";
|
||||
import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const initial = { output: "", entries: [] as { id: number }[] };
|
||||
const tracker = track(initial); // `initial` is transferred: never mutate it again
|
||||
|
|
@ -34,7 +34,7 @@ const replica = applyImmutable(prepared.base, prepared.ops);
|
|||
| External value after assigning or inserting it into a draft | Yes. The draft stored a validated clone. |
|
||||
| `tracker.value`, `prepared.base`, retained older revisions | No. |
|
||||
| `prepared.value`, `prepared.ops`, op tuples, paths, permutations, payloads | No. Payloads may be the same objects as parts of `prepared.value`. |
|
||||
| `applyImmutable()` inputs and result | No. The result shares containers with both inputs. |
|
||||
| `applyImmutable()` / `applyImmutableBatches()` inputs and result | No. The result shares containers with both inputs. |
|
||||
| Values from replicated state (`value`, listener values, loopback consumers) | No. In-process consumers may share the provider's containers. |
|
||||
| Mutable replica passed to `apply()` | Only through `apply()`, with batches it owns exclusively. Code that edits it between batches breaks convergence. |
|
||||
| Batch passed to `apply()` | Consumed. `apply()` adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again. |
|
||||
|
|
@ -50,6 +50,20 @@ let replica = tracker.value; // shared with authority; never mutated
|
|||
replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base
|
||||
```
|
||||
|
||||
When only the final result of an ordered backlog is needed, replay its batches
|
||||
without concatenating their operations. One copy-on-write scope is shared across
|
||||
the complete call, so no intermediate revision is exposed or safe to retain:
|
||||
|
||||
```ts
|
||||
replica = applyImmutableBatches(
|
||||
replica,
|
||||
queuedFrames.map((frame) => frame.ops),
|
||||
);
|
||||
```
|
||||
|
||||
Use separate `applyImmutable()` calls when every intermediate revision is
|
||||
published or retained.
|
||||
|
||||
Mutable replay needs a detached starting root and a detached copy of every batch
|
||||
for every replica. Never apply one in-memory batch to two mutable replicas:
|
||||
|
||||
|
|
@ -155,8 +169,8 @@ and large edit sets may fold into a region splice, an ancestor `s`, or `r`.
|
|||
|
||||
Reserved keys: the tracker never emits `__proto__`, `constructor`, or
|
||||
`prototype` as a path segment. A mutation at or below such a key is folded into a
|
||||
set of the nearest safe ancestor, or `r` at the root. `apply()`,
|
||||
`applyImmutable()`, and `decoder()` reject those segments with `UnsafePathError`.
|
||||
set of the nearest safe ancestor, or `r` at the root. `apply()`, `applyImmutable()`, `applyImmutableBatches()`, and `decoder()` reject
|
||||
those segments with `UnsafePathError`.
|
||||
The appliers write values as own data properties, never through a prototype setter.
|
||||
Appliers check op shape and path safety but not payload strictness. Chord's
|
||||
replicated-state replicas validate each resulting revision.
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
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";
|
||||
}
|
||||
|
|
@ -405,22 +405,35 @@ function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
|
|||
return root as unknown as T;
|
||||
}
|
||||
|
||||
/** Apply decoded operations without mutating the previous immutable value. */
|
||||
/** Apply one decoded operation batch without mutating the previous immutable value. */
|
||||
export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {
|
||||
return applyImmutableBatches(target, [ops]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply decoded operation batches as one final-result-only replay.
|
||||
*
|
||||
* Containers copied for an earlier batch may be mutated while applying a later
|
||||
* batch, so this deliberately exposes no intermediate revisions.
|
||||
*/
|
||||
export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T {
|
||||
let root = target as unknown as JsonValue;
|
||||
for (const op of ops) {
|
||||
assertValidOp(op);
|
||||
if (op[0] === "r") {
|
||||
root = op[1];
|
||||
continue;
|
||||
const owned = new WeakSet<object>();
|
||||
for (const ops of batches) {
|
||||
for (const op of ops) {
|
||||
assertValidOp(op);
|
||||
if (op[0] === "r") {
|
||||
root = op[1];
|
||||
continue;
|
||||
}
|
||||
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned);
|
||||
root = applyOps(root, [op]);
|
||||
}
|
||||
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1));
|
||||
root = applyOps(root, [op]);
|
||||
}
|
||||
return root as unknown as T;
|
||||
}
|
||||
|
||||
function copyContainers(root: JsonValue, path: Path): JsonValue {
|
||||
function copyContainers(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
|
||||
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
|
||||
if (Array.isArray(value)) return value.slice();
|
||||
if (!isObj(value)) throw new PathError(path);
|
||||
|
|
@ -438,13 +451,23 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
|
|||
}
|
||||
return result;
|
||||
};
|
||||
const copiedRoot = copy(root);
|
||||
let source = root;
|
||||
if (!isObj(root)) throw new PathError(path);
|
||||
let copiedRoot: JsonValue[] | Record<string, JsonValue>;
|
||||
if (owned.has(root)) copiedRoot = root as JsonValue[] | Record<string, JsonValue>;
|
||||
else {
|
||||
copiedRoot = copy(root);
|
||||
owned.add(copiedRoot);
|
||||
}
|
||||
let destination = copiedRoot;
|
||||
for (const segment of path) {
|
||||
if (!isObj(source) || !Object.hasOwn(source, segment)) throw new PathError(path);
|
||||
if (Array.isArray(source) && typeof segment !== "number") throw new UnsafePathError(segment);
|
||||
const child = (source as Record<Seg, JsonValue>)[segment]!;
|
||||
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
|
||||
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
|
||||
const child = (destination as Record<Seg, JsonValue>)[segment]!;
|
||||
if (!isObj(child)) throw new PathError(path);
|
||||
if (owned.has(child)) {
|
||||
destination = child as JsonValue[] | Record<string, JsonValue>;
|
||||
continue;
|
||||
}
|
||||
const copiedChild = copy(child);
|
||||
Object.defineProperty(destination, segment, {
|
||||
value: copiedChild,
|
||||
|
|
@ -452,7 +475,7 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
|
|||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
source = child;
|
||||
owned.add(copiedChild);
|
||||
destination = copiedChild;
|
||||
}
|
||||
return copiedRoot;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { copyJson } from "../json.ts";
|
||||
import type { JsonValue } from "../types.ts";
|
||||
import { applyImmutable } from "./apply-immutable-batch.ts";
|
||||
import { applyImmutableTrusted } from "./apply-immutable-trusted.ts";
|
||||
import type { Draft } from "./draft.ts";
|
||||
import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts";
|
||||
import { applyImmutable, type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts";
|
||||
|
||||
export type { Draft, JsonValue, Op };
|
||||
|
||||
|
|
|
|||
272
packages/chord/test/delta-apply-immutable.test.ts
Normal file
272
packages/chord/test/delta-apply-immutable.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
apply,
|
||||
applyImmutable,
|
||||
applyImmutableBatches,
|
||||
type JsonValue,
|
||||
type Op,
|
||||
PathError,
|
||||
track,
|
||||
UnsafePathError,
|
||||
} from "../src/delta/index.ts";
|
||||
|
||||
function freezeDeep<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
for (const child of Object.values(value)) freezeDeep(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
describe("checked immutable operation application", () => {
|
||||
it("copies each touched container once while preserving input and payload ownership", () => {
|
||||
const shared = freezeDeep({ nested: { value: 1 } });
|
||||
const untouched = freezeDeep({ value: 9 });
|
||||
const rowPayload = freezeDeep({ id: 4, label: "placed" });
|
||||
const base = freezeDeep({
|
||||
text: "abcdef",
|
||||
stable: { value: 7 },
|
||||
branch: { value: 1 },
|
||||
copy: null as { value: number } | null,
|
||||
placed: null as typeof shared | null,
|
||||
untouched: null as typeof untouched | null,
|
||||
left: null as typeof shared | null,
|
||||
right: null as typeof shared | null,
|
||||
meta: { count: 0, obsolete: true },
|
||||
rows: [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 2, label: "two" },
|
||||
{ id: 3, label: "three" },
|
||||
],
|
||||
});
|
||||
const operations = freezeDeep([
|
||||
["t", ["text"], 2],
|
||||
["a", ["text"], "!"],
|
||||
["s", ["meta", "count"], 1],
|
||||
["s", ["meta", "count"], 2],
|
||||
["d", ["meta", "obsolete"]],
|
||||
["s", ["copy"], base.branch],
|
||||
["s", ["copy", "value"], 2],
|
||||
["s", ["placed"], shared],
|
||||
["s", ["placed", "nested", "value"], 2],
|
||||
["s", ["untouched"], untouched],
|
||||
["s", ["left"], shared],
|
||||
["s", ["right"], shared],
|
||||
["s", ["left", "nested", "value"], 3],
|
||||
["p", ["rows"], 1, 1, [rowPayload]],
|
||||
["s", ["rows", 1, "label"], "edited"],
|
||||
["m", ["rows"], [1, 0, 2]],
|
||||
["s", ["rows", 0, "label"], "moved"],
|
||||
] satisfies Op[]);
|
||||
|
||||
const result = applyImmutable(base, operations);
|
||||
const mutableResult = apply(clone(base), clone(operations));
|
||||
|
||||
expect(result).toEqual(mutableResult);
|
||||
expect(result.text).toBe("cdef!");
|
||||
expect(result.stable).toBe(base.stable);
|
||||
expect(result.untouched).toBe(untouched);
|
||||
expect(result.copy).not.toBe(base.branch);
|
||||
expect(result.copy).toEqual({ value: 2 });
|
||||
expect(result.placed).not.toBe(shared);
|
||||
expect(result.placed).toEqual({ nested: { value: 2 } });
|
||||
expect(result.left).not.toBe(shared);
|
||||
expect(result.right).toBe(shared);
|
||||
expect(result.rows[0]).not.toBe(rowPayload);
|
||||
expect(result.rows[0]).toEqual({ id: 4, label: "moved" });
|
||||
expect(base.branch.value).toBe(1);
|
||||
expect(shared.nested.value).toBe(1);
|
||||
expect(rowPayload.label).toBe("placed");
|
||||
});
|
||||
|
||||
it("protects root replacement payloads before later object and array edits", () => {
|
||||
const replacement = freezeDeep({ nested: { value: 1 }, values: [1, 2, 3] });
|
||||
const result = applyImmutableBatches<typeof replacement>(undefined, [
|
||||
[["r", replacement]],
|
||||
[["s", ["nested", "value"], 2]],
|
||||
[
|
||||
["p", ["values"], 1, 1, [4, 5]],
|
||||
["m", ["values"], [3, 0, 1, 2]],
|
||||
],
|
||||
]);
|
||||
expect(result).toEqual({ nested: { value: 2 }, values: [3, 1, 4, 5] });
|
||||
expect(replacement).toEqual({ nested: { value: 1 }, values: [1, 2, 3] });
|
||||
|
||||
const array = freezeDeep([1, 2, 3]);
|
||||
const arrayResult = applyImmutable<number[]>(array, [
|
||||
["p", [], 1, 1, [4, 5]],
|
||||
["m", [], [3, 0, 1, 2]],
|
||||
["d", [1]],
|
||||
]);
|
||||
expect(arrayResult).toEqual([3, 4, 5]);
|
||||
expect(array).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("shares one private copy-on-write scope across batch partitions", () => {
|
||||
const base = freezeDeep({
|
||||
text: "abcdef",
|
||||
meta: { count: 0 },
|
||||
values: [
|
||||
{ id: 1, value: 1 },
|
||||
{ id: 2, value: 2 },
|
||||
{ id: 3, value: 3 },
|
||||
],
|
||||
});
|
||||
const batches = [
|
||||
[
|
||||
["s", ["meta", "count"], 1],
|
||||
["p", ["values"], 1, 1, [{ id: 4, value: 4 }]],
|
||||
] satisfies Op[],
|
||||
[] satisfies Op[],
|
||||
[
|
||||
["m", ["values"], [2, 0, 1]],
|
||||
["s", ["values", 2, "value"], 40],
|
||||
] satisfies Op[],
|
||||
[
|
||||
["t", ["text"], 2],
|
||||
["a", ["text"], "!"],
|
||||
] satisfies Op[],
|
||||
];
|
||||
|
||||
const intermediate = applyImmutable(base, batches[0]);
|
||||
const intermediateSnapshot = clone(intermediate);
|
||||
let sequential = intermediate;
|
||||
for (let index = 1; index < batches.length; index++) sequential = applyImmutable(sequential, batches[index]!);
|
||||
const streamed = applyImmutableBatches(base, batches);
|
||||
const flattened = applyImmutable(base, batches.flat());
|
||||
|
||||
expect(streamed).toEqual(sequential);
|
||||
expect(streamed).toEqual(flattened);
|
||||
expect(intermediate).toEqual(intermediateSnapshot);
|
||||
expect(base.meta.count).toBe(0);
|
||||
expect(base.values[1]!.id).toBe(2);
|
||||
});
|
||||
|
||||
it("handles tracker-produced batches across arbitrary revision boundaries", () => {
|
||||
type Document = { text: string; values: { id: number; score: number }[]; revision: number };
|
||||
const initial: Document = {
|
||||
text: "start",
|
||||
values: Array.from({ length: 8 }, (_, id) => ({ id, score: 0 })),
|
||||
revision: 0,
|
||||
};
|
||||
const tracker = track(initial);
|
||||
const batches: Array<readonly Op[]> = [];
|
||||
for (let revision = 1; revision <= 40; revision++) {
|
||||
const change = tracker.beginChange();
|
||||
change.state.revision = revision;
|
||||
change.state.text = `${change.state.text.slice(1)}${revision}`;
|
||||
switch (revision % 5) {
|
||||
case 0:
|
||||
change.state.values.reverse();
|
||||
break;
|
||||
case 1:
|
||||
change.state.values.push({ id: 100 + revision, score: revision });
|
||||
break;
|
||||
case 2:
|
||||
change.state.values.shift();
|
||||
break;
|
||||
case 3:
|
||||
change.state.values[revision % change.state.values.length]!.score = revision;
|
||||
break;
|
||||
default:
|
||||
change.state.values.splice(1, 1, { id: 200 + revision, score: revision });
|
||||
}
|
||||
const prepared = change.prepare();
|
||||
batches.push(prepared.ops);
|
||||
tracker.adopt(prepared);
|
||||
}
|
||||
|
||||
expect(applyImmutableBatches(initial, batches)).toEqual(tracker.value);
|
||||
});
|
||||
|
||||
it("does not expose partial application when validation or iteration fails", () => {
|
||||
const base = freezeDeep({ nested: { value: 1 } });
|
||||
const invalidBatches: readonly (readonly Op[])[] = [
|
||||
[["s", ["nested", "value"], 2]],
|
||||
[["s", ["constructor", "prototype", "polluted"], true]],
|
||||
];
|
||||
expect(() => applyImmutableBatches(base, invalidBatches)).toThrow();
|
||||
expect(base.nested.value).toBe(1);
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
|
||||
|
||||
function* throwingBatches(): Generator<readonly Op[]> {
|
||||
yield [["s", ["nested", "value"], 3]];
|
||||
throw new Error("revision stream failed");
|
||||
}
|
||||
expect(() => applyImmutableBatches(base, throwingBatches())).toThrow("revision stream failed");
|
||||
expect(base.nested.value).toBe(1);
|
||||
|
||||
let advancedPastInvalid = false;
|
||||
let iteratorClosed = false;
|
||||
function* invalidOperationBatches(): Generator<readonly Op[]> {
|
||||
try {
|
||||
yield [["s", ["nested", "value"], 4]];
|
||||
yield [["s", ["__proto__", "polluted"], true]];
|
||||
advancedPastInvalid = true;
|
||||
} finally {
|
||||
iteratorClosed = true;
|
||||
}
|
||||
}
|
||||
expect(() => applyImmutableBatches(base, invalidOperationBatches())).toThrow(UnsafePathError);
|
||||
expect(advancedPastInvalid).toBe(false);
|
||||
expect(iteratorClosed).toBe(true);
|
||||
expect(base.nested.value).toBe(1);
|
||||
|
||||
let reads = 0;
|
||||
const target = Object.defineProperty({}, "trap", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads++;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const malformed: Op[] = [["s", "bad-path", 1] as never];
|
||||
expect(() => applyImmutableBatches(target, [malformed])).toThrow(/path/);
|
||||
expect(reads).toBe(0);
|
||||
|
||||
expect(() => applyImmutable({ values: [] }, [["s", ["values", "missing", "value"], 1]])).toThrow(PathError);
|
||||
expect(() => applyImmutable({ values: [{}] }, [["s", ["values", "0", "value"], 1]])).toThrow(UnsafePathError);
|
||||
});
|
||||
|
||||
it("allows one immutable batch to fan out without mutating shared payloads", () => {
|
||||
const payload = freezeDeep({ nested: { value: 1 } });
|
||||
const operations: readonly Op[] = [
|
||||
["s", ["placed"], payload],
|
||||
["s", ["placed", "nested", "value"], 2],
|
||||
];
|
||||
const base = freezeDeep({ placed: null as typeof payload | null });
|
||||
const first = applyImmutable(base, operations);
|
||||
const second = applyImmutable(base, operations);
|
||||
expect(first).toEqual(second);
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.placed).not.toBe(second.placed);
|
||||
expect(payload.nested.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("immutable application complexity", () => {
|
||||
it("copies a wide object once rather than once per repeated write", () => {
|
||||
const width = 20_000;
|
||||
const base: Record<string, JsonValue> = {};
|
||||
for (let index = 0; index < width; index++) base[`field${index}`] = index;
|
||||
const operations: Op[] = Array.from({ length: 1_000 }, (_, index): Op => ["s", [`field${index}`], -index]);
|
||||
const originalKeys = Object.keys;
|
||||
let shallowCopies = 0;
|
||||
Object.keys = ((value: object): string[] => {
|
||||
shallowCopies++;
|
||||
return originalKeys(value);
|
||||
}) as typeof Object.keys;
|
||||
let result: Record<string, JsonValue>;
|
||||
try {
|
||||
result = applyImmutable(base, operations);
|
||||
} finally {
|
||||
Object.keys = originalKeys;
|
||||
}
|
||||
|
||||
expect(result.field999).toBe(-999);
|
||||
expect(base.field999).toBe(999);
|
||||
expect(shallowCopies).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -95,7 +95,6 @@ function sources() {
|
|||
"../src/delta/draft.ts",
|
||||
"../src/delta/diff.ts",
|
||||
"../src/delta/revision-validator.ts",
|
||||
"../src/delta/apply-immutable-batch.ts",
|
||||
"../src/delta/apply-immutable-trusted.ts",
|
||||
"./delta-traversal.bench.ts",
|
||||
].map((path) => [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue