Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/api-reference/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,11 @@ createDocumentEditor(source: EditingDocumentSource<BlockDocument>, options?: Edi
```ts
createEditingId(prefix: string): string
```
## `createEditingIdAllocator`

```ts
createEditingIdAllocator(existingIds: Iterable<string>, createId: () => string, subject: string): () => string
```
## `createEditingSession`

```ts
Expand Down
13 changes: 13 additions & 0 deletions packages/json-document-collaboration/benchmarks/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ console.log("json-document collaboration benchmark");
console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${config.warmups}`);

const ingestRows = [];
const objectRows = [];
for (const size of config.sizes) {
const initial = { items: Array.from({ length: size }, (_, index) => ({ id: `item-${index}`, done: false })) };
const author = createCollaborationRuntime(initial, { ...runtimeOptions, actorId: "author" });
Expand All @@ -29,13 +30,25 @@ for (const size of config.sizes) {
});
ingestRows.push({ size, ...ingest });

const wide = Object.fromEntries(Array.from({ length: size }, (_, index) => [`field${index}`, index]));
const objectAuthor = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "author" });
objectAuthor.document.commit([{ op: "replace", path: `/field${middle}`, value: -1 }]);
const objectBundle = objectAuthor.replica.exportBundle();
const objectIngest = measure(config, "remote wide object leaf ingest", () => {
const receiver = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "receiver" });
return () => receiver.replica.ingest(objectBundle).ok && receiver.document.value[`field${middle}`] === -1;
});
objectRows.push({ size, ...objectIngest });

measure(config, "export one-change bundle", () => () => (
author.replica.exportBundle().changes.length === 1
));
}

console.log("\nremote leaf ingest");
reportScaling(ingestRows);
console.log("\nremote wide object leaf ingest");
reportScaling(objectRows);

const ledgerSizes = (process.env.PERF_COLLABORATION_CHANGES ?? "100,1000,10000")
.split(",")
Expand Down
42 changes: 20 additions & 22 deletions packages/json-document-collaboration/src/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,26 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint {
rawPayload.reason ?? "checkpoint payload must contain only JSON values",
);
}
const ownedPayload = rawPayload.value as Readonly<Record<string, JSONValue>>;
if (
input.payload.kind !== "json-document-collaboration/checkpoint"
|| input.payload.version !== 1
ownedPayload.kind !== "json-document-collaboration/checkpoint"
|| ownedPayload.version !== 1
) {
return invalid("checkpoint payload kind or version is unsupported");
}

const base = applyPatch(input.payload.base, []);
if (!base.ok) {
return invalid(base.reason ?? "checkpoint base must be JSON");
const base = ownedPayload.base;
// Only missing fields still need Core's non-JSON diagnostic; present fields
// were already validated and detached with the complete payload.
if (base === undefined || ownedPayload.membership === undefined) {
const missing = applyPatch(undefined, []);
if (!missing.ok) return invalid(missing.reason!);
}
const membership = prepareMembership(input.payload.membership);
const membership = prepareMembership(ownedPayload.membership!);
if (!membership.ok) return membership;
const bundle = prepareBundle({
epoch: input.payload.epoch,
changes: input.payload.changes,
epoch: ownedPayload.epoch,
changes: ownedPayload.changes,
});
if (!bundle.ok) return bundle;
for (let index = 1; index < bundle.bundle.changes.length; index += 1) {
Expand All @@ -99,7 +103,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint {
);
}
}
if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base.value)) {
if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base!)) {
return invalid("checkpoint base does not match epoch baseDigest");
}
if (
Expand All @@ -115,7 +119,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint {
kind: "json-document-collaboration/checkpoint" as const,
version: 1 as const,
epoch: bundle.bundle.epoch,
base: base.value,
base: base!,
membership: membership.membership,
changes: bundle.bundle.changes,
});
Expand Down Expand Up @@ -202,33 +206,27 @@ export function verifyCheckpointProof(
}

function prepareMembership(
input: unknown,
input: JSONValue,
):
| {
readonly ok: true;
readonly membership: CollaborationMembership | null;
}
| { readonly ok: false; readonly reason: string } {
if (input === null) return { ok: true, membership: null };
const validated = applyPatch(input, []);
if (!validated.ok) {
return invalid(
validated.reason ?? "checkpoint membership must contain only JSON values",
);
}
if (
!isRecord(validated.value)
|| validated.value.version !== 1
|| !Array.isArray(validated.value.members)
!isRecord(input)
|| input.version !== 1
|| !Array.isArray(input.members)
) {
return invalid("checkpoint membership must be null or a version 1 list");
}
try {
const membership = canonicalMembership(
validated.value as unknown as CollaborationMembership,
input as unknown as CollaborationMembership,
);
if (
canonicalStringify(validated.value)
canonicalStringify(input)
!== canonicalStringify(membership as unknown as JSONValue)
) {
return invalid("checkpoint membership must be canonical");
Expand Down
20 changes: 14 additions & 6 deletions packages/json-document-collaboration/src/document-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface VisibleMember {
parent?: VisibleMember;
key: string;
children: VisibleMember[];
readonly childrenByKey: Map<string, VisibleMember> | undefined;
}

/** Compile the visible tree transition, retaining member identity in RFC 6902 moves. */
Expand Down Expand Up @@ -59,8 +60,8 @@ export function patchBetweenTrees(
if (staging === undefined) {
let key = "__json_document_transfer__";
if (Array.isArray(root.value)) key = String(root.children.length);
else while ([...root.children, ...target.children].some((child) => child.key === key)) key += "_";
staging = { id: "", value: [], container: "", key, children: [] };
else while (root.childrenByKey?.has(key) || target.childrenByKey?.has(key)) key += "_";
staging = { id: "", value: [], container: "", key, children: [], childrenByKey: undefined };
insert(staging, root, key);
operations.push({ op: "add", path: pointer(staging), value: [] });
}
Expand All @@ -74,12 +75,13 @@ export function patchBetweenTrees(
for (const child of [...node.children]) vacate(child);
const value = wanted.container === undefined ? wanted.value : Array.isArray(wanted.value) ? [] : {};
operations.push({ op: "replace", path: pointer(node), value });
const replacement: VisibleMember = { ...wanted, children: [], key: node.key };
const replacement: VisibleMember = { ...wanted, children: [], childrenByKey: wanted.childrenByKey && new Map(), key: node.key };
if (node.parent !== undefined) {
const parent = node.parent;
const index = parent.children.indexOf(node);
replacement.parent = parent;
parent.children[index] = replacement;
parent.childrenByKey?.set(replacement.key, replacement);
}
current.set(wanted.id, replacement);
node = replacement;
Expand All @@ -91,11 +93,11 @@ export function patchBetweenTrees(
if (existing !== undefined && !attached(existing, root)) existing = undefined;
const occupant = Array.isArray(node.value)
? node.children[index]
: node.children.find((entry) => entry.key === key);
: node.childrenByKey?.get(key);
if (!Array.isArray(node.value) && occupant !== undefined && occupant !== existing) vacate(occupant);
if (existing === undefined) {
const value = child.container === undefined ? child.value : Array.isArray(child.value) ? [] : {};
existing = { ...child, value, children: [] };
existing = { ...child, value, children: [], childrenByKey: child.childrenByKey && new Map() };
insert(existing, node, key);
current.set(child.id, existing);
operations.push({ op: "add", path: pointer(existing), value });
Expand Down Expand Up @@ -123,6 +125,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me
container: reference.kind === "container" ? reference.containerId : undefined,
key,
children: [],
childrenByKey: value !== null && typeof value === "object" && !Array.isArray(value) ? new Map() : undefined,
};
members.set(node.id, node);
if (value !== null && typeof value === "object") {
Expand All @@ -131,6 +134,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me
const member = snapshot(tree, childId, child, key, members);
member.parent = node;
node.children.push(member);
node.childrenByKey?.set(key, member);
}
}
return node;
Expand All @@ -155,12 +159,16 @@ function pointer(node: VisibleMember): string { return buildPointer(segments(nod
function detach(node: VisibleMember): void {
if (node.parent === undefined) throw new Error("cannot detach the document root");
node.parent.children.splice(node.parent.children.indexOf(node), 1);
node.parent.childrenByKey?.delete(node.key);
delete node.parent;
}

function insert(node: VisibleMember, parent: VisibleMember, key: string): void {
node.parent = parent;
node.key = key;
if (Array.isArray(parent.value)) parent.children.splice(Number(key), 0, node);
else parent.children.push(node);
else {
parent.children.push(node);
parent.childrenByKey!.set(key, node);
}
}
33 changes: 33 additions & 0 deletions packages/json-document-collaboration/tests/unit/checkpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto";

import { describe, expect, test, vi } from "vitest";
import * as core from "@interactive-os/json-document";
import { prepareCheckpoint } from "../../src/checkpoint.js";

import {
compactCollaborationCheckpoint,
Expand Down Expand Up @@ -108,6 +110,37 @@ function canonicalJSON(value: unknown): string {
}

describe("@interactive-os/json-document-collaboration checkpoints", () => {
test("owns the raw payload once and reuses its base and membership", () => {
const source = createCollaborationRuntime(
{ rows: Array.from({ length: 1_000 }, (_, id) => ({ id })) },
options("author", "ownership/v1", membership("author")),
);
const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint()));
const applyPatch = vi.spyOn(core, "applyPatch");
try {
const prepared = prepareCheckpoint(input);
expect(prepared.ok).toBe(true);
expect(applyPatch.mock.calls.filter(([value]) => value === input.payload)).toHaveLength(1);
expect(applyPatch.mock.calls.filter(([value]) => value === input.payload.base || value === input.payload.membership)).toHaveLength(0);
input.payload.base.rows[0].id = -1;
input.payload.membership.members[0].actorId = "poison";
if (!prepared.ok) throw new Error(prepared.reason);
expect(core.readPointer(prepared.checkpoint.payload.base, "/rows/0/id")).toMatchObject({ value: 0 });
expect(prepared.checkpoint.payload.membership).toEqual(membership("author"));
expect(Object.isFrozen(prepared.checkpoint.payload.base)).toBe(true);
} finally { applyPatch.mockRestore(); }
});

test.each(["base", "membership"])("preserves the missing %s JSON diagnostic and validation order", (field) => {
const source = createCollaborationRuntime(null, options("author", "missing/v1", undefined));
const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint()));
delete input.payload[field];
const missing = core.applyPatch(undefined, []);
expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: !missing.ok && missing.reason });
input.payload.version = 2;
expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: "checkpoint payload kind or version is unsupported" });
});

test("round-trips the complete same-epoch causal state", () => {
const members = membership("actor-a", "actor-b");
const source = createCollaborationRuntime(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { trackPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document";
import { createCollaborationRuntime } from "../../src/index.js";
import { createInitialTree } from "../../src/tree.js";
import { patchBetweenTrees } from "../../src/document-patch.js";

const options = { epochId: "tracking/v1", ruleset: { id: "tracking", digest: "v1" } };

describe("remote structural notification", () => {
test("looks up a wide object's keys without rescanning siblings for each key", () => {
const before = Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, index]));
const after = { ...before, field2500: -1 };
const beforeTree = createInitialTree(before, "wide");
const afterTree = createInitialTree(after, "wide");
const find = vi.spyOn(Array.prototype, "find");
try {
const operations = patchBetweenTrees(before, after, beforeTree, afterTree);
const siblingSearches = find.mock.contexts.filter((value) => (
Array.isArray(value) && value.length > 100 && value[0] && "key" in value[0]
));
expect(siblingSearches).toHaveLength(0);
expect(operations).toEqual([{ op: "replace", path: "/field2500", value: -1 }]);
} finally { find.mockRestore(); }
});

test.each([
{ name: "move then edit", initial: { items: [{ label: "a" }, { label: "b" }] }, pointer: "/items/0/label", patch: [{ op: "move", from: "/items/0", path: "/items/1" }, { op: "replace", path: "/items/1/label", value: "edited" }], expected: "/items/1/label" },
{ name: "escaped object member", initial: { "a/b": { label: "a" } }, pointer: "/a~1b/label", patch: [{ op: "move", from: "/a~1b", path: "/~0" }], expected: "/~0/label" },
Expand All @@ -18,6 +36,7 @@ describe("remote structural notification", () => {
{ name: "object swap", initial: { a: { label: "a" }, b: { label: "b" } }, pointer: "/a/label", patch: [{ op: "move", from: "/a", path: "/temp" }, { op: "move", from: "/b", path: "/a" }, { op: "move", from: "/temp", path: "/b" }], expected: "/b/label" },
{ name: "move out before replacing its parent", initial: { left: { item: { label: "a" } }, right: {} }, pointer: "/left/item/label", patch: [{ op: "move", from: "/left/item", path: "/right/item" }, { op: "replace", path: "/left", value: {} }], expected: "/right/item/label" },
{ name: "root array reorder", initial: [{ label: "a" }, { label: "b" }], pointer: "/0/label", patch: [{ op: "move", from: "/0", path: "/1" }], expected: "/1/label" },
{ name: "successive key swaps and a replaced container", initial: { a: { item: { label: "a" } }, b: { label: "b" }, __json_document_transfer__: 1, __json_document_transfer___: 2 }, pointer: "/a/item/label", patch: [{ op: "move", from: "/a/item", path: "/temp" }, { op: "replace", path: "/a", value: {} }, { op: "move", from: "/b", path: "/a/new" }, { op: "move", from: "/temp", path: "/b" }, { op: "replace", path: "/a/new/label", value: "edited" }], expected: "/b/label" },
] as const)("$name preserves the address of the same member", ({ initial, pointer, patch, expected }) => {
const local = createCollaborationRuntime(initial, { ...options, actorId: "local" });
const remote = createCollaborationRuntime(initial, { ...options, actorId: "remote" });
Expand Down
7 changes: 7 additions & 0 deletions packages/json-document-editing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ replica. Custom `createId` injection remains supported; its provider must ensure
uniqueness across all writers. Environments without `crypto.randomUUID` fail
explicitly with `editing.id-provider-unavailable`; no weak random fallback is used.

`createEditingIdAllocator(existingIds, createId, subject)` reads an iterable of
occupied IDs once and returns a function that reserves each newly allocated ID.
Use one allocator for a batch; the five structural editors share this owner.
Each call tries the injected provider at most 100 times before throwing
`createId did not produce a unique <subject> id`. The allocator covers its local
reservation set, not cross-replica uniqueness; the provider still owns that.

The last UI unsubscribe releases the session's document and external-history
observation connections. Local undo/redo validity is independent of UI subscriptions:
a one-shot change marker retains no session, history stack or UI callback and
Expand Down
13 changes: 12 additions & 1 deletion packages/json-document-editing/benchmarks/editors.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { benchmarkConfig, measure, reportScaling } from "../../../benchmarks/measure.mjs";
import { createDatabaseEditor, createSheetEditor, createTreeEditor } from "../dist/index.js";
import { createDatabaseEditor, createSheetEditor, createTreeEditor, createObjectEditor } from "../dist/index.js";

const config = benchmarkConfig("PERF_EDITING_ITEMS");
console.log("json-document editing benchmark");
Expand All @@ -8,6 +8,17 @@ console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${c
const workloads = new Map();
for (const size of config.sizes) {
console.log(`\nitems=${size}`);
const objects = Array.from({ length: size }, (_, index) => ({
id: `object-${index}`, label: "Object", x: 0, y: 0, width: 1, height: 1, color: "subtle",
}));
const copies = Math.min(size, 1_000);
record("object batch paste", size, measure(config, "object batch paste", () => {
let sequence = 0;
const editor = createObjectEditor({ objects }, { createId: () => `copy-${sequence++}` });
const clipboard = { type: "application/vnd.interactive-os.objects+json", objects: objects.slice(0, copies), text: "" };
return () => editor.dispatch({ type: "clipboard.paste", clipboard }).ok
&& editor.snapshot.value.objects.length === size + copies;
}));
const treeDocument = { nodes: Array.from({ length: size }, (_, index) => ({
id: `node-${index}`,
parentId: index === 0 ? null : "node-0",
Expand Down
Loading