diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bdd4bb7e..fc56747d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,9 +29,9 @@ jobs:
- name: Install dependencies
if: steps.scope.outputs.package_any == 'true'
run: npm ci --no-audit --no-fund
- - name: Verify v2 package
+ - name: Verify active v2 packages
if: steps.scope.outputs.package_full == 'true'
- run: npm run archive:check && npm run verify -w @interactive-os/json-document && npm run standard:check && npm run docs:evaluate
+ run: npm run archive:check && npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate
- name: Verify package documentation
if: steps.scope.outputs.package_full != 'true' && (steps.scope.outputs.package_docs == 'true' || steps.scope.outputs.package_smoke == 'true')
run: |
@@ -45,7 +45,9 @@ jobs:
if: steps.scope.outputs.package_full == 'true' || steps.scope.outputs.package_smoke == 'true'
env:
npm_config_cache: ${{ runner.temp }}/npm-cache
- run: npm pack -w @interactive-os/json-document --dry-run
+ run: |
+ npm run pack:library
+ npm run pack:companions
lab-extensions:
name: Archive isolation
diff --git a/README.md b/README.md
index b4a1ab4c..9bbf673d 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,17 @@ v2 root는 JSON, JSON Pointer, JSONPath, JSON Patch만 전제로 하며 Zod, Rea
selection, clipboard, history를 필수 계약에 넣지 않습니다.
```txt
-Pure Protocol -> Document Projection -> host adapter
+Pure Protocol
+ |-> local provider -----------\
+ | > same six-member Document Projection
+ `-> collaboration provider --/ |-> optional history/text authoring
+ `-> optional DOM/IME lease
```
+로컬 전용 사용자는 Core만 설치합니다. 협업으로 전환해도 편집기가 받는
+`JSONDocument` 포트는 바뀌지 않고, causal merge와 DOM publication lease만
+독립 package로 추가합니다.
+
공식 사이트: https://developer-1px.github.io/json-document/
## 문서 지도
@@ -30,14 +38,17 @@ Pure Protocol -> Document Projection -> host adapter
| 위치 | 역할 |
| --- | --- |
| [packages/json-document](packages/json-document) | 배포되는 v2 Kernel |
+| [packages/json-document-collaboration](packages/json-document-collaboration) | transport-free causal multi-writer provider |
+| [packages/contenteditable-collaboration](packages/contenteditable-collaboration) | collaborative string을 위한 optional DOM/IME publication lease |
| [apps/site](apps/site) | v2 Core 공개 문서 사이트 |
| [archive/v1](archive/v1) | 배포·workspace·검증에서 분리된 1.x 기록 |
-이 저장소가 배포하는 package는 `@interactive-os/json-document` 하나입니다.
-Selection, history, clipboard, persistence와 DOM lifecycle은 host adapter가
-여섯-member `JSONDocument` 위에서 조합합니다. DOM과 Input Events 정규화가
-필요한 제품은 별도 수명 주기를 가진 companion `@interactive-os/editable`을
-검토할 수 있으며, companion은 이 저장소의 배포물이나 v2 Core API가 아닙니다.
+v2 Kernel release는 `@interactive-os/json-document` 하나이며 dependency-free
+Core로 남습니다. 두 collaboration package는 독립 version과 release lifecycle을
+가진 optional companion입니다. Selection, clipboard, persistence와 제품별 DOM
+lifecycle은 host adapter가 여섯-member `JSONDocument` 위에서 조합합니다.
+일반 DOM과 Input Events 정규화가 필요한 제품은 별도 수명 주기의
+`@interactive-os/editable`도 검토할 수 있습니다.
## 경계
diff --git a/docs/README.md b/docs/README.md
index 2cc4e65e..06ae451d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,7 +8,7 @@
docs
|-- changelog.md # 사용자 영향 중심 변경 기록
|-- generated
-| `-- repo-catalog.json # v2 Core release catalog
+| `-- repo-catalog.json # v2 Core와 optional companion catalog
|-- public
| |-- overview.md # 프로젝트 이해
| |-- quickstart.md # 사용 시작
@@ -34,7 +34,7 @@ language binding입니다. Archive의 1.x 문서는 v2 exact 20-symbol·six-memb
| 위치 | 책임 | 독자 |
| --- | --- | --- |
| `changelog.md` | 사용자 영향 중심 변경 기록 | 외부 사용자, 릴리스 확인자 |
-| `generated/` | v2 Core release 표면으로 만든 reference data. 직접 편집하지 않는다. | evaluator |
+| `generated/` | v2 Core와 명시적으로 등록한 companion 표면으로 만든 reference data. 직접 편집하지 않는다. | evaluator |
| `public/` | 사용법과 프로젝트 이해를 위한 공식 문서 원천 | 외부 사용자, LLM, 사이트 방문자 |
| `standard/` | v2 root 정본 | 표준화 검토자, 대체 구현 작성자 |
@@ -45,7 +45,7 @@ language binding입니다. Archive의 1.x 문서는 v2 exact 20-symbol·six-memb
- public 문서는 usage와 프로젝트 이해만 다룬다.
- 릴리스 history, 검토 loop, maintainer-only gate는 public 문서에 쓰지 않는다.
- 내부 구현 경로는 public 문서에 쓰지 않는다.
-- generated catalog는 `packages/json-document`만 읽으며 archive, app, sibling
- companion을 release package로 추론하지 않는다.
+- generated catalog는 Core와 두 collaboration companion의 exact 경로만 읽으며
+ archive, app, 그 밖의 sibling package를 active package로 추론하지 않는다.
- 새 문서는 기존 책임 폴더 중 하나에 들어가야 한다.
- 새 책임 폴더가 필요하면 먼저 이 파일의 책임 표를 갱신한다.
diff --git a/docs/changelog.md b/docs/changelog.md
index 13393d43..e3a0e116 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,15 +1,27 @@
# Changelog
-All notable changes to the active v2 package are documented here. The complete
+All notable changes to the active v2 packages are documented here. The complete
1.x history is preserved under `archive/v1/docs/changelog.md`.
## Unreleased
+- Added the independently versioned, transport-free
+ `@interactive-os/json-document-collaboration` provider. Local and
+ collaborative runtimes expose the same six-member `JSONDocument` Projection;
+ causal bundles, conflicts, checkpoints, selective history, and text
+ authoring remain optional sidecars.
+- Added
+ `@interactive-os/json-document-contenteditable-collaboration`, an optional
+ publication lease that lets native input and IME composition keep temporary
+ DOM ownership while the collaborative model continues to ingest changes.
+- Kept the Core package dependency-free: local-only consumers install neither
+ collaboration package, and the Kernel API and 20-symbol public surface do not
+ change.
- **Breaking (`2.0.0-rc.0`):** Replaced the package root with the
provider-neutral v2 Kernel: exactly 8 runtime values and 12 public types, with
a non-generic six-member `JSONDocument` projection.
- Removed the 1.x `/session` and `/react` entrypoints, React and Zod peer
- dependencies, and all non-Core packages from the v2 release scope.
+ dependencies, and all 1.x non-Core packages from the v2 release scope.
- Archived the 1.x editing session, extensions, labs, demos, and their
documentation. Archive contents are not compiled, tested, cataloged, or
published as v2-compatible packages.
diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json
index bcb2ef5b..e56eebb8 100644
--- a/docs/generated/repo-catalog.json
+++ b/docs/generated/repo-catalog.json
@@ -16,6 +16,9 @@
"description": "Provider-neutral JSON editing protocol and headless document projection.",
"license": "MIT",
"summary": "문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 provider-neutral\nJSON 편집 protocol과 headless document projection입니다.",
+ "entrypoints": [
+ "."
+ ],
"publicExports": [
"JSONAppliedChange",
"JSONCapabilityResult",
@@ -49,9 +52,102 @@
"jsonpath",
"protocol"
]
+ },
+ {
+ "path": "packages/json-document-collaboration",
+ "name": "@interactive-os/json-document-collaboration",
+ "status": "companion",
+ "private": false,
+ "publishable": true,
+ "version": "0.1.0",
+ "description": "Transport-free causal collaboration provider for @interactive-os/json-document.",
+ "license": "MIT",
+ "summary": "Transport-free causal collaboration provider for the six-member\n`@interactive-os/json-document` Projection contract.",
+ "entrypoints": [
+ ".",
+ "./history",
+ "./text"
+ ],
+ "publicExports": [
+ "ActorId",
+ "ArrayPlacement",
+ "ChangeId",
+ "CollaborationAcceptance",
+ "CollaborationBundle",
+ "CollaborationChange",
+ "CollaborationCheckpoint",
+ "CollaborationCheckpointPayload",
+ "CollaborationCompactionOptions",
+ "CollaborationCompactionReport",
+ "CollaborationCompactionResult",
+ "CollaborationConflict",
+ "CollaborationControl",
+ "CollaborationEpoch",
+ "CollaborationEpochParent",
+ "CollaborationIngestFailure",
+ "CollaborationIngestResult",
+ "CollaborationIngestSuccess",
+ "CollaborationMember",
+ "CollaborationMembership",
+ "CollaborationRestoreOptions",
+ "CollaborationRestoreResult",
+ "CollaborationRulesetIdentity",
+ "CollaborationRuntime",
+ "CollaborationRuntimeOptions",
+ "CollaborationSnapshot",
+ "ContainerNodeId",
+ "MemberId",
+ "MemberPlacement",
+ "ObjectPlacement",
+ "PendingChange",
+ "PositionId",
+ "SemanticOperation",
+ "SuppressedChange",
+ "TextAtomId",
+ "TextNodeId",
+ "TextSpliceOperation",
+ "compactCollaborationCheckpoint",
+ "createCollaborationRuntime",
+ "restoreCollaborationRuntime"
+ ],
+ "publicExportCount": 40,
+ "keywords": []
+ },
+ {
+ "path": "packages/contenteditable-collaboration",
+ "name": "@interactive-os/json-document-contenteditable-collaboration",
+ "status": "companion",
+ "private": false,
+ "publishable": true,
+ "version": "0.1.0",
+ "description": "IME-safe contenteditable publication lease for json-document collaboration text.",
+ "license": "MIT",
+ "summary": "IME-safe DOM publication lease for\n`@interactive-os/json-document-collaboration/text`.",
+ "entrypoints": [
+ "."
+ ],
+ "publicExports": [
+ "CollaborationContentEditableAdapter",
+ "CollaborationContentEditableOptions",
+ "CollaborationContentEditableResult",
+ "CollaborationTextDOM",
+ "CollaborationTextDOMObservation",
+ "createCollaborationContentEditableAdapter",
+ "plainTextCollaborationDOM"
+ ],
+ "publicExportCount": 7,
+ "keywords": [
+ "@interactive-os/json-document",
+ "collaboration",
+ "contenteditable",
+ "ime",
+ "web"
+ ]
}
],
"totals": {
- "packages": 1
+ "packages": 3,
+ "core": 1,
+ "companions": 2
}
}
diff --git a/docs/public/overview.md b/docs/public/overview.md
index 98b63dea..85e6a006 100644
--- a/docs/public/overview.md
+++ b/docs/public/overview.md
@@ -6,7 +6,11 @@ json-document v2는 문서, 표, 슬라이드, 캔버스, 노트 편집기가
React, selection, clipboard, history를 필수 계약에 넣지 않습니다.
```txt
-Pure Protocol -> Document Projection -> host adapter
+Pure Protocol
+ |-> local provider -----------\
+ | > same six-member Document Projection
+ `-> collaboration provider --/ |-> optional history/text authoring
+ `-> optional DOM/IME lease
```
## 배경
@@ -108,18 +112,23 @@ if (result.ok) {
| 표면 | 상태 | 책임 |
| --- | --- | --- |
| `@interactive-os/json-document` | v2 Kernel | Pure Protocol과 여섯-member Projection |
+| `@interactive-os/json-document-collaboration` | optional companion | 같은 Projection 뒤의 transport-free causal merge |
+| `@interactive-os/json-document-contenteditable-collaboration` | optional companion | collaborative string의 DOM/IME publication lease |
패키지는 `/session`과 `/react`를 공개하지 않습니다. 구현 간 교환 가능한 코드는
루트 `JSONDocument` 여섯 member에만 의존하고, 편집 UX와 framework lifecycle은
-host 또는 별도 adapter가 소유합니다.
+host 또는 별도 adapter가 소유합니다. Local-only consumer는 Core만 설치하며,
+collaboration provider로 바꿔도 editor가 사용하는 `JSONDocument` API는
+변하지 않습니다.
## Host adapter와 companion
Host adapter는 공개 `JSONDocument`만 입력으로 받고 제품 의도를 Pointer와
JSON Patch로 번역합니다. Selection, history, clipboard, persistence, focus와
-remote protocol은 Core member를 늘리지 않고 adapter 쪽에 둡니다. Adapter를
-별도 package로 배포할 때는 Core와 독립적으로 version과 compatibility를
-검증해야 합니다.
+remote protocol은 Core member를 늘리지 않고 adapter 쪽에 둡니다.
+Collaboration companion도 transport, authentication, presence, persistence를
+소유하지 않습니다. Adapter와 companion은 Core와 독립적으로 version과
+compatibility를 검증합니다.
`@interactive-os/editable`은 DOM과 Input Events 정규화를 담당하는 별도 companion
예시입니다. `JSONDocument`는 canonical headless JSON state로 남고, editable은
diff --git a/llms.txt b/llms.txt
index e0c6d5c4..ca27ccc4 100644
--- a/llms.txt
+++ b/llms.txt
@@ -43,7 +43,11 @@ React hook, host service를 추가하지 않는다.
## Pure Protocol과 Projection
```txt
-Pure Protocol -> Document Projection -> host adapter
+Pure Protocol
+ |-> local provider -----------\
+ | > same six-member Document Projection
+ `-> collaboration provider --/ |-> optional history/text authoring
+ `-> optional DOM/IME lease
```
Pure Protocol은 JSON value, RFC 6901 JSON Pointer, RFC 9535 JSONPath,
@@ -191,15 +195,24 @@ Minor release에서 optional diagnostic field와 새 code가 추가될 수 있
introspection, DOM과 framework lifecycle은 host 또는 별도 adapter가
여섯-member `JSONDocument`를 조합해 소유한다.
-이 저장소의 v2 release는 Core package 하나만 포함한다. Host adapter는 공개
-Root Projection만 입력으로 받고 제품 의도를 Pointer와 JSON Patch로 번역한다.
-별도 package로 배포하는 adapter는 독립적으로 version과 compatibility를
-검증한다.
+v2 Kernel release는 dependency-free Core package 하나다. Local-only consumer는
+Core만 설치한다. 이 저장소의
+`@interactive-os/json-document-collaboration`은 같은 여섯-member Projection을
+제공하는 independently versioned, transport-free provider이고,
+`@interactive-os/json-document-contenteditable-collaboration`은 collaborative
+string을 위한 optional DOM/IME publication lease다. 두 companion을 사용해도
+Core Root API와 editor가 받는 `JSONDocument` port는 바뀌지 않는다.
+
+Host adapter는 공개 Root Projection만 입력으로 받고 제품 의도를 Pointer와
+JSON Patch로 번역한다. Collaboration companion은 transport, authentication,
+presence, persistence를 소유하지 않는다. 별도 package로 배포하는 adapter와
+companion은 Core와 독립적으로 version과 compatibility를 검증한다.
`@interactive-os/editable`은 DOM과 Input Events 정규화를 맡는 별도 companion
예시다. `JSONDocument`는 canonical headless JSON state로 남고, editable은
contenteditable lifecycle을 소유하며, 문서 의미는 adapter가 연결한다.
-Companion은 json-document package export나 release catalog의 일부가 아니다.
+`@interactive-os/editable`은 json-document package export나 active package
+catalog의 일부가 아니다.
## Host 책임
diff --git a/package-lock.json b/package-lock.json
index 7a5b4285..66c980c4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,8 @@
"name": "@interactive-os/json-document-monorepo",
"workspaces": [
"packages/json-document",
+ "packages/json-document-collaboration",
+ "packages/contenteditable-collaboration",
"apps/site"
],
"devDependencies": {
@@ -1097,6 +1099,14 @@
"resolved": "packages/json-document",
"link": true
},
+ "node_modules/@interactive-os/json-document-collaboration": {
+ "resolved": "packages/json-document-collaboration",
+ "link": true
+ },
+ "node_modules/@interactive-os/json-document-contenteditable-collaboration": {
+ "resolved": "packages/contenteditable-collaboration",
+ "link": true
+ },
"node_modules/@interactive-os/json-document-site": {
"resolved": "apps/site",
"link": true
@@ -5016,6 +5026,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "packages/contenteditable-collaboration": {
+ "name": "@interactive-os/json-document-contenteditable-collaboration",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@interactive-os/json-document": "*",
+ "@interactive-os/json-document-collaboration": "*",
+ "@types/node": "^25.9.0",
+ "jsdom": "^29.1.1",
+ "typescript": "^5.0.0",
+ "vitest": "^4.1.7"
+ },
+ "peerDependencies": {
+ "@interactive-os/json-document": "^2.0.0-rc.0",
+ "@interactive-os/json-document-collaboration": "^0.1.0"
+ }
+ },
"packages/json-document": {
"name": "@interactive-os/json-document",
"version": "2.0.0-rc.0",
@@ -5025,6 +5052,20 @@
"typescript": "^5.0.0",
"vitest": "^4.1.7"
}
+ },
+ "packages/json-document-collaboration": {
+ "name": "@interactive-os/json-document-collaboration",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@interactive-os/json-document": "*",
+ "@types/node": "^25.9.0",
+ "typescript": "^5.0.0",
+ "vitest": "^4.1.7"
+ },
+ "peerDependencies": {
+ "@interactive-os/json-document": "^2.0.0-rc.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 4bb0cdb0..adca1e67 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,8 @@
"type": "module",
"workspaces": [
"packages/json-document",
+ "packages/json-document-collaboration",
+ "packages/contenteditable-collaboration",
"apps/site"
],
"scripts": {
@@ -13,11 +15,13 @@
"typecheck": "npm run typecheck -w @interactive-os/json-document",
"smoke:package": "npm run smoke:package -w @interactive-os/json-document",
"perf:core": "npm run build -w @interactive-os/json-document && node scripts/benchmark-core.mjs",
- "release:check": "npm run archive:check && npm run verify -w @interactive-os/json-document && npm run standard:check && npm run docs:evaluate && npm run perf:core && npm run pack:library",
+ "release:check": "npm run archive:check && npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate && npm run perf:core && npm run pack:library && npm run pack:companions",
"standard:check": "node scripts/evaluate-standardization.mjs && npm test -w @interactive-os/json-document -- v2-",
"archive:check": "node scripts/evaluate-archive-isolation.mjs",
- "verify": "npm run archive:check && npm run typecheck && npm test && npm run build && npm run smoke:package && npm run docs:evaluate && npm run typecheck -w @interactive-os/json-document-site && npm test -w @interactive-os/json-document-site && npm run site:verify:pages && npm run browser:test",
+ "verify": "npm run archive:check && npm run typecheck && npm test && npm run build && npm run smoke:package && npm run verify:companions && npm run docs:evaluate && npm run typecheck -w @interactive-os/json-document-site && npm test -w @interactive-os/json-document-site && npm run site:verify:pages && npm run browser:test",
+ "verify:companions": "npm run verify -w @interactive-os/json-document-collaboration && npm run verify -w @interactive-os/json-document-contenteditable-collaboration",
"pack:library": "npm pack -w @interactive-os/json-document --dry-run --cache ./.npm-cache",
+ "pack:companions": "npm pack -w @interactive-os/json-document-collaboration --dry-run --cache ./.npm-cache && npm pack -w @interactive-os/json-document-contenteditable-collaboration --dry-run --cache ./.npm-cache",
"publish": "npm publish -w @interactive-os/json-document --tag next",
"browser:test": "playwright test",
"browser:test:headed": "playwright test --headed",
diff --git a/packages/contenteditable-collaboration/LICENSE b/packages/contenteditable-collaboration/LICENSE
new file mode 100644
index 00000000..334271fb
--- /dev/null
+++ b/packages/contenteditable-collaboration/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 json-document contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/contenteditable-collaboration/README.md b/packages/contenteditable-collaboration/README.md
new file mode 100644
index 00000000..22ae6f65
--- /dev/null
+++ b/packages/contenteditable-collaboration/README.md
@@ -0,0 +1,101 @@
+# json-document-contenteditable-collaboration
+
+IME-safe DOM publication lease for
+`@interactive-os/json-document-collaboration/text`.
+
+The adapter binds one collaborative string pointer to one contenteditable
+root. Collaboration ingestion and the six-member document model always update
+immediately. While the browser owns native input or IME composition, only
+rendering back into that root is delayed.
+
+```ts
+import {
+ createCollaborationTextRuntime,
+} from "@interactive-os/json-document-collaboration/text";
+import {
+ createCollaborationContentEditableAdapter,
+} from "@interactive-os/json-document-contenteditable-collaboration";
+
+const runtime = createCollaborationTextRuntime(
+ { title: "Shared title" },
+ {
+ actorId: "browser-a",
+ epochId: "document-42/v1",
+ ruleset: {
+ id: "example/document",
+ digest: "example/document/v1",
+ },
+ },
+);
+
+const adapter = createCollaborationContentEditableAdapter({
+ runtime,
+ pointer: "/title",
+ root: document.querySelector("[contenteditable]") as HTMLElement,
+});
+
+const unbind = adapter.bind();
+```
+
+`bind()` renders the current string, binds native input/composition events,
+and subscribes to `runtime.document`. Use `cancel()` to discard an in-flight
+DOM mutation and render the latest model. `reset()` clears all local lease and
+tail state and resynchronizes the root.
+
+## Input boundary
+
+- `beforeinput` or `compositionstart` captures the causal text basis before
+ the browser mutates DOM.
+- Intermediate composing `input` events never author Changes.
+- `compositionend` performs one plan and one commit.
+- A browser's trailing composition `input` is consumed without a second
+ commit; a timer releases publication when that event is absent.
+- A cancelled `beforeinput` that produces no `input` releases its native lease
+ on the next task instead of leaving publication stuck.
+- Remote Changes update the model immediately but cannot replace the leased
+ DOM root.
+- Tail publication rebases the local selection through remote text merges
+ before restoring it.
+- Atomic reset, deleted target, stale capture/plan, and invalid UTF-16
+ selection offsets fail closed and render the latest model instead of
+ resurrecting observed DOM.
+
+The default DOM projection stores plain text, serializes `
` and ordinary
+block boundaries as deterministic `\n`, and preserves anchor/focus direction.
+Restored offsets are clamped to the current string without landing inside a
+surrogate pair. Events owned by nested editable roots or independent form
+controls are not consumed by the parent adapter.
+
+Wrappers or rich DOM can provide a projection:
+
+```ts
+const adapter = createCollaborationContentEditableAdapter({
+ runtime,
+ pointer: "/title",
+ root,
+ dom: {
+ observe(root) {
+ return {
+ value: readVisibleText(root),
+ selection: readUTF16Selection(root),
+ };
+ },
+ render(root, value) {
+ renderRichText(root, value);
+ },
+ restoreSelection(root, selection) {
+ return restoreUTF16Selection(root, selection);
+ },
+ },
+});
+```
+
+The custom projection must use DOM/JavaScript UTF-16 offsets and must not
+author document changes while rendering.
+
+## Package boundary
+
+This package does not activate or depend on the archived 1.x DOM adapters. It
+does not own transport, presence, clipboard, shared selection, rich-text schema
+semantics, or React rendering. A framework renderer must not replace the same
+root independently while this adapter owns it.
diff --git a/packages/contenteditable-collaboration/package.json b/packages/contenteditable-collaboration/package.json
new file mode 100644
index 00000000..d5147b45
--- /dev/null
+++ b/packages/contenteditable-collaboration/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@interactive-os/json-document-contenteditable-collaboration",
+ "version": "0.1.0",
+ "description": "IME-safe contenteditable publication lease for json-document collaboration text.",
+ "type": "module",
+ "license": "MIT",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/developer-1px/json-document.git",
+ "directory": "packages/contenteditable-collaboration"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "tag": "next"
+ },
+ "keywords": [
+ "@interactive-os/json-document",
+ "collaboration",
+ "contenteditable",
+ "ime",
+ "web"
+ ],
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "clean": "rm -rf dist",
+ "prebuild": "npm run build -w @interactive-os/json-document-collaboration",
+ "build": "npm run clean && tsc -p tsconfig.json",
+ "prepack": "npm run build",
+ "test": "vitest run --config vitest.config.ts",
+ "pretypecheck": "npm run build -w @interactive-os/json-document-collaboration",
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
+ "verify": "npm run typecheck && npm test && npm run build"
+ },
+ "peerDependencies": {
+ "@interactive-os/json-document": "^2.0.0-rc.0",
+ "@interactive-os/json-document-collaboration": "^0.1.0"
+ },
+ "devDependencies": {
+ "@interactive-os/json-document": "*",
+ "@interactive-os/json-document-collaboration": "*",
+ "@types/node": "^25.9.0",
+ "jsdom": "^29.1.1",
+ "typescript": "^5.0.0",
+ "vitest": "^4.1.7"
+ }
+}
diff --git a/packages/contenteditable-collaboration/src/dom/plainText.ts b/packages/contenteditable-collaboration/src/dom/plainText.ts
new file mode 100644
index 00000000..6e2af5d5
--- /dev/null
+++ b/packages/contenteditable-collaboration/src/dom/plainText.ts
@@ -0,0 +1,268 @@
+import type { CollaborationTextSelection } from
+ "@interactive-os/json-document-collaboration/text";
+import type {
+ CollaborationTextDOM,
+ CollaborationTextDOMObservation,
+} from "../types.js";
+
+export const plainTextCollaborationDOM: CollaborationTextDOM = Object.freeze({
+ observe(root: HTMLElement): CollaborationTextDOMObservation {
+ return {
+ value: plainTextFromChildren(root),
+ selection: selectionInRoot(root),
+ };
+ },
+ render(root: HTMLElement, value: string): void {
+ root.replaceChildren(root.ownerDocument.createTextNode(value));
+ },
+ restoreSelection(
+ root: HTMLElement,
+ selection: CollaborationTextSelection,
+ ): boolean {
+ if (!root.isConnected) return false;
+ const value = root.textContent ?? "";
+ const clamped = clampSelectionToScalarBoundaries(value, selection);
+ const anchor = domPositionForOffset(root, clamped.anchor);
+ const focus = domPositionForOffset(root, clamped.focus);
+ const domSelection = root.ownerDocument.getSelection();
+ if (
+ anchor === null
+ || focus === null
+ || domSelection === null
+ ) {
+ return false;
+ }
+ try {
+ domSelection.removeAllRanges();
+ domSelection.collapse(anchor.node, anchor.offset);
+ domSelection.extend(focus.node, focus.offset);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+});
+
+function selectionInRoot(
+ root: HTMLElement,
+): CollaborationTextSelection | null {
+ const selection = root.ownerDocument.getSelection();
+ if (
+ selection === null
+ || selection.anchorNode === null
+ || selection.focusNode === null
+ || !containsNode(root, selection.anchorNode)
+ || !containsNode(root, selection.focusNode)
+ ) {
+ return null;
+ }
+ return {
+ anchor: textOffsetForPosition(
+ root,
+ selection.anchorNode,
+ selection.anchorOffset,
+ ),
+ focus: textOffsetForPosition(
+ root,
+ selection.focusNode,
+ selection.focusOffset,
+ ),
+ };
+}
+
+function containsNode(root: HTMLElement, node: Node): boolean {
+ return node === root || root.contains(node);
+}
+
+function textOffsetForPosition(
+ root: HTMLElement,
+ target: Node,
+ targetOffset: number,
+): number {
+ const projection = projectPlainText(root, target, targetOffset);
+ return projection.offset ?? projection.value.length;
+}
+
+function plainTextFromChildren(node: Node): string {
+ return projectPlainText(node).value;
+}
+
+function projectPlainText(
+ node: Node,
+ target?: Node,
+ targetOffset = 0,
+): { readonly value: string; readonly offset: number | null } {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const value = node.textContent ?? "";
+ return {
+ value,
+ offset: node === target
+ ? boundedInteger(targetOffset, 0, value.length)
+ : null,
+ };
+ }
+ if (isBreak(node)) {
+ return { value: "\n", offset: node === target ? 0 : null };
+ }
+
+ let value = "";
+ let offset: number | null = node === target && targetOffset === 0 ? 0 : null;
+ let previous: Node | null = null;
+ const children = Array.from(node.childNodes);
+ const boundedTargetOffset = boundedInteger(targetOffset, 0, children.length);
+ for (let index = 0; index < children.length; index += 1) {
+ const child = children[index]!;
+ const projected = projectPlainText(child, target, targetOffset);
+ value += blockSeparator(previous, child, value, projected.value);
+ if (node === target && boundedTargetOffset === index) offset = value.length;
+ if (offset === null && projected.offset !== null) {
+ offset = value.length + projected.offset;
+ }
+ value += projected.value;
+ previous = child;
+ }
+ if (node === target && boundedTargetOffset === children.length) {
+ offset = value.length;
+ }
+ return { value, offset };
+}
+
+function blockSeparator(
+ previous: Node | null,
+ current: Node,
+ value: string,
+ currentText: string,
+): string {
+ if (
+ previous === null
+ || (!isBlock(previous) && !isBlock(current))
+ || value.endsWith("\n")
+ || currentText.startsWith("\n")
+ ) {
+ return "";
+ }
+ return "\n";
+}
+
+function isBreak(node: Node): boolean {
+ return node.nodeType === Node.ELEMENT_NODE
+ && (node as Element).tagName.toLowerCase() === "br";
+}
+
+function isBlock(node: Node): boolean {
+ return node.nodeType === Node.ELEMENT_NODE
+ && BLOCK_ELEMENTS.has((node as Element).tagName.toLowerCase());
+}
+
+const BLOCK_ELEMENTS = new Set([
+ "address",
+ "article",
+ "aside",
+ "blockquote",
+ "div",
+ "dl",
+ "fieldset",
+ "figcaption",
+ "figure",
+ "footer",
+ "form",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "header",
+ "hr",
+ "li",
+ "main",
+ "nav",
+ "ol",
+ "p",
+ "pre",
+ "section",
+ "table",
+ "ul",
+]);
+
+function domPositionForOffset(
+ root: HTMLElement,
+ offset: number,
+): { readonly node: Node; readonly offset: number } | null {
+ let remaining = offset;
+ const visit = (
+ node: Node,
+ ): { readonly node: Node; readonly offset: number } | null => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const length = node.textContent?.length ?? 0;
+ if (remaining <= length) return { node, offset: remaining };
+ remaining -= length;
+ return null;
+ }
+ for (const child of Array.from(node.childNodes)) {
+ const found = visit(child);
+ if (found !== null) return found;
+ }
+ return null;
+ };
+ return visit(root) ?? {
+ node: root,
+ offset: root.childNodes.length,
+ };
+}
+
+function clampSelectionToScalarBoundaries(
+ value: string,
+ selection: CollaborationTextSelection,
+): CollaborationTextSelection {
+ const direction = selection.anchor === selection.focus
+ ? "collapsed"
+ : selection.anchor < selection.focus
+ ? "forward"
+ : "backward";
+ const anchorAffinity = direction === "forward" ? "backward" : "forward";
+ const focusAffinity = direction === "backward" ? "backward" : "forward";
+ const anchor = clampScalarOffset(
+ value,
+ selection.anchor,
+ anchorAffinity,
+ );
+ const focus = direction === "collapsed"
+ ? anchor
+ : clampScalarOffset(value, selection.focus, focusAffinity);
+ return { anchor, focus };
+}
+
+function clampScalarOffset(
+ value: string,
+ offset: number,
+ affinity: "backward" | "forward",
+): number {
+ const bounded = boundedInteger(offset, 0, value.length);
+ if (
+ bounded > 0
+ && bounded < value.length
+ && isHighSurrogate(value.charCodeAt(bounded - 1))
+ && isLowSurrogate(value.charCodeAt(bounded))
+ ) {
+ return affinity === "backward" ? bounded - 1 : bounded + 1;
+ }
+ return bounded;
+}
+
+function boundedInteger(
+ input: number,
+ minimum: number,
+ maximum: number,
+): number {
+ if (!Number.isFinite(input)) return minimum;
+ return Math.min(maximum, Math.max(minimum, Math.trunc(input)));
+}
+
+function isHighSurrogate(value: number): boolean {
+ return value >= 0xd800 && value <= 0xdbff;
+}
+
+function isLowSurrogate(value: number): boolean {
+ return value >= 0xdc00 && value <= 0xdfff;
+}
diff --git a/packages/contenteditable-collaboration/src/index.ts b/packages/contenteditable-collaboration/src/index.ts
new file mode 100644
index 00000000..2863b55f
--- /dev/null
+++ b/packages/contenteditable-collaboration/src/index.ts
@@ -0,0 +1,9 @@
+export { createCollaborationContentEditableAdapter } from "./lease.js";
+export { plainTextCollaborationDOM } from "./dom/plainText.js";
+export type {
+ CollaborationContentEditableAdapter,
+ CollaborationContentEditableOptions,
+ CollaborationContentEditableResult,
+ CollaborationTextDOM,
+ CollaborationTextDOMObservation,
+} from "./types.js";
diff --git a/packages/contenteditable-collaboration/src/lease.ts b/packages/contenteditable-collaboration/src/lease.ts
new file mode 100644
index 00000000..213b3ff0
--- /dev/null
+++ b/packages/contenteditable-collaboration/src/lease.ts
@@ -0,0 +1,458 @@
+import type {
+ CollaborationTextCapture,
+ CollaborationTextSelection,
+} from "@interactive-os/json-document-collaboration/text";
+import { plainTextCollaborationDOM } from "./dom/plainText.js";
+import type {
+ CollaborationContentEditableAdapter,
+ CollaborationContentEditableOptions,
+ CollaborationContentEditableResult,
+} from "./types.js";
+
+interface ActiveLease {
+ readonly capture: CollaborationTextCapture;
+ phase: "native" | "composing";
+ nativeFallback: ReturnType | null;
+}
+
+interface TailPublication {
+ readonly token: number;
+ readonly basis: TailSelectionBasis | null;
+ readonly selection: CollaborationTextSelection | null;
+ readonly timer: ReturnType;
+}
+
+interface TailSelectionBasis {
+ readonly capture: CollaborationTextCapture;
+ readonly selection: CollaborationTextSelection;
+}
+
+interface PublishedProjection {
+ readonly available: boolean;
+ readonly value: string;
+}
+
+/**
+ * Binds one contenteditable root to one collaborative string pointer.
+ *
+ * Collaboration ingestion is never paused. While native input owns the root,
+ * only DOM publication is leased; release always renders the latest model.
+ */
+export function createCollaborationContentEditableAdapter({
+ dom = plainTextCollaborationDOM,
+ onResult,
+ pointer,
+ root,
+ runtime,
+}: CollaborationContentEditableOptions): CollaborationContentEditableAdapter {
+ let activeLease: ActiveLease | null = null;
+ let tail: TailPublication | null = null;
+ let tailSequence = 0;
+ let published: PublishedProjection | null = null;
+ let bound = false;
+ let unsubscribeDocument: (() => void) | null = null;
+
+ const report = (
+ result: CollaborationContentEditableResult,
+ ): CollaborationContentEditableResult => {
+ onResult?.(result);
+ return result;
+ };
+
+ const currentDOMSelection = (): CollaborationTextSelection | null =>
+ dom.observe(root).selection;
+
+ const publishLatest = (
+ requestedSelection: CollaborationTextSelection | null | undefined,
+ force: boolean,
+ ): CollaborationContentEditableResult => {
+ const read = runtime.document.at(pointer);
+ const available = read.ok && typeof read.value === "string";
+ const value = available ? read.value as string : "";
+ const unchanged = (
+ published !== null
+ && published.available === available
+ && published.value === value
+ );
+ if (!force && unchanged) {
+ return NO_CHANGE;
+ }
+
+ const selection = requestedSelection === undefined
+ ? currentDOMSelection()
+ : requestedSelection;
+ dom.render(root, value);
+ published = { available, value };
+ if (selection !== null && available) {
+ dom.restoreSelection(root, selection);
+ }
+ return RENDERED;
+ };
+
+ const clearTail = (): void => {
+ if (tail === null) return;
+ clearTimeout(tail.timer);
+ tail = null;
+ };
+
+ const clearActiveLease = (): void => {
+ const fallback = activeLease?.nativeFallback;
+ if (fallback !== null && fallback !== undefined) clearTimeout(fallback);
+ activeLease = null;
+ };
+
+ const finishTail = (
+ expectedToken?: number,
+ ): CollaborationContentEditableResult => {
+ if (
+ tail === null
+ || (
+ expectedToken !== undefined
+ && tail.token !== expectedToken
+ )
+ ) {
+ return NO_CHANGE;
+ }
+ const basis = tail.basis;
+ const selection = tail.selection;
+ clearTail();
+ if (basis === null) return publishLatest(selection, true);
+
+ const planned = runtime.text.plan(basis.capture, {
+ value: basis.capture.value,
+ selection: basis.selection,
+ });
+ if (!planned.ok) {
+ publishLatest(basis.selection, true);
+ return failure(planned.code, planned.reason);
+ }
+ const committed = runtime.text.commit(planned.plan);
+ if (!committed.ok) {
+ publishLatest(basis.selection, true);
+ return failure(committed.code, committed.reason);
+ }
+ return publishLatest(committed.selection, true);
+ };
+
+ const enterTail = (
+ basis: TailSelectionBasis | null,
+ selection: CollaborationTextSelection | null,
+ ): void => {
+ clearTail();
+ const token = tailSequence + 1;
+ tailSequence = token;
+ const timer = setTimeout(() => {
+ const result = finishTail(token);
+ if (!result.ok) report(result);
+ }, 0);
+ tail = { token, basis, selection, timer };
+ };
+
+ const recover = (
+ failure: Extract<
+ CollaborationContentEditableResult,
+ { readonly ok: false }
+ >,
+ selection: CollaborationTextSelection | null,
+ composition: boolean,
+ ): CollaborationContentEditableResult => {
+ clearActiveLease();
+ if (composition) {
+ enterTail(null, selection);
+ } else {
+ publishLatest(selection, true);
+ }
+ return failure;
+ };
+
+ const begin = (
+ phase: ActiveLease["phase"],
+ event: Event,
+ ): CollaborationContentEditableResult => {
+ if (tail !== null) finishTail();
+ if (activeLease !== null) {
+ if (phase === "composing") {
+ activeLease.phase = "composing";
+ if (activeLease.nativeFallback !== null) {
+ clearTimeout(activeLease.nativeFallback);
+ activeLease.nativeFallback = null;
+ }
+ }
+ return NO_CHANGE;
+ }
+ const captured = runtime.text.capture(pointer);
+ if (!captured.ok) {
+ if (event.cancelable) event.preventDefault();
+ publishLatest(undefined, true);
+ return failure(captured.code, captured.reason);
+ }
+ activeLease = {
+ capture: captured.capture,
+ phase,
+ nativeFallback: null,
+ };
+ if (phase === "native") {
+ const lease = activeLease;
+ lease.nativeFallback = setTimeout(() => {
+ if (activeLease !== lease || lease.phase !== "native") return;
+ clearActiveLease();
+ publishLatest(undefined, true);
+ report(CANCELLED);
+ }, 0);
+ }
+ return LEASE_STARTED;
+ };
+
+ const finalize = (
+ composition: boolean,
+ ): CollaborationContentEditableResult => {
+ const lease = activeLease;
+ if (lease === null) return NO_CHANGE;
+ if (lease.nativeFallback !== null) {
+ clearTimeout(lease.nativeFallback);
+ lease.nativeFallback = null;
+ }
+ const observation = dom.observe(root);
+ const planned = runtime.text.plan(lease.capture, {
+ value: observation.value,
+ ...(observation.selection === null
+ ? {}
+ : { selection: observation.selection }),
+ });
+ if (!planned.ok) {
+ return recover(
+ failure(planned.code, planned.reason),
+ observation.selection,
+ composition,
+ );
+ }
+ const committed = runtime.text.commit(planned.plan);
+ if (!committed.ok) {
+ return recover(
+ failure(committed.code, committed.reason),
+ observation.selection,
+ composition,
+ );
+ }
+
+ clearActiveLease();
+ if (composition) {
+ const captured = runtime.text.capture(pointer);
+ enterTail(
+ captured.ok && committed.selection !== null
+ ? {
+ capture: captured.capture,
+ selection: committed.selection,
+ }
+ : null,
+ committed.selection,
+ );
+ } else {
+ publishLatest(committed.selection, true);
+ }
+ return Object.freeze({
+ ok: true,
+ kind: "committed",
+ changeId: committed.changeId,
+ projectionChanged: committed.projectionChanged,
+ selection: committed.selection,
+ });
+ };
+
+ const cancelInternal = (): CollaborationContentEditableResult => {
+ const changed = activeLease !== null || tail !== null;
+ if (!changed) return NO_CHANGE;
+ clearActiveLease();
+ clearTail();
+ publishLatest(undefined, true);
+ return CANCELLED;
+ };
+
+ const handleInternal = (
+ event: Event,
+ ): CollaborationContentEditableResult => {
+ if (event.type === "blur") return cancelInternal();
+
+ if (event.type === "beforeinput") {
+ if (tail !== null) {
+ if (isCompositionInput(event)) return NO_CHANGE;
+ finishTail();
+ }
+ if (activeLease !== null) return NO_CHANGE;
+ return begin("native", event);
+ }
+
+ if (event.type === "compositionstart") {
+ if (tail !== null) finishTail();
+ return begin("composing", event);
+ }
+
+ if (event.type === "compositionend") {
+ if (tail !== null || activeLease === null) return NO_CHANGE;
+ activeLease.phase = "composing";
+ return finalize(true);
+ }
+
+ if (event.type === "input") {
+ if (tail !== null) return finishTail();
+ if (activeLease?.phase === "composing") return NO_CHANGE;
+ if (activeLease !== null) return finalize(false);
+ const result = failure(
+ "missing_text_capture",
+ "native input arrived without a capture-time text basis",
+ );
+ publishLatest(undefined, true);
+ return result;
+ }
+
+ return NO_CHANGE;
+ };
+
+ const boundHandle = (event: Event): void => {
+ if (!eventBelongsToRoot(event, root)) return;
+ report(handleInternal(event));
+ };
+
+ const onDocumentChange = (): void => {
+ if (activeLease !== null || tail !== null) {
+ return;
+ }
+ publishLatest(undefined, false);
+ };
+
+ const unbind = (): void => {
+ if (!bound) return;
+ bound = false;
+ for (const type of ROOT_EVENTS) {
+ root.removeEventListener(type, boundHandle, true);
+ }
+ unsubscribeDocument?.();
+ unsubscribeDocument = null;
+ clearActiveLease();
+ clearTail();
+ };
+
+ return Object.freeze({
+ bind(): () => void {
+ if (bound) return () => {};
+ bound = true;
+ for (const type of ROOT_EVENTS) {
+ root.addEventListener(type, boundHandle, true);
+ }
+ unsubscribeDocument = runtime.document.subscribe(onDocumentChange);
+ const initial = publishLatest(undefined, true);
+ if (!initial.ok) report(initial);
+ let active = true;
+ return () => {
+ if (!active) return;
+ active = false;
+ unbind();
+ };
+ },
+ handle(event: Event): CollaborationContentEditableResult {
+ return report(handleInternal(event));
+ },
+ cancel(): CollaborationContentEditableResult {
+ return report(cancelInternal());
+ },
+ reset(): void {
+ clearActiveLease();
+ clearTail();
+ publishLatest(undefined, true);
+ },
+ });
+}
+
+function eventBelongsToRoot(event: Event, root: HTMLElement): boolean {
+ const target = event.target;
+ if (!(target instanceof root.ownerDocument.defaultView!.Node)) return false;
+ if (target === root) return true;
+ if (!root.contains(target)) return false;
+
+ let element = target instanceof root.ownerDocument.defaultView!.Element
+ ? target
+ : target.parentElement;
+ while (element !== null && element !== root) {
+ const tag = element.tagName.toLowerCase();
+ if (
+ tag === "input"
+ || tag === "textarea"
+ || tag === "select"
+ || tag === "option"
+ ) {
+ return false;
+ }
+ const editable = element.getAttribute("contenteditable");
+ const editableProperty = "contentEditable" in element
+ && typeof element.contentEditable === "string"
+ ? element.contentEditable.toLowerCase()
+ : "";
+ if (
+ (editable !== null || editableProperty !== "")
+ && (
+ editable === ""
+ || editable?.toLowerCase() === "true"
+ || editable?.toLowerCase() === "plaintext-only"
+ || editable?.toLowerCase() === "false"
+ || editableProperty === "true"
+ || editableProperty === "plaintext-only"
+ || editableProperty === "false"
+ )
+ ) {
+ return false;
+ }
+ element = element.parentElement;
+ }
+ return true;
+}
+
+function isCompositionInput(event: Event): boolean {
+ const inputType = "inputType" in event
+ && typeof event.inputType === "string"
+ ? event.inputType
+ : "";
+ const isComposing = "isComposing" in event
+ && event.isComposing === true;
+ return isComposing
+ || inputType === "insertCompositionText"
+ || inputType === "insertFromComposition"
+ || inputType === "deleteCompositionText";
+}
+
+function failure(
+ code: string,
+ reason: string,
+): Extract<
+ CollaborationContentEditableResult,
+ { readonly ok: false }
+> {
+ return Object.freeze({ ok: false, code, reason });
+}
+
+const ROOT_EVENTS = Object.freeze([
+ "beforeinput",
+ "compositionstart",
+ "compositionend",
+ "input",
+ "blur",
+] as const);
+
+const NO_CHANGE: CollaborationContentEditableResult = Object.freeze({
+ ok: true,
+ kind: "no-change",
+});
+
+const LEASE_STARTED: CollaborationContentEditableResult = Object.freeze({
+ ok: true,
+ kind: "lease-started",
+});
+
+const RENDERED: CollaborationContentEditableResult = Object.freeze({
+ ok: true,
+ kind: "rendered",
+});
+
+const CANCELLED: CollaborationContentEditableResult = Object.freeze({
+ ok: true,
+ kind: "cancelled",
+});
diff --git a/packages/contenteditable-collaboration/src/types.ts b/packages/contenteditable-collaboration/src/types.ts
new file mode 100644
index 00000000..dcd444d5
--- /dev/null
+++ b/packages/contenteditable-collaboration/src/types.ts
@@ -0,0 +1,65 @@
+import type { Pointer } from "@interactive-os/json-document";
+import type {
+ ChangeId,
+ CollaborationTextRuntime,
+ CollaborationTextSelection,
+} from "@interactive-os/json-document-collaboration/text";
+
+export interface CollaborationTextDOMObservation {
+ readonly value: string;
+ readonly selection: CollaborationTextSelection | null;
+}
+
+/**
+ * The DOM projection for one collaborative string field.
+ *
+ * Implementations may render wrappers, but observed and restored offsets must
+ * use JavaScript/DOM UTF-16 offsets.
+ */
+export interface CollaborationTextDOM {
+ observe(root: HTMLElement): CollaborationTextDOMObservation;
+ render(root: HTMLElement, value: string): void;
+ restoreSelection(
+ root: HTMLElement,
+ selection: CollaborationTextSelection,
+ ): boolean;
+}
+
+export interface CollaborationContentEditableOptions {
+ readonly runtime: CollaborationTextRuntime;
+ readonly pointer: Pointer;
+ readonly root: HTMLElement;
+ readonly dom?: CollaborationTextDOM;
+ readonly onResult?: (
+ result: CollaborationContentEditableResult,
+ ) => void;
+}
+
+export type CollaborationContentEditableResult =
+ | {
+ readonly ok: true;
+ readonly kind:
+ | "no-change"
+ | "lease-started"
+ | "rendered"
+ | "cancelled";
+ }
+ | {
+ readonly ok: true;
+ readonly kind: "committed";
+ readonly changeId: ChangeId | null;
+ readonly projectionChanged: boolean;
+ readonly selection: CollaborationTextSelection | null;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export interface CollaborationContentEditableAdapter {
+ bind(): () => void;
+ handle(event: Event): CollaborationContentEditableResult;
+ cancel(): CollaborationContentEditableResult;
+ reset(): void;
+}
diff --git a/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts b/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts
new file mode 100644
index 00000000..c302f0aa
--- /dev/null
+++ b/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts
@@ -0,0 +1,622 @@
+import {
+ afterEach,
+ describe,
+ expect,
+ test,
+ vi,
+} from "vitest";
+
+import {
+ createCollaborationRuntime,
+ type CollaborationRuntimeOptions,
+} from "@interactive-os/json-document-collaboration";
+import {
+ createCollaborationTextRuntime,
+} from "@interactive-os/json-document-collaboration/text";
+import {
+ createCollaborationContentEditableAdapter,
+ plainTextCollaborationDOM,
+} from "../src/index.js";
+
+const baseOptions = {
+ epochId: "contenteditable-collaboration/v1",
+ ruleset: {
+ id: "test/contenteditable-collaboration",
+ digest: "test/contenteditable-collaboration/v1",
+ },
+} as const;
+
+function textRuntime(
+ actorId: string,
+ initial: unknown = { title: "ab" },
+ overrides: Partial = {},
+) {
+ return createCollaborationTextRuntime(initial, {
+ ...baseOptions,
+ actorId,
+ ...overrides,
+ });
+}
+
+function atomicRuntime(
+ actorId: string,
+ initial: unknown = { title: "ab" },
+) {
+ return createCollaborationRuntime(initial, {
+ ...baseOptions,
+ actorId,
+ });
+}
+
+function createRoot(): HTMLElement {
+ const root = document.createElement("div");
+ root.contentEditable = "true";
+ document.body.append(root);
+ return root;
+}
+
+function setEditableText(
+ root: HTMLElement,
+ value: string,
+ anchor: number,
+ focus = anchor,
+): void {
+ root.replaceChildren(document.createTextNode(value));
+ const text = root.firstChild;
+ if (!(text instanceof Text)) throw new Error("missing text node");
+ const selection = document.getSelection();
+ selection?.removeAllRanges();
+ selection?.collapse(text, anchor);
+ selection?.extend(text, focus);
+}
+
+function input(
+ type: "beforeinput" | "input",
+ inputType: string,
+ isComposing = false,
+): InputEvent {
+ return new InputEvent(type, {
+ bubbles: true,
+ cancelable: type === "beforeinput",
+ inputType,
+ isComposing,
+ });
+}
+
+function ownChanges(
+ runtime: ReturnType,
+ actorId: string,
+) {
+ return runtime.collaboration.exportBundle().changes.filter(
+ (change) => change.changeId.actorId === actorId,
+ );
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ document.getSelection()?.removeAllRanges();
+ document.body.replaceChildren();
+});
+
+describe("@interactive-os/json-document-contenteditable-collaboration", () => {
+ test("ingests remote text immediately while leasing only DOM publication", () => {
+ vi.useFakeTimers();
+ const local = textRuntime("actor-a");
+ const remote = textRuntime("actor-b");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ const unbind = adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aYb", 2);
+
+ expect(remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }])).toMatchObject({ ok: true });
+ expect(local.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ expect(local.document.value).toEqual({ title: "aXb" });
+ expect(root.textContent).toBe("aYb");
+
+ expect(adapter.handle(new CompositionEvent("compositionend")))
+ .toMatchObject({
+ ok: true,
+ kind: "committed",
+ changeId: { actorId: "actor-a", counter: 1 },
+ });
+ expect(local.document.value).toEqual({ title: "aYXb" });
+ expect(root.textContent).toBe("aYb");
+ expect(ownChanges(local, "actor-a")).toHaveLength(1);
+
+ vi.runOnlyPendingTimers();
+ expect(root.textContent).toBe("aYXb");
+ expect(document.getSelection()?.anchorOffset).toBe(2);
+
+ expect(remote.collaboration.ingest(local.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(remote.document.value).toEqual(local.document.value);
+ unbind();
+ });
+
+ test("captures before native input and commits through the text profile", () => {
+ const local = textRuntime("actor-a");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ const unbind = adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ root.dispatchEvent(input("beforeinput", "insertText"));
+ setEditableText(root, "aXb", 2);
+ root.dispatchEvent(input("input", "insertText"));
+
+ expect(local.document.value).toEqual({ title: "aXb" });
+ expect(ownChanges(local, "actor-a")).toHaveLength(1);
+ expect(root.textContent).toBe("aXb");
+ unbind();
+ });
+
+ for (const order of [
+ "input-before-compositionend",
+ "input-after-compositionend",
+ ] as const) {
+ test(`authors exactly one Change for ${order}`, () => {
+ vi.useFakeTimers();
+ const local = textRuntime("actor-a");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aXb", 2);
+
+ if (order === "input-before-compositionend") {
+ adapter.handle(input("input", "insertCompositionText", true));
+ expect(ownChanges(local, "actor-a")).toHaveLength(0);
+ }
+
+ expect(adapter.handle(new CompositionEvent("compositionend")))
+ .toMatchObject({ ok: true, kind: "committed" });
+
+ if (order === "input-after-compositionend") {
+ adapter.handle(input(
+ "beforeinput",
+ "insertFromComposition",
+ ));
+ adapter.handle(input("input", "insertFromComposition"));
+ } else {
+ vi.runOnlyPendingTimers();
+ }
+
+ expect(local.document.value).toEqual({ title: "aXb" });
+ expect(root.textContent).toBe("aXb");
+ expect(ownChanges(local, "actor-a")).toHaveLength(1);
+ });
+ }
+
+ test("cancel and reset discard uncommitted DOM and ignore late IME events", () => {
+ for (const action of ["cancel", "reset"] as const) {
+ const local = textRuntime(`actor-${action}`);
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ const unbind = adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aXb", 2);
+ if (action === "cancel") {
+ expect(adapter.cancel()).toMatchObject({
+ ok: true,
+ kind: "cancelled",
+ });
+ } else {
+ adapter.reset();
+ }
+
+ expect(root.textContent).toBe("ab");
+ adapter.handle(new CompositionEvent("compositionend"));
+ adapter.handle(input("input", "insertFromComposition"));
+ expect(local.document.value).toEqual({ title: "ab" });
+ expect(ownChanges(local, `actor-${action}`)).toHaveLength(0);
+ unbind();
+ }
+ });
+
+ for (const scenario of [
+ {
+ name: "atomic reset",
+ patch: [{
+ op: "replace" as const,
+ path: "/title",
+ value: "reset",
+ }],
+ code: "text_generation_mismatch",
+ value: { title: "reset" },
+ dom: "reset",
+ },
+ {
+ name: "target deletion",
+ patch: [{
+ op: "remove" as const,
+ path: "/title",
+ }],
+ code: "text_target_deleted",
+ value: {},
+ dom: "",
+ },
+ ]) {
+ test(`fails closed after a remote ${scenario.name}`, () => {
+ vi.useFakeTimers();
+ const local = textRuntime("actor-a");
+ const remote = atomicRuntime("actor-b");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aXb", 2);
+
+ expect(remote.document.commit(scenario.patch))
+ .toMatchObject({ ok: true });
+ expect(local.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(local.document.value).toEqual(scenario.value);
+ expect(root.textContent).toBe("aXb");
+
+ expect(adapter.handle(new CompositionEvent("compositionend")))
+ .toMatchObject({ ok: false, code: scenario.code });
+ expect(ownChanges(local, "actor-a")).toHaveLength(0);
+
+ adapter.handle(input("input", "insertFromComposition"));
+ expect(root.textContent).toBe(scenario.dom);
+ expect(local.document.value).toEqual(scenario.value);
+ });
+ }
+
+ test("leases one surface without delaying publication to another", () => {
+ const initial = { title: "ab", note: "cd" };
+ const local = textRuntime("actor-a", initial);
+ const remote = textRuntime("actor-b", initial);
+ const titleRoot = createRoot();
+ const noteRoot = createRoot();
+ const title = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root: titleRoot,
+ });
+ const note = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/note",
+ root: noteRoot,
+ });
+ title.bind();
+ note.bind();
+
+ setEditableText(titleRoot, "ab", 1);
+ title.handle(new CompositionEvent("compositionstart"));
+ setEditableText(titleRoot, "aXb", 2);
+
+ remote.document.commit([{
+ op: "replace",
+ path: "/note",
+ value: "cYd",
+ }]);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+
+ expect(local.document.value).toEqual({ title: "ab", note: "cYd" });
+ expect(titleRoot.textContent).toBe("aXb");
+ expect(noteRoot.textContent).toBe("cYd");
+
+ title.cancel();
+ expect(titleRoot.textContent).toBe("ab");
+ });
+
+ test("clamps selection direction and surrogate boundaries after publication", () => {
+ const local = textRuntime("actor-a", { title: "A😀B" });
+ const remote = atomicRuntime("actor-b", { title: "A😀B" });
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "A😀B", 2);
+ remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "A😀",
+ }]);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+
+ expect(root.textContent).toBe("A😀");
+ expect(document.getSelection()?.anchorOffset).toBe(3);
+ expect(document.getSelection()?.focusOffset).toBe(3);
+
+ const backwardLocal = textRuntime("actor-c", { title: "abcdef" });
+ const backwardRemote = atomicRuntime("actor-d", { title: "abcdef" });
+ const backwardRoot = createRoot();
+ createCollaborationContentEditableAdapter({
+ runtime: backwardLocal,
+ pointer: "/title",
+ root: backwardRoot,
+ }).bind();
+ setEditableText(backwardRoot, "abcdef", 5, 1);
+ backwardRemote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "abc",
+ }]);
+ backwardLocal.collaboration.ingest(
+ backwardRemote.collaboration.exportBundle(),
+ );
+
+ expect(document.getSelection()?.anchorOffset).toBe(3);
+ expect(document.getSelection()?.focusOffset).toBe(1);
+ });
+
+ test("rejects a composed selection inside a surrogate and recovers the DOM", () => {
+ vi.useFakeTimers();
+ const local = textRuntime("actor-a", { title: "A😀B" });
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "A😀B", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "A😀B", 2);
+
+ expect(adapter.handle(new CompositionEvent("compositionend")))
+ .toMatchObject({
+ ok: false,
+ code: "invalid_text_offset",
+ });
+ expect(ownChanges(local, "actor-a")).toHaveLength(0);
+
+ adapter.handle(input("input", "insertFromComposition"));
+ expect(root.textContent).toBe("A😀B");
+ expect(document.getSelection()?.anchorOffset).toBe(3);
+ expect(document.getSelection()?.focusOffset).toBe(3);
+ });
+
+ test("fails closed when the same actor changes causal history mid-lease", () => {
+ const local = textRuntime("actor-a");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aXb", 2);
+ expect(local.document.commit([{
+ op: "add",
+ path: "/done",
+ value: true,
+ }])).toMatchObject({ ok: true });
+
+ expect(adapter.handle(new CompositionEvent("compositionend")))
+ .toMatchObject({
+ ok: false,
+ code: "stale_text_capture",
+ });
+ adapter.handle(input("input", "insertFromComposition"));
+
+ expect(local.document.value).toEqual({ title: "ab", done: true });
+ expect(root.textContent).toBe("ab");
+ expect(ownChanges(local, "actor-a")).toHaveLength(1);
+ });
+
+ test("scopes bound events to this editing host", () => {
+ const local = textRuntime("actor-a", { title: "ab" });
+ const root = createRoot();
+ createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ }).bind();
+
+ const ordinary = document.createElement("span");
+ ordinary.textContent = "ab";
+ root.replaceChildren(ordinary);
+ ordinary.dispatchEvent(input("beforeinput", "insertText"));
+ ordinary.textContent = "aXb";
+ ordinary.dispatchEvent(input("input", "insertText"));
+ expect(local.document.value).toEqual({ title: "aXb" });
+
+ const nested = document.createElement("span");
+ nested.contentEditable = "true";
+ nested.textContent = "nested";
+ root.append(nested);
+ nested.dispatchEvent(input("beforeinput", "insertText"));
+ nested.textContent = "nested!";
+ nested.dispatchEvent(input("input", "insertText"));
+ expect(local.document.value).toEqual({ title: "aXb" });
+ expect(nested.isConnected).toBe(true);
+
+ const control = document.createElement("input");
+ root.append(control);
+ control.dispatchEvent(input("beforeinput", "insertText"));
+ control.value = "independent";
+ control.dispatchEvent(input("input", "insertText"));
+ expect(local.document.value).toEqual({ title: "aXb" });
+ expect(control.isConnected).toBe(true);
+ });
+
+ test("serializes line breaks and block boundaries as plain text", () => {
+ const local = textRuntime("actor-a");
+ const root = createRoot();
+ createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ }).bind();
+
+ root.dispatchEvent(input("beforeinput", "insertLineBreak"));
+ root.innerHTML = "a
bc
";
+ root.dispatchEvent(input("input", "insertLineBreak"));
+
+ expect(local.document.value).toEqual({ title: "a\nb\nc" });
+ expect(root.textContent).toBe("a\nb\nc");
+ expect(plainTextCollaborationDOM.observe(root).value).toBe("a\nb\nc");
+ });
+
+ test("uses the same block serialization for value and selection offsets", () => {
+ const root = createRoot();
+ root.innerHTML = "a
b
";
+ const secondText = root.lastElementChild?.firstChild;
+ if (!(secondText instanceof Text)) throw new Error("missing block text");
+ const selection = document.getSelection();
+ selection?.removeAllRanges();
+ selection?.collapse(secondText, 1);
+
+ const observed = plainTextCollaborationDOM.observe(root);
+ expect(observed).toEqual({
+ value: "a\nb",
+ selection: { anchor: 3, focus: 3 },
+ });
+
+ plainTextCollaborationDOM.render(root, observed.value);
+ expect(root.childNodes).toHaveLength(1);
+ expect(root.firstChild).toBeInstanceOf(Text);
+ expect(plainTextCollaborationDOM.restoreSelection(
+ root,
+ observed.selection!,
+ )).toBe(true);
+ expect(plainTextCollaborationDOM.observe(root)).toEqual(observed);
+ });
+
+ test("rebases the composition tail selection through a remote merge", () => {
+ vi.useFakeTimers();
+ const local = textRuntime("actor-a");
+ const remote = textRuntime("actor-b");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aXb", 2);
+ adapter.handle(new CompositionEvent("compositionend"));
+
+ remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Yab",
+ }]);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+ adapter.handle(input("input", "insertCompositionText"));
+
+ expect(local.document.value).toEqual({ title: "YaXb" });
+ expect(root.textContent).toBe("YaXb");
+ expect(document.getSelection()?.anchorOffset).toBe(3);
+ expect(document.getSelection()?.focusOffset).toBe(3);
+ expect(ownChanges(local, "actor-a")).toHaveLength(1);
+ });
+
+ test("fails closed when a composition tail generation is reset or deleted", () => {
+ for (const scenario of [
+ {
+ patch: [{
+ op: "replace" as const,
+ path: "/title",
+ value: "reset",
+ }],
+ expected: { title: "reset" },
+ dom: "reset",
+ },
+ {
+ patch: [{ op: "remove" as const, path: "/title" }],
+ expected: {},
+ dom: "",
+ },
+ ]) {
+ const local = textRuntime("actor-a");
+ const remote = atomicRuntime("actor-b");
+ const root = createRoot();
+ const adapter = createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ });
+ adapter.bind();
+
+ setEditableText(root, "ab", 1);
+ adapter.handle(new CompositionEvent("compositionstart"));
+ setEditableText(root, "aXb", 2);
+ adapter.handle(new CompositionEvent("compositionend"));
+ remote.document.commit(scenario.patch);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+
+ expect(adapter.handle(input("input", "insertCompositionText")))
+ .toMatchObject({ ok: false });
+ expect(local.document.value).toEqual(scenario.expected);
+ expect(root.textContent).toBe(scenario.dom);
+ }
+ });
+
+ test("releases a native lease when beforeinput is cancelled without input", () => {
+ vi.useFakeTimers();
+ const local = textRuntime("actor-a");
+ const remote = textRuntime("actor-b");
+ const root = createRoot();
+ const results: unknown[] = [];
+ createCollaborationContentEditableAdapter({
+ runtime: local,
+ pointer: "/title",
+ root,
+ onResult: (result) => results.push(result),
+ }).bind();
+ root.addEventListener("beforeinput", (event) => event.preventDefault());
+
+ root.dispatchEvent(input("beforeinput", "insertText"));
+ remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aYb",
+ }]);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+ expect(root.textContent).toBe("ab");
+
+ vi.runOnlyPendingTimers();
+ expect(root.textContent).toBe("aYb");
+ expect(results).toContainEqual({ ok: true, kind: "cancelled" });
+ });
+});
diff --git a/packages/contenteditable-collaboration/tsconfig.json b/packages/contenteditable-collaboration/tsconfig.json
new file mode 100644
index 00000000..9323063f
--- /dev/null
+++ b/packages/contenteditable-collaboration/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "baseUrl": ".",
+ "paths": {
+ "@interactive-os/json-document-collaboration": [
+ "../json-document-collaboration/dist/index.d.ts"
+ ],
+ "@interactive-os/json-document-collaboration/text": [
+ "../json-document-collaboration/dist/text-index.d.ts"
+ ]
+ },
+ "declaration": true,
+ "declarationMap": false,
+ "sourceMap": false,
+ "strict": true,
+ "exactOptionalPropertyTypes": true,
+ "noUncheckedIndexedAccess": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "lib": [
+ "ES2022",
+ "DOM"
+ ]
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}
diff --git a/packages/contenteditable-collaboration/tsconfig.test.json b/packages/contenteditable-collaboration/tsconfig.test.json
new file mode 100644
index 00000000..2720ba72
--- /dev/null
+++ b/packages/contenteditable-collaboration/tsconfig.test.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": [
+ "node"
+ ],
+ "noEmit": true,
+ "rootDir": ".",
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/**/*.ts",
+ "tests/**/*.ts",
+ "vitest.config.ts"
+ ]
+}
diff --git a/packages/contenteditable-collaboration/vitest.config.ts b/packages/contenteditable-collaboration/vitest.config.ts
new file mode 100644
index 00000000..bd252e16
--- /dev/null
+++ b/packages/contenteditable-collaboration/vitest.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: [
+ {
+ find: /^@interactive-os\/json-document-collaboration\/text$/,
+ replacement: new URL(
+ "../json-document-collaboration/src/text-index.ts",
+ import.meta.url,
+ ).pathname,
+ },
+ {
+ find: /^@interactive-os\/json-document-collaboration$/,
+ replacement: new URL(
+ "../json-document-collaboration/src/index.ts",
+ import.meta.url,
+ ).pathname,
+ },
+ {
+ find: /^@interactive-os\/json-document$/,
+ replacement: new URL(
+ "../json-document/src/application/document/index.ts",
+ import.meta.url,
+ ).pathname,
+ },
+ ],
+ },
+ test: {
+ environment: "jsdom",
+ include: ["tests/**/*.test.ts"],
+ },
+});
diff --git a/packages/json-document-collaboration/LICENSE b/packages/json-document-collaboration/LICENSE
new file mode 100644
index 00000000..6b56fd76
--- /dev/null
+++ b/packages/json-document-collaboration/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) interactive-os contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md
new file mode 100644
index 00000000..dbe572a5
--- /dev/null
+++ b/packages/json-document-collaboration/README.md
@@ -0,0 +1,199 @@
+# @interactive-os/json-document-collaboration
+
+Transport-free causal collaboration provider for the six-member
+`@interactive-os/json-document` Projection contract.
+
+Local and collaborative providers expose the same six-member Projection API.
+
+```ts
+import {
+ createCollaborationRuntime,
+} from "@interactive-os/json-document-collaboration";
+
+const runtime = createCollaborationRuntime(initial, {
+ actorId: "browser-a",
+ epochId: "document-42/v1",
+ ruleset: {
+ id: "example/task-document",
+ digest: "task-document-rules-v1",
+ },
+});
+
+runtime.document.commit([
+ { op: "replace", path: "/title", value: "Shared title" },
+]);
+
+send(runtime.collaboration.exportBundle());
+receive((bundle) => runtime.collaboration.ingest(bundle));
+```
+
+The editor receives only `runtime.document`, whose required API is identical to
+the local-only provider. Causal state, conflicts, bundles, and sync controls
+remain on `runtime.collaboration`. The package contains no transport, presence,
+storage, DOM, React, or server dependency.
+
+Actor-local selective history is an opt-in authoring surface:
+
+```ts
+import {
+ createCollaborationHistoryRuntime,
+} from "@interactive-os/json-document-collaboration/history";
+
+const runtime = createCollaborationHistoryRuntime(initial, options);
+runtime.history.undo();
+runtime.history.redo();
+```
+
+The base runtime still ingests, materializes, and forwards history wire
+operations so peers converge, but it does not expose history authoring
+controls. History withdraws or reinstates the original Change contribution; it
+does not write an inverse value over another actor's work.
+
+Fine-grained JSON string collaboration is also opt-in:
+
+```ts
+import {
+ createCollaborationTextRuntime,
+} from "@interactive-os/json-document-collaboration/text";
+
+const runtime = createCollaborationTextRuntime(initial, options);
+
+// Ordinary editor code keeps using the same document API. String-to-string
+// replace operations compile to collaborative text splices in this profile.
+runtime.document.commit([
+ { op: "replace", path: "/title", value: "Shared title" },
+]);
+
+// Native input and IME adapters capture before the browser mutates the DOM.
+const captured = runtime.text.capture("/title");
+if (captured.ok) {
+ const planned = runtime.text.plan(captured.capture, {
+ value: finalDOMText,
+ selection: { anchor: 6, focus: 6 },
+ });
+ if (planned.ok) runtime.text.commit(planned.plan);
+}
+```
+
+Remote bundles may be ingested between `capture` and `plan`. The authored
+Change still uses the capture-time causal frontier, so unseen remote input stays
+concurrent and merges by stable text atoms. Any graph change after `plan`
+returns `stale_text_plan`; adapters recover by rendering the latest model
+instead of silently rebasing an already observed DOM result.
+
+`runtime.collaboration.subscribe` publishes one deeply immutable snapshot when
+an ingest adds causal state, even if it only changes pending/conflict metadata.
+Duplicate-only delivery publishes nothing. Unsubscribe follows the Projection
+subscription contract.
+
+## Checkpoints, membership, and epochs
+
+`exportCheckpoint()` is a lossless same-epoch artifact. It retains every ready
+and pending Change, history control, conflict input, and suppressed Change
+input. Restore replays the causal state rather than trusting a cached
+projection:
+
+```ts
+import {
+ compactCollaborationCheckpoint,
+ restoreCollaborationRuntime,
+} from "@interactive-os/json-document-collaboration";
+
+const checkpoint = runtime.collaboration.exportCheckpoint();
+const restored = restoreCollaborationRuntime(checkpoint, {
+ actorId: "browser-a",
+ ruleset: options.ruleset,
+});
+
+const compacted = compactCollaborationCheckpoint(checkpoint, {
+ mode: "new-epoch",
+ nextEpochId: "document-42/v2",
+ nextRuleset: options.ruleset,
+});
+```
+
+Compaction is deliberately destructive and therefore only produces a new
+epoch. It requires no pending Changes, uses the current valid JSON projection
+as the next base, resets actor counters, reports the discarded causal
+diagnostics, and binds the new epoch to the source checkpoint digest. Hosts
+must quiesce writers and coordinate the epoch switch; the transport-free core
+does not claim that authority.
+
+An optional canonical membership list binds admitted `actorId` values and
+credential identifiers into the epoch digest. Unknown Change authors,
+dependencies, and history references are rejected transactionally. Actor IDs
+are lineage identifiers, not authentication. A host may attach a proof and
+must pass `verify` on restore/compaction when authenticity matters.
+
+The checkpoint checksum is canonical SHA-256 tamper detection. It is not a
+signature. A custom `accepts` resolver is explicitly recorded in the epoch;
+restore and compaction fail with `acceptance_required` if that resolver is
+omitted.
+
+## Rich-text boundary
+
+The collaboration core does not add a generic rich-text command protocol.
+Rich editors already have a smaller convergence path: model their document as
+JSON objects and arrays, translate editor intent to standard JSON Patch, and
+use the `./text` profile for string leaves. Stable structure, moves, concurrent
+insertions, text atoms, history, acceptance, and checkpoints then use the same
+built-in wire operations that every base peer can materialize.
+
+A concrete ProseMirror, Lexical, or custom-schema resolver belongs in its own
+adapter package. It may expose higher-level commands, but it must commit
+ordinary patches through the six-member `document` port and keep its schema,
+DOM, marks, and selection policy out of this package. If a rich model genuinely
+needs a new wire protocol, every materializing participant must install that
+separate protocol; an unconfigured base document must never pretend it can
+resolve opaque commands.
+
+DOM/native-input publication leasing is likewise separate in
+`@interactive-os/json-document-contenteditable-collaboration`. It continues
+ingesting model Changes during composition and gates only rendering for the
+leased surface.
+
+## Protocol v3 profile
+
+The implemented profile includes:
+
+- immutable `Change { changeId, deps, ops }` envelopes;
+- actor/counter identities and dependency-frontier authoring;
+- idempotent, out-of-order bundle ingestion;
+- deterministic topological materialization;
+- stable hidden member and container identities;
+- immutable array placement incarnations and two-sided gap anchors;
+- identity-preserving object rename and array/object move;
+- fresh identities for copy, insertion, and container replacement;
+- stable semantic `test` preconditions for dependent commands;
+- atomic strings in the base authoring profile;
+- opt-in lazy text atoms with exact deletion and deterministic same-gap insert;
+- capture-time text frontiers and UTF-16 selection-gap restoration;
+- opt-in actor-local selective undo/redo with causal history reconstruction;
+- whole-Change acceptance and deterministic suppression;
+- canonical membership, SHA-256 checkpoints, restore, and new-epoch compaction;
+- valid JSON publication through the standard six-member Projection;
+- conflict and suppression sidecars outside `document.value`.
+
+Transport, presence, storage, signing keys, server coordination, and concrete
+rich-text schemas remain outside this package.
+
+`ruleset.id` and `ruleset.digest` are immutable within an epoch. They cover the
+domain acceptance rule and every materialization policy used by all writers.
+A mismatched bundle is rejected before any state changes.
+`baseDigest`, membership, acceptance mode, and protocol version are immutable
+within an epoch.
+
+Convergence requires `accepts` to be synchronous, total, side-effect-free, and
+referentially transparent. For the same candidate, every replica and every
+invocation must return the same complete result, including `code`, `reason`,
+and `pointer`. It must not read clocks, randomness, mutable closure state, or
+replica-local environment. The ruleset digest must identify its implementation
+and configuration; the runtime cannot prove this property for an arbitrary
+JavaScript callback.
+
+An `actorId` identifies one persisted authoring lineage within an epoch. Its
+retained counter and causal history must be ingested before that actor authors
+again. Authoring is blocked while that actor has pending history, and a
+non-contiguous or non-causal retained lineage is rejected. A genuinely new
+writer must use a fresh actor ID; reuse cannot be detected until conflicting
+history is observed.
diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json
new file mode 100644
index 00000000..1a58f57b
--- /dev/null
+++ b/packages/json-document-collaboration/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@interactive-os/json-document-collaboration",
+ "version": "0.1.0",
+ "description": "Transport-free causal collaboration provider for @interactive-os/json-document.",
+ "type": "module",
+ "license": "MIT",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/developer-1px/json-document.git",
+ "directory": "packages/json-document-collaboration"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "tag": "next"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./history": {
+ "types": "./dist/history-index.d.ts",
+ "import": "./dist/history-index.js"
+ },
+ "./text": {
+ "types": "./dist/text-index.d.ts",
+ "import": "./dist/text-index.js"
+ }
+ },
+ "scripts": {
+ "clean": "rm -rf dist",
+ "prebuild": "npm run build -w @interactive-os/json-document",
+ "build": "npm run clean && tsc -p tsconfig.json",
+ "prepack": "npm run build",
+ "test": "vitest run --config vitest.config.ts",
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
+ "verify": "npm run typecheck && npm test && npm run build"
+ },
+ "peerDependencies": {
+ "@interactive-os/json-document": "^2.0.0-rc.0"
+ },
+ "devDependencies": {
+ "@interactive-os/json-document": "*",
+ "@types/node": "^25.9.0",
+ "typescript": "^5.0.0",
+ "vitest": "^4.1.7"
+ }
+}
diff --git a/packages/json-document-collaboration/src/change.ts b/packages/json-document-collaboration/src/change.ts
new file mode 100644
index 00000000..bcfbcd8b
--- /dev/null
+++ b/packages/json-document-collaboration/src/change.ts
@@ -0,0 +1,989 @@
+import {
+ applyPatch,
+ type JSONValue,
+} from "@interactive-os/json-document";
+
+import type {
+ ChangeId,
+ CollaborationBundle,
+ CollaborationChange,
+ CollaborationEpoch,
+ CollaborationEpochParent,
+ CollaborationMembership,
+ CollaborationRuntimeOptions,
+ PendingChange,
+ SemanticOperation,
+} from "./types.js";
+
+export interface PreparedGraph {
+ readonly ordered: ReadonlyArray;
+ readonly readyKeys: ReadonlySet;
+ readonly pending: ReadonlyArray;
+ readonly heads: ReadonlyArray;
+}
+
+type PreparedBundle =
+ | { readonly ok: true; readonly bundle: CollaborationBundle }
+ | { readonly ok: false; readonly reason: string };
+
+export function createEpoch(
+ initial: JSONValue,
+ options: CollaborationRuntimeOptions,
+ parent: CollaborationEpochParent | null = null,
+): CollaborationEpoch {
+ const membership = canonicalMembership(options.membership);
+ return Object.freeze({
+ protocolVersion: 3,
+ epochId: options.epochId,
+ ruleset: Object.freeze({
+ id: options.ruleset.id,
+ digest: options.ruleset.digest,
+ }),
+ acceptance: options.accepts === undefined ? "none" : "custom",
+ baseDigest: fingerprintJSON(initial),
+ membershipDigest: fingerprintJSON(
+ membership as unknown as JSONValue,
+ ),
+ parent: parent === null
+ ? null
+ : Object.freeze({
+ epochId: parent.epochId,
+ checkpointDigest: parent.checkpointDigest,
+ }),
+ });
+}
+
+export function canonicalMembership(
+ input: CollaborationMembership | undefined,
+): CollaborationMembership | null {
+ if (input === undefined) return null;
+ if (input.version !== 1 || !Array.isArray(input.members)) {
+ throw new TypeError("membership must be a version 1 member list");
+ }
+ const members = input.members.map((member) => {
+ if (
+ typeof member !== "object"
+ || member === null
+ || !isNonEmptyString(member.actorId)
+ || (
+ member.credentialId !== undefined
+ && !isNonEmptyString(member.credentialId)
+ )
+ ) {
+ throw new TypeError(
+ "membership entries require actorId and an optional credentialId",
+ );
+ }
+ return Object.freeze({
+ actorId: member.actorId,
+ ...(member.credentialId === undefined
+ ? {}
+ : { credentialId: member.credentialId }),
+ });
+ }).sort((left, right) => (
+ left.actorId < right.actorId ? -1 : left.actorId > right.actorId ? 1 : 0
+ ));
+ if (members.length === 0) {
+ throw new TypeError("membership must admit at least one actor");
+ }
+ for (let index = 1; index < members.length; index += 1) {
+ if (members[index - 1]?.actorId === members[index]?.actorId) {
+ throw new TypeError("membership actorId values must be unique");
+ }
+ }
+ return Object.freeze({
+ version: 1,
+ members: Object.freeze(members),
+ });
+}
+
+export function membershipAllows(
+ membership: CollaborationMembership | null,
+ actorId: string,
+): boolean {
+ return (
+ membership === null
+ || membership.members.some((member) => member.actorId === actorId)
+ );
+}
+
+export function prepareBundle(input: unknown): PreparedBundle {
+ if (!isRecord(input)) return invalid("bundle must be an object");
+ const epoch = prepareEpoch(input.epoch);
+ if (!epoch.ok) return epoch;
+ if (!Array.isArray(input.changes)) {
+ return invalid("bundle changes must be an array");
+ }
+
+ const changes: CollaborationChange[] = [];
+ for (const candidate of input.changes) {
+ const prepared = prepareChange(candidate);
+ if (!prepared.ok) return prepared;
+ changes.push(prepared.change);
+ }
+
+ return {
+ ok: true,
+ bundle: Object.freeze({
+ epoch: epoch.epoch,
+ changes: Object.freeze(changes),
+ }),
+ };
+}
+
+export function freezeLocalChange(
+ changeId: ChangeId,
+ deps: ReadonlyArray,
+ ops: ReadonlyArray,
+): CollaborationChange {
+ return Object.freeze({
+ changeId: freezeChangeId(changeId),
+ deps: Object.freeze(deps.map(freezeChangeId)),
+ ops: Object.freeze(ops.map(freezeSemanticOperation)),
+ });
+}
+
+export function changeIdKey(changeId: ChangeId): string {
+ return `${changeId.actorId.length}:${changeId.actorId}:${changeId.counter}`;
+}
+
+export function compareChangeIds(left: ChangeId, right: ChangeId): number {
+ const byCounter = left.counter - right.counter;
+ return byCounter !== 0
+ ? byCounter
+ : left.actorId < right.actorId
+ ? -1
+ : left.actorId > right.actorId
+ ? 1
+ : 0;
+}
+
+export function compareChanges(
+ left: CollaborationChange,
+ right: CollaborationChange,
+): number {
+ return compareChangeIds(left.changeId, right.changeId);
+}
+
+export function changesEqual(
+ left: CollaborationChange,
+ right: CollaborationChange,
+): boolean {
+ return canonicalStringify(left as unknown as JSONValue)
+ === canonicalStringify(right as unknown as JSONValue);
+}
+
+export function graphCycle(
+ changes: ReadonlyMap,
+): ChangeId | null {
+ const visiting = new Set();
+ const visited = new Set();
+
+ const visit = (key: string): ChangeId | null => {
+ if (visiting.has(key)) return changes.get(key)?.changeId ?? null;
+ if (visited.has(key)) return null;
+ const change = changes.get(key);
+ if (change === undefined) return null;
+
+ visiting.add(key);
+ for (const dependency of change.deps) {
+ const dependencyKey = changeIdKey(dependency);
+ if (!changes.has(dependencyKey)) continue;
+ const found = visit(dependencyKey);
+ if (found !== null) return found;
+ }
+ visiting.delete(key);
+ visited.add(key);
+ return null;
+ };
+
+ for (const key of [...changes.keys()].sort()) {
+ const found = visit(key);
+ if (found !== null) return found;
+ }
+ return null;
+}
+
+export function prepareGraph(
+ changes: ReadonlyMap,
+): PreparedGraph {
+ const remaining = new Map(changes);
+ const readyKeys = new Set();
+ const ordered: CollaborationChange[] = [];
+
+ while (remaining.size > 0) {
+ const available = [...remaining.values()]
+ .filter((change) => change.deps.every((dependency) => (
+ readyKeys.has(changeIdKey(dependency))
+ )))
+ .sort(compareChanges);
+ const next = available[0];
+ if (next === undefined) break;
+ const key = changeIdKey(next.changeId);
+ remaining.delete(key);
+ readyKeys.add(key);
+ ordered.push(next);
+ }
+
+ const pending = [...remaining.values()]
+ .sort(compareChanges)
+ .map((change) => Object.freeze({
+ changeId: freezeChangeId(change.changeId),
+ missing: Object.freeze(
+ change.deps
+ .filter((dependency) => !readyKeys.has(changeIdKey(dependency)))
+ .sort(compareChangeIds)
+ .map(freezeChangeId),
+ ),
+ }));
+
+ const nonHeads = new Set();
+ for (const change of ordered) {
+ for (const dependency of change.deps) {
+ const key = changeIdKey(dependency);
+ if (readyKeys.has(key)) nonHeads.add(key);
+ }
+ }
+ const heads = ordered
+ .filter((change) => !nonHeads.has(changeIdKey(change.changeId)))
+ .map((change) => freezeChangeId(change.changeId))
+ .sort(compareChangeIds);
+
+ return {
+ ordered: Object.freeze(ordered),
+ readyKeys,
+ pending: Object.freeze(pending),
+ heads: Object.freeze(heads),
+ };
+}
+
+export function findActorFork(
+ ordered: ReadonlyArray,
+): ChangeId | null {
+ const changes = new Map(
+ ordered.map((change) => [changeIdKey(change.changeId), change]),
+ );
+ const byActor = new Map();
+ for (const change of ordered) {
+ const actorChanges = byActor.get(change.changeId.actorId);
+ if (actorChanges === undefined) {
+ byActor.set(change.changeId.actorId, [change]);
+ } else {
+ actorChanges.push(change);
+ }
+ }
+
+ for (const actorChanges of byActor.values()) {
+ actorChanges.sort(compareChanges);
+ const first = actorChanges[0];
+ if (first !== undefined && first.changeId.counter !== 1) {
+ return first.changeId;
+ }
+ for (let index = 1; index < actorChanges.length; index += 1) {
+ const previous = actorChanges[index - 1] as CollaborationChange;
+ const current = actorChanges[index] as CollaborationChange;
+ if (
+ current.changeId.counter !== previous.changeId.counter + 1
+ || !dependsTransitively(
+ current,
+ changeIdKey(previous.changeId),
+ changes,
+ new Set(),
+ )
+ ) {
+ return current.changeId;
+ }
+ }
+ }
+ return null;
+}
+
+export function findActorDependencyFork(
+ changes: ReadonlyMap,
+): ChangeId | null {
+ for (const change of [...changes.values()].sort(compareChanges)) {
+ const sameActorDependencies = change.deps.filter((dependency) => (
+ dependency.actorId === change.changeId.actorId
+ ));
+ if (change.changeId.counter === 1) {
+ if (sameActorDependencies.length > 0) return change.changeId;
+ continue;
+ }
+ if (
+ sameActorDependencies.length !== 1
+ || sameActorDependencies[0]?.counter !== change.changeId.counter - 1
+ ) {
+ return change.changeId;
+ }
+ }
+ return null;
+}
+
+export function freezeChangeId(changeId: ChangeId): ChangeId {
+ return Object.freeze({
+ actorId: changeId.actorId,
+ counter: changeId.counter,
+ });
+}
+
+export function fingerprintJSON(value: JSONValue): string {
+ return `sha256:${sha256(canonicalStringify(value))}`;
+}
+
+export function canonicalStringify(value: JSONValue): string {
+ if (value === null || typeof value !== "object") {
+ return JSON.stringify(value);
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalStringify).join(",")}]`;
+ }
+ const entries = Object.entries(value)
+ .sort(([left], [right]) => (
+ left < right ? -1 : left > right ? 1 : 0
+ ))
+ .map(([key, child]) => (
+ `${JSON.stringify(key)}:${canonicalStringify(child)}`
+ ));
+ return `{${entries.join(",")}}`;
+}
+
+function prepareEpoch(
+ input: unknown,
+):
+ | { readonly ok: true; readonly epoch: CollaborationEpoch }
+ | { readonly ok: false; readonly reason: string } {
+ if (!isRecord(input)) return invalid("bundle epoch must be an object");
+ if (input.protocolVersion !== 3) {
+ return invalid("bundle protocolVersion must be 3");
+ }
+ if (!isNonEmptyString(input.epochId)) {
+ return invalid("bundle epochId must be a non-empty string");
+ }
+ if (!isRecord(input.ruleset)) {
+ return invalid("bundle ruleset must be an object");
+ }
+ if (!isNonEmptyString(input.ruleset.id)) {
+ return invalid("bundle ruleset id must be a non-empty string");
+ }
+ if (!isNonEmptyString(input.ruleset.digest)) {
+ return invalid("bundle ruleset digest must be a non-empty string");
+ }
+ if (input.acceptance !== "none" && input.acceptance !== "custom") {
+ return invalid("bundle acceptance must be none or custom");
+ }
+ if (!isSha256Digest(input.baseDigest)) {
+ return invalid("bundle baseDigest must be a sha256 digest");
+ }
+ if (!isSha256Digest(input.membershipDigest)) {
+ return invalid("bundle membershipDigest must be a sha256 digest");
+ }
+ const parent = prepareEpochParent(input.parent);
+ if (!parent.ok) return parent;
+
+ return {
+ ok: true,
+ epoch: Object.freeze({
+ protocolVersion: 3,
+ epochId: input.epochId,
+ ruleset: Object.freeze({
+ id: input.ruleset.id,
+ digest: input.ruleset.digest,
+ }),
+ acceptance: input.acceptance,
+ baseDigest: input.baseDigest,
+ membershipDigest: input.membershipDigest,
+ parent: parent.parent,
+ }),
+ };
+}
+
+function prepareEpochParent(
+ input: unknown,
+):
+ | { readonly ok: true; readonly parent: CollaborationEpochParent | null }
+ | { readonly ok: false; readonly reason: string } {
+ if (input === null) return { ok: true, parent: null };
+ if (
+ !isRecord(input)
+ || !isNonEmptyString(input.epochId)
+ || !isSha256Digest(input.checkpointDigest)
+ ) {
+ return invalid(
+ "bundle epoch parent must be null or contain epochId and checkpointDigest",
+ );
+ }
+ return {
+ ok: true,
+ parent: Object.freeze({
+ epochId: input.epochId,
+ checkpointDigest: input.checkpointDigest,
+ }),
+ };
+}
+
+function prepareChange(
+ input: unknown,
+):
+ | { readonly ok: true; readonly change: CollaborationChange }
+ | { readonly ok: false; readonly reason: string } {
+ if (!isRecord(input)) return invalid("change must be an object");
+ const changeId = prepareChangeId(input.changeId);
+ if (!changeId.ok) return changeId;
+ if (!Array.isArray(input.deps)) {
+ return invalid("change deps must be an array");
+ }
+
+ const deps: ChangeId[] = [];
+ const dependencyKeys = new Set();
+ for (const inputDependency of input.deps) {
+ const dependency = prepareChangeId(inputDependency);
+ if (!dependency.ok) return dependency;
+ const key = changeIdKey(dependency.changeId);
+ if (key === changeIdKey(changeId.changeId)) {
+ return invalid("change cannot depend on itself");
+ }
+ if (dependencyKeys.has(key)) {
+ return invalid("change deps cannot contain duplicates");
+ }
+ dependencyKeys.add(key);
+ deps.push(dependency.changeId);
+ }
+
+ if (!Array.isArray(input.ops)) {
+ return invalid("change ops must be an array");
+ }
+ const ops: SemanticOperation[] = [];
+ for (const inputOperation of input.ops) {
+ const operation = prepareSemanticOperation(inputOperation);
+ if (!operation.ok) return operation;
+ ops.push(operation.operation);
+ }
+
+ return {
+ ok: true,
+ change: Object.freeze({
+ changeId: changeId.changeId,
+ deps: Object.freeze(deps.sort(compareChangeIds)),
+ ops: Object.freeze(ops),
+ }),
+ };
+}
+
+function prepareChangeId(
+ input: unknown,
+):
+ | { readonly ok: true; readonly changeId: ChangeId }
+ | { readonly ok: false; readonly reason: string } {
+ if (!isRecord(input)) return invalid("changeId must be an object");
+ if (!isNonEmptyString(input.actorId)) {
+ return invalid("changeId actorId must be a non-empty string");
+ }
+ if (
+ typeof input.counter !== "number"
+ || !Number.isSafeInteger(input.counter)
+ || input.counter < 1
+ ) {
+ return invalid("changeId counter must be a positive safe integer");
+ }
+ return {
+ ok: true,
+ changeId: Object.freeze({
+ actorId: input.actorId,
+ counter: input.counter,
+ }),
+ };
+}
+
+function prepareSemanticOperation(
+ input: unknown,
+):
+ | { readonly ok: true; readonly operation: SemanticOperation }
+ | { readonly ok: false; readonly reason: string } {
+ if (!isRecord(input) || !isNonEmptyString(input.kind)) {
+ return invalid("semantic operation must have a kind");
+ }
+
+ if (input.kind === "test") {
+ if (!isNonEmptyString(input.target)) {
+ return invalid("test target must be a non-empty string");
+ }
+ const expected = ownJSON(input.expected);
+ if (!expected.ok) return expected;
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "test",
+ target: input.target,
+ expected: expected.value,
+ }),
+ };
+ }
+
+ if (input.kind === "set") {
+ if (!isNonEmptyString(input.target)) {
+ return invalid("set target must be a non-empty string");
+ }
+ const value = ownJSON(input.value);
+ if (!value.ok) return value;
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "set",
+ target: input.target,
+ value: value.value,
+ }),
+ };
+ }
+
+ if (input.kind === "insert") {
+ if (!isNonEmptyString(input.parent) || !isNonEmptyString(input.member)) {
+ return invalid("insert parent and member must be non-empty strings");
+ }
+ const placement = preparePlacement(input.placement);
+ if (!placement.ok) return placement;
+ const value = ownJSON(input.value);
+ if (!value.ok) return value;
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "insert",
+ parent: input.parent,
+ member: input.member,
+ placement: placement.placement,
+ value: value.value,
+ }),
+ };
+ }
+
+ if (input.kind === "remove") {
+ if (!isNonEmptyString(input.target)) {
+ return invalid("remove target must be a non-empty string");
+ }
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "remove",
+ target: input.target,
+ }),
+ };
+ }
+
+ if (input.kind === "move") {
+ if (
+ !isNonEmptyString(input.target)
+ || !isNonEmptyString(input.parent)
+ ) {
+ return invalid("move target and parent must be non-empty strings");
+ }
+ const placement = preparePlacement(input.placement);
+ if (!placement.ok) return placement;
+ if (
+ input.replaced !== undefined
+ && !isNonEmptyString(input.replaced)
+ ) {
+ return invalid("move replaced must be a non-empty string");
+ }
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "move",
+ target: input.target,
+ parent: input.parent,
+ placement: placement.placement,
+ ...(input.replaced === undefined
+ ? {}
+ : { replaced: input.replaced }),
+ }),
+ };
+ }
+
+ if (input.kind === "move-to-root") {
+ if (!isNonEmptyString(input.source) || !isNonEmptyString(input.root)) {
+ return invalid("move-to-root source and root must be non-empty strings");
+ }
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "move-to-root",
+ source: input.source,
+ root: input.root,
+ }),
+ };
+ }
+
+ if (input.kind === "text-splice") {
+ if (
+ !isNonEmptyString(input.target)
+ || !isNonEmptyString(input.textNode)
+ ) {
+ return invalid(
+ "text-splice target and textNode must be non-empty strings",
+ );
+ }
+ if (
+ !(input.left === null || isNonEmptyString(input.left))
+ || !(input.right === null || isNonEmptyString(input.right))
+ ) {
+ return invalid("text-splice anchors must be null or non-empty strings");
+ }
+ if (
+ input.left !== null
+ && input.right !== null
+ && input.left === input.right
+ ) {
+ return invalid("text-splice anchors must identify different atoms");
+ }
+ if (!Array.isArray(input.removed)) {
+ return invalid("text-splice removed must be an array");
+ }
+ const removed: string[] = [];
+ const removedIds = new Set();
+ for (const atomId of input.removed) {
+ if (!isNonEmptyString(atomId)) {
+ return invalid(
+ "text-splice removed atoms must be non-empty strings",
+ );
+ }
+ if (removedIds.has(atomId)) {
+ return invalid("text-splice removed atoms must be unique");
+ }
+ removedIds.add(atomId);
+ removed.push(atomId);
+ }
+ if (
+ (input.left !== null && removedIds.has(input.left))
+ || (input.right !== null && removedIds.has(input.right))
+ ) {
+ return invalid("text-splice cannot remove either boundary atom");
+ }
+ if (typeof input.inserted !== "string") {
+ return invalid("text-splice inserted must be a string");
+ }
+ if (removed.length === 0 && input.inserted.length === 0) {
+ return invalid("text-splice must insert or remove text");
+ }
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "text-splice",
+ target: input.target,
+ textNode: input.textNode,
+ left: input.left,
+ right: input.right,
+ removed: Object.freeze(removed),
+ inserted: input.inserted,
+ }),
+ };
+ }
+
+ if (input.kind === "undo-change") {
+ const target = prepareChangeId(input.target);
+ if (!target.ok) return target;
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "undo-change",
+ target: target.changeId,
+ }),
+ };
+ }
+
+ if (input.kind === "redo-change") {
+ const undo = prepareChangeId(input.undo);
+ if (!undo.ok) return undo;
+ return {
+ ok: true,
+ operation: Object.freeze({
+ kind: "redo-change",
+ undo: undo.changeId,
+ }),
+ };
+ }
+
+ return invalid(`unknown semantic operation kind: ${input.kind}`);
+}
+
+function preparePlacement(
+ input: unknown,
+):
+ | {
+ readonly ok: true;
+ readonly placement:
+ | { readonly kind: "object"; readonly key: string }
+ | {
+ readonly kind: "array";
+ readonly after: string | null;
+ readonly before: string | null;
+ };
+ }
+ | { readonly ok: false; readonly reason: string } {
+ if (!isRecord(input)) return invalid("placement must be an object");
+ if (input.kind === "object" && typeof input.key === "string") {
+ return {
+ ok: true,
+ placement: Object.freeze({ kind: "object", key: input.key }),
+ };
+ }
+ if (
+ input.kind === "array"
+ && (input.after === null || isNonEmptyString(input.after))
+ && (input.before === null || isNonEmptyString(input.before))
+ ) {
+ return {
+ ok: true,
+ placement: Object.freeze({
+ kind: "array",
+ after: input.after,
+ before: input.before,
+ }),
+ };
+ }
+ return invalid("placement must be an object key or array anchor");
+}
+
+function freezeSemanticOperation(
+ operation: SemanticOperation,
+): SemanticOperation {
+ const prepared = prepareSemanticOperation(operation);
+ if (!prepared.ok) {
+ throw new TypeError(prepared.reason);
+ }
+ return prepared.operation;
+}
+
+function ownJSON(
+ input: unknown,
+):
+ | { readonly ok: true; readonly value: JSONValue }
+ | { readonly ok: false; readonly reason: string } {
+ const result = applyPatch(input, []);
+ return result.ok
+ ? { ok: true, value: result.value }
+ : invalid(result.reason ?? result.code);
+}
+
+function invalid(reason: string): { readonly ok: false; readonly reason: string } {
+ return { ok: false, reason };
+}
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.length > 0;
+}
+
+function isSha256Digest(value: unknown): value is string {
+ return (
+ typeof value === "string"
+ && /^sha256:[0-9a-f]{64}$/.test(value)
+ );
+}
+
+function dependsTransitively(
+ change: CollaborationChange,
+ targetKey: string,
+ changes: ReadonlyMap,
+ seen: Set,
+): boolean {
+ for (const dependency of change.deps) {
+ const key = changeIdKey(dependency);
+ if (key === targetKey) return true;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const dependencyChange = changes.get(key);
+ if (
+ dependencyChange !== undefined
+ && dependsTransitively(
+ dependencyChange,
+ targetKey,
+ changes,
+ seen,
+ )
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function sha256(source: string): string {
+ const input = new TextEncoder().encode(source);
+ const zeroPadding = (64 - ((input.length + 1 + 8) % 64)) % 64;
+ const bytes = new Uint8Array(input.length + 1 + zeroPadding + 8);
+ bytes.set(input);
+ bytes[input.length] = 0x80;
+ const bitLength = BigInt(input.length) * 8n;
+ for (let index = 0; index < 8; index += 1) {
+ bytes[bytes.length - 1 - index] = Number(
+ (bitLength >> BigInt(index * 8)) & 0xffn,
+ );
+ }
+
+ const hash = [
+ 0x6a09e667,
+ 0xbb67ae85,
+ 0x3c6ef372,
+ 0xa54ff53a,
+ 0x510e527f,
+ 0x9b05688c,
+ 0x1f83d9ab,
+ 0x5be0cd19,
+ ];
+ const words = new Uint32Array(64);
+
+ for (let offset = 0; offset < bytes.length; offset += 64) {
+ for (let index = 0; index < 16; index += 1) {
+ const cursor = offset + index * 4;
+ words[index] = (
+ ((bytes[cursor] as number) << 24)
+ | ((bytes[cursor + 1] as number) << 16)
+ | ((bytes[cursor + 2] as number) << 8)
+ | (bytes[cursor + 3] as number)
+ ) >>> 0;
+ }
+ for (let index = 16; index < 64; index += 1) {
+ const left = words[index - 15] as number;
+ const right = words[index - 2] as number;
+ const sigma0 = (
+ rotateRight(left, 7)
+ ^ rotateRight(left, 18)
+ ^ (left >>> 3)
+ ) >>> 0;
+ const sigma1 = (
+ rotateRight(right, 17)
+ ^ rotateRight(right, 19)
+ ^ (right >>> 10)
+ ) >>> 0;
+ words[index] = (
+ (words[index - 16] as number)
+ + sigma0
+ + (words[index - 7] as number)
+ + sigma1
+ ) >>> 0;
+ }
+
+ let a = hash[0] as number;
+ let b = hash[1] as number;
+ let c = hash[2] as number;
+ let d = hash[3] as number;
+ let e = hash[4] as number;
+ let f = hash[5] as number;
+ let g = hash[6] as number;
+ let h = hash[7] as number;
+ for (let index = 0; index < 64; index += 1) {
+ const sum1 = (
+ rotateRight(e, 6)
+ ^ rotateRight(e, 11)
+ ^ rotateRight(e, 25)
+ ) >>> 0;
+ const choice = ((e & f) ^ (~e & g)) >>> 0;
+ const temporary1 = (
+ h
+ + sum1
+ + choice
+ + (SHA256_CONSTANTS[index] as number)
+ + (words[index] as number)
+ ) >>> 0;
+ const sum0 = (
+ rotateRight(a, 2)
+ ^ rotateRight(a, 13)
+ ^ rotateRight(a, 22)
+ ) >>> 0;
+ const majority = ((a & b) ^ (a & c) ^ (b & c)) >>> 0;
+ const temporary2 = (sum0 + majority) >>> 0;
+
+ h = g;
+ g = f;
+ f = e;
+ e = (d + temporary1) >>> 0;
+ d = c;
+ c = b;
+ b = a;
+ a = (temporary1 + temporary2) >>> 0;
+ }
+
+ hash[0] = ((hash[0] as number) + a) >>> 0;
+ hash[1] = ((hash[1] as number) + b) >>> 0;
+ hash[2] = ((hash[2] as number) + c) >>> 0;
+ hash[3] = ((hash[3] as number) + d) >>> 0;
+ hash[4] = ((hash[4] as number) + e) >>> 0;
+ hash[5] = ((hash[5] as number) + f) >>> 0;
+ hash[6] = ((hash[6] as number) + g) >>> 0;
+ hash[7] = ((hash[7] as number) + h) >>> 0;
+ }
+
+ return hash.map((word) => word.toString(16).padStart(8, "0")).join("");
+}
+
+function rotateRight(value: number, count: number): number {
+ return ((value >>> count) | (value << (32 - count))) >>> 0;
+}
+
+const SHA256_CONSTANTS = Object.freeze([
+ 0x428a2f98,
+ 0x71374491,
+ 0xb5c0fbcf,
+ 0xe9b5dba5,
+ 0x3956c25b,
+ 0x59f111f1,
+ 0x923f82a4,
+ 0xab1c5ed5,
+ 0xd807aa98,
+ 0x12835b01,
+ 0x243185be,
+ 0x550c7dc3,
+ 0x72be5d74,
+ 0x80deb1fe,
+ 0x9bdc06a7,
+ 0xc19bf174,
+ 0xe49b69c1,
+ 0xefbe4786,
+ 0x0fc19dc6,
+ 0x240ca1cc,
+ 0x2de92c6f,
+ 0x4a7484aa,
+ 0x5cb0a9dc,
+ 0x76f988da,
+ 0x983e5152,
+ 0xa831c66d,
+ 0xb00327c8,
+ 0xbf597fc7,
+ 0xc6e00bf3,
+ 0xd5a79147,
+ 0x06ca6351,
+ 0x14292967,
+ 0x27b70a85,
+ 0x2e1b2138,
+ 0x4d2c6dfc,
+ 0x53380d13,
+ 0x650a7354,
+ 0x766a0abb,
+ 0x81c2c92e,
+ 0x92722c85,
+ 0xa2bfe8a1,
+ 0xa81a664b,
+ 0xc24b8b70,
+ 0xc76c51a3,
+ 0xd192e819,
+ 0xd6990624,
+ 0xf40e3585,
+ 0x106aa070,
+ 0x19a4c116,
+ 0x1e376c08,
+ 0x2748774c,
+ 0x34b0bcb5,
+ 0x391c0cb3,
+ 0x4ed8aa4a,
+ 0x5b9cca4f,
+ 0x682e6ff3,
+ 0x748f82ee,
+ 0x78a5636f,
+ 0x84c87814,
+ 0x8cc70208,
+ 0x90befffa,
+ 0xa4506ceb,
+ 0xbef9a3f7,
+ 0xc67178f2,
+]);
diff --git a/packages/json-document-collaboration/src/checkpoint.ts b/packages/json-document-collaboration/src/checkpoint.ts
new file mode 100644
index 00000000..720d7e20
--- /dev/null
+++ b/packages/json-document-collaboration/src/checkpoint.ts
@@ -0,0 +1,219 @@
+import {
+ applyPatch,
+ type JSONValue,
+} from "@interactive-os/json-document";
+
+import {
+ canonicalMembership,
+ canonicalStringify,
+ compareChanges,
+ fingerprintJSON,
+ prepareBundle,
+} from "./change.js";
+import type {
+ CollaborationCheckpoint,
+ CollaborationChange,
+ CollaborationEpoch,
+ CollaborationMembership,
+} from "./types.js";
+
+type PreparedCheckpoint =
+ | { readonly ok: true; readonly checkpoint: CollaborationCheckpoint }
+ | { readonly ok: false; readonly reason: string };
+
+export function createCheckpoint(
+ base: JSONValue,
+ membership: CollaborationMembership | null,
+ epoch: CollaborationEpoch,
+ changes: ReadonlyArray,
+): CollaborationCheckpoint {
+ const payload = Object.freeze({
+ kind: "json-document-collaboration/checkpoint" as const,
+ version: 1 as const,
+ epoch,
+ base,
+ membership,
+ changes: Object.freeze([...changes]),
+ });
+ return Object.freeze({
+ payload,
+ integrity: Object.freeze({
+ algorithm: "sha-256" as const,
+ digest: fingerprintJSON(payload as unknown as JSONValue),
+ }),
+ });
+}
+
+export function prepareCheckpoint(input: unknown): PreparedCheckpoint {
+ if (!isRecord(input) || !isRecord(input.payload)) {
+ return invalid("checkpoint and payload must be objects");
+ }
+ if (!hasOnlyKeys(input, ["payload", "integrity"])) {
+ return invalid("checkpoint contains unknown fields");
+ }
+ const rawPayload = applyPatch(input.payload, []);
+ if (!rawPayload.ok) {
+ return invalid(
+ rawPayload.reason ?? "checkpoint payload must contain only JSON values",
+ );
+ }
+ if (
+ input.payload.kind !== "json-document-collaboration/checkpoint"
+ || input.payload.version !== 1
+ ) {
+ return invalid("checkpoint payload kind or version is unsupported");
+ }
+
+ const base = applyPatch(input.payload.base, []);
+ if (!base.ok) {
+ return invalid(base.reason ?? "checkpoint base must be JSON");
+ }
+ const membership = prepareMembership(input.payload.membership);
+ if (!membership.ok) return membership;
+ const bundle = prepareBundle({
+ epoch: input.payload.epoch,
+ changes: input.payload.changes,
+ });
+ if (!bundle.ok) return bundle;
+ for (let index = 1; index < bundle.bundle.changes.length; index += 1) {
+ const previous = bundle.bundle.changes[index - 1];
+ const current = bundle.bundle.changes[index];
+ if (
+ previous === undefined
+ || current === undefined
+ || compareChanges(previous, current) >= 0
+ ) {
+ return invalid(
+ "checkpoint changes must be strictly sorted with unique changeIds",
+ );
+ }
+ }
+ if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base.value)) {
+ return invalid("checkpoint base does not match epoch baseDigest");
+ }
+ if (
+ bundle.bundle.epoch.membershipDigest
+ !== fingerprintJSON(membership.membership as unknown as JSONValue)
+ ) {
+ return invalid(
+ "checkpoint membership does not match epoch membershipDigest",
+ );
+ }
+
+ const payload = Object.freeze({
+ kind: "json-document-collaboration/checkpoint" as const,
+ version: 1 as const,
+ epoch: bundle.bundle.epoch,
+ base: base.value,
+ membership: membership.membership,
+ changes: bundle.bundle.changes,
+ });
+ if (
+ canonicalStringify(rawPayload.value)
+ !== canonicalStringify(payload as unknown as JSONValue)
+ ) {
+ return invalid("checkpoint payload must use the canonical wire shape");
+ }
+ if (!isRecord(input.integrity) || input.integrity.algorithm !== "sha-256") {
+ return invalid("checkpoint integrity algorithm must be sha-256");
+ }
+ if (
+ !hasOnlyKeys(input.integrity, [
+ "algorithm",
+ "digest",
+ "proof",
+ "keyId",
+ ])
+ ) {
+ return invalid("checkpoint integrity contains unknown fields");
+ }
+ const expectedDigest = fingerprintJSON(payload as unknown as JSONValue);
+ if (input.integrity.digest !== expectedDigest) {
+ return invalid("checkpoint integrity digest mismatch");
+ }
+ if (
+ input.integrity.proof !== undefined
+ && typeof input.integrity.proof !== "string"
+ ) {
+ return invalid("checkpoint integrity proof must be a string");
+ }
+ if (
+ input.integrity.keyId !== undefined
+ && typeof input.integrity.keyId !== "string"
+ ) {
+ return invalid("checkpoint integrity keyId must be a string");
+ }
+
+ return {
+ ok: true,
+ checkpoint: Object.freeze({
+ payload,
+ integrity: Object.freeze({
+ algorithm: "sha-256",
+ digest: expectedDigest,
+ ...(input.integrity.proof === undefined
+ ? {}
+ : { proof: input.integrity.proof }),
+ ...(input.integrity.keyId === undefined
+ ? {}
+ : { keyId: input.integrity.keyId }),
+ }),
+ }),
+ };
+}
+
+function prepareMembership(
+ input: unknown,
+):
+ | {
+ readonly ok: true;
+ readonly membership: CollaborationMembership | null;
+ }
+ | { readonly ok: false; readonly reason: string } {
+ if (input === null) return { ok: true, membership: null };
+ const validated = applyPatch(input, []);
+ if (!validated.ok) {
+ return invalid(
+ validated.reason ?? "checkpoint membership must contain only JSON values",
+ );
+ }
+ if (
+ !isRecord(validated.value)
+ || validated.value.version !== 1
+ || !Array.isArray(validated.value.members)
+ ) {
+ return invalid("checkpoint membership must be null or a version 1 list");
+ }
+ try {
+ const membership = canonicalMembership(
+ validated.value as unknown as CollaborationMembership,
+ );
+ if (
+ canonicalStringify(validated.value)
+ !== canonicalStringify(membership as unknown as JSONValue)
+ ) {
+ return invalid("checkpoint membership must be canonical");
+ }
+ return { ok: true, membership };
+ } catch (error) {
+ return invalid(
+ error instanceof Error ? error.message : "checkpoint membership is invalid",
+ );
+ }
+}
+
+function invalid(reason: string): { readonly ok: false; readonly reason: string } {
+ return { ok: false, reason };
+}
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function hasOnlyKeys(
+ value: Record,
+ allowed: ReadonlyArray,
+): boolean {
+ const keys = new Set(allowed);
+ return Object.keys(value).every((key) => keys.has(key));
+}
diff --git a/packages/json-document-collaboration/src/compact.ts b/packages/json-document-collaboration/src/compact.ts
new file mode 100644
index 00000000..953848d0
--- /dev/null
+++ b/packages/json-document-collaboration/src/compact.ts
@@ -0,0 +1,374 @@
+import type {
+ JSONCapabilityResult,
+ JSONValue,
+} from "@interactive-os/json-document";
+
+import {
+ canonicalMembership,
+ changeIdKey,
+ changesEqual,
+ createEpoch,
+ findActorDependencyFork,
+ findActorFork,
+ graphCycle,
+ prepareGraph,
+} from "./change.js";
+import {
+ createCheckpoint,
+ prepareCheckpoint,
+} from "./checkpoint.js";
+import {
+ acceptCandidate,
+ materializeChanges,
+} from "./materialize.js";
+import {
+ createInitialTree,
+ projectTree,
+} from "./tree.js";
+import type {
+ CollaborationChange,
+ CollaborationCheckpoint,
+ CollaborationCompactionOptions,
+ CollaborationCompactionResult,
+ CollaborationMembership,
+ ChangeId,
+} from "./types.js";
+
+export function compactCollaborationCheckpoint(
+ input: unknown,
+ options: CollaborationCompactionOptions,
+): CollaborationCompactionResult {
+ const prepared = prepareCheckpoint(input);
+ if (!prepared.ok) {
+ return failure("invalid_checkpoint", prepared.reason);
+ }
+ const checkpoint = prepared.checkpoint;
+ if (typeof options !== "object" || options === null) {
+ return failure("invalid_options", "compaction options must be an object");
+ }
+ if (
+ checkpoint.payload.epoch.acceptance === "custom"
+ && options.accepts === undefined
+ ) {
+ return failure(
+ "acceptance_required",
+ "this checkpoint requires the acceptance resolver bound to its ruleset",
+ );
+ }
+ if (
+ checkpoint.payload.epoch.acceptance === "none"
+ && options.accepts !== undefined
+ ) {
+ return failure(
+ "ruleset_mismatch",
+ "this checkpoint epoch does not bind a custom acceptance resolver",
+ );
+ }
+ const invalidOptions = validateOptions(checkpoint, options);
+ if (invalidOptions !== null) return invalidOptions;
+
+ const verification = verifyCheckpoint(checkpoint, options);
+ if (verification !== null) return verification;
+
+ const changes = new Map();
+ for (const change of checkpoint.payload.changes) {
+ const unauthorized = unauthorizedReference(
+ change,
+ checkpoint.payload.membership,
+ );
+ if (unauthorized !== null) {
+ return failure(
+ "membership_violation",
+ "checkpoint references an actor outside its epoch membership",
+ );
+ }
+ const key = changeIdKey(change.changeId);
+ const existing = changes.get(key);
+ if (existing !== undefined && !changesEqual(existing, change)) {
+ return failure(
+ "duplicate_mismatch",
+ "a checkpoint changeId has conflicting payloads",
+ );
+ }
+ if (existing === undefined) changes.set(key, change);
+ }
+ const cycle = graphCycle(changes);
+ if (cycle !== null) {
+ return failure(
+ "dependency_cycle",
+ "checkpoint causal dependencies contain a cycle",
+ );
+ }
+ const dependencyFork = findActorDependencyFork(changes);
+ if (dependencyFork !== null) {
+ return failure(
+ "actor_fork",
+ "checkpoint actor history is not one contiguous causal chain",
+ );
+ }
+ const graph = prepareGraph(changes);
+ if (graph.pending.length > 0) {
+ return failure(
+ "pending_changes",
+ "new-epoch compaction requires a checkpoint with no pending Changes",
+ );
+ }
+ const actorFork = findActorFork(graph.ordered);
+ if (actorFork !== null) {
+ return failure(
+ "actor_fork",
+ "checkpoint actor history is not one contiguous causal chain",
+ );
+ }
+
+ const initialTree = createInitialTree(
+ checkpoint.payload.base,
+ checkpoint.payload.epoch.baseDigest,
+ );
+ const initialProjection = projectTree(initialTree, () => false);
+ if (!initialProjection.ok) {
+ return failure(
+ "invalid_checkpoint",
+ initialProjection.reason ?? "checkpoint base is not materializable",
+ );
+ }
+ const initialAcceptance = safeAccept(
+ options.accepts,
+ initialProjection.value,
+ );
+ if (!initialAcceptance.ok) {
+ return failure(
+ initialAcceptance.code,
+ initialAcceptance.reason
+ ?? "checkpoint base was rejected by the source ruleset",
+ );
+ }
+
+ let materialized;
+ try {
+ materialized = materializeChanges(
+ initialTree,
+ graph.ordered,
+ (candidate) => safeAccept(options.accepts, candidate),
+ );
+ } catch (error) {
+ return failure(
+ "checkpoint_materialization_failed",
+ error instanceof Error
+ ? error.message
+ : "checkpoint materialization failed",
+ );
+ }
+ const nextAccepts = effectiveNextAcceptance(checkpoint, options);
+ const nextAcceptance = safeAccept(
+ nextAccepts,
+ materialized.value,
+ );
+ if (!nextAcceptance.ok) {
+ return failure(
+ nextAcceptance.code,
+ nextAcceptance.reason
+ ?? "compacted base was rejected by the next ruleset",
+ );
+ }
+
+ let nextMembership: CollaborationMembership | null;
+ try {
+ nextMembership = options.nextMembership === undefined
+ ? checkpoint.payload.membership
+ : options.nextMembership === null
+ ? null
+ : canonicalMembership(options.nextMembership);
+ } catch (error) {
+ return failure(
+ "invalid_membership",
+ error instanceof Error ? error.message : "next membership is invalid",
+ );
+ }
+
+ const nextEpoch = createEpoch(
+ materialized.value,
+ {
+ actorId: "__checkpoint_compactor__",
+ epochId: options.nextEpochId,
+ ruleset: options.nextRuleset,
+ ...(nextMembership === null ? {} : { membership: nextMembership }),
+ ...(nextAccepts === undefined
+ ? {}
+ : { accepts: nextAccepts }),
+ },
+ {
+ epochId: checkpoint.payload.epoch.epochId,
+ checkpointDigest: checkpoint.integrity.digest,
+ },
+ );
+ const compacted = createCheckpoint(
+ materialized.value,
+ nextMembership,
+ nextEpoch,
+ [],
+ );
+ const discardedHistoryControls = checkpoint.payload.changes.filter(
+ (change) => change.ops.some((operation) => (
+ operation.kind === "undo-change" || operation.kind === "redo-change"
+ )),
+ ).length;
+
+ return Object.freeze({
+ ok: true,
+ checkpoint: compacted,
+ report: Object.freeze({
+ discardedChanges: checkpoint.payload.changes.length,
+ discardedConflicts: materialized.conflicts.length,
+ discardedSuppressed: materialized.suppressed.length,
+ discardedHistoryControls,
+ }),
+ });
+}
+
+function validateOptions(
+ checkpoint: CollaborationCheckpoint,
+ options: CollaborationCompactionOptions,
+): Extract | null {
+ if (typeof options !== "object" || options === null) {
+ return failure("invalid_options", "compaction options must be an object");
+ }
+ if (options.mode !== "new-epoch") {
+ return failure(
+ "invalid_compaction_mode",
+ "causal history can only be compacted into a new epoch",
+ );
+ }
+ if (
+ typeof options.nextEpochId !== "string"
+ || options.nextEpochId.length === 0
+ ) {
+ return failure(
+ "invalid_epoch",
+ "nextEpochId must be a non-empty string",
+ );
+ }
+ if (options.nextEpochId === checkpoint.payload.epoch.epochId) {
+ return failure(
+ "epoch_reuse",
+ "new-epoch compaction must use a different epochId",
+ );
+ }
+ if (options.nextEpochId === checkpoint.payload.epoch.parent?.epochId) {
+ return failure(
+ "epoch_reuse",
+ "new-epoch compaction cannot reuse the immediate parent epochId",
+ );
+ }
+ if (
+ typeof options.nextRuleset !== "object"
+ || options.nextRuleset === null
+ || typeof options.nextRuleset.id !== "string"
+ || options.nextRuleset.id.length === 0
+ || typeof options.nextRuleset.digest !== "string"
+ || options.nextRuleset.digest.length === 0
+ ) {
+ return failure(
+ "invalid_ruleset",
+ "nextRuleset id and digest must be non-empty strings",
+ );
+ }
+ const sameRuleset = (
+ options.nextRuleset.id === checkpoint.payload.epoch.ruleset.id
+ && options.nextRuleset.digest === checkpoint.payload.epoch.ruleset.digest
+ );
+ const nextAcceptance = effectiveNextAcceptance(
+ checkpoint,
+ options,
+ ) === undefined
+ ? "none"
+ : "custom";
+ if (
+ sameRuleset
+ && nextAcceptance !== checkpoint.payload.epoch.acceptance
+ ) {
+ return failure(
+ "ruleset_mismatch",
+ "the same ruleset identity cannot change acceptance mode",
+ );
+ }
+ return null;
+}
+
+function effectiveNextAcceptance(
+ checkpoint: CollaborationCheckpoint,
+ options: CollaborationCompactionOptions,
+): CollaborationCompactionOptions["nextAccepts"] {
+ if (options.nextAccepts !== undefined) return options.nextAccepts;
+ return (
+ options.nextRuleset.id === checkpoint.payload.epoch.ruleset.id
+ && options.nextRuleset.digest === checkpoint.payload.epoch.ruleset.digest
+ )
+ ? options.accepts
+ : undefined;
+}
+
+function verifyCheckpoint(
+ checkpoint: CollaborationCheckpoint,
+ options: CollaborationCompactionOptions,
+): Extract | null {
+ if (options.verify === undefined) return null;
+ try {
+ const result = options.verify(checkpoint);
+ if (result?.ok === true) return null;
+ if (result?.ok === false && typeof result.code === "string") {
+ return failure(
+ result.code,
+ result.reason ?? "checkpoint proof verification failed",
+ );
+ }
+ return failure(
+ "checkpoint_verification_failed",
+ "checkpoint verifier must return a capability result",
+ );
+ } catch (error) {
+ return failure(
+ "checkpoint_verification_failed",
+ error instanceof Error
+ ? error.message
+ : "checkpoint proof verification failed",
+ );
+ }
+}
+
+function safeAccept(
+ accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined,
+ candidate: JSONValue,
+): JSONCapabilityResult {
+ return acceptCandidate(accepts, candidate);
+}
+
+function unauthorizedReference(
+ change: CollaborationChange,
+ membership: CollaborationMembership | null,
+): ChangeId | null {
+ if (membership === null) return null;
+ const allowed = (actorId: string): boolean => (
+ membership.members.some((member) => member.actorId === actorId)
+ );
+ if (!allowed(change.changeId.actorId)) return change.changeId;
+ for (const dependency of change.deps) {
+ if (!allowed(dependency.actorId)) return dependency;
+ }
+ for (const operation of change.ops) {
+ const referenced = operation.kind === "undo-change"
+ ? operation.target
+ : operation.kind === "redo-change"
+ ? operation.undo
+ : null;
+ if (referenced !== null && !allowed(referenced.actorId)) return referenced;
+ }
+ return null;
+}
+
+function failure(
+ code: string,
+ reason: string,
+): Extract {
+ return Object.freeze({ ok: false, code, reason });
+}
diff --git a/packages/json-document-collaboration/src/create.ts b/packages/json-document-collaboration/src/create.ts
new file mode 100644
index 00000000..d58c96b7
--- /dev/null
+++ b/packages/json-document-collaboration/src/create.ts
@@ -0,0 +1,1477 @@
+import {
+ applyPatch,
+ createJSONDocument,
+ parsePointer,
+ type JSONAppliedChange,
+ type JSONCapabilityResult,
+ type JSONDocument,
+ type JSONDocumentCommitOptions,
+ type JSONDocumentCommitResult,
+ type JSONPatchOperation,
+ type JSONValue,
+} from "@interactive-os/json-document";
+
+import {
+ canonicalMembership,
+ canonicalStringify,
+ changeIdKey,
+ changesEqual,
+ compareChangeIds,
+ compareChanges,
+ createEpoch,
+ findActorDependencyFork,
+ findActorFork,
+ freezeChangeId,
+ freezeLocalChange,
+ graphCycle,
+ membershipAllows,
+ prepareBundle,
+ prepareGraph,
+ type PreparedGraph,
+} from "./change.js";
+import { createCheckpoint } from "./checkpoint.js";
+import {
+ acceptCandidate,
+ historyOperationFor,
+ isUndoableChange,
+ materializeChanges,
+ projectAcceptedTree,
+ type MaterializedDocument,
+} from "./materialize.js";
+import {
+ createInitialTree,
+ projectTree,
+ resolveTextMemberSnapshot,
+ resolveTextSnapshot,
+ type TreeState,
+} from "./tree.js";
+import {
+ authoredTextAtomId,
+ createMinimalTextSplice,
+ initialTextAtomId,
+ projectText,
+ textUnits,
+ type TextAtomSnapshot,
+ type TextState,
+} from "./text-core.js";
+import { compilePatchOperations } from "./translate.js";
+import type {
+ ChangeId,
+ CollaborationBundle,
+ CollaborationChange,
+ CollaborationConflict,
+ CollaborationControl,
+ CollaborationEpoch,
+ CollaborationHistoryControl,
+ CollaborationHistoryResult,
+ CollaborationHistoryRuntime,
+ CollaborationHistorySnapshot,
+ CollaborationIngestResult,
+ CollaborationRuntime,
+ CollaborationRuntimeOptions,
+ CollaborationSnapshot,
+ CollaborationMembership,
+ CollaborationTextCapture,
+ CollaborationTextCommitResult,
+ CollaborationTextControl,
+ CollaborationTextObservation,
+ CollaborationTextPlan,
+ CollaborationTextPlanResult,
+ CollaborationTextRuntime,
+ CollaborationTextSelection,
+ PendingChange,
+ SuppressedChange,
+ TextAtomId,
+ TextSpliceOperation,
+} from "./types.js";
+
+interface PreparedLocalChange {
+ readonly patchValue: JSONValue;
+ readonly change: CollaborationChange | null;
+ readonly known: ReadonlyMap;
+ readonly graph: PreparedGraph;
+ readonly materialized: MaterializedDocument;
+}
+
+type PreparedLocalResult =
+ | { readonly ok: true; readonly value: PreparedLocalChange }
+ | Extract;
+
+interface PublicationEvent {
+ readonly documentChange?: JSONAppliedChange;
+ readonly collaborationSnapshot: CollaborationSnapshot;
+}
+
+interface PreparedHistoryChange {
+ readonly change: CollaborationChange;
+ readonly target: ChangeId;
+ readonly known: ReadonlyMap;
+ readonly graph: PreparedGraph;
+ readonly materialized: MaterializedDocument;
+ readonly projectionChanged: boolean;
+}
+
+type PreparedHistoryResult =
+ | { readonly ok: true; readonly value: PreparedHistoryChange }
+ | Extract;
+
+interface ResolvedHistoryState {
+ readonly snapshot: CollaborationHistorySnapshot;
+ readonly effectiveUndo: ChangeId | null;
+}
+
+interface InternalRuntime extends CollaborationHistoryRuntime {
+ readonly text?: CollaborationTextControl;
+}
+
+interface TextCaptureState {
+ readonly capture: CollaborationTextCapture;
+ readonly atoms: ReadonlyArray;
+ readonly deps: ReadonlyArray;
+ readonly actorCounter: number;
+}
+
+interface TextSelectionGap {
+ readonly left: TextAtomId | null;
+ readonly right: TextAtomId | null;
+ readonly affinity: "after-left" | "before-right";
+}
+
+interface TextPlanState {
+ readonly plan: CollaborationTextPlan;
+ readonly capture: TextCaptureState;
+ readonly operation: TextSpliceOperation | null;
+ readonly graphRevision: number;
+ readonly anchorGap: TextSelectionGap | null;
+ readonly focusGap: TextSelectionGap | null;
+}
+
+export function createCollaborationRuntime(
+ initial: unknown,
+ options: CollaborationRuntimeOptions,
+): CollaborationRuntime {
+ const runtime = createRuntime(initial, options);
+ return Object.freeze({
+ document: runtime.document,
+ collaboration: runtime.collaboration,
+ });
+}
+
+export function createCollaborationTextRuntime(
+ initial: unknown,
+ options: CollaborationRuntimeOptions,
+): CollaborationTextRuntime {
+ const runtime = createRuntime(initial, options, undefined, "text");
+ if (runtime.text === undefined) {
+ throw new Error("collaborative text profile was not initialized");
+ }
+ return Object.freeze({
+ document: runtime.document,
+ collaboration: runtime.collaboration,
+ text: runtime.text,
+ });
+}
+
+export function createCollaborationHistoryRuntime(
+ initial: unknown,
+ options: CollaborationRuntimeOptions,
+): CollaborationHistoryRuntime {
+ return createRuntime(initial, options);
+}
+
+export function createRestoredRuntime(
+ initial: unknown,
+ options: CollaborationRuntimeOptions,
+ expectedEpoch: CollaborationEpoch,
+): CollaborationHistoryRuntime {
+ return createRuntime(initial, options, expectedEpoch);
+}
+
+export function createRestoredTextRuntime(
+ initial: unknown,
+ options: CollaborationRuntimeOptions,
+ expectedEpoch: CollaborationEpoch,
+): CollaborationTextRuntime {
+ const runtime = createRuntime(initial, options, expectedEpoch, "text");
+ if (runtime.text === undefined) {
+ throw new Error("collaborative text profile was not initialized");
+ }
+ return Object.freeze({
+ document: runtime.document,
+ collaboration: runtime.collaboration,
+ text: runtime.text,
+ });
+}
+
+function createRuntime(
+ initial: unknown,
+ options: CollaborationRuntimeOptions,
+ expectedEpoch?: CollaborationEpoch,
+ profile: "atomic" | "text" = "atomic",
+): InternalRuntime {
+ validateOptions(options);
+
+ const validated = createJSONDocument(initial);
+ const initialValue = validated.value;
+ const membership = canonicalMembership(options.membership);
+ if (!membershipAllows(membership, options.actorId)) {
+ throw new TypeError("actorId is not admitted by this epoch membership");
+ }
+ const epoch = createEpoch(
+ initialValue,
+ options,
+ expectedEpoch?.parent ?? null,
+ );
+ if (
+ expectedEpoch !== undefined
+ && canonicalStringify(epoch as unknown as JSONValue)
+ !== canonicalStringify(expectedEpoch as unknown as JSONValue)
+ ) {
+ throw new TypeError("restored epoch does not match the checkpoint");
+ }
+ const initialTree = createInitialTree(
+ initialValue,
+ epoch.baseDigest,
+ );
+ const initialProjected = projectTree(initialTree, () => false);
+ if (!initialProjected.ok) {
+ throw new TypeError(`Initial collaboration tree is invalid: ${initialProjected.reason}`);
+ }
+
+ let evaluatingAcceptance = false;
+ const evaluateAcceptance = (candidate: JSONValue): JSONCapabilityResult => {
+ if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE;
+ evaluatingAcceptance = true;
+ try {
+ return acceptCandidate(options.accepts, candidate);
+ } finally {
+ evaluatingAcceptance = false;
+ }
+ };
+ const initialAcceptance = evaluateAcceptance(initialProjected.value);
+ if (!initialAcceptance.ok) {
+ throw new TypeError(
+ `Initial document value was rejected: ${initialAcceptance.reason ?? initialAcceptance.code}`,
+ );
+ }
+
+ const projection = createJSONDocument(initialProjected.value);
+ const actorId = options.actorId;
+ const documentListeners = new Set<(change: JSONAppliedChange) => void>();
+ const collaborationListeners = new Set<
+ (snapshot: CollaborationSnapshot) => void
+ >();
+ const publicationQueue: PublicationEvent[] = [];
+ let publishing = false;
+ let localCounter = 0;
+ let known = new Map();
+ let graph = prepareGraph(known);
+ let materialized = projectAcceptedTree(initialTree, [], Object.freeze([]));
+ let graphRevision = 0;
+ const textCaptures = new WeakMap<
+ CollaborationTextCapture,
+ TextCaptureState
+ >();
+ const textPlans = new WeakMap();
+
+ const prepareLocal = (
+ operations: ReadonlyArray,
+ ): PreparedLocalResult => {
+ if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE;
+ const patched = applyPatch(projection.value, operations);
+ if (!patched.ok) return patched;
+
+ const accepted = evaluateAcceptance(patched.value);
+ if (!accepted.ok) return accepted;
+
+ if (jsonEqual(projection.value, patched.value)) {
+ return {
+ ok: true,
+ value: {
+ patchValue: patched.value,
+ change: null,
+ known,
+ graph,
+ materialized,
+ },
+ };
+ }
+
+ if (graph.pending.some((row) => (
+ row.changeId.actorId === actorId
+ ))) {
+ return failure(
+ "actor_history_pending",
+ "cannot author while this actor has pending causal history",
+ );
+ }
+ if (localCounter >= Number.MAX_SAFE_INTEGER) {
+ return failure(
+ "actor_counter_exhausted",
+ "actor change counter reached the maximum safe integer",
+ );
+ }
+ const changeId: ChangeId = Object.freeze({
+ actorId,
+ counter: localCounter + 1,
+ });
+ const compiled = compilePatchOperations(
+ materialized.tree,
+ patched.change.applied,
+ changeId,
+ graph.ordered.length,
+ profile === "text" ? { collaborativeText: true } : undefined,
+ );
+ if (!compiled.ok) {
+ return failure(
+ "collaboration_unsupported",
+ compiled.reason,
+ );
+ }
+ const change = freezeLocalChange(
+ changeId,
+ authorDependencies(graph, actorId, localCounter),
+ compiled.value.ops,
+ );
+ const nextKnown = new Map(known);
+ nextKnown.set(changeIdKey(change.changeId), change);
+ const nextGraph = prepareGraph(nextKnown);
+ const nextMaterialized = materializeChanges(
+ initialTree,
+ nextGraph.ordered,
+ (candidate) => evaluateAcceptance(candidate),
+ );
+ if (!jsonEqual(nextMaterialized.value, patched.value)) {
+ return failure(
+ "collaboration_translation_mismatch",
+ "semantic collaboration operations did not preserve the JSON Patch result",
+ );
+ }
+
+ return {
+ ok: true,
+ value: {
+ patchValue: patched.value,
+ change,
+ known: nextKnown,
+ graph: nextGraph,
+ materialized: nextMaterialized,
+ },
+ };
+ };
+
+ const document = Object.freeze({
+ get value(): JSONValue {
+ return projection.value;
+ },
+ at(pointer: string) {
+ return projection.at(pointer);
+ },
+ query(jsonPath: string) {
+ return projection.query(jsonPath);
+ },
+ canPatch(
+ operations: ReadonlyArray,
+ ): JSONCapabilityResult {
+ const prepared = prepareLocal(operations);
+ return prepared.ok ? OK : prepared;
+ },
+ commit(
+ operations: ReadonlyArray,
+ commitOptions?: JSONDocumentCommitOptions,
+ ): JSONDocumentCommitResult {
+ const prepared = prepareLocal(operations);
+ if (!prepared.ok) return prepared;
+
+ const committed = projection.commit(operations, commitOptions);
+ if (!committed.ok) return committed;
+
+ if (prepared.value.change !== null) {
+ known = new Map(prepared.value.known);
+ graph = prepared.value.graph;
+ materialized = prepared.value.materialized;
+ localCounter = prepared.value.change.changeId.counter;
+ graphRevision += 1;
+ }
+
+ if (committed.change.applied.length > 0) {
+ enqueuePublication({
+ documentChange: committed.change,
+ collaborationSnapshot: currentSnapshot(),
+ });
+ }
+ return committed;
+ },
+ subscribe(listener: (change: JSONAppliedChange) => void): () => void {
+ documentListeners.add(listener);
+ let active = true;
+ return () => {
+ if (!active) return;
+ active = false;
+ documentListeners.delete(listener);
+ };
+ },
+ } satisfies JSONDocument);
+
+ const collaboration = Object.freeze({
+ epoch,
+ current: currentSnapshot,
+ exportBundle(): CollaborationBundle {
+ return Object.freeze({
+ epoch,
+ changes: Object.freeze([...known.values()].sort(compareChanges)),
+ });
+ },
+ exportCheckpoint() {
+ return createCheckpoint(
+ initialValue,
+ membership,
+ epoch,
+ [...known.values()].sort(compareChanges),
+ );
+ },
+ ingest(input: unknown): CollaborationIngestResult {
+ if (evaluatingAcceptance) {
+ return {
+ ok: false,
+ code: "acceptance_reentrancy",
+ reason: "acceptance callback cannot ingest collaboration changes",
+ };
+ }
+ const prepared = prepareBundle(input);
+ if (!prepared.ok) {
+ return {
+ ok: false,
+ code: "invalid_bundle",
+ reason: prepared.reason,
+ };
+ }
+ const compatibility = checkEpoch(epoch, prepared.bundle.epoch);
+ if (compatibility !== null) return compatibility;
+ const unauthorized = unauthorizedChange(
+ prepared.bundle.changes,
+ membership,
+ );
+ if (unauthorized !== null) {
+ return {
+ ok: false,
+ code: "membership_violation",
+ reason: "bundle references an actor outside this epoch membership",
+ changeId: freezeChangeId(unauthorized),
+ };
+ }
+
+ const nextKnown = new Map(known);
+ const duplicates: ChangeId[] = [];
+ let additions = 0;
+ for (const change of prepared.bundle.changes) {
+ const key = changeIdKey(change.changeId);
+ const existing = nextKnown.get(key);
+ if (existing !== undefined) {
+ if (!changesEqual(existing, change)) {
+ return {
+ ok: false,
+ code: "duplicate_mismatch",
+ reason: "a known changeId has a different payload",
+ changeId: freezeChangeId(change.changeId),
+ };
+ }
+ duplicates.push(freezeChangeId(change.changeId));
+ continue;
+ }
+ nextKnown.set(key, change);
+ additions += 1;
+ }
+
+ const cycle = graphCycle(nextKnown);
+ if (cycle !== null) {
+ return {
+ ok: false,
+ code: "dependency_cycle",
+ reason: "causal change dependencies contain a cycle",
+ changeId: freezeChangeId(cycle),
+ };
+ }
+ const dependencyFork = findActorDependencyFork(nextKnown);
+ if (dependencyFork !== null) {
+ return {
+ ok: false,
+ code: "actor_fork",
+ reason: "one actorId must form one contiguous causal change chain",
+ changeId: freezeChangeId(dependencyFork),
+ };
+ }
+ if (additions === 0) {
+ return {
+ ok: true,
+ integrated: Object.freeze([]),
+ pending: Object.freeze(graph.pending.map((row) => row.changeId)),
+ duplicates: Object.freeze(duplicates.sort(compareChangeIds)),
+ };
+ }
+
+ const nextGraph = prepareGraph(nextKnown);
+ const actorFork = findActorFork(nextGraph.ordered);
+ if (actorFork !== null) {
+ return {
+ ok: false,
+ code: "actor_fork",
+ reason: "one actorId must form one contiguous causal change chain",
+ changeId: freezeChangeId(actorFork),
+ };
+ }
+ const previousReady = graph.readyKeys;
+ const integrated = nextGraph.ordered
+ .filter((change) => !previousReady.has(changeIdKey(change.changeId)))
+ .map((change) => freezeChangeId(change.changeId))
+ .sort(compareChangeIds);
+ const nextMaterialized = materializeChanges(
+ initialTree,
+ nextGraph.ordered,
+ (candidate) => evaluateAcceptance(candidate),
+ );
+ const changed = !jsonEqual(projection.value, nextMaterialized.value);
+
+ known = nextKnown;
+ graph = nextGraph;
+ materialized = nextMaterialized;
+ graphRevision += 1;
+ for (const change of prepared.bundle.changes) {
+ if (
+ change.changeId.actorId === actorId
+ && change.changeId.counter > localCounter
+ ) {
+ localCounter = change.changeId.counter;
+ }
+ }
+
+ let documentChange: JSONAppliedChange | undefined;
+ if (changed) {
+ const publication = projection.commit([{
+ op: "replace",
+ path: "",
+ value: materialized.value,
+ }]);
+ if (!publication.ok) {
+ throw new Error(
+ `collaboration projection publication failed: ${publication.reason ?? publication.code}`,
+ );
+ }
+ documentChange = publication.change;
+ }
+ enqueuePublication({
+ ...(documentChange === undefined ? {} : { documentChange }),
+ collaborationSnapshot: currentSnapshot(),
+ });
+
+ return {
+ ok: true,
+ integrated: Object.freeze(integrated),
+ pending: Object.freeze(
+ nextGraph.pending
+ .map((row) => row.changeId)
+ .sort(compareChangeIds),
+ ),
+ duplicates: Object.freeze(duplicates.sort(compareChangeIds)),
+ };
+ },
+ subscribe(
+ listener: (snapshot: CollaborationSnapshot) => void,
+ ): () => void {
+ collaborationListeners.add(listener);
+ let active = true;
+ return () => {
+ if (!active) return;
+ active = false;
+ collaborationListeners.delete(listener);
+ };
+ },
+ } satisfies CollaborationControl);
+
+ const history = Object.freeze({
+ current(): CollaborationHistorySnapshot {
+ return resolveHistoryState().snapshot;
+ },
+ canUndo(): JSONCapabilityResult {
+ const prepared = prepareHistoryChange("undo");
+ return prepared.ok ? OK : prepared;
+ },
+ undo(): CollaborationHistoryResult {
+ return commitHistoryChange("undo");
+ },
+ canRedo(): JSONCapabilityResult {
+ const prepared = prepareHistoryChange("redo");
+ return prepared.ok ? OK : prepared;
+ },
+ redo(): CollaborationHistoryResult {
+ return commitHistoryChange("redo");
+ },
+ } satisfies CollaborationHistoryControl);
+
+ const text = profile === "text"
+ ? Object.freeze({
+ capture(pointer: string) {
+ if (evaluatingAcceptance) {
+ return textFailure(
+ "acceptance_reentrancy",
+ "acceptance callback cannot capture collaborative text",
+ );
+ }
+ let segments: string[];
+ try {
+ segments = parsePointer(pointer);
+ } catch (error) {
+ return textFailure(
+ "invalid_pointer",
+ error instanceof Error ? error.message : "invalid pointer",
+ );
+ }
+ const snapshot = resolveTextSnapshot(materialized.tree, segments);
+ if (!snapshot.ok) {
+ return textFailure(snapshot.code, snapshot.reason);
+ }
+ const capture = Object.freeze({
+ pointer,
+ target: snapshot.value.target,
+ textNode: snapshot.value.textNode,
+ value: snapshot.value.value,
+ });
+ const state: TextCaptureState = Object.freeze({
+ capture,
+ atoms: snapshot.value.atoms,
+ deps: Object.freeze(
+ authorDependencies(graph, actorId, localCounter)
+ .map(freezeChangeId),
+ ),
+ actorCounter: localCounter,
+ });
+ textCaptures.set(capture, state);
+ return Object.freeze({ ok: true, capture });
+ },
+ plan(
+ capture: CollaborationTextCapture,
+ observation: CollaborationTextObservation,
+ ): CollaborationTextPlanResult {
+ if (evaluatingAcceptance) {
+ return textFailure(
+ "acceptance_reentrancy",
+ "acceptance callback cannot plan collaborative text",
+ );
+ }
+ const captured = textCaptures.get(capture);
+ if (captured === undefined) {
+ return textFailure(
+ "invalid_text_capture",
+ "capture was not created by this text runtime",
+ );
+ }
+ if (captured.actorCounter !== localCounter) {
+ return textFailure(
+ "stale_text_capture",
+ "this actor authored another Change after text capture",
+ );
+ }
+ if (
+ typeof observation !== "object"
+ || observation === null
+ || typeof observation.value !== "string"
+ ) {
+ return textFailure(
+ "invalid_text_observation",
+ "text observation must contain a string value",
+ );
+ }
+ const selection = prepareTextSelection(
+ observation.selection,
+ observation.value,
+ );
+ if (!selection.ok) return selection;
+ const current = resolveTextMemberSnapshot(
+ materialized.tree,
+ captured.capture.target,
+ captured.capture.textNode,
+ );
+ if (!current.ok) {
+ return textFailure(current.code, current.reason);
+ }
+ if (graph.pending.some((row) => (
+ row.changeId.actorId === actorId
+ ))) {
+ return textFailure(
+ "actor_history_pending",
+ "cannot author while this actor has pending causal history",
+ );
+ }
+ const basis = Object.freeze({
+ textNode: captured.capture.textNode,
+ value: captured.capture.value,
+ atoms: captured.atoms,
+ });
+ const splice = createMinimalTextSplice(
+ basis,
+ observation.value,
+ );
+ if (
+ splice !== null
+ && localCounter >= Number.MAX_SAFE_INTEGER
+ ) {
+ return textFailure(
+ "actor_counter_exhausted",
+ "actor change counter reached the maximum safe integer",
+ );
+ }
+ const operation: TextSpliceOperation | null = splice === null
+ ? null
+ : Object.freeze({
+ kind: "text-splice",
+ target: captured.capture.target,
+ textNode: captured.capture.textNode,
+ left: splice.left,
+ right: splice.right,
+ removed: splice.removed,
+ inserted: splice.inserted,
+ });
+ const predictedChangeId = Object.freeze({
+ actorId,
+ counter: captured.actorCounter + 1,
+ });
+ const observedAtomIds = observedTextAtomIds(
+ captured.atoms,
+ operation,
+ predictedChangeId,
+ );
+ const selectionGaps = selection.value === null
+ ? null
+ : {
+ anchor: textSelectionGap(
+ observation.value,
+ observedAtomIds,
+ selection.value.anchor,
+ ),
+ focus: textSelectionGap(
+ observation.value,
+ observedAtomIds,
+ selection.value.focus,
+ ),
+ };
+ if (
+ selectionGaps !== null
+ && (selectionGaps.anchor === null || selectionGaps.focus === null)
+ ) {
+ return textFailure(
+ "invalid_text_offset",
+ "selection must use UTF-16 scalar boundaries",
+ );
+ }
+ const plan = Object.freeze({
+ pointer: captured.capture.pointer,
+ value: observation.value,
+ ...(selection.value === null
+ ? {}
+ : { selection: selection.value }),
+ });
+ textPlans.set(plan, Object.freeze({
+ plan,
+ capture: captured,
+ operation,
+ graphRevision,
+ anchorGap: selectionGaps?.anchor ?? null,
+ focusGap: selectionGaps?.focus ?? null,
+ }));
+ return Object.freeze({ ok: true, plan });
+ },
+ commit(plan: CollaborationTextPlan): CollaborationTextCommitResult {
+ if (evaluatingAcceptance) {
+ return textFailure(
+ "acceptance_reentrancy",
+ "acceptance callback cannot commit collaborative text",
+ );
+ }
+ const planned = textPlans.get(plan);
+ if (planned === undefined) {
+ return textFailure(
+ "invalid_text_plan",
+ "plan was not created by this text runtime",
+ );
+ }
+ if (
+ planned.capture.actorCounter !== localCounter
+ || graph.pending.some((row) => row.changeId.actorId === actorId)
+ ) {
+ return textFailure(
+ "stale_text_capture",
+ "this actor history changed after text capture",
+ );
+ }
+ if (planned.graphRevision !== graphRevision) {
+ return textFailure(
+ "stale_text_plan",
+ "causal state changed after text planning",
+ );
+ }
+ const current = resolveTextMemberSnapshot(
+ materialized.tree,
+ planned.capture.capture.target,
+ planned.capture.capture.textNode,
+ );
+ if (!current.ok) {
+ return textFailure(current.code, current.reason);
+ }
+ if (planned.operation === null) {
+ const textState = materialized.tree.texts.get(
+ planned.capture.capture.textNode,
+ );
+ const value = textState === undefined
+ ? current.value.value
+ : projectText(textState);
+ return Object.freeze({
+ ok: true,
+ changeId: null,
+ projectionChanged: false,
+ value,
+ selection: resolvePlannedSelection(planned, textState),
+ });
+ }
+ if (localCounter >= Number.MAX_SAFE_INTEGER) {
+ return textFailure(
+ "actor_counter_exhausted",
+ "actor change counter reached the maximum safe integer",
+ );
+ }
+
+ const changeId = Object.freeze({
+ actorId,
+ counter: localCounter + 1,
+ });
+ const change = freezeLocalChange(
+ changeId,
+ planned.capture.deps,
+ [planned.operation],
+ );
+ const nextKnown = new Map(known);
+ nextKnown.set(changeIdKey(changeId), change);
+ const nextGraph = prepareGraph(nextKnown);
+ const nextMaterialized = materializeChanges(
+ initialTree,
+ nextGraph.ordered,
+ (candidate) => evaluateAcceptance(candidate),
+ );
+ const changeKey = changeIdKey(changeId);
+ if (!nextMaterialized.history.appliedKeys.has(changeKey)) {
+ const suppressed = nextMaterialized.suppressed.find((entry) => (
+ changeIdKey(entry.changeId) === changeKey
+ ));
+ return textFailure(
+ suppressed?.code ?? "text_change_suppressed",
+ suppressed?.reason
+ ?? "collaborative text Change was suppressed",
+ );
+ }
+
+ const projectionChanged = !jsonEqual(
+ projection.value,
+ nextMaterialized.value,
+ );
+ known = nextKnown;
+ graph = nextGraph;
+ materialized = nextMaterialized;
+ localCounter = changeId.counter;
+ graphRevision += 1;
+
+ let documentChange: JSONAppliedChange | undefined;
+ if (projectionChanged) {
+ const publication = projection.commit([{
+ op: "replace",
+ path: "",
+ value: materialized.value,
+ }]);
+ if (!publication.ok) {
+ throw new Error(
+ `text projection publication failed: ${publication.reason ?? publication.code}`,
+ );
+ }
+ documentChange = publication.change;
+ }
+ enqueuePublication({
+ ...(documentChange === undefined ? {} : { documentChange }),
+ collaborationSnapshot: currentSnapshot(),
+ });
+
+ const textState = materialized.tree.texts.get(
+ planned.capture.capture.textNode,
+ );
+ if (textState === undefined) {
+ throw new Error("authored text generation is missing");
+ }
+ return Object.freeze({
+ ok: true,
+ changeId: freezeChangeId(changeId),
+ projectionChanged,
+ value: projectText(textState),
+ selection: resolvePlannedSelection(planned, textState),
+ });
+ },
+ } satisfies CollaborationTextControl)
+ : undefined;
+
+ return Object.freeze({
+ document,
+ collaboration,
+ history,
+ ...(text === undefined ? {} : { text }),
+ });
+
+ function resolveHistoryState(): ResolvedHistoryState {
+ let undoTarget: ChangeId | null = null;
+ let latestOwnDataIndex = -1;
+ for (let index = graph.ordered.length - 1; index >= 0; index -= 1) {
+ const change = graph.ordered[index];
+ if (
+ change !== undefined
+ && change.changeId.actorId === actorId
+ && isUndoableChange(change)
+ ) {
+ if (latestOwnDataIndex === -1) latestOwnDataIndex = index;
+ const key = changeIdKey(change.changeId);
+ if (
+ materialized.history.appliedKeys.has(key)
+ && !materialized.history.disabledByTarget.has(key)
+ ) {
+ undoTarget = freezeChangeId(change.changeId);
+ break;
+ }
+ }
+ }
+
+ let redoTarget: ChangeId | null = null;
+ let effectiveUndo: ChangeId | null = null;
+ for (let index = graph.ordered.length - 1; index >= 0; index -= 1) {
+ if (index <= latestOwnDataIndex) break;
+ const change = graph.ordered[index];
+ if (
+ change === undefined
+ || change.changeId.actorId !== actorId
+ || !materialized.history.appliedControlKeys.has(
+ changeIdKey(change.changeId),
+ )
+ ) {
+ continue;
+ }
+ const operation = historyOperationFor(change);
+ if (operation?.kind !== "undo-change") continue;
+ const targetKey = changeIdKey(operation.target);
+ const activeUndo = materialized.history.disabledByTarget.get(targetKey);
+ if (
+ activeUndo !== undefined
+ && changeIdKey(activeUndo) === changeIdKey(change.changeId)
+ ) {
+ redoTarget = freezeChangeId(operation.target);
+ effectiveUndo = freezeChangeId(change.changeId);
+ break;
+ }
+ }
+
+ return {
+ snapshot: Object.freeze({ undoTarget, redoTarget }),
+ effectiveUndo,
+ };
+ }
+
+ function prepareHistoryChange(
+ direction: "undo" | "redo",
+ ): PreparedHistoryResult {
+ if (evaluatingAcceptance) {
+ return failure(
+ "acceptance_reentrancy",
+ "acceptance callback cannot author history changes",
+ );
+ }
+ if (graph.pending.some((row) => row.changeId.actorId === actorId)) {
+ return failure(
+ "actor_history_pending",
+ "cannot author while this actor has pending causal history",
+ );
+ }
+ if (localCounter >= Number.MAX_SAFE_INTEGER) {
+ return failure(
+ "actor_counter_exhausted",
+ "actor change counter reached the maximum safe integer",
+ );
+ }
+
+ const resolved = resolveHistoryState();
+ const target = direction === "undo"
+ ? resolved.snapshot.undoTarget
+ : resolved.snapshot.redoTarget;
+ if (target === null) {
+ return failure(
+ direction === "undo" ? "nothing_to_undo" : "nothing_to_redo",
+ direction === "undo"
+ ? "this actor has no active accepted Change to undo"
+ : "this actor has no effective undo Change to redo",
+ );
+ }
+ if (direction === "redo" && resolved.effectiveUndo === null) {
+ return failure(
+ "nothing_to_redo",
+ "this actor has no effective undo Change to redo",
+ );
+ }
+
+ const changeId = Object.freeze({
+ actorId,
+ counter: localCounter + 1,
+ });
+ const operation = direction === "undo"
+ ? { kind: "undo-change" as const, target }
+ : {
+ kind: "redo-change" as const,
+ undo: resolved.effectiveUndo as ChangeId,
+ };
+ const change = freezeLocalChange(
+ changeId,
+ authorDependencies(graph, actorId, localCounter),
+ [operation],
+ );
+ const nextKnown = new Map(known);
+ nextKnown.set(changeIdKey(changeId), change);
+ const nextGraph = prepareGraph(nextKnown);
+ const nextMaterialized = materializeChanges(
+ initialTree,
+ nextGraph.ordered,
+ (candidate) => evaluateAcceptance(candidate),
+ );
+ const controlKey = changeIdKey(changeId);
+ if (!nextMaterialized.history.appliedControlKeys.has(controlKey)) {
+ const suppressed = nextMaterialized.suppressed.find((entry) => (
+ changeIdKey(entry.changeId) === controlKey
+ ));
+ return failure(
+ suppressed?.code ?? `${direction}_suppressed`,
+ suppressed?.reason ?? `${direction} could not preserve accepted changes`,
+ );
+ }
+
+ return {
+ ok: true,
+ value: {
+ change,
+ target,
+ known: nextKnown,
+ graph: nextGraph,
+ materialized: nextMaterialized,
+ projectionChanged: !jsonEqual(
+ projection.value,
+ nextMaterialized.value,
+ ),
+ },
+ };
+ }
+
+ function commitHistoryChange(
+ direction: "undo" | "redo",
+ ): CollaborationHistoryResult {
+ const prepared = prepareHistoryChange(direction);
+ if (!prepared.ok) return prepared;
+
+ known = new Map(prepared.value.known);
+ graph = prepared.value.graph;
+ materialized = prepared.value.materialized;
+ localCounter = prepared.value.change.changeId.counter;
+ graphRevision += 1;
+
+ let documentChange: JSONAppliedChange | undefined;
+ if (prepared.value.projectionChanged) {
+ const publication = projection.commit([{
+ op: "replace",
+ path: "",
+ value: materialized.value,
+ }]);
+ if (!publication.ok) {
+ throw new Error(
+ `history projection publication failed: ${publication.reason ?? publication.code}`,
+ );
+ }
+ documentChange = publication.change;
+ }
+ enqueuePublication({
+ ...(documentChange === undefined ? {} : { documentChange }),
+ collaborationSnapshot: currentSnapshot(),
+ });
+
+ return Object.freeze({
+ ok: true,
+ changeId: freezeChangeId(prepared.value.change.changeId),
+ target: freezeChangeId(prepared.value.target),
+ projectionChanged: prepared.value.projectionChanged,
+ });
+ }
+
+ function currentSnapshot(): CollaborationSnapshot {
+ return Object.freeze({
+ epoch,
+ heads: graph.heads,
+ pending: freezePending(graph.pending),
+ conflicts: freezeConflicts(materialized.conflicts),
+ suppressed: freezeSuppressed(materialized.suppressed),
+ });
+ }
+
+ function enqueuePublication(event: PublicationEvent): void {
+ publicationQueue.push(event);
+ if (publishing) return;
+
+ publishing = true;
+ try {
+ while (publicationQueue.length > 0) {
+ const next = publicationQueue.shift() as PublicationEvent;
+ if (next.documentChange !== undefined) {
+ for (const listener of [...documentListeners]) {
+ if (!documentListeners.has(listener)) continue;
+ try {
+ listener(next.documentChange);
+ } catch {
+ // Publication follows a committed state change. A listener
+ // failure cannot turn that write into an apparent failure or
+ // prevent delivery to the remaining active listeners.
+ }
+ }
+ }
+ for (const listener of [...collaborationListeners]) {
+ if (!collaborationListeners.has(listener)) continue;
+ try {
+ listener(next.collaborationSnapshot);
+ } catch {
+ // Collaboration snapshots follow committed causal state and use
+ // the same failure-isolation rule as the document Projection.
+ }
+ }
+ }
+ } finally {
+ publishing = false;
+ }
+ }
+}
+
+function checkEpoch(
+ expected: CollaborationEpoch,
+ actual: CollaborationEpoch,
+): Extract | null {
+ if (actual.epochId !== expected.epochId) {
+ return {
+ ok: false,
+ code: "epoch_mismatch",
+ reason: "bundle epochId does not match this document",
+ };
+ }
+ if (
+ actual.ruleset.id !== expected.ruleset.id
+ || actual.ruleset.digest !== expected.ruleset.digest
+ ) {
+ return {
+ ok: false,
+ code: "ruleset_mismatch",
+ reason: "bundle ruleset does not match this document epoch",
+ };
+ }
+ if (actual.acceptance !== expected.acceptance) {
+ return {
+ ok: false,
+ code: "ruleset_mismatch",
+ reason: "bundle acceptance mode does not match this document epoch",
+ };
+ }
+ if (actual.baseDigest !== expected.baseDigest) {
+ return {
+ ok: false,
+ code: "checkpoint_mismatch",
+ reason: "bundle checkpoint does not match this document epoch",
+ };
+ }
+ if (actual.membershipDigest !== expected.membershipDigest) {
+ return {
+ ok: false,
+ code: "membership_mismatch",
+ reason: "bundle membership does not match this document epoch",
+ };
+ }
+ if (
+ canonicalStringify(actual.parent as unknown as JSONValue)
+ !== canonicalStringify(expected.parent as unknown as JSONValue)
+ ) {
+ return {
+ ok: false,
+ code: "epoch_mismatch",
+ reason: "bundle epoch parent does not match this document epoch",
+ };
+ }
+ return null;
+}
+
+function validateOptions(options: CollaborationRuntimeOptions): void {
+ if (
+ typeof options !== "object"
+ || options === null
+ || typeof options.actorId !== "string"
+ || options.actorId.length === 0
+ ) {
+ throw new TypeError("actorId must be a non-empty string");
+ }
+ if (typeof options.epochId !== "string" || options.epochId.length === 0) {
+ throw new TypeError("epochId must be a non-empty string");
+ }
+ if (
+ typeof options.ruleset !== "object"
+ || options.ruleset === null
+ || typeof options.ruleset.id !== "string"
+ || options.ruleset.id.length === 0
+ || typeof options.ruleset.digest !== "string"
+ || options.ruleset.digest.length === 0
+ ) {
+ throw new TypeError("ruleset id and digest must be non-empty strings");
+ }
+ canonicalMembership(options.membership);
+}
+
+function unauthorizedChange(
+ changes: ReadonlyArray,
+ membership: CollaborationMembership | null,
+): ChangeId | null {
+ if (membership === null) return null;
+ for (const change of changes) {
+ if (!membershipAllows(membership, change.changeId.actorId)) {
+ return change.changeId;
+ }
+ for (const dependency of change.deps) {
+ if (!membershipAllows(membership, dependency.actorId)) {
+ return change.changeId;
+ }
+ }
+ for (const operation of change.ops) {
+ const referenced = operation.kind === "undo-change"
+ ? operation.target
+ : operation.kind === "redo-change"
+ ? operation.undo
+ : null;
+ if (
+ referenced !== null
+ && !membershipAllows(membership, referenced.actorId)
+ ) {
+ return change.changeId;
+ }
+ }
+ }
+ return null;
+}
+
+function freezePending(
+ pending: ReadonlyArray,
+): ReadonlyArray {
+ return Object.freeze(pending.map((row) => Object.freeze({
+ changeId: freezeChangeId(row.changeId),
+ missing: Object.freeze(row.missing.map(freezeChangeId)),
+ })));
+}
+
+function freezeConflicts(
+ conflicts: ReadonlyArray,
+): ReadonlyArray {
+ return Object.freeze([...conflicts]);
+}
+
+function freezeSuppressed(
+ suppressed: ReadonlyArray,
+): ReadonlyArray {
+ return Object.freeze([...suppressed]);
+}
+
+function authorDependencies(
+ graph: PreparedGraph,
+ actorId: string,
+ previousCounter: number,
+): ReadonlyArray {
+ if (previousCounter === 0) return graph.heads;
+ const previous = { actorId, counter: previousCounter };
+ const dependencies = [...graph.heads];
+ if (!dependencies.some((dependency) => (
+ changeIdKey(dependency) === changeIdKey(previous)
+ ))) {
+ dependencies.push(previous);
+ }
+ return Object.freeze(dependencies.sort(compareChangeIds));
+}
+
+function prepareTextSelection(
+ input: CollaborationTextSelection | undefined,
+ value: string,
+):
+ | {
+ readonly ok: true;
+ readonly value: CollaborationTextSelection | null;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ } {
+ if (input === undefined) return { ok: true, value: null };
+ if (
+ typeof input !== "object"
+ || input === null
+ || !Number.isSafeInteger(input.anchor)
+ || !Number.isSafeInteger(input.focus)
+ || input.anchor < 0
+ || input.focus < 0
+ || scalarBoundaryIndex(value, input.anchor) === null
+ || scalarBoundaryIndex(value, input.focus) === null
+ ) {
+ return textFailure(
+ "invalid_text_offset",
+ "selection offsets must be valid UTF-16 scalar boundaries",
+ );
+ }
+ return {
+ ok: true,
+ value: Object.freeze({
+ anchor: input.anchor,
+ focus: input.focus,
+ }),
+ };
+}
+
+function observedTextAtomIds(
+ captured: ReadonlyArray,
+ operation: TextSpliceOperation | null,
+ changeId: ChangeId,
+): ReadonlyArray {
+ if (operation === null) {
+ return Object.freeze(captured.map((atom) => atom.id));
+ }
+ const leftIndex = operation.left === null
+ ? -1
+ : captured.findIndex((atom) => atom.id === operation.left);
+ const rightIndex = operation.right === null
+ ? captured.length
+ : captured.findIndex((atom) => atom.id === operation.right);
+ const inserted = textUnits(operation.inserted).map((_, unitIndex) => (
+ authoredTextAtomId(changeId, 0, unitIndex)
+ ));
+ return Object.freeze([
+ ...captured.slice(0, leftIndex + 1).map((atom) => atom.id),
+ ...inserted,
+ ...captured.slice(rightIndex).map((atom) => atom.id),
+ ]);
+}
+
+function textSelectionGap(
+ value: string,
+ atomIds: ReadonlyArray,
+ offset: number,
+): TextSelectionGap | null {
+ const boundary = scalarBoundaryIndex(value, offset);
+ if (boundary === null || atomIds.length !== textUnits(value).length) {
+ return null;
+ }
+ return Object.freeze({
+ left: boundary === 0 ? null : atomIds[boundary - 1] ?? null,
+ right: boundary === atomIds.length ? null : atomIds[boundary] ?? null,
+ affinity: boundary === 0 ? "before-right" : "after-left",
+ });
+}
+
+function scalarBoundaryIndex(value: string, offset: number): number | null {
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > value.length) {
+ return null;
+ }
+ let cursor = 0;
+ const units = textUnits(value);
+ for (let index = 0; index <= units.length; index += 1) {
+ if (cursor === offset) return index;
+ const unit = units[index];
+ if (unit !== undefined) cursor += unit.length;
+ }
+ return null;
+}
+
+function resolvePlannedSelection(
+ planned: TextPlanState,
+ state: TextState | undefined,
+): CollaborationTextSelection | null {
+ if (
+ planned.plan.selection === undefined
+ || planned.anchorGap === null
+ || planned.focusGap === null
+ || state === undefined
+ ) {
+ return null;
+ }
+ return Object.freeze({
+ anchor: textGapOffset(state, planned.anchorGap),
+ focus: textGapOffset(state, planned.focusGap),
+ });
+}
+
+function textGapOffset(
+ state: TextState,
+ gap: TextSelectionGap,
+): number {
+ const order = state.order ?? textUnits(state.atomic).map((_, unitIndex) => (
+ initialTextAtomId(state.id, unitIndex)
+ ));
+ const rightIndex = gap.right === null ? -1 : order.indexOf(gap.right);
+ const leftIndex = gap.left === null ? -1 : order.indexOf(gap.left);
+ const boundary = gap.affinity === "after-left" && leftIndex >= 0
+ ? leftIndex + 1
+ : rightIndex >= 0
+ ? rightIndex
+ : leftIndex >= 0
+ ? leftIndex + 1
+ : gap.left === null
+ ? 0
+ : order.length;
+ let offset = 0;
+ for (let index = 0; index < boundary; index += 1) {
+ const id = order[index];
+ if (id === undefined) continue;
+ if (state.atoms === undefined) {
+ const unitIndex = Number(id.slice(id.lastIndexOf(":") + 1));
+ offset += textUnits(state.atomic)[unitIndex]?.length ?? 0;
+ continue;
+ }
+ const atom = state.atoms.get(id);
+ if (atom !== undefined && !atom.deleted) offset += atom.value.length;
+ }
+ return offset;
+}
+
+function textFailure(
+ code: string,
+ reason: string,
+): {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+} {
+ return Object.freeze({ ok: false, code, reason });
+}
+
+function jsonEqual(left: JSONValue, right: JSONValue): boolean {
+ return canonicalStringify(left) === canonicalStringify(right);
+}
+
+function failure(
+ code: string,
+ reason?: string,
+): Extract {
+ return Object.freeze({
+ ok: false,
+ code,
+ ...(reason === undefined ? {} : { reason }),
+ });
+}
+
+const OK: JSONCapabilityResult = Object.freeze({ ok: true });
+const ACCEPTANCE_REENTRANCY_FAILURE = failure(
+ "acceptance_reentrancy",
+ "acceptance callback cannot call canPatch or commit",
+);
diff --git a/packages/json-document-collaboration/src/history-index.ts b/packages/json-document-collaboration/src/history-index.ts
new file mode 100644
index 00000000..41d156b2
--- /dev/null
+++ b/packages/json-document-collaboration/src/history-index.ts
@@ -0,0 +1,10 @@
+export * from "./index.js";
+export { createCollaborationHistoryRuntime } from "./create.js";
+export { restoreCollaborationHistoryRuntime } from "./restore.js";
+export type {
+ CollaborationHistoryControl,
+ CollaborationHistoryResult,
+ CollaborationHistoryRestoreResult,
+ CollaborationHistoryRuntime,
+ CollaborationHistorySnapshot,
+} from "./types.js";
diff --git a/packages/json-document-collaboration/src/index.ts b/packages/json-document-collaboration/src/index.ts
new file mode 100644
index 00000000..b517884c
--- /dev/null
+++ b/packages/json-document-collaboration/src/index.ts
@@ -0,0 +1,42 @@
+export { compactCollaborationCheckpoint } from "./compact.js";
+export { createCollaborationRuntime } from "./create.js";
+export { restoreCollaborationRuntime } from "./restore.js";
+export type {
+ ActorId,
+ ArrayPlacement,
+ ChangeId,
+ CollaborationAcceptance,
+ CollaborationBundle,
+ CollaborationChange,
+ CollaborationCheckpoint,
+ CollaborationCheckpointPayload,
+ CollaborationCompactionOptions,
+ CollaborationCompactionReport,
+ CollaborationCompactionResult,
+ CollaborationConflict,
+ CollaborationControl,
+ CollaborationEpoch,
+ CollaborationEpochParent,
+ CollaborationIngestFailure,
+ CollaborationIngestResult,
+ CollaborationIngestSuccess,
+ CollaborationMember,
+ CollaborationMembership,
+ CollaborationRestoreOptions,
+ CollaborationRestoreResult,
+ CollaborationRulesetIdentity,
+ CollaborationRuntime,
+ CollaborationRuntimeOptions,
+ CollaborationSnapshot,
+ ContainerNodeId,
+ MemberId,
+ MemberPlacement,
+ ObjectPlacement,
+ PendingChange,
+ PositionId,
+ SemanticOperation,
+ SuppressedChange,
+ TextAtomId,
+ TextNodeId,
+ TextSpliceOperation,
+} from "./types.js";
diff --git a/packages/json-document-collaboration/src/materialize.ts b/packages/json-document-collaboration/src/materialize.ts
new file mode 100644
index 00000000..a92514ab
--- /dev/null
+++ b/packages/json-document-collaboration/src/materialize.ts
@@ -0,0 +1,570 @@
+import type {
+ JSONCapabilityResult,
+ JSONValue,
+} from "@interactive-os/json-document";
+
+import {
+ changeIdKey,
+ freezeChangeId,
+} from "./change.js";
+import {
+ applySemanticChange,
+ cloneTree,
+ projectTree,
+ type TreeState,
+} from "./tree.js";
+import type {
+ ChangeId,
+ CollaborationChange,
+ CollaborationConflict,
+ SemanticOperation,
+ SuppressedChange,
+} from "./types.js";
+
+type HistoryOperation = Extract<
+ SemanticOperation,
+ { readonly kind: "undo-change" | "redo-change" }
+>;
+
+export interface MaterializedHistoryState {
+ readonly disabledByTarget: ReadonlyMap;
+ readonly appliedKeys: ReadonlySet;
+ readonly appliedUndoTargets: ReadonlyMap;
+ readonly appliedControlKeys: ReadonlySet;
+}
+
+export interface MaterializedDocument {
+ readonly tree: TreeState;
+ readonly value: JSONValue;
+ readonly conflicts: ReadonlyArray;
+ readonly suppressed: ReadonlyArray;
+ readonly history: MaterializedHistoryState;
+}
+
+export function materializeChanges(
+ initialTree: TreeState,
+ ordered: ReadonlyArray,
+ accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined,
+): MaterializedDocument {
+ const isAncestor = createAncestry(ordered);
+ const changes = new Map(
+ ordered.map((change) => [changeIdKey(change.changeId), change]),
+ );
+ const disabledByTarget = new Map();
+ const appliedUndoTargets = new Map();
+ const appliedControlKeys = new Set();
+ const controlSuppressed: SuppressedChange[] = [];
+ let replay = replayDataChanges(
+ initialTree,
+ ordered,
+ disabledByTarget,
+ accepts,
+ isAncestor,
+ );
+
+ for (const change of ordered) {
+ const control = classifyHistoryChange(change);
+ if (control.kind === "none") continue;
+ if (control.kind === "invalid") {
+ controlSuppressed.push(freezeSuppressed(
+ change.changeId,
+ "invalid_history_change",
+ control.reason,
+ ));
+ continue;
+ }
+
+ const changeKey = changeIdKey(change.changeId);
+ if (control.operation.kind === "undo-change") {
+ const targetKey = changeIdKey(control.operation.target);
+ const target = changes.get(targetKey);
+ const invalidReason = validateUndoTarget(
+ change,
+ target,
+ control.operation.target,
+ replay,
+ disabledByTarget,
+ isAncestor,
+ );
+ if (invalidReason !== null) {
+ controlSuppressed.push(freezeSuppressed(
+ change.changeId,
+ invalidReason.code,
+ invalidReason.reason,
+ ));
+ continue;
+ }
+
+ const candidateDisabled = new Map(disabledByTarget);
+ candidateDisabled.set(targetKey, freezeChangeId(change.changeId));
+ const candidate = replayDataChanges(
+ initialTree,
+ ordered,
+ candidateDisabled,
+ accepts,
+ isAncestor,
+ );
+ const dependency = newlySuppressedAppliedChange(
+ replay,
+ candidate,
+ change.changeId,
+ changes,
+ isAncestor,
+ targetKey,
+ );
+ if (dependency !== null) {
+ controlSuppressed.push(freezeSuppressed(
+ change.changeId,
+ "undo_dependency_conflict",
+ `undo would suppress another accepted Change: ${dependency}`,
+ ));
+ continue;
+ }
+
+ disabledByTarget.clear();
+ for (const [key, value] of candidateDisabled) {
+ disabledByTarget.set(key, value);
+ }
+ replay = candidate;
+ appliedUndoTargets.set(changeKey, freezeChangeId(control.operation.target));
+ appliedControlKeys.add(changeKey);
+ continue;
+ }
+
+ const undoKey = changeIdKey(control.operation.undo);
+ const target = appliedUndoTargets.get(undoKey);
+ const targetKey = target === undefined ? null : changeIdKey(target);
+ const currentUndo = targetKey === null
+ ? undefined
+ : disabledByTarget.get(targetKey);
+ const latestActorUndo = latestEffectiveUndo(
+ disabledByTarget,
+ change.changeId.actorId,
+ );
+ const validRedo = (
+ target !== undefined
+ && currentUndo !== undefined
+ && changeIdKey(currentUndo) === undoKey
+ && latestActorUndo !== null
+ && changeIdKey(latestActorUndo) === undoKey
+ && change.changeId.actorId === control.operation.undo.actorId
+ && isAncestor(control.operation.undo, change.changeId)
+ && !hasInterveningActorData(
+ ordered,
+ control.operation.undo,
+ change.changeId,
+ )
+ );
+ if (!validRedo || targetKey === null) {
+ controlSuppressed.push(freezeSuppressed(
+ change.changeId,
+ "redo_target_invalid",
+ "redo must reference the currently effective causal undo from the same actor",
+ ));
+ continue;
+ }
+
+ const candidateDisabled = new Map(disabledByTarget);
+ candidateDisabled.delete(targetKey);
+ const candidate = replayDataChanges(
+ initialTree,
+ ordered,
+ candidateDisabled,
+ accepts,
+ isAncestor,
+ );
+ const dependency = newlySuppressedAppliedChange(
+ replay,
+ candidate,
+ change.changeId,
+ changes,
+ isAncestor,
+ );
+ if (
+ dependency !== null
+ || !candidate.appliedKeys.has(targetKey)
+ ) {
+ controlSuppressed.push(freezeSuppressed(
+ change.changeId,
+ "redo_dependency_conflict",
+ dependency === null
+ ? "redo target would remain suppressed"
+ : `redo would suppress another accepted Change: ${dependency}`,
+ ));
+ continue;
+ }
+
+ disabledByTarget.clear();
+ for (const [key, value] of candidateDisabled) {
+ disabledByTarget.set(key, value);
+ }
+ replay = candidate;
+ appliedControlKeys.add(changeKey);
+ }
+
+ const orderByKey = new Map(
+ ordered.map((change, order) => [changeIdKey(change.changeId), order]),
+ );
+ const suppressed = [...replay.suppressed, ...controlSuppressed]
+ .sort((left, right) => (
+ (orderByKey.get(changeIdKey(left.changeId)) ?? Number.MAX_SAFE_INTEGER)
+ - (orderByKey.get(changeIdKey(right.changeId)) ?? Number.MAX_SAFE_INTEGER)
+ ));
+ return {
+ tree: replay.tree,
+ value: replay.value,
+ conflicts: replay.conflicts,
+ suppressed: Object.freeze(suppressed),
+ history: {
+ disabledByTarget,
+ appliedKeys: replay.appliedKeys,
+ appliedUndoTargets,
+ appliedControlKeys,
+ },
+ };
+}
+
+export function projectAcceptedTree(
+ tree: TreeState,
+ ordered: ReadonlyArray,
+ suppressed: ReadonlyArray,
+): MaterializedDocument {
+ const projected = projectTree(tree, createAncestry(ordered));
+ if (!projected.ok) {
+ throw new Error(`materialized tree is invalid: ${projected.reason}`);
+ }
+ return {
+ tree,
+ value: projected.value,
+ conflicts: Object.freeze(projected.conflicts.map(freezeConflict)),
+ suppressed,
+ history: {
+ disabledByTarget: new Map(),
+ appliedKeys: new Set(),
+ appliedUndoTargets: new Map(),
+ appliedControlKeys: new Set(),
+ },
+ };
+}
+
+export function historyOperationFor(
+ change: CollaborationChange,
+): HistoryOperation | null {
+ const classified = classifyHistoryChange(change);
+ return classified.kind === "valid" ? classified.operation : null;
+}
+
+export function isUndoableChange(change: CollaborationChange): boolean {
+ return (
+ change.ops.length > 0
+ && change.ops.every((operation) => !isHistoryOperation(operation))
+ );
+}
+
+export function acceptCandidate(
+ accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined,
+ candidate: JSONValue,
+): JSONCapabilityResult {
+ if (accepts === undefined) return OK;
+ try {
+ const result = accepts(freezeJSON(candidate));
+ if (result?.ok === true) return OK;
+ if (result?.ok === false && typeof result.code === "string") {
+ return Object.freeze({
+ ok: false,
+ code: result.code,
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
+ ...(result.pointer === undefined ? {} : { pointer: result.pointer }),
+ });
+ }
+ return failure(
+ "schema_violation",
+ "acceptance callback must return a result with an ok discriminant",
+ );
+ } catch (error) {
+ return failure(
+ "schema_violation",
+ error instanceof Error ? error.message : "acceptance callback failed",
+ );
+ }
+}
+
+function createAncestry(
+ ordered: ReadonlyArray,
+): (left: ChangeId, right: ChangeId) => boolean {
+ const changes = new Map(
+ ordered.map((change) => [changeIdKey(change.changeId), change]),
+ );
+ const cache = new Map();
+
+ return (left: ChangeId, right: ChangeId): boolean => {
+ const leftKey = changeIdKey(left);
+ const rightKey = changeIdKey(right);
+ if (leftKey === rightKey) return false;
+ const pair = `${leftKey.length}:${leftKey}${rightKey}`;
+ const cached = cache.get(pair);
+ if (cached !== undefined) return cached;
+
+ const seen = new Set();
+ const visit = (currentKey: string): boolean => {
+ if (currentKey === leftKey) return true;
+ if (seen.has(currentKey)) return false;
+ seen.add(currentKey);
+ const current = changes.get(currentKey);
+ if (current === undefined) return false;
+ return current.deps.some((dependency) => visit(changeIdKey(dependency)));
+ };
+ const result = visit(rightKey);
+ cache.set(pair, result);
+ return result;
+ };
+}
+
+function freezeConflict(
+ conflict: CollaborationConflict,
+): CollaborationConflict {
+ if (conflict.kind === "object-key") {
+ return Object.freeze({
+ ...conflict,
+ alternatives: Object.freeze([...conflict.alternatives]),
+ });
+ }
+ return Object.freeze({
+ ...conflict,
+ winner: freezeChangeId(conflict.winner),
+ alternatives: Object.freeze(
+ conflict.alternatives.map(freezeChangeId),
+ ),
+ });
+}
+
+function freezeSuppressed(
+ changeId: ChangeId,
+ code: string,
+ reason?: string,
+ pointer?: string,
+): SuppressedChange {
+ return Object.freeze({
+ changeId: freezeChangeId(changeId),
+ code,
+ ...(reason === undefined ? {} : { reason }),
+ ...(pointer === undefined ? {} : { pointer }),
+ });
+}
+
+function failure(
+ code: string,
+ reason?: string,
+): Extract {
+ return Object.freeze({
+ ok: false,
+ code,
+ ...(reason === undefined ? {} : { reason }),
+ });
+}
+
+const OK: JSONCapabilityResult = Object.freeze({ ok: true });
+
+interface DataReplay {
+ readonly tree: TreeState;
+ readonly value: JSONValue;
+ readonly conflicts: ReadonlyArray;
+ readonly suppressed: ReadonlyArray;
+ readonly appliedKeys: ReadonlySet;
+}
+
+function replayDataChanges(
+ initialTree: TreeState,
+ ordered: ReadonlyArray,
+ disabledByTarget: ReadonlyMap,
+ accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined,
+ isAncestor: (left: ChangeId, right: ChangeId) => boolean,
+): DataReplay {
+ let tree = cloneTree(initialTree);
+ const suppressed: SuppressedChange[] = [];
+ const appliedKeys = new Set();
+
+ for (const [order, change] of ordered.entries()) {
+ const classified = classifyHistoryChange(change);
+ if (classified.kind !== "none") continue;
+ const key = changeIdKey(change.changeId);
+ if (disabledByTarget.has(key)) continue;
+
+ const candidate = cloneTree(tree);
+ const applied = applySemanticChange(candidate, change, order);
+ if (!applied.ok) {
+ suppressed.push(freezeSuppressed(
+ change.changeId,
+ applied.code,
+ applied.reason,
+ ));
+ continue;
+ }
+
+ const projected = projectTree(candidate, isAncestor);
+ if (!projected.ok) {
+ suppressed.push(freezeSuppressed(
+ change.changeId,
+ projected.code,
+ projected.reason,
+ ));
+ continue;
+ }
+
+ const accepted = acceptCandidate(accepts, projected.value);
+ if (!accepted.ok) {
+ suppressed.push(freezeSuppressed(
+ change.changeId,
+ accepted.code,
+ accepted.reason,
+ accepted.pointer,
+ ));
+ continue;
+ }
+ tree = candidate;
+ appliedKeys.add(key);
+ }
+
+ const projected = projectTree(tree, isAncestor);
+ if (!projected.ok) {
+ throw new Error(`materialized tree is invalid: ${projected.reason}`);
+ }
+ return {
+ tree,
+ value: projected.value,
+ conflicts: Object.freeze(projected.conflicts.map(freezeConflict)),
+ suppressed: Object.freeze(suppressed),
+ appliedKeys,
+ };
+}
+
+function classifyHistoryChange(
+ change: CollaborationChange,
+):
+ | { readonly kind: "none" }
+ | { readonly kind: "valid"; readonly operation: HistoryOperation }
+ | { readonly kind: "invalid"; readonly reason: string } {
+ const controls = change.ops.filter(isHistoryOperation);
+ if (controls.length === 0) return { kind: "none" };
+ if (controls.length !== 1 || change.ops.length !== 1) {
+ return {
+ kind: "invalid",
+ reason: "a history Change must contain exactly one history operation",
+ };
+ }
+ return { kind: "valid", operation: controls[0] as HistoryOperation };
+}
+
+function isHistoryOperation(
+ operation: SemanticOperation,
+): operation is HistoryOperation {
+ return (
+ operation.kind === "undo-change"
+ || operation.kind === "redo-change"
+ );
+}
+
+function validateUndoTarget(
+ undo: CollaborationChange,
+ target: CollaborationChange | undefined,
+ targetId: ChangeId,
+ replay: DataReplay,
+ disabledByTarget: ReadonlyMap,
+ isAncestor: (left: ChangeId, right: ChangeId) => boolean,
+): { readonly code: string; readonly reason: string } | null {
+ const targetKey = changeIdKey(targetId);
+ if (
+ target === undefined
+ || !isUndoableChange(target)
+ || undo.changeId.actorId !== targetId.actorId
+ || !isAncestor(targetId, undo.changeId)
+ ) {
+ return {
+ code: "undo_target_invalid",
+ reason: "undo must target an earlier causal data Change from the same actor",
+ };
+ }
+ if (
+ disabledByTarget.has(targetKey)
+ || !replay.appliedKeys.has(targetKey)
+ ) {
+ return {
+ code: "undo_target_inactive",
+ reason: "undo target is already inactive or suppressed",
+ };
+ }
+ return null;
+}
+
+function newlySuppressedAppliedChange(
+ previous: DataReplay,
+ candidate: DataReplay,
+ control: ChangeId,
+ changes: ReadonlyMap,
+ isAncestor: (left: ChangeId, right: ChangeId) => boolean,
+ ignoredKey?: string,
+): string | null {
+ for (const key of previous.appliedKeys) {
+ if (key === ignoredKey) continue;
+ const disappeared = changes.get(key);
+ if (
+ !candidate.appliedKeys.has(key)
+ && disappeared !== undefined
+ && isAncestor(disappeared.changeId, control)
+ ) {
+ return key;
+ }
+ }
+ return null;
+}
+
+function hasInterveningActorData(
+ ordered: ReadonlyArray,
+ start: ChangeId,
+ end: ChangeId,
+): boolean {
+ const startKey = changeIdKey(start);
+ const endKey = changeIdKey(end);
+ let between = false;
+ for (const change of ordered) {
+ const key = changeIdKey(change.changeId);
+ if (key === startKey) {
+ between = true;
+ continue;
+ }
+ if (key === endKey) return false;
+ if (
+ between
+ && change.changeId.actorId === end.actorId
+ && isUndoableChange(change)
+ ) {
+ return true;
+ }
+ }
+ return true;
+}
+
+function latestEffectiveUndo(
+ disabledByTarget: ReadonlyMap,
+ actorId: string,
+): ChangeId | null {
+ let latest: ChangeId | null = null;
+ for (const undo of disabledByTarget.values()) {
+ if (
+ undo.actorId === actorId
+ && (latest === null || undo.counter > latest.counter)
+ ) {
+ latest = undo;
+ }
+ }
+ return latest;
+}
+
+function freezeJSON(value: T): T {
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
+ return value;
+ }
+ for (const child of Object.values(value)) freezeJSON(child);
+ Object.freeze(value);
+ return value;
+}
diff --git a/packages/json-document-collaboration/src/restore.ts b/packages/json-document-collaboration/src/restore.ts
new file mode 100644
index 00000000..38c28701
--- /dev/null
+++ b/packages/json-document-collaboration/src/restore.ts
@@ -0,0 +1,188 @@
+import {
+ canonicalStringify,
+} from "./change.js";
+import { prepareCheckpoint } from "./checkpoint.js";
+import {
+ createRestoredRuntime,
+ createRestoredTextRuntime,
+} from "./create.js";
+import type { JSONValue } from "@interactive-os/json-document";
+import type {
+ CollaborationCheckpoint,
+ CollaborationHistoryRestoreResult,
+ CollaborationHistoryRuntime,
+ CollaborationRestoreOptions,
+ CollaborationRestoreResult,
+ CollaborationRuntime,
+ CollaborationTextRestoreResult,
+ CollaborationTextRuntime,
+} from "./types.js";
+
+type RestoredProfileRuntime =
+ | CollaborationHistoryRuntime
+ | CollaborationTextRuntime;
+
+type RestoredProfileResult =
+ | { readonly ok: true; readonly runtime: RestoredProfileRuntime }
+ | { readonly ok: false; readonly code: string; readonly reason: string };
+
+export function restoreCollaborationRuntime(
+ input: unknown,
+ options: CollaborationRestoreOptions,
+): CollaborationRestoreResult {
+ const restored = restoreHistoryRuntime(input, options);
+ if (!restored.ok) return restored;
+ const runtime: CollaborationRuntime = Object.freeze({
+ document: restored.runtime.document,
+ collaboration: restored.runtime.collaboration,
+ });
+ return Object.freeze({ ok: true, runtime });
+}
+
+export function restoreCollaborationHistoryRuntime(
+ input: unknown,
+ options: CollaborationRestoreOptions,
+): CollaborationHistoryRestoreResult {
+ const restored = restoreProfileRuntime(input, options, "history");
+ if (!restored.ok) return restored;
+ return Object.freeze({
+ ok: true,
+ runtime: restored.runtime as CollaborationHistoryRuntime,
+ });
+}
+
+export function restoreCollaborationTextRuntime(
+ input: unknown,
+ options: CollaborationRestoreOptions,
+): CollaborationTextRestoreResult {
+ const restored = restoreProfileRuntime(input, options, "text");
+ if (!restored.ok) return restored;
+ return Object.freeze({
+ ok: true,
+ runtime: restored.runtime as CollaborationTextRuntime,
+ });
+}
+
+function restoreHistoryRuntime(
+ input: unknown,
+ options: CollaborationRestoreOptions,
+): CollaborationHistoryRestoreResult {
+ return restoreCollaborationHistoryRuntime(input, options);
+}
+
+function restoreProfileRuntime(
+ input: unknown,
+ options: CollaborationRestoreOptions,
+ profile: "history" | "text",
+): RestoredProfileResult {
+ const prepared = prepareCheckpoint(input);
+ if (!prepared.ok) {
+ return failure("invalid_checkpoint", prepared.reason);
+ }
+ const checkpoint = prepared.checkpoint;
+ if (
+ checkpoint.payload.epoch.acceptance === "custom"
+ && options.accepts === undefined
+ ) {
+ return failure(
+ "acceptance_required",
+ "this checkpoint requires the acceptance resolver bound to its ruleset",
+ );
+ }
+ if (
+ checkpoint.payload.epoch.acceptance === "none"
+ && options.accepts !== undefined
+ ) {
+ return failure(
+ "ruleset_mismatch",
+ "this checkpoint epoch does not bind a custom acceptance resolver",
+ );
+ }
+ if (
+ canonicalStringify(
+ checkpoint.payload.epoch.ruleset as unknown as JSONValue,
+ )
+ !== canonicalStringify(options.ruleset as unknown as JSONValue)
+ ) {
+ return failure(
+ "ruleset_mismatch",
+ "restore ruleset does not match the checkpoint epoch",
+ );
+ }
+ const verification = verifyCheckpoint(checkpoint, options);
+ if (verification !== null) return verification;
+
+ try {
+ const restoreOptions = {
+ actorId: options.actorId,
+ epochId: checkpoint.payload.epoch.epochId,
+ ruleset: options.ruleset,
+ ...(checkpoint.payload.membership === null
+ ? {}
+ : { membership: checkpoint.payload.membership }),
+ ...(options.accepts === undefined
+ ? {}
+ : { accepts: options.accepts }),
+ };
+ const restored = profile === "text"
+ ? createRestoredTextRuntime(
+ checkpoint.payload.base,
+ restoreOptions,
+ checkpoint.payload.epoch,
+ )
+ : createRestoredRuntime(
+ checkpoint.payload.base,
+ restoreOptions,
+ checkpoint.payload.epoch,
+ );
+ const ingested = restored.collaboration.ingest({
+ epoch: checkpoint.payload.epoch,
+ changes: checkpoint.payload.changes,
+ });
+ if (!ingested.ok) {
+ return failure(ingested.code, ingested.reason);
+ }
+ const runtime: RestoredProfileRuntime = restored;
+ return Object.freeze({ ok: true, runtime });
+ } catch (error) {
+ return failure(
+ "checkpoint_restore_failed",
+ error instanceof Error ? error.message : "checkpoint restore failed",
+ );
+ }
+}
+
+function verifyCheckpoint(
+ checkpoint: CollaborationCheckpoint,
+ options: CollaborationRestoreOptions,
+): Extract | null {
+ if (options.verify === undefined) return null;
+ try {
+ const result = options.verify(checkpoint);
+ if (result?.ok === true) return null;
+ if (result?.ok === false && typeof result.code === "string") {
+ return failure(
+ result.code,
+ result.reason ?? "checkpoint proof verification failed",
+ );
+ }
+ return failure(
+ "checkpoint_verification_failed",
+ "checkpoint verifier must return a capability result",
+ );
+ } catch (error) {
+ return failure(
+ "checkpoint_verification_failed",
+ error instanceof Error
+ ? error.message
+ : "checkpoint proof verification failed",
+ );
+ }
+}
+
+function failure(
+ code: string,
+ reason: string,
+): Extract {
+ return Object.freeze({ ok: false, code, reason });
+}
diff --git a/packages/json-document-collaboration/src/text-core.ts b/packages/json-document-collaboration/src/text-core.ts
new file mode 100644
index 00000000..840ec329
--- /dev/null
+++ b/packages/json-document-collaboration/src/text-core.ts
@@ -0,0 +1,314 @@
+import { changeIdKey } from "./change.js";
+import type {
+ ChangeId,
+ TextAtomId,
+ TextNodeId,
+ TextSpliceOperation,
+} from "./types.js";
+
+export interface TextAtom {
+ readonly id: TextAtomId;
+ readonly value: string;
+ deleted: boolean;
+}
+
+export interface TextState {
+ readonly id: TextNodeId;
+ readonly atomic: string;
+ order?: TextAtomId[];
+ atoms?: Map;
+}
+
+export interface TextCoreFailure {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+}
+
+export interface TextAtomSnapshot {
+ readonly id: TextAtomId;
+ readonly value: string;
+}
+
+export interface TextSnapshot {
+ readonly textNode: TextNodeId;
+ readonly value: string;
+ readonly atoms: ReadonlyArray;
+}
+
+export interface MinimalTextSplice {
+ readonly left: TextAtomId | null;
+ readonly right: TextAtomId | null;
+ readonly removed: ReadonlyArray;
+ readonly inserted: string;
+}
+
+export function createTextNodeId(
+ seed: string,
+ path: string,
+): TextNodeId {
+ return `${seed}:text:${path}`;
+}
+
+export function createTextState(
+ id: TextNodeId,
+ value: string,
+): TextState {
+ return { id, atomic: value };
+}
+
+export function cloneTextState(state: TextState): TextState {
+ if (state.order === undefined || state.atoms === undefined) {
+ return {
+ id: state.id,
+ atomic: state.atomic,
+ };
+ }
+ return {
+ id: state.id,
+ atomic: state.atomic,
+ order: [...state.order],
+ atoms: new Map(
+ [...state.atoms].map(([id, atom]) => [
+ id,
+ {
+ id: atom.id,
+ value: atom.value,
+ deleted: atom.deleted,
+ },
+ ]),
+ ),
+ };
+}
+
+export function projectText(state: TextState): string {
+ if (state.order === undefined || state.atoms === undefined) {
+ return state.atomic;
+ }
+ let value = "";
+ for (const id of state.order) {
+ const atom = state.atoms.get(id);
+ if (atom !== undefined && !atom.deleted) value += atom.value;
+ }
+ return value;
+}
+
+export function snapshotText(state: TextState): TextSnapshot {
+ const atoms = state.order === undefined || state.atoms === undefined
+ ? textUnits(state.atomic).map((value, unitIndex) => Object.freeze({
+ id: initialTextAtomId(state.id, unitIndex),
+ value,
+ }))
+ : state.order.flatMap((id) => {
+ const atom = state.atoms?.get(id);
+ return atom === undefined || atom.deleted
+ ? []
+ : [Object.freeze({ id: atom.id, value: atom.value })];
+ });
+ return Object.freeze({
+ textNode: state.id,
+ value: atoms.map((atom) => atom.value).join(""),
+ atoms: Object.freeze(atoms),
+ });
+}
+
+export function createMinimalTextSplice(
+ before: TextSnapshot,
+ after: string,
+): MinimalTextSplice | null {
+ const afterUnits = textUnits(after);
+ let prefix = 0;
+ while (
+ prefix < before.atoms.length
+ && prefix < afterUnits.length
+ && before.atoms[prefix]?.value === afterUnits[prefix]
+ ) {
+ prefix += 1;
+ }
+
+ let suffix = 0;
+ while (
+ suffix < before.atoms.length - prefix
+ && suffix < afterUnits.length - prefix
+ && before.atoms[before.atoms.length - 1 - suffix]?.value
+ === afterUnits[afterUnits.length - 1 - suffix]
+ ) {
+ suffix += 1;
+ }
+
+ const removedAtoms = before.atoms.slice(
+ prefix,
+ before.atoms.length - suffix,
+ );
+ const inserted = afterUnits.slice(
+ prefix,
+ afterUnits.length - suffix,
+ ).join("");
+ if (removedAtoms.length === 0 && inserted.length === 0) return null;
+ return Object.freeze({
+ left: prefix === 0
+ ? null
+ : before.atoms[prefix - 1]?.id ?? null,
+ right: suffix === 0
+ ? null
+ : before.atoms[before.atoms.length - suffix]?.id ?? null,
+ removed: Object.freeze(removedAtoms.map((atom) => atom.id)),
+ inserted,
+ });
+}
+
+export function initialTextAtomId(
+ textNodeId: TextNodeId,
+ unitIndex: number,
+): TextAtomId {
+ return `text-atom:${textNodeId}:initial:${unitIndex}`;
+}
+
+export function authoredTextAtomId(
+ changeId: ChangeId,
+ opIndex: number,
+ unitIndex: number,
+): TextAtomId {
+ return `text-atom:${changeIdKey(changeId)}:${opIndex}:${unitIndex}`;
+}
+
+export function applyTextSplice(
+ state: TextState,
+ operation: TextSpliceOperation,
+ changeId: ChangeId,
+ opIndex: number,
+): TextCoreFailure | { readonly ok: true } {
+ const sequence = ensureSequence(state);
+ const leftIndex = boundaryIndex(
+ sequence.order,
+ sequence.atoms,
+ operation.left,
+ -1,
+ );
+ if (!leftIndex.ok) return leftIndex;
+ const rightIndex = boundaryIndex(
+ sequence.order,
+ sequence.atoms,
+ operation.right,
+ sequence.order.length,
+ );
+ if (!rightIndex.ok) return rightIndex;
+ if (leftIndex.value >= rightIndex.value) {
+ return failure(
+ "text_anchor_order_invalid",
+ "text gap anchors no longer identify an ordered interval",
+ );
+ }
+
+ const removed = new Set(operation.removed);
+ if (
+ (operation.left !== null && removed.has(operation.left))
+ || (operation.right !== null && removed.has(operation.right))
+ ) {
+ return failure(
+ "text_boundary_removed",
+ "text splice boundaries cannot also be removed",
+ );
+ }
+ for (const atomId of operation.removed) {
+ const atom = sequence.atoms.get(atomId);
+ const index = sequence.order.indexOf(atomId);
+ if (atom === undefined || index < 0) {
+ return failure(
+ "text_atom_not_found",
+ `text removal atom is missing: ${atomId}`,
+ );
+ }
+ if (index <= leftIndex.value || index >= rightIndex.value) {
+ return failure(
+ "text_removal_outside_gap",
+ `text removal atom is outside the captured gap: ${atomId}`,
+ );
+ }
+ }
+
+ for (const atomId of operation.removed) {
+ const atom = sequence.atoms.get(atomId) as TextAtom;
+ atom.deleted = true;
+ }
+
+ const insertedAtoms = textUnits(operation.inserted).map(
+ (value, unitIndex): TextAtom => ({
+ id: authoredTextAtomId(changeId, opIndex, unitIndex),
+ value,
+ deleted: false,
+ }),
+ );
+ for (const atom of insertedAtoms) {
+ if (sequence.atoms.has(atom.id)) {
+ return failure(
+ "text_atom_collision",
+ `text insertion atom already exists: ${atom.id}`,
+ );
+ }
+ }
+ sequence.order.splice(
+ rightIndex.value,
+ 0,
+ ...insertedAtoms.map((atom) => atom.id),
+ );
+ for (const atom of insertedAtoms) {
+ sequence.atoms.set(atom.id, atom);
+ }
+ return { ok: true };
+}
+
+export function textUnits(value: string): ReadonlyArray {
+ return Array.from(value);
+}
+
+function ensureSequence(
+ state: TextState,
+): {
+ readonly order: TextAtomId[];
+ readonly atoms: Map;
+} {
+ if (state.order !== undefined && state.atoms !== undefined) {
+ return {
+ order: state.order,
+ atoms: state.atoms,
+ };
+ }
+ const atoms = new Map();
+ const order = textUnits(state.atomic).map((value, unitIndex) => {
+ const id = initialTextAtomId(state.id, unitIndex);
+ atoms.set(id, { id, value, deleted: false });
+ return id;
+ });
+ state.order = order;
+ state.atoms = atoms;
+ return { order, atoms };
+}
+
+function boundaryIndex(
+ order: ReadonlyArray,
+ atoms: ReadonlyMap,
+ atomId: TextAtomId | null,
+ nullIndex: number,
+):
+ | { readonly ok: true; readonly value: number }
+ | TextCoreFailure {
+ if (atomId === null) return { ok: true, value: nullIndex };
+ if (!atoms.has(atomId)) {
+ return failure(
+ "text_anchor_not_found",
+ `text gap anchor is missing: ${atomId}`,
+ );
+ }
+ const index = order.indexOf(atomId);
+ return index < 0
+ ? failure(
+ "text_anchor_not_found",
+ `text gap anchor is missing from order: ${atomId}`,
+ )
+ : { ok: true, value: index };
+}
+
+function failure(code: string, reason: string): TextCoreFailure {
+ return { ok: false, code, reason };
+}
diff --git a/packages/json-document-collaboration/src/text-index.ts b/packages/json-document-collaboration/src/text-index.ts
new file mode 100644
index 00000000..8f4ba970
--- /dev/null
+++ b/packages/json-document-collaboration/src/text-index.ts
@@ -0,0 +1,15 @@
+export * from "./index.js";
+export { createCollaborationTextRuntime } from "./create.js";
+export { restoreCollaborationTextRuntime } from "./restore.js";
+export type {
+ CollaborationTextCapture,
+ CollaborationTextCaptureResult,
+ CollaborationTextCommitResult,
+ CollaborationTextControl,
+ CollaborationTextObservation,
+ CollaborationTextPlan,
+ CollaborationTextPlanResult,
+ CollaborationTextRestoreResult,
+ CollaborationTextRuntime,
+ CollaborationTextSelection,
+} from "./types.js";
diff --git a/packages/json-document-collaboration/src/translate.ts b/packages/json-document-collaboration/src/translate.ts
new file mode 100644
index 00000000..2b741e8d
--- /dev/null
+++ b/packages/json-document-collaboration/src/translate.ts
@@ -0,0 +1,415 @@
+import {
+ parsePointer,
+ type JSONPatchOperation,
+} from "@interactive-os/json-document";
+
+import { changeIdKey } from "./change.js";
+import {
+ applyAuthoredOperation,
+ arrayMembers,
+ arrayPositionId,
+ cloneTree,
+ objectMembersAt,
+ projectMemberValue,
+ resolveMember,
+ resolveParent,
+ resolveTextMemberSnapshot,
+ type TreeContainer,
+ type TreeFailure,
+ type TreeState,
+} from "./tree.js";
+import { createMinimalTextSplice } from "./text-core.js";
+import type {
+ ChangeId,
+ MemberId,
+ MemberPlacement,
+ SemanticOperation,
+ TextSpliceOperation,
+} from "./types.js";
+
+export interface CompiledOperations {
+ readonly ops: ReadonlyArray;
+ readonly tree: TreeState;
+}
+
+export interface CompilePatchOperationsOptions {
+ readonly collaborativeText?: boolean;
+}
+
+type CompileResult =
+ | { readonly ok: true; readonly value: CompiledOperations }
+ | TreeFailure;
+
+interface Destination {
+ readonly parent: TreeContainer;
+ readonly placement: MemberPlacement;
+ readonly existing?: MemberId;
+ readonly alternatives: ReadonlyArray;
+}
+
+export function compilePatchOperations(
+ base: TreeState,
+ operations: ReadonlyArray,
+ changeId: ChangeId,
+ order: number,
+ options: CompilePatchOperationsOptions = {},
+): CompileResult {
+ const tree = cloneTree(base);
+ const semantic: SemanticOperation[] = [];
+
+ const append = (operation: SemanticOperation): TreeFailure | null => {
+ const opIndex = semantic.length;
+ const applied = applyAuthoredOperation(
+ tree,
+ operation,
+ changeId,
+ opIndex,
+ order,
+ );
+ if (!applied.ok) return applied;
+ semantic.push(operation);
+ return null;
+ };
+ const removeMembers = (
+ memberIds: ReadonlyArray,
+ ): TreeFailure | null => {
+ for (const memberId of memberIds) {
+ const failed = append({ kind: "remove", target: memberId });
+ if (failed !== null) return failed;
+ }
+ return null;
+ };
+ const writeMember = (
+ memberId: MemberId,
+ value: Extract["value"],
+ allowCollaborativeText: boolean,
+ ): TreeFailure | null => {
+ if (
+ options.collaborativeText === true
+ && allowCollaborativeText
+ && typeof value === "string"
+ ) {
+ const snapshot = resolveTextMemberSnapshot(tree, memberId);
+ if (snapshot.ok) {
+ const planned = createMinimalTextSplice(snapshot.value, value);
+ if (planned === null) return null;
+ const operation: TextSpliceOperation = {
+ kind: "text-splice",
+ target: snapshot.value.target,
+ textNode: snapshot.value.textNode,
+ left: planned.left,
+ right: planned.right,
+ removed: planned.removed,
+ inserted: planned.inserted,
+ };
+ return append(operation);
+ }
+ if (snapshot.code !== "text_target_not_string") return snapshot;
+ }
+ return append({
+ kind: "set",
+ target: memberId,
+ value,
+ });
+ };
+ const writeDestination = (
+ destination: Destination,
+ value: Extract["value"],
+ allowCollaborativeText: boolean,
+ ): TreeFailure | null => {
+ const cleared = removeMembers(destination.alternatives);
+ if (cleared !== null) return cleared;
+ if (destination.existing !== undefined) {
+ return writeMember(
+ destination.existing,
+ value,
+ allowCollaborativeText,
+ );
+ }
+ return append(insertOrSet(
+ destination,
+ value,
+ changeId,
+ semantic.length,
+ ));
+ };
+
+ for (const operation of operations) {
+ if (operation.op === "test") {
+ const target = resolvePatchMember(tree, operation.path);
+ if (!target.ok) return target;
+ const failed = append({
+ kind: "test",
+ target: target.value,
+ expected: operation.value,
+ });
+ if (failed !== null) return failed;
+ continue;
+ }
+
+ if (operation.op === "add") {
+ const destination = destinationAt(tree, operation.path);
+ if (!destination.ok) {
+ if (operation.path !== "") return destination;
+ const failed = writeMember(tree.root, operation.value, true);
+ if (failed !== null) return failed;
+ continue;
+ }
+ const failed = writeDestination(
+ destination.value,
+ operation.value,
+ true,
+ );
+ if (failed !== null) return failed;
+ continue;
+ }
+
+ if (operation.op === "replace") {
+ const target = resolvePatchMember(tree, operation.path);
+ if (!target.ok) return target;
+ const cleared = removeMembers(objectAlternatives(tree, target.value));
+ if (cleared !== null) return cleared;
+ const failed = writeMember(target.value, operation.value, true);
+ if (failed !== null) return failed;
+ continue;
+ }
+
+ if (operation.op === "remove") {
+ const target = resolvePatchMember(tree, operation.path);
+ if (!target.ok) return target;
+ const failed = removeMembers([
+ ...objectAlternatives(tree, target.value),
+ target.value,
+ ]);
+ if (failed !== null) return failed;
+ continue;
+ }
+
+ if (operation.op === "copy") {
+ const source = resolvePatchMember(tree, operation.from);
+ if (!source.ok) return source;
+ const copied = projectMemberValue(tree, source.value);
+ if (!copied.ok) return copied;
+
+ if (operation.path === "") {
+ const failed = append({
+ kind: "set",
+ target: tree.root,
+ value: copied.value,
+ });
+ if (failed !== null) return failed;
+ continue;
+ }
+ const destination = destinationAt(tree, operation.path);
+ if (!destination.ok) return destination;
+ const failed = writeDestination(
+ destination.value,
+ copied.value,
+ false,
+ );
+ if (failed !== null) return failed;
+ continue;
+ }
+
+ const source = resolvePatchMember(tree, operation.from);
+ if (!source.ok) return source;
+ if (source.value === tree.root) {
+ return failure(
+ "unsupported_root_move",
+ "moving the root into one of its descendants is not supported",
+ );
+ }
+ const clearedSource = removeMembers(objectAlternatives(tree, source.value));
+ if (clearedSource !== null) return clearedSource;
+ if (operation.path === "") {
+ const failed = append({
+ kind: "move-to-root",
+ source: source.value,
+ root: tree.root,
+ });
+ if (failed !== null) return failed;
+ continue;
+ }
+
+ const withoutSource = cloneTree(tree);
+ const removed = applyAuthoredOperation(
+ withoutSource,
+ { kind: "remove", target: source.value },
+ changeId,
+ semantic.length,
+ order,
+ );
+ if (!removed.ok) return removed;
+ const destination = destinationAt(withoutSource, operation.path);
+ if (!destination.ok) return destination;
+ const clearedDestination = removeMembers(destination.value.alternatives);
+ if (clearedDestination !== null) return clearedDestination;
+ const failed = append({
+ kind: "move",
+ target: source.value,
+ parent: destination.value.parent.id,
+ placement: destination.value.placement,
+ ...(destination.value.existing === undefined
+ ? {}
+ : { replaced: destination.value.existing }),
+ });
+ if (failed !== null) return failed;
+ }
+
+ return {
+ ok: true,
+ value: {
+ ops: Object.freeze(semantic),
+ tree,
+ },
+ };
+}
+
+function insertOrSet(
+ destination: Destination,
+ value: Extract["value"],
+ changeId: ChangeId,
+ opIndex: number,
+): SemanticOperation {
+ if (destination.existing !== undefined) {
+ return {
+ kind: "set",
+ target: destination.existing,
+ value,
+ };
+ }
+ return {
+ kind: "insert",
+ parent: destination.parent.id,
+ member: authoredMemberId(changeId, opIndex),
+ placement: destination.placement,
+ value,
+ };
+}
+
+function destinationAt(
+ tree: TreeState,
+ pointer: string,
+): { readonly ok: true; readonly value: Destination } | TreeFailure {
+ let segments: string[];
+ try {
+ segments = parsePointer(pointer);
+ } catch (error) {
+ return failure(
+ "invalid_pointer",
+ error instanceof Error ? error.message : "invalid pointer",
+ );
+ }
+ const parent = resolveParent(tree, segments);
+ if (!parent.ok) return parent;
+
+ if (parent.value.container.kind === "object") {
+ const members = objectMembersAt(
+ tree,
+ parent.value.container,
+ parent.value.segment,
+ );
+ const existing = members.at(-1);
+ return {
+ ok: true,
+ value: {
+ parent: parent.value.container,
+ placement: {
+ kind: "object",
+ key: parent.value.segment,
+ },
+ ...(existing === undefined ? {} : { existing: existing.id }),
+ alternatives: Object.freeze(
+ members.slice(0, -1).map((member) => member.id),
+ ),
+ },
+ };
+ }
+
+ const children = arrayMembers(tree, parent.value.container);
+ const index = parent.value.segment === "-"
+ ? children.length
+ : parseArrayIndex(parent.value.segment);
+ if (index === null || index < 0 || index > children.length) {
+ return failure(
+ "invalid_array_index",
+ `array insertion index is invalid: ${parent.value.segment}`,
+ );
+ }
+ return {
+ ok: true,
+ value: {
+ parent: parent.value.container,
+ placement: {
+ kind: "array",
+ after: index === 0
+ ? null
+ : arrayPositionId(children[index - 1] as (typeof children)[number]),
+ before: index === children.length
+ ? null
+ : arrayPositionId(children[index] as (typeof children)[number]),
+ },
+ alternatives: Object.freeze([]),
+ },
+ };
+}
+
+function objectAlternatives(
+ tree: TreeState,
+ memberId: MemberId,
+): ReadonlyArray {
+ const member = tree.members.get(memberId);
+ const placed = member?.placed;
+ if (
+ member === undefined
+ || placed === undefined
+ || placed.placement.kind !== "object"
+ ) {
+ return [];
+ }
+ const container = tree.containers.get(placed.parent);
+ if (container === undefined || container.kind !== "object") return [];
+ return objectMembersAt(
+ tree,
+ container,
+ placed.placement.key,
+ )
+ .filter((candidate) => candidate.id !== memberId)
+ .map((candidate) => candidate.id);
+}
+
+function resolvePatchMember(
+ tree: TreeState,
+ pointer: string,
+): { readonly ok: true; readonly value: MemberId } | TreeFailure {
+ let segments: string[];
+ try {
+ segments = parsePointer(pointer);
+ } catch (error) {
+ return failure(
+ "invalid_pointer",
+ error instanceof Error ? error.message : "invalid pointer",
+ );
+ }
+ const resolved = resolveMember(tree, segments);
+ return resolved.ok
+ ? { ok: true, value: resolved.value.memberId }
+ : resolved;
+}
+
+function authoredMemberId(
+ changeId: ChangeId,
+ opIndex: number,
+): MemberId {
+ return `member:${changeIdKey(changeId)}:${opIndex}`;
+}
+
+function parseArrayIndex(segment: string): number | null {
+ if (!/^(0|[1-9]\d*)$/.test(segment)) return null;
+ const index = Number(segment);
+ return Number.isSafeInteger(index) ? index : null;
+}
+
+function failure(code: string, reason: string): TreeFailure {
+ return { ok: false, code, reason };
+}
diff --git a/packages/json-document-collaboration/src/tree.ts b/packages/json-document-collaboration/src/tree.ts
new file mode 100644
index 00000000..0143818e
--- /dev/null
+++ b/packages/json-document-collaboration/src/tree.ts
@@ -0,0 +1,1246 @@
+import type { JSONValue } from "@interactive-os/json-document";
+
+import {
+ canonicalStringify,
+ changeIdKey,
+ compareChangeIds,
+} from "./change.js";
+import {
+ applyTextSplice,
+ cloneTextState,
+ createTextNodeId,
+ createTextState,
+ projectText,
+ snapshotText,
+ type TextAtomSnapshot,
+ type TextState,
+} from "./text-core.js";
+import type {
+ ArrayPlacement,
+ ChangeId,
+ CollaborationChange,
+ CollaborationConflict,
+ ContainerNodeId,
+ MemberId,
+ MemberPlacement,
+ ObjectPlacement,
+ PositionId,
+ SemanticOperation,
+ TextNodeId,
+} from "./types.js";
+
+type ScalarJSON = null | boolean | number;
+
+type NodeReference =
+ | { readonly kind: "scalar"; readonly value: ScalarJSON }
+ | { readonly kind: "text"; readonly textNodeId: TextNodeId }
+ | { readonly kind: "container"; readonly containerId: ContainerNodeId };
+
+interface OperationStamp {
+ readonly changeId?: ChangeId;
+ readonly opIndex: number;
+ readonly order: number;
+}
+
+interface ValueWrite {
+ readonly node: NodeReference;
+ readonly stamp: OperationStamp;
+}
+
+interface PlacementWrite {
+ readonly placement: PlacedMember;
+ readonly stamp: OperationStamp;
+}
+
+interface PlacedMember {
+ readonly parent: ContainerNodeId;
+ readonly placement: MemberPlacement;
+ readonly positionId?: PositionId;
+}
+
+interface TreeMember {
+ readonly id: MemberId;
+ live: boolean;
+ node: NodeReference;
+ valueStamp: OperationStamp;
+ readonly valueWrites: ValueWrite[];
+ placed?: PlacedMember;
+ placementStamp?: OperationStamp;
+ readonly placementWrites: PlacementWrite[];
+}
+
+interface TreeContainer {
+ readonly id: ContainerNodeId;
+ readonly kind: "object" | "array";
+ /**
+ * Object containers retain MemberIds. Array containers retain immutable
+ * PositionIds so a moved or removed member leaves an anchorable tombstone.
+ */
+ readonly order: MemberId[];
+}
+
+export interface TreeState {
+ readonly root: MemberId;
+ readonly members: Map;
+ readonly containers: Map;
+ readonly arrayPositions: Map;
+ readonly texts: Map;
+}
+
+export interface TreeFailure {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+}
+
+export interface ProjectedTree {
+ readonly ok: true;
+ readonly value: JSONValue;
+ readonly conflicts: ReadonlyArray;
+}
+
+export interface ResolvedMember {
+ readonly memberId: MemberId;
+ readonly member: TreeMember;
+}
+
+export interface ResolvedParent {
+ readonly container: TreeContainer;
+ readonly segment: string;
+}
+
+export interface ResolvedTextSnapshot {
+ readonly target: MemberId;
+ readonly textNode: TextNodeId;
+ readonly value: string;
+ readonly atoms: ReadonlyArray;
+}
+
+type TreeResult = { readonly ok: true; readonly value: T } | TreeFailure;
+
+const INITIAL_STAMP: OperationStamp = Object.freeze({
+ opIndex: -1,
+ order: -1,
+});
+
+export function createInitialTree(
+ initial: JSONValue,
+ baseDigest: string,
+): TreeState {
+ const root = `root:${baseDigest}`;
+ const tree: TreeState = {
+ root,
+ members: new Map(),
+ containers: new Map(),
+ arrayPositions: new Map(),
+ texts: new Map(),
+ };
+ const built = buildNode(
+ tree,
+ initial,
+ `initial:${baseDigest}`,
+ "value",
+ INITIAL_STAMP,
+ );
+ if (!built.ok) throw new TypeError(built.reason);
+ tree.members.set(root, {
+ id: root,
+ live: true,
+ node: built.value,
+ valueStamp: INITIAL_STAMP,
+ valueWrites: [{ node: built.value, stamp: INITIAL_STAMP }],
+ placementWrites: [],
+ });
+ return tree;
+}
+
+export function cloneTree(tree: TreeState): TreeState {
+ const members = new Map();
+ for (const [id, member] of tree.members) {
+ members.set(id, {
+ id,
+ live: member.live,
+ node: member.node,
+ valueStamp: member.valueStamp,
+ valueWrites: [...member.valueWrites],
+ ...(member.placed === undefined ? {} : { placed: member.placed }),
+ ...(member.placementStamp === undefined
+ ? {}
+ : { placementStamp: member.placementStamp }),
+ placementWrites: [...member.placementWrites],
+ });
+ }
+ const containers = new Map();
+ for (const [id, container] of tree.containers) {
+ containers.set(id, {
+ id,
+ kind: container.kind,
+ order: [...container.order],
+ });
+ }
+ const texts = new Map();
+ for (const [id, state] of tree.texts) {
+ texts.set(id, cloneTextState(state));
+ }
+ return {
+ root: tree.root,
+ members,
+ containers,
+ arrayPositions: new Map(tree.arrayPositions),
+ texts,
+ };
+}
+
+export function applySemanticChange(
+ tree: TreeState,
+ change: CollaborationChange,
+ order: number,
+): TreeFailure | { readonly ok: true } {
+ for (const [opIndex, operation] of change.ops.entries()) {
+ const stamp: OperationStamp = {
+ changeId: change.changeId,
+ opIndex,
+ order,
+ };
+ const result = applySemanticOperation(tree, operation, stamp);
+ if (!result.ok) return result;
+ }
+ return { ok: true };
+}
+
+export function applyAuthoredOperation(
+ tree: TreeState,
+ operation: SemanticOperation,
+ changeId: ChangeId,
+ opIndex: number,
+ order: number,
+): TreeFailure | { readonly ok: true } {
+ return applySemanticOperation(tree, operation, {
+ changeId,
+ opIndex,
+ order,
+ });
+}
+
+export function projectTree(
+ tree: TreeState,
+ isAncestor: (left: ChangeId, right: ChangeId) => boolean,
+): ProjectedTree | TreeFailure {
+ const conflicts: CollaborationConflict[] = [];
+ const reachableMembers = new Set();
+ const projected = projectMember(
+ tree,
+ tree.root,
+ new Set(),
+ reachableMembers,
+ conflicts,
+ );
+ if (!projected.ok) return projected;
+
+ for (const memberId of [...reachableMembers].sort()) {
+ const member = tree.members.get(memberId);
+ if (member === undefined) continue;
+ const valueConflict = versionConflict(
+ member.valueWrites,
+ (write) => write.stamp,
+ isAncestor,
+ );
+ if (valueConflict !== null) {
+ conflicts.push({
+ kind: "member-value",
+ memberId,
+ winner: valueConflict.winner,
+ alternatives: valueConflict.alternatives,
+ });
+ }
+ const placementConflict = versionConflict(
+ member.placementWrites,
+ (write) => write.stamp,
+ isAncestor,
+ );
+ if (placementConflict !== null) {
+ conflicts.push({
+ kind: "member-placement",
+ memberId,
+ winner: placementConflict.winner,
+ alternatives: placementConflict.alternatives,
+ });
+ }
+ }
+
+ return {
+ ok: true,
+ value: projected.value,
+ conflicts,
+ };
+}
+
+export function resolveMember(
+ tree: TreeState,
+ segments: ReadonlyArray,
+): TreeResult {
+ let member = tree.members.get(tree.root);
+ if (member === undefined) return failure("missing_root", "root member is missing");
+
+ for (const segment of segments) {
+ if (!member.live) {
+ return failure("path_not_found", "pointer traverses a removed member");
+ }
+ if (member.node.kind !== "container") {
+ return failure("path_not_found", "pointer traverses a scalar value");
+ }
+ const container = tree.containers.get(member.node.containerId);
+ if (container === undefined) {
+ return failure("missing_container", "member container is missing");
+ }
+
+ const child = container.kind === "object"
+ ? visibleObjectMember(tree, container, segment)
+ : visibleArrayMember(tree, container, segment);
+ if (child === null) {
+ return failure("path_not_found", `path segment was not found: ${segment}`);
+ }
+ member = child;
+ }
+
+ return { ok: true, value: { memberId: member.id, member } };
+}
+
+export function resolveTextSnapshot(
+ tree: TreeState,
+ segments: ReadonlyArray,
+): TreeResult {
+ const resolved = resolveMember(tree, segments);
+ return resolved.ok
+ ? resolveTextMemberSnapshot(tree, resolved.value.memberId)
+ : resolved;
+}
+
+export function resolveTextMemberSnapshot(
+ tree: TreeState,
+ memberId: MemberId,
+ expectedTextNode?: TextNodeId,
+): TreeResult {
+ const member = tree.members.get(memberId);
+ if (member === undefined) {
+ return failure(
+ "text_target_not_found",
+ `text target is missing: ${memberId}`,
+ );
+ }
+ if (member.node.kind !== "text") {
+ return failure(
+ "text_target_not_string",
+ `text target is not a string: ${memberId}`,
+ );
+ }
+ if (
+ !member.live
+ && !containsReachableTextNode(tree, member.node.textNodeId)
+ ) {
+ return failure(
+ "text_target_deleted",
+ `text target is deleted: ${memberId}`,
+ );
+ }
+ if (
+ expectedTextNode !== undefined
+ && member.node.textNodeId !== expectedTextNode
+ ) {
+ return failure(
+ "text_generation_mismatch",
+ `text target generation changed: ${memberId}`,
+ );
+ }
+ const state = tree.texts.get(member.node.textNodeId);
+ if (state === undefined) {
+ return failure(
+ "missing_text_node",
+ `text node is missing: ${member.node.textNodeId}`,
+ );
+ }
+ const snapshot = snapshotText(state);
+ return {
+ ok: true,
+ value: Object.freeze({
+ target: memberId,
+ textNode: snapshot.textNode,
+ value: snapshot.value,
+ atoms: snapshot.atoms,
+ }),
+ };
+}
+
+export function resolveParent(
+ tree: TreeState,
+ segments: ReadonlyArray,
+): TreeResult {
+ if (segments.length === 0) {
+ return failure("invalid_root_target", "root has no parent container");
+ }
+ const parent = resolveMember(tree, segments.slice(0, -1));
+ if (!parent.ok) return parent;
+ if (parent.value.member.node.kind !== "container") {
+ return failure("path_not_found", "target parent is not a container");
+ }
+ const container = tree.containers.get(parent.value.member.node.containerId);
+ if (container === undefined) {
+ return failure("missing_container", "target parent container is missing");
+ }
+ return {
+ ok: true,
+ value: {
+ container,
+ segment: segments.at(-1) as string,
+ },
+ };
+}
+
+export function projectMemberValue(
+ tree: TreeState,
+ memberId: MemberId,
+): TreeResult {
+ const conflicts: CollaborationConflict[] = [];
+ const projected = projectMember(
+ tree,
+ memberId,
+ new Set(),
+ new Set(),
+ conflicts,
+ );
+ return projected.ok
+ ? { ok: true, value: projected.value }
+ : projected;
+}
+
+export function objectMemberAt(
+ tree: TreeState,
+ container: TreeContainer,
+ key: string,
+): TreeMember | null {
+ return objectMembersAt(tree, container, key).at(-1) ?? null;
+}
+
+export function objectMembersAt(
+ tree: TreeState,
+ container: TreeContainer,
+ key: string,
+): ReadonlyArray {
+ if (container.kind !== "object") return [];
+ const members = objectGroups(tree, container).get(key);
+ return members === undefined
+ ? []
+ : [...members].sort(compareMemberPlacements);
+}
+
+export function arrayMembers(
+ tree: TreeState,
+ container: TreeContainer,
+): ReadonlyArray {
+ return container.kind === "array"
+ ? visibleArrayMembers(tree, container)
+ : [];
+}
+
+export function arrayPositionId(member: TreeMember): PositionId | null {
+ return member.placed?.placement.kind === "array"
+ ? member.placed.positionId ?? null
+ : null;
+}
+
+function applySemanticOperation(
+ tree: TreeState,
+ operation: SemanticOperation,
+ stamp: OperationStamp,
+): TreeFailure | { readonly ok: true } {
+ if (operation.kind === "test") {
+ const target = tree.members.get(operation.target);
+ if (target === undefined || !target.live) {
+ return failure("test_target_not_found", `test target is not live: ${operation.target}`);
+ }
+ const actual = projectMemberValue(tree, target.id);
+ if (!actual.ok) return actual;
+ return canonicalStringify(actual.value)
+ === canonicalStringify(operation.expected)
+ ? { ok: true }
+ : failure("test_failed", `semantic test failed: ${operation.target}`);
+ }
+
+ if (operation.kind === "set") {
+ const target = tree.members.get(operation.target);
+ if (target === undefined || !target.live) {
+ return failure("target_not_found", `set target is not live: ${operation.target}`);
+ }
+ const built = buildNode(
+ tree,
+ operation.value,
+ operationSeed(stamp),
+ "value",
+ stamp,
+ );
+ if (!built.ok) return built;
+ target.node = built.value;
+ target.valueStamp = stamp;
+ target.valueWrites.push({ node: built.value, stamp });
+ return { ok: true };
+ }
+
+ if (operation.kind === "text-splice") {
+ const target = tree.members.get(operation.target);
+ if (target === undefined) {
+ return failure(
+ "text_target_not_found",
+ `text target is missing: ${operation.target}`,
+ );
+ }
+ if (target.node.kind !== "text") {
+ return failure(
+ "text_target_not_string",
+ `text target is not a string: ${operation.target}`,
+ );
+ }
+ if (target.node.textNodeId !== operation.textNode) {
+ return failure(
+ "text_generation_mismatch",
+ `text target generation changed: ${operation.target}`,
+ );
+ }
+ if (
+ !target.live
+ && !containsReachableTextNode(tree, operation.textNode)
+ ) {
+ return failure(
+ "text_target_deleted",
+ `text target is deleted: ${operation.target}`,
+ );
+ }
+ const text = tree.texts.get(operation.textNode);
+ if (text === undefined) {
+ return failure(
+ "missing_text_node",
+ `text node is missing: ${operation.textNode}`,
+ );
+ }
+ const changeId = stamp.changeId;
+ if (changeId === undefined) {
+ return failure(
+ "missing_change_id",
+ "text operations require an authored Change identity",
+ );
+ }
+ return applyTextSplice(
+ text,
+ operation,
+ changeId,
+ stamp.opIndex,
+ );
+ }
+
+ if (operation.kind === "insert") {
+ if (tree.members.has(operation.member)) {
+ return failure(
+ "identity_collision",
+ `insert member already exists: ${operation.member}`,
+ );
+ }
+ const parent = tree.containers.get(operation.parent);
+ if (parent === undefined) {
+ return failure("parent_not_found", `insert parent is missing: ${operation.parent}`);
+ }
+ const placementCheck = checkPlacement(parent, operation.placement);
+ if (!placementCheck.ok) return placementCheck;
+ const placed: PlacedMember = {
+ parent: parent.id,
+ placement: operation.placement,
+ ...(operation.placement.kind === "array"
+ ? { positionId: operationPositionId(operation.member, stamp) }
+ : {}),
+ };
+ const built = buildNode(
+ tree,
+ operation.value,
+ operationSeed(stamp),
+ "value",
+ stamp,
+ );
+ if (!built.ok) return built;
+ const member: TreeMember = {
+ id: operation.member,
+ live: true,
+ node: built.value,
+ valueStamp: stamp,
+ valueWrites: [{ node: built.value, stamp }],
+ placed,
+ placementStamp: stamp,
+ placementWrites: [{ placement: placed, stamp }],
+ };
+ tree.members.set(member.id, member);
+ const inserted = insertIntoContainer(
+ tree,
+ parent,
+ member.id,
+ operation.placement,
+ placed.positionId,
+ );
+ if (!inserted.ok) {
+ tree.members.delete(member.id);
+ return inserted;
+ }
+ if (placed.positionId !== undefined) {
+ tree.arrayPositions.set(placed.positionId, member.id);
+ }
+ return { ok: true };
+ }
+
+ if (operation.kind === "remove") {
+ const target = tree.members.get(operation.target);
+ if (
+ target === undefined
+ || target.id === tree.root
+ ) {
+ return failure("target_not_found", `remove target is invalid: ${operation.target}`);
+ }
+ if (target.live) target.live = false;
+ return { ok: true };
+ }
+
+ if (operation.kind === "move") {
+ const target = tree.members.get(operation.target);
+ if (
+ target === undefined
+ || !target.live
+ || target.id === tree.root
+ || target.placed === undefined
+ ) {
+ return failure("target_not_found", `move target is not live: ${operation.target}`);
+ }
+ const parent = tree.containers.get(operation.parent);
+ if (parent === undefined) {
+ return failure("parent_not_found", `move parent is missing: ${operation.parent}`);
+ }
+ const placementCheck = checkPlacement(parent, operation.placement);
+ if (!placementCheck.ok) return placementCheck;
+ if (
+ target.node.kind === "container"
+ && containsContainer(tree, target.node.containerId, parent.id, new Set())
+ ) {
+ return failure("move_cycle", "move target cannot contain its destination");
+ }
+
+ if (operation.replaced !== undefined) {
+ const replaced = tree.members.get(operation.replaced);
+ if (
+ replaced === undefined
+ || replaced.id === tree.root
+ || replaced.id === target.id
+ ) {
+ return failure(
+ "replaced_target_not_found",
+ `move replacement target is invalid: ${operation.replaced}`,
+ );
+ }
+ if (replaced.live) replaced.live = false;
+ }
+
+ const previousParent = tree.containers.get(target.placed.parent);
+ if (previousParent?.kind === "object") {
+ removeFromOrder(previousParent, target.id);
+ }
+ if (parent.kind === "object") {
+ removeFromOrder(parent, target.id);
+ }
+ const positionId = operation.placement.kind === "array"
+ ? operationPositionId(target.id, stamp)
+ : undefined;
+ const inserted = insertIntoContainer(
+ tree,
+ parent,
+ target.id,
+ operation.placement,
+ positionId,
+ );
+ if (!inserted.ok) return inserted;
+
+ const placed: PlacedMember = {
+ parent: parent.id,
+ placement: operation.placement,
+ ...(positionId === undefined ? {} : { positionId }),
+ };
+ if (positionId !== undefined) {
+ tree.arrayPositions.set(positionId, target.id);
+ }
+ target.placed = placed;
+ target.placementStamp = stamp;
+ target.placementWrites.push({ placement: placed, stamp });
+ return { ok: true };
+ }
+
+ if (
+ operation.kind === "undo-change"
+ || operation.kind === "redo-change"
+ ) {
+ return failure(
+ "history_control_in_data_change",
+ "history operations must be materialized by the history profile",
+ );
+ }
+
+ const source = tree.members.get(operation.source);
+ const root = tree.members.get(operation.root);
+ if (
+ source === undefined
+ || !source.live
+ || source.id === tree.root
+ || root === undefined
+ || root.id !== tree.root
+ ) {
+ return failure("target_not_found", "move-to-root target is not live");
+ }
+ root.node = source.node;
+ root.valueStamp = stamp;
+ root.valueWrites.push({ node: source.node, stamp });
+ source.live = false;
+ return { ok: true };
+}
+
+function buildNode(
+ tree: TreeState,
+ value: JSONValue,
+ seed: string,
+ path: string,
+ stamp: OperationStamp,
+): TreeResult {
+ if (typeof value === "string") {
+ const textNodeId = createTextNodeId(seed, path);
+ if (tree.texts.has(textNodeId)) {
+ return failure(
+ "identity_collision",
+ `text identity already exists: ${textNodeId}`,
+ );
+ }
+ tree.texts.set(textNodeId, createTextState(textNodeId, value));
+ return {
+ ok: true,
+ value: { kind: "text", textNodeId },
+ };
+ }
+ if (value === null || typeof value !== "object") {
+ return {
+ ok: true,
+ value: { kind: "scalar", value },
+ };
+ }
+
+ const containerId = `${seed}:container:${path}`;
+ if (tree.containers.has(containerId)) {
+ return failure(
+ "identity_collision",
+ `container identity already exists: ${containerId}`,
+ );
+ }
+ const container: TreeContainer = {
+ id: containerId,
+ kind: Array.isArray(value) ? "array" : "object",
+ order: [],
+ };
+ tree.containers.set(containerId, container);
+
+ const entries: ReadonlyArray = Array.isArray(value)
+ ? value.map((child, index) => [String(index), child] as const)
+ : Object.entries(value)
+ .sort(([left], [right]) => compareStrings(left, right));
+
+ let previousArrayPosition: PositionId | null = null;
+ for (const [segment, child] of entries) {
+ const childPath = `${path}/${encodeSegment(segment)}`;
+ const memberId = `${seed}:member:${childPath}`;
+ if (tree.members.has(memberId)) {
+ return failure(
+ "identity_collision",
+ `member identity already exists: ${memberId}`,
+ );
+ }
+ const childNode = buildNode(tree, child, seed, childPath, stamp);
+ if (!childNode.ok) return childNode;
+ const placement: MemberPlacement = container.kind === "object"
+ ? { kind: "object", key: segment }
+ : {
+ kind: "array",
+ after: previousArrayPosition,
+ before: null,
+ };
+ const positionId = container.kind === "array"
+ ? initialPositionId(memberId)
+ : null;
+ const placed: PlacedMember = {
+ parent: containerId,
+ placement,
+ ...(positionId === null ? {} : { positionId }),
+ };
+ tree.members.set(memberId, {
+ id: memberId,
+ live: true,
+ node: childNode.value,
+ valueStamp: stamp,
+ valueWrites: [{ node: childNode.value, stamp }],
+ placed,
+ placementStamp: stamp,
+ placementWrites: [{ placement: placed, stamp }],
+ });
+ if (positionId === null) {
+ container.order.push(memberId);
+ } else {
+ tree.arrayPositions.set(positionId, memberId);
+ container.order.push(positionId);
+ }
+ previousArrayPosition = positionId;
+ }
+
+ return {
+ ok: true,
+ value: { kind: "container", containerId },
+ };
+}
+
+function projectMember(
+ tree: TreeState,
+ memberId: MemberId,
+ visiting: Set,
+ reachableMembers: Set,
+ conflicts: CollaborationConflict[],
+): TreeResult {
+ const member = tree.members.get(memberId);
+ if (member === undefined || !member.live) {
+ return failure("target_not_found", `member is not live: ${memberId}`);
+ }
+ reachableMembers.add(member.id);
+ if (member.node.kind === "scalar") {
+ return { ok: true, value: member.node.value };
+ }
+ if (member.node.kind === "text") {
+ const text = tree.texts.get(member.node.textNodeId);
+ return text === undefined
+ ? failure(
+ "missing_text_node",
+ `text node is missing: ${member.node.textNodeId}`,
+ )
+ : { ok: true, value: projectText(text) };
+ }
+
+ const container = tree.containers.get(member.node.containerId);
+ if (container === undefined) {
+ return failure(
+ "missing_container",
+ `container is missing: ${member.node.containerId}`,
+ );
+ }
+ if (visiting.has(container.id)) {
+ return failure("projection_cycle", "container graph contains a cycle");
+ }
+
+ visiting.add(container.id);
+ if (container.kind === "array") {
+ const result: JSONValue[] = [];
+ for (const child of visibleArrayMembers(tree, container)) {
+ const projected = projectMember(
+ tree,
+ child.id,
+ visiting,
+ reachableMembers,
+ conflicts,
+ );
+ if (!projected.ok) {
+ visiting.delete(container.id);
+ return projected;
+ }
+ result.push(projected.value);
+ }
+ visiting.delete(container.id);
+ return { ok: true, value: result };
+ }
+
+ const groups = objectGroups(tree, container);
+ const winners = new Map();
+ for (const [key, members] of groups) {
+ const sorted = [...members].sort(compareMemberPlacements);
+ const winner = sorted.at(-1);
+ if (winner === undefined) continue;
+ winners.set(key, winner);
+ if (sorted.length > 1) {
+ conflicts.push({
+ kind: "object-key",
+ containerId: container.id,
+ key,
+ winner: winner.id,
+ alternatives: sorted
+ .slice(0, -1)
+ .map((alternative) => alternative.id)
+ .sort(compareStrings),
+ });
+ }
+ }
+
+ const result: Record = {};
+ for (const memberIdInOrder of container.order) {
+ const memberInOrder = tree.members.get(memberIdInOrder);
+ const placement = memberInOrder?.placed;
+ if (
+ memberInOrder === undefined
+ || !memberInOrder.live
+ || placement === undefined
+ || placement.parent !== container.id
+ || placement.placement.kind !== "object"
+ || winners.get(placement.placement.key)?.id !== memberInOrder.id
+ ) {
+ continue;
+ }
+ const projected = projectMember(
+ tree,
+ memberInOrder.id,
+ visiting,
+ reachableMembers,
+ conflicts,
+ );
+ if (!projected.ok) {
+ visiting.delete(container.id);
+ return projected;
+ }
+ Object.defineProperty(result, placement.placement.key, {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: projected.value,
+ });
+ }
+ visiting.delete(container.id);
+ return { ok: true, value: result };
+}
+
+function visibleObjectMember(
+ tree: TreeState,
+ container: TreeContainer,
+ key: string,
+): TreeMember | null {
+ const members = objectGroups(tree, container).get(key);
+ if (members === undefined || members.length === 0) return null;
+ return [...members].sort(compareMemberPlacements).at(-1) ?? null;
+}
+
+function objectGroups(
+ tree: TreeState,
+ container: TreeContainer,
+): Map {
+ const groups = new Map();
+ for (const memberId of container.order) {
+ const member = tree.members.get(memberId);
+ const placement = member?.placed;
+ if (
+ member === undefined
+ || !member.live
+ || placement === undefined
+ || placement.parent !== container.id
+ || placement.placement.kind !== "object"
+ ) {
+ continue;
+ }
+ const key = placement.placement.key;
+ const group = groups.get(key);
+ if (group === undefined) groups.set(key, [member]);
+ else group.push(member);
+ }
+ return groups;
+}
+
+function visibleArrayMember(
+ tree: TreeState,
+ container: TreeContainer,
+ segment: string,
+): TreeMember | null {
+ if (!/^(0|[1-9]\d*)$/.test(segment)) return null;
+ const index = Number(segment);
+ if (!Number.isSafeInteger(index)) return null;
+ return visibleArrayMembers(tree, container)[index] ?? null;
+}
+
+function visibleArrayMembers(
+ tree: TreeState,
+ container: TreeContainer,
+): TreeMember[] {
+ const members: TreeMember[] = [];
+ for (const positionId of container.order) {
+ const memberId = tree.arrayPositions.get(positionId);
+ if (memberId === undefined) continue;
+ const member = tree.members.get(memberId);
+ const placement = member?.placed;
+ if (
+ member !== undefined
+ && member.live
+ && placement?.parent === container.id
+ && placement.placement.kind === "array"
+ && placement.positionId === positionId
+ ) {
+ members.push(member);
+ }
+ }
+ return members;
+}
+
+function insertIntoContainer(
+ tree: TreeState,
+ container: TreeContainer,
+ memberId: MemberId,
+ placement: MemberPlacement,
+ positionId?: PositionId,
+): TreeFailure | { readonly ok: true } {
+ if (container.kind === "object") {
+ container.order.push(memberId);
+ return { ok: true };
+ }
+ if (positionId === undefined) {
+ return failure(
+ "missing_position_id",
+ "array insertion requires an immutable position identity",
+ );
+ }
+ if (tree.arrayPositions.has(positionId)) {
+ return failure(
+ "identity_collision",
+ `array position already exists: ${positionId}`,
+ );
+ }
+ const before = (placement as ArrayPlacement).before;
+ const after = (placement as ArrayPlacement).after;
+ const beforeIndex = before === null
+ ? -1
+ : arrayPositionIndex(tree, container, before);
+ const afterIndex = after === null
+ ? -1
+ : arrayPositionIndex(tree, container, after);
+ if (
+ before !== null
+ && beforeIndex >= 0
+ && after !== null
+ && afterIndex >= 0
+ ) {
+ if (afterIndex >= beforeIndex) {
+ return failure(
+ "anchor_order_invalid",
+ "array gap anchors no longer identify an ordered interval",
+ );
+ }
+ container.order.splice(beforeIndex, 0, positionId);
+ return { ok: true };
+ }
+ if (before !== null && beforeIndex >= 0) {
+ container.order.splice(beforeIndex, 0, positionId);
+ return { ok: true };
+ }
+ if (after !== null && afterIndex >= 0) {
+ container.order.splice(afterIndex + 1, 0, positionId);
+ return { ok: true };
+ }
+ if (before === null && after === null) {
+ container.order.push(positionId);
+ return { ok: true };
+ }
+ return failure(
+ "anchor_not_found",
+ "no observed array gap anchor is still present",
+ );
+}
+
+function arrayPositionIndex(
+ tree: TreeState,
+ container: TreeContainer,
+ positionId: PositionId,
+): number {
+ return tree.arrayPositions.has(positionId)
+ ? container.order.indexOf(positionId)
+ : -1;
+}
+
+function removeFromOrder(
+ container: TreeContainer,
+ memberId: MemberId,
+): void {
+ let index = container.order.indexOf(memberId);
+ while (index >= 0) {
+ container.order.splice(index, 1);
+ index = container.order.indexOf(memberId);
+ }
+}
+
+function checkPlacement(
+ container: TreeContainer,
+ placement: MemberPlacement,
+): TreeFailure | { readonly ok: true } {
+ return container.kind === placement.kind
+ ? { ok: true }
+ : failure(
+ "placement_mismatch",
+ `${placement.kind} placement cannot target ${container.kind} container`,
+ );
+}
+
+function containsContainer(
+ tree: TreeState,
+ currentId: ContainerNodeId,
+ targetId: ContainerNodeId,
+ seen: Set,
+): boolean {
+ if (currentId === targetId) return true;
+ if (seen.has(currentId)) return false;
+ seen.add(currentId);
+ const container = tree.containers.get(currentId);
+ if (container === undefined) return false;
+ const children = container.kind === "array"
+ ? visibleArrayMembers(tree, container)
+ : container.order.flatMap((memberId) => {
+ const member = tree.members.get(memberId);
+ return member === undefined ? [] : [member];
+ });
+ for (const member of children) {
+ if (
+ member.live
+ && member.placed?.parent === container.id
+ && member.node.kind === "container"
+ && containsContainer(
+ tree,
+ member.node.containerId,
+ targetId,
+ seen,
+ )
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function containsReachableTextNode(
+ tree: TreeState,
+ targetId: TextNodeId,
+): boolean {
+ const visitMember = (
+ memberId: MemberId,
+ seen: Set,
+ ): boolean => {
+ const member = tree.members.get(memberId);
+ if (member === undefined || !member.live) return false;
+ if (member.node.kind === "text") {
+ return member.node.textNodeId === targetId;
+ }
+ if (member.node.kind !== "container") return false;
+ if (seen.has(member.node.containerId)) return false;
+ seen.add(member.node.containerId);
+ const container = tree.containers.get(member.node.containerId);
+ if (container === undefined) return false;
+ const children = container.kind === "array"
+ ? visibleArrayMembers(tree, container)
+ : container.order.flatMap((childId) => {
+ const child = tree.members.get(childId);
+ return child === undefined ? [] : [child];
+ });
+ for (const child of children) {
+ if (
+ child.live
+ && child.placed?.parent === container.id
+ && visitMember(child.id, seen)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+ return visitMember(tree.root, new Set());
+}
+
+function versionConflict(
+ versions: ReadonlyArray,
+ stampOf: (version: T) => OperationStamp,
+ isAncestor: (left: ChangeId, right: ChangeId) => boolean,
+): {
+ readonly winner: ChangeId;
+ readonly alternatives: ReadonlyArray;
+} | null {
+ const authored = versions.filter((version) => (
+ stampOf(version).changeId !== undefined
+ ));
+ const maximal = authored.filter((candidate) => {
+ const candidateStamp = stampOf(candidate);
+ const candidateId = candidateStamp.changeId as ChangeId;
+ return !authored.some((other) => {
+ if (other === candidate) return false;
+ const otherStamp = stampOf(other);
+ const otherId = otherStamp.changeId as ChangeId;
+ if (changeIdKey(candidateId) === changeIdKey(otherId)) {
+ return candidateStamp.opIndex < otherStamp.opIndex;
+ }
+ return isAncestor(candidateId, otherId);
+ });
+ });
+ if (maximal.length < 2) return null;
+ const sorted = [...maximal].sort((left, right) => (
+ compareStamps(stampOf(left), stampOf(right))
+ ));
+ const winnerStamp = stampOf(sorted.at(-1) as T);
+ const winner = winnerStamp.changeId as ChangeId;
+ return {
+ winner,
+ alternatives: sorted
+ .slice(0, -1)
+ .map((version) => stampOf(version).changeId as ChangeId)
+ .sort(compareChangeIds),
+ };
+}
+
+function compareMemberPlacements(
+ left: TreeMember,
+ right: TreeMember,
+): number {
+ return compareStamps(
+ left.placementStamp ?? INITIAL_STAMP,
+ right.placementStamp ?? INITIAL_STAMP,
+ ) || compareStrings(left.id, right.id);
+}
+
+function compareStamps(
+ left: OperationStamp,
+ right: OperationStamp,
+): number {
+ return left.order - right.order || left.opIndex - right.opIndex;
+}
+
+function operationSeed(stamp: OperationStamp): string {
+ const changeId = stamp.changeId;
+ if (changeId === undefined) return "initial";
+ return `change:${changeIdKey(changeId)}:${stamp.opIndex}`;
+}
+
+function initialPositionId(memberId: MemberId): PositionId {
+ return `position:initial:${memberId}`;
+}
+
+function operationPositionId(
+ memberId: MemberId,
+ stamp: OperationStamp,
+): PositionId {
+ return `position:${operationSeed(stamp)}:${memberId}`;
+}
+
+function encodeSegment(segment: string): string {
+ return `${segment.length}:${segment}`;
+}
+
+function compareStrings(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
+
+function failure(code: string, reason: string): TreeFailure {
+ return { ok: false, code, reason };
+}
+
+export type {
+ TreeContainer,
+ TreeMember,
+};
diff --git a/packages/json-document-collaboration/src/types.ts b/packages/json-document-collaboration/src/types.ts
new file mode 100644
index 00000000..d4f61397
--- /dev/null
+++ b/packages/json-document-collaboration/src/types.ts
@@ -0,0 +1,425 @@
+import type {
+ JSONCapabilityResult,
+ JSONDocument,
+ JSONValue,
+} from "@interactive-os/json-document";
+
+export type ActorId = string;
+export type MemberId = string;
+export type ContainerNodeId = string;
+export type PositionId = string;
+export type TextNodeId = string;
+export type TextAtomId = string;
+
+export interface ChangeId {
+ readonly actorId: ActorId;
+ readonly counter: number;
+}
+
+export interface CollaborationRulesetIdentity {
+ readonly id: string;
+ readonly digest: string;
+}
+
+export interface CollaborationMember {
+ readonly actorId: ActorId;
+ readonly credentialId?: string;
+}
+
+export interface CollaborationMembership {
+ readonly version: 1;
+ readonly members: ReadonlyArray;
+}
+
+export interface CollaborationEpochParent {
+ readonly epochId: string;
+ readonly checkpointDigest: string;
+}
+
+export interface CollaborationEpoch {
+ readonly protocolVersion: 3;
+ readonly epochId: string;
+ readonly ruleset: CollaborationRulesetIdentity;
+ readonly acceptance: "none" | "custom";
+ readonly baseDigest: string;
+ readonly membershipDigest: string;
+ readonly parent: CollaborationEpochParent | null;
+}
+
+export type ObjectPlacement = {
+ readonly kind: "object";
+ readonly key: string;
+};
+
+export type ArrayPlacement = {
+ readonly kind: "array";
+ readonly after: PositionId | null;
+ readonly before: PositionId | null;
+};
+
+export type MemberPlacement = ObjectPlacement | ArrayPlacement;
+
+export type SemanticOperation =
+ | {
+ readonly kind: "test";
+ readonly target: MemberId;
+ readonly expected: JSONValue;
+ }
+ | {
+ readonly kind: "set";
+ readonly target: MemberId;
+ readonly value: JSONValue;
+ }
+ | {
+ readonly kind: "insert";
+ readonly parent: ContainerNodeId;
+ readonly member: MemberId;
+ readonly placement: MemberPlacement;
+ readonly value: JSONValue;
+ }
+ | {
+ readonly kind: "remove";
+ readonly target: MemberId;
+ }
+ | {
+ readonly kind: "move";
+ readonly target: MemberId;
+ readonly parent: ContainerNodeId;
+ readonly placement: MemberPlacement;
+ readonly replaced?: MemberId;
+ }
+ | {
+ readonly kind: "move-to-root";
+ readonly source: MemberId;
+ readonly root: MemberId;
+ }
+ | TextSpliceOperation
+ | {
+ readonly kind: "undo-change";
+ readonly target: ChangeId;
+ }
+ | {
+ readonly kind: "redo-change";
+ readonly undo: ChangeId;
+ };
+
+export interface TextSpliceOperation {
+ readonly kind: "text-splice";
+ readonly target: MemberId;
+ readonly textNode: TextNodeId;
+ readonly left: TextAtomId | null;
+ readonly right: TextAtomId | null;
+ readonly removed: ReadonlyArray;
+ readonly inserted: string;
+}
+
+export interface CollaborationChange {
+ readonly changeId: ChangeId;
+ readonly deps: ReadonlyArray;
+ readonly ops: ReadonlyArray;
+}
+
+export interface CollaborationBundle {
+ readonly epoch: CollaborationEpoch;
+ readonly changes: ReadonlyArray;
+}
+
+export interface CollaborationCheckpointPayload {
+ readonly kind: "json-document-collaboration/checkpoint";
+ readonly version: 1;
+ readonly epoch: CollaborationEpoch;
+ readonly base: JSONValue;
+ readonly membership: CollaborationMembership | null;
+ readonly changes: ReadonlyArray;
+}
+
+export interface CollaborationCheckpoint {
+ readonly payload: CollaborationCheckpointPayload;
+ readonly integrity: {
+ readonly algorithm: "sha-256";
+ readonly digest: string;
+ readonly proof?: string;
+ readonly keyId?: string;
+ };
+}
+
+export type CollaborationConflict =
+ | {
+ readonly kind: "object-key";
+ readonly containerId: ContainerNodeId;
+ readonly key: string;
+ readonly winner: MemberId;
+ readonly alternatives: ReadonlyArray;
+ }
+ | {
+ readonly kind: "member-value";
+ readonly memberId: MemberId;
+ readonly winner: ChangeId;
+ readonly alternatives: ReadonlyArray;
+ }
+ | {
+ readonly kind: "member-placement";
+ readonly memberId: MemberId;
+ readonly winner: ChangeId;
+ readonly alternatives: ReadonlyArray;
+ };
+
+export interface SuppressedChange {
+ readonly changeId: ChangeId;
+ readonly code: string;
+ readonly reason?: string;
+ readonly pointer?: string;
+}
+
+export interface PendingChange {
+ readonly changeId: ChangeId;
+ readonly missing: ReadonlyArray;
+}
+
+export interface CollaborationSnapshot {
+ readonly epoch: CollaborationEpoch;
+ readonly heads: ReadonlyArray;
+ readonly pending: ReadonlyArray;
+ readonly conflicts: ReadonlyArray;
+ readonly suppressed: ReadonlyArray;
+}
+
+export interface CollaborationIngestSuccess {
+ readonly ok: true;
+ readonly integrated: ReadonlyArray;
+ readonly pending: ReadonlyArray;
+ readonly duplicates: ReadonlyArray;
+}
+
+export interface CollaborationIngestFailure {
+ readonly ok: false;
+ readonly code:
+ | "invalid_bundle"
+ | "epoch_mismatch"
+ | "ruleset_mismatch"
+ | "checkpoint_mismatch"
+ | "membership_mismatch"
+ | "duplicate_mismatch"
+ | "dependency_cycle"
+ | "actor_fork"
+ | "membership_violation"
+ | "acceptance_reentrancy";
+ readonly reason: string;
+ readonly changeId?: ChangeId;
+}
+
+export type CollaborationIngestResult =
+ | CollaborationIngestSuccess
+ | CollaborationIngestFailure;
+
+export interface CollaborationControl {
+ readonly epoch: CollaborationEpoch;
+ current(): CollaborationSnapshot;
+ exportBundle(): CollaborationBundle;
+ exportCheckpoint(): CollaborationCheckpoint;
+ ingest(bundle: unknown): CollaborationIngestResult;
+ subscribe(listener: (snapshot: CollaborationSnapshot) => void): () => void;
+}
+
+export interface CollaborationHistorySnapshot {
+ readonly undoTarget: ChangeId | null;
+ readonly redoTarget: ChangeId | null;
+}
+
+export type CollaborationHistoryResult =
+ | {
+ readonly ok: true;
+ readonly changeId: ChangeId;
+ readonly target: ChangeId;
+ readonly projectionChanged: boolean;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason?: string;
+ };
+
+export interface CollaborationHistoryControl {
+ current(): CollaborationHistorySnapshot;
+ canUndo(): JSONCapabilityResult;
+ undo(): CollaborationHistoryResult;
+ canRedo(): JSONCapabilityResult;
+ redo(): CollaborationHistoryResult;
+}
+
+export interface CollaborationRuntime {
+ readonly document: JSONDocument;
+ readonly collaboration: CollaborationControl;
+}
+
+export interface CollaborationHistoryRuntime extends CollaborationRuntime {
+ readonly history: CollaborationHistoryControl;
+}
+
+export interface CollaborationTextSelection {
+ readonly anchor: number;
+ readonly focus: number;
+}
+
+export interface CollaborationTextObservation {
+ readonly value: string;
+ readonly selection?: CollaborationTextSelection;
+}
+
+export interface CollaborationTextCapture {
+ readonly pointer: string;
+ readonly target: MemberId;
+ readonly textNode: TextNodeId;
+ readonly value: string;
+}
+
+export interface CollaborationTextPlan {
+ readonly pointer: string;
+ readonly value: string;
+ readonly selection?: CollaborationTextSelection;
+}
+
+export type CollaborationTextCaptureResult =
+ | {
+ readonly ok: true;
+ readonly capture: CollaborationTextCapture;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export type CollaborationTextPlanResult =
+ | {
+ readonly ok: true;
+ readonly plan: CollaborationTextPlan;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export type CollaborationTextCommitResult =
+ | {
+ readonly ok: true;
+ readonly changeId: ChangeId | null;
+ readonly projectionChanged: boolean;
+ readonly value: string;
+ readonly selection: CollaborationTextSelection | null;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export interface CollaborationTextControl {
+ capture(pointer: string): CollaborationTextCaptureResult;
+ plan(
+ capture: CollaborationTextCapture,
+ observation: CollaborationTextObservation,
+ ): CollaborationTextPlanResult;
+ commit(plan: CollaborationTextPlan): CollaborationTextCommitResult;
+}
+
+export interface CollaborationTextRuntime extends CollaborationRuntime {
+ readonly text: CollaborationTextControl;
+}
+
+/**
+ * A convergence-critical acceptance rule.
+ *
+ * It must be synchronous, total, side-effect-free, and referentially
+ * transparent. Every replica and every invocation must return the same
+ * complete result, including code, reason, and pointer, for the same JSON
+ * candidate.
+ */
+export type CollaborationAcceptance = (
+ candidate: JSONValue,
+) => JSONCapabilityResult;
+
+export interface CollaborationRuntimeOptions {
+ readonly actorId: ActorId;
+ readonly epochId: string;
+ readonly ruleset: CollaborationRulesetIdentity;
+ readonly membership?: CollaborationMembership;
+ readonly accepts?: CollaborationAcceptance;
+}
+
+export interface CollaborationRestoreOptions {
+ readonly actorId: ActorId;
+ readonly ruleset: CollaborationRulesetIdentity;
+ readonly accepts?: CollaborationAcceptance;
+ readonly verify?: (
+ checkpoint: CollaborationCheckpoint,
+ ) => JSONCapabilityResult;
+}
+
+export type CollaborationRestoreResult =
+ | {
+ readonly ok: true;
+ readonly runtime: CollaborationRuntime;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export type CollaborationHistoryRestoreResult =
+ | {
+ readonly ok: true;
+ readonly runtime: CollaborationHistoryRuntime;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export type CollaborationTextRestoreResult =
+ | {
+ readonly ok: true;
+ readonly runtime: CollaborationTextRuntime;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
+
+export interface CollaborationCompactionOptions {
+ readonly mode: "new-epoch";
+ readonly nextEpochId: string;
+ readonly nextRuleset: CollaborationRulesetIdentity;
+ /**
+ * Omit to preserve the checkpoint membership. Use null to open membership.
+ */
+ readonly nextMembership?: CollaborationMembership | null;
+ readonly accepts?: CollaborationAcceptance;
+ readonly nextAccepts?: CollaborationAcceptance;
+ readonly verify?: (
+ checkpoint: CollaborationCheckpoint,
+ ) => JSONCapabilityResult;
+}
+
+export interface CollaborationCompactionReport {
+ readonly discardedChanges: number;
+ readonly discardedConflicts: number;
+ readonly discardedSuppressed: number;
+ readonly discardedHistoryControls: number;
+}
+
+export type CollaborationCompactionResult =
+ | {
+ readonly ok: true;
+ readonly checkpoint: CollaborationCheckpoint;
+ readonly report: CollaborationCompactionReport;
+ }
+ | {
+ readonly ok: false;
+ readonly code: string;
+ readonly reason: string;
+ };
diff --git a/packages/json-document-collaboration/tests/checkpoint.test.ts b/packages/json-document-collaboration/tests/checkpoint.test.ts
new file mode 100644
index 00000000..a10e5ed9
--- /dev/null
+++ b/packages/json-document-collaboration/tests/checkpoint.test.ts
@@ -0,0 +1,729 @@
+import { createHash } from "node:crypto";
+
+import { describe, expect, test, vi } from "vitest";
+
+import {
+ compactCollaborationCheckpoint,
+ createCollaborationRuntime,
+ restoreCollaborationRuntime,
+ type CollaborationBundle,
+ type CollaborationCheckpoint,
+ type CollaborationMembership,
+ type CollaborationRulesetIdentity,
+ type CollaborationRuntime,
+ type CollaborationRuntimeOptions,
+} from "../src/index.js";
+import {
+ createCollaborationHistoryRuntime,
+ restoreCollaborationHistoryRuntime,
+} from "../src/history-index.js";
+
+const ruleset: CollaborationRulesetIdentity = {
+ id: "test/checkpoint-json-tree",
+ digest: "test/checkpoint-json-tree/v1",
+};
+
+function membership(...actorIds: string[]): CollaborationMembership {
+ return {
+ version: 1,
+ members: actorIds.map((actorId) => ({ actorId })),
+ };
+}
+
+function options(
+ actorId: string,
+ epochId: string,
+ members: CollaborationMembership | undefined,
+ overrides: Partial = {},
+): CollaborationRuntimeOptions {
+ return {
+ actorId,
+ epochId,
+ ruleset,
+ ...(members === undefined ? {} : { membership: members }),
+ ...overrides,
+ };
+}
+
+function restoredRuntime(
+ result: ReturnType,
+): CollaborationRuntime {
+ if (!result.ok) {
+ throw new Error(`checkpoint restore failed: ${result.code}: ${result.reason}`);
+ }
+ return result.runtime;
+}
+
+function changeBundle(
+ bundle: CollaborationBundle,
+ actorId: string,
+ counter: number,
+): CollaborationBundle {
+ const change = bundle.changes.find((candidate) => (
+ candidate.changeId.actorId === actorId
+ && candidate.changeId.counter === counter
+ ));
+ if (change === undefined) {
+ throw new Error(`missing Change ${actorId}:${counter}`);
+ }
+ return {
+ epoch: bundle.epoch,
+ changes: [change],
+ };
+}
+
+function checkpointWithIntegrity(
+ payload: CollaborationCheckpoint["payload"],
+): CollaborationCheckpoint {
+ return {
+ payload,
+ integrity: {
+ algorithm: "sha-256",
+ digest: `sha256:${createHash("sha256")
+ .update(canonicalJSON(payload), "utf8")
+ .digest("hex")}`,
+ },
+ };
+}
+
+function canonicalJSON(value: unknown): string {
+ if (value === null || typeof value !== "object") {
+ const serialized = JSON.stringify(value);
+ if (serialized === undefined) {
+ throw new TypeError("checkpoint payload must contain only JSON values");
+ }
+ return serialized;
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalJSON).join(",")}]`;
+ }
+ return `{${Object.entries(value)
+ .sort(([left], [right]) => (
+ left < right ? -1 : left > right ? 1 : 0
+ ))
+ .map(([key, child]) => (
+ `${JSON.stringify(key)}:${canonicalJSON(child)}`
+ ))
+ .join(",")}}`;
+}
+
+describe("@interactive-os/json-document-collaboration checkpoints", () => {
+ test("round-trips the complete same-epoch causal state", () => {
+ const members = membership("actor-a", "actor-b");
+ const source = createCollaborationRuntime(
+ { title: "Draft", done: false },
+ options("actor-a", "checkpoint-roundtrip/v1", members),
+ );
+ const remote = createCollaborationRuntime(
+ { title: "Draft", done: false },
+ options("actor-b", "checkpoint-roundtrip/v1", members),
+ );
+
+ expect(source.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Local",
+ }])).toMatchObject({ ok: true });
+ expect(remote.document.commit([{
+ op: "replace",
+ path: "/done",
+ value: true,
+ }])).toMatchObject({ ok: true });
+ expect(source.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ const checkpoint = source.collaboration.exportCheckpoint();
+ const restored = restoredRuntime(restoreCollaborationRuntime(checkpoint, {
+ actorId: "actor-a",
+ ruleset,
+ }));
+
+ expect(restored.document.value).toEqual(source.document.value);
+ expect(restored.collaboration.current()).toEqual(
+ source.collaboration.current(),
+ );
+ expect(restored.collaboration.exportBundle()).toEqual(
+ source.collaboration.exportBundle(),
+ );
+
+ expect(restored.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Restored",
+ }])).toMatchObject({ ok: true });
+ expect(
+ restored.collaboration.exportBundle().changes.at(-1)?.changeId,
+ ).toEqual({
+ actorId: "actor-a",
+ counter: 2,
+ });
+ });
+
+ test("restores pending causal input and later releases it identically", () => {
+ const members = membership("actor-a", "receiver");
+ const initial = { title: "Draft" };
+ const author = createCollaborationRuntime(
+ initial,
+ options("actor-a", "checkpoint-pending/v1", members),
+ );
+ expect(author.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "First",
+ }])).toMatchObject({ ok: true });
+ expect(author.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Second",
+ }])).toMatchObject({ ok: true });
+
+ const complete = author.collaboration.exportBundle();
+ const first = changeBundle(complete, "actor-a", 1);
+ const second = changeBundle(complete, "actor-a", 2);
+ const receiver = createCollaborationRuntime(
+ initial,
+ options("receiver", "checkpoint-pending/v1", members),
+ );
+ expect(receiver.collaboration.ingest(second)).toMatchObject({
+ ok: true,
+ pending: [{ actorId: "actor-a", counter: 2 }],
+ });
+
+ const restored = restoredRuntime(restoreCollaborationRuntime(
+ receiver.collaboration.exportCheckpoint(),
+ { actorId: "receiver", ruleset },
+ ));
+ expect(restored.collaboration.current().pending).toEqual([
+ {
+ changeId: { actorId: "actor-a", counter: 2 },
+ missing: [{ actorId: "actor-a", counter: 1 }],
+ },
+ ]);
+
+ expect(restored.collaboration.ingest(first)).toMatchObject({
+ ok: true,
+ pending: [],
+ });
+ const reference = createCollaborationRuntime(
+ initial,
+ options("receiver", "checkpoint-pending/v1", members),
+ );
+ expect(reference.collaboration.ingest(complete)).toMatchObject({ ok: true });
+ expect(restored.document.value).toEqual(reference.document.value);
+ expect(restored.collaboration.current()).toEqual(
+ reference.collaboration.current(),
+ );
+ expect(restored.collaboration.exportBundle()).toEqual(
+ reference.collaboration.exportBundle(),
+ );
+ });
+
+ test("preserves concurrent conflicts and authored history controls", () => {
+ const members = membership("actor-a", "actor-b");
+ const initial = {
+ title: "Draft",
+ flag: false,
+ };
+ const left = createCollaborationHistoryRuntime(
+ initial,
+ options("actor-a", "checkpoint-history/v1", members),
+ );
+ const right = createCollaborationRuntime(
+ initial,
+ options("actor-b", "checkpoint-history/v1", members),
+ );
+
+ expect(left.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Left",
+ }])).toMatchObject({ ok: true });
+ expect(right.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Right",
+ }])).toMatchObject({ ok: true });
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(left.document.commit([{
+ op: "replace",
+ path: "/flag",
+ value: true,
+ }])).toMatchObject({ ok: true });
+ expect(left.history.undo()).toMatchObject({
+ ok: true,
+ target: { actorId: "actor-a", counter: 2 },
+ });
+ expect(left.collaboration.current().conflicts).not.toEqual([]);
+ expect(left.history.current().redoTarget).toEqual({
+ actorId: "actor-a",
+ counter: 2,
+ });
+
+ const restored = restoredRuntime(restoreCollaborationRuntime(
+ left.collaboration.exportCheckpoint(),
+ { actorId: "actor-a", ruleset },
+ ));
+
+ expect(restored.document.value).toEqual(left.document.value);
+ expect(restored.collaboration.current()).toEqual(
+ left.collaboration.current(),
+ );
+ expect(restored.collaboration.exportBundle()).toEqual(
+ left.collaboration.exportBundle(),
+ );
+ expect(
+ restored.collaboration.exportBundle().changes.some((change) => (
+ change.ops.some((operation) => operation.kind === "undo-change")
+ )),
+ ).toBe(true);
+
+ const restoredHistory = restoreCollaborationHistoryRuntime(
+ left.collaboration.exportCheckpoint(),
+ { actorId: "actor-a", ruleset },
+ );
+ if (!restoredHistory.ok) {
+ throw new Error(
+ `history restore failed: ${restoredHistory.code}: ${restoredHistory.reason}`,
+ );
+ }
+ expect(restoredHistory.runtime.history.current().redoTarget).toEqual({
+ actorId: "actor-a",
+ counter: 2,
+ });
+ const beforeRedo = restoredHistory.runtime.document.value as {
+ readonly title: unknown;
+ readonly flag: unknown;
+ };
+ expect(restoredHistory.runtime.history.redo()).toMatchObject({
+ ok: true,
+ target: { actorId: "actor-a", counter: 2 },
+ });
+ expect(restoredHistory.runtime.document.value).toEqual({
+ title: beforeRedo.title,
+ flag: true,
+ });
+ });
+
+ test("re-derives the same suppressed changes when restoring", () => {
+ const members = membership("author", "receiver");
+ const initial = { value: 0 };
+ const author = createCollaborationRuntime(
+ initial,
+ options("author", "checkpoint-suppressed/v1", members),
+ );
+ expect(author.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 2,
+ }])).toMatchObject({ ok: true });
+
+ const accepts: CollaborationRuntimeOptions["accepts"] = (candidate) => {
+ const object = candidate as { readonly value?: unknown };
+ return typeof object.value === "number" && object.value <= 1
+ ? { ok: true }
+ : {
+ ok: false,
+ code: "maximum_exceeded",
+ reason: "value must be at most one",
+ pointer: "/value",
+ };
+ };
+ const receiver = createCollaborationRuntime(
+ initial,
+ options("receiver", "checkpoint-suppressed/v1", members, { accepts }),
+ );
+ const untrusted = author.collaboration.exportBundle();
+ expect(receiver.collaboration.ingest({
+ epoch: receiver.collaboration.epoch,
+ changes: untrusted.changes,
+ }))
+ .toMatchObject({ ok: true });
+ expect(receiver.document.value).toEqual(initial);
+ expect(receiver.collaboration.current().suppressed).toEqual([{
+ changeId: { actorId: "author", counter: 1 },
+ code: "maximum_exceeded",
+ reason: "value must be at most one",
+ pointer: "/value",
+ }]);
+
+ const checkpoint = receiver.collaboration.exportCheckpoint();
+ expect(restoreCollaborationRuntime(checkpoint, {
+ actorId: "receiver",
+ ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "acceptance_required",
+ });
+ expect(compactCollaborationCheckpoint(checkpoint, {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-suppressed/missing-acceptance/v2",
+ nextRuleset: ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "acceptance_required",
+ });
+
+ const restored = restoredRuntime(restoreCollaborationRuntime(
+ checkpoint,
+ { actorId: "receiver", ruleset, accepts },
+ ));
+ expect(restored.document.value).toEqual(receiver.document.value);
+ expect(restored.collaboration.current()).toEqual(
+ receiver.collaboration.current(),
+ );
+ expect(restored.collaboration.exportBundle()).toEqual(
+ receiver.collaboration.exportBundle(),
+ );
+ });
+
+ test("rejects an unknown member transactionally", () => {
+ const members = membership("allowed", "receiver");
+ const receiver = createCollaborationRuntime(
+ { value: 0 },
+ options("receiver", "checkpoint-membership/v1", members),
+ );
+ const beforeValue = receiver.document.value;
+ const beforeSnapshot = receiver.collaboration.current();
+ const listener = vi.fn();
+ receiver.collaboration.subscribe(listener);
+
+ const result = receiver.collaboration.ingest({
+ epoch: receiver.collaboration.epoch,
+ changes: [
+ {
+ changeId: { actorId: "allowed", counter: 1 },
+ deps: [],
+ ops: [],
+ },
+ {
+ changeId: { actorId: "intruder", counter: 1 },
+ deps: [],
+ ops: [],
+ },
+ ],
+ });
+
+ expect(result).toMatchObject({ ok: false });
+ expect(receiver.document.value).toBe(beforeValue);
+ expect(receiver.collaboration.current()).toEqual(beforeSnapshot);
+ expect(receiver.collaboration.exportBundle().changes).toEqual([]);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ test.each([
+ {
+ name: "dependency",
+ deps: [{ actorId: "intruder", counter: 1 }],
+ ops: [],
+ },
+ {
+ name: "history target",
+ deps: [],
+ ops: [{
+ kind: "undo-change" as const,
+ target: { actorId: "intruder", counter: 1 },
+ }],
+ },
+ ])("rejects an unknown member referenced by a $name transactionally", ({
+ deps,
+ ops,
+ }) => {
+ const members = membership("allowed", "receiver");
+ const receiver = createCollaborationRuntime(
+ { value: 0 },
+ options("receiver", "checkpoint-member-reference/v1", members),
+ );
+ const before = receiver.collaboration.current();
+
+ expect(receiver.collaboration.ingest({
+ epoch: receiver.collaboration.epoch,
+ changes: [{
+ changeId: { actorId: "allowed", counter: 1 },
+ deps,
+ ops,
+ }],
+ })).toMatchObject({
+ ok: false,
+ code: "membership_violation",
+ changeId: { actorId: "allowed", counter: 1 },
+ });
+ expect(receiver.document.value).toEqual({ value: 0 });
+ expect(receiver.collaboration.current()).toEqual(before);
+ expect(receiver.collaboration.exportBundle().changes).toEqual([]);
+ });
+
+ test("refuses to compact a checkpoint with pending causal history", () => {
+ const members = membership("author", "receiver");
+ const initial = { title: "Draft" };
+ const author = createCollaborationRuntime(
+ initial,
+ options("author", "checkpoint-pending-compaction/v1", members),
+ );
+ expect(author.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "First",
+ }])).toMatchObject({ ok: true });
+ expect(author.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Second",
+ }])).toMatchObject({ ok: true });
+ const complete = author.collaboration.exportBundle();
+ const receiver = createCollaborationRuntime(
+ initial,
+ options("receiver", "checkpoint-pending-compaction/v1", members),
+ );
+ expect(receiver.collaboration.ingest(
+ changeBundle(complete, "author", 2),
+ )).toMatchObject({ ok: true });
+
+ expect(compactCollaborationCheckpoint(
+ receiver.collaboration.exportCheckpoint(),
+ {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-pending-compaction/v2",
+ nextRuleset: ruleset,
+ },
+ )).toMatchObject({
+ ok: false,
+ code: "pending_changes",
+ });
+ });
+
+ test("does not launder a non-member Change through compaction", () => {
+ const members = membership("allowed");
+ const initial = { value: 0 };
+ const allowed = createCollaborationRuntime(
+ initial,
+ options("allowed", "checkpoint-member-laundering/v1", members),
+ );
+ const intruder = createCollaborationRuntime(
+ initial,
+ options("intruder", "checkpoint-member-laundering/v1", undefined),
+ );
+ expect(intruder.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 1,
+ }])).toMatchObject({ ok: true });
+
+ const original = allowed.collaboration.exportCheckpoint();
+ const forged = checkpointWithIntegrity({
+ ...original.payload,
+ changes: intruder.collaboration.exportBundle().changes,
+ });
+ expect(compactCollaborationCheckpoint(forged, {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-member-laundering/v2",
+ nextRuleset: ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "membership_violation",
+ });
+ });
+
+ test.each([
+ {
+ name: "dependency",
+ deps: [{ actorId: "intruder", counter: 1 }],
+ ops: [],
+ },
+ {
+ name: "undo target",
+ deps: [],
+ ops: [{
+ kind: "undo-change" as const,
+ target: { actorId: "intruder", counter: 1 },
+ }],
+ },
+ {
+ name: "redo target",
+ deps: [],
+ ops: [{
+ kind: "redo-change" as const,
+ undo: { actorId: "intruder", counter: 1 },
+ }],
+ },
+ ])("does not compact a non-member $name reference", ({
+ name,
+ deps,
+ ops,
+ }) => {
+ const source = createCollaborationRuntime(
+ { value: 0 },
+ options(
+ "allowed",
+ "checkpoint-member-reference-compaction/v1",
+ membership("allowed"),
+ ),
+ );
+ const original = source.collaboration.exportCheckpoint();
+ const forged = checkpointWithIntegrity({
+ ...original.payload,
+ changes: [{
+ changeId: { actorId: "allowed", counter: 1 },
+ deps,
+ ops,
+ }],
+ });
+
+ expect(compactCollaborationCheckpoint(forged, {
+ mode: "new-epoch",
+ nextEpochId: `checkpoint-member-reference-compaction/v2/${name}`,
+ nextRuleset: ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "membership_violation",
+ });
+ });
+
+ test("rejects conflicting duplicate Change ids before compaction", () => {
+ const source = createCollaborationRuntime(
+ { value: 0 },
+ options("actor-a", "checkpoint-duplicate/v1", undefined),
+ );
+ expect(source.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 1,
+ }])).toMatchObject({ ok: true });
+ const original = source.collaboration.exportCheckpoint();
+ const change = original.payload.changes[0];
+ if (change === undefined) throw new Error("missing authored Change");
+ const forged = checkpointWithIntegrity({
+ ...original.payload,
+ changes: [
+ change,
+ {
+ ...change,
+ ops: [],
+ },
+ ],
+ });
+
+ expect(compactCollaborationCheckpoint(forged, {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-duplicate/v2",
+ nextRuleset: ruleset,
+ })).toMatchObject({ ok: false });
+ });
+
+ test("rejects a checksum-valid checkpoint with non-canonical Change order", () => {
+ const source = createCollaborationRuntime(
+ { value: 0 },
+ options("actor-a", "checkpoint-change-order/v1", undefined),
+ );
+ expect(source.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 1,
+ }])).toMatchObject({ ok: true });
+ expect(source.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 2,
+ }])).toMatchObject({ ok: true });
+ const original = source.collaboration.exportCheckpoint();
+ const reordered = checkpointWithIntegrity({
+ ...original.payload,
+ changes: [...original.payload.changes].reverse(),
+ });
+
+ expect(restoreCollaborationRuntime(reordered, {
+ actorId: "actor-a",
+ ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "invalid_checkpoint",
+ });
+ expect(compactCollaborationCheckpoint(reordered, {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-change-order/v2",
+ nextRuleset: ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "invalid_checkpoint",
+ });
+ });
+
+ test("rejects a checksum-valid checkpoint with a duplicate Change id", () => {
+ const source = createCollaborationRuntime(
+ { value: 0 },
+ options("actor-a", "checkpoint-change-duplicate/v1", undefined),
+ );
+ expect(source.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 1,
+ }])).toMatchObject({ ok: true });
+ const original = source.collaboration.exportCheckpoint();
+ const change = original.payload.changes[0];
+ if (change === undefined) throw new Error("missing authored Change");
+ const duplicated = checkpointWithIntegrity({
+ ...original.payload,
+ changes: [change, change],
+ });
+
+ expect(restoreCollaborationRuntime(duplicated, {
+ actorId: "actor-a",
+ ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "invalid_checkpoint",
+ });
+ expect(compactCollaborationCheckpoint(duplicated, {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-change-duplicate/v2",
+ nextRuleset: ruleset,
+ })).toMatchObject({
+ ok: false,
+ code: "invalid_checkpoint",
+ });
+ });
+
+ test("compacts only into a new epoch and rejects old-epoch bundles", () => {
+ const members = membership("actor-a");
+ const source = createCollaborationRuntime(
+ { title: "Draft" },
+ options("actor-a", "checkpoint-compaction/v1", members),
+ );
+ expect(source.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Compacted",
+ }])).toMatchObject({ ok: true });
+ const oldBundle = source.collaboration.exportBundle();
+ const compacted = compactCollaborationCheckpoint(
+ source.collaboration.exportCheckpoint(),
+ {
+ mode: "new-epoch",
+ nextEpochId: "checkpoint-compaction/v2",
+ nextRuleset: ruleset,
+ },
+ );
+ if (!compacted.ok) {
+ throw new Error(`checkpoint compaction failed: ${compacted.code}: ${compacted.reason}`);
+ }
+
+ const restored = restoredRuntime(restoreCollaborationRuntime(
+ compacted.checkpoint,
+ { actorId: "actor-a", ruleset },
+ ));
+ expect(restored.document.value).toEqual(source.document.value);
+ expect(restored.collaboration.epoch.epochId).toBe(
+ "checkpoint-compaction/v2",
+ );
+ expect(restored.collaboration.exportBundle().changes).toEqual([]);
+
+ const before = restored.collaboration.current();
+ expect(restored.collaboration.ingest(oldBundle)).toMatchObject({
+ ok: false,
+ code: "epoch_mismatch",
+ });
+ expect(restored.document.value).toEqual(source.document.value);
+ expect(restored.collaboration.current()).toEqual(before);
+ expect(restored.collaboration.exportBundle().changes).toEqual([]);
+ });
+});
diff --git a/packages/json-document-collaboration/tests/collaboration.test.ts b/packages/json-document-collaboration/tests/collaboration.test.ts
new file mode 100644
index 00000000..5f89cc26
--- /dev/null
+++ b/packages/json-document-collaboration/tests/collaboration.test.ts
@@ -0,0 +1,1054 @@
+import { describe, expect, test, vi } from "vitest";
+
+import {
+ createCollaborationRuntime,
+ type CollaborationBundle,
+ type CollaborationRuntimeOptions,
+} from "../src/index.js";
+
+const baseOptions = {
+ epochId: "shared-document/v1",
+ ruleset: {
+ id: "test/json-tree",
+ digest: "test/json-tree/v1",
+ },
+} as const;
+
+function runtime(
+ actorId: string,
+ initial: unknown = {
+ title: "Draft",
+ done: false,
+ items: ["a", "b"],
+ },
+ overrides: Partial = {},
+) {
+ return createCollaborationRuntime(initial, {
+ ...baseOptions,
+ actorId,
+ ...overrides,
+ });
+}
+
+describe("@interactive-os/json-document-collaboration", () => {
+ test("uses a canonical SHA-256 checkpoint fingerprint", () => {
+ const shared = runtime("actor-a", null);
+
+ expect(shared.collaboration.epoch.baseDigest).toBe(
+ "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
+ );
+ });
+
+ test("keeps the editor-facing document on the six-member Projection API", () => {
+ const shared = runtime("actor-a");
+
+ expect(Object.keys(shared).sort()).toEqual([
+ "collaboration",
+ "document",
+ ]);
+ expect(Object.keys(shared.document).sort()).toEqual([
+ "at",
+ "canPatch",
+ "commit",
+ "query",
+ "subscribe",
+ "value",
+ ]);
+ expect("ingest" in shared.document).toBe(false);
+ expect(typeof shared.collaboration.ingest).toBe("function");
+ });
+
+ test("merges concurrent edits to different members independent of arrival order", () => {
+ const left = runtime("actor-a");
+ const right = runtime("actor-b");
+
+ expect(left.document.commit([
+ { op: "replace", path: "/title", value: "Left" },
+ ])).toMatchObject({ ok: true });
+ expect(right.document.commit([
+ { op: "replace", path: "/done", value: true },
+ ])).toMatchObject({ ok: true });
+
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(right.collaboration.ingest(left.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ expect(left.document.value).toEqual({
+ title: "Left",
+ done: true,
+ items: ["a", "b"],
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().conflicts).toEqual([]);
+ });
+
+ test("keeps concurrent alternatives outside the ordinary JSON projection", () => {
+ const left = runtime("actor-a");
+ const right = runtime("actor-b");
+
+ left.document.commit([
+ { op: "replace", path: "/title", value: "Left" },
+ ]);
+ right.document.commit([
+ { op: "replace", path: "/title", value: "Right" },
+ ]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual(right.document.value);
+ expect(left.document.value).toMatchObject({ title: "Right" });
+ expect(left.collaboration.current().conflicts).toMatchObject([
+ {
+ kind: "member-value",
+ winner: { actorId: "actor-b", counter: 1 },
+ alternatives: [{ actorId: "actor-a", counter: 1 }],
+ },
+ ]);
+ });
+
+ test("orders concurrent array insertions by causal change identity", () => {
+ const left = runtime("actor-a");
+ const right = runtime("actor-b");
+
+ left.document.commit([
+ { op: "add", path: "/items/1", value: "left" },
+ ]);
+ right.document.commit([
+ { op: "add", path: "/items/1", value: "right" },
+ ]);
+
+ right.collaboration.ingest(left.collaboration.exportBundle());
+ left.collaboration.ingest(right.collaboration.exportBundle());
+
+ expect(left.document.value).toMatchObject({
+ items: ["a", "left", "right", "b"],
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ });
+
+ test("retains an array anchor when a concurrent move relocates that member", () => {
+ const initial = {
+ left: ["a", "b"],
+ right: [] as string[],
+ };
+ const left = runtime("actor-a", initial);
+ const right = runtime("actor-b", initial);
+
+ left.document.commit([{
+ op: "move",
+ from: "/left/1",
+ path: "/right/0",
+ }]);
+ right.document.commit([{
+ op: "add",
+ path: "/left/1",
+ value: "x",
+ }]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ left: ["a", "x"],
+ right: ["b"],
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().suppressed).toEqual([]);
+ });
+
+ test("retains start and end gap insertions when their only anchor moves away", () => {
+ for (const path of ["/left/0", "/left/1"]) {
+ const initial = {
+ left: ["b"],
+ right: [] as string[],
+ };
+ const mover = runtime("actor-a", initial);
+ const inserter = runtime("actor-b", initial);
+
+ expect(mover.document.commit([{
+ op: "move",
+ from: "/left/0",
+ path: "/right/0",
+ }])).toMatchObject({ ok: true });
+ expect(inserter.document.commit([{
+ op: "add",
+ path,
+ value: "x",
+ }])).toMatchObject({ ok: true });
+
+ expect(mover.collaboration.ingest(
+ inserter.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+ expect(inserter.collaboration.ingest(
+ mover.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+
+ expect(mover.document.value).toEqual({
+ left: ["x"],
+ right: ["b"],
+ });
+ expect(inserter.document.value).toEqual(mover.document.value);
+ expect(mover.collaboration.current().suppressed).toEqual([]);
+ expect(inserter.collaboration.current().suppressed).toEqual([]);
+ }
+ });
+
+ test("keeps an old array position anchor after a same-array move", () => {
+ const initial = { items: ["a", "b"] };
+ const mover = runtime("actor-a", initial);
+ const inserter = runtime("actor-b", initial);
+
+ expect(mover.document.commit([{
+ op: "move",
+ from: "/items/1",
+ path: "/items/0",
+ }])).toMatchObject({ ok: true });
+ expect(inserter.document.commit([{
+ op: "add",
+ path: "/items/2",
+ value: "x",
+ }])).toMatchObject({ ok: true });
+
+ expect(mover.collaboration.ingest(
+ inserter.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+ expect(inserter.collaboration.ingest(
+ mover.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+
+ expect(mover.document.value).toEqual({ items: ["b", "a", "x"] });
+ expect(inserter.document.value).toEqual(mover.document.value);
+ expect(mover.collaboration.current().suppressed).toEqual([]);
+ });
+
+ test("preserves member identity across rename while merging a concurrent child edit", () => {
+ const initial = {
+ cards: {
+ draft: {
+ title: "Draft",
+ },
+ },
+ };
+ const left = runtime("actor-a", initial);
+ const right = runtime("actor-b", initial);
+
+ left.document.commit([{
+ op: "move",
+ from: "/cards/draft",
+ path: "/cards/published",
+ }]);
+ right.document.commit([{
+ op: "replace",
+ path: "/cards/draft/title",
+ value: "Ready",
+ }]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ cards: {
+ published: {
+ title: "Ready",
+ },
+ },
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ });
+
+ test("gives copied subtrees fresh identities", () => {
+ const initial = {
+ cards: {
+ source: {
+ title: "Draft",
+ },
+ },
+ };
+ const left = runtime("actor-a", initial);
+ const right = runtime("actor-b", initial);
+
+ left.document.commit([{
+ op: "copy",
+ from: "/cards/source",
+ path: "/cards/copy",
+ }]);
+ right.document.commit([{
+ op: "replace",
+ path: "/cards/source/title",
+ value: "Changed",
+ }]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ cards: {
+ copy: {
+ title: "Draft",
+ },
+ source: {
+ title: "Changed",
+ },
+ },
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ });
+
+ test("does not retarget an edit across delete and same-key re-add", () => {
+ const initial = {
+ cards: {
+ draft: {
+ title: "Old",
+ },
+ },
+ };
+ const left = runtime("actor-a", initial);
+ const right = runtime("actor-b", initial);
+
+ left.document.commit([
+ { op: "remove", path: "/cards/draft" },
+ {
+ op: "add",
+ path: "/cards/draft",
+ value: { title: "New" },
+ },
+ ]);
+ right.document.commit([{
+ op: "replace",
+ path: "/cards/draft/title",
+ value: "Edited old member",
+ }]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ cards: {
+ draft: {
+ title: "New",
+ },
+ },
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ });
+
+ test("queues out-of-order changes and integrates the unlocked causal chain once", () => {
+ const source = runtime("actor-a");
+ const target = runtime("actor-b");
+
+ source.document.commit([
+ { op: "replace", path: "/title", value: "First" },
+ ]);
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Second" },
+ ]);
+ const exported = source.collaboration.exportBundle();
+ const first = exported.changes[0];
+ const second = exported.changes[1];
+ if (first === undefined || second === undefined) {
+ throw new Error("expected two changes");
+ }
+
+ const secondOnly: CollaborationBundle = {
+ epoch: exported.epoch,
+ changes: [second],
+ };
+ expect(target.collaboration.ingest(secondOnly)).toMatchObject({
+ ok: true,
+ integrated: [],
+ pending: [{ actorId: "actor-a", counter: 2 }],
+ });
+ expect(target.document.value).toMatchObject({ title: "Draft" });
+
+ const publications: unknown[] = [];
+ target.document.subscribe((change) => publications.push(change));
+ expect(target.collaboration.ingest({
+ epoch: exported.epoch,
+ changes: [first],
+ })).toMatchObject({
+ ok: true,
+ integrated: [
+ { actorId: "actor-a", counter: 1 },
+ { actorId: "actor-a", counter: 2 },
+ ],
+ pending: [],
+ });
+ expect(target.document.value).toMatchObject({ title: "Second" });
+ expect(publications).toHaveLength(1);
+ });
+
+ test("publishes immutable collaboration snapshots once per state-adding ingest", () => {
+ const source = runtime("actor-a");
+ const target = runtime("actor-b");
+ source.document.commit([
+ { op: "replace", path: "/title", value: "First" },
+ ]);
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Second" },
+ ]);
+ const bundle = source.collaboration.exportBundle();
+ const first = bundle.changes[0];
+ const second = bundle.changes[1];
+ if (first === undefined || second === undefined) {
+ throw new Error("expected two changes");
+ }
+
+ const snapshots: ReturnType[] = [];
+ const unsubscribe = target.collaboration.subscribe((snapshot) => {
+ snapshots.push(snapshot);
+ });
+ target.collaboration.ingest({
+ epoch: bundle.epoch,
+ changes: [second],
+ });
+
+ expect(snapshots).toHaveLength(1);
+ const pendingSnapshot = snapshots[0];
+ expect(Object.isFrozen(pendingSnapshot)).toBe(true);
+ expect(Object.isFrozen(pendingSnapshot?.pending)).toBe(true);
+ expect(Object.isFrozen(pendingSnapshot?.pending[0])).toBe(true);
+ expect(Object.isFrozen(pendingSnapshot?.pending[0]?.missing)).toBe(true);
+
+ target.collaboration.ingest({
+ epoch: bundle.epoch,
+ changes: [second],
+ });
+ expect(snapshots).toHaveLength(1);
+
+ unsubscribe();
+ target.collaboration.ingest({
+ epoch: bundle.epoch,
+ changes: [first],
+ });
+ expect(snapshots).toHaveLength(1);
+ expect(target.document.value).toMatchObject({ title: "Second" });
+ });
+
+ test("queues reentrant collaboration publications in causal order", () => {
+ const source = runtime("actor-a");
+ const target = runtime("actor-b");
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Remote" },
+ ]);
+
+ let reentered = false;
+ const publishedHeads: string[] = [];
+ target.collaboration.subscribe(() => {
+ if (reentered) return;
+ reentered = true;
+ expect(target.document.commit([
+ { op: "replace", path: "/done", value: true },
+ ])).toMatchObject({ ok: true });
+ });
+ target.collaboration.subscribe((snapshot) => {
+ const head = snapshot.heads[0];
+ if (head !== undefined) {
+ publishedHeads.push(`${head.actorId}:${head.counter}`);
+ }
+ });
+
+ expect(target.collaboration.ingest(
+ source.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+ expect(publishedHeads).toEqual(["actor-a:1", "actor-b:1"]);
+ expect(target.document.value).toMatchObject({
+ title: "Remote",
+ done: true,
+ });
+ });
+
+ test("isolates collaboration listener failures after committing state", () => {
+ const source = runtime("actor-a");
+ const target = runtime("actor-b");
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Integrated" },
+ ]);
+
+ let laterListenerCalls = 0;
+ target.collaboration.subscribe(() => {
+ throw new Error("listener failed");
+ });
+ target.collaboration.subscribe(() => {
+ laterListenerCalls += 1;
+ });
+
+ expect(() => target.collaboration.ingest(
+ source.collaboration.exportBundle(),
+ )).not.toThrow();
+ expect(laterListenerCalls).toBe(1);
+ expect(target.document.value).toMatchObject({ title: "Integrated" });
+ });
+
+ test("skips a collaboration listener unsubscribed during publication", () => {
+ const source = runtime("actor-a");
+ const target = runtime("actor-b");
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Integrated" },
+ ]);
+
+ const firstListener = vi.fn();
+ const secondListener = vi.fn();
+ let unsubscribeSecond = (): void => undefined;
+ target.collaboration.subscribe((snapshot) => {
+ firstListener(snapshot);
+ unsubscribeSecond();
+ });
+ unsubscribeSecond = target.collaboration.subscribe(secondListener);
+
+ expect(target.collaboration.ingest(
+ source.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+ expect(firstListener).toHaveBeenCalledOnce();
+ expect(secondListener).not.toHaveBeenCalled();
+ });
+
+ test("suppresses a whole concurrent Change when the combined projection is invalid", () => {
+ const accepts = (candidate: unknown) => {
+ const value = candidate as {
+ readonly local: boolean;
+ readonly remote: boolean;
+ };
+ return value.local && value.remote
+ ? {
+ ok: false as const,
+ code: "schema_violation",
+ reason: "local and remote are mutually exclusive",
+ pointer: "/remote",
+ }
+ : { ok: true as const };
+ };
+ const options = {
+ ruleset: {
+ id: "test/exclusive-flags",
+ digest: "test/exclusive-flags/v1",
+ },
+ accepts,
+ };
+ const left = runtime(
+ "actor-a",
+ { local: false, remote: false },
+ options,
+ );
+ const right = runtime(
+ "actor-b",
+ { local: false, remote: false },
+ options,
+ );
+
+ left.document.commit([
+ { op: "replace", path: "/local", value: true },
+ ]);
+ right.document.commit([
+ { op: "replace", path: "/remote", value: true },
+ ]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ local: true,
+ remote: false,
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().suppressed).toEqual([
+ {
+ changeId: { actorId: "actor-b", counter: 1 },
+ code: "schema_violation",
+ reason: "local and remote are mutually exclusive",
+ pointer: "/remote",
+ },
+ ]);
+ });
+
+ test("preserves explicit test preconditions when an ancestor Change is suppressed", () => {
+ const accepts = (candidate: unknown) => {
+ const value = candidate as {
+ readonly local: boolean;
+ readonly remote: boolean;
+ };
+ return value.local && value.remote
+ ? { ok: false as const, code: "schema_violation" }
+ : { ok: true as const };
+ };
+ const overrides = {
+ ruleset: {
+ id: "test/causal-precondition",
+ digest: "test/causal-precondition/v1",
+ },
+ accepts,
+ };
+ const left = runtime(
+ "actor-a",
+ { local: false, remote: false },
+ overrides,
+ );
+ const right = runtime(
+ "actor-b",
+ { local: false, remote: false },
+ overrides,
+ );
+
+ left.document.commit([
+ { op: "replace", path: "/local", value: true },
+ ]);
+ right.document.commit([
+ { op: "replace", path: "/remote", value: true },
+ ]);
+ right.document.commit([
+ { op: "test", path: "/remote", value: true },
+ { op: "add", path: "/note", value: "ready" },
+ ]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ local: true,
+ remote: false,
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().suppressed).toMatchObject([
+ {
+ changeId: { actorId: "actor-b", counter: 1 },
+ code: "schema_violation",
+ },
+ {
+ changeId: { actorId: "actor-b", counter: 2 },
+ code: "test_failed",
+ },
+ ]);
+ });
+
+ test("isolates acceptance candidates from mutation during remote materialization", () => {
+ const mutatingAcceptance = (candidate: unknown) => {
+ const value = candidate as { readonly forbidden: boolean };
+ if (value.forbidden) {
+ Reflect.set(value, "forbidden", false);
+ }
+ return value.forbidden
+ ? { ok: false as const, code: "schema_violation" }
+ : { ok: true as const };
+ };
+ const overrides = {
+ ruleset: {
+ id: "test/immutable-acceptance",
+ digest: "test/immutable-acceptance/v1",
+ },
+ };
+ expect(() => runtime(
+ "invalid-initial",
+ { forbidden: true },
+ { ...overrides, accepts: mutatingAcceptance },
+ )).toThrow("Initial document value was rejected");
+
+ const source = runtime("actor-a", { forbidden: false }, overrides);
+ const target = runtime("actor-b", { forbidden: false }, {
+ ...overrides,
+ accepts: mutatingAcceptance,
+ });
+ source.document.commit([
+ { op: "replace", path: "/forbidden", value: true },
+ ]);
+
+ const untrusted = source.collaboration.exportBundle();
+ expect(target.collaboration.ingest({
+ epoch: target.collaboration.epoch,
+ changes: untrusted.changes,
+ }))
+ .toMatchObject({ ok: true });
+ expect(target.document.value).toEqual({ forbidden: false });
+ expect(target.collaboration.current().suppressed).toMatchObject([
+ {
+ changeId: { actorId: "actor-a", counter: 1 },
+ code: "schema_violation",
+ },
+ ]);
+ });
+
+ test("retains both same-key insertions while projecting one deterministic winner", () => {
+ const left = runtime("actor-a", { fields: {} });
+ const right = runtime("actor-b", { fields: {} });
+
+ left.document.commit([
+ { op: "add", path: "/fields/name", value: "Left" },
+ ]);
+ right.document.commit([
+ { op: "add", path: "/fields/name", value: "Right" },
+ ]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({ fields: { name: "Right" } });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().conflicts).toMatchObject([
+ {
+ kind: "object-key",
+ key: "name",
+ },
+ ]);
+
+ const removal = [{ op: "remove", path: "/fields/name" }] as const;
+ expect(left.document.canPatch(removal)).toEqual({ ok: true });
+ expect(left.document.commit(removal)).toMatchObject({ ok: true });
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({ fields: {} });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().conflicts).toEqual([]);
+ });
+
+ test("retains concurrent moves that replace the same destination member", () => {
+ const initial = {
+ x: "X",
+ y: "Y",
+ z: "Z",
+ };
+ const left = runtime("actor-a", initial);
+ const right = runtime("actor-b", initial);
+
+ left.document.commit([{
+ op: "move",
+ from: "/x",
+ path: "/y",
+ }]);
+ right.document.commit([{
+ op: "move",
+ from: "/z",
+ path: "/y",
+ }]);
+
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({ y: "Z" });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().suppressed).toEqual([]);
+ expect(left.collaboration.current().conflicts).toMatchObject([
+ {
+ kind: "object-key",
+ key: "y",
+ },
+ ]);
+ });
+
+ test("moves a projected object-key winner without revealing its hidden alternative", () => {
+ const left = runtime("actor-a", { fields: {} });
+ const right = runtime("actor-b", { fields: {} });
+ left.document.commit([
+ { op: "add", path: "/fields/name", value: "Left" },
+ ]);
+ right.document.commit([
+ { op: "add", path: "/fields/name", value: "Right" },
+ ]);
+ left.collaboration.ingest(right.collaboration.exportBundle());
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ const move = [{
+ op: "move",
+ from: "/fields/name",
+ path: "/fields/other",
+ }] as const;
+ expect(left.document.canPatch(move)).toEqual({ ok: true });
+ expect(left.document.commit(move)).toMatchObject({ ok: true });
+ right.collaboration.ingest(left.collaboration.exportBundle());
+
+ expect(left.document.value).toEqual({
+ fields: {
+ other: "Right",
+ },
+ });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(left.collaboration.current().conflicts).toEqual([]);
+ });
+
+ test("keeps conflict cleanup effective when an alternative was concurrently removed", () => {
+ const initial = { fields: {}, sequence: 0 };
+ const left = runtime("actor-a", initial);
+ const right = runtime("actor-b", initial);
+ const resolver = runtime("actor-c", initial);
+
+ left.document.commit([
+ { op: "add", path: "/fields/name", value: "Left" },
+ ]);
+ right.document.commit([
+ { op: "add", path: "/fields/name", value: "Right" },
+ ]);
+ resolver.document.commit([
+ { op: "replace", path: "/sequence", value: 1 },
+ ]);
+ resolver.document.commit([
+ { op: "replace", path: "/sequence", value: 2 },
+ ]);
+ resolver.collaboration.ingest(left.collaboration.exportBundle());
+ resolver.collaboration.ingest(right.collaboration.exportBundle());
+
+ left.document.commit([
+ { op: "remove", path: "/fields/name" },
+ ]);
+ resolver.document.commit([
+ { op: "remove", path: "/fields/name" },
+ ]);
+
+ const receiver = runtime("receiver", initial);
+ receiver.collaboration.ingest(left.collaboration.exportBundle());
+ receiver.collaboration.ingest(right.collaboration.exportBundle());
+ receiver.collaboration.ingest(resolver.collaboration.exportBundle());
+
+ expect(receiver.document.value).toEqual({
+ fields: {},
+ sequence: 2,
+ });
+ expect(receiver.collaboration.current().suppressed).toEqual([]);
+ });
+
+ test("does not report causally superseded writes as concurrent conflicts", () => {
+ const shared = runtime("actor-a");
+
+ shared.document.commit([
+ { op: "replace", path: "/title", value: "First" },
+ ]);
+ shared.document.commit([
+ { op: "replace", path: "/title", value: "Second" },
+ ]);
+
+ expect(shared.document.value).toMatchObject({ title: "Second" });
+ expect(shared.collaboration.current().conflicts).toEqual([]);
+ });
+
+ test("converges after receiving the same three branches in different orders", () => {
+ const authors = ["actor-a", "actor-b", "actor-c"].map((actorId) => (
+ runtime(actorId)
+ ));
+ authors[0]?.document.commit([
+ { op: "replace", path: "/title", value: "A" },
+ ]);
+ authors[1]?.document.commit([
+ { op: "add", path: "/items/1", value: "B" },
+ ]);
+ authors[2]?.document.commit([
+ { op: "replace", path: "/done", value: true },
+ ]);
+ const bundles = authors.map((author) => author.collaboration.exportBundle());
+ const first = runtime("receiver-a");
+ const second = runtime("receiver-b");
+
+ for (const index of [0, 1, 2]) {
+ first.collaboration.ingest(bundles[index]);
+ }
+ for (const index of [2, 0, 1]) {
+ second.collaboration.ingest(bundles[index]);
+ }
+
+ expect(first.document.value).toEqual(second.document.value);
+ expect(first.collaboration.current()).toEqual(
+ second.collaboration.current(),
+ );
+ });
+
+ test("rejects a mismatched ruleset before changing causal or projected state", () => {
+ const left = runtime("actor-a");
+ const right = runtime("actor-b", undefined, {
+ ruleset: {
+ id: "test/other",
+ digest: "test/other/v1",
+ },
+ });
+ right.document.commit([
+ { op: "replace", path: "/title", value: "Other" },
+ ]);
+ const before = left.collaboration.current();
+
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toEqual({
+ ok: false,
+ code: "ruleset_mismatch",
+ reason: "bundle ruleset does not match this document epoch",
+ });
+ expect(left.document.value).toMatchObject({ title: "Draft" });
+ expect(left.collaboration.current()).toEqual(before);
+ });
+
+ test("treats identical delivery as idempotent and rejects changed duplicate payloads", () => {
+ const source = runtime("actor-a");
+ const target = runtime("actor-b");
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Once" },
+ ]);
+ const bundle = source.collaboration.exportBundle();
+
+ expect(target.collaboration.ingest(bundle)).toMatchObject({ ok: true });
+ expect(target.collaboration.ingest(bundle)).toMatchObject({
+ ok: true,
+ duplicates: [{ actorId: "actor-a", counter: 1 }],
+ });
+
+ const change = bundle.changes[0];
+ if (change === undefined) throw new Error("expected one change");
+ expect(target.collaboration.ingest({
+ epoch: bundle.epoch,
+ changes: [{
+ ...change,
+ ops: [{
+ kind: "set",
+ target: change.ops[0]?.kind === "set"
+ ? change.ops[0].target
+ : "different",
+ value: "tampered",
+ }],
+ }],
+ })).toMatchObject({
+ ok: false,
+ code: "duplicate_mismatch",
+ changeId: { actorId: "actor-a", counter: 1 },
+ });
+ expect(target.document.value).toMatchObject({ title: "Once" });
+ });
+
+ test("rejects a non-initial actor counter before it can poison history", () => {
+ const shared = runtime("actor-a");
+ const before = shared.collaboration.current();
+ const exhausted: CollaborationBundle = {
+ epoch: shared.collaboration.epoch,
+ changes: [{
+ changeId: {
+ actorId: "actor-a",
+ counter: Number.MAX_SAFE_INTEGER,
+ },
+ deps: [],
+ ops: [],
+ }],
+ };
+ expect(shared.collaboration.ingest(exhausted)).toEqual({
+ ok: false,
+ code: "actor_fork",
+ reason: "one actorId must form one contiguous causal change chain",
+ changeId: {
+ actorId: "actor-a",
+ counter: Number.MAX_SAFE_INTEGER,
+ },
+ });
+ expect(shared.collaboration.current()).toEqual(before);
+ expect(shared.collaboration.exportBundle().changes).toEqual([]);
+ expect(shared.document.value).toMatchObject({ title: "Draft" });
+ });
+
+ test("rejects a non-causal fork within one actor lineage transactionally", () => {
+ const target = runtime("peer");
+ const before = target.collaboration.current();
+ const forked: CollaborationBundle = {
+ epoch: target.collaboration.epoch,
+ changes: [
+ {
+ changeId: { actorId: "actor-a", counter: 1 },
+ deps: [],
+ ops: [],
+ },
+ {
+ changeId: { actorId: "actor-a", counter: 2 },
+ deps: [],
+ ops: [],
+ },
+ ],
+ };
+
+ expect(target.collaboration.ingest(forked)).toEqual({
+ ok: false,
+ code: "actor_fork",
+ reason: "one actorId must form one contiguous causal change chain",
+ changeId: { actorId: "actor-a", counter: 2 },
+ });
+ expect(target.collaboration.current()).toEqual(before);
+ expect(target.collaboration.exportBundle().changes).toEqual([]);
+ });
+
+ test("rejects a pending actor fork before it can poison later dependencies", () => {
+ const target = runtime("peer");
+ const fork: CollaborationBundle = {
+ epoch: target.collaboration.epoch,
+ changes: [{
+ changeId: { actorId: "actor-a", counter: 2 },
+ deps: [{ actorId: "actor-b", counter: 1 }],
+ ops: [],
+ }],
+ };
+
+ expect(target.collaboration.ingest(fork)).toEqual({
+ ok: false,
+ code: "actor_fork",
+ reason: "one actorId must form one contiguous causal change chain",
+ changeId: { actorId: "actor-a", counter: 2 },
+ });
+ expect(target.collaboration.exportBundle().changes).toEqual([]);
+ });
+
+ test("rejects a first Change that depends on its own future counter", () => {
+ const target = runtime("peer");
+ const poisoned: CollaborationBundle = {
+ epoch: target.collaboration.epoch,
+ changes: [{
+ changeId: { actorId: "actor-a", counter: 1 },
+ deps: [{ actorId: "actor-a", counter: 2 }],
+ ops: [],
+ }],
+ };
+
+ expect(target.collaboration.ingest(poisoned)).toEqual({
+ ok: false,
+ code: "actor_fork",
+ reason: "one actorId must form one contiguous causal change chain",
+ changeId: { actorId: "actor-a", counter: 1 },
+ });
+ expect(target.collaboration.exportBundle().changes).toEqual([]);
+ expect(target.collaboration.current().pending).toEqual([]);
+ });
+
+ test("does not let a restored actor fork while its own history is pending", () => {
+ const source = runtime("actor-a");
+ source.document.commit([
+ { op: "replace", path: "/title", value: "First" },
+ ]);
+ source.document.commit([
+ { op: "replace", path: "/title", value: "Second" },
+ ]);
+ const bundle = source.collaboration.exportBundle();
+ const first = bundle.changes[0];
+ const second = bundle.changes[1];
+ if (first === undefined || second === undefined) {
+ throw new Error("expected two changes");
+ }
+
+ const restored = runtime("actor-a");
+ expect(restored.collaboration.ingest({
+ epoch: bundle.epoch,
+ changes: [second],
+ })).toMatchObject({
+ ok: true,
+ pending: [{ actorId: "actor-a", counter: 2 }],
+ });
+ const operation = [{
+ op: "replace",
+ path: "/title",
+ value: "Fork",
+ }] as const;
+ expect(restored.document.canPatch(operation)).toEqual({
+ ok: false,
+ code: "actor_history_pending",
+ reason: "cannot author while this actor has pending causal history",
+ });
+
+ expect(restored.collaboration.ingest({
+ epoch: bundle.epoch,
+ changes: [first],
+ })).toMatchObject({ ok: true, pending: [] });
+ expect(restored.document.commit(operation)).toMatchObject({ ok: true });
+
+ const peer = runtime("peer");
+ expect(peer.collaboration.ingest(restored.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(peer.document.value).toMatchObject({ title: "Fork" });
+ expect(peer.collaboration.current().conflicts).toEqual([]);
+ });
+});
diff --git a/packages/json-document-collaboration/tests/history.test.ts b/packages/json-document-collaboration/tests/history.test.ts
new file mode 100644
index 00000000..10d2c01e
--- /dev/null
+++ b/packages/json-document-collaboration/tests/history.test.ts
@@ -0,0 +1,487 @@
+import { describe, expect, test, vi } from "vitest";
+
+import {
+ createCollaborationRuntime,
+ type CollaborationBundle,
+ type CollaborationRuntimeOptions,
+} from "../src/index.js";
+import { createCollaborationHistoryRuntime } from "../src/history-index.js";
+
+const baseOptions = {
+ epochId: "shared-history-document/v1",
+ ruleset: {
+ id: "test/json-tree-history",
+ digest: "test/json-tree-history/v1",
+ },
+} as const;
+
+function runtime(
+ actorId: string,
+ initial: unknown = {
+ title: "Draft",
+ done: false,
+ },
+ overrides: Partial = {},
+) {
+ return createCollaborationHistoryRuntime(initial, {
+ ...baseOptions,
+ actorId,
+ ...overrides,
+ });
+}
+
+function oneChangeBundle(
+ bundle: CollaborationBundle,
+ actorId: string,
+ counter: number,
+): CollaborationBundle {
+ const change = bundle.changes.find((candidate) => (
+ candidate.changeId.actorId === actorId
+ && candidate.changeId.counter === counter
+ ));
+ if (change === undefined) {
+ throw new Error(`missing Change ${actorId}:${counter}`);
+ }
+ return {
+ epoch: bundle.epoch,
+ changes: [change],
+ };
+}
+
+describe("@interactive-os/json-document-collaboration/history", () => {
+ test("adds history outside the unchanged six-member document", () => {
+ const shared = runtime("actor-a");
+
+ expect(Object.keys(shared).sort()).toEqual([
+ "collaboration",
+ "document",
+ "history",
+ ]);
+ expect(Object.keys(shared.document).sort()).toEqual([
+ "at",
+ "canPatch",
+ "commit",
+ "query",
+ "subscribe",
+ "value",
+ ]);
+ });
+
+ test("withdraws and reinstates only the local write behind a remote winner", () => {
+ const left = runtime("actor-a");
+ const right = runtime("actor-b");
+
+ expect(left.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Left",
+ }])).toMatchObject({ ok: true });
+ expect(right.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Right",
+ }])).toMatchObject({ ok: true });
+
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(left.document.value).toMatchObject({ title: "Right" });
+ expect(left.history.current().undoTarget).toEqual({
+ actorId: "actor-a",
+ counter: 1,
+ });
+
+ const documentListener = vi.fn();
+ const collaborationListener = vi.fn();
+ left.document.subscribe(documentListener);
+ left.collaboration.subscribe(collaborationListener);
+
+ expect(left.history.canUndo()).toMatchObject({ ok: true });
+ expect(left.history.undo()).toMatchObject({ ok: true });
+ expect(left.document.value).toMatchObject({ title: "Right" });
+ expect(left.history.current().redoTarget).toEqual({
+ actorId: "actor-a",
+ counter: 1,
+ });
+ expect(left.collaboration.exportBundle().changes).toHaveLength(3);
+ expect(documentListener).not.toHaveBeenCalled();
+ expect(collaborationListener).toHaveBeenCalledTimes(1);
+
+ expect(left.history.canRedo()).toMatchObject({ ok: true });
+ expect(left.history.redo()).toMatchObject({ ok: true });
+ expect(left.document.value).toMatchObject({ title: "Right" });
+ expect(left.collaboration.exportBundle().changes).toHaveLength(4);
+ expect(documentListener).not.toHaveBeenCalled();
+ expect(collaborationListener).toHaveBeenCalledTimes(2);
+
+ expect(right.collaboration.ingest(left.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(right.document.value).toEqual(left.document.value);
+ });
+
+ test("rejects undo when a remote descendant edits an inserted identity", () => {
+ const left = runtime("actor-a", { cards: {} });
+ const right = runtime("actor-b", { cards: {} });
+
+ expect(left.document.commit([{
+ op: "add",
+ path: "/cards/draft",
+ value: { title: "Draft" },
+ }])).toMatchObject({ ok: true });
+ expect(right.collaboration.ingest(left.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(right.document.commit([{
+ op: "replace",
+ path: "/cards/draft/title",
+ value: "Reviewed",
+ }])).toMatchObject({ ok: true });
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ const before = left.collaboration.exportBundle();
+ expect(left.history.current().undoTarget).toEqual({
+ actorId: "actor-a",
+ counter: 1,
+ });
+ expect(left.history.canUndo()).toMatchObject({ ok: false });
+ expect(left.history.undo()).toMatchObject({ ok: false });
+
+ expect(left.collaboration.exportBundle()).toEqual(before);
+ expect(left.history.current()).toMatchObject({
+ undoTarget: { actorId: "actor-a", counter: 1 },
+ });
+ expect(left.history.current().redoTarget).toBeNull();
+ expect(left.document.value).toEqual({
+ cards: {
+ draft: {
+ title: "Reviewed",
+ },
+ },
+ });
+ });
+
+ test("re-evaluates a schema-suppressed remote Change after undo", () => {
+ const accepts = (candidate: unknown) => {
+ const value = candidate as {
+ readonly local: boolean;
+ readonly remote: boolean;
+ };
+ return value.local && value.remote
+ ? {
+ ok: false as const,
+ code: "schema_violation",
+ reason: "local and remote are mutually exclusive",
+ }
+ : { ok: true as const };
+ };
+ const overrides = {
+ ruleset: {
+ id: "test/exclusive-history-flags",
+ digest: "test/exclusive-history-flags/v1",
+ },
+ accepts,
+ };
+ const left = runtime(
+ "actor-a",
+ { local: false, remote: false },
+ overrides,
+ );
+ const right = runtime(
+ "actor-b",
+ { local: false, remote: false },
+ overrides,
+ );
+
+ expect(left.document.commit([{
+ op: "replace",
+ path: "/local",
+ value: true,
+ }])).toMatchObject({ ok: true });
+ expect(right.document.commit([{
+ op: "replace",
+ path: "/remote",
+ value: true,
+ }])).toMatchObject({ ok: true });
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ expect(left.document.value).toEqual({
+ local: true,
+ remote: false,
+ });
+ expect(left.collaboration.current().suppressed).toMatchObject([{
+ changeId: { actorId: "actor-b", counter: 1 },
+ code: "schema_violation",
+ }]);
+
+ expect(left.history.undo()).toMatchObject({ ok: true });
+ expect(left.document.value).toEqual({
+ local: false,
+ remote: true,
+ });
+ expect(left.collaboration.current().suppressed).toEqual([]);
+
+ expect(right.collaboration.ingest(left.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(right.document.value).toEqual(left.document.value);
+ expect(right.collaboration.current()).toEqual(
+ left.collaboration.current(),
+ );
+ });
+
+ test("keeps an acknowledged undo-redo pair neutral when a concurrent edit arrives late", () => {
+ const author = runtime("actor-a", { value: 0 });
+ const remote = runtime("actor-b", { value: 0 });
+
+ expect(author.document.commit([{
+ op: "remove",
+ path: "/value",
+ }])).toMatchObject({ ok: true });
+ expect(remote.document.commit([{
+ op: "replace",
+ path: "/value",
+ value: 1,
+ }])).toMatchObject({ ok: true });
+
+ expect(author.history.undo()).toMatchObject({ ok: true });
+ expect(author.history.redo()).toMatchObject({ ok: true });
+ expect(author.document.value).toEqual({});
+
+ expect(author.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ expect(author.document.value).toEqual({});
+ expect(author.collaboration.current().suppressed).toMatchObject([{
+ changeId: { actorId: "actor-b", counter: 1 },
+ code: "target_not_found",
+ }]);
+ expect(author.history.current()).toEqual({
+ undoTarget: { actorId: "actor-a", counter: 1 },
+ redoTarget: null,
+ });
+ });
+
+ test("does not reinterpret a late array anchor after its observed move is undone", () => {
+ const author = runtime("actor-a", {
+ items: ["A", "B", "C", "D"],
+ });
+ const remote = runtime("actor-b", {
+ items: ["A", "B", "C", "D"],
+ });
+
+ expect(author.document.commit([{
+ op: "move",
+ from: "/items/1",
+ path: "/items/3",
+ }])).toMatchObject({ ok: true });
+ expect(author.document.value).toEqual({
+ items: ["A", "C", "D", "B"],
+ });
+ expect(remote.collaboration.ingest(author.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ expect(author.history.undo()).toMatchObject({ ok: true });
+ expect(author.document.value).toEqual({
+ items: ["A", "B", "C", "D"],
+ });
+
+ expect(remote.document.commit([{
+ op: "add",
+ path: "/items/3",
+ value: "X",
+ }])).toMatchObject({ ok: true });
+ expect(remote.document.value).toEqual({
+ items: ["A", "C", "D", "X", "B"],
+ });
+
+ expect(author.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ expect(author.document.value).toEqual({
+ items: ["A", "B", "C", "D", "X"],
+ });
+ expect(author.collaboration.current().suppressed).toEqual([]);
+
+ expect(remote.collaboration.ingest(author.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(remote.document.value).toEqual(author.document.value);
+ expect(remote.collaboration.current()).toEqual(
+ author.collaboration.current(),
+ );
+ });
+
+ test("converges when an undo Change arrives before its causal dependencies", () => {
+ const author = runtime("actor-a");
+ const remote = runtime("actor-b");
+
+ expect(author.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Local",
+ }])).toMatchObject({ ok: true });
+ expect(remote.document.commit([{
+ op: "replace",
+ path: "/done",
+ value: true,
+ }])).toMatchObject({ ok: true });
+ expect(author.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(author.history.undo()).toMatchObject({ ok: true });
+
+ const complete = author.collaboration.exportBundle();
+ const localEdit = oneChangeBundle(complete, "actor-a", 1);
+ const undo = oneChangeBundle(complete, "actor-a", 2);
+ const remoteEdit = oneChangeBundle(complete, "actor-b", 1);
+ const inOrder = runtime("receiver-a");
+ const outOfOrder = runtime("receiver-b");
+
+ expect(inOrder.collaboration.ingest(complete))
+ .toMatchObject({ ok: true });
+ expect(outOfOrder.collaboration.ingest(undo))
+ .toMatchObject({
+ ok: true,
+ pending: [{ actorId: "actor-a", counter: 2 }],
+ });
+ expect(outOfOrder.collaboration.ingest(remoteEdit))
+ .toMatchObject({ ok: true });
+ expect(outOfOrder.collaboration.ingest(localEdit))
+ .toMatchObject({ ok: true, pending: [] });
+
+ expect(outOfOrder.document.value).toEqual(inOrder.document.value);
+ expect(outOfOrder.collaboration.current()).toEqual(
+ inOrder.collaboration.current(),
+ );
+ expect(outOfOrder.collaboration.exportBundle()).toEqual(
+ inOrder.collaboration.exportBundle(),
+ );
+ });
+
+ test("lets a base peer relay history wire operations without authoring them", () => {
+ const author = runtime("actor-a");
+ author.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Temporary",
+ }]);
+ author.history.undo();
+
+ const relay = createCollaborationRuntime(
+ { title: "Draft", done: false },
+ { ...baseOptions, actorId: "relay" },
+ );
+ expect("history" in relay).toBe(false);
+ expect(relay.collaboration.ingest(
+ author.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+ expect(relay.document.value).toEqual(author.document.value);
+
+ const receiver = runtime("receiver");
+ expect(receiver.collaboration.ingest(
+ relay.collaboration.exportBundle(),
+ )).toMatchObject({ ok: true });
+ expect(receiver.document.value).toEqual(author.document.value);
+ expect(receiver.collaboration.current()).toEqual(
+ author.collaboration.current(),
+ );
+ });
+
+ test("publishes one document and collaboration event per visible undo or redo", () => {
+ const shared = runtime("actor-a");
+ expect(shared.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "Published",
+ }])).toMatchObject({ ok: true });
+
+ const documentValues: unknown[] = [];
+ const collaborationListener = vi.fn();
+ shared.document.subscribe(() => {
+ documentValues.push(shared.document.value);
+ });
+ shared.collaboration.subscribe(collaborationListener);
+
+ expect(shared.history.canUndo()).toMatchObject({ ok: true });
+ expect(shared.history.undo()).toMatchObject({ ok: true });
+ expect(shared.history.canRedo()).toMatchObject({ ok: true });
+ expect(shared.history.redo()).toMatchObject({ ok: true });
+
+ expect(documentValues).toEqual([
+ { title: "Draft", done: false },
+ { title: "Published", done: false },
+ ]);
+ expect(collaborationListener).toHaveBeenCalledTimes(2);
+ });
+
+ test("clears redo when the same actor authors a new data Change", () => {
+ const shared = runtime("actor-a");
+ shared.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "First",
+ }]);
+ expect(shared.history.undo()).toMatchObject({ ok: true });
+ expect(shared.history.current().redoTarget).toEqual({
+ actorId: "actor-a",
+ counter: 1,
+ });
+
+ shared.document.commit([{
+ op: "replace",
+ path: "/done",
+ value: true,
+ }]);
+
+ expect(shared.history.current().redoTarget).toBeNull();
+ expect(shared.history.canRedo()).toMatchObject({
+ ok: false,
+ code: "nothing_to_redo",
+ });
+ });
+
+ test("suppresses a forged redo that skips the latest effective undo", () => {
+ const shared = runtime("actor-a", {
+ first: 0,
+ second: 0,
+ });
+ shared.document.commit([{
+ op: "replace",
+ path: "/first",
+ value: 1,
+ }]);
+ shared.document.commit([{
+ op: "replace",
+ path: "/second",
+ value: 1,
+ }]);
+ expect(shared.history.undo()).toMatchObject({ ok: true });
+ expect(shared.history.undo()).toMatchObject({ ok: true });
+
+ const epoch = shared.collaboration.epoch;
+ expect(shared.collaboration.ingest({
+ epoch,
+ changes: [{
+ changeId: { actorId: "actor-a", counter: 5 },
+ deps: [{ actorId: "actor-a", counter: 4 }],
+ ops: [{
+ kind: "redo-change",
+ undo: { actorId: "actor-a", counter: 3 },
+ }],
+ }],
+ })).toMatchObject({
+ ok: true,
+ integrated: [{ actorId: "actor-a", counter: 5 }],
+ });
+
+ expect(shared.document.value).toEqual({
+ first: 0,
+ second: 0,
+ });
+ expect(shared.collaboration.current().suppressed).toMatchObject([{
+ changeId: { actorId: "actor-a", counter: 5 },
+ code: "redo_target_invalid",
+ }]);
+ expect(shared.history.current()).toEqual({
+ undoTarget: null,
+ redoTarget: { actorId: "actor-a", counter: 1 },
+ });
+ });
+});
diff --git a/packages/json-document-collaboration/tests/projection-conformance.test.ts b/packages/json-document-collaboration/tests/projection-conformance.test.ts
new file mode 100644
index 00000000..a46aa141
--- /dev/null
+++ b/packages/json-document-collaboration/tests/projection-conformance.test.ts
@@ -0,0 +1,61 @@
+import {
+ type JSONCapabilityResult,
+ type JSONValue,
+} from "@interactive-os/json-document";
+
+import { createCollaborationRuntime } from "../src/index.js";
+import {
+ runProjectionConformance,
+ type Projection,
+ type ProjectionAcceptance,
+ type ProjectionHarness,
+} from "../../json-document/tests/conformance/v2/projection-suite.js";
+
+function createProjection(
+ acceptance: ProjectionAcceptance,
+ initial: JSONValue,
+): Projection {
+ return createCollaborationRuntime(initial, {
+ actorId: "conformance",
+ epochId: "projection-conformance/v1",
+ ruleset: {
+ id: `projection-conformance/${acceptance}`,
+ digest: `projection-conformance/${acceptance}/v1`,
+ },
+ ...(acceptance === "task-list"
+ ? { accepts: taskListAcceptance }
+ : {}),
+ }).document;
+}
+
+function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult {
+ const valid = isRecord(candidate)
+ && typeof candidate.title === "string"
+ && Array.isArray(candidate.items)
+ && candidate.items.every((item) => (
+ isRecord(item)
+ && typeof item.id === "string"
+ && typeof item.done === "boolean"
+ ))
+ && isRecord(candidate.meta)
+ && typeof candidate.meta.owner === "string";
+ return valid
+ ? { ok: true }
+ : {
+ ok: false,
+ code: "schema_violation",
+ reason: "candidate does not satisfy the task-list acceptance rule",
+ };
+}
+
+function isRecord(
+ value: JSONValue | undefined,
+): value is { readonly [key: string]: JSONValue } {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+const harness: ProjectionHarness = {
+ create: createProjection,
+};
+
+runProjectionConformance(harness);
diff --git a/packages/json-document-collaboration/tests/text-wire.test.ts b/packages/json-document-collaboration/tests/text-wire.test.ts
new file mode 100644
index 00000000..8284ba6a
--- /dev/null
+++ b/packages/json-document-collaboration/tests/text-wire.test.ts
@@ -0,0 +1,656 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ createCollaborationRuntime,
+ type CollaborationBundle,
+ type CollaborationRuntimeOptions,
+} from "../src/index.js";
+import {
+ authoredTextAtomId,
+ createMinimalTextSplice,
+ createTextNodeId,
+ initialTextAtomId,
+} from "../src/text-core.js";
+import {
+ createInitialTree,
+ projectTree,
+ resolveTextMemberSnapshot,
+ resolveTextSnapshot,
+} from "../src/tree.js";
+import { compilePatchOperations } from "../src/translate.js";
+import type {
+ ChangeId,
+ CollaborationChange,
+ SemanticOperation,
+ TextSpliceOperation,
+} from "../src/types.js";
+
+const baseOptions = {
+ epochId: "shared-text-wire/v1",
+ ruleset: {
+ id: "test/json-tree-text",
+ digest: "test/json-tree-text/v1",
+ },
+} as const;
+
+function runtime(
+ actorId: string,
+ initial: unknown,
+ overrides: Partial = {},
+) {
+ return createCollaborationRuntime(initial, {
+ ...baseOptions,
+ actorId,
+ ...overrides,
+ });
+}
+
+function initialTextIds(
+ shared: ReturnType,
+ segments: ReadonlyArray,
+ value: string,
+) {
+ const seed = `initial:${shared.collaboration.epoch.baseDigest}`;
+ const path = [
+ "value",
+ ...segments.map((segment) => `${segment.length}:${segment}`),
+ ].join("/");
+ const textNode = createTextNodeId(seed, path);
+ return {
+ member: segments.length === 0
+ ? `root:${shared.collaboration.epoch.baseDigest}`
+ : `${seed}:member:${path}`,
+ textNode,
+ atoms: Array.from(value, (_, unitIndex) => (
+ initialTextAtomId(textNode, unitIndex)
+ )),
+ };
+}
+
+function change(
+ actorId: string,
+ ops: ReadonlyArray,
+ deps: ReadonlyArray = [],
+ counter = 1,
+): CollaborationChange {
+ return {
+ changeId: { actorId, counter },
+ deps,
+ ops,
+ };
+}
+
+function bundle(
+ shared: ReturnType,
+ changes: ReadonlyArray,
+): CollaborationBundle {
+ return {
+ epoch: shared.collaboration.epoch,
+ changes,
+ };
+}
+
+function splice(
+ target: ReturnType,
+ input: {
+ readonly left: number | null;
+ readonly right: number | null;
+ readonly removed?: ReadonlyArray;
+ readonly inserted?: string;
+ },
+): TextSpliceOperation {
+ return {
+ kind: "text-splice",
+ target: target.member,
+ textNode: target.textNode,
+ left: input.left === null ? null : target.atoms[input.left] as string,
+ right: input.right === null ? null : target.atoms[input.right] as string,
+ removed: (input.removed ?? []).map((index) => (
+ target.atoms[index] as string
+ )),
+ inserted: input.inserted ?? "",
+ };
+}
+
+describe("protocol v3 text-splice wire materialization", () => {
+ test("lets the base runtime ingest, project, export, and relay text Changes", () => {
+ const initial = { title: "ab" };
+ const receiver = runtime("receiver-a", initial);
+ const relay = runtime("receiver-b", initial);
+ const title = initialTextIds(receiver, ["title"], "ab");
+ const textChange = change("actor-a", [
+ splice(title, {
+ left: 0,
+ right: 1,
+ inserted: "X",
+ }),
+ ]);
+
+ expect(receiver.collaboration.ingest(bundle(receiver, [textChange])))
+ .toMatchObject({ ok: true });
+ expect(receiver.document.value).toEqual({ title: "aXb" });
+ expect(receiver.collaboration.exportBundle().changes[0]).toEqual(
+ textChange,
+ );
+
+ expect(relay.collaboration.ingest(receiver.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(relay.document.value).toEqual(receiver.document.value);
+ });
+
+ test("orders concurrent inserts in the same captured gap deterministically", () => {
+ const initial = { title: "ab" };
+ const basis = runtime("basis", initial);
+ const title = initialTextIds(basis, ["title"], "ab");
+ const left = change("actor-a", [
+ splice(title, { left: 0, right: 1, inserted: "X" }),
+ ]);
+ const right = change("actor-b", [
+ splice(title, { left: 0, right: 1, inserted: "Y" }),
+ ]);
+
+ for (const arrival of [[left, right], [right, left]]) {
+ const shared = runtime(`receiver-${arrival[0]?.changeId.actorId}`, initial);
+ for (const candidate of arrival) {
+ expect(shared.collaboration.ingest(bundle(shared, [candidate])))
+ .toMatchObject({ ok: true });
+ }
+ expect(shared.document.value).toEqual({ title: "aXYb" });
+ expect(shared.collaboration.current().suppressed).toEqual([]);
+ }
+ });
+
+ test("deletes only captured atoms and retains a concurrent insertion", () => {
+ const initial = { title: "abc" };
+ const basis = runtime("basis", initial);
+ const title = initialTextIds(basis, ["title"], "abc");
+ const replacement = change("actor-a", [
+ splice(title, {
+ left: 0,
+ right: 2,
+ removed: [1],
+ inserted: "X",
+ }),
+ ]);
+ const concurrentInsert = change("actor-b", [
+ splice(title, {
+ left: 0,
+ right: 2,
+ inserted: "Y",
+ }),
+ ]);
+
+ const first = runtime("receiver-a", initial);
+ const second = runtime("receiver-b", initial);
+ expect(first.collaboration.ingest(bundle(first, [
+ replacement,
+ concurrentInsert,
+ ]))).toMatchObject({ ok: true });
+ expect(second.collaboration.ingest(bundle(second, [
+ concurrentInsert,
+ replacement,
+ ]))).toMatchObject({ ok: true });
+
+ expect(first.document.value).toEqual({ title: "aXYc" });
+ expect(second.document.value).toEqual(first.document.value);
+ });
+
+ test("makes concurrent exact-atom deletion idempotent", () => {
+ const initial = { title: "abc" };
+ const basis = runtime("basis", initial);
+ const title = initialTextIds(basis, ["title"], "abc");
+ const remove = (actorId: string) => change(actorId, [
+ splice(title, {
+ left: 0,
+ right: 2,
+ removed: [1],
+ }),
+ ]);
+ const shared = runtime("receiver", initial);
+
+ expect(shared.collaboration.ingest(bundle(shared, [
+ remove("actor-a"),
+ remove("actor-b"),
+ ]))).toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({ title: "ac" });
+ expect(shared.collaboration.current().suppressed).toEqual([]);
+ });
+
+ test("uses lossless scalar units without normalization or surrogate splitting", () => {
+ const initial = { title: "A😀B" };
+ const shared = runtime("receiver", initial);
+ const title = initialTextIds(shared, ["title"], "A😀B");
+ expect(title.atoms).toHaveLength(3);
+ const inserted = `e\u0301👩💻\ud800`;
+ const unicodeChange = change("actor-a", [
+ splice(title, {
+ left: 0,
+ right: 2,
+ removed: [1],
+ inserted,
+ }),
+ ]);
+
+ expect(shared.collaboration.ingest(bundle(shared, [unicodeChange])))
+ .toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({ title: `A${inserted}B` });
+ expect((shared.document.value as { title: string }).title).not.toBe(
+ `A${inserted.normalize("NFC")}B`,
+ );
+ });
+
+ test("derives one authored atom for a surrogate pair", () => {
+ const initial = { title: "" };
+ const shared = runtime("receiver", initial);
+ const title = initialTextIds(shared, ["title"], "");
+ const insertId = { actorId: "actor-a", counter: 1 } as const;
+ const insert = change(insertId.actorId, [
+ splice(title, {
+ left: null,
+ right: null,
+ inserted: "😀",
+ }),
+ ]);
+ const remove = change(
+ "actor-a",
+ [{
+ kind: "text-splice",
+ target: title.member,
+ textNode: title.textNode,
+ left: null,
+ right: null,
+ removed: [authoredTextAtomId(insertId, 0, 0)],
+ inserted: "X",
+ }],
+ [insertId],
+ 2,
+ );
+
+ expect(shared.collaboration.ingest(bundle(shared, [remove])))
+ .toMatchObject({
+ ok: true,
+ pending: [{ actorId: "actor-a", counter: 2 }],
+ });
+ expect(shared.collaboration.ingest(bundle(shared, [insert])))
+ .toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({ title: "X" });
+ expect(shared.collaboration.current().pending).toEqual([]);
+ });
+
+ test("preserves text identity through an object rename", () => {
+ const initial = { source: "ab" };
+ const mover = runtime("actor-a", initial);
+ const source = initialTextIds(mover, ["source"], "ab");
+ expect(mover.document.commit([{
+ op: "move",
+ from: "/source",
+ path: "/renamed",
+ }])).toMatchObject({ ok: true });
+ const textChange = change("actor-b", [
+ splice(source, { left: 0, right: 1, inserted: "X" }),
+ ]);
+
+ const shared = runtime("receiver", initial);
+ expect(shared.collaboration.ingest(bundle(shared, [
+ ...mover.collaboration.exportBundle().changes,
+ textChange,
+ ]))).toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({ renamed: "aXb" });
+ });
+
+ test("preserves scalar text identity when moving it to the root slot", () => {
+ const initial = { source: "ab" };
+ const mover = runtime("actor-a", initial);
+ const source = initialTextIds(mover, ["source"], "ab");
+ expect(mover.document.commit([{
+ op: "move",
+ from: "/source",
+ path: "",
+ }])).toMatchObject({ ok: true });
+ const textChange = change("actor-b", [
+ splice(source, { left: 0, right: 1, inserted: "X" }),
+ ]);
+
+ const shared = runtime("receiver", initial);
+ expect(shared.collaboration.ingest(bundle(shared, [
+ ...mover.collaboration.exportBundle().changes,
+ textChange,
+ ]))).toMatchObject({ ok: true });
+ expect(shared.document.value).toBe("aXb");
+ expect(shared.collaboration.current().suppressed).toEqual([]);
+ });
+
+ test("gives copied text a fresh generation", () => {
+ const initial = { source: "ab" };
+ const copier = runtime("actor-a", initial);
+ const source = initialTextIds(copier, ["source"], "ab");
+ expect(copier.document.commit([{
+ op: "copy",
+ from: "/source",
+ path: "/copy",
+ }])).toMatchObject({ ok: true });
+ const textChange = change("actor-b", [
+ splice(source, { left: 0, right: 1, inserted: "X" }),
+ ]);
+
+ const shared = runtime("receiver", initial);
+ expect(shared.collaboration.ingest(bundle(shared, [
+ ...copier.collaboration.exportBundle().changes,
+ textChange,
+ ]))).toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({
+ source: "aXb",
+ copy: "ab",
+ });
+ });
+
+ test("does not resurrect a deleted member through an old text generation", () => {
+ const initial = { title: "ab" };
+ const remover = runtime("actor-a", initial);
+ const title = initialTextIds(remover, ["title"], "ab");
+ expect(remover.document.commit([{
+ op: "remove",
+ path: "/title",
+ }])).toMatchObject({ ok: true });
+ const textChange = change("actor-b", [
+ splice(title, { left: 0, right: 1, inserted: "X" }),
+ ]);
+
+ const shared = runtime("receiver", initial);
+ expect(shared.collaboration.ingest(bundle(shared, [
+ ...remover.collaboration.exportBundle().changes,
+ textChange,
+ ]))).toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({});
+ expect(shared.collaboration.current().suppressed).toMatchObject([{
+ changeId: { actorId: "actor-b", counter: 1 },
+ code: "text_target_deleted",
+ }]);
+ });
+
+ test("lets an atomic set replace an older collaborative text generation", () => {
+ const initial = { title: "ab" };
+ const setter = runtime("actor-a", initial);
+ const title = initialTextIds(setter, ["title"], "ab");
+ expect(setter.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "reset",
+ }])).toMatchObject({ ok: true });
+ const textChange = change("actor-b", [
+ splice(title, { left: 0, right: 1, inserted: "X" }),
+ ]);
+
+ const shared = runtime("receiver", initial);
+ expect(shared.collaboration.ingest(bundle(shared, [
+ ...setter.collaboration.exportBundle().changes,
+ textChange,
+ ]))).toMatchObject({ ok: true });
+ expect(shared.document.value).toEqual({ title: "reset" });
+ expect(shared.collaboration.current().suppressed).toMatchObject([{
+ changeId: { actorId: "actor-b", counter: 1 },
+ code: "text_generation_mismatch",
+ }]);
+ });
+
+ test("rejects malformed duplicate atom removals before changing state", () => {
+ const initial = { title: "ab" };
+ const shared = runtime("receiver", initial);
+ const title = initialTextIds(shared, ["title"], "ab");
+ const atom = title.atoms[0] as string;
+ const malformed = {
+ changeId: { actorId: "actor-a", counter: 1 },
+ deps: [],
+ ops: [{
+ kind: "text-splice",
+ target: title.member,
+ textNode: title.textNode,
+ left: null,
+ right: title.atoms[1],
+ removed: [atom, atom],
+ inserted: "",
+ }],
+ };
+
+ expect(shared.collaboration.ingest({
+ epoch: shared.collaboration.epoch,
+ changes: [malformed],
+ })).toMatchObject({
+ ok: false,
+ code: "invalid_bundle",
+ });
+ expect(shared.document.value).toEqual(initial);
+ expect(shared.collaboration.exportBundle().changes).toEqual([]);
+ });
+});
+
+describe("collaborative text authoring helpers", () => {
+ test("snapshots a pointer-resolved lazy string without promoting its state", () => {
+ const shared = runtime("basis", { title: "A😀B" });
+ const tree = createInitialTree(
+ shared.document.value,
+ shared.collaboration.epoch.baseDigest,
+ );
+ const snapshot = resolveTextSnapshot(tree, ["title"]);
+ expect(snapshot.ok).toBe(true);
+ if (!snapshot.ok) throw new Error(snapshot.reason);
+
+ expect(snapshot.value.value).toBe("A😀B");
+ expect(snapshot.value.atoms.map((atom) => atom.value)).toEqual([
+ "A",
+ "😀",
+ "B",
+ ]);
+ expect(snapshot.value.atoms.map((atom) => atom.id)).toEqual([
+ initialTextAtomId(snapshot.value.textNode, 0),
+ initialTextAtomId(snapshot.value.textNode, 1),
+ initialTextAtomId(snapshot.value.textNode, 2),
+ ]);
+ expect(tree.texts.get(snapshot.value.textNode)?.order).toBeUndefined();
+ expect(tree.texts.get(snapshot.value.textNode)?.atoms).toBeUndefined();
+ expect(Object.isFrozen(snapshot.value)).toBe(true);
+ expect(Object.isFrozen(snapshot.value.atoms)).toBe(true);
+ expect(Object.isFrozen(snapshot.value.atoms[0])).toBe(true);
+ });
+
+ test("plans one minimal scalar splice with stable Unicode boundaries", () => {
+ const shared = runtime("basis", { title: "A😀B" });
+ const tree = createInitialTree(
+ shared.document.value,
+ shared.collaboration.epoch.baseDigest,
+ );
+ const snapshot = resolveTextSnapshot(tree, ["title"]);
+ if (!snapshot.ok) throw new Error(snapshot.reason);
+
+ expect(createMinimalTextSplice(
+ snapshot.value,
+ "A😀e\u0301B",
+ )).toEqual({
+ left: snapshot.value.atoms[1]?.id,
+ right: snapshot.value.atoms[2]?.id,
+ removed: [],
+ inserted: "e\u0301",
+ });
+ expect(createMinimalTextSplice(snapshot.value, "AXB")).toEqual({
+ left: snapshot.value.atoms[0]?.id,
+ right: snapshot.value.atoms[2]?.id,
+ removed: [snapshot.value.atoms[1]?.id],
+ inserted: "X",
+ });
+ expect(createMinimalTextSplice(snapshot.value, "A😀B")).toBeNull();
+ });
+
+ test("revalidates a stable MemberId after rename and distinguishes stale causes", () => {
+ const initial = { title: "ab" };
+ const shared = runtime("basis", initial);
+ const initialTree = createInitialTree(
+ shared.document.value,
+ shared.collaboration.epoch.baseDigest,
+ );
+ const captured = resolveTextSnapshot(initialTree, ["title"]);
+ if (!captured.ok) throw new Error(captured.reason);
+
+ const renamed = compilePatchOperations(
+ initialTree,
+ [{ op: "move", from: "/title", path: "/renamed" }],
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ );
+ if (!renamed.ok) throw new Error(renamed.reason);
+ expect(resolveTextMemberSnapshot(
+ renamed.value.tree,
+ captured.value.target,
+ captured.value.textNode,
+ )).toMatchObject({
+ ok: true,
+ value: {
+ target: captured.value.target,
+ textNode: captured.value.textNode,
+ value: "ab",
+ },
+ });
+
+ const deleted = compilePatchOperations(
+ initialTree,
+ [{ op: "remove", path: "/title" }],
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ );
+ if (!deleted.ok) throw new Error(deleted.reason);
+ expect(resolveTextMemberSnapshot(
+ deleted.value.tree,
+ captured.value.target,
+ captured.value.textNode,
+ )).toMatchObject({
+ ok: false,
+ code: "text_target_deleted",
+ });
+
+ const reset = compilePatchOperations(
+ initialTree,
+ [{ op: "replace", path: "/title", value: "reset" }],
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ );
+ if (!reset.ok) throw new Error(reset.reason);
+ expect(resolveTextMemberSnapshot(
+ reset.value.tree,
+ captured.value.target,
+ captured.value.textNode,
+ )).toMatchObject({
+ ok: false,
+ code: "text_generation_mismatch",
+ });
+ });
+
+ test("keeps default replacement atomic and opts into text-splice explicitly", () => {
+ const shared = runtime("basis", { title: "ab" });
+ const tree = createInitialTree(
+ shared.document.value,
+ shared.collaboration.epoch.baseDigest,
+ );
+ const patch = [{
+ op: "replace" as const,
+ path: "/title",
+ value: "aXb",
+ }];
+ const atomic = compilePatchOperations(
+ tree,
+ patch,
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ );
+ const collaborative = compilePatchOperations(
+ tree,
+ patch,
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ { collaborativeText: true },
+ );
+ if (!atomic.ok) throw new Error(atomic.reason);
+ if (!collaborative.ok) throw new Error(collaborative.reason);
+
+ expect(atomic.value.ops).toMatchObject([{ kind: "set" }]);
+ expect(collaborative.value.ops).toMatchObject([{
+ kind: "text-splice",
+ inserted: "X",
+ removed: [],
+ }]);
+ expect(projectTree(collaborative.value.tree, () => false)).toMatchObject({
+ ok: true,
+ value: { title: "aXb" },
+ });
+ });
+
+ test("compiles add-overwrite and a sequential mixed batch through visible atoms", () => {
+ const shared = runtime("basis", { title: "ab" });
+ const tree = createInitialTree(
+ shared.document.value,
+ shared.collaboration.epoch.baseDigest,
+ );
+ const compiled = compilePatchOperations(
+ tree,
+ [
+ { op: "replace", path: "/title", value: "a😀b" },
+ { op: "add", path: "/draft", value: "x" },
+ { op: "replace", path: "/draft", value: "xy" },
+ { op: "add", path: "/title", value: "a😀Xb" },
+ ],
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ { collaborativeText: true },
+ );
+ if (!compiled.ok) throw new Error(compiled.reason);
+
+ expect(compiled.value.ops.map((operation) => operation.kind)).toEqual([
+ "text-splice",
+ "insert",
+ "text-splice",
+ "text-splice",
+ ]);
+ expect(compiled.value.ops[3]).toMatchObject({
+ kind: "text-splice",
+ left: authoredTextAtomId(
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ 0,
+ ),
+ inserted: "X",
+ });
+ expect(projectTree(compiled.value.tree, () => false)).toMatchObject({
+ ok: true,
+ value: {
+ title: "a😀Xb",
+ draft: "xy",
+ },
+ });
+ });
+
+ test("keeps copy overwrite atomic even in collaborative text mode", () => {
+ const shared = runtime("basis", {
+ source: "source",
+ target: "target",
+ });
+ const tree = createInitialTree(
+ shared.document.value,
+ shared.collaboration.epoch.baseDigest,
+ );
+ const compiled = compilePatchOperations(
+ tree,
+ [{ op: "copy", from: "/source", path: "/target" }],
+ { actorId: "actor-a", counter: 1 },
+ 0,
+ { collaborativeText: true },
+ );
+ if (!compiled.ok) throw new Error(compiled.reason);
+
+ expect(compiled.value.ops).toMatchObject([{ kind: "set" }]);
+ expect(projectTree(compiled.value.tree, () => false)).toMatchObject({
+ ok: true,
+ value: {
+ source: "source",
+ target: "source",
+ },
+ });
+ });
+});
diff --git a/packages/json-document-collaboration/tests/text.test.ts b/packages/json-document-collaboration/tests/text.test.ts
new file mode 100644
index 00000000..f2827cde
--- /dev/null
+++ b/packages/json-document-collaboration/tests/text.test.ts
@@ -0,0 +1,311 @@
+import { describe, expect, test, vi } from "vitest";
+
+import {
+ createCollaborationRuntime,
+ type CollaborationRuntimeOptions,
+} from "../src/index.js";
+import {
+ restoreCollaborationHistoryRuntime,
+} from "../src/history-index.js";
+import {
+ createCollaborationTextRuntime,
+ restoreCollaborationTextRuntime,
+} from "../src/text-index.js";
+
+const baseOptions = {
+ epochId: "shared-text-authoring/v1",
+ ruleset: {
+ id: "test/json-tree-text-authoring",
+ digest: "test/json-tree-text-authoring/v1",
+ },
+} as const;
+
+function textRuntime(
+ actorId: string,
+ initial: unknown = { title: "ab" },
+ overrides: Partial = {},
+) {
+ return createCollaborationTextRuntime(initial, {
+ ...baseOptions,
+ actorId,
+ ...overrides,
+ });
+}
+
+describe("@interactive-os/json-document-collaboration/text", () => {
+ test("adds text authoring beside the unchanged six-member document", () => {
+ const shared = textRuntime("actor-a");
+
+ expect(Object.keys(shared).sort()).toEqual([
+ "collaboration",
+ "document",
+ "text",
+ ]);
+ expect(Object.keys(shared.document).sort()).toEqual([
+ "at",
+ "canPatch",
+ "commit",
+ "query",
+ "subscribe",
+ "value",
+ ]);
+ expect(Object.keys(shared.text).sort()).toEqual([
+ "capture",
+ "commit",
+ "plan",
+ ]);
+ });
+
+ test("keeps root string replace atomic and opts text runtime into splice", () => {
+ const atomic = createCollaborationRuntime(
+ { title: "ab" },
+ { ...baseOptions, actorId: "atomic" },
+ );
+ const collaborative = textRuntime("text");
+
+ expect(atomic.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }])).toMatchObject({ ok: true });
+ expect(collaborative.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }])).toMatchObject({ ok: true });
+
+ expect(atomic.collaboration.exportBundle().changes[0]?.ops)
+ .toMatchObject([{ kind: "set" }]);
+ expect(collaborative.collaboration.exportBundle().changes[0]?.ops)
+ .toMatchObject([{ kind: "text-splice" }]);
+ });
+
+ test("merges concurrent document string replaces and lets a base peer relay", () => {
+ const left = textRuntime("actor-a");
+ const right = textRuntime("actor-b");
+ const relay = createCollaborationRuntime(
+ { title: "ab" },
+ { ...baseOptions, actorId: "relay" },
+ );
+
+ expect(left.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }])).toMatchObject({ ok: true });
+ expect(right.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aYb",
+ }])).toMatchObject({ ok: true });
+
+ expect(left.collaboration.ingest(right.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(right.collaboration.ingest(left.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(left.document.value).toEqual({ title: "aXYb" });
+ expect(right.document.value).toEqual(left.document.value);
+
+ expect(relay.collaboration.ingest(left.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(relay.document.value).toEqual(left.document.value);
+ });
+
+ test("authors from the capture frontier while merging remote input", () => {
+ const local = textRuntime("actor-a");
+ const remote = textRuntime("actor-b");
+ const captured = local.text.capture("/title");
+ if (!captured.ok) throw new Error(captured.reason);
+
+ expect(remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }])).toMatchObject({ ok: true });
+ expect(local.collaboration.ingest(remote.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+
+ const planned = local.text.plan(captured.capture, {
+ value: "aYb",
+ selection: { anchor: 2, focus: 2 },
+ });
+ if (!planned.ok) throw new Error(planned.reason);
+ const documentListener = vi.fn();
+ const collaborationListener = vi.fn();
+ local.document.subscribe(documentListener);
+ local.collaboration.subscribe(collaborationListener);
+
+ expect(local.text.commit(planned.plan)).toMatchObject({
+ ok: true,
+ projectionChanged: true,
+ value: "aYXb",
+ selection: { anchor: 2, focus: 2 },
+ });
+ const localChange = local.collaboration.exportBundle().changes.find(
+ (change) => change.changeId.actorId === "actor-a",
+ );
+ expect(localChange?.deps).toEqual([]);
+ expect(localChange?.ops).toMatchObject([{ kind: "text-splice" }]);
+ expect(documentListener).toHaveBeenCalledTimes(1);
+ expect(collaborationListener).toHaveBeenCalledTimes(1);
+
+ expect(remote.collaboration.ingest(local.collaboration.exportBundle()))
+ .toMatchObject({ ok: true });
+ expect(remote.document.value).toEqual(local.document.value);
+ });
+
+ test("does not make a capture stale on remote ingest but rejects graph changes after plan", () => {
+ const local = textRuntime("actor-a");
+ const firstRemote = textRuntime("actor-b");
+ const secondRemote = textRuntime("actor-c");
+ const captured = local.text.capture("/title");
+ if (!captured.ok) throw new Error(captured.reason);
+
+ firstRemote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }]);
+ local.collaboration.ingest(firstRemote.collaboration.exportBundle());
+ const planned = local.text.plan(captured.capture, { value: "aYb" });
+ if (!planned.ok) throw new Error(planned.reason);
+
+ secondRemote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aZb",
+ }]);
+ local.collaboration.ingest(secondRemote.collaboration.exportBundle());
+ expect(local.text.commit(planned.plan)).toMatchObject({
+ ok: false,
+ code: "stale_text_plan",
+ });
+ expect(
+ local.collaboration.exportBundle().changes.some(
+ (change) => change.changeId.actorId === "actor-a",
+ ),
+ ).toBe(false);
+ });
+
+ test("rejects a capture after the same actor authors another Change", () => {
+ const shared = textRuntime("actor-a");
+ const captured = shared.text.capture("/title");
+ if (!captured.ok) throw new Error(captured.reason);
+
+ shared.document.commit([{
+ op: "add",
+ path: "/done",
+ value: true,
+ }]);
+ expect(shared.text.plan(captured.capture, { value: "aXb" }))
+ .toMatchObject({
+ ok: false,
+ code: "stale_text_capture",
+ });
+ });
+
+ test("fails closed when a remote atomic reset changes the text generation", () => {
+ const local = textRuntime("actor-a");
+ const resetter = createCollaborationRuntime(
+ { title: "ab" },
+ { ...baseOptions, actorId: "actor-b" },
+ );
+ const captured = local.text.capture("/title");
+ if (!captured.ok) throw new Error(captured.reason);
+
+ resetter.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "reset",
+ }]);
+ local.collaboration.ingest(resetter.collaboration.exportBundle());
+
+ expect(local.text.plan(captured.capture, { value: "aXb" }))
+ .toMatchObject({
+ ok: false,
+ code: "text_generation_mismatch",
+ });
+ expect(local.document.value).toEqual({ title: "reset" });
+ });
+
+ test("rejects selection offsets inside a surrogate pair", () => {
+ const shared = textRuntime("actor-a", { title: "A😀B" });
+ const captured = shared.text.capture("/title");
+ if (!captured.ok) throw new Error(captured.reason);
+
+ expect(shared.text.plan(captured.capture, {
+ value: "A😀B",
+ selection: { anchor: 2, focus: 2 },
+ })).toMatchObject({
+ ok: false,
+ code: "invalid_text_offset",
+ });
+ });
+
+ test("maps a no-op capture selection through a remote insertion without authoring", () => {
+ const local = textRuntime("actor-a");
+ const remote = textRuntime("actor-b");
+ const captured = local.text.capture("/title");
+ if (!captured.ok) throw new Error(captured.reason);
+ remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }]);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+
+ const planned = local.text.plan(captured.capture, {
+ value: "ab",
+ selection: { anchor: 1, focus: 1 },
+ });
+ if (!planned.ok) throw new Error(planned.reason);
+ const listener = vi.fn();
+ local.collaboration.subscribe(listener);
+ expect(local.text.commit(planned.plan)).toEqual({
+ ok: true,
+ changeId: null,
+ projectionChanged: false,
+ value: "aXb",
+ selection: { anchor: 1, focus: 1 },
+ });
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ test("restores text authoring and selective history from the same checkpoint", () => {
+ const local = textRuntime("actor-a");
+ const remote = textRuntime("actor-b");
+ local.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aXb",
+ }]);
+ remote.document.commit([{
+ op: "replace",
+ path: "/title",
+ value: "aYb",
+ }]);
+ local.collaboration.ingest(remote.collaboration.exportBundle());
+ const checkpoint = local.collaboration.exportCheckpoint();
+
+ const textRestored = restoreCollaborationTextRuntime(checkpoint, {
+ actorId: "actor-a",
+ ruleset: baseOptions.ruleset,
+ });
+ if (!textRestored.ok) throw new Error(textRestored.reason);
+ expect(textRestored.runtime.text.capture("/title"))
+ .toMatchObject({ ok: true });
+
+ const historyRestored = restoreCollaborationHistoryRuntime(checkpoint, {
+ actorId: "actor-a",
+ ruleset: baseOptions.ruleset,
+ });
+ if (!historyRestored.ok) throw new Error(historyRestored.reason);
+ expect(historyRestored.runtime.history.undo()).toMatchObject({
+ ok: true,
+ target: { actorId: "actor-a", counter: 1 },
+ });
+ expect(historyRestored.runtime.document.value).toEqual({ title: "aYb" });
+ expect(historyRestored.runtime.history.redo()).toMatchObject({ ok: true });
+ expect(historyRestored.runtime.document.value).toEqual(local.document.value);
+ });
+});
diff --git a/packages/json-document-collaboration/tsconfig.json b/packages/json-document-collaboration/tsconfig.json
new file mode 100644
index 00000000..e2cc84c6
--- /dev/null
+++ b/packages/json-document-collaboration/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "declarationMap": false,
+ "sourceMap": false,
+ "strict": true,
+ "exactOptionalPropertyTypes": true,
+ "noUncheckedIndexedAccess": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}
diff --git a/packages/json-document-collaboration/tsconfig.test.json b/packages/json-document-collaboration/tsconfig.test.json
new file mode 100644
index 00000000..78864572
--- /dev/null
+++ b/packages/json-document-collaboration/tsconfig.test.json
@@ -0,0 +1,24 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@interactive-os/json-document": [
+ "../json-document/src/application/document/index.ts"
+ ]
+ },
+ "types": [
+ "node"
+ ],
+ "noEmit": true,
+ "rootDir": "..",
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": [
+ "src/**/*.ts",
+ "tests/**/*.ts",
+ "../json-document/tests/conformance/v2/projection-suite.ts",
+ "vitest.config.ts"
+ ]
+}
diff --git a/packages/json-document-collaboration/vitest.config.ts b/packages/json-document-collaboration/vitest.config.ts
new file mode 100644
index 00000000..0a8ed5cc
--- /dev/null
+++ b/packages/json-document-collaboration/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@interactive-os/json-document": new URL(
+ "../json-document/src/application/document/index.ts",
+ import.meta.url,
+ ).pathname,
+ },
+ },
+ test: {
+ include: ["tests/**/*.test.ts"],
+ },
+});
diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs
index c129ee44..be3f7cae 100644
--- a/scripts/ci-scope.mjs
+++ b/scripts/ci-scope.mjs
@@ -17,6 +17,10 @@ const coreRuntime = [
/^packages\/json-document\/tests\//,
/^packages\/json-document\/(?:package\.json|public-contract\.json|tsconfig(?:\.test)?\.json|vitest\.config\.ts|eslint\.config\.js)$/,
];
+const companionRuntime = [
+ /^packages\/json-document-collaboration\//,
+ /^packages\/contenteditable-collaboration\//,
+];
const packageDocs = [
/^docs\//,
/^llms\.txt$/,
@@ -50,6 +54,7 @@ const packageFull = matches([
...rootDependency,
...ciWorkflow,
...coreRuntime,
+ ...companionRuntime,
...packageTooling,
]);
const packageDocsChanged = matches(packageDocs);
diff --git a/scripts/evaluate-archive-isolation.mjs b/scripts/evaluate-archive-isolation.mjs
index f34fa8a4..2728ec12 100644
--- a/scripts/evaluate-archive-isolation.mjs
+++ b/scripts/evaluate-archive-isolation.mjs
@@ -2,9 +2,20 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const root = new URL("..", import.meta.url).pathname;
-const expectedWorkspaces = ["packages/json-document", "apps/site"];
+const expectedWorkspaces = [
+ "packages/json-document",
+ "packages/json-document-collaboration",
+ "packages/contenteditable-collaboration",
+ "apps/site",
+];
const rootPackage = json("package.json");
const kernelPackage = json("packages/json-document/package.json");
+const collaborationPackage = json(
+ "packages/json-document-collaboration/package.json",
+);
+const contenteditableCollaborationPackage = json(
+ "packages/contenteditable-collaboration/package.json",
+);
const failures = [];
if (JSON.stringify(rootPackage.workspaces) !== JSON.stringify(expectedWorkspaces)) {
@@ -15,6 +26,33 @@ if (JSON.stringify(Object.keys(kernelPackage.exports ?? {})) !== JSON.stringify(
failures.push("the v2 package must expose only its root entrypoint");
}
+if (
+ collaborationPackage.name
+ !== "@interactive-os/json-document-collaboration"
+ || JSON.stringify(Object.keys(collaborationPackage.exports ?? {}))
+ !== JSON.stringify([".", "./history", "./text"])
+ || collaborationPackage.peerDependencies?.["@interactive-os/json-document"]
+ !== "^2.0.0-rc.0"
+) {
+ failures.push("the collaboration companion package surface is invalid");
+}
+
+if (
+ contenteditableCollaborationPackage.name
+ !== "@interactive-os/json-document-contenteditable-collaboration"
+ || JSON.stringify(
+ Object.keys(contenteditableCollaborationPackage.exports ?? {}),
+ ) !== JSON.stringify(["."])
+ || contenteditableCollaborationPackage.peerDependencies?.[
+ "@interactive-os/json-document"
+ ] !== "^2.0.0-rc.0"
+ || contenteditableCollaborationPackage.peerDependencies?.[
+ "@interactive-os/json-document-collaboration"
+ ] !== "^0.1.0"
+) {
+ failures.push("the contenteditable collaboration companion surface is invalid");
+}
+
for (const requiredPath of [
"archive/v1/packages",
"archive/v1/apps",
@@ -31,6 +69,8 @@ const forbiddenImport =
/@interactive-os\/json-document\/(?:session|react)\b|src\/application\/(?:session|react-document)\b/;
for (const activePath of [
"packages/json-document/src",
+ "packages/json-document-collaboration/src",
+ "packages/contenteditable-collaboration/src",
"apps/site/src",
"config",
]) {
@@ -59,7 +99,7 @@ if (failures.length > 0) {
process.exitCode = 1;
} else {
console.log(
- "v2 archive isolation ok: 2 active workspaces, 1 publishable entrypoint",
+ "v2 archive isolation ok: 4 active workspaces, 1 Kernel and 2 optional companions",
);
}
diff --git a/scripts/evaluate-docs.mjs b/scripts/evaluate-docs.mjs
index db488780..2dd1fe64 100644
--- a/scripts/evaluate-docs.mjs
+++ b/scripts/evaluate-docs.mjs
@@ -40,6 +40,10 @@ const publicDocs = {
const surfaces = {
rootReadme: read("README.md"),
packageReadme: read("packages/json-document/README.md"),
+ collaborationReadme: read("packages/json-document-collaboration/README.md"),
+ contenteditableCollaborationReadme: read(
+ "packages/contenteditable-collaboration/README.md",
+ ),
llms: read("llms.txt"),
...publicDocs,
};
@@ -76,6 +80,10 @@ const expectedPublicTypes = [
"QueryResult",
"ReadResult",
];
+const activeCompanionPackages = new Set([
+ "@interactive-os/json-document-collaboration",
+ "@interactive-os/json-document-contenteditable-collaboration",
+]);
if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([
"api.md",
@@ -138,8 +146,16 @@ for (const [name, source] of Object.entries({
if (/@interactive-os\/json-document\/(?:session|react)\b/.test(source)) {
fail(`${name}: removed package subpath is still documented.`);
}
- if (/@interactive-os\/json-document-[a-z0-9-]+\b/.test(source)) {
- fail(`${name}: archived json-document extension is still documented as current.`);
+ for (
+ const match of source.matchAll(
+ /@interactive-os\/json-document-[a-z0-9-]+\b/g,
+ )
+ ) {
+ if (!activeCompanionPackages.has(match[0])) {
+ fail(
+ `${name}: archived json-document extension is still documented as current: ${match[0]}.`,
+ );
+ }
}
if (/\blabs\/extensions\b/.test(source)) {
fail(`${name}: archived lab path is still documented as current.`);
@@ -189,11 +205,15 @@ const required = [
["rootReadme", surfaces.rootReadme, /docs\/public\/api\.md/],
["rootReadme", surfaces.rootReadme, /## 코드 지도/],
["rootReadme", surfaces.rootReadme, /packages\/json-document/],
+ ["rootReadme", surfaces.rootReadme, /packages\/json-document-collaboration/],
+ ["rootReadme", surfaces.rootReadme, /packages\/contenteditable-collaboration/],
["rootReadme", surfaces.rootReadme, /@interactive-os\/editable/],
["overview", surfaces.overview, /## 배경/],
["overview", surfaces.overview, /## 핵심 개념/],
["overview", surfaces.overview, /검색: JSONPath -> Pointer\[\]/],
["overview", surfaces.overview, /## Host adapter와 companion/],
+ ["overview", surfaces.overview, /@interactive-os\/json-document-collaboration/],
+ ["overview", surfaces.overview, /@interactive-os\/json-document-contenteditable-collaboration/],
["overview", surfaces.overview, /@interactive-os\/editable/],
["quickstart", surfaces.quickstart, /튜토리얼: 작은 카드 편집기 만들기/],
["quickstart", surfaces.quickstart, /JSONPath는 변경 언어가 아닙니다/],
@@ -205,9 +225,15 @@ const required = [
["api", surfaces.api, /## Host와 adapter/],
["packageReadme", surfaces.packageReadme, /npm install @interactive-os\/json-document@2\.0\.0-rc\.0/],
["packageReadme", surfaces.packageReadme, /패키지는 `\/session`이나 `\/react` subpath를\s*공개하지 않습니다/],
+ ["collaborationReadme", surfaces.collaborationReadme, /same six-member Projection API/],
+ ["collaborationReadme", surfaces.collaborationReadme, /contains no transport, presence,\s*storage, DOM, React, or server dependency/],
+ ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /IME-safe DOM publication lease/],
+ ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /does not activate or depend on the archived 1\.x DOM adapters/],
["llms", surfaces.llms, /2\.0\.0-rc\.0.*Candidate/],
["llms", surfaces.llms, /공개 Root는 정확히 다음 20개 symbol/],
["llms", surfaces.llms, /## Host adapter와 companion/],
+ ["llms", surfaces.llms, /@interactive-os\/json-document-collaboration/],
+ ["llms", surfaces.llms, /@interactive-os\/json-document-contenteditable-collaboration/],
["llms", surfaces.llms, /@interactive-os\/editable/],
["profile", profile, /root entrypoint 하나와 20개 Kernel symbol/],
["profile", profile, /Acceptance callback[\s\S]*`canPatch`[\s\S]*`commit`/],
@@ -242,23 +268,43 @@ if (
}
const generatedCore = generatedCatalog.packages?.[0];
+const generatedCollaboration = generatedCatalog.packages?.[1];
+const generatedContenteditableCollaboration = generatedCatalog.packages?.[2];
const generatedExports = [...expectedPublicValues, ...expectedPublicTypes].sort();
if (
generatedCatalog.schemaVersion !== 1
- || generatedCatalog.packages?.length !== 1
+ || generatedCatalog.packages?.length !== 3
|| generatedCore?.path !== "packages/json-document"
|| generatedCore?.name !== "@interactive-os/json-document"
|| generatedCore?.status !== "core"
+ || JSON.stringify(generatedCore?.entrypoints) !== JSON.stringify(["."])
|| JSON.stringify(generatedCore?.publicExports) !== JSON.stringify(generatedExports)
|| generatedCore?.publicExportCount !== 20
+ || generatedCollaboration?.path !== "packages/json-document-collaboration"
+ || generatedCollaboration?.name
+ !== "@interactive-os/json-document-collaboration"
+ || generatedCollaboration?.status !== "companion"
+ || JSON.stringify(generatedCollaboration?.entrypoints)
+ !== JSON.stringify([".", "./history", "./text"])
+ || generatedCollaboration?.publicExportCount !== 40
+ || generatedContenteditableCollaboration?.path
+ !== "packages/contenteditable-collaboration"
+ || generatedContenteditableCollaboration?.name
+ !== "@interactive-os/json-document-contenteditable-collaboration"
+ || generatedContenteditableCollaboration?.status !== "companion"
+ || JSON.stringify(generatedContenteditableCollaboration?.entrypoints)
+ !== JSON.stringify(["."])
+ || generatedContenteditableCollaboration?.publicExportCount !== 7
|| generatedCatalog.officialExtensions !== undefined
|| generatedCatalog.labExtensions !== undefined
|| generatedCatalog.apps !== undefined
|| JSON.stringify(generatedCatalog.totals) !== JSON.stringify({
- packages: 1,
+ packages: 3,
+ core: 1,
+ companions: 2,
})
) {
- fail("generated catalog: release scope must contain the v2 core package only.");
+ fail("generated catalog: active scope must contain one v2 Core and two exact companions.");
}
const expectedRoutePaths = ["/", "/docs", "/docs/tutorial", "/docs/api"];
diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs
index ef23022b..d40f0b6b 100644
--- a/scripts/generate-docs.mjs
+++ b/scripts/generate-docs.mjs
@@ -39,7 +39,23 @@ if (stale.length > 0) {
function createReleaseCatalog() {
const rootPackage = readJson("package.json");
- const core = packageDoc("packages/json-document");
+ const packages = [
+ packageDoc(
+ "packages/json-document",
+ "core",
+ "src/application/document/index.ts",
+ ),
+ packageDoc(
+ "packages/json-document-collaboration",
+ "companion",
+ "src/index.ts",
+ ),
+ packageDoc(
+ "packages/contenteditable-collaboration",
+ "companion",
+ "src/index.ts",
+ ),
+ ];
return {
schemaVersion: 1,
@@ -48,28 +64,31 @@ function createReleaseCatalog() {
private: rootPackage.private === true,
summary: summaryFromReadme(read("README.md")),
},
- packages: [core],
+ packages,
totals: {
- packages: 1,
+ packages: packages.length,
+ core: packages.filter((item) => item.status === "core").length,
+ companions: packages.filter((item) => item.status === "companion").length,
},
};
}
-function packageDoc(path) {
+function packageDoc(path, status, sourcePath) {
const pkg = readJson(`${path}/package.json`);
- const source = read(`${path}/src/application/document/index.ts`);
+ const source = read(`${path}/${sourcePath}`);
const publicExports = extractExports(source);
return {
path,
name: stringValue(pkg.name),
- status: "core",
+ status,
private: pkg.private === true,
publishable: pkg.private !== true,
version: stringValue(pkg.version),
description: stringValue(pkg.description),
license: stringValue(pkg.license),
summary: summaryFromReadme(read(`${path}/README.md`)) ?? stringValue(pkg.description),
+ entrypoints: Object.keys(pkg.exports ?? {}),
publicExports,
publicExportCount: publicExports.length,
keywords: Array.isArray(pkg.keywords)