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
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ All notable changes to the active v2 packages are documented here. The complete

## Unreleased

- Made the collaborative text profile include actor-local selective history,
and made capture-time text commits return the canonical owned
`JSONAppliedChange` with optional commit metadata for editor integration.
- Declared the `2.0.0` Projection Profile Stable after the same black-box suite
passed the public reference binding and a reference-free independent
implementation across form, table/data-grid, outliner/tree, rich text, and
Expand Down
2 changes: 1 addition & 1 deletion docs/generated/repo-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"status": "companion",
"private": false,
"publishable": true,
"version": "0.1.0-rc.1",
"version": "0.1.0-rc.2",
"description": "Transport-free causal collaboration provider for @interactive-os/json-document.",
"license": "MIT",
"summary": "Transport-free causal collaboration provider for the six-member\n`@interactive-os/json-document` Projection contract.",
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

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

12 changes: 11 additions & 1 deletion packages/json-document-collaboration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
} from "@interactive-os/json-document-collaboration/text";

const runtime = createCollaborationTextRuntime(initial, options);
runtime.history.undo();

// Ordinary editor code keeps using the same document API. String-to-string
// replace operations compile to collaborative text splices in this profile.
Expand All @@ -71,7 +72,11 @@ if (captured.ok) {
value: finalDOMText,
selection: { anchor: 6, focus: 6 },
});
if (planned.ok) runtime.text.commit(planned.plan);
if (planned.ok) {
runtime.text.commit(planned.plan, {
metadata: { editor: { transactionId } },
});
}
}
```

Expand All @@ -81,6 +86,11 @@ concurrent and merges by stable text atoms. Any graph change after `plan`
returns `stale_text_plan`; adapters recover by rendering the latest model
instead of silently rebasing an already observed DOM result.

The text profile includes the same actor-local selective `history` sidecar as
the history profile. A successful text commit returns the canonical
`JSONAppliedChange`, including owned metadata, so editor transaction tracking
does not need a second publication protocol.

`runtime.collaboration.subscribe` publishes one deeply immutable snapshot when
an ingest adds causal state, even if it only changes pending/conflict metadata.
Duplicate-only delivery publishes nothing. Unsubscribe follows the Projection
Expand Down
2 changes: 1 addition & 1 deletion packages/json-document-collaboration/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@interactive-os/json-document-collaboration",
"version": "0.1.0-rc.1",
"version": "0.1.0-rc.2",
"description": "Transport-free causal collaboration provider for @interactive-os/json-document.",
"type": "module",
"license": "MIT",
Expand Down
44 changes: 36 additions & 8 deletions packages/json-document-collaboration/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export function createCollaborationTextRuntime(
return Object.freeze({
document: runtime.document,
collaboration: runtime.collaboration,
history: runtime.history,
text: runtime.text,
});
}
Expand Down Expand Up @@ -200,6 +201,7 @@ export function createRestoredTextRuntime(
return Object.freeze({
document: runtime.document,
collaboration: runtime.collaboration,
history: runtime.history,
text: runtime.text,
});
}
Expand Down Expand Up @@ -781,7 +783,10 @@ function createRuntime(
}));
return Object.freeze({ ok: true, plan });
},
commit(plan: CollaborationTextPlan): CollaborationTextCommitResult {
commit(
plan: CollaborationTextPlan,
commitOptions?: JSONDocumentCommitOptions,
): CollaborationTextCommitResult {
if (evaluatingAcceptance) {
return textFailure(
"acceptance_reentrancy",
Expand Down Expand Up @@ -818,6 +823,13 @@ function createRuntime(
if (!current.ok) {
return textFailure(current.code, current.reason);
}
const metadataProbe = projection.commit([], commitOptions);
if (!metadataProbe.ok) {
return textFailure(
metadataProbe.code,
metadataProbe.reason ?? metadataProbe.code,
);
}
if (planned.operation === null) {
const textState = materialized.tree.texts.get(
planned.capture.capture.textNode,
Expand All @@ -827,6 +839,7 @@ function createRuntime(
: projectText(textState);
return Object.freeze({
ok: true,
change: metadataProbe.change,
changeId: null,
projectionChanged: false,
value,
Expand Down Expand Up @@ -885,7 +898,11 @@ function createRuntime(
op: "replace",
path: "",
value: materialized.value,
}]);
}], {
...(metadataProbe.change.metadata === undefined
? {}
: { metadata: metadataProbe.change.metadata }),
});
if (!publication.ok) {
throw new Error(
`text projection publication failed: ${publication.reason ?? publication.code}`,
Expand All @@ -906,6 +923,7 @@ function createRuntime(
}
return Object.freeze({
ok: true,
change: documentChange ?? metadataProbe.change,
changeId: freezeChangeId(changeId),
projectionChanged,
value: projectText(textState),
Expand All @@ -924,6 +942,7 @@ function createRuntime(

function resolveHistoryState(): ResolvedHistoryState {
let undoTarget: ChangeId | null = null;
let undoDepth = 0;
let latestOwnDataIndex = -1;
for (let index = graph.ordered.length - 1; index >= 0; index -= 1) {
const change = graph.ordered[index];
Expand All @@ -938,13 +957,14 @@ function createRuntime(
materialized.history.appliedKeys.has(key)
&& !materialized.history.disabledByTarget.has(key)
) {
undoTarget = freezeChangeId(change.changeId);
break;
undoDepth += 1;
undoTarget ??= freezeChangeId(change.changeId);
}
}
}

let redoTarget: ChangeId | null = null;
let redoDepth = 0;
let effectiveUndo: ChangeId | null = null;
for (let index = graph.ordered.length - 1; index >= 0; index -= 1) {
if (index <= latestOwnDataIndex) break;
Expand All @@ -966,14 +986,22 @@ function createRuntime(
activeUndo !== undefined
&& changeIdKey(activeUndo) === changeIdKey(change.changeId)
) {
redoTarget = freezeChangeId(operation.target);
effectiveUndo = freezeChangeId(change.changeId);
break;
redoDepth += 1;
if (redoTarget === null) {
redoTarget = freezeChangeId(operation.target);
effectiveUndo = freezeChangeId(change.changeId);
}
}
}

return {
snapshot: Object.freeze({ undoTarget, redoTarget }),
snapshot: Object.freeze({
undoTarget,
redoTarget,
undoDepth,
redoDepth,
revision: graphRevision,
}),
effectiveUndo,
};
}
Expand Down
3 changes: 3 additions & 0 deletions packages/json-document-collaboration/src/text-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ export * from "./index.js";
export { createCollaborationTextRuntime } from "./create.js";
export { restoreCollaborationTextRuntime } from "./restore.js";
export type {
CollaborationHistoryControl,
CollaborationHistoryResult,
CollaborationHistorySnapshot,
CollaborationTextCapture,
CollaborationTextCaptureResult,
CollaborationTextCommitResult,
Expand Down
13 changes: 11 additions & 2 deletions packages/json-document-collaboration/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
JSONAppliedChange,
JSONCapabilityResult,
JSONDocument,
JSONDocumentCommitOptions,
JSONValue,
} from "@interactive-os/json-document";

Expand Down Expand Up @@ -224,6 +226,9 @@ export interface CollaborationControl {
export interface CollaborationHistorySnapshot {
readonly undoTarget: ChangeId | null;
readonly redoTarget: ChangeId | null;
readonly undoDepth: number;
readonly redoDepth: number;
readonly revision: number;
}

export type CollaborationHistoryResult =
Expand Down Expand Up @@ -304,6 +309,7 @@ export type CollaborationTextPlanResult =
export type CollaborationTextCommitResult =
| {
readonly ok: true;
readonly change: JSONAppliedChange;
readonly changeId: ChangeId | null;
readonly projectionChanged: boolean;
readonly value: string;
Expand All @@ -321,10 +327,13 @@ export interface CollaborationTextControl {
capture: CollaborationTextCapture,
observation: CollaborationTextObservation,
): CollaborationTextPlanResult;
commit(plan: CollaborationTextPlan): CollaborationTextCommitResult;
commit(
plan: CollaborationTextPlan,
options?: JSONDocumentCommitOptions,
): CollaborationTextCommitResult;
}

export interface CollaborationTextRuntime extends CollaborationRuntime {
export interface CollaborationTextRuntime extends CollaborationHistoryRuntime {
readonly text: CollaborationTextControl;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/json-document-collaboration/tests/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ describe("@interactive-os/json-document-collaboration/history", () => {
expect(author.history.current()).toEqual({
undoTarget: { actorId: "actor-a", counter: 1 },
redoTarget: null,
undoDepth: 1,
redoDepth: 0,
revision: 4,
});
});

Expand Down Expand Up @@ -482,6 +485,9 @@ describe("@interactive-os/json-document-collaboration/history", () => {
expect(shared.history.current()).toEqual({
undoTarget: null,
redoTarget: { actorId: "actor-a", counter: 1 },
undoDepth: 0,
redoDepth: 2,
revision: 5,
});
});
});
49 changes: 49 additions & 0 deletions packages/json-document-collaboration/tests/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("@interactive-os/json-document-collaboration/text", () => {
expect(Object.keys(shared).sort()).toEqual([
"collaboration",
"document",
"history",
"text",
]);
expect(Object.keys(shared.document).sort()).toEqual([
Expand Down Expand Up @@ -263,6 +264,7 @@ describe("@interactive-os/json-document-collaboration/text", () => {
local.collaboration.subscribe(listener);
expect(local.text.commit(planned.plan)).toEqual({
ok: true,
change: { applied: [] },
changeId: null,
projectionChanged: false,
value: "aXb",
Expand All @@ -271,6 +273,53 @@ describe("@interactive-os/json-document-collaboration/text", () => {
expect(listener).not.toHaveBeenCalled();
});

test("publishes owned editor metadata and fails before causal mutation when metadata is invalid", () => {
const shared = textRuntime("actor-a");
const captured = shared.text.capture("/title");
if (!captured.ok) throw new Error(captured.reason);
const planned = shared.text.plan(captured.capture, {
value: "aXb",
selection: { anchor: 2, focus: 2 },
});
if (!planned.ok) throw new Error(planned.reason);

const metadata = {
editor: {
transactionId: "editor-1",
},
};
const listener = vi.fn();
shared.document.subscribe(listener);
const committed = shared.text.commit(planned.plan, { metadata });
expect(committed).toMatchObject({
ok: true,
change: {
metadata,
},
});
if (!committed.ok) throw new Error(committed.reason);
expect(Object.isFrozen(committed.change.metadata)).toBe(true);
expect(listener).toHaveBeenCalledWith(committed.change);

const nextCapture = shared.text.capture("/title");
if (!nextCapture.ok) throw new Error(nextCapture.reason);
const nextPlan = shared.text.plan(nextCapture.capture, {
value: "aXYb",
});
if (!nextPlan.ok) throw new Error(nextPlan.reason);
const before = shared.collaboration.exportBundle();
const circular: { self?: unknown } = {};
circular.self = circular;
expect(shared.text.commit(nextPlan.plan, {
metadata: circular as never,
})).toMatchObject({
ok: false,
code: "not_serializable",
});
expect(shared.collaboration.exportBundle()).toEqual(before);
expect(shared.document.value).toEqual({ title: "aXb" });
});

test("restores text authoring and selective history from the same checkpoint", () => {
const local = textRuntime("actor-a");
const remote = textRuntime("actor-b");
Expand Down