From d6f6fabf3d6946530e3d6b76e41d8549abf8d926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 28 Jul 2026 16:32:51 +0900 Subject: [PATCH] feat: add optional collaboration providers --- .github/workflows/ci.yml | 8 +- README.md | 23 +- docs/README.md | 8 +- docs/changelog.md | 16 +- docs/generated/repo-catalog.json | 98 +- docs/public/overview.md | 19 +- llms.txt | 25 +- package-lock.json | 41 + package.json | 8 +- .../contenteditable-collaboration/LICENSE | 21 + .../contenteditable-collaboration/README.md | 101 ++ .../package.json | 60 + .../src/dom/plainText.ts | 268 +++ .../src/index.ts | 9 + .../src/lease.ts | 458 +++++ .../src/types.ts | 65 + .../contenteditable-collaboration.test.ts | 622 +++++++ .../tsconfig.json | 31 + .../tsconfig.test.json | 16 + .../vitest.config.ts | 33 + packages/json-document-collaboration/LICENSE | 21 + .../json-document-collaboration/README.md | 199 +++ .../json-document-collaboration/package.json | 57 + .../json-document-collaboration/src/change.ts | 989 +++++++++++ .../src/checkpoint.ts | 219 +++ .../src/compact.ts | 374 +++++ .../json-document-collaboration/src/create.ts | 1477 +++++++++++++++++ .../src/history-index.ts | 10 + .../json-document-collaboration/src/index.ts | 42 + .../src/materialize.ts | 570 +++++++ .../src/restore.ts | 188 +++ .../src/text-core.ts | 314 ++++ .../src/text-index.ts | 15 + .../src/translate.ts | 415 +++++ .../json-document-collaboration/src/tree.ts | 1246 ++++++++++++++ .../json-document-collaboration/src/types.ts | 425 +++++ .../tests/checkpoint.test.ts | 729 ++++++++ .../tests/collaboration.test.ts | 1054 ++++++++++++ .../tests/history.test.ts | 487 ++++++ .../tests/projection-conformance.test.ts | 61 + .../tests/text-wire.test.ts | 656 ++++++++ .../tests/text.test.ts | 311 ++++ .../json-document-collaboration/tsconfig.json | 18 + .../tsconfig.test.json | 24 + .../vitest.config.ts | 15 + scripts/ci-scope.mjs | 5 + scripts/evaluate-archive-isolation.mjs | 44 +- scripts/evaluate-docs.mjs | 56 +- scripts/generate-docs.mjs | 31 +- 49 files changed, 11940 insertions(+), 42 deletions(-) create mode 100644 packages/contenteditable-collaboration/LICENSE create mode 100644 packages/contenteditable-collaboration/README.md create mode 100644 packages/contenteditable-collaboration/package.json create mode 100644 packages/contenteditable-collaboration/src/dom/plainText.ts create mode 100644 packages/contenteditable-collaboration/src/index.ts create mode 100644 packages/contenteditable-collaboration/src/lease.ts create mode 100644 packages/contenteditable-collaboration/src/types.ts create mode 100644 packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts create mode 100644 packages/contenteditable-collaboration/tsconfig.json create mode 100644 packages/contenteditable-collaboration/tsconfig.test.json create mode 100644 packages/contenteditable-collaboration/vitest.config.ts create mode 100644 packages/json-document-collaboration/LICENSE create mode 100644 packages/json-document-collaboration/README.md create mode 100644 packages/json-document-collaboration/package.json create mode 100644 packages/json-document-collaboration/src/change.ts create mode 100644 packages/json-document-collaboration/src/checkpoint.ts create mode 100644 packages/json-document-collaboration/src/compact.ts create mode 100644 packages/json-document-collaboration/src/create.ts create mode 100644 packages/json-document-collaboration/src/history-index.ts create mode 100644 packages/json-document-collaboration/src/index.ts create mode 100644 packages/json-document-collaboration/src/materialize.ts create mode 100644 packages/json-document-collaboration/src/restore.ts create mode 100644 packages/json-document-collaboration/src/text-core.ts create mode 100644 packages/json-document-collaboration/src/text-index.ts create mode 100644 packages/json-document-collaboration/src/translate.ts create mode 100644 packages/json-document-collaboration/src/tree.ts create mode 100644 packages/json-document-collaboration/src/types.ts create mode 100644 packages/json-document-collaboration/tests/checkpoint.test.ts create mode 100644 packages/json-document-collaboration/tests/collaboration.test.ts create mode 100644 packages/json-document-collaboration/tests/history.test.ts create mode 100644 packages/json-document-collaboration/tests/projection-conformance.test.ts create mode 100644 packages/json-document-collaboration/tests/text-wire.test.ts create mode 100644 packages/json-document-collaboration/tests/text.test.ts create mode 100644 packages/json-document-collaboration/tsconfig.json create mode 100644 packages/json-document-collaboration/tsconfig.test.json create mode 100644 packages/json-document-collaboration/vitest.config.ts 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
b
c
"; + 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)