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
6 changes: 6 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ reconciliation까지의 DOM 편집 정본은 `standards/dom-editing-lifecycle.md
identifier나 동작을 바꾸지 않으며, 과거 version 문서는 정본 public surface의
Root symbol·six-member 계약을 확장하지 않습니다.

EditingSession의 확정된 공통 의미는 `standards/editing-session.md`가 소유합니다.
ES 규칙은 공통 의미를 규정하고 현재 TypeScript binding·local History 정책은 별도
표에서 구별합니다. callback 형태·재시도 시점·구독 전략을 보편 조건으로 굳히지
않습니다. 각 규칙을 owner의 행동 테스트에 연결합니다. `docs:evaluate`는 그 증거 연결을 검사하고 package test가
실제 행동을 검증합니다. 이 확정은 전체 Hands의 Stable 선언을 뜻하지 않습니다.

편집 문법의 안정화 설계는 `standards/editing-grammar.md`에 있습니다. 공통 편집
규칙, Hands profile의 선택, 입력 매핑의 소유자와 적합성 증거를 연결하는 Design
Draft이며 기존 Stable profile의 권위를 변경하지 않습니다. API reference와 Usage는
Expand Down
25 changes: 23 additions & 2 deletions docs/evaluate.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync } from "node:fs";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
Expand Down Expand Up @@ -139,6 +139,7 @@ const publicContract = readJson("packages/json-document/public-contract.json");
const rootPackage = readJson("package.json");
const implementationShape = read("standards/repository-implementation-shape.md");
const domEditingLifecycle = read("standards/dom-editing-lifecycle.md");
const editingSession = read("standards/editing-session.md");

if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([
"adapter-clipboard.md",
Expand Down Expand Up @@ -222,10 +223,30 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([
if (JSON.stringify(fileNames("standards")) !== JSON.stringify([
"dom-editing-lifecycle.md",
"editing-grammar.md",
"editing-session.md",
"repository-implementation-shape.md",
"repository-naming.md",
])) {
fail("standards: only repository naming, implementation shape, DOM editing lifecycle, and the editing grammar design may appear at the root.");
fail("standards: only repository naming, implementation shape, DOM editing lifecycle, EditingSession contract, and the editing grammar design may appear at the root.");
}

// Each normative session rule must retain a concrete behavior case at its owner.
// This checks evidence references; package test execution checks the behavior itself.
const sessionRules = new Set([...editingSession.matchAll(/^\| (ES-[A-Z-]+) \| (?!\[)/gm)].map((match) => match[1]));
const sessionEvidence = [...editingSession.matchAll(/^\| (ES-[A-Z-]+) \| \[[^\]]+\]\(([^)]+)\) \| `([^`]+)` \|$/gm)];
if (sessionRules.size === 0) fail("EditingSession: missing normative rules.");
for (const rule of sessionRules) {
if (!sessionEvidence.some((match) => match[1] === rule)) fail(`EditingSession: ${rule} has no behavior evidence.`);
}
for (const [, rule, path, name] of sessionEvidence) {
if (!sessionRules.has(rule)) fail(`EditingSession: evidence refers to unknown rule ${rule}.`);
const target = join("standards", path);
if (!existsSync(join(root, target)) || !read(target).includes(JSON.stringify(name))) {
fail(`EditingSession: ${rule} lost behavior evidence ${path}: ${name}.`);
}
}
for (const [, path] of editingSession.matchAll(/\]\(([^)]+)\)/g)) {
if (!existsSync(join(root, "standards", path))) fail(`EditingSession: missing local reference ${path}.`);
}

for (const token of [
Expand Down
5 changes: 4 additions & 1 deletion docs/public/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ History 항목은 JSON 값이 실제로 바뀐 편집에서 생깁니다. Select
현재 편집 대상만 바꾸므로 기록을 추가하지 않습니다. 검사를 통과하지 못한
요청과 문서 값이 그대로인 요청도 되돌릴 값이 없어 기록되지 않습니다.

기본 local history는 외부 문서 변경을 받으면 비워집니다. 다른 참여자의 변경을
기본 local history는 실제 외부 문서 변경이 있으면 비워집니다. UI 구독자가 없거나
구독을 해제한 뒤에도 같은 정책을 따릅니다. 외부 변경 후 값이 원래 값으로
돌아와도 이전 Undo/Redo 기록은 되살아나지 않습니다. 동일 값의 새 snapshot
reference나 문서 no-op은 기록을 지우지 않습니다. 다른 참여자의 변경을
보존하며 내 기여만 취소하려면 [Collaborative History](collaboration-history.md)의
공식 연결 API를 사용합니다. document만 바꾸는 것으로 history 의미까지 바뀌지는 않습니다.

Expand Down
10 changes: 7 additions & 3 deletions docs/public/intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,14 @@ type EditingResult<Selection extends JSONValue> =
`EditingSnapshot`은 처리 뒤의 값과 Selection, revision, 실행 취소 상태를
묶습니다. `type`은 editor가 수행할 동작을 나타내고, 각 동작에 필요한 필드는
editor별 Intent union에서 정합니다. 성공 결과에는 snapshot이 들어 있으며
JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. 실패하면
문서와 Selection은 요청 전 상태를 유지합니다.
JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다.

값이 바뀐 요청은 History 항목을 만들고
외부 변경의 동기화를 마친 뒤 요청 자체가 검증·commit에서 거절되면
문서와 Selection, History는 그 요청의 시작 상태를 유지합니다. 이미 완료된 외부
commit은 되돌아가지 않습니다. Selection mapping/reconciliation callback의 예외와
재시도는 [History의 동기화·복구 계약](history.md)에서 설명합니다.

기본 local History에서 기록 대상인 값 변경 요청은 History 항목을 만들고
`change.metadata.editing.origin`에 `intent.type`을 남깁니다. Selection만 바뀐
요청은 성공 snapshot을 돌려주지만 History 항목은 만들지 않습니다.

Expand Down
16 changes: 14 additions & 2 deletions packages/json-document-editing/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @interactive-os/json-document-editing

The [EditingSession contract](../../standards/editing-session.md) separates common
state, observation, recovery and history-owner invariants from the current
TypeScript binding and local-history policies, with behavior evidence at each
owner. Implementations must preserve the applicable contract; domain clipboard,
input defaults and complete Hands profiles retain their own decisions. This is
not a Stable release declaration for every export in this package.

`EditingSession` observes snapshots by JSON value, not reference identity.
Fresh-copy JSONDocument implementations retain local history until an actual
external value change. Undo reverses each operation against its sequential
Expand Down Expand Up @@ -72,8 +79,13 @@ replica. Custom `createId` injection remains supported; its provider must ensure
uniqueness across all writers. Environments without `crypto.randomUUID` fail
explicitly with `editing.id-provider-unavailable`; no weak random fallback is used.

The session subscribes to its document only while it has observers. The last
unsubscribe releases that connection; later reads catch up with external state.
The last UI unsubscribe releases the session's document and external-history
observation connections. Local undo/redo validity is independent of UI subscriptions:
a one-shot change marker retains no session, history stack or UI callback and
releases itself on the next document change. Later reads invalidate local history
even if external edits returned the value to the previous snapshot. Fresh-copy
snapshots and document no-ops do not invalidate history. This does not replay every
unobserved intermediate selection or guarantee identical revision counts.
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
Expand Down
20 changes: 20 additions & 0 deletions packages/json-document-editing/src/history-invalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { jsonEqual, type JSONAppliedChange, type JSONDocument } from "@interactive-os/json-document";

/** A one-shot change marker. The document retains no editor, history or UI callback. */
export function observeHistoryInvalidation(
document: JSONDocument,
pendingOwnChange?: JSONAppliedChange,
): { readonly changed: boolean } {
const marker = { changed: false };
const release = document.subscribe((change) => {
// A reentrant commit may return before its queued notification is delivered.
// Earlier queued changes precede this history entry; start after its own change.
if (pendingOwnChange) {
if (jsonEqual(change, pendingOwnChange)) pendingOwnChange = undefined;
return;
}
marker.changed = true;
release();
});
return marker;
}
26 changes: 21 additions & 5 deletions packages/json-document-editing/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type JSONValue,
} from "@interactive-os/json-document";
import type { SelectionHistoryEntry } from "@interactive-os/json-document-selection";
import { observeHistoryInvalidation } from "./history-invalidation.js";
import { invertEditingPatch } from "./invert-patch.js";
import type { EditingHistoryOptions, EditingHistoryResult, EditingHistoryStatus } from "./history.js";

Expand Down Expand Up @@ -68,6 +69,7 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
let undoStack: HistoryEntry<Selection>[] = [];
let redoStack: HistoryEntry<Selection>[] = [];
let activeHistoryGroup: string | undefined;
let historyInvalidation: { readonly changed: boolean } | undefined;
let isCommitting = false;
let observedValue = document.value;
let unsubscribeDocument: (() => void) | null = null;
Expand Down Expand Up @@ -142,10 +144,12 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
}
const nextHistory = options.history?.status();
const historyChanged = nextHistory?.revision !== observedHistory?.revision;
const localHistoryChanged = historyInvalidation?.changed === true;
const latest = document.value;
if (jsonEqual(observedValue, latest)) {
if (!historyChanged) return;
if (!historyChanged && !localHistoryChanged) return;
observedHistory = nextHistory;
if (localHistoryChanged) clearLocalHistory();
} else {
const before = observedValue;
const replay = options.mapSelection && change !== undefined ? applyPatch(before, change.applied) : null;
Expand All @@ -155,9 +159,7 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
observedValue = latest;
observedHistory = nextHistory;
selection = nextSelection;
undoStack = [];
redoStack = [];
activeHistoryGroup = undefined;
clearLocalHistory();
}
revision += 1;
change = undefined;
Expand All @@ -167,6 +169,18 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
}
}

function clearLocalHistory(): void {
undoStack = [];
redoStack = [];
activeHistoryGroup = undefined;
historyInvalidation = undefined;
}

function trackLocalHistory(followedByChange: boolean, pendingOwnChange?: JSONAppliedChange): void {
if (options.history || undoStack.length + redoStack.length === 0) return;
historyInvalidation = followedByChange ? { changed: true } : observeHistoryInvalidation(document, pendingOwnChange);
}

function commit(
operations: ReadonlyArray<JSONPatchOperation>,
metadata: Readonly<Record<string, JSONValue>>,
Expand All @@ -182,7 +196,7 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
// reentrant document write needs a replay to recover the earlier value.
const replay = notifications > 1 ? applyPatch(before, result.change.applied) : null;
observedValue = replay?.ok ? replay.value : document.value;
return result;
return { ...result, followedByChange: notifications > 1, pendingOwnChange: notifications === 0 ? result.change : undefined };
} finally {
release();
isCommitting = false;
Expand Down Expand Up @@ -267,6 +281,7 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
activeHistoryGroup = plan.historyGroup;
redoStack = [];
}
if (result.change.applied.length > 0) trackLocalHistory(result.followedByChange, result.pendingOwnChange);
return { ok: true, snapshot: publishCommit(), change: result.change };
}

Expand All @@ -280,6 +295,7 @@ export function createEditingSession<Selection extends JSONValue>(options: Editi
selection = nextSelection;
revision += 1;
activeHistoryGroup = undefined;
if (result.change.applied.length > 0) trackLocalHistory(result.followedByChange, result.pendingOwnChange);
return { ok: true, snapshot: currentSnapshot(), change: result.change };
}

Expand Down
94 changes: 94 additions & 0 deletions packages/json-document-editing/tests/session-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,97 @@ describe("selection-aware editing history", () => {
expect(session.reconcile((selection) => selection).revision).toBe(snapshot.revision);
});
});


describe("local history is independent of UI observation", () => {
test.each(["active", "absent", "released", "resubscribed"])("invalidates undo and redo after an external value round trip (%s)", (observation) => {
for (const direction of ["undo", "redo"] as const) {
const document = createJSONDocument({ n: 0 });
const session = createEditingSession<number>({ document, selection: 0 });
const release = observation === "absent" ? () => {} : session.subscribe(() => {});
expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: 1, origin: "local" }).ok).toBe(true);
if (direction === "redo") expect(session.undo().ok).toBe(true);
if (observation === "released" || observation === "resubscribed") release();
const before = session.snapshot;
expect(before[direction === "undo" ? "canUndo" : "canRedo"]).toBe(true);
expect(document.commit([{ op: "replace", path: "/n", value: 2 }]).ok).toBe(true);
expect(document.commit([{ op: "replace", path: "/n", value: direction === "undo" ? 1 : 0 }]).ok).toBe(true);
const again = observation === "resubscribed" ? session.subscribe(() => {}) : () => {};
expect(session.snapshot).toMatchObject({ value: before.value, canUndo: false, canRedo: false });
expect(session[direction]()).toMatchObject({ ok: false, code: "history.empty" });
expect(document.value).toEqual(before.value);
release();
again();
}
});

test("fresh copies and document no-ops retain unobserved history", () => {
const inner = createJSONDocument({ n: 0 });
const document = { ...inner, get value() { return structuredClone(inner.value); } };
const session = createEditingSession({ document, selection: null });
expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "local" }).ok).toBe(true);
inner.commit([{ op: "replace", path: "/n", value: 1 }]);
expect(session.snapshot).toMatchObject({ revision: 1, canUndo: true });
expect(session.undo().ok).toBe(true);
inner.commit([{ op: "replace", path: "/n", value: 0 }]);
expect(session.snapshot.canRedo).toBe(true);
expect(session.redo().ok).toBe(true);
});

test("catches an external round trip authored by a commit subscriber", () => {
const document = createJSONDocument({ n: 0 });
let written = false;
document.subscribe(() => {
if (written) return;
written = true;
document.commit([{ op: "replace", path: "/n", value: 2 }]);
document.commit([{ op: "replace", path: "/n", value: 1 }]);
});
const session = createEditingSession({ document, selection: null });
expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "local" }).ok).toBe(true);
expect(session.snapshot).toMatchObject({ value: { n: 1 }, canUndo: false, canRedo: false });
expect(session.undo()).toMatchObject({ ok: false, code: "history.empty" });
});
});


test("retains an editor-authored step during an external notification", () => {
const document = createJSONDocument({ n: 0 });
const session = createEditingSession({ document, selection: null });
session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "initial" });
const release = session.subscribe((snapshot) => {
if ((snapshot.value as { n: number }).n === 2) {
expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 3 }], selectionAfter: null, origin: "follow-up" }).ok).toBe(true);
}
});
document.commit([{ op: "replace", path: "/n", value: 2 }]);
release();
expect(session.snapshot).toMatchObject({ value: { n: 3 }, canUndo: true });
expect(session.undo()).toMatchObject({ ok: true, snapshot: { value: { n: 2 } } });
});

test("keeps only a one-shot history marker after UI release", () => {
const inner = createJSONDocument({ n: 0 });
let connections = 0;
const document = { ...inner, get value() { return inner.value; },
subscribe(listener: Parameters<typeof inner.subscribe>[0]) {
connections++;
const release = inner.subscribe(listener);
return () => { connections--; release(); };
},
};
const session = createEditingSession({ document, selection: null });
expect(connections).toBe(0);
const release = session.subscribe(() => {});
for (const n of [1, 2, 3]) {
session.apply({ operations: [{ op: "replace", path: "/n", value: n }], selectionAfter: null, origin: "local" });
expect(connections).toBe(2);
}
release();
expect(connections).toBe(1);
document.commit([{ op: "replace", path: "/n", value: 4 }]);
expect(connections).toBe(0);
document.commit([{ op: "replace", path: "/n", value: 3 }]);
expect(session.snapshot.canUndo).toBe(false);
expect(connections).toBe(0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ describe("Rich Text React locality", () => {
expect(createRichTextRenderStore(editor).getBlockIds()).toEqual(["move-1", "move-2", "move-0"]);
expect([...container.querySelectorAll("p[data-rich-text-node-id]")].map((node) => node.getAttribute("data-rich-text-node-id"))).toEqual(["move-1", "move-2", "move-0"]);
await act(async () => root.unmount());
// UI subscriptions are gone; the one-shot local History marker remains.
expect(active).toBe(1);
const valueAfterMove = inner.value;
expect(inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "external" }]).ok).toBe(true);
expect(active).toBe(0);
expect(inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "x" }]).ok).toBe(true);
expect(inner.value).toEqual(valueAfterMove);
expect(editor.snapshot.canUndo).toBe(false);
expect(editor.undo()).toMatchObject({ ok: false, code: "history.empty" });
expect(active).toBe(0);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ describe("Rich Text extension protocol", () => {
expect(editor.dispatch({ type: "text.insert", text: "!" }).ok).toBe(true);
expect(inner.at("/content/0/content/0/text")).toMatchObject({ ok: true, value: "a!" });
unsubscribe();
// UI observation is released; only the history invalidation marker remains.
expect(active).toBe(1);
inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "external" }]);
expect(active).toBe(0);
inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "a!" }]);
expect(editor.snapshot.canUndo).toBe(false);
expect(editor.undo()).toMatchObject({ ok: false, code: "history.empty" });
expect(active).toBe(0);
});

Expand Down
Loading