diff --git a/docs/changelog.md b/docs/changelog.md index da6d5f09..50c13010 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 1f43539f..04c32993 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -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.", diff --git a/package-lock.json b/package-lock.json index 98712f8f..b988e3cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5055,7 +5055,7 @@ }, "packages/json-document-collaboration": { "name": "@interactive-os/json-document-collaboration", - "version": "0.1.0-rc.1", + "version": "0.1.0-rc.2", "license": "MIT", "devDependencies": { "@interactive-os/json-document": "*", diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index dbe572a5..4a1d8839 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -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. @@ -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 } }, + }); + } } ``` @@ -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 diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index f13b397c..fbe92a29 100644 --- a/packages/json-document-collaboration/package.json +++ b/packages/json-document-collaboration/package.json @@ -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", diff --git a/packages/json-document-collaboration/src/create.ts b/packages/json-document-collaboration/src/create.ts index 4361efff..b216539f 100644 --- a/packages/json-document-collaboration/src/create.ts +++ b/packages/json-document-collaboration/src/create.ts @@ -169,6 +169,7 @@ export function createCollaborationTextRuntime( return Object.freeze({ document: runtime.document, collaboration: runtime.collaboration, + history: runtime.history, text: runtime.text, }); } @@ -200,6 +201,7 @@ export function createRestoredTextRuntime( return Object.freeze({ document: runtime.document, collaboration: runtime.collaboration, + history: runtime.history, text: runtime.text, }); } @@ -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", @@ -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, @@ -827,6 +839,7 @@ function createRuntime( : projectText(textState); return Object.freeze({ ok: true, + change: metadataProbe.change, changeId: null, projectionChanged: false, value, @@ -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}`, @@ -906,6 +923,7 @@ function createRuntime( } return Object.freeze({ ok: true, + change: documentChange ?? metadataProbe.change, changeId: freezeChangeId(changeId), projectionChanged, value: projectText(textState), @@ -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]; @@ -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; @@ -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, }; } diff --git a/packages/json-document-collaboration/src/text-index.ts b/packages/json-document-collaboration/src/text-index.ts index 8f4ba970..fe2339cc 100644 --- a/packages/json-document-collaboration/src/text-index.ts +++ b/packages/json-document-collaboration/src/text-index.ts @@ -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, diff --git a/packages/json-document-collaboration/src/types.ts b/packages/json-document-collaboration/src/types.ts index d4f61397..6183466e 100644 --- a/packages/json-document-collaboration/src/types.ts +++ b/packages/json-document-collaboration/src/types.ts @@ -1,6 +1,8 @@ import type { + JSONAppliedChange, JSONCapabilityResult, JSONDocument, + JSONDocumentCommitOptions, JSONValue, } from "@interactive-os/json-document"; @@ -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 = @@ -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; @@ -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; } diff --git a/packages/json-document-collaboration/tests/history.test.ts b/packages/json-document-collaboration/tests/history.test.ts index 10d2c01e..c77704a3 100644 --- a/packages/json-document-collaboration/tests/history.test.ts +++ b/packages/json-document-collaboration/tests/history.test.ts @@ -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, }); }); @@ -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, }); }); }); diff --git a/packages/json-document-collaboration/tests/text.test.ts b/packages/json-document-collaboration/tests/text.test.ts index f2827cde..5f1f1fd9 100644 --- a/packages/json-document-collaboration/tests/text.test.ts +++ b/packages/json-document-collaboration/tests/text.test.ts @@ -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([ @@ -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", @@ -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");