From 23e48e19409cce37b90d64783606990f1e445b14 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: Sun, 12 Jul 2026 23:01:14 +0900 Subject: [PATCH] feat: coordinate causal changes with editable input --- README.md | 9 + docs/composition-island-architecture.md | 75 +- package.json | 6 + packages/editable/browser/editor.ts | 26 +- .../editable/browser/editorCoordinator.ts | 352 ++++++++- packages/editable/browser/index.ts | 3 +- packages/editable/causalHost.test.ts | 736 ++++++++++++++++++ packages/editable/editor.ts | 3 +- packages/editable/index.ts | 3 +- packages/editable/publicApi.test.ts | 27 +- pnpm-lock.yaml | 56 ++ src/editable-lab/JsonEditableDemo.tsx | 89 ++- src/editable-lab/causalDocumentInbox.test.ts | 149 ++++ src/editable-lab/causalDocumentInbox.ts | 103 +++ tests/browser/editable.spec.ts | 23 + vendor/json-document-e8d572d4/README.md | 28 + vendor/json-document-e8d572d4/SHA256SUMS | 4 + ...json-document-causal-patch-inbox-0.1.0.tgz | Bin 0 -> 16730 bytes ...ive-os-json-document-id-resolver-0.1.0.tgz | Bin 0 -> 3419 bytes ...ve-os-json-document-patch-rebase-0.1.0.tgz | Bin 0 -> 8126 bytes ...s-json-document-stable-id-rebase-0.1.0.tgz | Bin 0 -> 5950 bytes 21 files changed, 1655 insertions(+), 37 deletions(-) create mode 100644 packages/editable/causalHost.test.ts create mode 100644 src/editable-lab/causalDocumentInbox.test.ts create mode 100644 src/editable-lab/causalDocumentInbox.ts create mode 100644 vendor/json-document-e8d572d4/README.md create mode 100644 vendor/json-document-e8d572d4/SHA256SUMS create mode 100644 vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz create mode 100644 vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz create mode 100644 vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz create mode 100644 vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz diff --git a/README.md b/README.md index 662df2b..bd5b521 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ block to the IME; proven-disjoint `remote` updates are queued until settling, while local or same-block commands return `composition_conflict` and must be retried afterward. +`getJsonEditableDocumentHost(editor)` exposes a document-host capability for +delayed or causal changes without widening the established `JsonEditable` +shape. It flushes pending native input before a ready change, defers while the +browser still owns a composition island, and identifies coordinator-owned +native, application, history, and ready publications with a pre-commit +sequence. The demo wires the SHA-pinned `json-document` causal inbox through +that capability; “지연 편집 추적” exercises both positional rebase after a +local insertion and settle-time retry during IME composition. + Enter is handled as a structural intent, independently from DOM ownership. A semantic paragraph event is retained and replayed once composition settles; candidate-confirming `keydown Enter` by itself is never treated as a paragraph. diff --git a/docs/composition-island-architecture.md b/docs/composition-island-architecture.md index 5d33b87..000157c 100644 --- a/docs/composition-island-architecture.md +++ b/docs/composition-island-architecture.md @@ -18,9 +18,11 @@ JSONDocument -> keyed DOM bounded DOM diff -> JSONDocument document transactions wait ``` -This is a short, block-granular lease rather than a CRDT. A CRDT or remote-op -adapter can sit above the coordinator, but it must retry or buffer an operation -that receives `composition_conflict`. +This is a short, block-granular lease rather than a CRDT. A causal or remote-op +adapter can sit above the coordinator through +`getJsonEditableDocumentHost(editor)`. The host flushes pending native input +and defers ready work until the lease is released; the app retries a deferred +inbox from a microtask after observing idle state. ## Ownership Invariants @@ -145,6 +147,70 @@ cannot capture a stale pre-normalization DOM caret. - Direct writes to the supplied `JSONDocument`: unsupported while mounted. The editor reports `out_of_band_document_write` and recovers conservatively. +### Causal document host + +`getJsonEditableDocumentHost(editor)` is the only supported path for an adapter +whose own `doc.commit()` must be reconciled as an editor-owned publication. It +is a small capability rather than a second transaction API, and the separate +getter keeps the established `JsonEditable` structural contract unchanged: + +```ts +const inbox = createCausalPatchInbox(document, { + positionalSchema: EditableDocumentSchema, + host: getJsonEditableDocumentHost(editor), +}); +``` + +Before every coordinator-owned native, application, or history change, +`runDocumentChange` assigns a monotonic sequence. `ownsPublication` returns +that sequence only during the synchronous publication; origin strings are not +used as authority. A ready apply reserves from the same sequence before its +scope-bound closure runs. Raw `document.commit()` calls therefore remain out of +band. +Editor snapshot subscribers are exception-isolated and report +`subscriber_failed`; fault observers are isolated as well. A user callback +therefore cannot abort the document's subscriber loop before a causal inbox +journals an owned publication. + +For a ready causal envelope, `runReady` first drains mutation records. It calls +the supplied `apply()` exactly once only when the editor is idle and has no +pending composition, native intent, structural intent, or queued remote patch. +It refuses immediately, without attempting that drain, while a browser event +handler or another coordinator-owned document publication is still on the +stack. This prevents both pre-native DOM interleaving and nested journal order +from depending on subscriber registration order. + +That condition must hold both before and after the drain: recovery that cancels +a damaged composition still defers the current turn, so a causal render cannot +overlap an OS composition that has not delivered its final event. Otherwise +`runReady` returns `host_not_ready` without calling `apply()`. The first +publication whose `mergeKey` matches the ready envelope id is reconciled as a +remote change. A nested or second publication remains out of band, while the +causal inbox independently detects projection divergence. + +Browser composition activity is tracked separately from the pinned +`CompositionSession`. Losing an ancestor or Text identity may cancel the pin, +but it does not release the OS lease. Ready work remains deferred until +`compositionend`, blur, or a non-composing `insertFromComposition` input clears +that latch. Release is queued after the native event handler returns, so a +synchronous editor subscriber cannot re-enter the inbox before final native +flush and phase handling complete. A generation token prevents an old release +from closing a newly started composition, and a new composing signal cancels +any orphaned settle timer from a damaged session. Clearing the latch emits a +snapshot change so a coalesced retry waiting behind a damaged composition wakes +up. + +If `compositionend` arrives after the pinned session was already lost, the +editor still enters the same 30 ms settling window before exposing idle state; +this leaves room for the browser's late final input. + +Retry is scheduled in a microtask after an idle snapshot, not synchronously +inside an editor subscriber. This lets the current document publication finish +and reach the causal inbox's ownership subscriber before another ingestion +begins. The app-owned `causalDocumentInbox` tracer implements this coalesced +retry and avoids a same-revision microtask loop when another pending input state +still prevents readiness. + The demo's “remote” origin means a queued asynchronous application update, not a full collaborative undo model. True collaboration needs causal operations and origin-selective undo in the CRDT/OT layer instead of this linear queue. @@ -162,6 +228,7 @@ The mount API intentionally stays small: ```ts const editor = mountJsonEditable({ root, document, onFault }); +const host = getJsonEditableDocumentHost(editor); editor.dispatch(action); editor.getSnapshot(); editor.subscribe(listener); @@ -216,6 +283,8 @@ before it removes listeners and restores the host attributes it borrowed. The automated suite verifies: - coordinator transitions and history in jsdom; +- causal host publication ownership, delayed positional rebase, selection + restoration, pending-native flush, composition defer, and microtask retry; - owner-document/cross-realm DOM behavior; - ordinary typing, empty-block deletion, and root-boundary Select All in real Chromium, Firefox, and WebKit; diff --git a/package.json b/package.json index db64b78..e6f607a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ }, "dependencies": { "@interactive-os/json-document": "1.1.0-rc.0", + "@interactive-os/json-document-causal-patch-inbox": "file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz", + "@interactive-os/json-document-id-resolver": "file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "lucide-react": "^0.545.0", @@ -43,6 +45,10 @@ "vitest": "^4.1.5" }, "pnpm": { + "overrides": { + "@interactive-os/json-document-patch-rebase": "file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz", + "@interactive-os/json-document-stable-id-rebase": "file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz" + }, "onlyBuiltDependencies": [ "esbuild", "lightningcss" diff --git a/packages/editable/browser/editor.ts b/packages/editable/browser/editor.ts index 33f57fa..7e41706 100644 --- a/packages/editable/browser/editor.ts +++ b/packages/editable/browser/editor.ts @@ -1,4 +1,5 @@ import type { + JSONChangeMetadata, JSONDocument, JSONPatchOperation, SelectionSnap, @@ -8,7 +9,10 @@ import type { EditorDocumentCommand, } from "../core"; -export { mountJsonEditable } from "./editorCoordinator"; +export { + getJsonEditableDocumentHost, + mountJsonEditable, +} from "./editorCoordinator"; export type EditorPhase = | "idle" @@ -36,7 +40,8 @@ export type EditorFault = { | "input_state_lost" | "composition_overlap" | "composition_conflict" - | "queued_change_commit_failed"; + | "queued_change_commit_failed" + | "subscriber_failed"; recoverable: boolean; reason: string; }; @@ -70,6 +75,23 @@ export type EditorResult = reason: string; }; +export type JsonEditableDocumentHost = { + ownsPublication(publication: { + operations: ReadonlyArray; + metadata?: JSONChangeMetadata; + }): false | { sequence: number }; + runReady(request: { + id: string; + apply(): void; + }): + | { ok: true } + | { + ok: false; + code: "host_not_ready"; + reason: string; + }; +}; + export type JsonEditable = { dispatch(action: EditorAction): EditorResult; getSnapshot(): EditorSnapshot; diff --git a/packages/editable/browser/editorCoordinator.ts b/packages/editable/browser/editorCoordinator.ts index 183d974..288e9e7 100644 --- a/packages/editable/browser/editorCoordinator.ts +++ b/packages/editable/browser/editorCoordinator.ts @@ -1,5 +1,6 @@ import { applyPatch, + type JSONChangeMetadata, type JSONDocument, type JSONPatchOperation, type SelectionSnap, @@ -27,6 +28,7 @@ import type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./editor"; import { @@ -116,6 +118,11 @@ type BlockChange = { typeChanged: boolean; }; +type ReadyDocumentChange = { + id: string; + publicationCount: number; +}; + const OWNED_HOST_ATTRIBUTES = [ "contenteditable", "spellcheck", @@ -125,6 +132,17 @@ const OWNED_HOST_ATTRIBUTES = [ ] as const; let editorSequence = 0; +const documentHosts = new WeakMap(); + +export function getJsonEditableDocumentHost( + editor: JsonEditable, +): JsonEditableDocumentHost { + const host = documentHosts.get(editor); + if (host === undefined) { + throw new TypeError("The editor was not created by mountJsonEditable()."); + } + return host; +} export function mountJsonEditable( options: MountJsonEditableOptions, @@ -133,6 +151,14 @@ export function mountJsonEditable( } class JsonEditableCoordinator implements JsonEditable { + private readonly documentHost: JsonEditableDocumentHost = { + ownsPublication: () => { + const sequence = this.activeDocumentPublicationSequence; + return sequence === null ? false : { sequence }; + }, + runReady: (request) => this.runReadyDocumentChange(request), + }; + private readonly root: HTMLElement; private readonly document: JSONDocument; private readonly onFault: ((fault: EditorFault) => void) | undefined; @@ -146,15 +172,21 @@ class JsonEditableCoordinator implements JsonEditable { private phase: EditorPhase = "idle"; private revision = 0; private composition: CompositionSession | null = null; + private browserCompositionActive = false; + private browserCompositionGeneration = 0; private compositionSequence = 0; private blockSequence = 0; private settleTimer: number | null = null; private nativeTurnTimer: number | null = null; private commitSource: ChangeSource | null = null; private commitTextChanges: ReadonlyMap | null = null; + private documentPublicationSequence = 0; + private activeDocumentPublicationSequence: number | null = null; + private readyDocumentChange: ReadyDocumentChange | null = null; private lastNativeCompositionHistoryId: number | null = null; private dispatching = false; private destroyed = false; + private browserEventDepth = 0; private domWriteDepth = 0; private pendingRecords: MutationRecord[] = []; private mutationFlushQueued = false; @@ -205,13 +237,14 @@ class JsonEditableCoordinator implements JsonEditable { this.observe(); this.attachEvents(); - this.stopDocumentSubscription = document.subscribe(() => { - this.onDocumentChange(); + this.stopDocumentSubscription = document.subscribe((_operations, metadata) => { + this.onDocumentChange(metadata); }); this.stopSelectionSubscription = document.selection?.subscribe(() => { this.bump(); }) ?? null; + documentHosts.set(this, this.documentHost); } dispatch(action: EditorAction): EditorResult { @@ -494,6 +527,10 @@ class JsonEditableCoordinator implements JsonEditable { ): T { const previousSource = this.commitSource; const previousTextChanges = this.commitTextChanges; + const previousPublicationSequence = + this.activeDocumentPublicationSequence; + const publicationSequence = this.reserveDocumentPublicationSequence(); + this.activeDocumentPublicationSequence = publicationSequence; this.commitSource = source; this.commitTextChanges = textChanges; try { @@ -501,13 +538,125 @@ class JsonEditableCoordinator implements JsonEditable { } finally { this.commitSource = previousSource; this.commitTextChanges = previousTextChanges; + this.activeDocumentPublicationSequence = previousPublicationSequence; + } + } + + private runReadyDocumentChange( + request: Parameters[0], + ): ReturnType { + if (this.destroyed) { + throw new Error("The editor has been destroyed."); + } + if (this.dispatching) { + return { + ok: false, + code: "host_not_ready", + reason: "The editor is already dispatching a document transaction.", + }; + } + if ( + this.browserEventDepth > 0 || + this.activeDocumentPublicationSequence !== null + ) { + return { + ok: false, + code: "host_not_ready", + reason: + "The editor is still handling a browser event or publishing a document change.", + }; + } + + this.dispatching = true; + try { + const enteredReady = this.canApplyReadyDocumentChange(); + this.flushNativeMutations([], false); + if (this.destroyed) { + throw new Error( + "The editor was destroyed while preparing a ready document change.", + ); + } + if (!enteredReady || !this.canApplyReadyDocumentChange()) { + return { + ok: false, + code: "host_not_ready", + reason: + "The editor must settle native input and composition before applying this document change.", + }; + } + + const readyChange: ReadyDocumentChange = { + id: request.id, + publicationCount: 0, + }; + const previousPublicationSequence = + this.activeDocumentPublicationSequence; + this.activeDocumentPublicationSequence = + this.reserveDocumentPublicationSequence(); + this.readyDocumentChange = readyChange; + const selectionBefore = selectionSnapshotSignature( + this.document.selection?.snapshot() ?? null, + ); + let didThrow = false; + let thrown: unknown; + try { + request.apply(); + } catch (error) { + didThrow = true; + thrown = error; + } finally { + this.readyDocumentChange = null; + this.activeDocumentPublicationSequence = previousPublicationSequence; + } + const selectionChanged = + selectionSnapshotSignature( + this.document.selection?.snapshot() ?? null, + ) !== selectionBefore; + + if ( + !this.destroyed && + this.composition === null && + (readyChange.publicationCount > 0 || selectionChanged) + ) { + try { + restoreDOMSelection( + this.root, + this.document.value, + this.document.selection?.snapshot() ?? null, + ); + } catch (error) { + if (!didThrow) { + throw error; + } + } + } + if (didThrow) { + throw thrown; + } + return { ok: true }; + } finally { + this.dispatching = false; } } - private onDocumentChange(): void { + private canApplyReadyDocumentChange(): boolean { + return ( + this.phase === "idle" && + !this.destroyed && + this.browserEventDepth === 0 && + this.activeDocumentPublicationSequence === null && + !this.browserCompositionActive && + this.composition === null && + this.pendingNativeIntent === null && + this.pendingStructuralIntent === null && + this.queuedRemotePatches.length === 0 + ); + } + + private onDocumentChange(metadata?: JSONChangeMetadata): void { const before = this.lastValue; const after = this.document.value; - const source = this.commitSource ?? "external"; + const source = this.documentChangeSource(metadata); if (source !== "native") { this.lastNativeCompositionHistoryId = null; } @@ -532,6 +681,30 @@ class JsonEditableCoordinator implements JsonEditable { this.bump(); } + private documentChangeSource(metadata?: JSONChangeMetadata): ChangeSource { + if (this.commitSource !== null) { + return this.commitSource; + } + const readyChange = this.readyDocumentChange; + if (readyChange === null) { + return "external"; + } + readyChange.publicationCount += 1; + return readyChange.publicationCount === 1 && + metadata?.mergeKey === readyChange.id + ? "remote" + : "external"; + } + + private reserveDocumentPublicationSequence(): number { + const sequence = this.documentPublicationSequence + 1; + if (!Number.isSafeInteger(sequence)) { + throw new Error("The editor document publication sequence is exhausted."); + } + this.documentPublicationSequence = sequence; + return sequence; + } + private reconcileComposition( after: EditableDocumentValue, changes: ReadonlyArray, @@ -1175,7 +1348,12 @@ class JsonEditableCoordinator implements JsonEditable { private endComposition(): void { if (this.composition === null) { - this.setPhase("idle"); + if (this.browserCompositionActive) { + this.setPhase("settling"); + this.scheduleSettle(); + } else { + this.setPhase("idle"); + } return; } this.flushNativeMutations([], true); @@ -1616,39 +1794,103 @@ class JsonEditableCoordinator implements JsonEditable { } private attachEvents(): void { - this.root.addEventListener("beforeinput", this.onBeforeInput); - this.root.addEventListener("input", this.onInput); - this.root.addEventListener("compositionstart", this.onCompositionStart); - this.root.addEventListener("compositionupdate", this.onCompositionUpdate); - this.root.addEventListener("compositionend", this.onCompositionEnd); - this.root.addEventListener("paste", this.onPaste); - this.root.addEventListener("cut", this.onCut); - this.root.addEventListener("keydown", this.onKeyDown); - this.root.addEventListener("blur", this.onBlur); + this.root.addEventListener("beforeinput", this.guardedOnBeforeInput); + this.root.addEventListener("input", this.guardedOnInput); + this.root.addEventListener( + "compositionstart", + this.guardedOnCompositionStart, + ); + this.root.addEventListener( + "compositionupdate", + this.guardedOnCompositionUpdate, + ); + this.root.addEventListener("compositionend", this.guardedOnCompositionEnd); + this.root.addEventListener("paste", this.guardedOnPaste); + this.root.addEventListener("cut", this.guardedOnCut); + this.root.addEventListener("keydown", this.guardedOnKeyDown); + this.root.addEventListener("blur", this.guardedOnBlur); this.root.ownerDocument.addEventListener( "selectionchange", - this.onSelectionChange, + this.guardedOnSelectionChange, ); } private detachEvents(): void { - this.root.removeEventListener("beforeinput", this.onBeforeInput); - this.root.removeEventListener("input", this.onInput); - this.root.removeEventListener("compositionstart", this.onCompositionStart); - this.root.removeEventListener("compositionupdate", this.onCompositionUpdate); - this.root.removeEventListener("compositionend", this.onCompositionEnd); - this.root.removeEventListener("paste", this.onPaste); - this.root.removeEventListener("cut", this.onCut); - this.root.removeEventListener("keydown", this.onKeyDown); - this.root.removeEventListener("blur", this.onBlur); + this.root.removeEventListener("beforeinput", this.guardedOnBeforeInput); + this.root.removeEventListener("input", this.guardedOnInput); + this.root.removeEventListener( + "compositionstart", + this.guardedOnCompositionStart, + ); + this.root.removeEventListener( + "compositionupdate", + this.guardedOnCompositionUpdate, + ); + this.root.removeEventListener("compositionend", this.guardedOnCompositionEnd); + this.root.removeEventListener("paste", this.guardedOnPaste); + this.root.removeEventListener("cut", this.guardedOnCut); + this.root.removeEventListener("keydown", this.guardedOnKeyDown); + this.root.removeEventListener("blur", this.guardedOnBlur); this.root.ownerDocument.removeEventListener( "selectionchange", - this.onSelectionChange, + this.guardedOnSelectionChange, ); } + private readonly guardedOnBeforeInput = (event: Event): void => { + this.runBrowserEvent(() => this.onBeforeInput(event)); + }; + + private readonly guardedOnInput = (event: Event): void => { + this.runBrowserEvent(() => this.onInput(event)); + }; + + private readonly guardedOnCompositionStart = (): void => { + this.runBrowserEvent(this.onCompositionStart); + }; + + private readonly guardedOnCompositionUpdate = (): void => { + this.runBrowserEvent(this.onCompositionUpdate); + }; + + private readonly guardedOnCompositionEnd = (event: Event): void => { + this.runBrowserEvent(() => this.onCompositionEnd(event)); + }; + + private readonly guardedOnPaste = (event: ClipboardEvent): void => { + this.runBrowserEvent(() => this.onPaste(event)); + }; + + private readonly guardedOnCut = (event: ClipboardEvent): void => { + this.runBrowserEvent(() => this.onCut(event)); + }; + + private readonly guardedOnKeyDown = (event: KeyboardEvent): void => { + this.runBrowserEvent(() => this.onKeyDown(event)); + }; + + private readonly guardedOnBlur = (): void => { + this.runBrowserEvent(this.onBlur); + }; + + private readonly guardedOnSelectionChange = (): void => { + this.runBrowserEvent(this.onSelectionChange); + }; + + private runBrowserEvent(run: () => void): void { + this.browserEventDepth += 1; + try { + run(); + } finally { + this.browserEventDepth -= 1; + } + } + private readonly onBeforeInput = (rawEvent: Event): void => { const event = rawEvent as InputEvent; + if (event.isComposing || isCompositionInputType(event.inputType)) { + this.markBrowserCompositionActive(); + } const targetSelection = isTextMutationInputType(event.inputType) ? this.selectionFromInputTarget(event) : null; @@ -1808,6 +2050,11 @@ class JsonEditableCoordinator implements JsonEditable { private readonly onInput = (rawEvent: Event): void => { const event = rawEvent as InputEvent; + if (event.isComposing) { + this.markBrowserCompositionActive(); + } else if (event.inputType === "insertFromComposition") { + this.scheduleBrowserCompositionRelease(); + } this.inputTargetSelection = null; if (this.pendingNativeIntent !== null) { this.commitPendingNativeIntent(); @@ -1868,6 +2115,7 @@ class JsonEditableCoordinator implements JsonEditable { } private readonly onCompositionStart = (): void => { + this.markBrowserCompositionActive(); this.nativeEvidenceUntil = performance.now() + 100; if (this.pendingNativeIntent !== null) { return; @@ -1876,6 +2124,7 @@ class JsonEditableCoordinator implements JsonEditable { }; private readonly onCompositionUpdate = (): void => { + this.markBrowserCompositionActive(); this.nativeEvidenceUntil = performance.now() + 100; if (this.pendingNativeIntent !== null) { return; @@ -1885,6 +2134,7 @@ class JsonEditableCoordinator implements JsonEditable { private readonly onCompositionEnd = (rawEvent: Event): void => { const event = rawEvent as CompositionEvent; + this.scheduleBrowserCompositionRelease(); this.nativeEvidenceUntil = performance.now() + 100; const hasParagraphEvidence = hasTrailingLineBreak(event.data); let session = this.composition; @@ -1980,6 +2230,7 @@ class JsonEditableCoordinator implements JsonEditable { }; private readonly onBlur = (): void => { + this.scheduleBrowserCompositionRelease(); if (this.composition === null) { this.closeNativeTurn(); } else { @@ -1987,6 +2238,31 @@ class JsonEditableCoordinator implements JsonEditable { } }; + private markBrowserCompositionActive(): void { + this.clearSettleTimer(); + this.browserCompositionGeneration += 1; + this.browserCompositionActive = true; + } + + private scheduleBrowserCompositionRelease(): void { + if (!this.browserCompositionActive) { + return; + } + const generation = this.browserCompositionGeneration; + queueMicrotask(() => { + if ( + !this.browserCompositionActive || + this.browserCompositionGeneration !== generation + ) { + return; + } + this.browserCompositionActive = false; + if (!this.destroyed) { + this.bump(); + } + }); + } + private createBlockId(): string { let id: string; do { @@ -2041,12 +2317,24 @@ class JsonEditableCoordinator implements JsonEditable { this.revision += 1; const snapshot = this.getSnapshot(); for (const listener of [...this.listeners]) { - listener(snapshot); + try { + listener(snapshot); + } catch (error) { + this.reportFault({ + code: "subscriber_failed", + recoverable: true, + reason: callbackFailureReason("Editor subscriber", error), + }); + } } } private reportFault(fault: EditorFault): void { - this.onFault?.(fault); + try { + this.onFault?.(fault); + } catch { + // Fault observers cannot be allowed to interrupt document publication. + } } } @@ -2090,6 +2378,16 @@ function selectionAt(path: string, offset: number): SelectionSnap { return selectionBetween(path, offset, path, offset); } +function selectionSnapshotSignature(selection: SelectionSnap | null): string { + return JSON.stringify(selection); +} + +function callbackFailureReason(label: string, error: unknown): string { + return error instanceof Error + ? `${label} failed: ${error.message}` + : `${label} failed with an unknown value.`; +} + function selectionBetween( anchorPath: string, anchorOffset: number, diff --git a/packages/editable/browser/index.ts b/packages/editable/browser/index.ts index 6a76d99..741484c 100644 --- a/packages/editable/browser/index.ts +++ b/packages/editable/browser/index.ts @@ -15,7 +15,7 @@ export type { EditablePoint, OrderedEditableSelection, } from "../core"; -export { mountJsonEditable } from "./editor"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./editor"; export type { EditorAction, EditorFault, @@ -23,5 +23,6 @@ export type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./editor"; diff --git a/packages/editable/causalHost.test.ts b/packages/editable/causalHost.test.ts new file mode 100644 index 0000000..cf7b766 --- /dev/null +++ b/packages/editable/causalHost.test.ts @@ -0,0 +1,736 @@ +// @vitest-environment jsdom + +import { + createCausalPatchInbox, + type CausalPatchInbox, +} from "@interactive-os/json-document-causal-patch-inbox"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createEditableDocument, + EditableDocumentSchema, + type EditorFault, + getJsonEditableDocumentHost, + type JsonEditable, + mountJsonEditable, +} from "./index"; + +type Fixture = ReturnType; + +const mountedEditors: JsonEditable[] = []; +const mountedInboxes: CausalPatchInbox[] = []; + +afterEach(() => { + for (const inbox of mountedInboxes.splice(0)) { + inbox.dispose(); + } + for (const editor of mountedEditors.splice(0)) { + editor.destroy(); + } + window.document.body.replaceChildren(); + vi.useRealTimers(); +}); + +function setupEditor(onFault?: (fault: EditorFault) => void) { + const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "causal-host-test", + blocks: [ + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "second" }, + ], + }); + const root = window.document.createElement("div"); + const faults: EditorFault[] = []; + window.document.body.append(root); + const editor = mountJsonEditable({ + root, + document, + onFault: (fault) => { + faults.push(fault); + onFault?.(fault); + }, + }); + mountedEditors.push(editor); + return { document, editor, faults, root }; +} + +function createInbox(fixture: Fixture) { + const inbox = createCausalPatchInbox(fixture.document, { + host: getJsonEditableDocumentHost(fixture.editor), + positionalSchema: EditableDocumentSchema, + }); + mountedInboxes.push(inbox); + return inbox; +} + +function textNode(fixture: Fixture, blockId: string): Text { + const node = fixture.root.querySelector( + `[data-editable-block="${blockId}"] [data-editable-text]`, + )?.firstChild; + if (!(node instanceof Text)) { + throw new Error(`Missing editable Text node for ${blockId}.`); + } + return node; +} + +function setDOMCaret(node: Text, offset: number): void { + const selection = window.getSelection(); + if (selection === null) { + throw new Error("The test DOM does not expose a Selection."); + } + const range = window.document.createRange(); + range.setStart(node, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + +function inputEvent( + type: "beforeinput" | "input", + inputType: string, + options: { data?: string | null; isComposing?: boolean } = {}, +): InputEvent { + return new InputEvent(type, { + bubbles: true, + cancelable: type === "beforeinput", + data: options.data, + inputType, + isComposing: options.isComposing ?? false, + }); +} + +describe("editable causal document host", () => { + it("rebases a delayed positional edit after an owned local insertion", () => { + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const baseRevision = inbox.current().journalRevision; + + expect(baseRevision).toBe(0); + expect( + fixture.editor.dispatch({ + type: "patch", + patch: [ + { + op: "add", + path: "/blocks/0", + value: { id: "local", type: "paragraph", text: "local" }, + }, + ], + }), + ).toMatchObject({ ok: true, change: "document" }); + expect(inbox.current().journalRevision).toBe(1); + + const result = inbox.ingest({ + id: "delayed-beta", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision, + operations: [ + { + op: "replace", + path: "/blocks/1/text", + value: "Reviewed", + }, + ], + selectionAfter: { path: "/blocks/1/text", offset: 4 }, + }, + }); + + expect(result).toMatchObject({ + ok: true, + applied: ["delayed-beta"], + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: "pointer_shifted", + pointer: "/blocks/1/text", + rebasedPointer: "/blocks/2/text", + }), + ]), + }); + expect(fixture.document.value.blocks).toEqual([ + { id: "local", type: "paragraph", text: "local" }, + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "Reviewed" }, + ]); + expect(fixture.document.selection?.primaryRange).toEqual({ + anchor: { path: "/blocks/2/text", offset: 4 }, + focus: { path: "/blocks/2/text", offset: 4 }, + }); + const beta = textNode(fixture, "beta"); + expect(window.getSelection()?.focusNode).toBe(beta); + expect(window.getSelection()?.focusOffset).toBe(4); + expect(inbox.current()).toMatchObject({ + status: "active", + journalRevision: 2, + frontier: ["delayed-beta"], + }); + expect(fixture.faults).toEqual([]); + }); + + it("lets another causal inbox journal a ready publication as host-owned", () => { + const fixture = setupEditor(); + const applyingInbox = createInbox(fixture); + const observingInbox = createInbox(fixture); + + expect( + applyingInbox.ingest({ + id: "from-first-inbox", + dependsOn: [], + operations: [ + { op: "replace", path: "/blocks/0/text", value: "shared" }, + ], + }), + ).toMatchObject({ ok: true, applied: ["from-first-inbox"] }); + + expect(observingInbox.current()).toMatchObject({ + status: "active", + journalRevision: 1, + frontier: [], + }); + expect(fixture.faults).toEqual([]); + }); + + it("journals pending native input, defers for composition, then retries when idle", async () => { + vi.useFakeTimers(); + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const composingNode = textNode(fixture, "alpha"); + setDOMCaret(composingNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + composingNode.insertData(2, "한"); + setDOMCaret(composingNode, 3); + + let retryResult: ReturnType | undefined; + let retryScheduled = false; + const unsubscribe = fixture.editor.subscribe((snapshot) => { + if ( + retryResult === undefined && + !retryScheduled && + snapshot.phase === "idle" && + snapshot.queuedChanges === 0 + ) { + retryScheduled = true; + queueMicrotask(() => { + retryResult = inbox.ingest([]); + }); + } + }); + + const deferred = inbox.ingest({ + id: "after-composition", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { + op: "replace", + path: "/blocks/1/text", + value: "after IME", + }, + ], + }, + }); + + expect(deferred).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "after-composition", + }); + expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); + expect(inbox.current()).toMatchObject({ + journalRevision: 1, + queued: [{ id: "after-composition", missing: [] }], + }); + expect(textNode(fixture, "alpha")).toBe(composingNode); + + fixture.root.dispatchEvent( + inputEvent("input", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + fixture.root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ); + await vi.advanceTimersByTimeAsync(31); + + expect(retryResult).toMatchObject({ + ok: true, + applied: ["after-composition"], + }); + expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ + "ab한cdef", + "after IME", + ]); + expect(inbox.current()).toMatchObject({ + status: "active", + journalRevision: 2, + queued: [], + }); + expect(fixture.faults).toEqual([]); + unsubscribe(); + }); + + it("defers the turn when flushing a damaged composition makes the editor idle", async () => { + vi.useFakeTimers(); + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const pinnedNode = textNode(fixture, "alpha"); + setDOMCaret(pinnedNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + const replacement = window.document.createTextNode(pinnedNode.data); + pinnedNode.parentNode?.replaceChild(replacement, pinnedNode); + + const deferred = inbox.ingest({ + id: "after-damaged-composition", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "recovered" }, + ], + }, + }); + + expect(deferred).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "after-damaged-composition", + }); + expect(fixture.editor.getSnapshot()).toMatchObject({ + phase: "idle", + composition: null, + }); + expect(fixture.document.value.blocks[1]?.text).toBe("second"); + + expect(inbox.ingest([])).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "after-damaged-composition", + }); + let releaseRetry: ReturnType | undefined; + let retryOnRelease = true; + fixture.editor.subscribe((snapshot) => { + if ( + retryOnRelease && + releaseRetry === undefined && + snapshot.phase === "idle" + ) { + releaseRetry = inbox.ingest([]); + } + }); + fixture.root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + expect(releaseRetry).toBeUndefined(); + expect(fixture.document.value.blocks[1]?.text).toBe("second"); + await Promise.resolve(); + expect(releaseRetry).toBeUndefined(); + expect(fixture.editor.getSnapshot().phase).toBe("settling"); + await vi.advanceTimersByTimeAsync(31); + retryOnRelease = false; + expect(releaseRetry).toMatchObject({ + ok: true, + applied: ["after-damaged-composition"], + }); + expect(fixture.document.value.blocks[1]?.text).toBe("recovered"); + }); + + it("does not apply when the editor is destroyed during the readiness flush", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const pinnedNode = textNode(fixture, "alpha"); + setDOMCaret(pinnedNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + let armed = false; + fixture.editor.subscribe((snapshot) => { + if (armed && snapshot.phase === "idle") { + fixture.editor.destroy(); + } + }); + armed = true; + pinnedNode.parentNode?.replaceChild( + window.document.createTextNode(pinnedNode.data), + pinnedNode, + ); + const apply = vi.fn(); + + expect(() => + documentHost.runReady({ id: "destroyed-during-flush", apply }), + ).toThrow("destroyed"); + expect(apply).not.toHaveBeenCalled(); + }); + + it("does not let a damaged-session settle timer end a new composition", async () => { + vi.useFakeTimers(); + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const node = textNode(fixture, "alpha"); + setDOMCaret(node, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + node.parentNode?.replaceChild( + window.document.createTextNode(node.data), + node, + ); + expect( + inbox.ingest({ + id: "wait-for-new-composition", + dependsOn: [], + operations: [ + { op: "replace", path: "/blocks/1/text", value: "later" }, + ], + }), + ).toMatchObject({ ok: false, code: "host_not_ready" }); + fixture.root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + + const nextNode = textNode(fixture, "alpha"); + setDOMCaret(nextNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + await vi.advanceTimersByTimeAsync(31); + + expect(fixture.editor.getSnapshot()).toMatchObject({ + phase: "composing", + composition: { blockId: "alpha" }, + }); + expect(textNode(fixture, "alpha")).toBe(nextNode); + expect(fixture.document.value.blocks[1]?.text).toBe("second"); + }); + + it("does not apply reentrantly from a native beforeinput subscriber", () => { + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + let armed = false; + let nestedResult: ReturnType | undefined; + fixture.editor.subscribe(() => { + if (!armed || nestedResult !== undefined) { + return; + } + nestedResult = inbox.ingest({ + id: "during-beforeinput", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "delayed" }, + ], + }, + }); + }); + setDOMCaret(textNode(fixture, "alpha"), 1); + armed = true; + + expect( + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertText", { data: "X" }), + ), + ).toBe(false); + + expect(nestedResult).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "during-beforeinput", + }); + expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ + "aXbcdef", + "second", + ]); + expect(inbox.ingest([])).toMatchObject({ + ok: true, + applied: ["during-beforeinput"], + }); + expect(fixture.document.value.blocks[1]?.text).toBe("delayed"); + }); + + it("assigns owned publication sequences before commit and rejects raw writes", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const ownerships: Array = []; + let reentrantResult: + | ReturnType + | undefined; + const nestedApply = vi.fn(); + fixture.document.subscribe((operations, metadata) => { + ownerships.push( + documentHost.ownsPublication({ operations, metadata }), + ); + reentrantResult ??= documentHost.runReady({ + id: "nested", + apply: nestedApply, + }); + }); + + expect( + fixture.editor.dispatch({ + type: "replaceText", + blockId: "alpha", + from: 0, + to: 1, + text: "A", + }).ok, + ).toBe(true); + expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); + expect(fixture.editor.dispatch({ type: "redo" }).ok).toBe(true); + expect(fixture.editor.dispatch({ type: "reset" }).ok).toBe(true); + + const ownedSequences = ownerships.map((ownership) => { + if (ownership === false) { + throw new Error("Expected a coordinator-owned publication."); + } + return ownership.sequence; + }); + expect(ownedSequences).toHaveLength(4); + expect( + ownedSequences.every( + (sequence, index) => index === 0 || sequence > ownedSequences[index - 1]!, + ), + ).toBe(true); + expect(reentrantResult).toMatchObject({ + ok: false, + code: "host_not_ready", + }); + expect(nestedApply).not.toHaveBeenCalled(); + expect( + documentHost.ownsPublication({ operations: [] }), + ).toBe(false); + + expect( + fixture.document.commit([ + { op: "replace", path: "/blocks/0/text", value: "raw" }, + ]), + ).toEqual({ ok: true }); + expect(ownerships.at(-1)).toBe(false); + expect(fixture.faults).toContainEqual( + expect.objectContaining({ code: "out_of_band_document_write" }), + ); + }); + + it("journals an owned publication even when an editor subscriber throws", () => { + const fixture = setupEditor(() => { + throw new Error("fault observer failed"); + }); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const subscriberFailure = new Error("subscriber failed"); + fixture.editor.subscribe(() => { + throw subscriberFailure; + }); + + expect(() => + fixture.editor.dispatch({ + type: "patch", + patch: [ + { + op: "add", + path: "/blocks/0", + value: { id: "leading", type: "paragraph", text: "leading" }, + }, + ], + }), + ).not.toThrow(); + expect(inbox.current().journalRevision).toBe(1); + + expect( + inbox.ingest({ + id: "after-throwing-subscriber", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "correct" }, + ], + }, + }), + ).toMatchObject({ ok: true, applied: ["after-throwing-subscriber"] }); + expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ + "leading", + "abcdef", + "correct", + ]); + expect(fixture.faults).toContainEqual( + expect.objectContaining({ code: "subscriber_failed" }), + ); + }); + + it("propagates ready apply failures without losing an already published DOM change", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const readyOwnerships: Array = []; + fixture.document.subscribe((operations, metadata) => { + readyOwnerships.push( + documentHost.ownsPublication({ operations, metadata }), + ); + }); + fixture.document.selection?.collapse({ + path: "/blocks/0/text", + offset: 1, + }); + const outside = window.document.createElement("button"); + window.document.body.append(outside); + outside.focus(); + const beforeFailure = new Error("before publication"); + expect(() => + documentHost.runReady({ + id: "before", + apply() { + throw beforeFailure; + }, + }), + ).toThrow(beforeFailure); + expect(fixture.document.value.blocks[0]?.text).toBe("abcdef"); + expect(window.document.activeElement).toBe(outside); + + const afterFailure = new Error("after publication"); + expect(() => + documentHost.runReady({ + id: "after", + apply() { + expect( + fixture.document.commit( + [{ op: "replace", path: "/blocks/0/text", value: "published" }], + { mergeKey: "after", origin: "causal-test" }, + ), + ).toEqual({ ok: true }); + throw afterFailure; + }, + }), + ).toThrow(afterFailure); + expect(fixture.document.value.blocks[0]?.text).toBe("published"); + expect(textNode(fixture, "alpha").data).toBe("published"); + expect(readyOwnerships).toEqual([{ sequence: 2 }]); + expect(fixture.faults).toEqual([]); + + expect( + fixture.editor.dispatch({ + type: "replaceText", + blockId: "beta", + from: 0, + to: 0, + text: "still usable ", + }).ok, + ).toBe(true); + }); + + it("restores DOM selection after a ready test-only commit", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const publications = vi.fn(); + fixture.document.subscribe(publications); + const outside = window.document.createElement("button"); + window.document.body.append(outside); + outside.focus(); + + expect( + documentHost.runReady({ + id: "test-only", + apply() { + expect( + fixture.document.commit( + [{ op: "test", path: "/blocks/1/id", value: "beta" }], + { mergeKey: "test-only", origin: "causal-test" }, + ), + ).toEqual({ ok: true }); + }, + }), + ).toEqual({ ok: true }); + expect(window.document.activeElement).toBe(outside); + + expect( + documentHost.runReady({ + id: "selection-only", + apply() { + expect( + fixture.document.commit( + [{ op: "test", path: "/blocks/1/id", value: "beta" }], + { + mergeKey: "selection-only", + origin: "causal-test", + selectionAfter: { path: "/blocks/1/text", offset: 3 }, + }, + ), + ).toEqual({ ok: true }); + }, + }), + ).toEqual({ ok: true }); + + expect(publications).not.toHaveBeenCalled(); + expect(fixture.document.selection?.primaryRange).toEqual({ + anchor: { path: "/blocks/1/text", offset: 3 }, + focus: { path: "/blocks/1/text", offset: 3 }, + }); + expect(window.getSelection()?.focusNode).toBe(textNode(fixture, "beta")); + expect(window.getSelection()?.focusOffset).toBe(3); + }); + + it("isolates an earlier editor observer and restores selection", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const alpha = textNode(fixture, "alpha"); + setDOMCaret(alpha, 1); + fixture.document.selection?.collapse({ + path: "/blocks/0/text", + offset: 1, + }); + const observerFailure = new Error("observer failed"); + fixture.editor.subscribe(() => { + throw observerFailure; + }); + + expect( + documentHost.runReady({ + id: "selection-observer-failure", + apply() { + fixture.document.commit( + [{ op: "test", path: "/blocks/1/id", value: "beta" }], + { + mergeKey: "selection-observer-failure", + origin: "causal-test", + selectionAfter: { path: "/blocks/1/text", offset: 2 }, + }, + ); + }, + }), + ).toEqual({ ok: true }); + + expect(fixture.document.selection?.primaryRange).toEqual({ + anchor: { path: "/blocks/1/text", offset: 2 }, + focus: { path: "/blocks/1/text", offset: 2 }, + }); + expect(window.getSelection()?.focusNode).toBe(textNode(fixture, "beta")); + expect(window.getSelection()?.focusOffset).toBe(2); + expect(fixture.faults).toContainEqual( + expect.objectContaining({ code: "subscriber_failed" }), + ); + }); +}); diff --git a/packages/editable/editor.ts b/packages/editable/editor.ts index eb4f59f..c0283a5 100644 --- a/packages/editable/editor.ts +++ b/packages/editable/editor.ts @@ -1,4 +1,4 @@ -export { mountJsonEditable } from "./browser"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./browser"; export type { EditorAction, EditorFault, @@ -6,5 +6,6 @@ export type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./browser"; diff --git a/packages/editable/index.ts b/packages/editable/index.ts index f29492b..49b3545 100644 --- a/packages/editable/index.ts +++ b/packages/editable/index.ts @@ -15,7 +15,7 @@ export type { EditablePoint, OrderedEditableSelection, } from "./browser"; -export { mountJsonEditable } from "./browser"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./browser"; export type { EditorAction, EditorFault, @@ -23,5 +23,6 @@ export type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./browser"; diff --git a/packages/editable/publicApi.test.ts b/packages/editable/publicApi.test.ts index 88822f3..0ef40e2 100644 --- a/packages/editable/publicApi.test.ts +++ b/packages/editable/publicApi.test.ts @@ -1,4 +1,5 @@ import type { + JSONChangeMetadata, JSONDocument, JSONPatchOperation, Pointer, @@ -18,9 +19,11 @@ import type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, OrderedEditableSelection, } from "./index"; +import { getJsonEditableDocumentHost } from "./index"; import * as PublicAPI from "./index"; type ExpectedBlockType = "paragraph" | "heading" | "quote" | "code"; @@ -55,7 +58,8 @@ type ExpectedFault = { | "input_state_lost" | "composition_overlap" | "composition_conflict" - | "queued_change_commit_failed"; + | "queued_change_commit_failed" + | "subscriber_failed"; recoverable: boolean; reason: string; }; @@ -109,6 +113,22 @@ type ExpectedResult = | "commit_failed"; reason: string; }; +type ExpectedDocumentHost = { + ownsPublication(publication: { + operations: ReadonlyArray; + metadata?: JSONChangeMetadata; + }): false | { sequence: number }; + runReady(request: { + id: string; + apply(): void; + }): + | { ok: true } + | { + ok: false; + code: "host_not_ready"; + reason: string; + }; +}; type ExpectedEditor = { dispatch(action: ExpectedAction): ExpectedResult; getSnapshot(): ExpectedSnapshot; @@ -130,6 +150,7 @@ describe("editable public API", () => { "editableBlockIndexFromTextPath", "editableTextPath", "findEditableBlockIndex", + "getJsonEditableDocumentHost", "mountJsonEditable", "orderedEditableSelection", "primaryEditablePoint", @@ -149,6 +170,7 @@ describe("editable public API", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf< MountJsonEditableOptions @@ -172,6 +194,9 @@ describe("editable public API", () => { expectTypeOf(PublicAPI.findEditableBlockIndex).toEqualTypeOf< (value: ExpectedDocument, blockId: string) => number >(); + expectTypeOf(getJsonEditableDocumentHost).toEqualTypeOf< + (editor: ExpectedEditor) => ExpectedDocumentHost + >(); expectTypeOf(PublicAPI.primaryEditablePoint).toEqualTypeOf< ( value: ExpectedDocument, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c9e35..2f5afab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@interactive-os/json-document-patch-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz + '@interactive-os/json-document-stable-id-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz + importers: .: @@ -11,6 +15,12 @@ importers: '@interactive-os/json-document': specifier: 1.1.0-rc.0 version: 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + '@interactive-os/json-document-causal-patch-inbox': + specifier: file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz + version: file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + '@interactive-os/json-document-id-resolver': + specifier: file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz + version: file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) '@tanstack/react-router': specifier: ^1.170.16 version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -276,6 +286,31 @@ packages: '@noble/hashes': optional: true + '@interactive-os/json-document-causal-patch-inbox@file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz': + resolution: {integrity: sha512-oGkLQJOvcAV1j05+XjKQxatp4ks7aar6LbdKh/g2cdDv3sQFz+kRNSStyJOxsHgqFLypMREV4WhSafGuCR8lUg==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + + '@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz': + resolution: {integrity: sha512-rEDUwGPn7syeNLWDgM+40Klu/5qUWk/GWDieeUZRS+MYVN7lBghRPb1+MRAhdKfBRTUHLWek/R1DKweG+TBLKQ==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + + '@interactive-os/json-document-patch-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz': + resolution: {integrity: sha512-6ZZXG/6tB4DZns11lEo5yIkMVnS4m/+SVcb9tHBKCNWbTHP9zX8x9ahlrSL6kzfoEbcFXNzJPj73+tqzPZbY2Q==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + + '@interactive-os/json-document-stable-id-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz': + resolution: {integrity: sha512-d7wp2R8AKWyQjVEAjw9EHGePz/Jyj7zmddR3Fy9lCWEA1wgxyzEyBnI5DhAerFeUCNBFFVmghLgi14xaMmD0Bw==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + '@interactive-os/json-document-id-resolver': ^0.1.0 + '@interactive-os/json-document@1.1.0-rc.0': resolution: {integrity: sha512-gC88ujdx95iCQWQBzkcNHRL76pGmNbjgVX5tSyawok+HKYfIp8b2pIQWEJNKm1kERARwX2CQLDo+fg6MgFVPFg==} peerDependencies: @@ -1497,6 +1532,27 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@interactive-os/json-document-causal-patch-inbox@file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + '@interactive-os/json-document-patch-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + '@interactive-os/json-document-stable-id-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + transitivePeerDependencies: + - '@interactive-os/json-document-id-resolver' + + '@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + + '@interactive-os/json-document-patch-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + + '@interactive-os/json-document-stable-id-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + '@interactive-os/json-document-id-resolver': file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + '@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)': dependencies: zod: 4.4.3 diff --git a/src/editable-lab/JsonEditableDemo.tsx b/src/editable-lab/JsonEditableDemo.tsx index 594cd1b..62dd80b 100644 --- a/src/editable-lab/JsonEditableDemo.tsx +++ b/src/editable-lab/JsonEditableDemo.tsx @@ -24,14 +24,17 @@ import { type JsonEditable, mountJsonEditable, } from "../../packages/editable"; +import { createEditableCausalInbox } from "./causalDocumentInbox"; type EditableDocument = ReturnType; +type EditableCausalInbox = ReturnType; declare global { interface Window { __jsonEditableLab?: { document: EditableDocument; editor: JsonEditable; + causalInbox: EditableCausalInbox; }; } } @@ -40,6 +43,8 @@ export function JsonEditableDemo() { const document = useMemo(() => createEditableDocument(), []); const rootRef = useRef(null); const editorRef = useRef(null); + const causalInboxRef = useRef(null); + const causalTracerSequenceRef = useRef(0); const [snapshot, setSnapshot] = useState(null); const [lastFault, setLastFault] = useState(null); @@ -54,18 +59,22 @@ export function JsonEditableDemo() { document, onFault: setLastFault, }); + const causalInbox = createEditableCausalInbox(document, editor); editorRef.current = editor; + causalInboxRef.current = causalInbox; setSnapshot(editor.getSnapshot()); const unsubscribe = editor.subscribe(setSnapshot); - window.__jsonEditableLab = { document, editor }; + window.__jsonEditableLab = { document, editor, causalInbox }; return () => { if (window.__jsonEditableLab?.editor === editor) { delete window.__jsonEditableLab; } unsubscribe(); + causalInbox.dispose(); editor.destroy(); editorRef.current = null; + causalInboxRef.current = null; }; }, [document]); @@ -112,6 +121,73 @@ export function JsonEditableDemo() { }); }, []); + const runCausalTracer = useCallback(() => { + const editor = editorRef.current; + const inbox = causalInboxRef.current; + const editorSnapshot = editor?.getSnapshot(); + const inboxSnapshot = inbox?.current(); + const block = [...document.value.blocks] + .reverse() + .find( + (candidate) => candidate.id !== editorSnapshot?.composition?.blockId, + ); + if ( + editor === null || + inbox === null || + block === undefined || + inboxSnapshot?.journalRevision === undefined + ) { + return; + } + + causalTracerSequenceRef.current += 1; + const sequence = causalTracerSequenceRef.current; + const base = document.value; + const blockIndex = base.blocks.findIndex( + (candidate) => candidate.id === block.id, + ); + if (blockIndex < 0) { + return; + } + if (editorSnapshot?.composition === null) { + const local = editor.dispatch({ + type: "patch", + patch: [ + { + op: "add", + path: "/blocks/0", + value: { + id: `causal-local-${sequence}`, + type: "paragraph", + text: `로컬 선행 변경 ${sequence}`, + }, + }, + ], + label: "causal tracer local insertion", + }); + if (!local.ok) { + return; + } + } + + inbox.ingest({ + id: `causal-delayed-${sequence}`, + dependsOn: inboxSnapshot.frontier, + intent: { + kind: "positional", + base, + baseRevision: inboxSnapshot.journalRevision, + operations: [ + { + op: "replace", + path: `/blocks/${blockIndex}/text`, + value: `${block.text} · 지연 변경 ${sequence}`, + }, + ], + }, + }); + }, [document]); + return (
현재 조합과 충돌 + {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: mountJsonEditable assigns the empty host's editing semantics. */} @@ -197,6 +280,10 @@ export function JsonEditableDemo() { }} /> +
diff --git a/src/editable-lab/causalDocumentInbox.test.ts b/src/editable-lab/causalDocumentInbox.test.ts new file mode 100644 index 0000000..55ca8f8 --- /dev/null +++ b/src/editable-lab/causalDocumentInbox.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createEditableDocument, + type JsonEditable, + mountJsonEditable, +} from "../../packages/editable"; +import { createEditableCausalInbox } from "./causalDocumentInbox"; + +let editor: JsonEditable | null = null; + +afterEach(() => { + editor?.destroy(); + editor = null; + window.document.body.replaceChildren(); + vi.useRealTimers(); +}); + +describe("editable lab causal inbox", () => { + it("retries a deferred envelope in a microtask after composition settles", async () => { + vi.useFakeTimers(); + const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "causal-lab-test", + blocks: [ + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "second" }, + ], + }); + const root = window.document.createElement("div"); + window.document.body.append(root); + editor = mountJsonEditable({ root, document }); + const inbox = createEditableCausalInbox(document, editor); + const base = document.value; + const node = root.querySelector( + '[data-editable-block="alpha"] [data-editable-text]', + )?.firstChild; + if (!(node instanceof Text)) { + throw new Error("Missing alpha Text node."); + } + setDOMCaret(node, 2); + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + + const result = inbox.ingest({ + id: "delayed", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "settled" }, + ], + }, + }); + expect(result).toMatchObject({ ok: false, code: "host_not_ready" }); + + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + await vi.advanceTimersByTimeAsync(31); + + expect(document.value.blocks[1]?.text).toBe("settled"); + expect(inbox.current()).toMatchObject({ + status: "active", + frontier: ["delayed"], + queued: [], + }); + inbox.dispose(); + }); + + it("does not retry a recovered pin until the browser composition ends", async () => { + vi.useFakeTimers(); + const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "causal-recovery-test", + blocks: [ + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "second" }, + ], + }); + const root = window.document.createElement("div"); + window.document.body.append(root); + editor = mountJsonEditable({ root, document }); + const inbox = createEditableCausalInbox(document, editor); + const base = document.value; + const node = root.querySelector( + '[data-editable-block="alpha"] [data-editable-text]', + )?.firstChild; + if (!(node instanceof Text)) { + throw new Error("Missing alpha Text node."); + } + setDOMCaret(node, 2); + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + node.parentNode?.replaceChild( + window.document.createTextNode(node.data), + node, + ); + + expect( + inbox.ingest({ + id: "after-recovery", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "released" }, + ], + }, + }), + ).toMatchObject({ ok: false, code: "host_not_ready" }); + await Promise.resolve(); + expect(document.value.blocks[1]?.text).toBe("second"); + + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + await Promise.resolve(); + expect(document.value.blocks[1]?.text).toBe("second"); + await vi.advanceTimersByTimeAsync(31); + + expect(document.value.blocks[1]?.text).toBe("released"); + expect(inbox.current()).toMatchObject({ + status: "active", + frontier: ["after-recovery"], + queued: [], + }); + inbox.dispose(); + }); +}); + +function setDOMCaret(node: Text, offset: number): void { + const selection = window.getSelection(); + if (selection === null) { + throw new Error("The test DOM does not expose a Selection."); + } + const range = window.document.createRange(); + range.setStart(node, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} diff --git a/src/editable-lab/causalDocumentInbox.ts b/src/editable-lab/causalDocumentInbox.ts new file mode 100644 index 0000000..33f5861 --- /dev/null +++ b/src/editable-lab/causalDocumentInbox.ts @@ -0,0 +1,103 @@ +import type { JSONDocument } from "@interactive-os/json-document"; +import { + type CausalPatchInbox, + createCausalPatchInbox, +} from "@interactive-os/json-document-causal-patch-inbox"; +import { + EditableDocumentSchema, + type EditableDocumentValue, + getJsonEditableDocumentHost, + type JsonEditable, +} from "../../packages/editable"; + +export function createEditableCausalInbox( + document: JSONDocument, + editor: JsonEditable, +): CausalPatchInbox { + const inbox = createCausalPatchInbox(document, { + host: getJsonEditableDocumentHost(editor), + positionalSchema: EditableDocumentSchema, + stableIdScopes: [ + { + scope: "editable-block", + query: "/blocks/*", + readId: (value) => readBlockId(value), + }, + ], + }); + let disposed = false; + let retryPending = false; + let retryScheduled = false; + let lastAttemptedRevision: number | null = null; + + const scheduleRetry = ( + snapshot: ReturnType, + allowCurrentRevision = false, + ): void => { + if ( + disposed || + !retryPending || + retryScheduled || + snapshot.phase !== "idle" || + snapshot.queuedChanges !== 0 || + (!allowCurrentRevision && lastAttemptedRevision === snapshot.revision) + ) { + return; + } + retryScheduled = true; + queueMicrotask(() => { + retryScheduled = false; + if (disposed || !retryPending) { + return; + } + const latest = editor.getSnapshot(); + if (latest.phase !== "idle" || latest.queuedChanges !== 0) { + return; + } + retryPending = false; + lastAttemptedRevision = latest.revision; + const result = inbox.ingest([]); + if (!result.ok && result.code === "host_not_ready") { + retryPending = true; + } + }); + }; + + const stopEditorSubscription = editor.subscribe((snapshot) => { + scheduleRetry(snapshot); + }); + + return { + ingest(input) { + const result = inbox.ingest(input); + if (!result.ok && result.code === "host_not_ready") { + retryPending = true; + scheduleRetry(editor.getSnapshot(), true); + } else if (result.ok) { + retryPending = false; + } + return result; + }, + current: () => inbox.current(), + dispose() { + if (disposed) { + return; + } + disposed = true; + stopEditorSubscription(); + inbox.dispose(); + }, + }; +} + +function readBlockId(value: unknown): string | null { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" + ) { + return null; + } + return value.id; +} diff --git a/tests/browser/editable.spec.ts b/tests/browser/editable.spec.ts index 2480a3d..ca0eb9a 100644 --- a/tests/browser/editable.spec.ts +++ b/tests/browser/editable.spec.ts @@ -129,6 +129,29 @@ test("ordinary browser Enter splits the selected block through the model", async await expect(lastFault(page)).toHaveText("null"); }); +test("the delayed-edit tracer rebases across its local leading insertion", async ({ + page, +}) => { + const initialCount = (await readDocumentBlocks(page)).length; + + await page.getByRole("button", { name: "지연 편집 추적" }).click(); + + await expect.poll(() => readDocumentBlocks(page)).toHaveLength(initialCount + 1); + const blocks = await readDocumentBlocks(page); + expect(blocks[0]).toEqual({ + id: "causal-local-1", + type: "paragraph", + text: "로컬 선행 변경 1", + }); + expect(blocks.find((block) => block.id === "render-rule")?.text).toBe( + "renderOutside(compositionIsland) · 지연 변경 1", + ); + await expect(blockSurface(page, "render-rule")).toHaveText( + "renderOutside(compositionIsland) · 지연 변경 1", + ); + await expect(lastFault(page)).toHaveText("null"); +}); + // These tests dispatch untrusted CompositionEvent/InputEvent objects in a real // browser page and manually perform the DOM mutation a browser would perform. // They validate the coordinator protocol, but they do not create an OS IME diff --git a/vendor/json-document-e8d572d4/README.md b/vendor/json-document-e8d572d4/README.md new file mode 100644 index 0000000..405b52e --- /dev/null +++ b/vendor/json-document-e8d572d4/README.md @@ -0,0 +1,28 @@ +# json-document dogfood artifacts + +These packages were built from merged `json-document` commit +`e8d572d4af1d6570dde2783972e40b9f914236c2` (PR #232). They keep this +integration tracer independent from an npm release and make the exact upstream +code under test reviewable. + +## Rebuild + +From a clean checkout of that commit in `../json-document`: + +```sh +npm ci +npm run build +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./labs/extensions/causal-patch-inbox +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./packages/id-resolver +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./labs/extensions/patch-rebase +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./labs/extensions/stable-id-rebase +``` + +## SHA-256 + +```text +7fae978a0449b6d7e705be959703edad17c16b4d67868ee8d18498302daecf04 interactive-os-json-document-causal-patch-inbox-0.1.0.tgz +d0787346d5a566fbbf4847343f519f761ba25e9a79762663167eaba7413ce8bf interactive-os-json-document-id-resolver-0.1.0.tgz +88a20dd7d755b2befb365e74fffce860573abcc392eb74c9aa44976fb9578b64 interactive-os-json-document-patch-rebase-0.1.0.tgz +5e246ee1b82c06598b1a89ac0f85cea9e826d1730577c04f264519aa93d8512a interactive-os-json-document-stable-id-rebase-0.1.0.tgz +``` diff --git a/vendor/json-document-e8d572d4/SHA256SUMS b/vendor/json-document-e8d572d4/SHA256SUMS new file mode 100644 index 0000000..55c6963 --- /dev/null +++ b/vendor/json-document-e8d572d4/SHA256SUMS @@ -0,0 +1,4 @@ +7fae978a0449b6d7e705be959703edad17c16b4d67868ee8d18498302daecf04 interactive-os-json-document-causal-patch-inbox-0.1.0.tgz +d0787346d5a566fbbf4847343f519f761ba25e9a79762663167eaba7413ce8bf interactive-os-json-document-id-resolver-0.1.0.tgz +88a20dd7d755b2befb365e74fffce860573abcc392eb74c9aa44976fb9578b64 interactive-os-json-document-patch-rebase-0.1.0.tgz +5e246ee1b82c06598b1a89ac0f85cea9e826d1730577c04f264519aa93d8512a interactive-os-json-document-stable-id-rebase-0.1.0.tgz diff --git a/vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz b/vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..275d1b200913a818723a7d5b84affe273ede5c8e GIT binary patch literal 16730 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 YPq%i}_P70Qf1mmLKUEDxsQ|tJ050wZB>(^b literal 0 HcmV?d00001 diff --git a/vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz b/vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..a816487a9ef8b8bbb4731717dc239171d7c023c7 GIT binary patch literal 5950 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 literal 0 HcmV?d00001