From 934a71701421d62a7159077c73443f08e5173058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 7 Sep 2026 22:38:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Editing=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=9D=98=20=EA=B4=80=EC=B0=B0=EA=B3=BC=20History=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EB=B3=B4=EC=99=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #719 --- docs/api-reference/collaboration.md | 79 ++++++++ docs/api-reference/editing.md | 9 +- docs/api-reference/packages.mjs | 3 + docs/public/collaboration-history.md | 18 ++ docs/public/history.md | 11 ++ .../json-document-collaboration/README.md | 8 + .../src/editing-index.ts | 26 ++- .../src/history-runtime.ts | 20 +- .../json-document-collaboration/src/types.ts | 7 + .../tests/editing-lifecycle.test.ts | 171 ++++++++++++++++++ packages/json-document-editing/README.md | 28 +++ packages/json-document-editing/src/history.ts | 11 +- packages/json-document-editing/src/session.ts | 116 +++++++----- .../tests/session-lifecycle.test.ts | 110 +++++++++++ .../tests/react-connector.test.tsx | 14 ++ scripts/generate-api-reference.mjs | 4 +- .../unit/public-document-contract.test.ts | 14 ++ 17 files changed, 588 insertions(+), 61 deletions(-) create mode 100644 packages/json-document-collaboration/tests/editing-lifecycle.test.ts create mode 100644 packages/json-document-editing/tests/session-lifecycle.test.ts diff --git a/docs/api-reference/collaboration.md b/docs/api-reference/collaboration.md index f11909fe3..42a5ce0c6 100644 --- a/docs/api-reference/collaboration.md +++ b/docs/api-reference/collaboration.md @@ -425,6 +425,85 @@ interface TextSpliceOperation { readonly inserted: string; } ``` +## `@interactive-os/json-document-collaboration/history` + +아래 API는 package root가 아닌 이 subpath에서 import합니다. +### `createHistoryRuntime` + +```ts +createHistoryRuntime(initial: unknown, options: CollaborationRuntimeOptions): HistoryRuntime +``` +### `History` + +```ts +interface History { + status(): HistoryStatus; + canUndo(): JSONPatchValidationResult; + undo(): HistoryResult; + canRedo(): JSONPatchValidationResult; + redo(): HistoryResult; +} +``` +### `HistoryRestoreResult` + +```ts +type HistoryRestoreResult = + | { + readonly ok: true; + readonly runtime: HistoryRuntime; + } + | { + readonly ok: false; + readonly code: string; + readonly reason: string; + }; +``` +### `HistoryResult` + +```ts +type HistoryResult = + | { + readonly ok: true; + readonly changeId: ChangeId; + readonly target: ChangeId; + readonly didChangeDocument: boolean; + /** This operation's applied change; null when it only changes causal history. */ + readonly change: JSONAppliedChange | null; + /** Captured before subscribers can author a later transition. */ + readonly status: HistoryStatus & { + readonly canUndo: boolean; + readonly canRedo: boolean; + }; + } + | { + readonly ok: false; + readonly code: string; + readonly reason?: string; + }; +``` +### `HistoryRuntime` + +```ts +interface HistoryRuntime extends CollaborationRuntime { + readonly history: History; +} +``` +### `HistoryStatus` + +```ts +interface HistoryStatus { + readonly undoTarget: ChangeId | null; + readonly redoTarget: ChangeId | null; + readonly undoDepth: number; + readonly redoDepth: number; + readonly revision: number; +} +``` +### `restoreHistoryRuntime` + +```ts +restoreHistoryRuntime(input: unknown, options: CollaborationRestoreOptions): HistoryRestoreResult +``` ## `@interactive-os/json-document-collaboration/editing` 아래 API는 package root가 아닌 이 subpath에서 import합니다. diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index d3d7fb457..711a3cfb5 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -940,7 +940,14 @@ interface EditingHistoryOptions { ```ts type EditingHistoryResult = - | { readonly ok: true; readonly target: string } + | { + readonly ok: true; + readonly target: string; + /** This operation's applied change; null for a history-only transition. */ + readonly change: JSONAppliedChange | null; + /** This operation's status, captured before notifying subscribers. */ + readonly status: EditingHistoryStatus; + } | { readonly ok: false; readonly code: string; readonly reason?: string }; ``` ## `EditingHistoryStatus` diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index 303096a8e..0e02331d9 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -31,6 +31,9 @@ export const apiReferencePackages = [ ].map(([slug, packageName, entrypoint, owner, responsibility]) => ({ slug, packageName, entrypoint, owner, responsibility, subpaths: slug === "collaboration" ? [{ + packageName: "@interactive-os/json-document-collaboration/history", + entrypoint: "packages/json-document-collaboration/src/history-index.ts", + }, { packageName: "@interactive-os/json-document-collaboration/editing", entrypoint: "packages/json-document-collaboration/src/editing-index.ts", }] : [], diff --git a/docs/public/collaboration-history.md b/docs/public/collaboration-history.md index c49625743..8062b7ba2 100644 --- a/docs/public/collaboration-history.md +++ b/docs/public/collaboration-history.md @@ -49,6 +49,24 @@ Editor는 자신이 기록한 target의 Selection을 복원하되 현재 문서 Editor가 생성되기 전에 작성된 target은 알 수 없는 과거 Selection을 만들지 않고 현재 Selection을 reconcile합니다. Selection은 collaboration wire에 들어가지 않습니다. +`runtime.history.undo/redo`의 성공 결과는 자기 작업의 `change`와 `status`를 +담습니다. `change`는 실제 `JSONAppliedChange`이며, 문서 값이 그대로면 `null`입니다. +`status`는 구독자 통지 전의 undo/redo target·revision·`canUndo`·`canRedo`입니다. +구독자가 별도 편집을 실행해도 그 변경을 원래 undo/redo 결과에 섞지 않습니다. +공식 연결 API가 이 정보를 Editing에 전달하므로 Host는 알림 순서로 작업을 +구분할 필요가 없습니다. + +직접 `EditingHistory`를 구현한다면 성공 결과에 `target`, `change`, `status`를 +모두 반환합니다. `change`는 해당 작업 직전 문서에 적용할 수 있는 자기 변경이고, +`status`는 해당 작업 직후의 `EditingHistoryStatus`입니다. 이전의 target-only +구현은 이 두 필드를 추가해야 합니다. + +선택 mapping/reconciliation 콜백이 예외를 던지면 일관된 snapshot이 준비될 때까지 +세션은 새 편집이나 undo/redo를 실행하지 않습니다. 이미 성공한 협업 undo/redo는 +취소되지 않으며, 다음 읽기는 보관한 작업 결과의 선택 복원만 재시도합니다. +콜백을 수정하거나 editor를 다시 구성한 뒤 진행합니다. 콜백 예외를 history 작업의 +거절로 해석해 같은 작업을 다시 실행하지 않습니다. + 협업 undo 단위는 인과 commit 하나입니다. Local `historyGroup`은 이 단위를 합치지 않으며, external history에서 `history: "ignore"` plan은 변경 전에 거절됩니다. 기본 local 사용에서는 기존 grouping을 유지합니다. diff --git a/docs/public/history.md b/docs/public/history.md index 2d8c677f7..b4540fd52 100644 --- a/docs/public/history.md +++ b/docs/public/history.md @@ -33,6 +33,17 @@ History 항목은 JSON 값이 실제로 바뀐 편집에서 생깁니다. Select 보존하며 내 기여만 취소하려면 [Collaborative History](collaboration-history.md)의 공식 연결 API를 사용합니다. document만 바꾸는 것으로 history 의미까지 바뀌지는 않습니다. +`createEditingSession`의 선택 mapping/reconciliation 콜백은 외부 변경에 맞는 +선택을 계산한 뒤 값·선택·history 상태·revision을 함께 확정합니다. 콜백이 +예외를 던지면 이전의 일관된 상태를 보관하고, 다음 읽기나 명령에서 동기화를 +재시도합니다. 실패가 지속되는 동안에는 오래된 undo를 현재 문서에 적용하지 +않습니다. 동기화에 성공하면 local history를 비우고 새 snapshot을 알립니다. +외부 document commit 자체는 이미 완료됐으므로 콜백 오류로 되돌아가지 않습니다. + +snapshot 읽기에서 외부 변경을 따라잡아도 그 revision의 알림은 전달됩니다. +다른 구독자가 먼저 읽었다는 이유로 알림이 누락되지 않습니다. 구독 해제 함수는 +여러 번 호출해도 같은 콜백으로 새로 만든 구독을 해제하지 않습니다. + 여기까지 `editor.dispatch`로 시작한 요청이 Selection과 Topology를 읽고, Clipboard를 거쳐 문서와 History를 바꾸는 흐름을 살펴봤습니다. editor가 받는 전체 요청은 [Intent 레퍼런스](intent.md)에서 확인할 수 있습니다. diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index 4728857cd..f77473653 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -34,6 +34,14 @@ History remains local unless this connection is explicitly configured. See [Collaborative History](../../docs/public/collaboration-history.md) and the owner [API reference](../../docs/api-reference/collaboration.md). +Successful `runtime.history.undo/redo` results include their own `change` +(`JSONAppliedChange | null`) and immutable resulting `status`, including +`canUndo` and `canRedo`. Both are captured before notifying document or replica +subscribers. A history-only operation returns `change: null`, even when a +subscriber authors another document change before the call returns. The Editing +connection forwards that result instead of treating the first observed change +as the history operation. These fields do not enter bundles or checkpoints. + 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 diff --git a/packages/json-document-collaboration/src/editing-index.ts b/packages/json-document-collaboration/src/editing-index.ts index e3305bd30..0e06d5a5f 100644 --- a/packages/json-document-collaboration/src/editing-index.ts +++ b/packages/json-document-collaboration/src/editing-index.ts @@ -1,28 +1,36 @@ -import type { EditingHistory } from "@interactive-os/json-document-editing"; +import type { EditingHistory, EditingHistoryStatus } from "@interactive-os/json-document-editing"; import { changeIdKey } from "./change.js"; -import type { HistoryRuntime } from "./types.js"; +import type { HistoryRuntime, HistoryStatus } from "./types.js"; /** Bind Editing to this runtime's selective history, one causal commit per step. */ export function createCollaborationEditingHistory(runtime: HistoryRuntime): EditingHistory { return { status() { const status = runtime.history.status(); - return { - undoTarget: status.undoTarget === null ? null : changeIdKey(status.undoTarget), - redoTarget: status.redoTarget === null ? null : changeIdKey(status.redoTarget), + return editingStatus({ + ...status, canUndo: runtime.history.canUndo().ok, canRedo: runtime.history.canRedo().ok, - revision: status.revision, - }; + }); }, undo() { const result = runtime.history.undo(); - return result.ok ? { ok: true, target: changeIdKey(result.target) } : result; + return result.ok ? { ok: true, target: changeIdKey(result.target), change: result.change, status: editingStatus(result.status) } : result; }, redo() { const result = runtime.history.redo(); - return result.ok ? { ok: true, target: changeIdKey(result.target) } : result; + return result.ok ? { ok: true, target: changeIdKey(result.target), change: result.change, status: editingStatus(result.status) } : result; }, subscribe: (listener) => runtime.replica.subscribe(listener), }; } + +function editingStatus(status: HistoryStatus & { readonly canUndo: boolean; readonly canRedo: boolean }): EditingHistoryStatus { + return Object.freeze({ + undoTarget: status.undoTarget === null ? null : changeIdKey(status.undoTarget), + redoTarget: status.redoTarget === null ? null : changeIdKey(status.redoTarget), + canUndo: status.canUndo, + canRedo: status.canRedo, + revision: status.revision, + }); +} diff --git a/packages/json-document-collaboration/src/history-runtime.ts b/packages/json-document-collaboration/src/history-runtime.ts index f31dcc279..eadb570ba 100644 --- a/packages/json-document-collaboration/src/history-runtime.ts +++ b/packages/json-document-collaboration/src/history-runtime.ts @@ -235,17 +235,23 @@ export function createHistory(state: RuntimeState): History { } documentChange = documentCommit.change; } - state.notify({ - ...(documentChange === undefined ? {} : { documentChange }), - replicaStatus: state.replicaStatus(), - }); - - return Object.freeze({ - ok: true, + const result = Object.freeze({ + ok: true as const, changeId: freezeChangeId(prepared.value.change.changeId), target: freezeChangeId(prepared.value.target), didChangeDocument: prepared.value.didChangeDocument, + change: documentChange ?? null, + status: Object.freeze({ + ...resolveHistoryState().status, + canUndo: prepareHistoryChange("undo").ok, + canRedo: prepareHistoryChange("redo").ok, + }), + }); + state.notify({ + ...(documentChange === undefined ? {} : { documentChange }), + replicaStatus: state.replicaStatus(), }); + return result; } return Object.freeze({ diff --git a/packages/json-document-collaboration/src/types.ts b/packages/json-document-collaboration/src/types.ts index 656ef9ab3..23d8e5c15 100644 --- a/packages/json-document-collaboration/src/types.ts +++ b/packages/json-document-collaboration/src/types.ts @@ -237,6 +237,13 @@ export type HistoryResult = readonly changeId: ChangeId; readonly target: ChangeId; readonly didChangeDocument: boolean; + /** This operation's applied change; null when it only changes causal history. */ + readonly change: JSONAppliedChange | null; + /** Captured before subscribers can author a later transition. */ + readonly status: HistoryStatus & { + readonly canUndo: boolean; + readonly canRedo: boolean; + }; } | { readonly ok: false; diff --git a/packages/json-document-collaboration/tests/editing-lifecycle.test.ts b/packages/json-document-collaboration/tests/editing-lifecycle.test.ts new file mode 100644 index 000000000..53e278a00 --- /dev/null +++ b/packages/json-document-collaboration/tests/editing-lifecycle.test.ts @@ -0,0 +1,171 @@ +import { type JSONValue } from "@interactive-os/json-document"; +import { createEditingSession, type EditingSnapshot } from "@interactive-os/json-document-editing"; +import { describe, expect, test } from "vitest"; +import { createHistoryRuntime } from "../src/history-index.js"; +import { createCollaborationEditingHistory } from "../src/editing-index.js"; + +const initial = { n: 0, other: 0 }; +function runtime(actorId = "local") { + return createHistoryRuntime(initial, { actorId, epochId: "editing-lifecycle", ruleset: { id: "review", digest: "1" } }); +} + +test("an earlier snapshot reader cannot swallow document or causal-only history notifications", () => { + const local = runtime(); + const remote = runtime("remote"); + const session = createEditingSession({ document: local.document, selection: null, history: createCollaborationEditingHistory(local) }); + local.document.subscribe(() => { void session.snapshot; }); + local.replica.subscribe(() => { void session.snapshot; }); + const seen: number[] = []; + session.subscribe((snapshot) => seen.push(snapshot.revision)); + local.document.commit([{ op: "replace", path: "/n", value: 1 }]); + remote.replica.ingest(local.replica.exportBundle()); + remote.document.commit([{ op: "replace", path: "/n", value: 2 }]); + local.replica.ingest(remote.replica.exportBundle()); + expect(local.history.undo()).toMatchObject({ ok: true, didChangeDocument: false }); + expect(seen).toEqual([1, 2, 3]); +}); + +test("history results retain their own immutable change and status before subscriber writes", () => { + const local = runtime(); + local.document.commit([{ op: "replace", path: "/n", value: 1 }]); + let written = false; + local.replica.subscribe(() => { + if (written) return; + written = true; + local.document.commit([{ op: "replace", path: "/other", value: 99 }]); + }); + const undone = local.history.undo(); + expect(undone).toMatchObject({ + ok: true, didChangeDocument: true, + change: { applied: [{ op: "replace", path: "/n", value: 0 }] }, + status: { revision: 2, undoTarget: null, redoTarget: { actorId: "local", counter: 1 }, canUndo: false, canRedo: true }, + }); + if (!undone.ok) throw new Error(undone.code); + expect(Object.isFrozen(undone)).toBe(true); + expect(Object.isFrozen(undone.change?.applied)).toBe(true); + expect(Object.isFrozen(undone.status)).toBe(true); + expect(Object.isFrozen(undone.status.redoTarget)).toBe(true); + expect(local.history.status()).toMatchObject({ revision: 3, redoTarget: null }); +}); + +test("releases both connections across resubscription and editor recreation", () => { + const local = runtime(); + const owner = createCollaborationEditingHistory(local); + let documentConnections = 0; + let historyConnections = 0; + const document = { + ...local.document, + get value() { return local.document.value; }, + subscribe(listener: Parameters[0]) { + documentConnections++; + const release = local.document.subscribe(listener); + return () => { documentConnections--; release(); }; + }, + }; + const history = { + ...owner, + subscribe(listener: () => void) { + historyConnections++; + const release = owner.subscribe(listener); + return () => { historyConnections--; release(); }; + }, + }; + const session = createEditingSession({ document, history, selection: 0 }); + const seen: number[] = []; + const listener = (snapshot: EditingSnapshot) => seen.push(snapshot.revision); + const oldRelease = session.subscribe(listener); + const secondRelease = session.subscribe(() => {}); + oldRelease(); + expect([documentConnections, historyConnections]).toEqual([1, 1]); + secondRelease(); + expect([documentConnections, historyConnections]).toEqual([0, 0]); + const newRelease = session.subscribe(listener); + oldRelease(); + local.document.commit([{ op: "replace", path: "/n", value: 1 }]); + expect(seen).toEqual([1]); + newRelease(); + newRelease(); + expect([documentConnections, historyConnections]).toEqual([0, 0]); + const recreated = createEditingSession({ document, history, selection: 7 }); + expect(recreated.undo()).toMatchObject({ ok: true, snapshot: { selection: 7, value: initial } }); + expect([documentConnections, historyConnections]).toEqual([0, 0]); + expect(session.snapshot.value).toEqual(initial); + expect(seen).toEqual([1]); +}); + +describe.each([true, false])("external history (observed: %s)", (observed) => { + describe.each(["undo", "redo"] as const)("%s attribution", (direction) => { + test.each([true, false])("separates a replica subscriber write (history-only: %s)", (historyOnly) => { + const local = runtime(); + const session = createEditingSession({ document: local.document, selection: 0, history: createCollaborationEditingHistory(local) }); + const seen: EditingSnapshot[] = []; + if (observed) session.subscribe((snapshot) => seen.push(snapshot)); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: 1, origin: "local" }); + if (historyOnly) { + const remote = runtime("remote"); + remote.replica.ingest(local.replica.exportBundle()); + remote.document.commit([{ op: "replace", path: "/n", value: 2 }]); + local.replica.ingest(remote.replica.exportBundle()); + } + if (direction === "redo") expect(session.undo().ok).toBe(true); + const before = session.snapshot; + seen.length = 0; + let written = false; + local.replica.subscribe(() => { + if (written) return; + written = true; + local.document.commit([{ op: "replace", path: "/other", value: 99 }]); + }); + const result = session[direction](); + const ownValue = { n: historyOnly ? 2 : direction === "undo" ? 0 : 1, other: 0 }; + expect(result).toMatchObject({ ok: true, snapshot: { + value: ownValue, selection: direction === "undo" ? 0 : 1, + revision: before.revision + 1, canUndo: direction === "redo", canRedo: direction === "undo", + } }); + if (!result.ok) throw new Error(result.code); + if (historyOnly) expect(result.change).toBeUndefined(); + else expect(result.change?.applied).toEqual([{ op: "replace", path: "/n", value: ownValue.n }]); + expect(session.snapshot).toMatchObject({ value: { ...ownValue, other: 99 }, revision: before.revision + 2, canUndo: true, canRedo: false }); + if (observed) expect(seen.map((snapshot) => snapshot.value)).toEqual([ownValue, { ...ownValue, other: 99 }]); + }); + }); + + test.each([ + { direction: "undo", historyOnly: true }, { direction: "undo", historyOnly: false }, + { direction: "redo", historyOnly: true }, { direction: "redo", historyOnly: false }, + ] as const)("recovers selection after committed $direction without repeating history (history-only: $historyOnly)", ({ direction, historyOnly }) => { + const local = runtime(); + let rejects = false; + const session = createEditingSession({ + document: local.document, selection: 0, history: createCollaborationEditingHistory(local), + reconcileSelection(selection) { + if (rejects) throw new Error("selection failed"); + return selection; + }, + }); + const seen: JSONValue[] = []; + if (observed) session.subscribe((snapshot) => seen.push(snapshot.selection)); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: 1, origin: "local" }); + if (historyOnly) { + const remote = runtime("remote"); + remote.replica.ingest(local.replica.exportBundle()); + remote.document.commit([{ op: "replace", path: "/n", value: 2 }]); + local.replica.ingest(remote.replica.exportBundle()); + } + if (direction === "redo") expect(session.undo().ok).toBe(true); + const before = session.snapshot; + rejects = true; + expect(() => session[direction]()).toThrow("selection failed"); + const committedHistory = local.history.status(); + expect(direction === "undo" ? committedHistory.redoTarget : committedHistory.undoTarget).not.toBeNull(); + expect(() => session.snapshot).toThrow("selection failed"); + expect(() => session.undo()).toThrow("selection failed"); + expect(() => session.redo()).toThrow("selection failed"); + expect(local.history.status()).toEqual(committedHistory); + rejects = false; + const restoredSelection = direction === "undo" ? 0 : 1; + expect(session.snapshot).toMatchObject({ selection: restoredSelection, revision: before.revision + 1, canUndo: direction === "redo", canRedo: direction === "undo" }); + if (observed) expect(seen.at(-1)).toBe(restoredSelection); + expect(session[direction === "undo" ? "redo" : "undo"]()).toMatchObject({ ok: true, snapshot: { selection: 1 - restoredSelection } }); + }); +}); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 30515ab07..00b4a0400 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -10,6 +10,9 @@ Snapshots and their selections are immutable owned values. Subscriber failures do not reject completed edits; reentrant notifications are delivered in revision order. A returned result describes its own transition, even if a subscriber has already performed another transition. +Every synchronized revision is published, including synchronization started by +a snapshot read or another command. A reader's subscription order cannot consume +the notification intended for another observer. Domains can provide `mapSelection(selection, { before, after, change })` and `reconcileSelection(selection, value)` to `createEditingSession`. Both are pure @@ -21,6 +24,15 @@ All nine built-in editors reconcile external deletion using their own selection families; Calendar retains valid off-screen occurrences. Rich Text maps stable text IDs through external text replacement, including affinity and scalar boundaries. +Mapping and reconciliation must complete before the session advances its value, +selection, history status or revision. A thrown callback error leaves the last +coherent state retained; reads and commands retry synchronization and throw again +until it succeeds, before authoring another mutation. Successful recovery +publishes the coherent snapshot and invalidates local inverse history. Document +commits remain committed even if their observer-triggered reconciliation fails. +An edit whose own transition completed still returns its own success when a later +subscriber's document write cannot be reconciled; the next read surfaces that error. + External changes clear **local inverse history**, not an optional external `EditingHistory` owner. Every domain editor accepts `{ history }`; Rich Text accepts it in `RichTextEditorOptions`. The official Collaboration connection is @@ -39,6 +51,20 @@ current document on undo/redo. Selections are not added to the collaboration wir Unknown targets (for example, changes made before this editor existed) reconcile the current selection instead of inventing historical selection. +An `EditingHistory` implementation returns `{ ok: true, target, change, status }`. +`change` is the operation's own `JSONAppliedChange`, or explicitly `null` when +only history changes. `status` is its resulting `EditingHistoryStatus` captured +before notifying subscribers, not a later live status. Editing does not infer +operation ownership from notification order. Custom implementations of the earlier +target-only result must supply both fields; the official connection supplies them. + +If selection restoration throws after a successful external undo/redo, that +history operation has already committed. The session retains its result and +selection reference, and retries only selection restoration on the next read. +Further commands cannot author until restoration succeeds. Fix the callback or +recreate the editor; do not retry the history operation as if it were rejected. +Callback exceptions are programming errors, not `{ ok: false }` commit rejections. + `createEditingId(prefix)` supplies opaque UUID-based identities for Document, Order, Object, Tree, Calendar and Rich Text. IDs do not restart per editor or replica. Custom `createId` injection remains supported; its provider must ensure @@ -47,6 +73,8 @@ explicitly with `editing.id-provider-unavailable`; no weak random fallback is us The session subscribes to its document only while it has observers. The last unsubscribe releases that connection; later reads catch up with external state. +Unsubscribe is idempotent: calling an old release again cannot remove a new +subscription that reuses the same callback. `DocumentEditor` moves existing blocks with JSON Patch `move`, preserving their member identity when composed with a collaboration document. diff --git a/packages/json-document-editing/src/history.ts b/packages/json-document-editing/src/history.ts index c5db9aceb..4b597d79b 100644 --- a/packages/json-document-editing/src/history.ts +++ b/packages/json-document-editing/src/history.ts @@ -1,3 +1,5 @@ +import type { JSONAppliedChange } from "@interactive-os/json-document"; + /** Optional history owner. Its steps replace local inverse-patch history. */ export interface EditingHistory { status(): EditingHistoryStatus; @@ -16,7 +18,14 @@ export interface EditingHistoryStatus { } export type EditingHistoryResult = - | { readonly ok: true; readonly target: string } + | { + readonly ok: true; + readonly target: string; + /** This operation's applied change; null for a history-only transition. */ + readonly change: JSONAppliedChange | null; + /** This operation's status, captured before notifying subscribers. */ + readonly status: EditingHistoryStatus; + } | { readonly ok: false; readonly code: string; readonly reason?: string }; export interface EditingHistoryOptions { diff --git a/packages/json-document-editing/src/session.ts b/packages/json-document-editing/src/session.ts index 90f227edc..0531d6f37 100644 --- a/packages/json-document-editing/src/session.ts +++ b/packages/json-document-editing/src/session.ts @@ -9,7 +9,7 @@ import { } from "@interactive-os/json-document"; import type { SelectionHistoryEntry } from "@interactive-os/json-document-selection"; import { invertEditingPatch } from "./invert-patch.js"; -import type { EditingHistoryOptions, EditingHistoryResult } from "./history.js"; +import type { EditingHistoryOptions, EditingHistoryResult, EditingHistoryStatus } from "./history.js"; export interface EditingDocumentChange { readonly before: JSONValue; @@ -72,7 +72,12 @@ export function createEditingSession(options: Editi let observedValue = document.value; let unsubscribeDocument: (() => void) | null = null; let unsubscribeHistory: (() => void) | null = null; - let historyRevision = options.history?.status().revision; + let observedHistory = options.history?.status(); + let pendingHistoryRestore: { + readonly value: JSONValue; + readonly status: EditingHistoryStatus; + readonly reference: { readonly value: JSONValue; readonly selection: Selection }; + } | null = null; const historySelections = new Map(options: Editi value: observedValue, selection, revision, - canUndo: options.history?.status().canUndo ?? undoStack.length > 0, - canRedo: options.history?.status().canRedo ?? redoStack.length > 0, + canUndo: observedHistory?.canUndo ?? undoStack.length > 0, + canRedo: observedHistory?.canRedo ?? redoStack.length > 0, }); } @@ -116,32 +121,45 @@ export function createEditingSession(options: Editi return current; } - function synchronizeExternalChange(change?: JSONAppliedChange): boolean { - if (isCommitting) return false; - const nextHistoryRevision = options.history?.status().revision; - const historyChanged = nextHistoryRevision !== historyRevision; - historyRevision = nextHistoryRevision; + function mappedSelection( + reference: { readonly value: JSONValue; readonly selection: Selection }, + after: JSONValue, + change: JSONAppliedChange | null, + ): Selection { + let next = reference.selection; + if (options.mapSelection) next = ownSelection(options.mapSelection(next, { before: reference.value, after, change })); + if (options.reconcileSelection) next = ownSelection(options.reconcileSelection(next, after)); + return next; + } + + function synchronizeExternalChange(change?: JSONAppliedChange): void { + if (isCommitting) return; + if (pendingHistoryRestore) completeHistoryRestore(); + const nextHistory = options.history?.status(); + const historyChanged = nextHistory?.revision !== observedHistory?.revision; const latest = document.value; if (jsonEqual(observedValue, latest)) { - if (historyChanged) revision += 1; - return historyChanged; + if (historyChanged) { + observedHistory = nextHistory; + revision += 1; + publish(); + } + return; } const before = observedValue; + const replay = options.mapSelection && change !== undefined ? applyPatch(before, change.applied) : null; + // A failed callback leaves the entire observed transition retryable. + const nextSelection = mappedSelection({ value: before, selection }, latest, + replay?.ok && jsonEqual(replay.value, latest) ? change! : null); observedValue = latest; - if (options.mapSelection) { - const replay = change === undefined ? null : applyPatch(before, change.applied); - selection = ownSelection(options.mapSelection(selection, { - before, - after: latest, - change: replay?.ok && jsonEqual(replay.value, latest) ? change! : null, - })); - } - if (options.reconcileSelection) selection = ownSelection(options.reconcileSelection(selection, latest)); + observedHistory = nextHistory; + selection = nextSelection; undoStack = []; redoStack = []; activeHistoryGroup = undefined; revision += 1; - return true; + // Reads and commands must not consume a revision without its notification. + publish(); } function commit( @@ -167,16 +185,22 @@ export function createEditingSession(options: Editi } function observeDocument(change: JSONAppliedChange): void { - if (isCommitting) return; - if (synchronizeExternalChange(change)) publish(); + synchronizeExternalChange(change); } function publishCommit(): EditingSnapshot { const own = publish(); - if (synchronizeExternalChange()) publish(); + catchUpAfterCommit(); return own; } + function catchUpAfterCommit(): void { + try { synchronizeExternalChange(); } catch { + // A later external callback failure cannot reject this completed edit. + // The next read or command retries synchronization and surfaces the error. + } + } + function apply(plan: EditingPlan): EditingResult { if (isCommitting) return { ok: false, code: "editing.reentrancy" }; synchronizeExternalChange(); @@ -209,7 +233,7 @@ export function createEditingSession(options: Editi selection = selectionAfter; revision += 1; const historyStatus = options.history?.status(); - historyRevision = historyStatus?.revision; + observedHistory = historyStatus; if (historyStatus?.undoTarget && result.change.applied.length > 0 && jsonEqual(observedValue, document.value)) { historySelections.set(historyStatus.undoTarget, { before: { value: beforeValue, selection: beforeSelection }, @@ -256,32 +280,37 @@ export function createEditingSession(options: Editi function restoreExternal(direction: "undo" | "redo"): EditingResult { const history = options.history!; - const target = direction === "undo" ? history.status().undoTarget : history.status().redoTarget; - const retained = target === null ? undefined : historySelections.get(target); - const reference = retained?.[direction === "undo" ? "before" : "after"] ?? { value: observedValue, selection }; const before = observedValue; - const changes: JSONAppliedChange[] = []; - const release = document.subscribe((change) => { changes.push(change); }); let result: EditingHistoryResult; isCommitting = true; try { result = history[direction](); } finally { - release(); isCommitting = false; } if (!result.ok) return result; - const change = changes[0]; - const replay = changes.length > 1 && change ? applyPatch(before, change.applied) : null; - observedValue = replay?.ok ? replay.value : document.value; - historyRevision = history.status().revision; - selection = reference.selection; - if (options.mapSelection) selection = ownSelection(options.mapSelection(selection, { - before: reference.value, after: observedValue, change: null, - })); - if (options.reconcileSelection) selection = ownSelection(options.reconcileSelection(selection, observedValue)); + const replay = result.change === null ? { ok: true as const, value: before } : applyPatch(before, result.change.applied); + if (!replay.ok) throw new TypeError("EditingHistory returned a change that cannot apply to its pre-state."); + const retained = historySelections.get(result.target); + pendingHistoryRestore = { + value: replay.value, + status: result.status, + reference: retained?.[direction === "undo" ? "before" : "after"] ?? { value: before, selection }, + }; + const snapshot = completeHistoryRestore(); + catchUpAfterCommit(); + return { ok: true, snapshot, ...(result.change === null ? {} : { change: result.change }) }; + } + + function completeHistoryRestore(): EditingSnapshot { + const pending = pendingHistoryRestore!; + const nextSelection = mappedSelection(pending.reference, pending.value, null); + observedValue = pending.value; + observedHistory = pending.status; + selection = nextSelection; + pendingHistoryRestore = null; revision += 1; - return { ok: true, snapshot: publishCommit(), ...(change === undefined ? {} : { change }) }; + return publish(); } return { @@ -341,9 +370,12 @@ export function createEditingSession(options: Editi listeners.add(listener); unsubscribeDocument ??= document.subscribe(observeDocument); unsubscribeHistory ??= options.history?.subscribe(() => { - if (synchronizeExternalChange()) publish(); + synchronizeExternalChange(); }) ?? null; + let active = true; return () => { + if (!active) return; + active = false; listeners.delete(listener); if (listeners.size > 0) return; unsubscribeDocument?.(); diff --git a/packages/json-document-editing/tests/session-lifecycle.test.ts b/packages/json-document-editing/tests/session-lifecycle.test.ts new file mode 100644 index 000000000..ba887a20d --- /dev/null +++ b/packages/json-document-editing/tests/session-lifecycle.test.ts @@ -0,0 +1,110 @@ +import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; +import { createEditingSession } from "../src/session.js"; + +describe("editing observation and recovery", () => { + test.each(["before", "after"])("publishes an external change despite a snapshot reader (%s)", (order) => { + const document = createJSONDocument({ n: 0 }); + const session = createEditingSession({ document, selection: null }); + const seen: number[] = []; + const reads: JSONValue[] = []; + const read = () => { reads.push(session.snapshot.value); }; + if (order === "before") document.subscribe(read); + session.subscribe((snapshot) => seen.push(snapshot.revision)); + if (order === "after") document.subscribe(read); + document.commit([{ op: "replace", path: "/n", value: 1 }]); + expect(seen).toEqual([1]); + expect(reads).toEqual([{ n: 1 }]); + }); + + test("keeps revisions ordered when a snapshot reader triggers a reentrant edit", () => { + const document = createJSONDocument({ n: 0 }); + const session = createEditingSession({ document, selection: null }); + document.subscribe(() => { void session.snapshot; }); + const seen: number[] = []; + session.subscribe((snapshot) => { if (snapshot.revision === 1) session.select(null); }); + session.subscribe((snapshot) => seen.push(snapshot.revision)); + document.commit([{ op: "replace", path: "/n", value: 1 }]); + expect(seen).toEqual([1, 2]); + }); + + describe.each([true, false])("callback failure (observed: %s)", (observed) => { + test.each(["map", "reconcile"])("retries %s from the last coherent selection without applying stale history", (failing) => { + const document = createJSONDocument({ n: 0 }); + let rejects = true; + const inputs: number[] = []; + const session = createEditingSession({ + document, selection: 0, + mapSelection(selection, { after }) { + inputs.push(selection); + if (failing === "map" && rejects && (after as { n: number }).n === 2) throw new Error("selection failed"); + return selection + 10; + }, + reconcileSelection(selection, value) { + if (failing === "reconcile" && rejects && (value as { n: number }).n === 2) throw new Error("selection failed"); + return selection; + }, + }); + const seen: number[] = []; + if (observed) session.subscribe((snapshot) => seen.push(snapshot.revision)); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: 1, origin: "local" }); + const retained = session.snapshot; + expect(document.commit([{ op: "replace", path: "/n", value: 2 }]).ok).toBe(true); + for (const operation of [ + () => session.snapshot, + () => session.undo(), + () => session.redo(), + () => session.select(99), + () => session.reconcile(() => 99), + () => session.apply({ operations: [{ op: "replace" as const, path: "/n", value: 99 }], selectionAfter: 99, origin: "blocked" }), + ]) expect(operation).toThrow("selection failed"); + expect(document.value).toEqual({ n: 2 }); + expect(retained).toMatchObject({ value: { n: 1 }, selection: 1, revision: 1 }); + if (observed) expect(seen).toEqual([1]); + rejects = false; + expect(session.snapshot).toMatchObject({ value: { n: 2 }, selection: 11, revision: 2, canUndo: false }); + expect(inputs.every((selection) => selection === 1)).toBe(true); + expect(session.undo()).toMatchObject({ ok: false, code: "history.empty" }); + if (observed) expect(seen).toEqual([1, 2]); + }); + }); + + test("does not reject its completed edit when follow-up external selection mapping fails", () => { + const document = createJSONDocument({ n: 0 }); + let written = false; + document.subscribe(() => { + if (written) return; + written = true; + document.commit([{ op: "replace", path: "/n", value: 2 }]); + }); + const session = createEditingSession({ + document, selection: null, + reconcileSelection(selection, value) { + if ((value as { n: number }).n === 2) throw new Error("external selection failed"); + return selection; + }, + }); + expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "local" })) + .toMatchObject({ ok: true, snapshot: { value: { n: 1 }, revision: 1 } }); + expect(() => session.snapshot).toThrow("external selection failed"); + expect(() => session.undo()).toThrow("external selection failed"); + expect(document.value).toEqual({ n: 2 }); + }); + + test("an old unsubscribe cannot remove a later subscription with the same callback", () => { + const document = createJSONDocument({ n: 0 }); + const session = createEditingSession({ document, selection: null }); + const seen: number[] = []; + const listener = (snapshot: { revision: number }) => seen.push(snapshot.revision); + const oldRelease = session.subscribe(listener); + oldRelease(); + const newRelease = session.subscribe(listener); + oldRelease(); + document.commit([{ op: "replace", path: "/n", value: 1 }]); + expect(seen).toEqual([1]); + newRelease(); + document.commit([{ op: "replace", path: "/n", value: 2 }]); + expect(seen).toEqual([1]); + expect(session.snapshot.value).toEqual({ n: 2 }); + }); +}); diff --git a/packages/json-document-react/tests/react-connector.test.tsx b/packages/json-document-react/tests/react-connector.test.tsx index 47dbeea65..400826bd6 100644 --- a/packages/json-document-react/tests/react-connector.test.tsx +++ b/packages/json-document-react/tests/react-connector.test.tsx @@ -130,4 +130,18 @@ describe("React Connector", () => { expect(screen.getByText("Initial")).toBeTruthy(); expect(editors[0]).toBe(editors[1]); }); + + test("updates Editing consumers when an earlier document subscriber reads the editor", () => { + const document = createJSONDocument({ blocks: [{ id: "a", text: "Alpha" }] }); + const editor = createDocumentEditor(document); + document.subscribe(() => { void editor.snapshot; }); + function View() { + const snapshot = useEditingSnapshot(editor); + const value = snapshot.value as { blocks: ReadonlyArray<{ text: string }> }; + return {snapshot.revision}:{value.blocks[0]?.text}; + } + render(); + act(() => { document.commit([{ op: "replace", path: "/blocks/0/text", value: "External" }]); }); + expect(screen.getByText("1:External")).toBeTruthy(); + }); }); diff --git a/scripts/generate-api-reference.mjs b/scripts/generate-api-reference.mjs index 0486673bb..d3595a2d0 100644 --- a/scripts/generate-api-reference.mjs +++ b/scripts/generate-api-reference.mjs @@ -77,7 +77,9 @@ for (const descriptor of apiReferencePackages) { for (const subpath of descriptor.subpaths) { const subpathEntry = program.getSourceFile(join(root, subpath.entrypoint)); if (!subpathEntry) throw new Error(`public entrypoint를 찾을 수 없습니다: ${subpath.entrypoint}`); - const subpathExports = checker.getExportsOfModule(checker.getSymbolAtLocation(subpathEntry)).sort((a, b) => a.name.localeCompare(b.name)); + const subpathExports = checker.getExportsOfModule(checker.getSymbolAtLocation(subpathEntry)) + .filter((symbol) => !exports.some((rootExport) => rootExport === symbol)) + .sort((a, b) => a.name.localeCompare(b.name)); exportCount += subpathExports.length; sections.push(`## \`${subpath.packageName}\`\n\n아래 API는 package root가 아닌 이 subpath에서 import합니다.`); sections.push(...subpathExports.map((symbol) => [ diff --git a/site/tests/unit/public-document-contract.test.ts b/site/tests/unit/public-document-contract.test.ts index 7f00e5824..94c48818c 100644 --- a/site/tests/unit/public-document-contract.test.ts +++ b/site/tests/unit/public-document-contract.test.ts @@ -15,6 +15,20 @@ function findings(source: string) { } describe("public documentation contract", () => { + test("documents HistoryResult at its owning public subpath without duplicating root exports", () => { + const collaboration = apiReferencePackages.find(({ slug }) => slug === "collaboration"); + expect(collaboration?.subpaths).toContainEqual({ + packageName: "@interactive-os/json-document-collaboration/history", + entrypoint: "packages/json-document-collaboration/src/history-index.ts", + }); + const reference = readFileSync(new URL("../../../docs/api-reference/collaboration.md", import.meta.url), "utf8"); + const history = reference.split("## `@interactive-os/json-document-collaboration/history`")[1]?.split("## `@interactive-os/json-document-collaboration/editing`")[0]; + expect(history).toContain("### `HistoryResult`"); + expect(history).toContain("readonly change: JSONAppliedChange | null"); + expect(history).toContain("readonly status: HistoryStatus"); + expect(history).not.toContain("### `ActorId`"); + }); + test("accepts the exact published llms source against the canonical Core contract", () => { expect(symbolCount).toBe(23); expect(findings(llms)).toEqual([]); From fa8543d06c0e1332631676f864c60a1a04dfc81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 7 Sep 2026 22:54:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=98=20=EC=9E=AC=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9D=84=20=EB=AA=85=EB=A0=B9=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EC=B2=98=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #719 --- .../tests/editing-lifecycle.test.ts | 32 +++++++++ packages/json-document-editing/README.md | 3 +- packages/json-document-editing/src/session.ts | 51 ++++++++------- .../tests/session-lifecycle.test.ts | 65 ++++++++++++++++++- 4 files changed, 126 insertions(+), 25 deletions(-) diff --git a/packages/json-document-collaboration/tests/editing-lifecycle.test.ts b/packages/json-document-collaboration/tests/editing-lifecycle.test.ts index 53e278a00..60ccf5e64 100644 --- a/packages/json-document-collaboration/tests/editing-lifecycle.test.ts +++ b/packages/json-document-collaboration/tests/editing-lifecycle.test.ts @@ -48,6 +48,38 @@ test("history results retain their own immutable change and status before subscr expect(local.history.status()).toMatchObject({ revision: 3, redoTarget: null }); }); +test("completes a subscriber's pending selection restore before synchronizing later state", () => { + const local = runtime(); + const remote = runtime("remote"); + let rejects = false; + const session = createEditingSession({ + document: local.document, selection: 0, history: createCollaborationEditingHistory(local), + reconcileSelection(selection) { + if (rejects) { + rejects = false; + throw new Error("selection failed once"); + } + return selection; + }, + }); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: 1, origin: "local" }); + const seen: EditingSnapshot[] = []; + let failure: unknown; + session.subscribe((snapshot) => { + seen.push(snapshot); + if (snapshot.revision !== 2) return; + rejects = true; + try { session.undo(); } catch (error) { failure = error; } + }); + remote.replica.ingest(local.replica.exportBundle()); + remote.document.commit([{ op: "replace", path: "/other", value: 2 }]); + local.replica.ingest(remote.replica.exportBundle()); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe("selection failed once"); + expect(session.snapshot).toMatchObject({ value: { n: 0, other: 2 }, selection: 0, revision: 3, canRedo: true }); + expect(seen.map(({ revision, selection }) => [revision, selection])).toEqual([[2, 1], [3, 0]]); +}); + test("releases both connections across resubscription and editor recreation", () => { const local = runtime(); const owner = createCollaborationEditingHistory(local); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 00b4a0400..d47698af7 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -12,7 +12,8 @@ order. A returned result describes its own transition, even if a subscriber has already performed another transition. Every synchronized revision is published, including synchronization started by a snapshot read or another command. A reader's subscription order cannot consume -the notification intended for another observer. +the notification intended for another observer. Synchronization also catches up +with writes made by those observers before returning to a read or new command. Domains can provide `mapSelection(selection, { before, after, change })` and `reconcileSelection(selection, value)` to `createEditingSession`. Both are pure diff --git a/packages/json-document-editing/src/session.ts b/packages/json-document-editing/src/session.ts index 0531d6f37..66c939ce3 100644 --- a/packages/json-document-editing/src/session.ts +++ b/packages/json-document-editing/src/session.ts @@ -134,32 +134,37 @@ export function createEditingSession(options: Editi function synchronizeExternalChange(change?: JSONAppliedChange): void { if (isCommitting) return; - if (pendingHistoryRestore) completeHistoryRestore(); - const nextHistory = options.history?.status(); - const historyChanged = nextHistory?.revision !== observedHistory?.revision; - const latest = document.value; - if (jsonEqual(observedValue, latest)) { - if (historyChanged) { + for (;;) { + if (pendingHistoryRestore) { + completeHistoryRestore(); + change = undefined; + continue; + } + const nextHistory = options.history?.status(); + const historyChanged = nextHistory?.revision !== observedHistory?.revision; + const latest = document.value; + if (jsonEqual(observedValue, latest)) { + if (!historyChanged) return; + observedHistory = nextHistory; + } else { + const before = observedValue; + const replay = options.mapSelection && change !== undefined ? applyPatch(before, change.applied) : null; + // A failed callback leaves the entire observed transition retryable. + const nextSelection = mappedSelection({ value: before, selection }, latest, + replay?.ok && jsonEqual(replay.value, latest) ? change! : null); + observedValue = latest; observedHistory = nextHistory; - revision += 1; - publish(); + selection = nextSelection; + undoStack = []; + redoStack = []; + activeHistoryGroup = undefined; } - return; + revision += 1; + change = undefined; + // Publish every revision, then catch up with any subscriber-authored change + // before a caller can read the state or author another mutation. + publish(); } - const before = observedValue; - const replay = options.mapSelection && change !== undefined ? applyPatch(before, change.applied) : null; - // A failed callback leaves the entire observed transition retryable. - const nextSelection = mappedSelection({ value: before, selection }, latest, - replay?.ok && jsonEqual(replay.value, latest) ? change! : null); - observedValue = latest; - observedHistory = nextHistory; - selection = nextSelection; - undoStack = []; - redoStack = []; - activeHistoryGroup = undefined; - revision += 1; - // Reads and commands must not consume a revision without its notification. - publish(); } function commit( diff --git a/packages/json-document-editing/tests/session-lifecycle.test.ts b/packages/json-document-editing/tests/session-lifecycle.test.ts index ba887a20d..0c968be19 100644 --- a/packages/json-document-editing/tests/session-lifecycle.test.ts +++ b/packages/json-document-editing/tests/session-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; +import { createJSONDocument, type JSONAppliedChange, type JSONValue } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { createEditingSession } from "../src/session.js"; @@ -28,6 +28,69 @@ describe("editing observation and recovery", () => { expect(seen).toEqual([1, 2]); }); + test("does not reuse an observed patch for a subscriber's equivalent later patch", () => { + const document = createJSONDocument({ rows: [] }); + const changes: Array = []; + const session = createEditingSession({ + document, selection: null, + mapSelection(selection, { change }) { changes.push(change); return selection; }, + }); + const seen: number[] = []; + session.subscribe((snapshot) => { + seen.push(snapshot.revision); + if (snapshot.revision === 1) document.commit([{ op: "add", path: "/rows/0", value: 1 }]); + }); + const result = document.commit([{ op: "add", path: "/rows/0", value: 1 }]); + if (!result.ok) throw new Error(result.code); + expect(changes).toEqual([result.change, null]); + expect(seen).toEqual([1, 2]); + expect(session.snapshot.value).toEqual({ rows: [1, 1] }); + }); + + test.each(["snapshot", "apply", "select", "reconcile", "undo", "redo"] as const)("blocks %s when synchronization observers write an unreconciled change", (operation) => { + const document = createJSONDocument({ n: 0 }); + let attempted = false; + let failure: unknown; + document.subscribe(() => { + if (attempted) return; + attempted = true; + try { operations[operation](); } catch (error) { failure = error; } + }); + let rejects = false; + const session = createEditingSession({ + document, selection: 0, + mapSelection: (selection) => selection + 10, + reconcileSelection(selection, value) { + if (rejects && (value as { n: number }).n === 2) throw new Error("selection failed"); + return selection; + }, + }); + const operations = { + snapshot: () => session.snapshot, + apply: () => session.apply({ operations: [{ op: "replace", path: "/n", value: 99 }], selectionAfter: 99, origin: "blocked" }), + select: () => session.select(99), + reconcile: () => session.reconcile(() => 99), + undo: () => session.undo(), + redo: () => session.redo(), + }; + const seen: number[] = []; + session.subscribe((snapshot) => { + seen.push(snapshot.revision); + if (snapshot.revision !== 1) return; + rejects = true; + document.commit([{ op: "replace", path: "/n", value: 2 }]); + }); + document.commit([{ op: "replace", path: "/n", value: 1 }]); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe("selection failed"); + expect(document.value).toEqual({ n: 2 }); + expect(seen).toEqual([1]); + rejects = false; + expect(session.snapshot).toMatchObject({ value: { n: 2 }, selection: 20, revision: 2, canUndo: false }); + expect(seen).toEqual([1, 2]); + expect(session.undo()).toMatchObject({ ok: false, code: "history.empty" }); + }); + describe.each([true, false])("callback failure (observed: %s)", (observed) => { test.each(["map", "reconcile"])("retries %s from the last coherent selection without applying stale history", (failing) => { const document = createJSONDocument({ n: 0 });