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
2 changes: 1 addition & 1 deletion docs/api-reference/json-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ type ReadResult =
## `trackPointer`

```ts
trackPointer(pointer: Pointer, applied: ReadonlyArray<JSONPatchOperation>): Pointer | null
trackPointer(pointer: Pointer, applied: ReadonlyArray<JSONPatchOperation>, before?: JSONValue): Pointer | null
```
## `tryParsePointer`

Expand Down
24 changes: 19 additions & 5 deletions docs/public/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,23 @@ function asPointer(path: string): Pointer | null {
`null`을 돌려줍니다. `appendSegment`는 Pointer에 segment를 하나 추가하고,
`parentPointer`는 부모 위치를 돌려줍니다.

`trackPointer(pointer, operations)`는 patch가 적용된 뒤 같은 값이 이동한
위치를 계산합니다. 값이 제거됐거나 더 이상 한 위치로 추적되지 않으면
`null`입니다.
`trackPointer(pointer, change.applied, before)`는 commit 직전 snapshot을 문맥으로
사용하여 patch 이후 위치를 계산합니다. 각 operation의 중간 상태에서 객체 key와
배열 index를 구별합니다. 값이 제거되거나 상위 값의 교체로 위치를 잃으면 `null`입니다.
같은 위치의 `replace`는 유지되며 교체된 값의 자손은 해제됩니다.

```ts
import { createJSONDocument, trackPointer } from "@interactive-os/json-document";

const document = createJSONDocument({ items: { "0": "a", "1": "b" } });
const before = document.value;
const result = document.commit([{ op: "add", path: "/items/0", value: "A" }]);
if (result.ok) trackPointer("/items/1", result.change.applied, before); // /items/1
```

기존 두 인자 호출도 호환됩니다. 다만 문맥이 없으면 숫자 segment를 배열 index로
간주하는 이전 동작이 유지되므로, 숫자 객체 key를 포함할 수 있는 일반 JSON에는
세 인자 호출을 사용합니다. `operations`에는 commit이 돌려준 concrete `change.applied`를 전달합니다.

JSON Pointer의 array segment를 index로 해석해야 하는 adapter는 정본
`parseArrayIndex`를 사용합니다. 선행 0, 음수, 안전하지 않은 정수는
Expand Down Expand Up @@ -284,13 +298,13 @@ type Failure = {

## 공개 export

Package root는 다음 21개 symbol을 공개합니다.
Package root는 다음 23개 symbol을 공개합니다.

```txt
values
applyPatch, createJSONDocument
appendSegment, buildPointer, parentPointer, parsePointer
trackPointer, tryParsePointer
jsonEqual, parseArrayIndex, trackPointer, tryParsePointer

types
JSONValue, Pointer, JSONPatchOperation
Expand Down
4 changes: 2 additions & 2 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ import {
```

Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다.
공개 Root는 정확히 다음 21개 symbol이다.
공개 Root는 정확히 다음 23개 symbol이다.

```txt
values
appendSegment, applyPatch, buildPointer, createJSONDocument
parentPointer, parsePointer, trackPointer, tryParsePointer
jsonEqual, parentPointer, parseArrayIndex, parsePointer, trackPointer, tryParsePointer

types
JSONAppliedChange, JSONPatchValidationResult
Expand Down
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"site:evaluate:live": "node site/scripts/evaluate-live.mjs"
},
"devDependencies": {
"jsonpath-js": "0.3.1",
"@playwright/test": "^1.60.0"
},
"optionalDependencies": {
Expand Down
15 changes: 15 additions & 0 deletions packages/json-document-collaboration/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# @interactive-os/json-document-collaboration

Remote `document.subscribe` notifications compile visible tree identities into
ordered JSON Patch moves, insertions, and removals. Consumers can use
`trackPointer(pointer, change.applied, before)` with the previous snapshot to
follow array reorders, object renames, and cross-container moves. A batch may use
temporary transfer locations to preserve identities through swaps; only the
final document is published. Root replacement still invalidates descendants,
while moving a surviving container to root retains its descendant addresses.
JSON-equal transitions remain notification-free under the JSONDocument contract.

Data-only causal append reuses the previous materialization. Reordered histories
and history controls still replay from the epoch base. Causal ancestry uses actor
frontiers rather than recursive dependency walks. `benchmarks/runtime.mjs` measures
both remote history ingest and the first subsequent edit by a new actor; no wire
or checkpoint format changed.

Transport-free causal collaboration engine for the six-member
`@interactive-os/json-document` JSON Document contract.

Expand Down
10 changes: 10 additions & 0 deletions packages/json-document-collaboration/benchmarks/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ seed.document.commit([{ op: "replace", path: "/value", value: 1 }]);
const first = seed.replica.exportBundle().changes[0];
if (first === undefined || first.ops[0]?.kind !== "set") throw new Error("ledger seed failed");
const ledgerRows = [];
const commitRows = [];
console.log("\nledger replay");
for (const size of ledgerSizes) {
const changes = Array.from({ length: size }, (_, index) => ({
Expand All @@ -58,5 +59,14 @@ for (const size of ledgerSizes) {
return () => receiver.replica.ingest(bundle).ok;
});
ledgerRows.push({ size, ...result });
const commit = measure(config, `${size} prior changes -> new actor commit`, () => {
const receiver = createCollaborationRuntime({ value: 0 }, { ...runtimeOptions, actorId: "ledger-receiver" });
if (!receiver.replica.ingest(bundle).ok) throw new Error("ledger ingest failed");
return () => receiver.document.commit([{ op: "replace", path: "/value", value: 2 }]).ok
&& receiver.document.value.value === 2;
});
commitRows.push({ size, ...commit });
}
reportScaling(ledgerRows);
console.log("\ncontinued editing after remote history");
reportScaling(commitRows);
183 changes: 153 additions & 30 deletions packages/json-document-collaboration/src/document-patch.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,166 @@
import { buildPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document";
import { jsonEqual } from "@interactive-os/json-document";
import { buildPointer, jsonEqual, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document";
import { visibleMemberEntries, type TreeState } from "./tree.js";

export function patchBetweenValues(
before: JSONValue,
after: JSONValue,
): ReadonlyArray<JSONPatchOperation> {
const operations = diff(before, after, []);
return operations.length === 0 && !jsonEqual(before, after)
? [{ op: "replace", path: "", value: after }]
: operations;
interface VisibleMember {
readonly id: string;
readonly value: JSONValue;
readonly container: string | undefined;
parent?: VisibleMember;
key: string;
children: VisibleMember[];
}

function diff(
/** Compile the visible tree transition, retaining member identity in RFC 6902 moves. */
export function patchBetweenTrees(
before: JSONValue,
after: JSONValue,
segments: ReadonlyArray<string | number>,
): JSONPatchOperation[] {
if (before === after || jsonEqual(before, after)) return [];
if (isRecord(before) && isRecord(after)) {
const operations: JSONPatchOperation[] = [];
for (const key of Object.keys(after)) {
if (!Object.prototype.hasOwnProperty.call(before, key)) {
operations.push({ op: "add", path: buildPointer([...segments, key]), value: after[key]! });
continue;
beforeTree: TreeState,
afterTree: TreeState,
): ReadonlyArray<JSONPatchOperation> {
const current = new Map<string, VisibleMember>();
const desired = new Map<string, VisibleMember>();
let root = snapshot(beforeTree, beforeTree.root, before, "", current);
const target = snapshot(afterTree, afterTree.root, after, "", desired);
const operations: JSONPatchOperation[] = [];
if (root.container !== target.container && target.container !== undefined) {
const source = [...current.values()].find((member) => member.container === target.container);
if (source !== undefined) {
operations.push({ op: "move", from: pointer(source), path: "" });
detach(source);
root = source;
}
}
// Root replacement invalidates descendants, just as a local root replace does.
if (root.container !== target.container || root.container === undefined) {
return jsonEqual(before, after) ? [] : [{ op: "replace", path: "", value: after }];
}
let staging: VisibleMember | undefined;

function remove(node: VisibleMember): void {
operations.push({ op: "remove", path: pointer(node) });
detach(node);
}

function move(node: VisibleMember, parent: VisibleMember, key: string): void {
const from = pointer(node);
detach(node);
// RFC 6902 resolves the destination after removing the source.
const path = buildPointer([...segments(parent), key]);
insert(node, parent, key);
operations.push({ op: "move", from, path });
}

function retain(node: VisibleMember): boolean {
return desired.has(node.id) || node.children.some(retain);
}

function vacate(node: VisibleMember): void {
if (!retain(node)) { remove(node); return; }
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: [] };
insert(staging, root, key);
operations.push({ op: "add", path: pointer(staging), value: [] });
}
move(node, staging, String(staging.children.length));
}

function reconcile(node: VisibleMember, wanted: VisibleMember): void {
if (node.container !== wanted.container || node.container === undefined) {
if (jsonEqual(node.value, wanted.value) && node.container === wanted.container) return;
// Rescue members moved out of a replaced container before dropping it.
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 };
if (node.parent !== undefined) {
const parent = node.parent;
const index = parent.children.indexOf(node);
replacement.parent = parent;
parent.children[index] = replacement;
}
current.set(wanted.id, replacement);
node = replacement;
if (wanted.container === undefined) return;
}
for (const [index, child] of wanted.children.entries()) {
const key = Array.isArray(wanted.value) ? String(index) : child.key;
let existing = current.get(child.id);
if (existing !== undefined && !attached(existing, root)) existing = undefined;
const occupant = Array.isArray(node.value)
? node.children[index]
: node.children.find((entry) => entry.key === 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: [] };
insert(existing, node, key);
current.set(child.id, existing);
operations.push({ op: "add", path: pointer(existing), value });
} else if (existing.parent !== node || occupant !== existing) {
move(existing, node, key);
}
operations.push(...diff(before[key]!, after[key]!, [...segments, key]));
reconcile(existing, child);
}
for (const key of Object.keys(before)) {
if (Object.prototype.hasOwnProperty.call(after, key)) continue;
operations.push({ op: "remove", path: buildPointer([...segments, key]) });
const wantedIds = new Set(wanted.children.map((child) => child.id));
for (const child of [...node.children]) {
if (child !== staging && !wantedIds.has(child.id)) vacate(child);
}
return operations;
}
if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
return before.flatMap((value, index) => diff(value, after[index]!, [...segments, index]));

reconcile(root, target);
if (staging !== undefined) remove(staging);
return operations;
}

function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, members: Map<string, VisibleMember>): VisibleMember {
const reference = tree.members.get(id)!.node;
const node: VisibleMember = {
id,
value,
container: reference.kind === "container" ? reference.containerId : undefined,
key,
children: [],
};
members.set(node.id, node);
if (value !== null && typeof value === "object") {
for (const [key, childId] of visibleMemberEntries(tree, id)) {
const child = Array.isArray(value) ? value[Number(key)]! : (value as Readonly<Record<string, JSONValue>>)[key]!;
const member = snapshot(tree, childId, child, key, members);
member.parent = node;
node.children.push(member);
}
}
return [{ op: "replace", path: buildPointer(segments), value: after }];
return node;
}

function attached(node: VisibleMember, root: VisibleMember): boolean {
while (node.parent !== undefined) node = node.parent;
return node === root;
}

function segments(node: VisibleMember): string[] {
const result: string[] = [];
while (node.parent !== undefined) {
result.push(Array.isArray(node.parent.value) ? String(node.parent.children.indexOf(node)) : node.key);
node = node.parent;
}
return result.reverse();
}

function pointer(node: VisibleMember): string { return buildPointer(segments(node)); }

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);
delete node.parent;
}

function isRecord(value: JSONValue): value is { readonly [key: string]: JSONValue } {
return typeof value === "object" && value !== null && !Array.isArray(value);
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function createDocumentRuntime(state: RuntimeState): JSONDocument {
state.initialTree,
nextGraph.ordered,
state.materializeValidation,
{ ordered: state.graph.ordered, materialized: state.materialized },
);
if (!jsonEqual(nextMaterialized.value, patched.value)) {
return failure(
Expand Down
7 changes: 5 additions & 2 deletions packages/json-document-collaboration/src/history-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
prepareGraph,
type PreparedGraph,
} from "./change.js";
import { patchBetweenValues } from "./document-patch.js";
import { patchBetweenTrees } from "./document-patch.js";
import { jsonEqual } from "@interactive-os/json-document";
import {
historyOperationFor,
Expand Down Expand Up @@ -212,6 +212,7 @@ export function createHistory(state: RuntimeState): History {
const prepared = prepareHistoryChange(direction);
if (!prepared.ok) return prepared;

const previousTree = state.materialized.tree;
assignCausalState(state, {
known: prepared.value.known,
graph: prepared.value.graph,
Expand All @@ -221,9 +222,11 @@ export function createHistory(state: RuntimeState): History {

let documentChange = undefined;
if (prepared.value.didChangeDocument) {
const documentCommit = state.documentStore.commit(patchBetweenValues(
const documentCommit = state.documentStore.commit(patchBetweenTrees(
state.documentStore.value,
state.materialized.value,
previousTree,
state.materialized.tree,
));
if (!documentCommit.ok) {
throw new Error(
Expand Down
Loading