From 1e2901ac3b05ad404418b0973e24dcd851339f10 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: Thu, 30 Jul 2026 11:16:27 +0900 Subject: [PATCH 1/5] docs: establish canonical naming standard --- README.md | 25 +- apps/site/src/routes/Home.tsx | 10 +- apps/site/src/site-routes.json | 8 +- apps/site/tests/docs-consistency.test.ts | 36 +- apps/site/tests/site-shell.test.tsx | 10 +- docs/README.md | 21 +- docs/generated/repo-catalog.json | 14 +- .../245-concept-dictionary-naming-ssot.md | 668 ++++++++++++++++++ docs/public/api.md | 21 +- docs/public/overview.md | 43 +- docs/public/quickstart.md | 7 +- docs/standard/concept-and-naming-standard.md | 590 ++++++++++++++++ docs/standard/v2-projection-profile.md | 8 + llms.txt | 33 +- .../contenteditable-collaboration/README.md | 19 +- .../package.json | 2 +- .../json-document-collaboration/README.md | 33 +- .../json-document-collaboration/package.json | 2 +- packages/json-document/README.md | 26 +- packages/json-document/package.json | 2 +- scripts/evaluate-docs.mjs | 100 ++- 21 files changed, 1555 insertions(+), 123 deletions(-) create mode 100644 docs/issues/245-concept-dictionary-naming-ssot.md create mode 100644 docs/standard/concept-and-naming-standard.md diff --git a/README.md b/README.md index 9bbf673d..d0b7ab41 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # json-document json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 -provider-neutral JSON 편집 protocol과 headless document projection입니다. +implementation-neutral JSON 편집 API와 headless JSON Document입니다. v2 root는 JSON, JSON Pointer, JSONPath, JSON Patch만 전제로 하며 Zod, React, selection, clipboard, history를 필수 계약에 넣지 않습니다. ```txt -Pure Protocol - |-> local provider -----------\ - | > same six-member Document Projection - `-> collaboration provider --/ |-> optional history/text authoring - `-> optional DOM/IME lease +stateless JSON Patch + |-> local implementation -----\ + | > same six-member JSON Document + `-> collaboration engine -----/ |-> optional history/text authoring + `-> optional native-input DOM lease ``` 로컬 전용 사용자는 Core만 설치합니다. 협업으로 전환해도 편집기가 받는 -`JSONDocument` 포트는 바뀌지 않고, causal merge와 DOM publication lease만 +`JSONDocument` 포트는 바뀌지 않고, causal merge와 native-input DOM lease만 독립 package로 추가합니다. 공식 사이트: https://developer-1px.github.io/json-document/ @@ -29,7 +29,8 @@ Pure Protocol | 공개 API | [docs/public/api.md](docs/public/api.md) | | 문서 구조 | [docs/README.md](docs/README.md) | | 변경 기록 | [docs/changelog.md](docs/changelog.md) | -| v2 Projection 표준 | [docs/standard/v2-projection-profile.md](docs/standard/v2-projection-profile.md) | +| 개념·이름 정본 | [docs/standard/concept-and-naming-standard.md](docs/standard/concept-and-naming-standard.md) | +| v2 compatibility profile | [docs/standard/v2-projection-profile.md](docs/standard/v2-projection-profile.md) | | v2 공개 표면 manifest | [docs/standard/v2-public-surface.json](docs/standard/v2-public-surface.json) | | 1.x 기록 | [archive/v1/docs](archive/v1/docs) | @@ -38,8 +39,8 @@ Pure Protocol | 위치 | 역할 | | --- | --- | | [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 | +| [packages/json-document-collaboration](packages/json-document-collaboration) | transport-free causal collaboration engine | +| [packages/contenteditable-collaboration](packages/contenteditable-collaboration) | collaborative string을 위한 optional native-input DOM lease | | [apps/site](apps/site) | v2 Core 공개 문서 사이트 | | [archive/v1](archive/v1) | 배포·workspace·검증에서 분리된 1.x 기록 | @@ -54,11 +55,11 @@ lifecycle은 host adapter가 여섯-member `JSONDocument` 위에서 조합합니 v2 Kernel이 제공하는 최소 계약: -- immutable JSON snapshot +- immutable document value - JSON Pointer read와 JSONPath query - state를 바꾸지 않는 `canPatch` - ordered atomic JSON Patch commit -- canonical applied change publication +- canonical applied change notification 편집 툴이 계속 소유하는 것: diff --git a/apps/site/src/routes/Home.tsx b/apps/site/src/routes/Home.tsx index f6bba8a3..68cccd16 100644 --- a/apps/site/src/routes/Home.tsx +++ b/apps/site/src/routes/Home.tsx @@ -1,7 +1,7 @@ const modelRows = [ - ["protocol", "provider-neutral JSON Patch application"], - ["projection", "value, at, query, canPatch, commit, subscribe"], - ["acceptance", "optional provider-neutral candidate validation"], + ["patch", "stateless JSON Patch application"], + ["document", "value, at, query, canPatch, commit, subscribe"], + ["validation", "optional implementation-neutral candidate validation"], ["host", "selection, history, rendering, focus, persistence, collaboration"], ] as const; @@ -18,13 +18,13 @@ export function Home() {

- Provider-neutral JSON editing + Implementation-neutral JSON editing

json-document

- A headless JSON protocol and six-member document projection for + A headless JSON API and six-member JSON Document for documents, tables, slides, canvases, and notes.

diff --git a/apps/site/src/site-routes.json b/apps/site/src/site-routes.json index bb28f96e..0edca835 100644 --- a/apps/site/src/site-routes.json +++ b/apps/site/src/site-routes.json @@ -3,28 +3,28 @@ "path": "/", "label": "Overview", "title": "json-document - Headless JSON editing", - "description": "Provider-neutral JSON editing protocol and six-member headless document projection for JSON Patch, JSON Pointer, and JSONPath.", + "description": "Implementation-neutral JSON editing and a six-member headless JSON Document for JSON Patch, JSON Pointer, and JSONPath.", "group": "Start" }, { "path": "/docs", "label": "Docs", "title": "json-document Docs - json-document", - "description": "User guide to the v2 Kernel, Pure Protocol, Document Projection, and host adapter boundaries.", + "description": "User guide to stateless JSON Patch, the v2 JSON Document, validation, and host adapter boundaries.", "group": "Start" }, { "path": "/docs/tutorial", "label": "Quickstart", "title": "Tutorial - json-document", - "description": "Step-by-step guide to reading, probing, committing, and subscribing to provider-neutral JSON document changes.", + "description": "Step-by-step guide to reading, validating, committing, and subscribing to implementation-neutral JSON document changes.", "group": "Start" }, { "path": "/docs/api", "label": "API reference", "title": "json-document API - json-document", - "description": "Public v2 Kernel API reference for the exact root surface, six-member Projection, JSON Patch, Pointer, and JSONPath.", + "description": "Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath.", "group": "Start" } ] diff --git a/apps/site/tests/docs-consistency.test.ts b/apps/site/tests/docs-consistency.test.ts index a658adf9..7415560c 100644 --- a/apps/site/tests/docs-consistency.test.ts +++ b/apps/site/tests/docs-consistency.test.ts @@ -29,7 +29,9 @@ const publicDocs = { }; const docs = { rootReadme: read("README.md"), + docsReadme: read("docs/README.md"), readme: read("packages/json-document/README.md"), + naming: read("docs/standard/concept-and-naming-standard.md"), profile: read("docs/standard/v2-projection-profile.md"), llms: read("llms.txt"), site: Object.values(publicDocs).join("\n\n"), @@ -52,6 +54,7 @@ describe("public docs consistency", () => { "quickstart.md", ]); expect(readdirSync(join(root, "docs/standard")).sort()).toEqual([ + "concept-and-naming-standard.md", "v2-projection-profile.md", "v2-public-surface.json", ]); @@ -113,6 +116,7 @@ describe("public docs consistency", () => { test("keeps core usage and project understanding in public docs", () => { expect(docs.rootReadme).toMatch(/## 문서 지도/); expect(docs.rootReadme).toMatch(/docs\/public\/overview\.md/); + expect(docs.rootReadme).toMatch(/docs\/standard\/concept-and-naming-standard\.md/); expect(docs.rootReadme).toMatch(/## 코드 지도/); expect(docs.rootReadme).toMatch(/packages\/json-document/); expect(docs.overview).toMatch(/## 배경/); @@ -122,10 +126,40 @@ describe("public docs consistency", () => { expect(docs.api).toMatch(/## 작업별 진입점/); expect(docs.api).toMatch(/ReadResult/); expect(docs.readme).toMatch(/npm install @interactive-os\/json-document@2\.0\.0/); - expect(docs.readme).toMatch(/provider-neutral/); + expect(docs.readme).toMatch(/implementation-neutral/); expect(docs.llms).toMatch(/2\.0\.0.*Stable/); }); + test("locks one canonical concept and naming standard", () => { + expect(docs.naming).toMatch(/상태: Canonical/); + expect(docs.naming).toMatch(/## 이름 권위/); + expect(docs.naming).toMatch(/## 개념 경계/); + expect(docs.naming).toMatch(/## 접두어와 casing/); + expect(docs.naming).toMatch(/## 접미어/); + expect(docs.naming).toMatch(/## 함수 동사/); + expect(docs.naming).toMatch(/## Boolean/); + expect(docs.naming).toMatch(/## v2 compatibility map/); + expect(docs.naming).toMatch(/## Current public surface decisions/); + expect(docs.naming).toMatch(/## 새 concept admission/); + + for (const term of [ + "JSON Document", + "patch validation", + "change notification", + "collaboration engine", + "replica status", + "native-input DOM lease", + ]) { + expect(docs.naming).toContain(term); + } + + for (const source of [docs.rootReadme, docs.overview, docs.readme]) { + expect(source).not.toMatch( + /Pure Protocol|Document Projection|document projection|local provider|collaboration provider|DOM publication lease/, + ); + } + }); + test("keeps JSONPath scoped to search and JSON Pointer scoped to mutation", () => { expect(docs.readme).toMatch(/query\(jsonPath\).*Pointer 배열/); expect(docs.profile).toMatch(/JSONPath를 mutation target으로 받아들이면 안 되며/); diff --git a/apps/site/tests/site-shell.test.tsx b/apps/site/tests/site-shell.test.tsx index e12ca9f4..7221601e 100644 --- a/apps/site/tests/site-shell.test.tsx +++ b/apps/site/tests/site-shell.test.tsx @@ -27,7 +27,7 @@ describe("official site shell", () => { expect(screen.getByRole("link", { name: "Skip to content" }).getAttribute("href")).toBe("#main-content"); expect(screen.getByRole("heading", { level: 1, name: "json-document" })).toBeTruthy(); - expect(screen.getByText(/Provider-neutral JSON editing/)).toBeTruthy(); + expect(screen.getByText(/Implementation-neutral JSON editing/)).toBeTruthy(); expect(screen.getByText("npm install @interactive-os/json-document@2.0.0")).toBeTruthy(); expect(screen.getByText('import { createJSONDocument } from "@interactive-os/json-document";')).toBeTruthy(); expect(screen.getByRole("link", { name: "npm" }).getAttribute("href")).toBe("https://www.npmjs.com/package/@interactive-os/json-document"); @@ -46,7 +46,7 @@ describe("official site shell", () => { await user.click(nav.getByRole("link", { name: "Docs" })); await waitFor(() => expect(document.title).toBe("json-document Docs - json-document")); - expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("User guide to the v2 Kernel, Pure Protocol, Document Projection, and host adapter boundaries."); + expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("User guide to stateless JSON Patch, the v2 JSON Document, validation, and host adapter boundaries."); expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute("href")).toBe("https://developer-1px.github.io/json-document/docs"); expect(await screen.findByRole("heading", { level: 1, name: "json-document Docs" }, { timeout: 10000 })).toBeTruthy(); expect(screen.getByRole("heading", { name: "배경" })).toBeTruthy(); @@ -58,11 +58,11 @@ describe("official site shell", () => { await user.click(nav.getByRole("link", { name: "API reference" })); await waitFor(() => expect(document.title).toBe("json-document API - json-document")); - expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact root surface, six-member Projection, JSON Patch, Pointer, and JSONPath."); - expect(document.head.querySelector('meta[property="og:description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact root surface, six-member Projection, JSON Patch, Pointer, and JSONPath."); + expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath."); + expect(document.head.querySelector('meta[property="og:description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath."); expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute("href")).toBe("https://developer-1px.github.io/json-document/docs/api"); expect(document.head.querySelector('meta[name="twitter:title"]')?.getAttribute("content")).toBe("json-document API - json-document"); - expect(document.head.querySelector('meta[name="twitter:description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact root surface, six-member Projection, JSON Patch, Pointer, and JSONPath."); + expect(document.head.querySelector('meta[name="twitter:description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath."); expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute("content")).toBe("https://developer-1px.github.io/json-document/docs/api"); expect(await screen.findByRole("heading", { level: 1, name: "json-document API" })).toBeTruthy(); expect(screen.getByRole("heading", { name: "작업별 진입점" })).toBeTruthy(); diff --git a/docs/README.md b/docs/README.md index 06ae451d..af339a04 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,12 +9,15 @@ docs |-- changelog.md # 사용자 영향 중심 변경 기록 |-- generated | `-- repo-catalog.json # v2 Core와 optional companion catalog +|-- issues +| `-- 245-concept-dictionary-naming-ssot.md # issue #245 raw 실행 계약 |-- public | |-- overview.md # 프로젝트 이해 | |-- quickstart.md # 사용 시작 | `-- api.md # 공개 API `-- standard - |-- v2-projection-profile.md # 현재 v2 root 규범 profile + |-- concept-and-naming-standard.md # 전체 repository 개념·이름 정본 + |-- v2-projection-profile.md # 현재 v2 root compatibility profile `-- v2-public-surface.json # v2 공개 binding ``` @@ -24,10 +27,12 @@ generated catalog의 입력이 아닙니다. ## 규범 우선순위 -현재 v2 portable root의 정본은 `v2-projection-profile.md`, -`v2-public-surface.json`, 그리고 profile이 지정한 conformance vector와 -language binding입니다. Archive의 1.x 문서는 v2 exact 20-symbol·six-member -계약을 확장하지 않습니다. +Repository 전체의 개념과 이름 정본은 +`concept-and-naming-standard.md`입니다. 현재 v2 portable root의 compatibility +정본은 `v2-projection-profile.md`, `v2-public-surface.json`, 그리고 profile이 +지정한 conformance vector와 language binding입니다. 이름 정본은 stable v2 +identifier나 동작을 바꾸지 않으며, Archive의 1.x 문서는 v2 exact +20-symbol·six-member 계약을 확장하지 않습니다. ## 책임 기준 @@ -35,8 +40,9 @@ language binding입니다. Archive의 1.x 문서는 v2 exact 20-symbol·six-memb | --- | --- | --- | | `changelog.md` | 사용자 영향 중심 변경 기록 | 외부 사용자, 릴리스 확인자 | | `generated/` | v2 Core와 명시적으로 등록한 companion 표면으로 만든 reference data. 직접 편집하지 않는다. | evaluator | +| `issues/` | Context recovery를 위해 보존한 issue raw 실행 계약. Concept authority는 아니다. | maintainer, 실행 agent | | `public/` | 사용법과 프로젝트 이해를 위한 공식 문서 원천 | 외부 사용자, LLM, 사이트 방문자 | -| `standard/` | v2 root 정본 | 표준화 검토자, 대체 구현 작성자 | +| `standard/` | Repository 개념·이름 정본과 v2 root compatibility 정본 | 표준화 검토자, 대체 구현 작성자 | ## 작성 원칙 @@ -49,3 +55,6 @@ language binding입니다. Archive의 1.x 문서는 v2 exact 20-symbol·six-memb archive, app, 그 밖의 sibling package를 active package로 추론하지 않는다. - 새 문서는 기존 책임 폴더 중 하나에 들어가야 한다. - 새 책임 폴더가 필요하면 먼저 이 파일의 책임 표를 갱신한다. +- 새 public concept와 이름은 + `standard/concept-and-naming-standard.md`의 admission과 문법을 먼저 + 통과해야 한다. diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 04c32993..a369467d 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -3,7 +3,7 @@ "repo": { "name": "@interactive-os/json-document-monorepo", "private": true, - "summary": "json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는\nprovider-neutral JSON 편집 protocol과 headless document projection입니다." + "summary": "json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는\nimplementation-neutral JSON 편집 API와 headless JSON Document입니다." }, "packages": [ { @@ -13,9 +13,9 @@ "private": false, "publishable": true, "version": "2.0.0", - "description": "Provider-neutral JSON editing protocol and headless document projection.", + "description": "Implementation-neutral JSON Patch API and headless JSON Document.", "license": "MIT", - "summary": "문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 provider-neutral\nJSON 편집 protocol과 headless document projection입니다.", + "summary": "문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는\nimplementation-neutral JSON 편집 API와 headless JSON Document입니다.", "entrypoints": [ "." ], @@ -60,9 +60,9 @@ "private": false, "publishable": true, "version": "0.1.0-rc.2", - "description": "Transport-free causal collaboration provider for @interactive-os/json-document.", + "description": "Transport-free causal collaboration engine for @interactive-os/json-document.", "license": "MIT", - "summary": "Transport-free causal collaboration provider for the six-member\n`@interactive-os/json-document` Projection contract.", + "summary": "Transport-free causal collaboration engine for the six-member\n`@interactive-os/json-document` JSON Document contract.", "entrypoints": [ ".", "./history", @@ -120,9 +120,9 @@ "private": false, "publishable": true, "version": "0.1.0-rc.1", - "description": "IME-safe contenteditable publication lease for json-document collaboration text.", + "description": "IME-safe native-input DOM lease for json-document collaboration text.", "license": "MIT", - "summary": "IME-safe DOM publication lease for\n`@interactive-os/json-document-collaboration/text`.", + "summary": "IME-safe native-input DOM lease for\n`@interactive-os/json-document-collaboration/text`.", "entrypoints": [ "." ], diff --git a/docs/issues/245-concept-dictionary-naming-ssot.md b/docs/issues/245-concept-dictionary-naming-ssot.md new file mode 100644 index 00000000..4ade5858 --- /dev/null +++ b/docs/issues/245-concept-dictionary-naming-ssot.md @@ -0,0 +1,668 @@ +# Issue #245 raw — Establish the canonical concept dictionary and naming grammar + +이 파일은 GitHub issue #245에 등록한 실행 계약의 raw 보존본이다. 개념과 이름의 +현재 정본은 `docs/standard/concept-and-naming-standard.md`이며, 두 문서가 +충돌하면 standard가 구현 결과의 authority다. Goal Anchor의 scope authority는 +issue #245와 명시적으로 승인된 append-only contract delta다. + +### Goal Anchor + +- Outcome: Establish one durable, repository-owned source of truth for the concepts and naming grammar of the current JSON document, causal collaboration, history, collaborative text, checkpoint, and contenteditable boundaries. +- Done: A canonical concept dictionary and prefix/suffix/grammar rules are published in the repository; every current public concept is classified as keep, merge, rename, restrict, or remove; v2 compatibility names are mapped to canonical terms; future names can be evaluated mechanically without reconstructing this discussion. +- Don't: Do not change runtime behavior, protocol semantics, wire compatibility, package boundaries, or the stable v2 public API in this issue. Do not treat documentation cleanup as authorization for breaking renames. Do not introduce synonyms that increase the number of concepts. + +The Goal Anchor above is immutable history. Any change to Outcome, Done, or Don't +requires an explicit, append-only contract-delta comment with the user's +authorization and reason. Context summaries and implementation plans are not +scope authority; after context loss, reread this issue and the current diff. + +## Why + +The repository currently has a sound implementation model, but its vocabulary is +distributed across the public overview, the stable v2 Projection Profile, API +reference, package READMEs, TypeScript contracts, conformance fixtures, and +implementation names. + +Several local terms describe concepts that already have stable standard or +de-facto names: + +- `Pure Protocol` overlaps stateless JSON Patch application. +- `Document Projection` overlaps the already-public `JSONDocument`. +- `snapshot` is used for current values, diagnostic state, historical state, and + restore artifacts. +- `acceptance`, `capability`, `probe`, schema checks, and validation overlap. +- `publication`, notification, observation, and subscription overlap. +- `provider` describes a transport-free collaboration engine even though major + collaboration ecosystems use provider for network connectors. +- `CollaborationSnapshot` contains current causal diagnostics rather than a + complete historical or restoreable snapshot. +- `Control`, `Runtime`, `Adapter`, and package-name prefixes overlap without one + explicit grammar. + +This creates same-name/different-meaning and different-name/same-meaning costs. +The intended change is concept reduction, not cosmetic renaming. + +## Naming authority order + +Every naming decision must use this order: + +1. Exact normative-standard term when the code models that concept. +2. Stable platform or framework term already used by the repository. +3. De-facto domain term recognized by practitioners. +4. Consistent local ubiquitous language. +5. Responsibility name describing what the item owns or decides. +6. A short descriptive name only when no stronger term exists. + +Lower-priority vocabulary must not replace a higher-priority term without a +written reason. + +Normative anchors: + +- RFC 8259: JSON +- RFC 6901: JSON Pointer +- RFC 6902: JSON Patch and JSON Patch operation +- RFC 9535: JSONPath +- W3C DOM, Input Events, UI Events, and composition terminology + +De-facto collaboration anchors: + +- change, dependency, change DAG, head, conflict, materialized view +- replica, sync, checkpoint/snapshot, compaction +- presence/awareness/ephemeral state +- relative position/cursor and text splice +- adapter/binding and transport-agnostic engine + +## Canonical concept dictionary + +### Standard JSON layer + +| Canonical concept | Definition | Excludes | +| --- | --- | --- | +| JSON value | RFC 8259-compatible document data | Runtime state, DOM state, protocol envelope | +| JSON Pointer | RFC 6901 address identifying one location | JSONPath query, stable collaboration identity | +| JSONPath | RFC 9535 query returning matching locations | Mutation target | +| JSON Patch | RFC 6902 ordered operation batch | Merge Patch, semantic collaboration operations | +| Patch application | Stateless, atomic application of JSON Patch to a JSON value | Stateful commit, publication, collaboration | + +`Pure Protocol` must not remain a separate user-facing concept. In prose it +becomes **stateless JSON Patch application**. `applyPatch` remains the API name. + +### Document layer + +| Canonical concept | Definition | Excludes | +| --- | --- | --- | +| JSON Document | Stateful document exposing current value, reads, patch validation, commit, and subscription | DOM, selection, history, transport | +| document value | Current immutable JSON value owned by a JSON Document | Checkpoint, replica status | +| patch validation | Side-effect-free evaluation of whether a patch can be committed | Commit, boolean-only capability | +| commit | Atomic stateful application of JSON Patch | Stateless patch application | +| applied change | Canonical operations that were actually applied, with owned metadata when present | Candidate operations that failed | +| change notification | Applied change delivered after a successful state-changing commit | Current value, replica diagnostics | +| subscription | Registration that observes change notifications | Transport subscription | +| validation | Provider-neutral candidate-document constraint evaluation | Transformation or normalization | + +Collapse the following vocabulary: + +```text +Document Projection + Projection snapshot -> JSON Document + document value +acceptance + capability probe + schema acceptance -> validation +publication -> change notification +``` + +### Collaboration layer + +| Canonical concept | Definition | Excludes | +| --- | --- | --- | +| collaboration engine | Transport-agnostic component that converts authored patches to causal changes and converges replicas | Network provider | +| replica | One collaboration participant's causal state and sync surface | User presence | +| change | Atomic authored causal record | Unapplied command | +| Change ID | Actor ID plus actor-local counter | Pointer or array index | +| dependency | Causal predecessor of a change | Runtime/package dependency | +| change DAG | Changes connected by causal dependencies | Materialized JSON tree | +| head | Current causal frontier change without a known successor | Array head or document root | +| collaboration bundle | Transport-neutral epoch-and-changes exchange artifact | Network message framing | +| replica status | Current heads, pending changes, conflicts, and suppressed changes | Restoreable snapshot/checkpoint | +| pending change | Change waiting for missing dependencies | Failed or suppressed change | +| conflict | Deterministic winner and preserved concurrent alternatives | Validation failure | +| suppressed change | Known change currently excluded from materialization by validation/history rules | Permanently rejected input | +| materialization | Deterministic derivation of the current JSON document from the change DAG | DOM rendering | + +`CollaborationSnapshot` is canonically **replica status**. `provider` must not +describe the transport-free engine. `Provider` is reserved for actual network, +storage, schema, or host providers when one exists. + +### Lifecycle layer + +| Canonical concept | Definition | Excludes | +| --- | --- | --- | +| epoch | Generation in which base document, ruleset, and membership are fixed | Browser session | +| membership | Actors admitted to author changes in an epoch | Authentication proof | +| checkpoint | Versioned, integrity-protected artifact sufficient for restore | Current diagnostic status | +| compaction | Folding causal history into a new epoch base | In-place history deletion | +| restore | Validating a checkpoint and reconstructing a runtime | Ordinary undo | + +### Collaborative history layer + +| Canonical concept | Definition | +| --- | --- | +| selective undo | Disabling the current actor's causal contribution without overwriting another actor | +| selective redo | Reinstating an eligible selectively undone contribution | +| history status | Current undo/redo targets, depths, and revision | + +History undo/redo is not document time travel and not inverse-value overwrite. + +### Collaborative text layer + +| Canonical concept | Definition | +| --- | --- | +| text atom | Stable-identity unit in collaborative text | +| text splice | Removal and insertion relative to stable left/right context | +| relative selection | Anchor/focus positions that retain causal context | +| text capture | Pre-native-input text, selection, and causal frontier | +| text plan | Prepared splice derived from a capture and final DOM observation | +| text commit | Causal commit of a valid, non-stale text plan | + +`capture -> plan -> commit` remains distinct from ordinary patch validation and +commit because it owns stale-plan detection and native-input reconciliation. + +### DOM and editor boundary + +| Canonical concept | Definition | +| --- | --- | +| DOM adapter | Boundary that observes, renders, and restores platform DOM state | +| contenteditable adapter | Adapter connecting one contenteditable root to collaborative text | +| native-input DOM lease | Temporary browser ownership of DOM mutation while model ingestion continues and model-to-DOM rendering is deferred | +| composition session | W3C IME composition lifecycle | + +The lease never pauses collaboration ingestion. It gates DOM rendering only. + +## Canonical naming grammar + +Public TypeScript names use: + +```text +[standard or domain qualifier] + [subject] + [role suffix] +``` + +Examples: + +```text +JSONPatchOperation +JSONDocumentCommitResult +CollaborationCheckpoint +ReplicaStatus +TextPlanResult +ContentEditableAdapter +``` + +Do not use: + +```text +[package name] + [subpath/profile name] + [subject] + [role] +``` + +Subpath exports already provide a namespace. Avoid names such as +`CollaborationTextCaptureResult` when `TextCaptureResult` is exported from the +`/text` subpath. + +## Prefix rules + +### `JSON` + +Use only when the type's semantics are defined by the standard JSON boundary: + +- keep: `JSONValue`, `JSONDocument`, `JSONPatchOperation`, + `JSONPatchResult`, `JSONAppliedChange`, `JSONChangeMetadata`, + `JSONPathSyntaxError` +- avoid: `JSONCollaborationRuntime`, `JSONReplicaStatus`, `JSONTextCapture` + +`JSON` means “defined by the JSON standard boundary,” not merely “contains or +uses JSON.” + +### `Collaboration` + +Use only for top-level collaboration concepts and artifacts: + +- `CollaborationRuntime` +- `CollaborationChange` +- `CollaborationBundle` +- `CollaborationConflict` +- `CollaborationCheckpoint` +- `CollaborationEpoch` +- `CollaborationMembership` + +Do not repeat it on every history, text, status, or DOM type. + +### `Collaborative` + +Use as an adjective only when a type must explicitly distinguish a +collaborative subject outside an already-namespaced subpath. Prefer `TextRuntime` +from `/text` over either `CollaborativeTextRuntime` or +`CollaborationTextRuntime`. + +### `DOM` + +Preserve W3C casing. A platform boundary must include a responsibility suffix: + +- prefer: `TextDOMAdapter` +- avoid: `CollaborationTextDOM` + +### `ContentEditable` + +Use PascalCase `ContentEditable` in TypeScript names. Do not repeat the package's +collaboration qualifier unless collision evidence requires it. + +### `Id` + +Use `Id` in type names and `...Id` in values/properties: + +- `ActorId`, `ChangeId`, `actorId`, `epochId` +- do not use `ActorID`, `ChangeID`, `actorID` + +This follows the repository's established JavaScript/TypeScript casing. + +## Suffix rules + +| Suffix | Use only for | Must not mean | +| --- | --- | --- | +| `Document` | User-facing logical document state | Replica diagnostics or checkpoint | +| `Value` | JSON content itself | Stateful document | +| `Operation` | Command/protocol atom to execute | Historical record | +| `Change` | Authored or applied record | Candidate command | +| `Result` | `{ ok: true } \| { ok: false }` expected-outcome union | Arbitrary return object | +| `Success` | Reusable success variant of a Result | Boolean status | +| `Failure` | Returned expected-failure variant | Thrown exception | +| `Error` | Thrown `Error` subclass | Expected result failure | +| `Options` | Caller-supplied optional input | Runtime state or shared ruleset | +| `Runtime` | Stateful composition of related capabilities | Plain data | +| `Replica` | One participant's causal state/sync surface | Transport | +| `Status` | Current diagnostic information | Restore artifact | +| `Snapshot` | Complete immutable state at a point in time | Diagnostics or checkpoint by default | +| `Checkpoint` | Versioned restoreable artifact | Current status | +| `Payload` | Serializable body inside an envelope | Top-level domain object | +| `Report` | Statistics/diagnostics of a completed operation | Success/failure union | +| `Adapter` | External platform/model conversion boundary | Domain state | +| `Binding` | Persistent synchronization between two public models | General wrapper | + +### Restricted suffixes + +The following suffixes must not appear in new public names: + +- `Control` +- `Manager` +- `Helper` +- `Util` +- `Common` +- `Misc` +- `Data` +- `Info` + +Replace them with the owned responsibility: + +```text +CollaborationControl -> CollaborationReplica +CollaborationSnapshot -> ReplicaStatus +PointerHelper -> PointerParser or PointerBuilder +CheckpointManager -> CheckpointStore or CheckpointVerifier +``` + +`Data` remains allowed only where it is the exact standard/domain term, such as +JSON data, not as a vague container suffix. + +## Operation versus Change + +This distinction is invariant: + +```text +Operation = instruction to perform +Change = authored/applied historical record +``` + +Examples: + +- `JSONPatchOperation` is a command. +- `SemanticOperation` is an internal collaboration command atom. +- `TextSpliceOperation` is a text command atom. +- `JSONAppliedChange` records applied operations. +- `CollaborationChange` records authored semantic operations and dependencies. + +## Function verb rules + +| Prefix | Meaning | +| --- | --- | +| `create*` | Construct a new document/runtime/adapter/identity | +| `restore*` | Validate persisted state and reconstruct a runtime | +| `apply*` | Apply operations to a value or state | +| `validate*` | Inspect without state mutation | +| `parse*` | Parse syntax and throw on invalid syntax | +| `tryParse*` | Parse syntax and return null/failure on invalid syntax | +| `build*` | Assemble a public representation from structured parts | +| `append*` | Add one component to an existing representation | +| `track*` | Track identity/location through changes | +| `materialize*` | Fold change history/DAG into current domain value | +| `project*` | Derive a lower-level read representation; internal use only | +| `export*` | Produce a typed transport-neutral artifact | +| `ingest*` | Validate and integrate untrusted external artifact | +| `compact*` | Fold history into a new recovery boundary | + +Do not mix `accept*`, `check*`, `probe*`, and `validate*` for the same validation +responsibility. Boolean capability predicates may use `can*`. + +## Boolean rules + +Public booleans must read as predicates: + +- `is*`: classification/current state +- `has*`: possession/existence +- `can*`: capability +- `should*`: policy decision +- `did*`: outcome of a completed operation + +Examples: + +```text +isUndoable +hasPendingChanges +canUndo +shouldPublish +didChangeDocument +``` + +`ok` remains the established Result discriminant. + +Avoid unqualified public boolean fields such as `active`, `available`, `valid`, +`accepted`, or `changed`. Use `isActive`, `isAvailable`, `isValid`, +`wasAccepted`, or `didChangeDocument`. + +Canonical replacement: + +```text +projectionChanged -> didChangeDocument +``` + +## Collection and abbreviation rules + +Collections use plural nouns: + +- `operations`, `changes`, `dependencies`, `heads`, `conflicts`, `members` + +Public names and new wire fields must not introduce avoidable abbreviations: + +- prefer: `dependencies`, `operations`, `document`, `previous`, `current` +- avoid: `deps`, `ops`, `doc`, `prev`, `curr`, `cfg`, `ctx` + +Existing versioned wire fields such as `deps` and `ops` remain unchanged until a +new explicitly authorized protocol version. Documentation may call them +dependencies and operations without changing their serialized spelling. + +Stable acronyms remain uppercase: + +- `JSON`, `DOM`, `URI`, `URL`, `IME` +- `Id` follows the repository's TypeScript identifier rule above. + +## Evaluation of current public vocabulary + +| Current name | Responsibility | Match | Ambiguity | Decision | Canonical/proposed name | +| --- | --- | --- | --- | --- | --- | +| `JSONDocument` | Stateful document API | Exact domain | None | keep | `JSONDocument` | +| `Document Projection` | Same stateful document API | Weak/local | DB projection collision | merge | JSON Document | +| `Pure Protocol` | Stateless patch application | Weak/local | Wire protocol collision | merge | stateless JSON Patch | +| `canPatch` | Validate patch and candidate | Near | Capability/probe synonym | v3 rename | `validatePatch` | +| `JSONCapabilityResult` | Patch-validation result | None | Over-broad capability | v3 rename | `JSONPatchValidationResult` | +| `accepts` | Candidate validator | Near | Looks boolean but returns Result | v3 rename | `validate` | +| acceptance | Candidate validation | Near | Duplicate validation concept | merge | validation | +| publication | Subscriber notification | Near | Process/mechanism ambiguity | merge | change notification | +| `JSONAppliedChange` | Canonical applied operations | Exact responsibility | None | keep | `JSONAppliedChange` | +| collaboration provider | Transport-free causal engine | Mismatch | Provider implies connector | rename in prose | collaboration engine | +| `CollaborationControl` | Replica status/sync/checkpoint API | None | Vague responsibility | vNext rename | `CollaborationReplica` | +| `runtime.collaboration` | Replica surface | Near | Package name, not role | vNext rename | `runtime.replica` | +| `CollaborationSnapshot` | Current causal diagnostics | Mismatch | Snapshot/checkpoint collision | vNext rename | `ReplicaStatus` | +| `current()` | Return replica status | Weak | Return meaning hidden | vNext rename | `status()` | +| `CollaborationBundle` | Epoch-and-change exchange artifact | Near | Full vs incremental unspecified | keep | `CollaborationBundle` | +| `SuppressedChange` | Known but inactive contribution | Exact local semantics | Rejection confusion documented | keep | `SuppressedChange` | +| `materializeChanges` | Derive current document from DAG | Exact de-facto | None | keep | `materializeChanges` | +| `projectionChanged` | Commit changed visible document | Weak | Removed concept leaks | vNext rename | `didChangeDocument` | +| `CollaborationTextDOM` | DOM observe/render/selection boundary | Mismatch | Sounds like DOM state | rename | `TextDOMAdapter` | +| publication lease | Defer DOM rendering during native input | Local | Could imply sync lock | rename in prose | native-input DOM lease | + +## Target public vocabulary + +The exact vNext API remains a separate breaking-change decision, but new +documentation and design work should converge toward: + +```text +Core +├─ JSONValue +├─ Pointer +├─ JSONPatchOperation +├─ JSONAppliedChange +├─ JSONPatchValidationResult +├─ JSONPatchResult +├─ JSONDocument +├─ JSONDocumentOptions +├─ JSONDocumentCommitOptions +└─ JSONDocumentCommitResult + +Collaboration root +├─ ActorId +├─ ChangeId +├─ CollaborationChange +├─ CollaborationBundle +├─ CollaborationConflict +├─ CollaborationEpoch +├─ CollaborationMembership +├─ CollaborationCheckpoint +├─ CollaborationRuntime +├─ CollaborationReplica +├─ ReplicaStatus +├─ PendingChange +└─ SuppressedChange + +History subpath +├─ History +├─ HistoryStatus +├─ HistoryResult +└─ HistoryRuntime + +Text subpath +├─ TextSelection +├─ TextObservation +├─ TextCapture +├─ TextCaptureResult +├─ TextPlan +├─ TextPlanResult +├─ TextCommitResult +└─ TextRuntime + +Contenteditable package +├─ TextDOMAdapter +├─ DOMObservation +├─ ContentEditableOptions +├─ ContentEditableResult +└─ ContentEditableAdapter +``` + +The target list is vocabulary direction, not authorization to rename every +public symbol. Each breaking rename still requires usage evidence and a +migration plan. + +## Required repository SSOT + +Create one canonical, human-maintained concept-and-naming document. It must: + +1. Contain the Goal Anchor or link back to this issue. +2. State the naming authority order. +3. Define every canonical concept above. +4. Contain prefix, suffix, verb, boolean, collection, casing, and abbreviation + rules. +5. Include allowed, restricted, and forbidden examples. +6. Include a canonical-term-to-v2-API compatibility map. +7. Distinguish normative standard terms, de-facto terms, and local terms. +8. Define how a new concept is proposed and rejected. +9. Be referenced by overview, API, standard-profile, collaboration, history, + text, contenteditable, and contributor-facing documentation. +10. Be checked for drift by repository verification. + +Generated catalogs may consume this SSOT but must not become its authority. + +## Concept admission test + +A new public concept or synonym is admitted only when all answers are yes: + +```text +1. Does it represent a responsibility not already named? +2. Is there no applicable normative or stable de-facto name? +3. Does the name follow the prefix/suffix/grammar rules? +4. Does it reduce or preserve the number of concepts? +5. Is its boundary distinguishable from adjacent concepts? +6. Can its public and wire compatibility impact be stated? +``` + +If the proposal fails any item, reuse or qualify an existing canonical term. + +## Implementation phases + +### Phase A — publish the non-breaking SSOT + +- Add the canonical concept-and-naming document. +- Replace the overview's distributed concept table with a summary and link. +- Add the v2 compatibility map. +- Normalize prose: + - Pure Protocol -> stateless JSON Patch + - Document Projection -> JSON Document + - acceptance/capability probe -> validation + - publication -> change notification + - local provider -> local implementation + - collaboration provider -> collaboration engine + - collaboration snapshot -> replica status + - DOM projection -> DOM adapter +- Preserve code symbols when documentation refers specifically to the v2 API. +- Mark code identifiers with code formatting so conceptual and compatibility + names cannot be confused. + +### Phase B — non-public implementation vocabulary + +Evaluate and rename internal-only identifiers where tests prove no public or +wire impact: + +```text +ProjectionOptions -> DocumentOptions +ProjectionDocument -> JSONDocumentImplementation +createProjection -> createDocument +acceptCandidate -> validateCandidate +PublishedProjection -> RenderedDocumentValue +``` + +Do not rename an identifier merely because it appears in this candidate list; +confirm responsibility and visibility first. + +### Phase C — design a versioned breaking migration + +Prepare, but do not execute without explicit authorization: + +```text +canPatch -> validatePatch +JSONCapabilityResult -> JSONPatchValidationResult +accepts -> validate +CollaborationControl -> CollaborationReplica +runtime.collaboration -> runtime.replica +CollaborationSnapshot -> ReplicaStatus +current() -> status() +projectionChanged -> didChangeDocument +CollaborationTextDOM -> TextDOMAdapter +``` + +The migration design must address TypeScript aliases, deprecation windows, +machine-readable manifests, conformance suites, wire fields, package subpaths, +documentation, and semver. + +## Acceptance criteria + +- [ ] Exactly one repository document is declared the concept-and-naming SSOT. +- [ ] The SSOT contains all concepts and grammar rules in this issue. +- [ ] RFC/W3C-defined names retain exact standard spelling and meaning. +- [ ] Operation and Change are explicitly distinguished. +- [ ] Value, Status, Snapshot, and Checkpoint are explicitly distinguished. +- [ ] Runtime, Replica, Adapter, and Binding are explicitly distinguished. +- [ ] Result, Success, Failure, Error, Options, Payload, and Report suffixes are + explicitly distinguished. +- [ ] JSON, Collaboration, Collaborative, DOM, ContentEditable, and Id prefix/ + casing rules are explicit. +- [ ] Function verb and boolean predicate rules are explicit. +- [ ] Collection and abbreviation rules are explicit. +- [ ] `Control`, `Manager`, `Helper`, `Util`, `Common`, `Misc`, vague `Data`, and + vague `Info` are restricted for new public names. +- [ ] Every current public concept is marked keep, merge, rename, restrict, or + remove. +- [ ] Canonical prose and stable v2 identifiers are visibly distinguished. +- [ ] The compatibility map covers the core, collaboration, history, text, and + contenteditable public surfaces. +- [ ] No runtime, protocol, wire, or stable v2 API behavior changes. +- [ ] Repository checks detect undocumented vocabulary drift or stale generated + concept output. +- [ ] A future breaking migration is separately scoped and not silently bundled + into this issue. + +## Verification + +At minimum: + +- documentation generation/check +- documentation evaluation +- site documentation consistency tests +- source/public-surface drift checks +- focused searches for forbidden or deprecated conceptual synonyms +- full diff audit against the Goal Anchor + +Commands should be derived from the repository at implementation time. Current +likely checks include: + +```text +npm run docs:check +npm run docs:evaluate +npm test -w @interactive-os/json-document-site +npm run standard:check +``` + +## Explicitly out of scope + +- Runtime behavior changes +- CRDT or merge-rule changes +- Wire protocol field renames (`deps`, `ops`, etc.) +- Epoch/checkpoint format changes +- Package restructuring +- Presence/awareness implementation +- A generic rich-text protocol +- Immediate v2 public API removal or breaking rename +- Compatibility aliases or deprecations before a separate migration decision +- Creating unrelated cleanup issues + +## Risks + +- The v2 Projection Profile is stable and machine checked. Conceptual prose must + not falsely claim that existing v2 symbol names already changed. +- `Projection` appears in standards, vectors, independent implementations, + collaboration bindings, site descriptions, and tests. Removing the word + mechanically would destroy compatibility context. +- `deps` and `ops` are versioned wire fields. Expanding them in prose does not + authorize serialized-field changes. +- Over-prefix removal can create collisions at root entrypoints. Subpath + simplification must be evaluated per export surface. +- `Snapshot` may remain valid for internal complete immutable states. Restrict + the meaning rather than globally replacing every occurrence. +- `provider` may remain valid for actual schema/network/storage providers. Only + the transport-free collaboration-engine misuse is targeted. + +## Deferred decisions + +The following remain visible but outside Done: + +- Exact v3/vNext public API shape +- Alias and deprecation duration +- Whether `/history` and `/text` become stronger naming namespaces +- Whether `Pointer` should ever become `JSONPointer` in TypeScript +- Whether result booleans standardize on `didChangeDocument` or retain a + compatibility spelling +- Whether wire protocol v4 expands `deps` and `ops` + +These require explicit follow-up authorization; they are not hidden acceptance +criteria for this issue. diff --git a/docs/public/api.md b/docs/public/api.md index fe0647f2..cab95b9a 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -85,7 +85,7 @@ interface JSONDocument { ## value와 ownership -`value`는 현재 immutable snapshot입니다. Initial value, patch payload, +`value`는 현재 immutable document value입니다. Initial value, patch payload, metadata, 이전 snapshot, read result와 published change는 caller의 mutable reference와 격리됩니다. @@ -165,7 +165,7 @@ function asPointer(path: string): Pointer | null { ## applyPatch `applyPatch`는 schema provider, mutable session, UI 없이 ordered RFC 6902 -batch를 적용하는 Pure Protocol 함수입니다. +batch를 적용하는 stateless JSON Patch 함수입니다. ```ts import { applyPatch } from "@interactive-os/json-document"; @@ -191,7 +191,7 @@ canonical sequence입니다. ## canPatch와 commit -두 method는 같은 JSON, Pointer, Patch, acceptance 의미를 사용합니다. +두 method는 같은 JSON, Pointer, Patch와 validation 의미를 사용합니다. ```ts const operations = [ @@ -216,7 +216,7 @@ if (capability.ok) { ``` `canPatch`는 state와 subscriber를 바꾸지 않습니다. `commit`은 batch 전체를 -동기적·원자적으로 적용합니다. 실패와 state-equivalent no-op은 publication을 +동기적·원자적으로 적용합니다. 실패와 state-equivalent no-op은 change notification을 만들지 않습니다. 성공 commit은 post-commit snapshot을 중복 반환하지 않습니다. @@ -235,10 +235,10 @@ unsubscribe(); Subscriber는 이미 publish된 `JSONAppliedChange`를 받습니다. Unsubscribe 뒤의 변경은 전달되지 않습니다. -## acceptance +## Validation -`createJSONDocument`의 `accepts` option은 특정 schema object를 요구하지 않는 -provider-neutral boundary입니다. +`createJSONDocument`의 stable v2 `accepts` option은 특정 schema object를 +요구하지 않는 implementation-neutral validation boundary입니다. ```ts import * as z from "zod"; @@ -269,10 +269,15 @@ const acceptedDocument = createJSONDocument( ); ``` -Initial state와 commit candidate 모두 publication 전에 검사됩니다. Callback이 +Initial state와 commit candidate 모두 commit notification 전에 검사됩니다. Callback이 parse한 변환값은 Core state로 채택되지 않으며, normalization이 필요하면 그 변경을 JSON Patch에 명시합니다. +Canonical concept는 validation이고 `accepts`와 `JSONCapabilityResult`는 stable +v2 compatibility identifier입니다. Naming 기준과 vNext 후보는 +[Concept and Naming Standard](https://github.com/developer-1px/json-document/blob/main/docs/standard/concept-and-naming-standard.md)를 +따릅니다. + ## Result 공개 Result는 `ok`로 분기합니다. diff --git a/docs/public/overview.md b/docs/public/overview.md index 85e6a006..8ab5dbb2 100644 --- a/docs/public/overview.md +++ b/docs/public/overview.md @@ -1,16 +1,16 @@ # json-document Docs json-document v2는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 -있는 provider-neutral JSON 편집 protocol과 headless document projection입니다. +있는 implementation-neutral JSON 편집 API와 headless JSON Document입니다. 루트 package는 JSON, JSON Pointer, JSONPath, JSON Patch만 전제로 하며 Zod, React, selection, clipboard, history를 필수 계약에 넣지 않습니다. ```txt -Pure Protocol - |-> local provider -----------\ - | > same six-member Document Projection - `-> collaboration provider --/ |-> optional history/text authoring - `-> optional DOM/IME lease +stateless JSON Patch + |-> local implementation -----\ + | > same six-member JSON Document + `-> collaboration engine -----/ |-> optional history/text authoring + `-> optional native-input DOM lease ``` ## 배경 @@ -26,7 +26,7 @@ Core는 다음만 소유합니다. - RFC 6901 JSON Pointer - RFC 9535 JSONPath query - RFC 6902 JSON Patch -- immutable snapshot, capability result, atomic commit, publication +- immutable document value, patch validation, atomic commit, change notification DOM, focus, keyboard, geometry, system clipboard, filesystem, network, formula, CRDT와 OT는 host 또는 adapter 책임입니다. @@ -39,18 +39,25 @@ CRDT와 OT는 host 또는 adapter 책임입니다. | JSON Pointer | 한 위치를 정확히 가리키는 주소. 예: `/lists/0/cards/0/title` | | JSONPath | 여러 위치를 찾는 query. 결과는 Pointer 목록 | | JSON Patch | ordered atomic mutation 형식 | -| Pure Protocol | 현재 document instance 없이 JSON Patch를 적용하는 함수 | -| Document Projection | 현재 snapshot에 read, probe, commit, publication을 연결한 여섯-member port | -| acceptance | candidate state를 publish 전에 검사하는 provider-neutral callback | +| Stateless JSON Patch | 현재 document instance 없이 JSON Patch를 적용하는 함수 | +| JSON Document | 현재 document value에 read, validation, commit, notification을 연결한 여섯-member port | +| validation | Candidate document를 commit 전에 검사하는 implementation-neutral callback | | Host adapter | selection, clipboard, history, DOM과 고수준 편집 동사를 소유하는 별도 계층 | +전체 canonical concept, 접두어·접미어·동사·boolean 규칙과 stable v2 이름의 +compatibility map은 +[Concept and Naming Standard](https://github.com/developer-1px/json-document/blob/main/docs/standard/concept-and-naming-standard.md)가 +정의합니다. `Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 +stable v2 identifier 또는 compatibility label이며 별도 canonical concept가 +아닙니다. + 가장 중요한 경계는 query와 mutation을 섞지 않는 것입니다. ```txt 검색: JSONPath -> Pointer[] 변경: Pointer -> JSON Patch -검증: JSON candidate -> acceptance result -상태: immutable JSON snapshot +검증: JSON candidate -> validation result +상태: immutable document value ``` ## 기본 사용 흐름 @@ -111,14 +118,14 @@ 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 | +| `@interactive-os/json-document` | v2 Kernel | Stateless JSON Patch와 여섯-member JSON Document | +| `@interactive-os/json-document-collaboration` | optional companion | 같은 JSON Document 뒤의 transport-free causal engine | +| `@interactive-os/json-document-contenteditable-collaboration` | optional companion | collaborative string의 native-input DOM lease | 패키지는 `/session`과 `/react`를 공개하지 않습니다. 구현 간 교환 가능한 코드는 루트 `JSONDocument` 여섯 member에만 의존하고, 편집 UX와 framework lifecycle은 host 또는 별도 adapter가 소유합니다. Local-only consumer는 Core만 설치하며, -collaboration provider로 바꿔도 editor가 사용하는 `JSONDocument` API는 +collaboration engine으로 바꿔도 editor가 사용하는 `JSONDocument` API는 변하지 않습니다. ## Host adapter와 companion @@ -153,11 +160,11 @@ contenteditable lifecycle을 소유하며, 문서별 의미는 adapter가 연결 ## 이걸로 할 수 있는 것들 -- Form과 settings editor: acceptance로 publish 가능한 JSON 구조 제한 +- Form과 settings editor: validation으로 commit 가능한 JSON 구조 제한 - Data grid: cell과 row 변경을 ordered JSON Patch로 표현 - Outliner와 block docs: tree command를 Pointer와 Patch로 환원 - Slide와 whiteboard: object property와 layer state를 headless JSON으로 관리 - 저장과 협업 adapter: subscribed canonical change를 외부 log로 전달 제품별 selection, clipboard, history는 host 또는 별도 adapter에서 -조합합니다. Core Projection은 그 기능을 필수 member로 요구하지 않습니다. +조합합니다. Core JSON Document는 그 기능을 필수 member로 요구하지 않습니다. diff --git a/docs/public/quickstart.md b/docs/public/quickstart.md index 65233bd6..36a74e70 100644 --- a/docs/public/quickstart.md +++ b/docs/public/quickstart.md @@ -125,10 +125,11 @@ if (preview.ok) { 입력 state와 operation은 변경되지 않으며, 성공 result는 caller input과 격리됩니다. -## 6. 선택한 provider로 acceptance 연결하기 +## 6. 선택한 validator 연결하기 -Core는 Zod를 요구하지 않습니다. 어떤 validator든 작은 acceptance callback으로 -연결할 수 있습니다. +Core는 Zod를 요구하지 않습니다. 어떤 validator든 stable v2 `accepts` +callback으로 연결할 수 있습니다. Canonical concept는 validation이며 +`accepts`는 compatibility identifier입니다. ```ts import * as z from "zod"; diff --git a/docs/standard/concept-and-naming-standard.md b/docs/standard/concept-and-naming-standard.md new file mode 100644 index 00000000..852b7e5d --- /dev/null +++ b/docs/standard/concept-and-naming-standard.md @@ -0,0 +1,590 @@ +# json-document Concept and Naming Standard + +상태: Canonical + +이 문서는 현재 `json-document` repository의 개념과 이름 문법을 정의하는 유일한 +사람 작성 정본이다. 개요, API 문서, v2 compatibility profile, package README, +generated catalog와 구현 이름은 이 문서를 참조한다. Generated artifact와 현재 +구현은 이 문서의 개념 의미를 덮어쓸 수 없다. + +이 문서는 runtime 동작, protocol 의미, wire 형식과 stable v2 public API를 +변경하지 않는다. Canonical term과 v2 compatibility identifier가 다르면 문서는 +canonical term을 먼저 설명하고 실제 identifier를 code font로 병기한다. + +## 이름 권위 + +이름은 다음 우선순위로 결정한다. + +1. 구현한 개념에 해당하는 normative standard의 정확한 이름 +2. repository가 사용하는 platform 또는 framework의 안정된 이름 +3. 실무자가 알아보는 de-facto domain 이름 +4. 모호하지 않고 일관된 local ubiquitous language +5. 대상이 소유하거나 결정하는 책임 이름 +6. 앞선 이름이 없을 때만 쓰는 짧은 설명 이름 + +낮은 순위의 이름이 높은 순위의 이름을 대체하려면 이유를 기록해야 한다. + +Normative anchor는 RFC 8259 JSON, RFC 6901 JSON Pointer, RFC 6902 JSON +Patch, RFC 9535 JSONPath와 W3C DOM·Input Events·UI Events·composition +용어다. + +협업 영역의 de-facto anchor는 change, dependency, change DAG, head, +conflict, replica, materialized view, checkpoint, compaction, relative +position, text splice, adapter와 transport-agnostic engine이다. + +## 개념 경계 + +### 표준 JSON + +| Canonical term | 정의 | 포함하지 않는 것 | +| --- | --- | --- | +| JSON value | RFC 8259와 호환되는 document data | Runtime state, DOM state, protocol envelope | +| JSON Pointer | RFC 6901에 따라 한 위치를 식별하는 주소 | JSONPath query, collaboration identity | +| JSONPath | RFC 9535에 따라 여러 위치를 찾는 query | Mutation target | +| JSON Patch | RFC 6902 ordered operation batch | Merge Patch, semantic operation | +| Patch application | JSON value에 JSON Patch를 원자적으로 적용하는 stateless 연산 | Stateful commit, notification, collaboration | + +`Pure Protocol`은 별도 canonical concept가 아니다. v2 문맥에서 이 이름을 +인용해야 할 때는 **v2 Pure Protocol compatibility label**이라고 쓰고, 일반 +설명에서는 **stateless JSON Patch application**을 쓴다. + +### JSON Document + +| Canonical term | 정의 | 포함하지 않는 것 | +| --- | --- | --- | +| JSON Document | 현재 value, read, patch validation, commit과 subscription을 제공하는 stateful document | DOM, selection, history, transport | +| document value | JSON Document가 소유하는 현재 immutable JSON value | Checkpoint, replica status | +| patch validation | Patch를 commit할 수 있는지 state 변경 없이 평가하는 작업 | Commit, boolean-only capability | +| validation | Candidate document가 configured constraint를 만족하는지 평가하는 작업 | Transformation, normalization | +| commit | JSON Patch를 state에 원자적으로 적용하는 작업 | Stateless Patch application | +| applied change | 실제 적용된 canonical operations와 owned metadata | 실패한 candidate operations | +| change notification | State-changing commit 뒤 subscriber에게 전달되는 applied change | Document value, replica diagnostics | +| subscription | Change notification listener를 등록한 연결 | Network subscription | + +다음 local synonym은 canonical concept에 합친다. + +```text +Document Projection + Projection snapshot + -> JSON Document + document value + +acceptance + capability probe + schema acceptance + -> validation + +publication + -> change notification +``` + +`Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 stable v2 +compatibility identifier로 남는다. Canonical prose에서 새 concept를 만들지는 +않는다. + +### Collaboration + +| Canonical term | 정의 | 포함하지 않는 것 | +| --- | --- | --- | +| collaboration engine | Authored Patch를 causal changes로 번역하고 replicas를 수렴시키는 transport-agnostic engine | Network provider | +| replica | 한 participant가 소유한 causal state와 sync surface | User presence | +| change | 작성된 원자적 causal record | 적용 전 command | +| Change ID | Actor ID와 actor-local counter로 만든 change identity | Pointer, array index | +| dependency | Change의 causal predecessor | Runtime/package dependency | +| change DAG | Dependencies로 연결한 directed acyclic graph | Materialized JSON tree | +| head | 알려진 successor가 없는 현재 causal frontier change | Array head, document root | +| collaboration bundle | Epoch과 changes를 담는 transport-neutral exchange artifact | Network framing | +| replica status | Heads, pending changes, conflicts, suppressed changes를 담은 현재 진단 상태 | Restore artifact | +| pending change | Dependencies가 도착하지 않아 materialize할 수 없는 change | Failed 또는 suppressed change | +| conflict | Deterministic winner와 보존된 concurrent alternatives | Validation failure | +| suppressed change | 알려졌지만 validation/history 규칙 때문에 현재 materialization에 기여하지 않는 change | 영구 폐기된 input | +| materialization | Change DAG에서 현재 JSON document value를 결정적으로 계산하는 작업 | DOM rendering | + +`provider`는 실제 network, storage, schema 또는 host provider에만 쓴다. +Transport를 소유하지 않는 collaboration package는 **collaboration engine**이다. + +`CollaborationSnapshot`은 stable current identifier지만 canonical concept는 +**replica status**다. Snapshot이나 checkpoint로 설명하지 않는다. + +### Lifecycle + +| Canonical term | 정의 | 포함하지 않는 것 | +| --- | --- | --- | +| epoch | Base document, ruleset과 membership이 고정되는 collaboration generation | Browser session | +| membership | 한 epoch에서 change를 작성하도록 admitted된 actors | Authentication proof | +| checkpoint | Restore에 충분한 versioned integrity-protected artifact | Current diagnostic status | +| compaction | Causal history를 새 epoch base로 접는 작업 | In-place history deletion | +| restore | Checkpoint를 검증하고 runtime을 재구성하는 작업 | Undo | + +### Collaborative history + +| Canonical term | 정의 | +| --- | --- | +| selective undo | 다른 actor를 덮어쓰지 않고 현재 actor의 causal contribution을 비활성화하는 작업 | +| selective redo | 유효한 selective undo contribution을 다시 활성화하는 작업 | +| history status | 현재 undo/redo target, depth와 revision | + +History undo/redo는 document time travel이나 inverse-value overwrite가 아니다. + +### Collaborative text + +| Canonical term | 정의 | +| --- | --- | +| text atom | Collaborative string의 stable-identity unit | +| text splice | Stable left/right context에 상대적인 removal과 insertion | +| relative selection | Causal context를 유지하는 anchor/focus 위치 | +| text capture | Native input 전 text, selection과 causal frontier | +| text plan | Capture와 최종 DOM observation에서 준비한 splice | +| text commit | 유효하고 stale하지 않은 Text Plan의 causal commit | + +`capture -> plan -> commit`은 stale-plan detection과 native-input +reconciliation을 소유하므로 일반 patch validation과 합치지 않는다. + +### DOM과 editor + +| Canonical term | 정의 | +| --- | --- | +| DOM adapter | Platform DOM state를 observe, render, restore하는 경계 | +| contenteditable adapter | 한 contenteditable root와 collaborative text를 연결하는 adapter | +| native-input DOM lease | Model ingestion은 계속하면서 browser에 DOM mutation ownership을 임시로 맡기고 model-to-DOM rendering만 유예하는 상태 | +| composition session | W3C IME composition lifecycle | + +Native-input DOM lease는 collaboration ingestion을 멈추지 않는다. + +## 이름 문법 + +Public TypeScript 이름은 다음 순서로 만든다. + +```text +[standard 또는 domain qualifier] + [subject] + [role suffix] +``` + +예: + +```text +JSONPatchOperation +JSONDocumentCommitResult +CollaborationCheckpoint +ReplicaStatus +TextPlanResult +ContentEditableAdapter +``` + +Package 이름과 subpath가 이미 namespace이면 타입 이름에서 반복하지 않는다. + +```text +avoid CollaborationTextCaptureResult +prefer TextCaptureResult from the /text subpath +``` + +## 접두어와 casing + +### `JSON` + +타입 의미가 JSON 표준 경계에 의해 정의될 때만 사용한다. + +```text +keep JSONValue, JSONDocument, JSONPatchOperation +keep JSONPatchResult, JSONAppliedChange, JSONChangeMetadata +avoid JSONCollaborationRuntime, JSONReplicaStatus, JSONTextCapture +``` + +`JSON`은 JSON을 사용한다는 표시가 아니라 JSON 표준 경계가 의미를 정의한다는 +표시다. + +### `Collaboration` + +최상위 collaboration concept와 artifact에만 사용한다. + +```text +CollaborationRuntime +CollaborationChange +CollaborationBundle +CollaborationConflict +CollaborationCheckpoint +CollaborationEpoch +CollaborationMembership +``` + +History, text, status, DOM type마다 반복하지 않는다. + +### `Collaborative` + +이미 namespace가 제공되지 않는 표면에서 collaborative subject를 구분해야 할 +때만 형용사로 사용한다. + +```text +prefer TextRuntime from /text +next CollaborativeTextRuntime +avoid CollaborationTextRuntime +``` + +### `DOM` + +W3C casing을 유지한다. Platform boundary라면 책임 suffix를 붙인다. + +```text +prefer TextDOMAdapter +avoid CollaborationTextDOM +``` + +### `ContentEditable` + +TypeScript type은 `ContentEditable` casing을 사용한다. Package가 collaboration +namespace를 이미 제공하면 그 접두어를 반복하지 않는다. + +### `Id` + +Type은 `Id`, value와 property는 `...Id`를 사용한다. + +```text +ActorId, ChangeId, actorId, epochId +``` + +`ActorID`, `ChangeID`, `actorID`는 사용하지 않는다. + +## 접미어 + +| Suffix | 사용하는 경우 | 사용하지 않는 경우 | +| --- | --- | --- | +| `Document` | 사용자-facing 논리 document state | Replica diagnostics, checkpoint | +| `Value` | JSON content 자체 | Stateful document | +| `Operation` | 실행할 command 또는 protocol atom | Historical record | +| `Change` | 작성되거나 적용된 record | Candidate command | +| `Result` | `{ ok: true } \| { ok: false }` expected-outcome union | 임의 return object | +| `Success` | 독립 재사용하는 Result success variant | Boolean status | +| `Failure` | 반환하는 expected-failure variant | Thrown exception | +| `Error` | Thrown `Error` subclass | Expected failure value | +| `Options` | Caller가 제공하는 선택적 입력 | Runtime state, shared ruleset | +| `Runtime` | 관련 stateful capabilities의 조합 | Plain data | +| `Replica` | 한 participant의 causal state와 sync surface | Transport | +| `Status` | 현재 진단 정보 | Restore artifact | +| `Snapshot` | 한 시점의 완전한 immutable state | Diagnostics 또는 checkpoint의 동의어 | +| `Checkpoint` | Versioned restoreable artifact | Current status | +| `Payload` | Envelope 안의 serializable body | Top-level domain object | +| `Report` | 완료 작업의 통계와 진단 | Success/failure union | +| `Adapter` | 외부 platform/model 변환 경계 | Domain state | +| `Binding` | 두 public model 사이의 지속적 synchronization | 일반 wrapper | + +### 제한 suffix + +새 public 이름에는 다음 suffix를 쓰지 않는다. + +```text +Control +Manager +Helper +Util +Common +Misc +Data +Info +``` + +`Data`가 JSON data처럼 정확한 표준 용어일 때는 예외다. 기존 compatibility +identifier는 versioned migration 없이 제거하지 않는다. + +책임 이름으로 대체한다. + +```text +CollaborationControl -> CollaborationReplica +CollaborationSnapshot -> ReplicaStatus +PointerHelper -> PointerParser 또는 PointerBuilder +CheckpointManager -> CheckpointStore 또는 CheckpointVerifier +``` + +## Operation과 Change + +이 구분은 불변이다. + +```text +Operation = 수행할 instruction +Change = 작성되거나 적용된 historical record +``` + +따라서: + +- `JSONPatchOperation`, `SemanticOperation`, `TextSpliceOperation`은 command다. +- `JSONAppliedChange`, `CollaborationChange`, `PendingChange`, + `SuppressedChange`는 record다. + +## 함수 동사 + +| Prefix | 의미 | +| --- | --- | +| `create*` | 새 document, runtime, adapter 또는 identity 구성 | +| `restore*` | Persisted state 검증과 runtime 재구성 | +| `apply*` | Operations를 value 또는 state에 적용 | +| `validate*` | State 변경 없이 입력 검사 | +| `parse*` | Syntax parse, invalid syntax에서 throw | +| `tryParse*` | Syntax parse, invalid syntax에서 null/failure 반환 | +| `build*` | Structured parts로 public representation 조립 | +| `append*` | 기존 representation에 component 하나 추가 | +| `track*` | Change를 통과한 identity/location 추적 | +| `materialize*` | Change history/DAG를 현재 domain value로 fold | +| `project*` | 낮은 수준 구조에서 read representation 파생; internal 전용 | +| `export*` | Typed transport-neutral artifact 생성 | +| `ingest*` | 신뢰하지 않는 외부 artifact 검증과 replica 통합 | +| `compact*` | History를 새 recovery boundary로 fold | + +같은 validation 책임에 `accept*`, `check*`, `probe*`, `validate*`를 섞지 않는다. +Boolean capability predicate만 `can*`을 사용할 수 있다. + +## Boolean + +Public boolean은 predicate로 읽혀야 한다. + +| Prefix | 의미 | +| --- | --- | +| `is*` | Classification 또는 current state | +| `has*` | Possession 또는 existence | +| `can*` | Capability | +| `should*` | Policy decision | +| `did*` | 완료된 operation의 outcome | + +예: + +```text +isUndoable +hasPendingChanges +canUndo +shouldPublish +didChangeDocument +``` + +`ok`는 Result discriminant이므로 유지한다. `active`, `available`, `valid`, +`accepted`, `changed` 같은 무접두 public boolean은 새로 만들지 않는다. + +Canonical replacement candidate: + +```text +projectionChanged -> didChangeDocument +``` + +Candidate는 vNext migration 방향이며 이 문서만으로 public rename이 승인되지는 +않는다. + +## Collection과 축약 + +Collection은 복수 명사를 사용한다. + +```text +operations, changes, dependencies, heads, conflicts, members +``` + +새 public name과 wire field에는 피할 수 있는 축약을 만들지 않는다. + +```text +prefer dependencies, operations, document, previous, current +avoid deps, ops, doc, prev, curr, cfg, ctx +``` + +기존 versioned wire field `deps`, `ops`는 별도 protocol version 승인 전까지 +변경하지 않는다. Prose에서는 dependencies와 operations라고 설명할 수 있다. + +안정된 acronym은 대문자를 유지한다. + +```text +JSON, DOM, URI, URL, IME +``` + +Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. + +## 현재 이름 평가 + +| Current name | 책임 | Match | 모호성 | 결정 | Canonical 또는 proposed name | +| --- | --- | --- | --- | --- | --- | +| `JSONDocument` | Stateful document API | Exact domain | 없음 | keep | `JSONDocument` | +| Document Projection | 같은 stateful document API | Weak/local | DB projection과 충돌 | merge | JSON Document | +| Pure Protocol | Stateless Patch application | Weak/local | Wire protocol과 충돌 | merge | stateless JSON Patch | +| `canPatch` | Patch와 candidate validation | Near | Capability/probe synonym | vNext rename | `validatePatch` | +| `JSONCapabilityResult` | Patch validation result | None | 지나치게 넓음 | vNext rename | `JSONPatchValidationResult` | +| `accepts` | Candidate validator | Near | Boolean처럼 보이나 Result 반환 | vNext rename | `validate` | +| acceptance | Candidate validation | Near | Validation과 중복 | merge | validation | +| publication | Subscriber notification | Near | Process/mechanism 모호 | merge | change notification | +| `JSONAppliedChange` | Applied canonical operations | Exact | 없음 | keep | `JSONAppliedChange` | +| collaboration provider | Transport-free causal engine | Mismatch | Provider는 connector를 암시 | rename prose | collaboration engine | +| `CollaborationControl` | Replica status/sync/checkpoint API | None | Vague responsibility | vNext rename | `CollaborationReplica` | +| `runtime.collaboration` | Replica surface | Near | Package 이름이지 역할이 아님 | vNext rename | `runtime.replica` | +| `CollaborationSnapshot` | Current causal diagnostics | Mismatch | Snapshot/checkpoint 충돌 | vNext rename | `ReplicaStatus` | +| `current()` | Replica status 반환 | Weak | Return 의미가 숨음 | vNext rename | `status()` | +| `CollaborationBundle` | Epoch/change exchange artifact | Near | Full/incremental 불명확 | keep | `CollaborationBundle` | +| `SuppressedChange` | Known inactive contribution | Exact local | Rejection과 구분 필요 | keep | `SuppressedChange` | +| `materializeChanges` | DAG에서 current document 파생 | Exact de-facto | 없음 | keep | `materializeChanges` | +| `projectionChanged` | Visible document change outcome | Weak | 제거할 concept 누출 | vNext rename | `didChangeDocument` | +| `CollaborationTextDOM` | DOM observe/render/selection boundary | Mismatch | DOM state처럼 보임 | vNext rename | `TextDOMAdapter` | +| publication lease | Native input 중 DOM rendering 유예 | Local | Sync lock으로 오해 | rename prose | native-input DOM lease | + +## v2 compatibility map + +| Canonical term | Stable v2 identifier 또는 label | 사용 규칙 | +| --- | --- | --- | +| JSON Document | `JSONDocument`, Document Projection, Projection | `JSONDocument`는 유지하고 Projection은 v2 compatibility 문맥에서만 사용 | +| Stateless JSON Patch | Pure Protocol, `applyPatch` | `applyPatch`는 유지하고 Pure Protocol을 새 canonical concept로 사용하지 않음 | +| Patch validation | `canPatch`, `JSONCapabilityResult` | Identifier는 유지하고 prose에서 validation으로 설명 | +| Validation | acceptance, `accepts` | Identifier는 유지하고 prose에서 validation callback으로 설명 | +| Applied change | `JSONAppliedChange` | 그대로 사용 | +| Change notification | publication, `subscribe` | `subscribe`는 유지하고 publication을 notification으로 설명 | +| Collaboration engine | collaboration provider package description | Transport-free engine으로 설명 | +| Replica | `CollaborationControl`, `runtime.collaboration` | vNext 전까지 identifier 유지 | +| Replica status | `CollaborationSnapshot`, `current()` | vNext 전까지 identifier 유지 | +| History status | `CollaborationHistorySnapshot` | vNext 전까지 identifier 유지 | +| Native-input DOM lease | DOM/IME publication lease | Rendering만 유예한다고 명시 | +| DOM adapter | `CollaborationTextDOM` | vNext 전까지 identifier 유지 | + +## Current public surface decisions + +아래 표는 현재 public export를 빠짐없이 keep, merge 또는 vNext rename으로 +분류한다. **keep**은 이름과 의미를 유지한다. **merge**는 identifier를 유지하되 +별도 canonical concept로 세지 않는다. **vNext rename**은 migration 후보일 뿐 +현재 rename 승인이 아니다. + +### Core root + +| Decision | Current public exports | Canonical rule | +| --- | --- | --- | +| keep | `JSONValue`, `Pointer`, `JSONPatchOperation`, `JSONAppliedChange`, `JSONPatchResult`, `JSONDocument`, `JSONDocumentCommitOptions`, `JSONDocumentCommitResult`, `ReadResult`, `QueryResult` | Standard JSON 또는 책임 suffix와 일치 | +| keep | `createJSONDocument`, `applyPatch`, `parsePointer`, `tryParsePointer`, `buildPointer`, `parentPointer`, `appendSegment`, `trackPointer` | Function verb grammar와 일치 | +| merge | `JSONChangeMetadata` | Applied change의 metadata이며 별도 domain concept를 만들지 않음 | +| vNext rename | `JSONCapabilityResult` | Canonical patch validation; 후보 `JSONPatchValidationResult` | + +### Collaboration root + +| Decision | Current public exports | Canonical rule | +| --- | --- | --- | +| keep | `ActorId`, `ChangeId`, `MemberId`, `ContainerNodeId`, `PositionId`, `TextNodeId`, `TextAtomId` | Stable identity와 `Id` casing | +| keep | `ObjectPlacement`, `ArrayPlacement`, `MemberPlacement` | Stable placement responsibility | +| keep | `SemanticOperation`, `TextSpliceOperation` | Operation은 instruction | +| keep | `CollaborationChange`, `PendingChange`, `SuppressedChange` | Change는 record | +| keep | `CollaborationBundle`, `CollaborationConflict`, `CollaborationCheckpoint`, `CollaborationCheckpointPayload` | Top-level collaboration artifact와 suffix가 일치 | +| keep | `CollaborationEpoch`, `CollaborationEpochParent`, `CollaborationMember`, `CollaborationMembership`, `CollaborationRulesetIdentity` | Lifecycle과 membership 책임이 명확 | +| keep | `CollaborationIngestSuccess`, `CollaborationIngestFailure`, `CollaborationIngestResult` | Result variant grammar와 일치 | +| keep | `CollaborationRuntime`, `CollaborationRuntimeOptions`, `CollaborationRestoreOptions`, `CollaborationRestoreResult`, `CollaborationCompactionOptions`, `CollaborationCompactionReport`, `CollaborationCompactionResult` | Runtime/Options/Result/Report grammar와 일치 | +| keep | `createCollaborationRuntime`, `restoreCollaborationRuntime`, `compactCollaborationCheckpoint` | Function verb grammar와 일치 | +| merge | `CollaborationAcceptance` | Canonical validation function; identifier만 유지 | +| vNext rename | `CollaborationControl` | Canonical replica; 후보 `CollaborationReplica` | +| vNext rename | `CollaborationSnapshot` | Canonical replica status; 후보 `ReplicaStatus` | + +### History subpath + +| Decision | Current public exports | Canonical rule | +| --- | --- | --- | +| keep compatibility | `CollaborationHistoryResult`, `CollaborationHistoryRuntime`, `CollaborationHistoryRestoreResult`, `createCollaborationHistoryRuntime`, `restoreCollaborationHistoryRuntime` | 현재 subpath API를 유지; vNext에서 namespace 반복 축소 평가 | +| vNext rename | `CollaborationHistoryControl` | `Control` 제거; 후보 `History` | +| vNext rename | `CollaborationHistorySnapshot` | Canonical history status; 후보 `HistoryStatus` | + +### Text subpath + +| Decision | Current public exports | Canonical rule | +| --- | --- | --- | +| keep compatibility | `CollaborationTextSelection`, `CollaborationTextObservation`, `CollaborationTextCapture`, `CollaborationTextCaptureResult`, `CollaborationTextPlan`, `CollaborationTextPlanResult`, `CollaborationTextCommitResult`, `CollaborationTextRuntime`, `CollaborationTextRestoreResult`, `createCollaborationTextRuntime`, `restoreCollaborationTextRuntime` | 현재 subpath API를 유지; vNext에서 `Text*`로 namespace 반복 축소 평가 | +| keep compatibility | `CollaborationHistoryResult`, `CollaborationHistorySnapshot`, `CollaborationHistoryControl` | Text profile이 노출하는 history compatibility surface | +| vNext rename | `CollaborationTextControl` | `Control` 제거; 실제 capture/plan/commit 책임에 맞는 `Text` capability 이름 결정 | + +### Contenteditable package + +| Decision | Current public exports | Canonical rule | +| --- | --- | --- | +| keep compatibility | `CollaborationContentEditableAdapter`, `CollaborationContentEditableOptions`, `CollaborationContentEditableResult`, `createCollaborationContentEditableAdapter` | 현재 API 유지; vNext에서 package namespace 반복 축소 평가 | +| vNext rename | `CollaborationTextDOM`, `CollaborationTextDOMObservation`, `plainTextCollaborationDOM` | Canonical DOM adapter; 후보 `TextDOMAdapter`, `DOMObservation`, `plainTextDOMAdapter` | + +## Target vocabulary + +이 목록은 naming 방향이며 public rename 승인이 아니다. + +```text +Core +├─ JSONValue +├─ Pointer +├─ JSONPatchOperation +├─ JSONAppliedChange +├─ JSONPatchValidationResult +├─ JSONPatchResult +├─ JSONDocument +├─ JSONDocumentOptions +├─ JSONDocumentCommitOptions +└─ JSONDocumentCommitResult + +Collaboration root +├─ ActorId +├─ ChangeId +├─ CollaborationChange +├─ CollaborationBundle +├─ CollaborationConflict +├─ CollaborationEpoch +├─ CollaborationMembership +├─ CollaborationCheckpoint +├─ CollaborationRuntime +├─ CollaborationReplica +├─ ReplicaStatus +├─ PendingChange +└─ SuppressedChange + +History subpath +├─ History +├─ HistoryStatus +├─ HistoryResult +└─ HistoryRuntime + +Text subpath +├─ TextSelection +├─ TextObservation +├─ TextCapture +├─ TextCaptureResult +├─ TextPlan +├─ TextPlanResult +├─ TextCommitResult +└─ TextRuntime + +Contenteditable package +├─ TextDOMAdapter +├─ DOMObservation +├─ ContentEditableOptions +├─ ContentEditableResult +└─ ContentEditableAdapter +``` + +## 새 concept admission + +새 public concept나 synonym은 다음 질문에 모두 yes일 때만 추가한다. + +```text +1. 아직 이름 붙지 않은 별도 책임인가? +2. 적용 가능한 normative 또는 stable de-facto name이 없는가? +3. Prefix, suffix, verb, boolean과 casing 규칙을 따르는가? +4. 독자가 기억할 concept 수를 늘리지 않는가? +5. 인접 concept와 경계를 검증할 수 있는가? +6. Public API와 wire compatibility 영향을 설명할 수 있는가? +``` + +하나라도 no이면 기존 canonical term을 재사용하거나 정확한 qualifier를 붙인다. + +## 변경 절차 + +1. Naming evaluation table에 책임, 근거, 안정성, 일관성, 모호성, concept + reduction과 compatibility 영향을 기록한다. +2. Canonical prose 변경과 code identifier 변경을 분리한다. +3. Stable identifier나 wire field 변경은 별도 versioned migration으로 승인받는다. +4. Overview, API, compatibility profile, collaboration/history/text, + contenteditable와 contributor 문서를 함께 갱신한다. +5. Generated artifact는 이 정본을 복제하지 않고 참조 또는 검증한다. +6. Naming drift check와 관련 test를 통과한다. + +## 불변 규칙 + +1. RFC/W3C 이름은 동의어를 만들지 않는다. +2. `JSON`은 표준 JSON boundary type에만 붙인다. +3. `Collaboration`은 최상위 collaboration artifact에만 붙인다. +4. Subpath가 namespace이면 type name에 namespace를 반복하지 않는다. +5. Operation은 instruction이고 Change는 record다. +6. Result는 `{ ok }` success/failure union에만 붙인다. +7. Failure는 반환값이고 Error는 thrown object다. +8. Options는 caller input, Status는 현재 진단, Snapshot은 완전한 시점 + state, Checkpoint는 restoreable artifact다. +9. Runtime은 stateful capability 조합, Replica는 causal participant, + Adapter는 외부 platform boundary다. +10. `Control`, `Manager`, `Helper`, `Util`, `Common`, `Misc`, vague `Data`, + vague `Info`를 새 public 이름에 쓰지 않는다. +11. Public boolean은 `is`, `has`, `can`, `should`, `did`로 읽히게 한다. +12. 새 public field를 불필요하게 축약하지 않는다. +13. 같은 validation 책임에는 하나의 동사를 사용한다. +14. Public canonical concept는 JSON Document이며 Projection은 v2 + compatibility label로만 남긴다. +15. 이름 변경은 runtime logic, protocol semantics 또는 wire behavior 변경을 + 승인하지 않는다. diff --git a/docs/standard/v2-projection-profile.md b/docs/standard/v2-projection-profile.md index b72061a7..e2e3a6db 100644 --- a/docs/standard/v2-projection-profile.md +++ b/docs/standard/v2-projection-profile.md @@ -3,6 +3,14 @@ 상태: Stable (`2.0.0`). TypeScript root binding, reference implementation, 독립 test implementation, 다섯 pressure vertical이 stable gate를 통과했다. +`Projection`, Pure Protocol, acceptance, capability와 publication은 이 stable +v2 compatibility profile의 기존 label이다. Repository 전체의 canonical +concept와 naming grammar는 +[Concept and Naming Standard](concept-and-naming-standard.md)가 정의한다. +이 profile의 identifier와 requirement wording은 v2 compatibility를 위해 +유지하며, 새 문서에서는 각각 JSON Document, stateless JSON Patch, validation과 +change notification으로 설명한다. + 이 profile은 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 의존할 수 있는 최소 JSON 편집 계약을 정의한다. 구현체는 더 많은 기능을 제공할 수 있지만 portable consumer는 이 계약만으로 동작할 수 있어야 한다. diff --git a/llms.txt b/llms.txt index 90a53255..f1e51cab 100644 --- a/llms.txt +++ b/llms.txt @@ -1,7 +1,7 @@ # json-document v2 json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 사용할 수 -있는 provider-neutral JSON 편집 Kernel이다. UI component library가 아니다. +있는 implementation-neutral JSON 편집 Kernel이다. UI component library가 아니다. 현재 package version과 표준 상태는 `2.0.0` Stable이다. Reference와 독립 implementation이 같은 conformance suite를 통과하고, 다섯 pressure vertical을 @@ -40,19 +40,22 @@ types 구현 내부 경로를 import하지 않는다. Root에 새 edit verb, schema provider object, React hook, host service를 추가하지 않는다. -## Pure Protocol과 Projection +## Stateless JSON Patch와 JSON Document ```txt -Pure Protocol - |-> local provider -----------\ - | > same six-member Document Projection - `-> collaboration provider --/ |-> optional history/text authoring - `-> optional DOM/IME lease +stateless JSON Patch + |-> local implementation -----\ + | > same six-member JSON Document + `-> collaboration engine -----/ |-> optional history/text authoring + `-> optional native-input DOM lease ``` -Pure Protocol은 JSON value, RFC 6901 JSON Pointer, RFC 9535 JSONPath, -RFC 6902 JSON Patch, Result 의미를 소유한다. Projection은 현재 snapshot과 -publication을 연결한다. +Stateless JSON Patch는 JSON value, RFC 6901 JSON Pointer, RFC 9535 JSONPath, +RFC 6902 JSON Patch, Result 의미를 소유한다. JSON Document는 현재 document +value와 change notification을 연결한다. + +Canonical concept와 stable v2 compatibility identifier의 관계는 +`docs/standard/concept-and-naming-standard.md`가 정의한다. `JSONDocument`의 필수 member는 정확히 여섯 개다. @@ -149,8 +152,8 @@ if (result.ok) { ## Acceptance -Schema provider는 Root contract가 아니다. 필요하면 작은 acceptance callback으로 -조합한다. +Schema provider는 Root contract가 아니다. 필요하면 validator를 stable v2 +`accepts` callback으로 조합한다. ```ts import { z } from "zod"; @@ -176,7 +179,7 @@ function accepts(candidate: JSONValue): JSONCapabilityResult { const document = createJSONDocument({ title: "Draft" }, { accepts }); ``` -Acceptance는 initial state와 commit candidate를 publication 전에 검사한다. +Validation은 initial state와 commit candidate를 change notification 전에 검사한다. Provider parse 결과로 candidate를 몰래 변환하지 않는다. Normalization은 applied JSON Patch에 명시한다. @@ -200,7 +203,7 @@ 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을 사용해도 +string을 위한 optional native-input DOM lease다. 두 companion을 사용해도 Core Root API와 editor가 받는 `JSONDocument` port는 바뀌지 않는다. Host adapter는 공개 Root Projection만 입력으로 받고 제품 의도를 Pointer와 @@ -229,7 +232,7 @@ indent/outdent, visible-row focus, slide selection box, grid coordinate와 같 `packages/json-document/tests/public/v2-signature-contract.test-d.ts` - Projection black-box binding: `packages/json-document/tests/public/v2-projection-standard-conformance.test.ts` -- Pure Protocol black-box binding: +- Stateless JSON Patch black-box binding (v2 Pure Protocol compatibility artifact): `packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts` Reference implementation의 우연한 동작이 profile과 conformance vector보다 높은 diff --git a/packages/contenteditable-collaboration/README.md b/packages/contenteditable-collaboration/README.md index 22ae6f65..70119360 100644 --- a/packages/contenteditable-collaboration/README.md +++ b/packages/contenteditable-collaboration/README.md @@ -1,6 +1,6 @@ # json-document-contenteditable-collaboration -IME-safe DOM publication lease for +IME-safe native-input DOM lease for `@interactive-os/json-document-collaboration/text`. The adapter binds one collaborative string pointer to one contenteditable @@ -8,6 +8,11 @@ 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. +Canonical DOM adapter와 lease vocabulary는 +[Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 +정의합니다. `CollaborationTextDOM`은 stable compatibility identifier이고 +canonical concept는 DOM adapter입니다. + ```ts import { createCollaborationTextRuntime, @@ -49,24 +54,24 @@ tail state and resynchronizes the root. - 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. + commit; a timer releases the native-input DOM lease 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. + on the next task instead of leaving DOM rendering stuck. - Remote Changes update the model immediately but cannot replace the leased DOM root. -- Tail publication rebases the local selection through remote text merges +- Tail rendering 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 +The default DOM adapter 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: +Wrappers or rich DOM can provide an adapter: ```ts const adapter = createCollaborationContentEditableAdapter({ @@ -90,7 +95,7 @@ const adapter = createCollaborationContentEditableAdapter({ }); ``` -The custom projection must use DOM/JavaScript UTF-16 offsets and must not +The custom DOM adapter must use DOM/JavaScript UTF-16 offsets and must not author document changes while rendering. ## Package boundary diff --git a/packages/contenteditable-collaboration/package.json b/packages/contenteditable-collaboration/package.json index ae5cbbe7..a4f33220 100644 --- a/packages/contenteditable-collaboration/package.json +++ b/packages/contenteditable-collaboration/package.json @@ -1,7 +1,7 @@ { "name": "@interactive-os/json-document-contenteditable-collaboration", "version": "0.1.0-rc.1", - "description": "IME-safe contenteditable publication lease for json-document collaboration text.", + "description": "IME-safe native-input DOM lease for json-document collaboration text.", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index f8cc1cf8..c811a817 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -1,9 +1,16 @@ # @interactive-os/json-document-collaboration -Transport-free causal collaboration provider for the six-member -`@interactive-os/json-document` Projection contract. +Transport-free causal collaboration engine for the six-member +`@interactive-os/json-document` JSON Document contract. -Local and collaborative providers expose the same six-member Projection API. +Local and collaborative implementations expose the same six-member +`JSONDocument` API. + +Canonical concept와 stable compatibility identifier의 관계는 +[Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 +정의합니다. `runtime.collaboration`, `CollaborationControl`과 +`CollaborationSnapshot`은 현재 API 이름이며 canonical prose에서는 각각 replica +surface와 replica status로 설명합니다. ```ts import { @@ -89,10 +96,10 @@ instead of silently rebasing an already observed DOM result. The text profile includes the same actor-local selective `history` sidecar as the history profile. A successful text commit returns the canonical `JSONAppliedChange`, including owned metadata, so editor transaction tracking -does not need a second publication protocol. +does not need a second change-notification protocol. -`runtime.collaboration.subscribe` publishes one deeply immutable snapshot when -an ingest adds causal state, even if it only changes pending/conflict metadata. +`runtime.collaboration.subscribe` publishes one deeply immutable replica status +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. @@ -146,7 +153,7 @@ 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 +insertions, text atoms, history, validation, 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 @@ -157,16 +164,16 @@ 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 +Native-input DOM 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. ## Release evidence -The ordinary package suite includes a provider-neutral task-board host that +The ordinary package suite includes an implementation-neutral task-board host that depends only on the six-member `JSONDocument`. The same host commands run -unchanged against the local and collaboration providers, including a +unchanged against the local implementation and collaboration engine, including a concurrent card edit that follows a structural move by stable identity. It also runs a small deterministic convergence soak on every verification. @@ -206,16 +213,16 @@ The implemented profile includes: - 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; +- whole-Change validation and deterministic suppression; - canonical membership, SHA-256 checkpoints, restore, and new-epoch compaction; -- valid JSON publication through the standard six-member Projection; +- valid JSON change notification through the standard six-member `JSONDocument`; - 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. +domain validation 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. diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index 684eb524..167ea744 100644 --- a/packages/json-document-collaboration/package.json +++ b/packages/json-document-collaboration/package.json @@ -1,7 +1,7 @@ { "name": "@interactive-os/json-document-collaboration", "version": "0.1.0-rc.2", - "description": "Transport-free causal collaboration provider for @interactive-os/json-document.", + "description": "Transport-free causal collaboration engine for @interactive-os/json-document.", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/packages/json-document/README.md b/packages/json-document/README.md index 4b19c09b..1d7684b6 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -1,16 +1,20 @@ # json-document -문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 provider-neutral -JSON 편집 protocol과 headless document projection입니다. +문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 +implementation-neutral JSON 편집 API와 headless JSON Document입니다. v2 root는 JSON, RFC 6901 JSON Pointer, RFC 9535 JSONPath, RFC 6902 JSON Patch만 전제로 합니다. UI framework, schema provider, history, selection, clipboard는 core 계약이 아닙니다. ```txt -Pure Protocol -> Document Projection -> host adapter +stateless JSON Patch -> JSON Document -> host adapter ``` +Canonical concept와 stable v2 identifier의 관계는 +[Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 +정의합니다. + 현재 버전은 `2.0.0`입니다. reference와 독립 구현이 같은 conformance suite를 통과했고, form·table/data-grid·outliner/tree·rich text·storage/collaboration pressure gate까지 검증한 Stable profile입니다. @@ -58,12 +62,12 @@ Document의 필수 member는 여섯 개뿐입니다. | Member | 책임 | | --- | --- | -| `value` | immutable current snapshot | +| `value` | immutable current document value | | `at(pointer)` | 정확한 JSON Pointer 한 곳 읽기 | | `query(jsonPath)` | JSONPath를 Pointer 배열로 환원 | -| `canPatch(operations)` | state를 바꾸지 않는 동일 의미 probe | +| `canPatch(operations)` | state를 바꾸지 않는 patch validation | | `commit(operations, options?)` | 유일한 stateful mutation | -| `subscribe(listener)` | publish된 change 구독 | +| `subscribe(listener)` | change notification 구독 | subscriber는 state가 commit된 뒤 호출된다. 한 subscriber의 예외는 `commit` 밖으로 전파되거나 성공 result를 바꾸지 않으며, 뒤 subscriber의 전달도 막지 @@ -73,11 +77,11 @@ subscriber는 state가 commit된 뒤 호출된다. 한 subscriber의 예외는 ` 새 error code와 optional field가 추가될 수 있으므로 consumer는 exact key 집합에 의존하지 않아야 합니다. -## Schema acceptance +## Validation -Core는 특정 schema object를 받지 않습니다. provider를 작은 acceptance 함수로 -연결합니다. 반환된 parse value를 받지 않으므로 commit-time transform이 state에 -몰래 들어갈 수 없습니다. +Core는 특정 schema object를 받지 않습니다. Validator를 stable v2 `accepts` +callback으로 연결합니다. 반환된 parse value를 받지 않으므로 commit-time +transform이 state에 몰래 들어갈 수 없습니다. ```ts import { z } from "zod"; @@ -105,7 +109,7 @@ const document = createJSONDocument( ); ``` -initial value와 patch payload, metadata, published snapshot/change는 document가 +Initial value와 patch payload, metadata, exposed document value/change는 document가 소유합니다. caller reference나 subscriber가 committed state를 우회해 바꿀 수 없습니다. diff --git a/packages/json-document/package.json b/packages/json-document/package.json index 88e12d98..66d913c6 100644 --- a/packages/json-document/package.json +++ b/packages/json-document/package.json @@ -1,7 +1,7 @@ { "name": "@interactive-os/json-document", "version": "2.0.0", - "description": "Provider-neutral JSON editing protocol and headless document projection.", + "description": "Implementation-neutral JSON Patch API and headless JSON Document.", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/scripts/evaluate-docs.mjs b/scripts/evaluate-docs.mjs index de36e3e6..8a1af75f 100644 --- a/scripts/evaluate-docs.mjs +++ b/scripts/evaluate-docs.mjs @@ -37,8 +37,11 @@ const publicDocs = { quickstart: read("docs/public/quickstart.md"), api: read("docs/public/api.md"), }; +const docsReadme = read("docs/README.md"); +const namingStandard = read("docs/standard/concept-and-naming-standard.md"); const surfaces = { rootReadme: read("README.md"), + docsReadme, packageReadme: read("packages/json-document/README.md"), collaborationReadme: read("packages/json-document-collaboration/README.md"), contenteditableCollaborationReadme: read( @@ -94,10 +97,11 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ } if (JSON.stringify(fileNames("docs/standard")) !== JSON.stringify([ + "concept-and-naming-standard.md", "v2-projection-profile.md", "v2-public-surface.json", ])) { - fail("docs/standard: only the v2 profile and machine-readable surface may remain active."); + fail("docs/standard: naming SSOT, v2 profile, and machine-readable surface must be the only active standards."); } if (JSON.stringify(fileNames("docs/generated")) !== JSON.stringify([ @@ -200,6 +204,7 @@ for (const [name, source] of Object.entries({ } const required = [ + ["rootReadme", surfaces.rootReadme, /docs\/standard\/concept-and-naming-standard\.md/], ["rootReadme", surfaces.rootReadme, /## 문서 지도/], ["rootReadme", surfaces.rootReadme, /docs\/public\/overview\.md/], ["rootReadme", surfaces.rootReadme, /docs\/public\/api\.md/], @@ -210,6 +215,7 @@ const required = [ ["rootReadme", surfaces.rootReadme, /@interactive-os\/editable/], ["overview", surfaces.overview, /## 배경/], ["overview", surfaces.overview, /## 핵심 개념/], + ["overview", surfaces.overview, /Concept and Naming Standard/], ["overview", surfaces.overview, /검색: JSONPath -> Pointer\[\]/], ["overview", surfaces.overview, /## Host adapter와 companion/], ["overview", surfaces.overview, /@interactive-os\/json-document-collaboration/], @@ -225,9 +231,11 @@ const required = [ ["api", surfaces.api, /## Host와 adapter/], ["packageReadme", surfaces.packageReadme, /npm install @interactive-os\/json-document@2\.0\.0/], ["packageReadme", surfaces.packageReadme, /패키지는 `\/session`이나 `\/react` subpath를\s*공개하지 않습니다/], - ["collaborationReadme", surfaces.collaborationReadme, /same six-member Projection API/], + ["collaborationReadme", surfaces.collaborationReadme, /same six-member\s+`JSONDocument` API/], + ["collaborationReadme", surfaces.collaborationReadme, /Concept and Naming Standard/], ["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, /IME-safe native-input DOM lease/], + ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /Concept and Naming Standard/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /does not activate or depend on the archived 1\.x DOM adapters/], ["llms", surfaces.llms, /2\.0\.0.*Stable/], ["llms", surfaces.llms, /공개 Root는 정확히 다음 20개 symbol/], @@ -237,12 +245,94 @@ const required = [ ["llms", surfaces.llms, /@interactive-os\/editable/], ["profile", profile, /root entrypoint 하나와 20개 Kernel symbol/], ["profile", profile, /Acceptance callback[\s\S]*`canPatch`[\s\S]*`commit`/], + ["profile", profile, /Concept and Naming Standard/], + ["docsReadme", docsReadme, /concept-and-naming-standard\.md/], + ["namingStandard", namingStandard, /상태: Canonical/], + ["namingStandard", namingStandard, /## 이름 권위/], + ["namingStandard", namingStandard, /## 개념 경계/], + ["namingStandard", namingStandard, /## 접두어와 casing/], + ["namingStandard", namingStandard, /## 접미어/], + ["namingStandard", namingStandard, /## Operation과 Change/], + ["namingStandard", namingStandard, /## 함수 동사/], + ["namingStandard", namingStandard, /## Boolean/], + ["namingStandard", namingStandard, /## Collection과 축약/], + ["namingStandard", namingStandard, /## 현재 이름 평가/], + ["namingStandard", namingStandard, /## v2 compatibility map/], + ["namingStandard", namingStandard, /## 새 concept admission/], + ["namingStandard", namingStandard, /runtime logic, protocol semantics 또는 wire behavior/], ]; for (const [name, source, pattern] of required) { requirePattern(name, source, pattern); } +for (const [name, source] of Object.entries({ + rootReadme: surfaces.rootReadme, + overview: surfaces.overview, + packageReadme: surfaces.packageReadme, + siteHome, +})) { + for (const deprecatedConcept of [ + "Pure Protocol", + "Document Projection", + "document projection", + "local provider", + "collaboration provider", + "DOM publication lease", + ]) { + if (source.includes(deprecatedConcept)) { + fail(`${name}: deprecated canonical concept remains: ${deprecatedConcept}.`); + } + } +} + +for (const term of [ + "JSON value", + "JSON Pointer", + "JSONPath", + "JSON Patch", + "JSON Document", + "patch validation", + "change notification", + "collaboration engine", + "replica status", + "checkpoint", + "compaction", + "selective undo", + "text splice", + "DOM adapter", + "native-input DOM lease", +]) { + if (!namingStandard.includes(term)) { + fail(`naming standard: missing canonical term ${term}.`); + } +} + +for (const restrictedSuffix of [ + "Control", + "Manager", + "Helper", + "Util", + "Common", + "Misc", + "Data", + "Info", +]) { + if (!namingStandard.includes(restrictedSuffix)) { + fail(`naming standard: missing restricted suffix ${restrictedSuffix}.`); + } +} + +for (const packageEntry of generatedCatalog.packages ?? []) { + for (const publicExport of packageEntry.publicExports ?? []) { + if (!namingStandard.includes(`\`${publicExport}\``)) { + fail( + `naming standard: ${packageEntry.name} public export is unclassified: ${publicExport}.`, + ); + } + } +} + if ( JSON.stringify(Object.keys(publicContract)) !== JSON.stringify(["root"]) || JSON.stringify(publicContract.root.values) !== JSON.stringify(expectedPublicValues) @@ -336,8 +426,8 @@ for (const pattern of [ } for (const pattern of [ - /Provider-neutral JSON editing/, - /six-member document projection/, + /Implementation-neutral JSON editing/, + /six-member JSON Document/, /npm install @interactive-os\/json-document@2\.0\.0/, /Rich editing belongs to host adapters/, ]) { From 682234a0f70cdbbca425168eeb342f2443960480 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: Thu, 30 Jul 2026 12:38:46 +0900 Subject: [PATCH 2/5] refactor: apply canonical naming repository-wide --- README.md | 6 +- apps/site/index.html | 6 +- apps/site/src/routes/Home.tsx | 6 +- apps/site/src/site-routes.json | 2 +- apps/site/tests/docs-consistency.test.ts | 11 +- docs/generated/repo-catalog.json | 22 +- .../245-concept-dictionary-naming-ssot.md | 30 ++ docs/public/api.md | 41 +-- docs/public/overview.md | 16 +- docs/public/quickstart.md | 20 +- docs/standard/concept-and-naming-standard.md | 135 +++++--- docs/standard/v2-projection-profile.md | 22 +- docs/standard/v2-public-surface.json | 6 +- llms.txt | 34 +- .../contenteditable-collaboration/README.md | 16 +- .../src/dom/{plainText.ts => plain-text.ts} | 22 +- .../src/index.ts | 15 +- .../src/lease.ts | 117 +++---- .../src/types.ts | 51 ++- .../contenteditable-collaboration.test.ts | 72 ++--- .../tests/naming-compatibility.test.ts | 28 ++ .../json-document-collaboration/README.md | 47 +-- .../src/{arrayIndex.ts => array-index.ts} | 0 .../json-document-collaboration/src/change.ts | 4 +- .../src/compact.ts | 53 ++-- .../json-document-collaboration/src/create.ts | 292 ++++++++++-------- .../src/history-index.ts | 15 +- .../json-document-collaboration/src/index.ts | 3 + .../src/{jsonEqual.ts => json-equal.ts} | 0 .../src/materialize.ts | 120 +++---- .../src/restore.ts | 36 ++- .../src/text-index.ts | 23 +- .../src/translate.ts | 2 +- .../json-document-collaboration/src/types.ts | 161 +++++++--- .../tests/checkpoint.test.ts | 144 ++++----- .../tests/collaboration.test.ts | 258 ++++++++-------- .../tests/convergence-soak.test.ts | 74 ++--- .../tests/foundation-parity.test.ts | 4 +- .../tests/history.test.ts | 116 +++---- .../tests/naming-compatibility.test.ts | 54 ++++ .../tests/provider-substitution.test.ts | 12 +- .../tests/support/task-board-host.ts | 6 +- .../tests/text-wire.test.ts | 76 ++--- .../tests/text.test.ts | 60 ++-- packages/json-document/README.md | 20 +- packages/json-document/public-contract.json | 2 + .../src/application/document/contract.ts | 15 +- .../src/application/document/create.ts | 17 +- .../src/application/document/index.ts | 2 + .../src/application/document/protocol.ts | 2 +- .../{projection => json-document}/create.ts | 84 ++--- .../{projection => json-document}/index.ts | 2 +- .../src/foundation/json/clone.ts | 2 +- .../src/foundation/json/index.ts | 2 +- .../{trustedClone.ts => trusted-clone.ts} | 0 .../src/foundation/patch/container.ts | 2 +- .../src/foundation/patch/fast/apply.ts | 4 +- .../src/foundation/patch/fast/array.ts | 4 +- .../src/foundation/patch/fast/replace.ts | 2 +- .../src/foundation/patch/path.ts | 2 +- .../{replaceValue.ts => replace-value.ts} | 2 +- ...entialReplace.ts => sequential-replace.ts} | 2 +- .../src/foundation/patch/track.ts | 2 +- .../src/foundation/patch/trusted.ts | 10 +- .../src/foundation/patch/value.ts | 2 +- .../pointer/{arrayIndex.ts => array-index.ts} | 0 .../src/foundation/pointer/core.ts | 2 +- .../src/foundation/protocol/apply.ts | 14 +- .../src/foundation/protocol/contract.ts | 5 +- .../src/foundation/protocol/index.ts | 1 + .../foundation/durability-parity.test.ts | 4 +- .../tests/public/naming-compatibility.test.ts | 27 ++ .../public/v2-signature-contract.test-d.ts | 20 +- .../tests/smoke/package-smoke.mjs | 10 +- scripts/evaluate-docs.mjs | 102 +++++- scripts/evaluate-json-document-layers.mjs | 2 +- scripts/evaluate-live-site.mjs | 2 +- scripts/evaluate-standardization.mjs | 37 ++- 78 files changed, 1594 insertions(+), 1050 deletions(-) rename packages/contenteditable-collaboration/src/dom/{plainText.ts => plain-text.ts} (92%) create mode 100644 packages/contenteditable-collaboration/tests/naming-compatibility.test.ts rename packages/json-document-collaboration/src/{arrayIndex.ts => array-index.ts} (100%) rename packages/json-document-collaboration/src/{jsonEqual.ts => json-equal.ts} (100%) create mode 100644 packages/json-document-collaboration/tests/naming-compatibility.test.ts rename packages/json-document/src/domain/{projection => json-document}/create.ts (75%) rename packages/json-document/src/domain/{projection => json-document}/index.ts (88%) rename packages/json-document/src/foundation/json/{trustedClone.ts => trusted-clone.ts} (100%) rename packages/json-document/src/foundation/patch/{replaceValue.ts => replace-value.ts} (94%) rename packages/json-document/src/foundation/patch/{sequentialReplace.ts => sequential-replace.ts} (98%) rename packages/json-document/src/foundation/pointer/{arrayIndex.ts => array-index.ts} (100%) create mode 100644 packages/json-document/tests/public/naming-compatibility.test.ts diff --git a/README.md b/README.md index d0b7ab41..a77e2b13 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ selection, clipboard, history를 필수 계약에 넣지 않습니다. ```txt stateless JSON Patch |-> local implementation -----\ - | > same six-member JSON Document + | > same seven-member JSON Document `-> collaboration engine -----/ |-> optional history/text authoring `-> optional native-input DOM lease ``` @@ -47,7 +47,7 @@ stateless JSON Patch 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` 위에서 조합합니다. +lifecycle은 host adapter가 일곱-member `JSONDocument` 위에서 조합합니다. 일반 DOM과 Input Events 정규화가 필요한 제품은 별도 수명 주기의 `@interactive-os/editable`도 검토할 수 있습니다. @@ -57,7 +57,7 @@ v2 Kernel이 제공하는 최소 계약: - immutable document value - JSON Pointer read와 JSONPath query -- state를 바꾸지 않는 `canPatch` +- state를 바꾸지 않는 `validatePatch` - ordered atomic JSON Patch commit - canonical applied change notification diff --git a/apps/site/index.html b/apps/site/index.html index 9b72cf92..02bd398b 100644 --- a/apps/site/index.html +++ b/apps/site/index.html @@ -5,7 +5,7 @@ @@ -13,14 +13,14 @@ diff --git a/apps/site/src/routes/Home.tsx b/apps/site/src/routes/Home.tsx index 68cccd16..261666d1 100644 --- a/apps/site/src/routes/Home.tsx +++ b/apps/site/src/routes/Home.tsx @@ -1,6 +1,6 @@ const modelRows = [ ["patch", "stateless JSON Patch application"], - ["document", "value, at, query, canPatch, commit, subscribe"], + ["document", "value, at, query, validatePatch, commit, subscribe"], ["validation", "optional implementation-neutral candidate validation"], ["host", "selection, history, rendering, focus, persistence, collaboration"], ] as const; @@ -24,7 +24,7 @@ export function Home() { json-document

- A headless JSON API and six-member JSON Document for + A headless JSON API and seven-member JSON Document for documents, tables, slides, canvases, and notes.

@@ -79,7 +79,7 @@ export function Home() {

Boundary

  • Root Kernel is React- and Zod-free.
  • -
  • Rich editing belongs to host adapters composed over the six-member document.
  • +
  • Rich editing belongs to host adapters composed over the seven-member document.
  • Mutation inputs are JSON Patch with JSON Pointer paths.
  • JSONPath is search-only and returns pointers.
diff --git a/apps/site/src/site-routes.json b/apps/site/src/site-routes.json index 0edca835..e3e18469 100644 --- a/apps/site/src/site-routes.json +++ b/apps/site/src/site-routes.json @@ -3,7 +3,7 @@ "path": "/", "label": "Overview", "title": "json-document - Headless JSON editing", - "description": "Implementation-neutral JSON editing and a six-member headless JSON Document for JSON Patch, JSON Pointer, and JSONPath.", + "description": "Implementation-neutral JSON editing and a seven-member headless JSON Document for JSON Patch, JSON Pointer, and JSONPath.", "group": "Start" }, { diff --git a/apps/site/tests/docs-consistency.test.ts b/apps/site/tests/docs-consistency.test.ts index 7415560c..ad6a7335 100644 --- a/apps/site/tests/docs-consistency.test.ts +++ b/apps/site/tests/docs-consistency.test.ts @@ -203,17 +203,26 @@ describe("public docs consistency", () => { "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", + "JSONDocumentOptions", "JSONDocumentCommitOptions", "JSONDocumentCommitResult", "JSONPatchOperation", "JSONPatchResult", + "JSONPatchValidationResult", "JSONValue", "Pointer", "QueryResult", "ReadResult", ]); - for (const member of ["value", "at", "query", "canPatch", "commit", "subscribe"]) { + for (const member of [ + "value", + "at", + "query", + "validatePatch", + "commit", + "subscribe", + ]) { expect(docs.api).toContain(member); } expect(docs.api).toMatch(/applyPatch[\s\S]*RFC 6902/); diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index a369467d..edae8290 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -26,8 +26,10 @@ "JSONDocument", "JSONDocumentCommitOptions", "JSONDocumentCommitResult", + "JSONDocumentOptions", "JSONPatchOperation", "JSONPatchResult", + "JSONPatchValidationResult", "JSONValue", "Pointer", "QueryResult", @@ -41,7 +43,7 @@ "trackPointer", "tryParsePointer" ], - "publicExportCount": 20, + "publicExportCount": 22, "keywords": [ "document", "editor", @@ -62,7 +64,7 @@ "version": "0.1.0-rc.2", "description": "Transport-free causal collaboration engine for @interactive-os/json-document.", "license": "MIT", - "summary": "Transport-free causal collaboration engine for the six-member\n`@interactive-os/json-document` JSON Document contract.", + "summary": "Transport-free causal collaboration engine for the seven-member\n`@interactive-os/json-document` JSON Document contract.", "entrypoints": [ ".", "./history", @@ -89,18 +91,21 @@ "CollaborationIngestSuccess", "CollaborationMember", "CollaborationMembership", + "CollaborationReplica", "CollaborationRestoreOptions", "CollaborationRestoreResult", "CollaborationRulesetIdentity", "CollaborationRuntime", "CollaborationRuntimeOptions", "CollaborationSnapshot", + "CollaborationValidation", "ContainerNodeId", "MemberId", "MemberPlacement", "ObjectPlacement", "PendingChange", "PositionId", + "ReplicaStatus", "SemanticOperation", "SuppressedChange", "TextAtomId", @@ -110,7 +115,7 @@ "createCollaborationRuntime", "restoreCollaborationRuntime" ], - "publicExportCount": 40, + "publicExportCount": 43, "keywords": [] }, { @@ -132,10 +137,17 @@ "CollaborationContentEditableResult", "CollaborationTextDOM", "CollaborationTextDOMObservation", + "ContentEditableAdapter", + "ContentEditableOptions", + "ContentEditableResult", + "DOMObservation", + "TextDOMAdapter", "createCollaborationContentEditableAdapter", - "plainTextCollaborationDOM" + "createContentEditableAdapter", + "plainTextCollaborationDOM", + "plainTextDOMAdapter" ], - "publicExportCount": 7, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "collaboration", diff --git a/docs/issues/245-concept-dictionary-naming-ssot.md b/docs/issues/245-concept-dictionary-naming-ssot.md index 4ade5858..e1908034 100644 --- a/docs/issues/245-concept-dictionary-naming-ssot.md +++ b/docs/issues/245-concept-dictionary-naming-ssot.md @@ -16,6 +16,36 @@ requires an explicit, append-only contract-delta comment with the user's authorization and reason. Context summaries and implementation plans are not scope authority; after context loss, reread this issue and the current diff. +## Contract delta — repository-wide application + +Authorized by the user on 2026-07-30: “응 이거 전수 적용이 goal이야.” + +Reason: the original Done condition classified current names and established the +canonical vocabulary, but did not require the implementation, paths, tests, and +public surfaces to use that vocabulary. The user clarified that repository-wide +application—not classification alone—is the intended outcome. + +### Before + +- Outcome: establish the durable concept and naming source of truth. +- Done: publish the dictionary and grammar, classify public concepts, map v2 + compatibility names, and make future names mechanically evaluable. +- Don't: do not change stable v2 public API names. + +### After + +- Outcome: apply the canonical concept dictionary and naming grammar throughout + public APIs, internal identifiers, file and folder names, tests, documentation, + site copy, and package metadata. +- Done: every in-scope repository name either uses the canonical vocabulary or is + an explicitly marked compatibility alias; automated checks reject reintroduction + of superseded vocabulary; the complete test and build surface proves behavior + unchanged. +- Don't: do not change runtime behavior, protocol semantics, wire formats, or + package boundaries. Preserve existing stable v2 consumers through explicit + compatibility aliases and a documented migration path rather than silently + removing their identifiers. + ## Why The repository currently has a sound implementation model, but its vocabulary is diff --git a/docs/public/api.md b/docs/public/api.md index cab95b9a..13b6e294 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -7,7 +7,7 @@ |-- createJSONDocument |-- applyPatch |-- Pointer helpers -`-- six-member JSONDocument +`-- seven-member JSONDocument ``` ## 기준 @@ -16,7 +16,7 @@ - Core state, patch payload와 metadata는 JSON data입니다. - 정확한 주소와 mutation target은 JSON Pointer입니다. - JSONPath는 query 전용이며 결과를 Pointer 배열로 돌려줍니다. -- `canPatch`는 side effect 없는 probe이고 `commit`만 state를 변경합니다. +- `validatePatch`는 side effect 없는 validation이고 `commit`만 state를 변경합니다. - 실패는 boolean `ok`와 stable string `code`를 가진 Result입니다. ## 작업별 진입점 @@ -26,7 +26,7 @@ | 현재 snapshot 읽기 | `document.value` | immutable JSON value | | 한 위치 읽기 | `document.at(pointer)` | `ReadResult` | | 여러 위치 찾기 | `document.query(jsonPath)` | `QueryResult` | -| patch 가능성 확인 | `document.canPatch(operations)` | `JSONCapabilityResult` | +| patch 가능성 확인 | `document.validatePatch(operations)` | `JSONPatchValidationResult` | | state 변경 | `document.commit(operations, options?)` | `JSONDocumentCommitResult` | | 변경 구독 | `document.subscribe(listener)` | unsubscribe function | | instance 없는 patch | `applyPatch(value, operations)` | `JSONPatchResult` | @@ -68,9 +68,9 @@ interface JSONDocument { readonly value: JSONValue; at(pointer: Pointer): ReadResult; query(jsonPath: string): QueryResult; - canPatch( + validatePatch( operations: ReadonlyArray, - ): JSONCapabilityResult; + ): JSONPatchValidationResult; commit( operations: ReadonlyArray, options?: JSONDocumentCommitOptions, @@ -189,7 +189,7 @@ if (result.ok) { `change.applied`는 `/-`를 concrete index로 바꾸고 RFC operation field만 남긴 canonical sequence입니다. -## canPatch와 commit +## validatePatch와 commit 두 method는 같은 JSON, Pointer, Patch와 validation 의미를 사용합니다. @@ -198,9 +198,9 @@ const operations = [ { op: "replace", path: "/title", value: "Ready" }, ] as const; -const capability = document.canPatch(operations); +const validation = document.validatePatch(operations); -if (capability.ok) { +if (validation.ok) { const committed = document.commit(operations, { metadata: { origin: "title-field", @@ -215,7 +215,7 @@ if (capability.ok) { } ``` -`canPatch`는 state와 subscriber를 바꾸지 않습니다. `commit`은 batch 전체를 +`validatePatch`는 state와 subscriber를 바꾸지 않습니다. `commit`은 batch 전체를 동기적·원자적으로 적용합니다. 실패와 state-equivalent no-op은 change notification을 만들지 않습니다. @@ -237,14 +237,15 @@ Subscriber는 이미 publish된 `JSONAppliedChange`를 받습니다. Unsubscribe ## Validation -`createJSONDocument`의 stable v2 `accepts` option은 특정 schema object를 -요구하지 않는 implementation-neutral validation boundary입니다. +`createJSONDocument`의 canonical `validate` option은 특정 schema object를 +요구하지 않는 implementation-neutral validation boundary입니다. Stable v2 +`accepts`는 deprecated compatibility alias입니다. ```ts import * as z from "zod"; import { createJSONDocument, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONValue, } from "@interactive-os/json-document"; @@ -252,7 +253,7 @@ const Schema = z.object({ title: z.string().min(1), }); -function accepts(candidate: JSONValue): JSONCapabilityResult { +function validate(candidate: JSONValue): JSONPatchValidationResult { const parsed = Schema.safeParse(candidate); return parsed.success ? { ok: true } @@ -265,7 +266,7 @@ function accepts(candidate: JSONValue): JSONCapabilityResult { const acceptedDocument = createJSONDocument( { title: "Draft" }, - { accepts }, + { validate }, ); ``` @@ -273,8 +274,9 @@ Initial state와 commit candidate 모두 commit notification 전에 검사됩니 parse한 변환값은 Core state로 채택되지 않으며, normalization이 필요하면 그 변경을 JSON Patch에 명시합니다. -Canonical concept는 validation이고 `accepts`와 `JSONCapabilityResult`는 stable -v2 compatibility identifier입니다. Naming 기준과 vNext 후보는 +Canonical concept와 result는 validation과 `JSONPatchValidationResult`입니다. +`accepts`와 `JSONCapabilityResult`는 deprecated stable v2 compatibility +identifier입니다. Naming 기준은 [Concept and Naming Standard](https://github.com/developer-1px/json-document/blob/main/docs/standard/concept-and-naming-standard.md)를 따릅니다. @@ -296,7 +298,7 @@ exact key 집합이나 exhaustive code union에 의존하지 않습니다. ## 공개 root -Root는 20개 Kernel symbol만 공개합니다. +Root는 22개 public symbol만 공개합니다. ```txt values @@ -307,7 +309,8 @@ values types JSONValue, Pointer, JSONPatchOperation JSONAppliedChange, JSONPatchResult, JSONDocumentCommitResult - JSONCapabilityResult, JSONChangeMetadata, JSONDocumentCommitOptions + JSONPatchValidationResult, JSONCapabilityResult, JSONChangeMetadata + JSONDocumentOptions, JSONDocumentCommitOptions ReadResult, QueryResult, JSONDocument ``` @@ -315,4 +318,4 @@ types 패키지는 `/session`이나 `/react` subpath를 공개하지 않습니다. Selection, clipboard, history, schema introspection, DOM lifecycle과 framework binding은 -host 또는 별도 adapter가 여섯-member `JSONDocument`를 조합해 구현합니다. +host 또는 별도 adapter가 일곱-member `JSONDocument`를 조합해 구현합니다. diff --git a/docs/public/overview.md b/docs/public/overview.md index 8ab5dbb2..3f484b67 100644 --- a/docs/public/overview.md +++ b/docs/public/overview.md @@ -8,7 +8,7 @@ React, selection, clipboard, history를 필수 계약에 넣지 않습니다. ```txt stateless JSON Patch |-> local implementation -----\ - | > same six-member JSON Document + | > same seven-member JSON Document `-> collaboration engine -----/ |-> optional history/text authoring `-> optional native-input DOM lease ``` @@ -40,7 +40,7 @@ CRDT와 OT는 host 또는 adapter 책임입니다. | JSONPath | 여러 위치를 찾는 query. 결과는 Pointer 목록 | | JSON Patch | ordered atomic mutation 형식 | | Stateless JSON Patch | 현재 document instance 없이 JSON Patch를 적용하는 함수 | -| JSON Document | 현재 document value에 read, validation, commit, notification을 연결한 여섯-member port | +| JSON Document | 현재 document value에 read, validation, commit, notification을 연결한 일곱-member port | | validation | Candidate document를 commit 전에 검사하는 implementation-neutral callback | | Host adapter | selection, clipboard, history, DOM과 고수준 편집 동사를 소유하는 별도 계층 | @@ -48,8 +48,8 @@ CRDT와 OT는 host 또는 adapter 책임입니다. compatibility map은 [Concept and Naming Standard](https://github.com/developer-1px/json-document/blob/main/docs/standard/concept-and-naming-standard.md)가 정의합니다. `Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 -stable v2 identifier 또는 compatibility label이며 별도 canonical concept가 -아닙니다. +deprecated stable v2 identifier 또는 compatibility label이며 별도 canonical +concept가 아닙니다. 가장 중요한 경계는 query와 mutation을 섞지 않는 것입니다. @@ -75,7 +75,7 @@ const patch = [ { op: "replace", path: "/status", value: "doing" }, ] as const; -if (document.canPatch(patch).ok) { +if (document.validatePatch(patch).ok) { const result = document.commit(patch, { metadata: { origin: "status-control" }, }); @@ -93,7 +93,7 @@ if (document.canPatch(patch).ok) { value at query -canPatch +validatePatch commit subscribe ``` @@ -118,7 +118,7 @@ if (result.ok) { | 표면 | 상태 | 책임 | | --- | --- | --- | -| `@interactive-os/json-document` | v2 Kernel | Stateless JSON Patch와 여섯-member JSON Document | +| `@interactive-os/json-document` | v2 Kernel | Stateless JSON Patch와 일곱-member JSON Document | | `@interactive-os/json-document-collaboration` | optional companion | 같은 JSON Document 뒤의 transport-free causal engine | | `@interactive-os/json-document-contenteditable-collaboration` | optional companion | collaborative string의 native-input DOM lease | @@ -148,7 +148,7 @@ contenteditable lifecycle을 소유하며, 문서별 의미는 adapter가 연결 | --- | --- | | 현재 값 읽기 | `document.value`, `document.at(pointer)` | | 여러 위치 찾기 | `document.query(jsonPath)` | -| 변경 가능성 확인 | `document.canPatch(operations)` | +| 변경 가능성 확인 | `document.validatePatch(operations)` | | 상태 변경 | `document.commit(operations, options?)` | | 변경 구독 | `document.subscribe(listener)` | | instance 없는 patch 적용 | `applyPatch(value, operations)` | diff --git a/docs/public/quickstart.md b/docs/public/quickstart.md index 36a74e70..38126e57 100644 --- a/docs/public/quickstart.md +++ b/docs/public/quickstart.md @@ -1,6 +1,6 @@ # 튜토리얼: 작은 카드 편집기 만들기 -작은 board state를 여섯-member v2 Core로 읽고, 검증하고, 변경하고, 구독합니다. +작은 board state를 일곱-member Core로 읽고, 검증하고, 변경하고, 구독합니다. 루트 package에는 schema provider나 UI framework가 필요하지 않습니다. ## 1. JSON document 만들기 @@ -58,9 +58,9 @@ const operations = [{ value: "doing", }] as const; -const capability = document.canPatch(operations); +const validation = document.validatePatch(operations); -if (capability.ok) { +if (validation.ok) { const result = document.commit(operations, { metadata: { origin: "card-status", @@ -75,7 +75,7 @@ if (capability.ok) { } ``` -`canPatch`는 state와 subscriber를 바꾸지 않습니다. `commit`은 ordered batch +`validatePatch`는 state와 subscriber를 바꾸지 않습니다. `commit`은 ordered batch 전체를 적용하거나 아무것도 적용하지 않습니다. ## 4. 변경 구독하기 @@ -127,15 +127,15 @@ if (preview.ok) { ## 6. 선택한 validator 연결하기 -Core는 Zod를 요구하지 않습니다. 어떤 validator든 stable v2 `accepts` -callback으로 연결할 수 있습니다. Canonical concept는 validation이며 -`accepts`는 compatibility identifier입니다. +Core는 Zod를 요구하지 않습니다. 어떤 validator든 canonical `validate` +callback으로 연결할 수 있습니다. Stable v2 `accepts`는 deprecated +compatibility identifier입니다. ```ts import * as z from "zod"; import { createJSONDocument, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONValue, } from "@interactive-os/json-document"; @@ -151,7 +151,7 @@ const Board = z.object({ })), }); -function acceptsBoard(candidate: JSONValue): JSONCapabilityResult { +function validateBoard(candidate: JSONValue): JSONPatchValidationResult { const result = Board.safeParse(candidate); return result.success ? { ok: true } @@ -163,7 +163,7 @@ function acceptsBoard(candidate: JSONValue): JSONCapabilityResult { } const acceptedDocument = createJSONDocument(initialBoard, { - accepts: acceptsBoard, + validate: validateBoard, }); ``` diff --git a/docs/standard/concept-and-naming-standard.md b/docs/standard/concept-and-naming-standard.md index 852b7e5d..17cb84c9 100644 --- a/docs/standard/concept-and-naming-standard.md +++ b/docs/standard/concept-and-naming-standard.md @@ -74,9 +74,9 @@ publication -> change notification ``` -`Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 stable v2 -compatibility identifier로 남는다. Canonical prose에서 새 concept를 만들지는 -않는다. +`Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 deprecated stable +v2 compatibility identifier로만 남는다. Canonical code는 `JSONDocument`, +`validatePatch`, `JSONPatchValidationResult`, `validate`를 사용한다. ### Collaboration @@ -99,8 +99,8 @@ compatibility identifier로 남는다. Canonical prose에서 새 concept를 만 `provider`는 실제 network, storage, schema 또는 host provider에만 쓴다. Transport를 소유하지 않는 collaboration package는 **collaboration engine**이다. -`CollaborationSnapshot`은 stable current identifier지만 canonical concept는 -**replica status**다. Snapshot이나 checkpoint로 설명하지 않는다. +`CollaborationSnapshot`은 deprecated compatibility alias다. Canonical type은 +`ReplicaStatus`이며 snapshot이나 checkpoint로 설명하지 않는다. ### Lifecycle @@ -351,14 +351,13 @@ didChangeDocument `ok`는 Result discriminant이므로 유지한다. `active`, `available`, `valid`, `accepted`, `changed` 같은 무접두 public boolean은 새로 만들지 않는다. -Canonical replacement candidate: +Canonical replacement: ```text -projectionChanged -> didChangeDocument +projectionChanged (deprecated alias) -> didChangeDocument ``` -Candidate는 vNext migration 방향이며 이 문서만으로 public rename이 승인되지는 -않는다. +Canonical field와 compatibility field는 같은 boolean을 반환한다. ## Collection과 축약 @@ -386,6 +385,18 @@ JSON, DOM, URI, URL, IME Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. +## 파일과 경로 + +- TypeScript module file과 folder는 `kebab-case`를 사용한다. +- React component처럼 platform convention이 책임을 나타내는 파일은 + `PascalCase`를 허용한다. +- Package entrypoint는 export subpath와 맞춘 `index.ts` 또는 + `-index.ts`를 사용한다. +- Compatibility profile, vector, fixture와 test file은 호환 대상의 기존 이름을 + 포함할 수 있지만 경로 자체가 compatibility artifact임을 드러내야 한다. +- Canonical domain path는 `domain/json-document`다. + `domain/projection`은 사용하지 않는다. + ## 현재 이름 평가 | Current name | 책임 | Match | 모호성 | 결정 | Canonical 또는 proposed name | @@ -393,22 +404,22 @@ Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. | `JSONDocument` | Stateful document API | Exact domain | 없음 | keep | `JSONDocument` | | Document Projection | 같은 stateful document API | Weak/local | DB projection과 충돌 | merge | JSON Document | | Pure Protocol | Stateless Patch application | Weak/local | Wire protocol과 충돌 | merge | stateless JSON Patch | -| `canPatch` | Patch와 candidate validation | Near | Capability/probe synonym | vNext rename | `validatePatch` | -| `JSONCapabilityResult` | Patch validation result | None | 지나치게 넓음 | vNext rename | `JSONPatchValidationResult` | -| `accepts` | Candidate validator | Near | Boolean처럼 보이나 Result 반환 | vNext rename | `validate` | +| `canPatch` | Patch와 candidate validation | Near | Capability/probe synonym | compatibility alias | `validatePatch` | +| `JSONCapabilityResult` | Patch validation result | None | 지나치게 넓음 | compatibility alias | `JSONPatchValidationResult` | +| `accepts` | Candidate validator | Near | Boolean처럼 보이나 Result 반환 | compatibility alias | `validate` | | acceptance | Candidate validation | Near | Validation과 중복 | merge | validation | | publication | Subscriber notification | Near | Process/mechanism 모호 | merge | change notification | | `JSONAppliedChange` | Applied canonical operations | Exact | 없음 | keep | `JSONAppliedChange` | | collaboration provider | Transport-free causal engine | Mismatch | Provider는 connector를 암시 | rename prose | collaboration engine | -| `CollaborationControl` | Replica status/sync/checkpoint API | None | Vague responsibility | vNext rename | `CollaborationReplica` | -| `runtime.collaboration` | Replica surface | Near | Package 이름이지 역할이 아님 | vNext rename | `runtime.replica` | -| `CollaborationSnapshot` | Current causal diagnostics | Mismatch | Snapshot/checkpoint 충돌 | vNext rename | `ReplicaStatus` | -| `current()` | Replica status 반환 | Weak | Return 의미가 숨음 | vNext rename | `status()` | +| `CollaborationControl` | Replica status/sync/checkpoint API | None | Vague responsibility | compatibility alias | `CollaborationReplica` | +| `runtime.collaboration` | Replica surface | Near | Package 이름이지 역할이 아님 | compatibility alias | `runtime.replica` | +| `CollaborationSnapshot` | Current causal diagnostics | Mismatch | Snapshot/checkpoint 충돌 | compatibility alias | `ReplicaStatus` | +| `current()` | Replica status 반환 | Weak | Return 의미가 숨음 | compatibility alias | `status()` | | `CollaborationBundle` | Epoch/change exchange artifact | Near | Full/incremental 불명확 | keep | `CollaborationBundle` | | `SuppressedChange` | Known inactive contribution | Exact local | Rejection과 구분 필요 | keep | `SuppressedChange` | | `materializeChanges` | DAG에서 current document 파생 | Exact de-facto | 없음 | keep | `materializeChanges` | -| `projectionChanged` | Visible document change outcome | Weak | 제거할 concept 누출 | vNext rename | `didChangeDocument` | -| `CollaborationTextDOM` | DOM observe/render/selection boundary | Mismatch | DOM state처럼 보임 | vNext rename | `TextDOMAdapter` | +| `projectionChanged` | Visible document change outcome | Weak | 제거할 concept 누출 | compatibility alias | `didChangeDocument` | +| `CollaborationTextDOM` | DOM observe/render/selection boundary | Mismatch | DOM state처럼 보임 | compatibility alias | `TextDOMAdapter` | | publication lease | Native input 중 DOM rendering 유예 | Local | Sync lock으로 오해 | rename prose | native-input DOM lease | ## v2 compatibility map @@ -417,32 +428,74 @@ Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. | --- | --- | --- | | JSON Document | `JSONDocument`, Document Projection, Projection | `JSONDocument`는 유지하고 Projection은 v2 compatibility 문맥에서만 사용 | | Stateless JSON Patch | Pure Protocol, `applyPatch` | `applyPatch`는 유지하고 Pure Protocol을 새 canonical concept로 사용하지 않음 | -| Patch validation | `canPatch`, `JSONCapabilityResult` | Identifier는 유지하고 prose에서 validation으로 설명 | -| Validation | acceptance, `accepts` | Identifier는 유지하고 prose에서 validation callback으로 설명 | +| Patch validation | `canPatch`, `JSONCapabilityResult` | Deprecated alias; code는 `validatePatch`, `JSONPatchValidationResult` 사용 | +| Validation | acceptance, `accepts` | Wire label과 deprecated alias; code는 `validate` 사용 | | Applied change | `JSONAppliedChange` | 그대로 사용 | | Change notification | publication, `subscribe` | `subscribe`는 유지하고 publication을 notification으로 설명 | | Collaboration engine | collaboration provider package description | Transport-free engine으로 설명 | -| Replica | `CollaborationControl`, `runtime.collaboration` | vNext 전까지 identifier 유지 | -| Replica status | `CollaborationSnapshot`, `current()` | vNext 전까지 identifier 유지 | -| History status | `CollaborationHistorySnapshot` | vNext 전까지 identifier 유지 | +| Replica | `CollaborationControl`, `runtime.collaboration` | Deprecated alias; `CollaborationReplica`, `runtime.replica` 사용 | +| Replica status | `CollaborationSnapshot`, `current()` | Deprecated alias; `ReplicaStatus`, `status()` 사용 | +| History status | `CollaborationHistorySnapshot` | Deprecated alias; `HistoryStatus` 사용 | | Native-input DOM lease | DOM/IME publication lease | Rendering만 유예한다고 명시 | -| DOM adapter | `CollaborationTextDOM` | vNext 전까지 identifier 유지 | +| DOM adapter | `CollaborationTextDOM` | Deprecated alias; `TextDOMAdapter` 사용 | ## Current public surface decisions -아래 표는 현재 public export를 빠짐없이 keep, merge 또는 vNext rename으로 -분류한다. **keep**은 이름과 의미를 유지한다. **merge**는 identifier를 유지하되 -별도 canonical concept로 세지 않는다. **vNext rename**은 migration 후보일 뿐 -현재 rename 승인이 아니다. +## Compatibility migration + +Compatibility alias는 기존 runtime object 또는 export와 같은 값을 가리키며 별도 +동작이나 개념을 만들지 않는다. + +```text +Core + canPatch -> validatePatch + JSONCapabilityResult -> JSONPatchValidationResult + options.accepts -> options.validate + +Collaboration + runtime.collaboration -> runtime.replica + CollaborationControl -> CollaborationReplica + CollaborationSnapshot -> ReplicaStatus + replica.current() -> replica.status() + CollaborationAcceptance -> CollaborationValidation + +History subpath + CollaborationHistory* -> History* + createCollaborationHistoryRuntime -> createHistoryRuntime + restoreCollaborationHistoryRuntime -> restoreHistoryRuntime + history.current() -> history.status() + +Text subpath + CollaborationText* -> Text* + createCollaborationTextRuntime -> createTextRuntime + restoreCollaborationTextRuntime -> restoreTextRuntime + +Contenteditable package + CollaborationContentEditable* -> ContentEditable* + createCollaborationContentEditableAdapter -> createContentEditableAdapter + CollaborationTextDOM* -> TextDOMAdapter / DOMObservation + plainTextCollaborationDOM -> plainTextDOMAdapter + +Boolean result + projectionChanged -> didChangeDocument +``` + +Wire fields와 error code인 `acceptance`, `acceptance_required`, +`acceptance_reentrancy`, `deps`, `ops`는 protocol compatibility 영역이므로 +이 migration의 identifier 대상이 아니다. + +아래 표는 현재 public export를 canonical 또는 compatibility alias로 분류한다. +Canonical 이름은 새 코드와 문서가 사용한다. Compatibility alias는 기존 소비자를 +보호하지만 새 코드에서 사용하지 않으며 `@deprecated`로 표시한다. ### Core root | Decision | Current public exports | Canonical rule | | --- | --- | --- | -| keep | `JSONValue`, `Pointer`, `JSONPatchOperation`, `JSONAppliedChange`, `JSONPatchResult`, `JSONDocument`, `JSONDocumentCommitOptions`, `JSONDocumentCommitResult`, `ReadResult`, `QueryResult` | Standard JSON 또는 책임 suffix와 일치 | +| keep | `JSONValue`, `Pointer`, `JSONPatchOperation`, `JSONAppliedChange`, `JSONPatchResult`, `JSONDocument`, `JSONDocumentOptions`, `JSONDocumentCommitOptions`, `JSONDocumentCommitResult`, `JSONPatchValidationResult`, `ReadResult`, `QueryResult` | Standard JSON 또는 책임 suffix와 일치 | | keep | `createJSONDocument`, `applyPatch`, `parsePointer`, `tryParsePointer`, `buildPointer`, `parentPointer`, `appendSegment`, `trackPointer` | Function verb grammar와 일치 | | merge | `JSONChangeMetadata` | Applied change의 metadata이며 별도 domain concept를 만들지 않음 | -| vNext rename | `JSONCapabilityResult` | Canonical patch validation; 후보 `JSONPatchValidationResult` | +| compatibility alias | `JSONCapabilityResult`, `canPatch`, `accepts` | 각각 `JSONPatchValidationResult`, `validatePatch`, `validate`로 대체 | ### Collaboration root @@ -455,38 +508,34 @@ Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. | keep | `CollaborationBundle`, `CollaborationConflict`, `CollaborationCheckpoint`, `CollaborationCheckpointPayload` | Top-level collaboration artifact와 suffix가 일치 | | keep | `CollaborationEpoch`, `CollaborationEpochParent`, `CollaborationMember`, `CollaborationMembership`, `CollaborationRulesetIdentity` | Lifecycle과 membership 책임이 명확 | | keep | `CollaborationIngestSuccess`, `CollaborationIngestFailure`, `CollaborationIngestResult` | Result variant grammar와 일치 | -| keep | `CollaborationRuntime`, `CollaborationRuntimeOptions`, `CollaborationRestoreOptions`, `CollaborationRestoreResult`, `CollaborationCompactionOptions`, `CollaborationCompactionReport`, `CollaborationCompactionResult` | Runtime/Options/Result/Report grammar와 일치 | +| keep | `CollaborationRuntime`, `CollaborationRuntimeOptions`, `CollaborationRestoreOptions`, `CollaborationRestoreResult`, `CollaborationCompactionOptions`, `CollaborationCompactionReport`, `CollaborationCompactionResult`, `CollaborationReplica`, `ReplicaStatus`, `CollaborationValidation` | Runtime/Options/Result/Report/Replica/Status grammar와 일치 | | keep | `createCollaborationRuntime`, `restoreCollaborationRuntime`, `compactCollaborationCheckpoint` | Function verb grammar와 일치 | -| merge | `CollaborationAcceptance` | Canonical validation function; identifier만 유지 | -| vNext rename | `CollaborationControl` | Canonical replica; 후보 `CollaborationReplica` | -| vNext rename | `CollaborationSnapshot` | Canonical replica status; 후보 `ReplicaStatus` | +| compatibility alias | `CollaborationAcceptance`, `CollaborationControl`, `CollaborationSnapshot`, `runtime.collaboration`, `current()` | 각각 `CollaborationValidation`, `CollaborationReplica`, `ReplicaStatus`, `runtime.replica`, `status()`로 대체 | ### History subpath | Decision | Current public exports | Canonical rule | | --- | --- | --- | -| keep compatibility | `CollaborationHistoryResult`, `CollaborationHistoryRuntime`, `CollaborationHistoryRestoreResult`, `createCollaborationHistoryRuntime`, `restoreCollaborationHistoryRuntime` | 현재 subpath API를 유지; vNext에서 namespace 반복 축소 평가 | -| vNext rename | `CollaborationHistoryControl` | `Control` 제거; 후보 `History` | -| vNext rename | `CollaborationHistorySnapshot` | Canonical history status; 후보 `HistoryStatus` | +| keep | `History`, `HistoryStatus`, `HistoryResult`, `HistoryRuntime`, `HistoryRestoreResult`, `createHistoryRuntime`, `restoreHistoryRuntime` | Subpath namespace와 책임 suffix가 일치 | +| compatibility alias | `CollaborationHistoryControl`, `CollaborationHistorySnapshot`, `CollaborationHistoryResult`, `CollaborationHistoryRuntime`, `CollaborationHistoryRestoreResult`, `createCollaborationHistoryRuntime`, `restoreCollaborationHistoryRuntime`, `current()` | Canonical `History*`, `createHistoryRuntime`, `restoreHistoryRuntime`, `status()`로 대체 | ### Text subpath | Decision | Current public exports | Canonical rule | | --- | --- | --- | -| keep compatibility | `CollaborationTextSelection`, `CollaborationTextObservation`, `CollaborationTextCapture`, `CollaborationTextCaptureResult`, `CollaborationTextPlan`, `CollaborationTextPlanResult`, `CollaborationTextCommitResult`, `CollaborationTextRuntime`, `CollaborationTextRestoreResult`, `createCollaborationTextRuntime`, `restoreCollaborationTextRuntime` | 현재 subpath API를 유지; vNext에서 `Text*`로 namespace 반복 축소 평가 | -| keep compatibility | `CollaborationHistoryResult`, `CollaborationHistorySnapshot`, `CollaborationHistoryControl` | Text profile이 노출하는 history compatibility surface | -| vNext rename | `CollaborationTextControl` | `Control` 제거; 실제 capture/plan/commit 책임에 맞는 `Text` capability 이름 결정 | +| keep | `Text`, `TextSelection`, `TextObservation`, `TextCapture`, `TextCaptureResult`, `TextPlan`, `TextPlanResult`, `TextCommitResult`, `TextRuntime`, `TextRestoreResult`, `createTextRuntime`, `restoreTextRuntime` | Subpath namespace와 capture/plan/commit 책임이 일치 | +| compatibility alias | `CollaborationTextControl`, `CollaborationTextSelection`, `CollaborationTextObservation`, `CollaborationTextCapture`, `CollaborationTextCaptureResult`, `CollaborationTextPlan`, `CollaborationTextPlanResult`, `CollaborationTextCommitResult`, `CollaborationTextRuntime`, `CollaborationTextRestoreResult`, `createCollaborationTextRuntime`, `restoreCollaborationTextRuntime` | Canonical `Text*`, `createTextRuntime`, `restoreTextRuntime`로 대체 | ### Contenteditable package | Decision | Current public exports | Canonical rule | | --- | --- | --- | -| keep compatibility | `CollaborationContentEditableAdapter`, `CollaborationContentEditableOptions`, `CollaborationContentEditableResult`, `createCollaborationContentEditableAdapter` | 현재 API 유지; vNext에서 package namespace 반복 축소 평가 | -| vNext rename | `CollaborationTextDOM`, `CollaborationTextDOMObservation`, `plainTextCollaborationDOM` | Canonical DOM adapter; 후보 `TextDOMAdapter`, `DOMObservation`, `plainTextDOMAdapter` | +| keep | `ContentEditableAdapter`, `ContentEditableOptions`, `ContentEditableResult`, `createContentEditableAdapter`, `TextDOMAdapter`, `DOMObservation`, `plainTextDOMAdapter` | Package namespace와 DOM Adapter 책임이 일치 | +| compatibility alias | `CollaborationContentEditableAdapter`, `CollaborationContentEditableOptions`, `CollaborationContentEditableResult`, `CollaborationTextDOM`, `CollaborationTextDOMObservation`, `createCollaborationContentEditableAdapter`, `plainTextCollaborationDOM` | Canonical 이름으로 대체 | ## Target vocabulary -이 목록은 naming 방향이며 public rename 승인이 아니다. +이 목록은 현재 canonical public vocabulary다. ```text Core diff --git a/docs/standard/v2-projection-profile.md b/docs/standard/v2-projection-profile.md index e2e3a6db..b294ff29 100644 --- a/docs/standard/v2-projection-profile.md +++ b/docs/standard/v2-projection-profile.md @@ -30,16 +30,20 @@ adapter가 Projection과 조합한다. Pure Protocol -> Projection -> host adapter ``` -## 여섯 member +## Canonical 일곱 member와 v2 여섯-member compatibility 아래 TypeScript는 root package가 공개하는 application-owned 구조 계약이다. ```ts -interface Projection { +interface JSONDocument { readonly value: JSONValue; at(pointer: Pointer): ReadResult; query(jsonPath: string): QueryResult; + validatePatch( + operations: ReadonlyArray, + ): JSONPatchValidationResult; + /** @deprecated Use validatePatch. */ canPatch( operations: ReadonlyArray, ): JSONCapabilityResult; @@ -51,8 +55,9 @@ interface Projection { } ``` -`at`은 정확한 주소 한 곳을 읽고, `query`는 여러 주소를 찾는다. `canPatch`는 -같은 mutation을 실행하지 않고 검증하며, `commit`만 core state를 바꾼다. +`at`은 정확한 주소 한 곳을 읽고, `query`는 여러 주소를 찾는다. +`validatePatch`는 같은 mutation을 실행하지 않고 검증하며, `commit`만 core +state를 바꾼다. `canPatch`는 v2 portable consumer를 위한 deprecated alias다. `subscribe`는 이미 publish된 변경만 전달한다. 아직 result를 돌려줄 document가 없는 construction 단계에서 initial value가 @@ -205,13 +210,14 @@ array-property 분류만 공통 leaf에 두고, parity test가 untrusted boundar ## Package binding -`@interactive-os/json-document`는 root entrypoint 하나와 20개 Kernel symbol만 -공개한다. `JSONDocument`의 member는 여섯 개다. +`@interactive-os/json-document`는 root entrypoint 하나와 22개 symbol을 +공개한다. `JSONDocument`의 canonical member는 일곱 개이며, 기존 v2 portable +consumer가 의존하는 여섯 member는 그대로 유지된다. ```txt values 8 -types 12 -total 20 +types 14 +total 22 ``` 패키지는 runtime dependency와 peer dependency가 없다. `/session`과 `/react`는 diff --git a/docs/standard/v2-public-surface.json b/docs/standard/v2-public-surface.json index 09d1b00d..37623046 100644 --- a/docs/standard/v2-public-surface.json +++ b/docs/standard/v2-public-surface.json @@ -82,10 +82,12 @@ "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", + "JSONDocumentOptions", "JSONDocumentCommitOptions", "JSONDocumentCommitResult", "JSONPatchOperation", "JSONPatchResult", + "JSONPatchValidationResult", "JSONValue", "Pointer", "QueryResult", @@ -94,8 +96,8 @@ }, "counts": { "values": 8, - "types": 12, - "exports": 20, + "types": 14, + "exports": 22, "projectionMembers": 6 } } diff --git a/llms.txt b/llms.txt index f1e51cab..b00f24f5 100644 --- a/llms.txt +++ b/llms.txt @@ -23,7 +23,7 @@ import { ``` Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다. -공개 Root는 정확히 다음 20개 symbol이다. +공개 Root는 정확히 다음 22개 symbol이다. ```txt values @@ -31,8 +31,9 @@ values parentPointer, parsePointer, trackPointer, tryParsePointer types - JSONAppliedChange, JSONCapabilityResult, JSONChangeMetadata - JSONDocument, JSONDocumentCommitOptions, JSONDocumentCommitResult + JSONAppliedChange, JSONPatchValidationResult, JSONCapabilityResult + JSONChangeMetadata, JSONDocument, JSONDocumentOptions + JSONDocumentCommitOptions, JSONDocumentCommitResult JSONPatchOperation, JSONPatchResult, JSONValue, Pointer QueryResult, ReadResult ``` @@ -45,7 +46,7 @@ React hook, host service를 추가하지 않는다. ```txt stateless JSON Patch |-> local implementation -----\ - | > same six-member JSON Document + | > same seven-member JSON Document `-> collaboration engine -----/ |-> optional history/text authoring `-> optional native-input DOM lease ``` @@ -64,9 +65,9 @@ interface JSONDocument { readonly value: JSONValue; at(pointer: Pointer): ReadResult; query(jsonPath: string): QueryResult; - canPatch( + validatePatch( operations: ReadonlyArray, - ): JSONCapabilityResult; + ): JSONPatchValidationResult; commit( operations: ReadonlyArray, options?: JSONDocumentCommitOptions, @@ -93,7 +94,7 @@ const operations = [ { op: "replace", path: "/items/0/done", value: true }, ] as const; -if (document.canPatch(operations).ok) { +if (document.validatePatch(operations).ok) { const committed = document.commit(operations, { metadata: { origin: "toggle" }, }); @@ -150,22 +151,23 @@ if (result.ok) { 성공한 `applied`는 `/-`를 실제 index로 canonicalize하고 RFC operation field만 보존한다. 입력 state, operation payload, 성공 value와 change는 서로 격리된다. -## Acceptance +## Validation -Schema provider는 Root contract가 아니다. 필요하면 validator를 stable v2 -`accepts` callback으로 조합한다. +Schema provider는 Root contract가 아니다. 필요하면 validator를 canonical +`validate` callback으로 조합한다. `accepts`는 deprecated stable v2 +compatibility alias다. ```ts import { z } from "zod"; import { createJSONDocument, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONValue, } from "@interactive-os/json-document"; const Schema = z.object({ title: z.string().min(1) }); -function accepts(candidate: JSONValue): JSONCapabilityResult { +function validate(candidate: JSONValue): JSONPatchValidationResult { const parsed = Schema.safeParse(candidate); return parsed.success ? { ok: true } @@ -176,7 +178,7 @@ function accepts(candidate: JSONValue): JSONCapabilityResult { }; } -const document = createJSONDocument({ title: "Draft" }, { accepts }); +const document = createJSONDocument({ title: "Draft" }, { validate }); ``` Validation은 initial state와 commit candidate를 change notification 전에 검사한다. @@ -196,17 +198,17 @@ Minor release에서 optional diagnostic field와 새 code가 추가될 수 있 패키지는 root entrypoint만 공개한다. Selection, clipboard, history, schema introspection, DOM과 framework lifecycle은 host 또는 별도 adapter가 -여섯-member `JSONDocument`를 조합해 소유한다. +일곱-member `JSONDocument`를 조합해 소유한다. v2 Kernel release는 dependency-free Core package 하나다. Local-only consumer는 Core만 설치한다. 이 저장소의 -`@interactive-os/json-document-collaboration`은 같은 여섯-member Projection을 +`@interactive-os/json-document-collaboration`은 같은 canonical JSON Document을 제공하는 independently versioned, transport-free provider이고, `@interactive-os/json-document-contenteditable-collaboration`은 collaborative string을 위한 optional native-input DOM lease다. 두 companion을 사용해도 Core Root API와 editor가 받는 `JSONDocument` port는 바뀌지 않는다. -Host adapter는 공개 Root Projection만 입력으로 받고 제품 의도를 Pointer와 +Host adapter는 공개 JSON Document만 입력으로 받고 제품 의도를 Pointer와 JSON Patch로 번역한다. Collaboration companion은 transport, authentication, presence, persistence를 소유하지 않는다. 별도 package로 배포하는 adapter와 companion은 Core와 독립적으로 version과 compatibility를 검증한다. diff --git a/packages/contenteditable-collaboration/README.md b/packages/contenteditable-collaboration/README.md index 70119360..c53214ae 100644 --- a/packages/contenteditable-collaboration/README.md +++ b/packages/contenteditable-collaboration/README.md @@ -4,24 +4,24 @@ IME-safe native-input DOM 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 +root. Collaboration ingestion and the seven-member document model always update immediately. While the browser owns native input or IME composition, only rendering back into that root is delayed. Canonical DOM adapter와 lease vocabulary는 [Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 -정의합니다. `CollaborationTextDOM`은 stable compatibility identifier이고 -canonical concept는 DOM adapter입니다. +정의합니다. `TextDOMAdapter`는 canonical public identifier이고 +`CollaborationTextDOM`은 deprecated compatibility alias입니다. ```ts import { - createCollaborationTextRuntime, + createTextRuntime, } from "@interactive-os/json-document-collaboration/text"; import { - createCollaborationContentEditableAdapter, + createContentEditableAdapter, } from "@interactive-os/json-document-contenteditable-collaboration"; -const runtime = createCollaborationTextRuntime( +const runtime = createTextRuntime( { title: "Shared title" }, { actorId: "browser-a", @@ -33,7 +33,7 @@ const runtime = createCollaborationTextRuntime( }, ); -const adapter = createCollaborationContentEditableAdapter({ +const adapter = createContentEditableAdapter({ runtime, pointer: "/title", root: document.querySelector("[contenteditable]") as HTMLElement, @@ -74,7 +74,7 @@ controls are not consumed by the parent adapter. Wrappers or rich DOM can provide an adapter: ```ts -const adapter = createCollaborationContentEditableAdapter({ +const adapter = createContentEditableAdapter({ runtime, pointer: "/title", root, diff --git a/packages/contenteditable-collaboration/src/dom/plainText.ts b/packages/contenteditable-collaboration/src/dom/plain-text.ts similarity index 92% rename from packages/contenteditable-collaboration/src/dom/plainText.ts rename to packages/contenteditable-collaboration/src/dom/plain-text.ts index 6e2af5d5..e42dd39f 100644 --- a/packages/contenteditable-collaboration/src/dom/plainText.ts +++ b/packages/contenteditable-collaboration/src/dom/plain-text.ts @@ -1,12 +1,12 @@ -import type { CollaborationTextSelection } from +import type { TextSelection } from "@interactive-os/json-document-collaboration/text"; import type { - CollaborationTextDOM, - CollaborationTextDOMObservation, + TextDOMAdapter, + DOMObservation, } from "../types.js"; -export const plainTextCollaborationDOM: CollaborationTextDOM = Object.freeze({ - observe(root: HTMLElement): CollaborationTextDOMObservation { +export const plainTextDOMAdapter: TextDOMAdapter = Object.freeze({ + observe(root: HTMLElement): DOMObservation { return { value: plainTextFromChildren(root), selection: selectionInRoot(root), @@ -17,7 +17,7 @@ export const plainTextCollaborationDOM: CollaborationTextDOM = Object.freeze({ }, restoreSelection( root: HTMLElement, - selection: CollaborationTextSelection, + selection: TextSelection, ): boolean { if (!root.isConnected) return false; const value = root.textContent ?? ""; @@ -45,7 +45,7 @@ export const plainTextCollaborationDOM: CollaborationTextDOM = Object.freeze({ function selectionInRoot( root: HTMLElement, -): CollaborationTextSelection | null { +): TextSelection | null { const selection = root.ownerDocument.getSelection(); if ( selection === null @@ -79,8 +79,8 @@ function textOffsetForPosition( target: Node, targetOffset: number, ): number { - const projection = projectPlainText(root, target, targetOffset); - return projection.offset ?? projection.value.length; + const observation = projectPlainText(root, target, targetOffset); + return observation.offset ?? observation.value.length; } function plainTextFromChildren(node: Node): string { @@ -213,8 +213,8 @@ function domPositionForOffset( function clampSelectionToScalarBoundaries( value: string, - selection: CollaborationTextSelection, -): CollaborationTextSelection { + selection: TextSelection, +): TextSelection { const direction = selection.anchor === selection.focus ? "collapsed" : selection.anchor < selection.focus diff --git a/packages/contenteditable-collaboration/src/index.ts b/packages/contenteditable-collaboration/src/index.ts index 2863b55f..80fd5095 100644 --- a/packages/contenteditable-collaboration/src/index.ts +++ b/packages/contenteditable-collaboration/src/index.ts @@ -1,9 +1,20 @@ -export { createCollaborationContentEditableAdapter } from "./lease.js"; -export { plainTextCollaborationDOM } from "./dom/plainText.js"; +export { + createContentEditableAdapter, + createContentEditableAdapter as createCollaborationContentEditableAdapter, +} from "./lease.js"; +export { + plainTextDOMAdapter, + plainTextDOMAdapter as plainTextCollaborationDOM, +} from "./dom/plain-text.js"; export type { + ContentEditableAdapter, + ContentEditableOptions, + ContentEditableResult, + DOMObservation, CollaborationContentEditableAdapter, CollaborationContentEditableOptions, CollaborationContentEditableResult, CollaborationTextDOM, CollaborationTextDOMObservation, + TextDOMAdapter, } from "./types.js"; diff --git a/packages/contenteditable-collaboration/src/lease.ts b/packages/contenteditable-collaboration/src/lease.ts index 213b3ff0..11891f37 100644 --- a/packages/contenteditable-collaboration/src/lease.ts +++ b/packages/contenteditable-collaboration/src/lease.ts @@ -1,33 +1,33 @@ import type { - CollaborationTextCapture, - CollaborationTextSelection, + TextCapture, + TextSelection, } from "@interactive-os/json-document-collaboration/text"; -import { plainTextCollaborationDOM } from "./dom/plainText.js"; +import { plainTextDOMAdapter } from "./dom/plain-text.js"; import type { - CollaborationContentEditableAdapter, - CollaborationContentEditableOptions, - CollaborationContentEditableResult, + ContentEditableAdapter, + ContentEditableOptions, + ContentEditableResult, } from "./types.js"; interface ActiveLease { - readonly capture: CollaborationTextCapture; + readonly capture: TextCapture; phase: "native" | "composing"; nativeFallback: ReturnType | null; } -interface TailPublication { +interface TailReconciliation { readonly token: number; readonly basis: TailSelectionBasis | null; - readonly selection: CollaborationTextSelection | null; + readonly selection: TextSelection | null; readonly timer: ReturnType; } interface TailSelectionBasis { - readonly capture: CollaborationTextCapture; - readonly selection: CollaborationTextSelection; + readonly capture: TextCapture; + readonly selection: TextSelection; } -interface PublishedProjection { +interface RenderedDocument { readonly available: boolean; readonly value: string; } @@ -36,43 +36,43 @@ interface PublishedProjection { * 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. + * only DOM rendering is leased; release always renders the latest model. */ -export function createCollaborationContentEditableAdapter({ - dom = plainTextCollaborationDOM, +export function createContentEditableAdapter({ + dom = plainTextDOMAdapter, onResult, pointer, root, runtime, -}: CollaborationContentEditableOptions): CollaborationContentEditableAdapter { +}: ContentEditableOptions): ContentEditableAdapter { let activeLease: ActiveLease | null = null; - let tail: TailPublication | null = null; + let tail: TailReconciliation | null = null; let tailSequence = 0; - let published: PublishedProjection | null = null; + let renderedDocument: RenderedDocument | null = null; let bound = false; let unsubscribeDocument: (() => void) | null = null; const report = ( - result: CollaborationContentEditableResult, - ): CollaborationContentEditableResult => { + result: ContentEditableResult, + ): ContentEditableResult => { onResult?.(result); return result; }; - const currentDOMSelection = (): CollaborationTextSelection | null => + const currentDOMSelection = (): TextSelection | null => dom.observe(root).selection; - const publishLatest = ( - requestedSelection: CollaborationTextSelection | null | undefined, + const renderLatest = ( + requestedSelection: TextSelection | null | undefined, force: boolean, - ): CollaborationContentEditableResult => { + ): ContentEditableResult => { 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 + renderedDocument !== null + && renderedDocument.available === available + && renderedDocument.value === value ); if (!force && unchanged) { return NO_CHANGE; @@ -82,7 +82,7 @@ export function createCollaborationContentEditableAdapter({ ? currentDOMSelection() : requestedSelection; dom.render(root, value); - published = { available, value }; + renderedDocument = { available, value }; if (selection !== null && available) { dom.restoreSelection(root, selection); } @@ -103,7 +103,7 @@ export function createCollaborationContentEditableAdapter({ const finishTail = ( expectedToken?: number, - ): CollaborationContentEditableResult => { + ): ContentEditableResult => { if ( tail === null || ( @@ -116,27 +116,27 @@ export function createCollaborationContentEditableAdapter({ const basis = tail.basis; const selection = tail.selection; clearTail(); - if (basis === null) return publishLatest(selection, true); + if (basis === null) return renderLatest(selection, true); const planned = runtime.text.plan(basis.capture, { value: basis.capture.value, selection: basis.selection, }); if (!planned.ok) { - publishLatest(basis.selection, true); + renderLatest(basis.selection, true); return failure(planned.code, planned.reason); } const committed = runtime.text.commit(planned.plan); if (!committed.ok) { - publishLatest(basis.selection, true); + renderLatest(basis.selection, true); return failure(committed.code, committed.reason); } - return publishLatest(committed.selection, true); + return renderLatest(committed.selection, true); }; const enterTail = ( basis: TailSelectionBasis | null, - selection: CollaborationTextSelection | null, + selection: TextSelection | null, ): void => { clearTail(); const token = tailSequence + 1; @@ -150,17 +150,17 @@ export function createCollaborationContentEditableAdapter({ const recover = ( failure: Extract< - CollaborationContentEditableResult, + ContentEditableResult, { readonly ok: false } >, - selection: CollaborationTextSelection | null, + selection: TextSelection | null, composition: boolean, - ): CollaborationContentEditableResult => { + ): ContentEditableResult => { clearActiveLease(); if (composition) { enterTail(null, selection); } else { - publishLatest(selection, true); + renderLatest(selection, true); } return failure; }; @@ -168,7 +168,7 @@ export function createCollaborationContentEditableAdapter({ const begin = ( phase: ActiveLease["phase"], event: Event, - ): CollaborationContentEditableResult => { + ): ContentEditableResult => { if (tail !== null) finishTail(); if (activeLease !== null) { if (phase === "composing") { @@ -183,7 +183,7 @@ export function createCollaborationContentEditableAdapter({ const captured = runtime.text.capture(pointer); if (!captured.ok) { if (event.cancelable) event.preventDefault(); - publishLatest(undefined, true); + renderLatest(undefined, true); return failure(captured.code, captured.reason); } activeLease = { @@ -196,7 +196,7 @@ export function createCollaborationContentEditableAdapter({ lease.nativeFallback = setTimeout(() => { if (activeLease !== lease || lease.phase !== "native") return; clearActiveLease(); - publishLatest(undefined, true); + renderLatest(undefined, true); report(CANCELLED); }, 0); } @@ -205,7 +205,7 @@ export function createCollaborationContentEditableAdapter({ const finalize = ( composition: boolean, - ): CollaborationContentEditableResult => { + ): ContentEditableResult => { const lease = activeLease; if (lease === null) return NO_CHANGE; if (lease.nativeFallback !== null) { @@ -248,29 +248,30 @@ export function createCollaborationContentEditableAdapter({ committed.selection, ); } else { - publishLatest(committed.selection, true); + renderLatest(committed.selection, true); } return Object.freeze({ ok: true, kind: "committed", changeId: committed.changeId, - projectionChanged: committed.projectionChanged, + didChangeDocument: committed.didChangeDocument, + projectionChanged: committed.didChangeDocument, selection: committed.selection, }); }; - const cancelInternal = (): CollaborationContentEditableResult => { + const cancelInternal = (): ContentEditableResult => { const changed = activeLease !== null || tail !== null; if (!changed) return NO_CHANGE; clearActiveLease(); clearTail(); - publishLatest(undefined, true); + renderLatest(undefined, true); return CANCELLED; }; const handleInternal = ( event: Event, - ): CollaborationContentEditableResult => { + ): ContentEditableResult => { if (event.type === "blur") return cancelInternal(); if (event.type === "beforeinput") { @@ -301,7 +302,7 @@ export function createCollaborationContentEditableAdapter({ "missing_text_capture", "native input arrived without a capture-time text basis", ); - publishLatest(undefined, true); + renderLatest(undefined, true); return result; } @@ -317,7 +318,7 @@ export function createCollaborationContentEditableAdapter({ if (activeLease !== null || tail !== null) { return; } - publishLatest(undefined, false); + renderLatest(undefined, false); }; const unbind = (): void => { @@ -340,7 +341,7 @@ export function createCollaborationContentEditableAdapter({ root.addEventListener(type, boundHandle, true); } unsubscribeDocument = runtime.document.subscribe(onDocumentChange); - const initial = publishLatest(undefined, true); + const initial = renderLatest(undefined, true); if (!initial.ok) report(initial); let active = true; return () => { @@ -349,16 +350,16 @@ export function createCollaborationContentEditableAdapter({ unbind(); }; }, - handle(event: Event): CollaborationContentEditableResult { + handle(event: Event): ContentEditableResult { return report(handleInternal(event)); }, - cancel(): CollaborationContentEditableResult { + cancel(): ContentEditableResult { return report(cancelInternal()); }, reset(): void { clearActiveLease(); clearTail(); - publishLatest(undefined, true); + renderLatest(undefined, true); }, }); } @@ -423,7 +424,7 @@ function failure( code: string, reason: string, ): Extract< - CollaborationContentEditableResult, + ContentEditableResult, { readonly ok: false } > { return Object.freeze({ ok: false, code, reason }); @@ -437,22 +438,22 @@ const ROOT_EVENTS = Object.freeze([ "blur", ] as const); -const NO_CHANGE: CollaborationContentEditableResult = Object.freeze({ +const NO_CHANGE: ContentEditableResult = Object.freeze({ ok: true, kind: "no-change", }); -const LEASE_STARTED: CollaborationContentEditableResult = Object.freeze({ +const LEASE_STARTED: ContentEditableResult = Object.freeze({ ok: true, kind: "lease-started", }); -const RENDERED: CollaborationContentEditableResult = Object.freeze({ +const RENDERED: ContentEditableResult = Object.freeze({ ok: true, kind: "rendered", }); -const CANCELLED: CollaborationContentEditableResult = Object.freeze({ +const CANCELLED: ContentEditableResult = Object.freeze({ ok: true, kind: "cancelled", }); diff --git a/packages/contenteditable-collaboration/src/types.ts b/packages/contenteditable-collaboration/src/types.ts index dcd444d5..66176f60 100644 --- a/packages/contenteditable-collaboration/src/types.ts +++ b/packages/contenteditable-collaboration/src/types.ts @@ -1,41 +1,50 @@ import type { Pointer } from "@interactive-os/json-document"; import type { ChangeId, - CollaborationTextRuntime, - CollaborationTextSelection, + TextRuntime, + TextSelection, } from "@interactive-os/json-document-collaboration/text"; -export interface CollaborationTextDOMObservation { +export interface DOMObservation { readonly value: string; - readonly selection: CollaborationTextSelection | null; + readonly selection: TextSelection | null; } +/** @deprecated Use DOMObservation. */ +export type CollaborationTextDOMObservation = DOMObservation; + /** - * The DOM projection for one collaborative string field. + * The DOM adapter 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; +export interface TextDOMAdapter { + observe(root: HTMLElement): DOMObservation; render(root: HTMLElement, value: string): void; restoreSelection( root: HTMLElement, - selection: CollaborationTextSelection, + selection: TextSelection, ): boolean; } -export interface CollaborationContentEditableOptions { - readonly runtime: CollaborationTextRuntime; +/** @deprecated Use TextDOMAdapter. */ +export type CollaborationTextDOM = TextDOMAdapter; + +export interface ContentEditableOptions { + readonly runtime: TextRuntime; readonly pointer: Pointer; readonly root: HTMLElement; - readonly dom?: CollaborationTextDOM; + readonly dom?: TextDOMAdapter; readonly onResult?: ( - result: CollaborationContentEditableResult, + result: ContentEditableResult, ) => void; } -export type CollaborationContentEditableResult = +/** @deprecated Use ContentEditableOptions. */ +export type CollaborationContentEditableOptions = ContentEditableOptions; + +export type ContentEditableResult = | { readonly ok: true; readonly kind: @@ -48,8 +57,10 @@ export type CollaborationContentEditableResult = readonly ok: true; readonly kind: "committed"; readonly changeId: ChangeId | null; + readonly didChangeDocument: boolean; + /** @deprecated Use didChangeDocument. */ readonly projectionChanged: boolean; - readonly selection: CollaborationTextSelection | null; + readonly selection: TextSelection | null; } | { readonly ok: false; @@ -57,9 +68,15 @@ export type CollaborationContentEditableResult = readonly reason: string; }; -export interface CollaborationContentEditableAdapter { +/** @deprecated Use ContentEditableResult. */ +export type CollaborationContentEditableResult = ContentEditableResult; + +export interface ContentEditableAdapter { bind(): () => void; - handle(event: Event): CollaborationContentEditableResult; - cancel(): CollaborationContentEditableResult; + handle(event: Event): ContentEditableResult; + cancel(): ContentEditableResult; reset(): void; } + +/** @deprecated Use ContentEditableAdapter. */ +export type CollaborationContentEditableAdapter = ContentEditableAdapter; diff --git a/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts b/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts index c302f0aa..07bde7a4 100644 --- a/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts +++ b/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts @@ -11,11 +11,11 @@ import { type CollaborationRuntimeOptions, } from "@interactive-os/json-document-collaboration"; import { - createCollaborationTextRuntime, + createTextRuntime, } from "@interactive-os/json-document-collaboration/text"; import { - createCollaborationContentEditableAdapter, - plainTextCollaborationDOM, + createContentEditableAdapter, + plainTextDOMAdapter, } from "../src/index.js"; const baseOptions = { @@ -31,7 +31,7 @@ function textRuntime( initial: unknown = { title: "ab" }, overrides: Partial = {}, ) { - return createCollaborationTextRuntime(initial, { + return createTextRuntime(initial, { ...baseOptions, actorId, ...overrides, @@ -87,7 +87,7 @@ function ownChanges( runtime: ReturnType, actorId: string, ) { - return runtime.collaboration.exportBundle().changes.filter( + return runtime.replica.exportBundle().changes.filter( (change) => change.changeId.actorId === actorId, ); } @@ -104,7 +104,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const local = textRuntime("actor-a"); const remote = textRuntime("actor-b"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -120,7 +120,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { path: "/title", value: "aXb", }])).toMatchObject({ ok: true }); - expect(local.collaboration.ingest(remote.collaboration.exportBundle())) + expect(local.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); expect(local.document.value).toEqual({ title: "aXb" }); @@ -140,7 +140,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { expect(root.textContent).toBe("aYXb"); expect(document.getSelection()?.anchorOffset).toBe(2); - expect(remote.collaboration.ingest(local.collaboration.exportBundle())) + expect(remote.replica.ingest(local.replica.exportBundle())) .toMatchObject({ ok: true }); expect(remote.document.value).toEqual(local.document.value); unbind(); @@ -149,7 +149,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { test("captures before native input and commits through the text profile", () => { const local = textRuntime("actor-a"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -175,7 +175,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { vi.useFakeTimers(); const local = textRuntime("actor-a"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -214,7 +214,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { for (const action of ["cancel", "reset"] as const) { const local = textRuntime(`actor-${action}`); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -270,7 +270,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const local = textRuntime("actor-a"); const remote = atomicRuntime("actor-b"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -283,7 +283,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { expect(remote.document.commit(scenario.patch)) .toMatchObject({ ok: true }); - expect(local.collaboration.ingest(remote.collaboration.exportBundle())) + expect(local.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); expect(local.document.value).toEqual(scenario.value); expect(root.textContent).toBe("aXb"); @@ -304,12 +304,12 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const remote = textRuntime("actor-b", initial); const titleRoot = createRoot(); const noteRoot = createRoot(); - const title = createCollaborationContentEditableAdapter({ + const title = createContentEditableAdapter({ runtime: local, pointer: "/title", root: titleRoot, }); - const note = createCollaborationContentEditableAdapter({ + const note = createContentEditableAdapter({ runtime: local, pointer: "/note", root: noteRoot, @@ -326,7 +326,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { path: "/note", value: "cYd", }]); - local.collaboration.ingest(remote.collaboration.exportBundle()); + local.replica.ingest(remote.replica.exportBundle()); expect(local.document.value).toEqual({ title: "ab", note: "cYd" }); expect(titleRoot.textContent).toBe("aXb"); @@ -340,7 +340,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const local = textRuntime("actor-a", { title: "A😀B" }); const remote = atomicRuntime("actor-b", { title: "A😀B" }); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -353,7 +353,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { path: "/title", value: "A😀", }]); - local.collaboration.ingest(remote.collaboration.exportBundle()); + local.replica.ingest(remote.replica.exportBundle()); expect(root.textContent).toBe("A😀"); expect(document.getSelection()?.anchorOffset).toBe(3); @@ -362,7 +362,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const backwardLocal = textRuntime("actor-c", { title: "abcdef" }); const backwardRemote = atomicRuntime("actor-d", { title: "abcdef" }); const backwardRoot = createRoot(); - createCollaborationContentEditableAdapter({ + createContentEditableAdapter({ runtime: backwardLocal, pointer: "/title", root: backwardRoot, @@ -373,8 +373,8 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { path: "/title", value: "abc", }]); - backwardLocal.collaboration.ingest( - backwardRemote.collaboration.exportBundle(), + backwardLocal.replica.ingest( + backwardRemote.replica.exportBundle(), ); expect(document.getSelection()?.anchorOffset).toBe(3); @@ -385,7 +385,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { vi.useFakeTimers(); const local = textRuntime("actor-a", { title: "A😀B" }); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -412,7 +412,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { test("fails closed when the same actor changes causal history mid-lease", () => { const local = textRuntime("actor-a"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -443,7 +443,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { test("scopes bound events to this editing host", () => { const local = textRuntime("actor-a", { title: "ab" }); const root = createRoot(); - createCollaborationContentEditableAdapter({ + createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -479,7 +479,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { test("serializes line breaks and block boundaries as plain text", () => { const local = textRuntime("actor-a"); const root = createRoot(); - createCollaborationContentEditableAdapter({ + createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -491,7 +491,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { 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"); + expect(plainTextDOMAdapter.observe(root).value).toBe("a\nb\nc"); }); test("uses the same block serialization for value and selection offsets", () => { @@ -503,20 +503,20 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { selection?.removeAllRanges(); selection?.collapse(secondText, 1); - const observed = plainTextCollaborationDOM.observe(root); + const observed = plainTextDOMAdapter.observe(root); expect(observed).toEqual({ value: "a\nb", selection: { anchor: 3, focus: 3 }, }); - plainTextCollaborationDOM.render(root, observed.value); + plainTextDOMAdapter.render(root, observed.value); expect(root.childNodes).toHaveLength(1); expect(root.firstChild).toBeInstanceOf(Text); - expect(plainTextCollaborationDOM.restoreSelection( + expect(plainTextDOMAdapter.restoreSelection( root, observed.selection!, )).toBe(true); - expect(plainTextCollaborationDOM.observe(root)).toEqual(observed); + expect(plainTextDOMAdapter.observe(root)).toEqual(observed); }); test("rebases the composition tail selection through a remote merge", () => { @@ -524,7 +524,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const local = textRuntime("actor-a"); const remote = textRuntime("actor-b"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -541,7 +541,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { path: "/title", value: "Yab", }]); - local.collaboration.ingest(remote.collaboration.exportBundle()); + local.replica.ingest(remote.replica.exportBundle()); adapter.handle(input("input", "insertCompositionText")); expect(local.document.value).toEqual({ title: "YaXb" }); @@ -571,7 +571,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const local = textRuntime("actor-a"); const remote = atomicRuntime("actor-b"); const root = createRoot(); - const adapter = createCollaborationContentEditableAdapter({ + const adapter = createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -583,7 +583,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { setEditableText(root, "aXb", 2); adapter.handle(new CompositionEvent("compositionend")); remote.document.commit(scenario.patch); - local.collaboration.ingest(remote.collaboration.exportBundle()); + local.replica.ingest(remote.replica.exportBundle()); expect(adapter.handle(input("input", "insertCompositionText"))) .toMatchObject({ ok: false }); @@ -598,7 +598,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { const remote = textRuntime("actor-b"); const root = createRoot(); const results: unknown[] = []; - createCollaborationContentEditableAdapter({ + createContentEditableAdapter({ runtime: local, pointer: "/title", root, @@ -612,7 +612,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { path: "/title", value: "aYb", }]); - local.collaboration.ingest(remote.collaboration.exportBundle()); + local.replica.ingest(remote.replica.exportBundle()); expect(root.textContent).toBe("ab"); vi.runOnlyPendingTimers(); diff --git a/packages/contenteditable-collaboration/tests/naming-compatibility.test.ts b/packages/contenteditable-collaboration/tests/naming-compatibility.test.ts new file mode 100644 index 00000000..1e330801 --- /dev/null +++ b/packages/contenteditable-collaboration/tests/naming-compatibility.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; + +import { + createCollaborationContentEditableAdapter, + createContentEditableAdapter, + plainTextCollaborationDOM, + plainTextDOMAdapter, + type CollaborationContentEditableAdapter, + type CollaborationTextDOM, + type ContentEditableAdapter, + type TextDOMAdapter, +} from "../src/index.js"; + +describe("deprecated naming compatibility", () => { + test("canonical and compatibility values share one implementation", () => { + expect(createCollaborationContentEditableAdapter) + .toBe(createContentEditableAdapter); + expect(plainTextCollaborationDOM).toBe(plainTextDOMAdapter); + }); + + test("compatibility types are structural aliases", () => { + const adapter = null as unknown as ContentEditableAdapter; + adapter satisfies CollaborationContentEditableAdapter; + + const dom = null as unknown as TextDOMAdapter; + dom satisfies CollaborationTextDOM; + }); +}); diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index c811a817..53e36204 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -1,15 +1,15 @@ # @interactive-os/json-document-collaboration -Transport-free causal collaboration engine for the six-member +Transport-free causal collaboration engine for the seven-member `@interactive-os/json-document` JSON Document contract. -Local and collaborative implementations expose the same six-member +Local and collaborative implementations expose the same seven-member `JSONDocument` API. Canonical concept와 stable compatibility identifier의 관계는 [Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 -정의합니다. `runtime.collaboration`, `CollaborationControl`과 -`CollaborationSnapshot`은 현재 API 이름이며 canonical prose에서는 각각 replica +정의합니다. `runtime.replica`, `CollaborationReplica`과 +`ReplicaStatus`은 현재 API 이름이며 canonical prose에서는 각각 replica surface와 replica status로 설명합니다. ```ts @@ -30,40 +30,40 @@ runtime.document.commit([ { op: "replace", path: "/title", value: "Shared title" }, ]); -send(runtime.collaboration.exportBundle()); -receive((bundle) => runtime.collaboration.ingest(bundle)); +send(runtime.replica.exportBundle()); +receive((bundle) => runtime.replica.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, +the local-only implementation. Causal state, conflicts, bundles, and sync capabilities +remain on `runtime.replica`. 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, + createHistoryRuntime, } from "@interactive-os/json-document-collaboration/history"; -const runtime = createCollaborationHistoryRuntime(initial, options); +const runtime = createHistoryRuntime(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 +capabilities. 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, + createTextRuntime, } from "@interactive-os/json-document-collaboration/text"; -const runtime = createCollaborationTextRuntime(initial, options); +const runtime = createTextRuntime(initial, options); runtime.history.undo(); // Ordinary editor code keeps using the same document API. String-to-string @@ -98,17 +98,17 @@ the history profile. A successful text commit returns the canonical `JSONAppliedChange`, including owned metadata, so editor transaction tracking does not need a second change-notification protocol. -`runtime.collaboration.subscribe` publishes one deeply immutable replica status +`runtime.replica.subscribe` publishes one deeply immutable replica status when an ingest adds causal state, even if it only changes pending/conflict metadata. -Duplicate-only delivery publishes nothing. Unsubscribe follows the Projection +Duplicate-only delivery publishes nothing. Unsubscribe follows the JSON Document 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: +input. Restore replays the causal state rather than trusting a cached document +value: ```ts import { @@ -116,7 +116,7 @@ import { restoreCollaborationRuntime, } from "@interactive-os/json-document-collaboration"; -const checkpoint = runtime.collaboration.exportCheckpoint(); +const checkpoint = runtime.replica.exportCheckpoint(); const restored = restoreCollaborationRuntime(checkpoint, { actorId: "browser-a", ruleset: options.ruleset, @@ -143,7 +143,8 @@ 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; +signature. A custom `validate` resolver is recorded by the epoch's +wire-compatible `acceptance` mode; restore and compaction fail with `acceptance_required` if that resolver is omitted. @@ -158,7 +159,7 @@ 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, +ordinary patches through the seven-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 @@ -172,7 +173,7 @@ leased surface. ## Release evidence The ordinary package suite includes an implementation-neutral task-board host that -depends only on the six-member `JSONDocument`. The same host commands run +depends only on the seven-member `JSONDocument`. The same host commands run unchanged against the local implementation and collaboration engine, including a concurrent card edit that follows a structural move by stable identity. @@ -215,7 +216,7 @@ The implemented profile includes: - opt-in actor-local selective undo/redo with causal history reconstruction; - whole-Change validation and deterministic suppression; - canonical membership, SHA-256 checkpoints, restore, and new-epoch compaction; -- valid JSON change notification through the standard six-member `JSONDocument`; +- valid JSON change notification through the standard seven-member `JSONDocument`; - conflict and suppression sidecars outside `document.value`. Transport, presence, storage, signing keys, server coordination, and concrete @@ -227,7 +228,7 @@ 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 +Convergence requires `validate` 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 diff --git a/packages/json-document-collaboration/src/arrayIndex.ts b/packages/json-document-collaboration/src/array-index.ts similarity index 100% rename from packages/json-document-collaboration/src/arrayIndex.ts rename to packages/json-document-collaboration/src/array-index.ts diff --git a/packages/json-document-collaboration/src/change.ts b/packages/json-document-collaboration/src/change.ts index bcfbcd8b..b576e242 100644 --- a/packages/json-document-collaboration/src/change.ts +++ b/packages/json-document-collaboration/src/change.ts @@ -39,7 +39,9 @@ export function createEpoch( id: options.ruleset.id, digest: options.ruleset.digest, }), - acceptance: options.accepts === undefined ? "none" : "custom", + acceptance: (options.validate ?? options.accepts) === undefined + ? "none" + : "custom", baseDigest: fingerprintJSON(initial), membershipDigest: fingerprintJSON( membership as unknown as JSONValue, diff --git a/packages/json-document-collaboration/src/compact.ts b/packages/json-document-collaboration/src/compact.ts index c48da364..1d7b25fb 100644 --- a/packages/json-document-collaboration/src/compact.ts +++ b/packages/json-document-collaboration/src/compact.ts @@ -14,7 +14,7 @@ import { verifyCheckpointProof, } from "./checkpoint.js"; import { - acceptCandidate, + validateCandidate, materializeChanges, } from "./materialize.js"; import { @@ -42,9 +42,10 @@ export function compactCollaborationCheckpoint( if (typeof options !== "object" || options === null) { return failure("invalid_options", "compaction options must be an object"); } + const validate = options.validate ?? options.accepts; if ( checkpoint.payload.epoch.acceptance === "custom" - && options.accepts === undefined + && validate === undefined ) { return failure( "acceptance_required", @@ -53,7 +54,7 @@ export function compactCollaborationCheckpoint( } if ( checkpoint.payload.epoch.acceptance === "none" - && options.accepts !== undefined + && validate !== undefined ) { return failure( "ruleset_mismatch", @@ -121,18 +122,18 @@ export function compactCollaborationCheckpoint( checkpoint.payload.base, checkpoint.payload.epoch.baseDigest, ); - const initialProjection = projectTree(initialTree, () => false); - if (!initialProjection.ok) { + const initialDocument = projectTree(initialTree, () => false); + if (!initialDocument.ok) { return failure( "invalid_checkpoint", - initialProjection.reason ?? "checkpoint base is not materializable", + initialDocument.reason ?? "checkpoint base is not materializable", ); } - const initialAcceptance = acceptCandidate(options.accepts, initialProjection.value); - if (!initialAcceptance.ok) { + const initialValidation = validateCandidate(validate, initialDocument.value); + if (!initialValidation.ok) { return failure( - initialAcceptance.code, - initialAcceptance.reason + initialValidation.code, + initialValidation.reason ?? "checkpoint base was rejected by the source ruleset", ); } @@ -142,7 +143,7 @@ export function compactCollaborationCheckpoint( materialized = materializeChanges( initialTree, graph.ordered, - (candidate) => acceptCandidate(options.accepts, candidate), + (candidate) => validateCandidate(validate, candidate), ); } catch (error) { return failure( @@ -152,12 +153,12 @@ export function compactCollaborationCheckpoint( : "checkpoint materialization failed", ); } - const nextAccepts = effectiveNextAcceptance(checkpoint, options); - const nextAcceptance = acceptCandidate(nextAccepts, materialized.value); - if (!nextAcceptance.ok) { + const nextValidate = effectiveNextValidation(checkpoint, options); + const nextValidation = validateCandidate(nextValidate, materialized.value); + if (!nextValidation.ok) { return failure( - nextAcceptance.code, - nextAcceptance.reason + nextValidation.code, + nextValidation.reason ?? "compacted base was rejected by the next ruleset", ); } @@ -183,9 +184,9 @@ export function compactCollaborationCheckpoint( epochId: options.nextEpochId, ruleset: options.nextRuleset, ...(nextMembership === null ? {} : { membership: nextMembership }), - ...(nextAccepts === undefined + ...(nextValidate === undefined ? {} - : { accepts: nextAccepts }), + : { validate: nextValidate }), }, { epochId: checkpoint.payload.epoch.epochId, @@ -198,7 +199,7 @@ export function compactCollaborationCheckpoint( nextEpoch, [], ); - const discardedHistoryControls = checkpoint.payload.changes.filter( + const discardedHistoryChanges = checkpoint.payload.changes.filter( (change) => change.ops.some((operation) => ( operation.kind === "undo-change" || operation.kind === "redo-change" )), @@ -211,7 +212,8 @@ export function compactCollaborationCheckpoint( discardedChanges: checkpoint.payload.changes.length, discardedConflicts: materialized.conflicts.length, discardedSuppressed: materialized.suppressed.length, - discardedHistoryControls, + discardedHistoryChanges, + discardedHistoryControls: discardedHistoryChanges, }), }); } @@ -267,7 +269,7 @@ function validateOptions( options.nextRuleset.id === checkpoint.payload.epoch.ruleset.id && options.nextRuleset.digest === checkpoint.payload.epoch.ruleset.digest ); - const nextAcceptance = effectiveNextAcceptance( + const nextValidation = effectiveNextValidation( checkpoint, options, ) === undefined @@ -275,7 +277,7 @@ function validateOptions( : "custom"; if ( sameRuleset - && nextAcceptance !== checkpoint.payload.epoch.acceptance + && nextValidation !== checkpoint.payload.epoch.acceptance ) { return failure( "ruleset_mismatch", @@ -285,16 +287,17 @@ function validateOptions( return null; } -function effectiveNextAcceptance( +function effectiveNextValidation( checkpoint: CollaborationCheckpoint, options: CollaborationCompactionOptions, -): CollaborationCompactionOptions["nextAccepts"] { +): CollaborationCompactionOptions["nextValidate"] { + if (options.nextValidate !== undefined) return options.nextValidate; 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 + ? options.validate ?? options.accepts : undefined; } diff --git a/packages/json-document-collaboration/src/create.ts b/packages/json-document-collaboration/src/create.ts index c56b0c67..b5c505f2 100644 --- a/packages/json-document-collaboration/src/create.ts +++ b/packages/json-document-collaboration/src/create.ts @@ -3,7 +3,7 @@ import { createJSONDocument, parsePointer, type JSONAppliedChange, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONDocument, type JSONDocumentCommitOptions, type JSONDocumentCommitResult, @@ -29,14 +29,14 @@ import { prepareGraph, type PreparedGraph, } from "./change.js"; -import { jsonEqual } from "./jsonEqual.js"; +import { jsonEqual } from "./json-equal.js"; import { createCheckpoint } from "./checkpoint.js"; import { - acceptCandidate, + validateCandidate, historyOperationFor, isUndoableChange, materializeChanges, - projectAcceptedTree, + materializeTree, type MaterializedDocument, } from "./materialize.js"; import { @@ -60,25 +60,25 @@ import type { CollaborationBundle, CollaborationChange, CollaborationConflict, - CollaborationControl, + CollaborationReplica, CollaborationEpoch, - CollaborationHistoryControl, - CollaborationHistoryResult, - CollaborationHistoryRuntime, - CollaborationHistorySnapshot, + History, + HistoryResult, + HistoryRuntime, + HistoryStatus, CollaborationIngestResult, CollaborationRuntime, CollaborationRuntimeOptions, - CollaborationSnapshot, + ReplicaStatus, CollaborationMembership, - CollaborationTextCapture, - CollaborationTextCommitResult, - CollaborationTextControl, - CollaborationTextObservation, - CollaborationTextPlan, - CollaborationTextPlanResult, - CollaborationTextRuntime, - CollaborationTextSelection, + TextCapture, + Text, + TextCommitResult, + TextObservation, + TextPlan, + TextPlanResult, + TextRuntime, + TextSelection, PendingChange, SuppressedChange, TextAtomId, @@ -97,9 +97,9 @@ type PreparedLocalResult = | { readonly ok: true; readonly value: PreparedLocalChange } | Extract; -interface PublicationEvent { +interface NotificationEvent { readonly documentChange?: JSONAppliedChange; - readonly collaborationSnapshot: CollaborationSnapshot; + readonly replicaStatus: ReplicaStatus; } interface PreparedHistoryChange { @@ -108,24 +108,24 @@ interface PreparedHistoryChange { readonly known: ReadonlyMap; readonly graph: PreparedGraph; readonly materialized: MaterializedDocument; - readonly projectionChanged: boolean; + readonly didChangeDocument: boolean; } type PreparedHistoryResult = | { readonly ok: true; readonly value: PreparedHistoryChange } - | Extract; + | Extract; interface ResolvedHistoryState { - readonly snapshot: CollaborationHistorySnapshot; + readonly status: HistoryStatus; readonly effectiveUndo: ChangeId | null; } -interface InternalRuntime extends CollaborationHistoryRuntime { - readonly text?: CollaborationTextControl; +interface InternalRuntime extends HistoryRuntime { + readonly text?: Text; } interface TextCaptureState { - readonly capture: CollaborationTextCapture; + readonly capture: TextCapture; readonly atoms: ReadonlyArray; readonly deps: ReadonlyArray; readonly actorCounter: number; @@ -138,7 +138,7 @@ interface TextSelectionGap { } interface TextPlanState { - readonly plan: CollaborationTextPlan; + readonly plan: TextPlan; readonly capture: TextCaptureState; readonly operation: TextSpliceOperation | null; readonly graphRevision: number; @@ -153,30 +153,32 @@ export function createCollaborationRuntime( const runtime = createRuntime(initial, options); return Object.freeze({ document: runtime.document, + replica: runtime.replica, collaboration: runtime.collaboration, }); } -export function createCollaborationTextRuntime( +export function createTextRuntime( initial: unknown, options: CollaborationRuntimeOptions, -): CollaborationTextRuntime { +): TextRuntime { 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, + replica: runtime.replica, collaboration: runtime.collaboration, history: runtime.history, text: runtime.text, }); } -export function createCollaborationHistoryRuntime( +export function createHistoryRuntime( initial: unknown, options: CollaborationRuntimeOptions, -): CollaborationHistoryRuntime { +): HistoryRuntime { return createRuntime(initial, options); } @@ -184,7 +186,7 @@ export function createRestoredRuntime( initial: unknown, options: CollaborationRuntimeOptions, expectedEpoch: CollaborationEpoch, -): CollaborationHistoryRuntime { +): HistoryRuntime { return createRuntime(initial, options, expectedEpoch); } @@ -192,13 +194,14 @@ export function createRestoredTextRuntime( initial: unknown, options: CollaborationRuntimeOptions, expectedEpoch: CollaborationEpoch, -): CollaborationTextRuntime { +): TextRuntime { 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, + replica: runtime.replica, collaboration: runtime.collaboration, history: runtime.history, text: runtime.text, @@ -240,53 +243,56 @@ function createRuntime( 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; + let evaluatingValidation = false; + const validate = options.validate ?? options.accepts; + const evaluateValidation = ( + candidate: JSONValue, + ): JSONPatchValidationResult => { + if (evaluatingValidation) return ACCEPTANCE_REENTRANCY_FAILURE; + evaluatingValidation = true; try { - return acceptCandidate(options.accepts, candidate); + return validateCandidate(validate, candidate); } finally { - evaluatingAcceptance = false; + evaluatingValidation = false; } }; - const initialAcceptance = evaluateAcceptance(initialProjected.value); - if (!initialAcceptance.ok) { + const initialValidation = evaluateValidation(initialProjected.value); + if (!initialValidation.ok) { throw new TypeError( - `Initial document value was rejected: ${initialAcceptance.reason ?? initialAcceptance.code}`, + `Initial document value was rejected: ${initialValidation.reason ?? initialValidation.code}`, ); } - const projection = createJSONDocument(initialProjected.value); + const documentStore = createJSONDocument(initialProjected.value); const actorId = options.actorId; const documentListeners = new Set<(change: JSONAppliedChange) => void>(); const collaborationListeners = new Set< - (snapshot: CollaborationSnapshot) => void + (status: ReplicaStatus) => void >(); - const publicationQueue: PublicationEvent[] = []; - let publishing = false; + const notificationQueue: NotificationEvent[] = []; + let notifying = false; let localCounter = 0; let known = new Map(); let graph = prepareGraph(known); - let materialized = projectAcceptedTree(initialTree, [], Object.freeze([])); + let materialized = materializeTree(initialTree, [], Object.freeze([])); let graphRevision = 0; const textCaptures = new WeakMap< - CollaborationTextCapture, + TextCapture, TextCaptureState >(); - const textPlans = new WeakMap(); + const textPlans = new WeakMap(); const prepareLocal = ( operations: ReadonlyArray, ): PreparedLocalResult => { - if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; - const patched = applyPatch(projection.value, operations); + if (evaluatingValidation) return ACCEPTANCE_REENTRANCY_FAILURE; + const patched = applyPatch(documentStore.value, operations); if (!patched.ok) return patched; - const accepted = evaluateAcceptance(patched.value); - if (!accepted.ok) return accepted; + const validation = evaluateValidation(patched.value); + if (!validation.ok) return validation; - if (jsonEqual(projection.value, patched.value)) { + if (jsonEqual(documentStore.value, patched.value)) { return { ok: true, value: { @@ -341,7 +347,7 @@ function createRuntime( const nextMaterialized = materializeChanges( initialTree, nextGraph.ordered, - (candidate) => evaluateAcceptance(candidate), + (candidate) => evaluateValidation(candidate), ); if (!jsonEqual(nextMaterialized.value, patched.value)) { return failure( @@ -364,17 +370,23 @@ function createRuntime( const document = Object.freeze({ get value(): JSONValue { - return projection.value; + return documentStore.value; }, at(pointer: string) { - return projection.at(pointer); + return documentStore.at(pointer); }, query(jsonPath: string) { - return projection.query(jsonPath); + return documentStore.query(jsonPath); + }, + validatePatch( + operations: ReadonlyArray, + ): JSONPatchValidationResult { + const prepared = prepareLocal(operations); + return prepared.ok ? OK : prepared; }, canPatch( operations: ReadonlyArray, - ): JSONCapabilityResult { + ): JSONPatchValidationResult { const prepared = prepareLocal(operations); return prepared.ok ? OK : prepared; }, @@ -385,7 +397,7 @@ function createRuntime( const prepared = prepareLocal(operations); if (!prepared.ok) return prepared; - const committed = projection.commit(operations, commitOptions); + const committed = documentStore.commit(operations, commitOptions); if (!committed.ok) return committed; if (prepared.value.change !== null) { @@ -397,9 +409,9 @@ function createRuntime( } if (committed.change.applied.length > 0) { - enqueuePublication({ + enqueueNotification({ documentChange: committed.change, - collaborationSnapshot: currentSnapshot(), + replicaStatus: currentReplicaStatus(), }); } return committed; @@ -417,7 +429,8 @@ function createRuntime( const collaboration = Object.freeze({ epoch, - current: currentSnapshot, + status: currentReplicaStatus, + current: currentReplicaStatus, exportBundle(): CollaborationBundle { return Object.freeze({ epoch, @@ -433,7 +446,7 @@ function createRuntime( ); }, ingest(input: unknown): CollaborationIngestResult { - if (evaluatingAcceptance) { + if (evaluatingValidation) { return { ok: false, code: "acceptance_reentrancy", @@ -530,9 +543,9 @@ function createRuntime( const nextMaterialized = materializeChanges( initialTree, nextGraph.ordered, - (candidate) => evaluateAcceptance(candidate), + (candidate) => evaluateValidation(candidate), ); - const changed = !jsonEqual(projection.value, nextMaterialized.value); + const changed = !jsonEqual(documentStore.value, nextMaterialized.value); known = nextKnown; graph = nextGraph; @@ -549,21 +562,21 @@ function createRuntime( let documentChange: JSONAppliedChange | undefined; if (changed) { - const publication = projection.commit([{ + const documentCommit = documentStore.commit([{ op: "replace", path: "", value: materialized.value, }]); - if (!publication.ok) { + if (!documentCommit.ok) { throw new Error( - `collaboration projection publication failed: ${publication.reason ?? publication.code}`, + `collaboration projection publication failed: ${documentCommit.reason ?? documentCommit.code}`, ); } - documentChange = publication.change; + documentChange = documentCommit.change; } - enqueuePublication({ + enqueueNotification({ ...(documentChange === undefined ? {} : { documentChange }), - collaborationSnapshot: currentSnapshot(), + replicaStatus: currentReplicaStatus(), }); return { @@ -578,7 +591,7 @@ function createRuntime( }; }, subscribe( - listener: (snapshot: CollaborationSnapshot) => void, + listener: (status: ReplicaStatus) => void, ): () => void { collaborationListeners.add(listener); let active = true; @@ -588,32 +601,35 @@ function createRuntime( collaborationListeners.delete(listener); }; }, - } satisfies CollaborationControl); + } satisfies CollaborationReplica); const history = Object.freeze({ - current(): CollaborationHistorySnapshot { - return resolveHistoryState().snapshot; + status(): HistoryStatus { + return resolveHistoryState().status; + }, + current(): HistoryStatus { + return resolveHistoryState().status; }, - canUndo(): JSONCapabilityResult { + canUndo(): JSONPatchValidationResult { const prepared = prepareHistoryChange("undo"); return prepared.ok ? OK : prepared; }, - undo(): CollaborationHistoryResult { + undo(): HistoryResult { return commitHistoryChange("undo"); }, - canRedo(): JSONCapabilityResult { + canRedo(): JSONPatchValidationResult { const prepared = prepareHistoryChange("redo"); return prepared.ok ? OK : prepared; }, - redo(): CollaborationHistoryResult { + redo(): HistoryResult { return commitHistoryChange("redo"); }, - } satisfies CollaborationHistoryControl); + } satisfies History); const text = profile === "text" ? Object.freeze({ capture(pointer: string) { - if (evaluatingAcceptance) { + if (evaluatingValidation) { return textFailure( "acceptance_reentrancy", "acceptance callback cannot capture collaborative text", @@ -651,10 +667,10 @@ function createRuntime( return Object.freeze({ ok: true, capture }); }, plan( - capture: CollaborationTextCapture, - observation: CollaborationTextObservation, - ): CollaborationTextPlanResult { - if (evaluatingAcceptance) { + capture: TextCapture, + observation: TextObservation, + ): TextPlanResult { + if (evaluatingValidation) { return textFailure( "acceptance_reentrancy", "acceptance callback cannot plan collaborative text", @@ -783,10 +799,10 @@ function createRuntime( return Object.freeze({ ok: true, plan }); }, commit( - plan: CollaborationTextPlan, + plan: TextPlan, commitOptions?: JSONDocumentCommitOptions, - ): CollaborationTextCommitResult { - if (evaluatingAcceptance) { + ): TextCommitResult { + if (evaluatingValidation) { return textFailure( "acceptance_reentrancy", "acceptance callback cannot commit collaborative text", @@ -822,7 +838,7 @@ function createRuntime( if (!current.ok) { return textFailure(current.code, current.reason); } - const metadataProbe = projection.commit([], commitOptions); + const metadataProbe = documentStore.commit([], commitOptions); if (!metadataProbe.ok) { return textFailure( metadataProbe.code, @@ -840,6 +856,7 @@ function createRuntime( ok: true, change: metadataProbe.change, changeId: null, + didChangeDocument: false, projectionChanged: false, value, selection: resolvePlannedSelection(planned, textState), @@ -867,7 +884,7 @@ function createRuntime( const nextMaterialized = materializeChanges( initialTree, nextGraph.ordered, - (candidate) => evaluateAcceptance(candidate), + (candidate) => evaluateValidation(candidate), ); const changeKey = changeIdKey(changeId); if (!nextMaterialized.history.appliedKeys.has(changeKey)) { @@ -881,8 +898,8 @@ function createRuntime( ); } - const projectionChanged = !jsonEqual( - projection.value, + const didChangeDocument = !jsonEqual( + documentStore.value, nextMaterialized.value, ); known = nextKnown; @@ -892,8 +909,8 @@ function createRuntime( graphRevision += 1; let documentChange: JSONAppliedChange | undefined; - if (projectionChanged) { - const publication = projection.commit([{ + if (didChangeDocument) { + const documentCommit = documentStore.commit([{ op: "replace", path: "", value: materialized.value, @@ -902,16 +919,16 @@ function createRuntime( ? {} : { metadata: metadataProbe.change.metadata }), }); - if (!publication.ok) { + if (!documentCommit.ok) { throw new Error( - `text projection publication failed: ${publication.reason ?? publication.code}`, + `text projection publication failed: ${documentCommit.reason ?? documentCommit.code}`, ); } - documentChange = publication.change; + documentChange = documentCommit.change; } - enqueuePublication({ + enqueueNotification({ ...(documentChange === undefined ? {} : { documentChange }), - collaborationSnapshot: currentSnapshot(), + replicaStatus: currentReplicaStatus(), }); const textState = materialized.tree.texts.get( @@ -924,16 +941,18 @@ function createRuntime( ok: true, change: documentChange ?? metadataProbe.change, changeId: freezeChangeId(changeId), - projectionChanged, + didChangeDocument, + projectionChanged: didChangeDocument, value: projectText(textState), selection: resolvePlannedSelection(planned, textState), }); }, - } satisfies CollaborationTextControl) + } satisfies Text) : undefined; return Object.freeze({ document, + replica: collaboration, collaboration, history, ...(text === undefined ? {} : { text }), @@ -971,7 +990,7 @@ function createRuntime( if ( change === undefined || change.changeId.actorId !== actorId - || !materialized.history.appliedControlKeys.has( + || !materialized.history.appliedHistoryKeys.has( changeIdKey(change.changeId), ) ) { @@ -994,7 +1013,7 @@ function createRuntime( } return { - snapshot: Object.freeze({ + status: Object.freeze({ undoTarget, redoTarget, undoDepth, @@ -1008,7 +1027,7 @@ function createRuntime( function prepareHistoryChange( direction: "undo" | "redo", ): PreparedHistoryResult { - if (evaluatingAcceptance) { + if (evaluatingValidation) { return failure( "acceptance_reentrancy", "acceptance callback cannot author history changes", @@ -1029,8 +1048,8 @@ function createRuntime( const resolved = resolveHistoryState(); const target = direction === "undo" - ? resolved.snapshot.undoTarget - : resolved.snapshot.redoTarget; + ? resolved.status.undoTarget + : resolved.status.redoTarget; if (target === null) { return failure( direction === "undo" ? "nothing_to_undo" : "nothing_to_redo", @@ -1067,10 +1086,10 @@ function createRuntime( const nextMaterialized = materializeChanges( initialTree, nextGraph.ordered, - (candidate) => evaluateAcceptance(candidate), + (candidate) => evaluateValidation(candidate), ); const controlKey = changeIdKey(changeId); - if (!nextMaterialized.history.appliedControlKeys.has(controlKey)) { + if (!nextMaterialized.history.appliedHistoryKeys.has(controlKey)) { const suppressed = nextMaterialized.suppressed.find((entry) => ( changeIdKey(entry.changeId) === controlKey )); @@ -1088,8 +1107,8 @@ function createRuntime( known: nextKnown, graph: nextGraph, materialized: nextMaterialized, - projectionChanged: !jsonEqual( - projection.value, + didChangeDocument: !jsonEqual( + documentStore.value, nextMaterialized.value, ), }, @@ -1098,7 +1117,7 @@ function createRuntime( function commitHistoryChange( direction: "undo" | "redo", - ): CollaborationHistoryResult { + ): HistoryResult { const prepared = prepareHistoryChange(direction); if (!prepared.ok) return prepared; @@ -1109,33 +1128,34 @@ function createRuntime( graphRevision += 1; let documentChange: JSONAppliedChange | undefined; - if (prepared.value.projectionChanged) { - const publication = projection.commit([{ + if (prepared.value.didChangeDocument) { + const documentCommit = documentStore.commit([{ op: "replace", path: "", value: materialized.value, }]); - if (!publication.ok) { + if (!documentCommit.ok) { throw new Error( - `history projection publication failed: ${publication.reason ?? publication.code}`, + `history projection publication failed: ${documentCommit.reason ?? documentCommit.code}`, ); } - documentChange = publication.change; + documentChange = documentCommit.change; } - enqueuePublication({ + enqueueNotification({ ...(documentChange === undefined ? {} : { documentChange }), - collaborationSnapshot: currentSnapshot(), + replicaStatus: currentReplicaStatus(), }); return Object.freeze({ ok: true, changeId: freezeChangeId(prepared.value.change.changeId), target: freezeChangeId(prepared.value.target), - projectionChanged: prepared.value.projectionChanged, + didChangeDocument: prepared.value.didChangeDocument, + projectionChanged: prepared.value.didChangeDocument, }); } - function currentSnapshot(): CollaborationSnapshot { + function currentReplicaStatus(): ReplicaStatus { return Object.freeze({ epoch, heads: graph.heads, @@ -1145,21 +1165,21 @@ function createRuntime( }); } - function enqueuePublication(event: PublicationEvent): void { - publicationQueue.push(event); - if (publishing) return; + function enqueueNotification(event: NotificationEvent): void { + notificationQueue.push(event); + if (notifying) return; - publishing = true; + notifying = true; try { - while (publicationQueue.length > 0) { - const next = publicationQueue.shift() as PublicationEvent; + while (notificationQueue.length > 0) { + const next = notificationQueue.shift() as NotificationEvent; 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 + // Notification follows a committed state change. A listener // failure cannot turn that write into an apparent failure or // prevent delivery to the remaining active listeners. } @@ -1168,15 +1188,15 @@ function createRuntime( for (const listener of [...collaborationListeners]) { if (!collaborationListeners.has(listener)) continue; try { - listener(next.collaborationSnapshot); + listener(next.replicaStatus); } catch { - // Collaboration snapshots follow committed causal state and use - // the same failure-isolation rule as the document Projection. + // Replica status follows committed causal state and uses the same + // failure-isolation rule as JSON Document notifications. } } } } finally { - publishing = false; + notifying = false; } } } @@ -1330,12 +1350,12 @@ function authorDependencies( } function prepareTextSelection( - input: CollaborationTextSelection | undefined, + input: TextSelection | undefined, value: string, ): | { readonly ok: true; - readonly value: CollaborationTextSelection | null; + readonly value: TextSelection | null; } | { readonly ok: false; @@ -1424,7 +1444,7 @@ function scalarBoundaryIndex(value: string, offset: number): number | null { function resolvePlannedSelection( planned: TextPlanState, state: TextState | undefined, -): CollaborationTextSelection | null { +): TextSelection | null { if ( planned.plan.selection === undefined || planned.anchorGap === null @@ -1494,7 +1514,7 @@ function failure( }); } -const OK: JSONCapabilityResult = Object.freeze({ ok: true }); +const OK: JSONPatchValidationResult = 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 index 41d156b2..f0045da4 100644 --- a/packages/json-document-collaboration/src/history-index.ts +++ b/packages/json-document-collaboration/src/history-index.ts @@ -1,10 +1,21 @@ export * from "./index.js"; -export { createCollaborationHistoryRuntime } from "./create.js"; -export { restoreCollaborationHistoryRuntime } from "./restore.js"; +export { + createHistoryRuntime, + createHistoryRuntime as createCollaborationHistoryRuntime, +} from "./create.js"; +export { + restoreHistoryRuntime, + restoreHistoryRuntime as restoreCollaborationHistoryRuntime, +} from "./restore.js"; export type { CollaborationHistoryControl, CollaborationHistoryResult, CollaborationHistoryRestoreResult, CollaborationHistoryRuntime, CollaborationHistorySnapshot, + History, + HistoryRestoreResult, + HistoryResult, + HistoryRuntime, + HistoryStatus, } from "./types.js"; diff --git a/packages/json-document-collaboration/src/index.ts b/packages/json-document-collaboration/src/index.ts index b517884c..944dc052 100644 --- a/packages/json-document-collaboration/src/index.ts +++ b/packages/json-document-collaboration/src/index.ts @@ -22,11 +22,13 @@ export type { CollaborationIngestSuccess, CollaborationMember, CollaborationMembership, + CollaborationReplica, CollaborationRestoreOptions, CollaborationRestoreResult, CollaborationRulesetIdentity, CollaborationRuntime, CollaborationRuntimeOptions, + CollaborationValidation, CollaborationSnapshot, ContainerNodeId, MemberId, @@ -34,6 +36,7 @@ export type { ObjectPlacement, PendingChange, PositionId, + ReplicaStatus, SemanticOperation, SuppressedChange, TextAtomId, diff --git a/packages/json-document-collaboration/src/jsonEqual.ts b/packages/json-document-collaboration/src/json-equal.ts similarity index 100% rename from packages/json-document-collaboration/src/jsonEqual.ts rename to packages/json-document-collaboration/src/json-equal.ts diff --git a/packages/json-document-collaboration/src/materialize.ts b/packages/json-document-collaboration/src/materialize.ts index 153994d0..73b7604c 100644 --- a/packages/json-document-collaboration/src/materialize.ts +++ b/packages/json-document-collaboration/src/materialize.ts @@ -1,5 +1,5 @@ import type { - JSONCapabilityResult, + JSONPatchValidationResult, JSONValue, } from "@interactive-os/json-document"; @@ -30,7 +30,7 @@ interface MaterializedHistoryState { readonly disabledByTarget: ReadonlyMap; readonly appliedKeys: ReadonlySet; readonly appliedUndoTargets: ReadonlyMap; - readonly appliedControlKeys: ReadonlySet; + readonly appliedHistoryKeys: ReadonlySet; } export interface MaterializedDocument { @@ -44,7 +44,7 @@ export interface MaterializedDocument { export function materializeChanges( initialTree: TreeState, ordered: ReadonlyArray, - accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined, + validate: ((candidate: JSONValue) => JSONPatchValidationResult) | undefined, ): MaterializedDocument { const isAncestor = createAncestry(ordered); const changes = new Map( @@ -52,42 +52,42 @@ export function materializeChanges( ); const disabledByTarget = new Map(); const appliedUndoTargets = new Map(); - const appliedControlKeys = new Set(); - const controlSuppressed: SuppressedChange[] = []; + const appliedHistoryKeys = new Set(); + const historySuppressed: SuppressedChange[] = []; let replay = replayDataChanges( initialTree, ordered, disabledByTarget, - accepts, + validate, isAncestor, ); for (const change of ordered) { - const control = classifyHistoryChange(change); - if (control.kind === "none") continue; - if (control.kind === "invalid") { - controlSuppressed.push(freezeSuppressed( + const historyChange = classifyHistoryChange(change); + if (historyChange.kind === "none") continue; + if (historyChange.kind === "invalid") { + historySuppressed.push(freezeSuppressed( change.changeId, "invalid_history_change", - control.reason, + historyChange.reason, )); continue; } const changeKey = changeIdKey(change.changeId); - if (control.operation.kind === "undo-change") { - const targetKey = changeIdKey(control.operation.target); + if (historyChange.operation.kind === "undo-change") { + const targetKey = changeIdKey(historyChange.operation.target); const target = changes.get(targetKey); const invalidReason = validateUndoTarget( change, target, - control.operation.target, + historyChange.operation.target, replay, disabledByTarget, isAncestor, ); if (invalidReason !== null) { - controlSuppressed.push(freezeSuppressed( + historySuppressed.push(freezeSuppressed( change.changeId, invalidReason.code, invalidReason.reason, @@ -101,7 +101,7 @@ export function materializeChanges( initialTree, ordered, candidateDisabled, - accepts, + validate, isAncestor, ); const dependency = newlySuppressedAppliedChange( @@ -113,7 +113,7 @@ export function materializeChanges( targetKey, ); if (dependency !== null) { - controlSuppressed.push(freezeSuppressed( + historySuppressed.push(freezeSuppressed( change.changeId, "undo_dependency_conflict", `undo would suppress another accepted Change: ${dependency}`, @@ -126,12 +126,12 @@ export function materializeChanges( disabledByTarget.set(key, value); } replay = candidate; - appliedUndoTargets.set(changeKey, freezeChangeId(control.operation.target)); - appliedControlKeys.add(changeKey); + appliedUndoTargets.set(changeKey, freezeChangeId(historyChange.operation.target)); + appliedHistoryKeys.add(changeKey); continue; } - const undoKey = changeIdKey(control.operation.undo); + const undoKey = changeIdKey(historyChange.operation.undo); const target = appliedUndoTargets.get(undoKey); const targetKey = target === undefined ? null : changeIdKey(target); const currentUndo = targetKey === null @@ -147,16 +147,16 @@ export function materializeChanges( && changeIdKey(currentUndo) === undoKey && latestActorUndo !== null && changeIdKey(latestActorUndo) === undoKey - && change.changeId.actorId === control.operation.undo.actorId - && isAncestor(control.operation.undo, change.changeId) + && change.changeId.actorId === historyChange.operation.undo.actorId + && isAncestor(historyChange.operation.undo, change.changeId) && !hasInterveningActorData( ordered, - control.operation.undo, + historyChange.operation.undo, change.changeId, ) ); if (!validRedo || targetKey === null) { - controlSuppressed.push(freezeSuppressed( + historySuppressed.push(freezeSuppressed( change.changeId, "redo_target_invalid", "redo must reference the currently effective causal undo from the same actor", @@ -170,7 +170,7 @@ export function materializeChanges( initialTree, ordered, candidateDisabled, - accepts, + validate, isAncestor, ); const dependency = newlySuppressedAppliedChange( @@ -184,7 +184,7 @@ export function materializeChanges( dependency !== null || !candidate.appliedKeys.has(targetKey) ) { - controlSuppressed.push(freezeSuppressed( + historySuppressed.push(freezeSuppressed( change.changeId, "redo_dependency_conflict", dependency === null @@ -199,13 +199,13 @@ export function materializeChanges( disabledByTarget.set(key, value); } replay = candidate; - appliedControlKeys.add(changeKey); + appliedHistoryKeys.add(changeKey); } const orderByKey = new Map( ordered.map((change, order) => [changeIdKey(change.changeId), order]), ); - const suppressed = [...replay.suppressed, ...controlSuppressed] + const suppressed = [...replay.suppressed, ...historySuppressed] .sort((left, right) => ( (orderByKey.get(changeIdKey(left.changeId)) ?? Number.MAX_SAFE_INTEGER) - (orderByKey.get(changeIdKey(right.changeId)) ?? Number.MAX_SAFE_INTEGER) @@ -219,30 +219,30 @@ export function materializeChanges( disabledByTarget, appliedKeys: replay.appliedKeys, appliedUndoTargets, - appliedControlKeys, + appliedHistoryKeys, }, }; } -export function projectAcceptedTree( +export function materializeTree( 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}`); + const materializedDocument = projectTree(tree, createAncestry(ordered)); + if (!materializedDocument.ok) { + throw new Error(`materialized tree is invalid: ${materializedDocument.reason}`); } return { tree, - value: projected.value, - conflicts: Object.freeze(projected.conflicts.map(freezeConflict)), + value: materializedDocument.value, + conflicts: Object.freeze(materializedDocument.conflicts.map(freezeConflict)), suppressed, history: { disabledByTarget: new Map(), appliedKeys: new Set(), appliedUndoTargets: new Map(), - appliedControlKeys: new Set(), + appliedHistoryKeys: new Set(), }, }; } @@ -261,13 +261,13 @@ export function isUndoableChange(change: CollaborationChange): boolean { ); } -export function acceptCandidate( - accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined, +export function validateCandidate( + validate: ((candidate: JSONValue) => JSONPatchValidationResult) | undefined, candidate: JSONValue, -): JSONCapabilityResult { - if (accepts === undefined) return OK; +): JSONPatchValidationResult { + if (validate === undefined) return OK; try { - const result = accepts(freezeJSON(candidate)); + const result = validate(freezeJSON(candidate)); if (result?.ok === true) return OK; if (result?.ok === false && typeof result.code === "string") { return Object.freeze({ @@ -355,7 +355,7 @@ function freezeSuppressed( function failure( code: string, reason?: string, -): Extract { +): Extract { return Object.freeze({ ok: false, code, @@ -363,7 +363,7 @@ function failure( }); } -const OK: JSONCapabilityResult = Object.freeze({ ok: true }); +const OK: JSONPatchValidationResult = Object.freeze({ ok: true }); interface DataReplay { readonly tree: TreeState; @@ -377,7 +377,7 @@ function replayDataChanges( initialTree: TreeState, ordered: ReadonlyArray, disabledByTarget: ReadonlyMap, - accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined, + validate: ((candidate: JSONValue) => JSONPatchValidationResult) | undefined, isAncestor: (left: ChangeId, right: ChangeId) => boolean, ): DataReplay { let tree = cloneTree(initialTree); @@ -401,23 +401,23 @@ function replayDataChanges( continue; } - const projected = projectTree(candidate, isAncestor); - if (!projected.ok) { + const materializedDocument = projectTree(candidate, isAncestor); + if (!materializedDocument.ok) { suppressed.push(freezeSuppressed( change.changeId, - projected.code, - projected.reason, + materializedDocument.code, + materializedDocument.reason, )); continue; } - const accepted = acceptCandidate(accepts, projected.value); - if (!accepted.ok) { + const validation = validateCandidate(validate, materializedDocument.value); + if (!validation.ok) { suppressed.push(freezeSuppressed( change.changeId, - accepted.code, - accepted.reason, - accepted.pointer, + validation.code, + validation.reason, + validation.pointer, )); continue; } @@ -425,14 +425,14 @@ function replayDataChanges( appliedKeys.add(key); } - const projected = projectTree(tree, isAncestor); - if (!projected.ok) { - throw new Error(`materialized tree is invalid: ${projected.reason}`); + const materializedDocument = projectTree(tree, isAncestor); + if (!materializedDocument.ok) { + throw new Error(`materialized tree is invalid: ${materializedDocument.reason}`); } return { tree, - value: projected.value, - conflicts: Object.freeze(projected.conflicts.map(freezeConflict)), + value: materializedDocument.value, + conflicts: Object.freeze(materializedDocument.conflicts.map(freezeConflict)), suppressed: Object.freeze(suppressed), appliedKeys, }; @@ -499,7 +499,7 @@ function validateUndoTarget( function newlySuppressedAppliedChange( previous: DataReplay, candidate: DataReplay, - control: ChangeId, + historyChange: ChangeId, changes: ReadonlyMap, isAncestor: (left: ChangeId, right: ChangeId) => boolean, ignoredKey?: string, @@ -510,7 +510,7 @@ function newlySuppressedAppliedChange( if ( !candidate.appliedKeys.has(key) && disappeared !== undefined - && isAncestor(disappeared.changeId, control) + && isAncestor(disappeared.changeId, historyChange) ) { return key; } diff --git a/packages/json-document-collaboration/src/restore.ts b/packages/json-document-collaboration/src/restore.ts index d6d92e77..50fddb1e 100644 --- a/packages/json-document-collaboration/src/restore.ts +++ b/packages/json-document-collaboration/src/restore.ts @@ -11,18 +11,18 @@ import { } from "./create.js"; import type { JSONValue } from "@interactive-os/json-document"; import type { - CollaborationHistoryRestoreResult, - CollaborationHistoryRuntime, + HistoryRestoreResult, + HistoryRuntime, CollaborationRestoreOptions, CollaborationRestoreResult, CollaborationRuntime, - CollaborationTextRestoreResult, - CollaborationTextRuntime, + TextRestoreResult, + TextRuntime, } from "./types.js"; type RestoredProfileRuntime = - | CollaborationHistoryRuntime - | CollaborationTextRuntime; + | HistoryRuntime + | TextRuntime; type RestoredProfileResult = | { readonly ok: true; readonly runtime: RestoredProfileRuntime } @@ -32,36 +32,37 @@ export function restoreCollaborationRuntime( input: unknown, options: CollaborationRestoreOptions, ): CollaborationRestoreResult { - const restored = restoreCollaborationHistoryRuntime(input, options); + const restored = restoreHistoryRuntime(input, options); if (!restored.ok) return restored; const runtime: CollaborationRuntime = Object.freeze({ document: restored.runtime.document, + replica: restored.runtime.replica, collaboration: restored.runtime.collaboration, }); return Object.freeze({ ok: true, runtime }); } -export function restoreCollaborationHistoryRuntime( +export function restoreHistoryRuntime( input: unknown, options: CollaborationRestoreOptions, -): CollaborationHistoryRestoreResult { +): HistoryRestoreResult { const restored = restoreProfileRuntime(input, options, "history"); if (!restored.ok) return restored; return Object.freeze({ ok: true, - runtime: restored.runtime as CollaborationHistoryRuntime, + runtime: restored.runtime as HistoryRuntime, }); } -export function restoreCollaborationTextRuntime( +export function restoreTextRuntime( input: unknown, options: CollaborationRestoreOptions, -): CollaborationTextRestoreResult { +): TextRestoreResult { const restored = restoreProfileRuntime(input, options, "text"); if (!restored.ok) return restored; return Object.freeze({ ok: true, - runtime: restored.runtime as CollaborationTextRuntime, + runtime: restored.runtime as TextRuntime, }); } @@ -75,9 +76,10 @@ function restoreProfileRuntime( return failure("invalid_checkpoint", prepared.reason); } const checkpoint = prepared.checkpoint; + const validate = options.validate ?? options.accepts; if ( checkpoint.payload.epoch.acceptance === "custom" - && options.accepts === undefined + && validate === undefined ) { return failure( "acceptance_required", @@ -86,7 +88,7 @@ function restoreProfileRuntime( } if ( checkpoint.payload.epoch.acceptance === "none" - && options.accepts !== undefined + && validate !== undefined ) { return failure( "ruleset_mismatch", @@ -115,9 +117,9 @@ function restoreProfileRuntime( ...(checkpoint.payload.membership === null ? {} : { membership: checkpoint.payload.membership }), - ...(options.accepts === undefined + ...(validate === undefined ? {} - : { accepts: options.accepts }), + : { validate }), }; const restored = profile === "text" ? createRestoredTextRuntime( diff --git a/packages/json-document-collaboration/src/text-index.ts b/packages/json-document-collaboration/src/text-index.ts index fe2339cc..bff96c53 100644 --- a/packages/json-document-collaboration/src/text-index.ts +++ b/packages/json-document-collaboration/src/text-index.ts @@ -1,6 +1,12 @@ export * from "./index.js"; -export { createCollaborationTextRuntime } from "./create.js"; -export { restoreCollaborationTextRuntime } from "./restore.js"; +export { + createTextRuntime, + createTextRuntime as createCollaborationTextRuntime, +} from "./create.js"; +export { + restoreTextRuntime, + restoreTextRuntime as restoreCollaborationTextRuntime, +} from "./restore.js"; export type { CollaborationHistoryControl, CollaborationHistoryResult, @@ -15,4 +21,17 @@ export type { CollaborationTextRestoreResult, CollaborationTextRuntime, CollaborationTextSelection, + History, + HistoryResult, + HistoryStatus, + Text, + TextCapture, + TextCaptureResult, + TextCommitResult, + TextObservation, + TextPlan, + TextPlanResult, + TextRestoreResult, + TextRuntime, + TextSelection, } from "./types.js"; diff --git a/packages/json-document-collaboration/src/translate.ts b/packages/json-document-collaboration/src/translate.ts index 0320a4e3..4b2261ba 100644 --- a/packages/json-document-collaboration/src/translate.ts +++ b/packages/json-document-collaboration/src/translate.ts @@ -4,7 +4,7 @@ import { } from "@interactive-os/json-document"; import { changeIdKey } from "./change.js"; -import { parseArrayIndex } from "./arrayIndex.js"; +import { parseArrayIndex } from "./array-index.js"; import { applyAuthoredOperation, arrayMembers, diff --git a/packages/json-document-collaboration/src/types.ts b/packages/json-document-collaboration/src/types.ts index 6183466e..0786d218 100644 --- a/packages/json-document-collaboration/src/types.ts +++ b/packages/json-document-collaboration/src/types.ts @@ -3,6 +3,7 @@ import type { JSONCapabilityResult, JSONDocument, JSONDocumentCommitOptions, + JSONPatchValidationResult, JSONValue, } from "@interactive-os/json-document"; @@ -178,7 +179,7 @@ export interface PendingChange { readonly missing: ReadonlyArray; } -export interface CollaborationSnapshot { +export interface ReplicaStatus { readonly epoch: CollaborationEpoch; readonly heads: ReadonlyArray; readonly pending: ReadonlyArray; @@ -186,6 +187,9 @@ export interface CollaborationSnapshot { readonly suppressed: ReadonlyArray; } +/** @deprecated Use ReplicaStatus. */ +export type CollaborationSnapshot = ReplicaStatus; + export interface CollaborationIngestSuccess { readonly ok: true; readonly integrated: ReadonlyArray; @@ -214,16 +218,21 @@ export type CollaborationIngestResult = | CollaborationIngestSuccess | CollaborationIngestFailure; -export interface CollaborationControl { +export interface CollaborationReplica { readonly epoch: CollaborationEpoch; - current(): CollaborationSnapshot; + status(): ReplicaStatus; + /** @deprecated Use status. */ + current(): ReplicaStatus; exportBundle(): CollaborationBundle; exportCheckpoint(): CollaborationCheckpoint; ingest(bundle: unknown): CollaborationIngestResult; - subscribe(listener: (snapshot: CollaborationSnapshot) => void): () => void; + subscribe(listener: (status: ReplicaStatus) => void): () => void; } -export interface CollaborationHistorySnapshot { +/** @deprecated Use CollaborationReplica. */ +export type CollaborationControl = CollaborationReplica; + +export interface HistoryStatus { readonly undoTarget: ChangeId | null; readonly redoTarget: ChangeId | null; readonly undoDepth: number; @@ -231,11 +240,16 @@ export interface CollaborationHistorySnapshot { readonly revision: number; } -export type CollaborationHistoryResult = +/** @deprecated Use HistoryStatus. */ +export type CollaborationHistorySnapshot = HistoryStatus; + +export type HistoryResult = | { readonly ok: true; readonly changeId: ChangeId; readonly target: ChangeId; + readonly didChangeDocument: boolean; + /** @deprecated Use didChangeDocument. */ readonly projectionChanged: boolean; } | { @@ -244,50 +258,75 @@ export type CollaborationHistoryResult = readonly reason?: string; }; -export interface CollaborationHistoryControl { - current(): CollaborationHistorySnapshot; - canUndo(): JSONCapabilityResult; - undo(): CollaborationHistoryResult; - canRedo(): JSONCapabilityResult; - redo(): CollaborationHistoryResult; +/** @deprecated Use HistoryResult. */ +export type CollaborationHistoryResult = HistoryResult; + +export interface History { + status(): HistoryStatus; + /** @deprecated Use status. */ + current(): HistoryStatus; + canUndo(): JSONPatchValidationResult; + undo(): HistoryResult; + canRedo(): JSONPatchValidationResult; + redo(): HistoryResult; } +/** @deprecated Use History. */ +export type CollaborationHistoryControl = History; + export interface CollaborationRuntime { readonly document: JSONDocument; - readonly collaboration: CollaborationControl; + readonly replica: CollaborationReplica; + /** @deprecated Use replica. */ + readonly collaboration: CollaborationReplica; } -export interface CollaborationHistoryRuntime extends CollaborationRuntime { - readonly history: CollaborationHistoryControl; +export interface HistoryRuntime extends CollaborationRuntime { + readonly history: History; } -export interface CollaborationTextSelection { +/** @deprecated Import HistoryRuntime from the /history subpath. */ +export type CollaborationHistoryRuntime = HistoryRuntime; + +export interface TextSelection { readonly anchor: number; readonly focus: number; } -export interface CollaborationTextObservation { +/** @deprecated Use TextSelection from the /text subpath. */ +export type CollaborationTextSelection = TextSelection; + +export interface TextObservation { readonly value: string; - readonly selection?: CollaborationTextSelection; + readonly selection?: TextSelection; } -export interface CollaborationTextCapture { +/** @deprecated Use TextObservation from the /text subpath. */ +export type CollaborationTextObservation = TextObservation; + +export interface TextCapture { readonly pointer: string; readonly target: MemberId; readonly textNode: TextNodeId; readonly value: string; } -export interface CollaborationTextPlan { +/** @deprecated Use TextCapture from the /text subpath. */ +export type CollaborationTextCapture = TextCapture; + +export interface TextPlan { readonly pointer: string; readonly value: string; - readonly selection?: CollaborationTextSelection; + readonly selection?: TextSelection; } -export type CollaborationTextCaptureResult = +/** @deprecated Use TextPlan from the /text subpath. */ +export type CollaborationTextPlan = TextPlan; + +export type TextCaptureResult = | { readonly ok: true; - readonly capture: CollaborationTextCapture; + readonly capture: TextCapture; } | { readonly ok: false; @@ -295,10 +334,13 @@ export type CollaborationTextCaptureResult = readonly reason: string; }; -export type CollaborationTextPlanResult = +/** @deprecated Use TextCaptureResult from the /text subpath. */ +export type CollaborationTextCaptureResult = TextCaptureResult; + +export type TextPlanResult = | { readonly ok: true; - readonly plan: CollaborationTextPlan; + readonly plan: TextPlan; } | { readonly ok: false; @@ -306,14 +348,19 @@ export type CollaborationTextPlanResult = readonly reason: string; }; -export type CollaborationTextCommitResult = +/** @deprecated Use TextPlanResult from the /text subpath. */ +export type CollaborationTextPlanResult = TextPlanResult; + +export type TextCommitResult = | { readonly ok: true; readonly change: JSONAppliedChange; readonly changeId: ChangeId | null; + readonly didChangeDocument: boolean; + /** @deprecated Use didChangeDocument. */ readonly projectionChanged: boolean; readonly value: string; - readonly selection: CollaborationTextSelection | null; + readonly selection: TextSelection | null; } | { readonly ok: false; @@ -321,45 +368,61 @@ export type CollaborationTextCommitResult = readonly reason: string; }; -export interface CollaborationTextControl { - capture(pointer: string): CollaborationTextCaptureResult; +/** @deprecated Use TextCommitResult from the /text subpath. */ +export type CollaborationTextCommitResult = TextCommitResult; + +export interface Text { + capture(pointer: string): TextCaptureResult; plan( - capture: CollaborationTextCapture, - observation: CollaborationTextObservation, - ): CollaborationTextPlanResult; + capture: TextCapture, + observation: TextObservation, + ): TextPlanResult; commit( - plan: CollaborationTextPlan, + plan: TextPlan, options?: JSONDocumentCommitOptions, - ): CollaborationTextCommitResult; + ): TextCommitResult; } -export interface CollaborationTextRuntime extends CollaborationHistoryRuntime { - readonly text: CollaborationTextControl; +/** @deprecated Use Text from the /text subpath. */ +export type CollaborationTextControl = Text; + +export interface TextRuntime extends HistoryRuntime { + readonly text: Text; } +/** @deprecated Use TextRuntime from the /text subpath. */ +export type CollaborationTextRuntime = TextRuntime; + /** - * A convergence-critical acceptance rule. + * A convergence-critical validation 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 = ( +export type CollaborationValidation = ( candidate: JSONValue, -) => JSONCapabilityResult; +) => JSONPatchValidationResult; + +/** @deprecated Use CollaborationValidation. */ +export type CollaborationAcceptance = CollaborationValidation; export interface CollaborationRuntimeOptions { readonly actorId: ActorId; readonly epochId: string; readonly ruleset: CollaborationRulesetIdentity; readonly membership?: CollaborationMembership; + readonly validate?: CollaborationValidation; + /** @deprecated Use validate. */ readonly accepts?: CollaborationAcceptance; } export interface CollaborationRestoreOptions { readonly actorId: ActorId; readonly ruleset: CollaborationRulesetIdentity; + readonly validate?: CollaborationValidation; + /** @deprecated Use validate. */ readonly accepts?: CollaborationAcceptance; readonly verify?: ( checkpoint: CollaborationCheckpoint, @@ -377,10 +440,10 @@ export type CollaborationRestoreResult = readonly reason: string; }; -export type CollaborationHistoryRestoreResult = +export type HistoryRestoreResult = | { readonly ok: true; - readonly runtime: CollaborationHistoryRuntime; + readonly runtime: HistoryRuntime; } | { readonly ok: false; @@ -388,10 +451,13 @@ export type CollaborationHistoryRestoreResult = readonly reason: string; }; -export type CollaborationTextRestoreResult = +/** @deprecated Use HistoryRestoreResult from the /history subpath. */ +export type CollaborationHistoryRestoreResult = HistoryRestoreResult; + +export type TextRestoreResult = | { readonly ok: true; - readonly runtime: CollaborationTextRuntime; + readonly runtime: TextRuntime; } | { readonly ok: false; @@ -399,6 +465,9 @@ export type CollaborationTextRestoreResult = readonly reason: string; }; +/** @deprecated Use TextRestoreResult from the /text subpath. */ +export type CollaborationTextRestoreResult = TextRestoreResult; + export interface CollaborationCompactionOptions { readonly mode: "new-epoch"; readonly nextEpochId: string; @@ -407,7 +476,11 @@ export interface CollaborationCompactionOptions { * Omit to preserve the checkpoint membership. Use null to open membership. */ readonly nextMembership?: CollaborationMembership | null; + readonly validate?: CollaborationValidation; + /** @deprecated Use validate. */ readonly accepts?: CollaborationAcceptance; + readonly nextValidate?: CollaborationValidation; + /** @deprecated Use nextValidate. */ readonly nextAccepts?: CollaborationAcceptance; readonly verify?: ( checkpoint: CollaborationCheckpoint, @@ -418,6 +491,8 @@ export interface CollaborationCompactionReport { readonly discardedChanges: number; readonly discardedConflicts: number; readonly discardedSuppressed: number; + readonly discardedHistoryChanges: number; + /** @deprecated Use discardedHistoryChanges. */ readonly discardedHistoryControls: number; } diff --git a/packages/json-document-collaboration/tests/checkpoint.test.ts b/packages/json-document-collaboration/tests/checkpoint.test.ts index a10e5ed9..d7f7619f 100644 --- a/packages/json-document-collaboration/tests/checkpoint.test.ts +++ b/packages/json-document-collaboration/tests/checkpoint.test.ts @@ -14,8 +14,8 @@ import { type CollaborationRuntimeOptions, } from "../src/index.js"; import { - createCollaborationHistoryRuntime, - restoreCollaborationHistoryRuntime, + createHistoryRuntime, + restoreHistoryRuntime, } from "../src/history-index.js"; const ruleset: CollaborationRulesetIdentity = { @@ -129,21 +129,21 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/done", value: true, }])).toMatchObject({ ok: true }); - expect(source.collaboration.ingest(remote.collaboration.exportBundle())) + expect(source.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); - const checkpoint = source.collaboration.exportCheckpoint(); + const checkpoint = source.replica.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.replica.status()).toEqual( + source.replica.status(), ); - expect(restored.collaboration.exportBundle()).toEqual( - source.collaboration.exportBundle(), + expect(restored.replica.exportBundle()).toEqual( + source.replica.exportBundle(), ); expect(restored.document.commit([{ @@ -152,7 +152,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { value: "Restored", }])).toMatchObject({ ok: true }); expect( - restored.collaboration.exportBundle().changes.at(-1)?.changeId, + restored.replica.exportBundle().changes.at(-1)?.changeId, ).toEqual({ actorId: "actor-a", counter: 2, @@ -177,30 +177,30 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { value: "Second", }])).toMatchObject({ ok: true }); - const complete = author.collaboration.exportBundle(); + const complete = author.replica.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({ + expect(receiver.replica.ingest(second)).toMatchObject({ ok: true, pending: [{ actorId: "actor-a", counter: 2 }], }); const restored = restoredRuntime(restoreCollaborationRuntime( - receiver.collaboration.exportCheckpoint(), + receiver.replica.exportCheckpoint(), { actorId: "receiver", ruleset }, )); - expect(restored.collaboration.current().pending).toEqual([ + expect(restored.replica.status().pending).toEqual([ { changeId: { actorId: "actor-a", counter: 2 }, missing: [{ actorId: "actor-a", counter: 1 }], }, ]); - expect(restored.collaboration.ingest(first)).toMatchObject({ + expect(restored.replica.ingest(first)).toMatchObject({ ok: true, pending: [], }); @@ -208,13 +208,13 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { initial, options("receiver", "checkpoint-pending/v1", members), ); - expect(reference.collaboration.ingest(complete)).toMatchObject({ ok: true }); + expect(reference.replica.ingest(complete)).toMatchObject({ ok: true }); expect(restored.document.value).toEqual(reference.document.value); - expect(restored.collaboration.current()).toEqual( - reference.collaboration.current(), + expect(restored.replica.status()).toEqual( + reference.replica.status(), ); - expect(restored.collaboration.exportBundle()).toEqual( - reference.collaboration.exportBundle(), + expect(restored.replica.exportBundle()).toEqual( + reference.replica.exportBundle(), ); }); @@ -224,7 +224,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { title: "Draft", flag: false, }; - const left = createCollaborationHistoryRuntime( + const left = createHistoryRuntime( initial, options("actor-a", "checkpoint-history/v1", members), ); @@ -243,7 +243,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/title", value: "Right", }])).toMatchObject({ ok: true }); - expect(left.collaboration.ingest(right.collaboration.exportBundle())) + expect(left.replica.ingest(right.replica.exportBundle())) .toMatchObject({ ok: true }); expect(left.document.commit([{ op: "replace", @@ -254,32 +254,32 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { ok: true, target: { actorId: "actor-a", counter: 2 }, }); - expect(left.collaboration.current().conflicts).not.toEqual([]); - expect(left.history.current().redoTarget).toEqual({ + expect(left.replica.status().conflicts).not.toEqual([]); + expect(left.history.status().redoTarget).toEqual({ actorId: "actor-a", counter: 2, }); const restored = restoredRuntime(restoreCollaborationRuntime( - left.collaboration.exportCheckpoint(), + left.replica.exportCheckpoint(), { actorId: "actor-a", ruleset }, )); expect(restored.document.value).toEqual(left.document.value); - expect(restored.collaboration.current()).toEqual( - left.collaboration.current(), + expect(restored.replica.status()).toEqual( + left.replica.status(), ); - expect(restored.collaboration.exportBundle()).toEqual( - left.collaboration.exportBundle(), + expect(restored.replica.exportBundle()).toEqual( + left.replica.exportBundle(), ); expect( - restored.collaboration.exportBundle().changes.some((change) => ( + restored.replica.exportBundle().changes.some((change) => ( change.ops.some((operation) => operation.kind === "undo-change") )), ).toBe(true); - const restoredHistory = restoreCollaborationHistoryRuntime( - left.collaboration.exportCheckpoint(), + const restoredHistory = restoreHistoryRuntime( + left.replica.exportCheckpoint(), { actorId: "actor-a", ruleset }, ); if (!restoredHistory.ok) { @@ -287,7 +287,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { `history restore failed: ${restoredHistory.code}: ${restoredHistory.reason}`, ); } - expect(restoredHistory.runtime.history.current().redoTarget).toEqual({ + expect(restoredHistory.runtime.history.status().redoTarget).toEqual({ actorId: "actor-a", counter: 2, }); @@ -318,7 +318,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { value: 2, }])).toMatchObject({ ok: true }); - const accepts: CollaborationRuntimeOptions["accepts"] = (candidate) => { + const validate: CollaborationRuntimeOptions["validate"] = (candidate) => { const object = candidate as { readonly value?: unknown }; return typeof object.value === "number" && object.value <= 1 ? { ok: true } @@ -331,23 +331,23 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { }; const receiver = createCollaborationRuntime( initial, - options("receiver", "checkpoint-suppressed/v1", members, { accepts }), + options("receiver", "checkpoint-suppressed/v1", members, { validate }), ); - const untrusted = author.collaboration.exportBundle(); - expect(receiver.collaboration.ingest({ - epoch: receiver.collaboration.epoch, + const untrusted = author.replica.exportBundle(); + expect(receiver.replica.ingest({ + epoch: receiver.replica.epoch, changes: untrusted.changes, })) .toMatchObject({ ok: true }); expect(receiver.document.value).toEqual(initial); - expect(receiver.collaboration.current().suppressed).toEqual([{ + expect(receiver.replica.status().suppressed).toEqual([{ changeId: { actorId: "author", counter: 1 }, code: "maximum_exceeded", reason: "value must be at most one", pointer: "/value", }]); - const checkpoint = receiver.collaboration.exportCheckpoint(); + const checkpoint = receiver.replica.exportCheckpoint(); expect(restoreCollaborationRuntime(checkpoint, { actorId: "receiver", ruleset, @@ -366,14 +366,14 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { const restored = restoredRuntime(restoreCollaborationRuntime( checkpoint, - { actorId: "receiver", ruleset, accepts }, + { actorId: "receiver", ruleset, validate }, )); expect(restored.document.value).toEqual(receiver.document.value); - expect(restored.collaboration.current()).toEqual( - receiver.collaboration.current(), + expect(restored.replica.status()).toEqual( + receiver.replica.status(), ); - expect(restored.collaboration.exportBundle()).toEqual( - receiver.collaboration.exportBundle(), + expect(restored.replica.exportBundle()).toEqual( + receiver.replica.exportBundle(), ); }); @@ -384,12 +384,12 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { options("receiver", "checkpoint-membership/v1", members), ); const beforeValue = receiver.document.value; - const beforeSnapshot = receiver.collaboration.current(); + const beforeSnapshot = receiver.replica.status(); const listener = vi.fn(); - receiver.collaboration.subscribe(listener); + receiver.replica.subscribe(listener); - const result = receiver.collaboration.ingest({ - epoch: receiver.collaboration.epoch, + const result = receiver.replica.ingest({ + epoch: receiver.replica.epoch, changes: [ { changeId: { actorId: "allowed", counter: 1 }, @@ -406,8 +406,8 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { expect(result).toMatchObject({ ok: false }); expect(receiver.document.value).toBe(beforeValue); - expect(receiver.collaboration.current()).toEqual(beforeSnapshot); - expect(receiver.collaboration.exportBundle().changes).toEqual([]); + expect(receiver.replica.status()).toEqual(beforeSnapshot); + expect(receiver.replica.exportBundle().changes).toEqual([]); expect(listener).not.toHaveBeenCalled(); }); @@ -434,10 +434,10 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { { value: 0 }, options("receiver", "checkpoint-member-reference/v1", members), ); - const before = receiver.collaboration.current(); + const before = receiver.replica.status(); - expect(receiver.collaboration.ingest({ - epoch: receiver.collaboration.epoch, + expect(receiver.replica.ingest({ + epoch: receiver.replica.epoch, changes: [{ changeId: { actorId: "allowed", counter: 1 }, deps, @@ -449,8 +449,8 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { changeId: { actorId: "allowed", counter: 1 }, }); expect(receiver.document.value).toEqual({ value: 0 }); - expect(receiver.collaboration.current()).toEqual(before); - expect(receiver.collaboration.exportBundle().changes).toEqual([]); + expect(receiver.replica.status()).toEqual(before); + expect(receiver.replica.exportBundle().changes).toEqual([]); }); test("refuses to compact a checkpoint with pending causal history", () => { @@ -470,17 +470,17 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/title", value: "Second", }])).toMatchObject({ ok: true }); - const complete = author.collaboration.exportBundle(); + const complete = author.replica.exportBundle(); const receiver = createCollaborationRuntime( initial, options("receiver", "checkpoint-pending-compaction/v1", members), ); - expect(receiver.collaboration.ingest( + expect(receiver.replica.ingest( changeBundle(complete, "author", 2), )).toMatchObject({ ok: true }); expect(compactCollaborationCheckpoint( - receiver.collaboration.exportCheckpoint(), + receiver.replica.exportCheckpoint(), { mode: "new-epoch", nextEpochId: "checkpoint-pending-compaction/v2", @@ -509,10 +509,10 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { value: 1, }])).toMatchObject({ ok: true }); - const original = allowed.collaboration.exportCheckpoint(); + const original = allowed.replica.exportCheckpoint(); const forged = checkpointWithIntegrity({ ...original.payload, - changes: intruder.collaboration.exportBundle().changes, + changes: intruder.replica.exportBundle().changes, }); expect(compactCollaborationCheckpoint(forged, { mode: "new-epoch", @@ -559,7 +559,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { membership("allowed"), ), ); - const original = source.collaboration.exportCheckpoint(); + const original = source.replica.exportCheckpoint(); const forged = checkpointWithIntegrity({ ...original.payload, changes: [{ @@ -589,7 +589,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/value", value: 1, }])).toMatchObject({ ok: true }); - const original = source.collaboration.exportCheckpoint(); + const original = source.replica.exportCheckpoint(); const change = original.payload.changes[0]; if (change === undefined) throw new Error("missing authored Change"); const forged = checkpointWithIntegrity({ @@ -625,7 +625,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/value", value: 2, }])).toMatchObject({ ok: true }); - const original = source.collaboration.exportCheckpoint(); + const original = source.replica.exportCheckpoint(); const reordered = checkpointWithIntegrity({ ...original.payload, changes: [...original.payload.changes].reverse(), @@ -658,7 +658,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/value", value: 1, }])).toMatchObject({ ok: true }); - const original = source.collaboration.exportCheckpoint(); + const original = source.replica.exportCheckpoint(); const change = original.payload.changes[0]; if (change === undefined) throw new Error("missing authored Change"); const duplicated = checkpointWithIntegrity({ @@ -694,9 +694,9 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { path: "/title", value: "Compacted", }])).toMatchObject({ ok: true }); - const oldBundle = source.collaboration.exportBundle(); + const oldBundle = source.replica.exportBundle(); const compacted = compactCollaborationCheckpoint( - source.collaboration.exportCheckpoint(), + source.replica.exportCheckpoint(), { mode: "new-epoch", nextEpochId: "checkpoint-compaction/v2", @@ -712,18 +712,18 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { { actorId: "actor-a", ruleset }, )); expect(restored.document.value).toEqual(source.document.value); - expect(restored.collaboration.epoch.epochId).toBe( + expect(restored.replica.epoch.epochId).toBe( "checkpoint-compaction/v2", ); - expect(restored.collaboration.exportBundle().changes).toEqual([]); + expect(restored.replica.exportBundle().changes).toEqual([]); - const before = restored.collaboration.current(); - expect(restored.collaboration.ingest(oldBundle)).toMatchObject({ + const before = restored.replica.status(); + expect(restored.replica.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([]); + expect(restored.replica.status()).toEqual(before); + expect(restored.replica.exportBundle().changes).toEqual([]); }); }); diff --git a/packages/json-document-collaboration/tests/collaboration.test.ts b/packages/json-document-collaboration/tests/collaboration.test.ts index 5f89cc26..f1c1f98d 100644 --- a/packages/json-document-collaboration/tests/collaboration.test.ts +++ b/packages/json-document-collaboration/tests/collaboration.test.ts @@ -34,17 +34,18 @@ 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( + expect(shared.replica.epoch.baseDigest).toBe( "sha256:74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b", ); }); - test("keeps the editor-facing document on the six-member Projection API", () => { + test("exposes the canonical seven-member JSON Document API", () => { const shared = runtime("actor-a"); expect(Object.keys(shared).sort()).toEqual([ "collaboration", "document", + "replica", ]); expect(Object.keys(shared.document).sort()).toEqual([ "at", @@ -52,10 +53,11 @@ describe("@interactive-os/json-document-collaboration", () => { "commit", "query", "subscribe", + "validatePatch", "value", ]); expect("ingest" in shared.document).toBe(false); - expect(typeof shared.collaboration.ingest).toBe("function"); + expect(typeof shared.replica.ingest).toBe("function"); }); test("merges concurrent edits to different members independent of arrival order", () => { @@ -69,9 +71,9 @@ describe("@interactive-os/json-document-collaboration", () => { { op: "replace", path: "/done", value: true }, ])).toMatchObject({ ok: true }); - expect(left.collaboration.ingest(right.collaboration.exportBundle())) + expect(left.replica.ingest(right.replica.exportBundle())) .toMatchObject({ ok: true }); - expect(right.collaboration.ingest(left.collaboration.exportBundle())) + expect(right.replica.ingest(left.replica.exportBundle())) .toMatchObject({ ok: true }); expect(left.document.value).toEqual({ @@ -80,10 +82,10 @@ describe("@interactive-os/json-document-collaboration", () => { items: ["a", "b"], }); expect(right.document.value).toEqual(left.document.value); - expect(left.collaboration.current().conflicts).toEqual([]); + expect(left.replica.status().conflicts).toEqual([]); }); - test("keeps concurrent alternatives outside the ordinary JSON projection", () => { + test("keeps concurrent alternatives outside the ordinary JSON document", () => { const left = runtime("actor-a"); const right = runtime("actor-b"); @@ -94,12 +96,12 @@ describe("@interactive-os/json-document-collaboration", () => { { op: "replace", path: "/title", value: "Right" }, ]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual(right.document.value); expect(left.document.value).toMatchObject({ title: "Right" }); - expect(left.collaboration.current().conflicts).toMatchObject([ + expect(left.replica.status().conflicts).toMatchObject([ { kind: "member-value", winner: { actorId: "actor-b", counter: 1 }, @@ -119,8 +121,8 @@ describe("@interactive-os/json-document-collaboration", () => { { op: "add", path: "/items/1", value: "right" }, ]); - right.collaboration.ingest(left.collaboration.exportBundle()); - left.collaboration.ingest(right.collaboration.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); expect(left.document.value).toMatchObject({ items: ["a", "left", "right", "b"], @@ -147,15 +149,15 @@ describe("@interactive-os/json-document-collaboration", () => { value: "x", }]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.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([]); + expect(left.replica.status().suppressed).toEqual([]); }); test("retains start and end gap insertions when their only anchor moves away", () => { @@ -178,11 +180,11 @@ describe("@interactive-os/json-document-collaboration", () => { value: "x", }])).toMatchObject({ ok: true }); - expect(mover.collaboration.ingest( - inserter.collaboration.exportBundle(), + expect(mover.replica.ingest( + inserter.replica.exportBundle(), )).toMatchObject({ ok: true }); - expect(inserter.collaboration.ingest( - mover.collaboration.exportBundle(), + expect(inserter.replica.ingest( + mover.replica.exportBundle(), )).toMatchObject({ ok: true }); expect(mover.document.value).toEqual({ @@ -190,8 +192,8 @@ describe("@interactive-os/json-document-collaboration", () => { right: ["b"], }); expect(inserter.document.value).toEqual(mover.document.value); - expect(mover.collaboration.current().suppressed).toEqual([]); - expect(inserter.collaboration.current().suppressed).toEqual([]); + expect(mover.replica.status().suppressed).toEqual([]); + expect(inserter.replica.status().suppressed).toEqual([]); } }); @@ -211,16 +213,16 @@ describe("@interactive-os/json-document-collaboration", () => { value: "x", }])).toMatchObject({ ok: true }); - expect(mover.collaboration.ingest( - inserter.collaboration.exportBundle(), + expect(mover.replica.ingest( + inserter.replica.exportBundle(), )).toMatchObject({ ok: true }); - expect(inserter.collaboration.ingest( - mover.collaboration.exportBundle(), + expect(inserter.replica.ingest( + mover.replica.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([]); + expect(mover.replica.status().suppressed).toEqual([]); }); test("preserves member identity across rename while merging a concurrent child edit", () => { @@ -245,8 +247,8 @@ describe("@interactive-os/json-document-collaboration", () => { value: "Ready", }]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ cards: { @@ -280,8 +282,8 @@ describe("@interactive-os/json-document-collaboration", () => { value: "Changed", }]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ cards: { @@ -321,8 +323,8 @@ describe("@interactive-os/json-document-collaboration", () => { value: "Edited old member", }]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ cards: { @@ -344,7 +346,7 @@ describe("@interactive-os/json-document-collaboration", () => { source.document.commit([ { op: "replace", path: "/title", value: "Second" }, ]); - const exported = source.collaboration.exportBundle(); + const exported = source.replica.exportBundle(); const first = exported.changes[0]; const second = exported.changes[1]; if (first === undefined || second === undefined) { @@ -355,7 +357,7 @@ describe("@interactive-os/json-document-collaboration", () => { epoch: exported.epoch, changes: [second], }; - expect(target.collaboration.ingest(secondOnly)).toMatchObject({ + expect(target.replica.ingest(secondOnly)).toMatchObject({ ok: true, integrated: [], pending: [{ actorId: "actor-a", counter: 2 }], @@ -364,7 +366,7 @@ describe("@interactive-os/json-document-collaboration", () => { const publications: unknown[] = []; target.document.subscribe((change) => publications.push(change)); - expect(target.collaboration.ingest({ + expect(target.replica.ingest({ epoch: exported.epoch, changes: [first], })).toMatchObject({ @@ -388,18 +390,18 @@ describe("@interactive-os/json-document-collaboration", () => { source.document.commit([ { op: "replace", path: "/title", value: "Second" }, ]); - const bundle = source.collaboration.exportBundle(); + const bundle = source.replica.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) => { + const snapshots: ReturnType[] = []; + const unsubscribe = target.replica.subscribe((snapshot) => { snapshots.push(snapshot); }); - target.collaboration.ingest({ + target.replica.ingest({ epoch: bundle.epoch, changes: [second], }); @@ -411,14 +413,14 @@ describe("@interactive-os/json-document-collaboration", () => { expect(Object.isFrozen(pendingSnapshot?.pending[0])).toBe(true); expect(Object.isFrozen(pendingSnapshot?.pending[0]?.missing)).toBe(true); - target.collaboration.ingest({ + target.replica.ingest({ epoch: bundle.epoch, changes: [second], }); expect(snapshots).toHaveLength(1); unsubscribe(); - target.collaboration.ingest({ + target.replica.ingest({ epoch: bundle.epoch, changes: [first], }); @@ -435,22 +437,22 @@ describe("@interactive-os/json-document-collaboration", () => { let reentered = false; const publishedHeads: string[] = []; - target.collaboration.subscribe(() => { + target.replica.subscribe(() => { if (reentered) return; reentered = true; expect(target.document.commit([ { op: "replace", path: "/done", value: true }, ])).toMatchObject({ ok: true }); }); - target.collaboration.subscribe((snapshot) => { + target.replica.subscribe((snapshot) => { const head = snapshot.heads[0]; if (head !== undefined) { publishedHeads.push(`${head.actorId}:${head.counter}`); } }); - expect(target.collaboration.ingest( - source.collaboration.exportBundle(), + expect(target.replica.ingest( + source.replica.exportBundle(), )).toMatchObject({ ok: true }); expect(publishedHeads).toEqual(["actor-a:1", "actor-b:1"]); expect(target.document.value).toMatchObject({ @@ -467,21 +469,21 @@ describe("@interactive-os/json-document-collaboration", () => { ]); let laterListenerCalls = 0; - target.collaboration.subscribe(() => { + target.replica.subscribe(() => { throw new Error("listener failed"); }); - target.collaboration.subscribe(() => { + target.replica.subscribe(() => { laterListenerCalls += 1; }); - expect(() => target.collaboration.ingest( - source.collaboration.exportBundle(), + expect(() => target.replica.ingest( + source.replica.exportBundle(), )).not.toThrow(); expect(laterListenerCalls).toBe(1); expect(target.document.value).toMatchObject({ title: "Integrated" }); }); - test("skips a collaboration listener unsubscribed during publication", () => { + test("skips a collaboration listener unsubscribed during notification", () => { const source = runtime("actor-a"); const target = runtime("actor-b"); source.document.commit([ @@ -491,21 +493,21 @@ describe("@interactive-os/json-document-collaboration", () => { const firstListener = vi.fn(); const secondListener = vi.fn(); let unsubscribeSecond = (): void => undefined; - target.collaboration.subscribe((snapshot) => { + target.replica.subscribe((snapshot) => { firstListener(snapshot); unsubscribeSecond(); }); - unsubscribeSecond = target.collaboration.subscribe(secondListener); + unsubscribeSecond = target.replica.subscribe(secondListener); - expect(target.collaboration.ingest( - source.collaboration.exportBundle(), + expect(target.replica.ingest( + source.replica.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) => { + test("suppresses a whole concurrent Change when the combined document is invalid", () => { + const validate = (candidate: unknown) => { const value = candidate as { readonly local: boolean; readonly remote: boolean; @@ -524,7 +526,7 @@ describe("@interactive-os/json-document-collaboration", () => { id: "test/exclusive-flags", digest: "test/exclusive-flags/v1", }, - accepts, + validate, }; const left = runtime( "actor-a", @@ -544,15 +546,15 @@ describe("@interactive-os/json-document-collaboration", () => { { op: "replace", path: "/remote", value: true }, ]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ local: true, remote: false, }); expect(right.document.value).toEqual(left.document.value); - expect(left.collaboration.current().suppressed).toEqual([ + expect(left.replica.status().suppressed).toEqual([ { changeId: { actorId: "actor-b", counter: 1 }, code: "schema_violation", @@ -563,7 +565,7 @@ describe("@interactive-os/json-document-collaboration", () => { }); test("preserves explicit test preconditions when an ancestor Change is suppressed", () => { - const accepts = (candidate: unknown) => { + const validate = (candidate: unknown) => { const value = candidate as { readonly local: boolean; readonly remote: boolean; @@ -577,7 +579,7 @@ describe("@interactive-os/json-document-collaboration", () => { id: "test/causal-precondition", digest: "test/causal-precondition/v1", }, - accepts, + validate, }; const left = runtime( "actor-a", @@ -601,15 +603,15 @@ describe("@interactive-os/json-document-collaboration", () => { { op: "add", path: "/note", value: "ready" }, ]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ local: true, remote: false, }); expect(right.document.value).toEqual(left.document.value); - expect(left.collaboration.current().suppressed).toMatchObject([ + expect(left.replica.status().suppressed).toMatchObject([ { changeId: { actorId: "actor-b", counter: 1 }, code: "schema_violation", @@ -640,26 +642,26 @@ describe("@interactive-os/json-document-collaboration", () => { expect(() => runtime( "invalid-initial", { forbidden: true }, - { ...overrides, accepts: mutatingAcceptance }, + { ...overrides, validate: 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, + validate: mutatingAcceptance, }); source.document.commit([ { op: "replace", path: "/forbidden", value: true }, ]); - const untrusted = source.collaboration.exportBundle(); - expect(target.collaboration.ingest({ - epoch: target.collaboration.epoch, + const untrusted = source.replica.exportBundle(); + expect(target.replica.ingest({ + epoch: target.replica.epoch, changes: untrusted.changes, })) .toMatchObject({ ok: true }); expect(target.document.value).toEqual({ forbidden: false }); - expect(target.collaboration.current().suppressed).toMatchObject([ + expect(target.replica.status().suppressed).toMatchObject([ { changeId: { actorId: "actor-a", counter: 1 }, code: "schema_violation", @@ -678,12 +680,12 @@ describe("@interactive-os/json-document-collaboration", () => { { op: "add", path: "/fields/name", value: "Right" }, ]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ fields: { name: "Right" } }); expect(right.document.value).toEqual(left.document.value); - expect(left.collaboration.current().conflicts).toMatchObject([ + expect(left.replica.status().conflicts).toMatchObject([ { kind: "object-key", key: "name", @@ -691,13 +693,13 @@ describe("@interactive-os/json-document-collaboration", () => { ]); const removal = [{ op: "remove", path: "/fields/name" }] as const; - expect(left.document.canPatch(removal)).toEqual({ ok: true }); + expect(left.document.validatePatch(removal)).toEqual({ ok: true }); expect(left.document.commit(removal)).toMatchObject({ ok: true }); - right.collaboration.ingest(left.collaboration.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ fields: {} }); expect(right.document.value).toEqual(left.document.value); - expect(left.collaboration.current().conflicts).toEqual([]); + expect(left.replica.status().conflicts).toEqual([]); }); test("retains concurrent moves that replace the same destination member", () => { @@ -720,13 +722,13 @@ describe("@interactive-os/json-document-collaboration", () => { path: "/y", }]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.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([ + expect(left.replica.status().suppressed).toEqual([]); + expect(left.replica.status().conflicts).toMatchObject([ { kind: "object-key", key: "y", @@ -743,17 +745,17 @@ describe("@interactive-os/json-document-collaboration", () => { right.document.commit([ { op: "add", path: "/fields/name", value: "Right" }, ]); - left.collaboration.ingest(right.collaboration.exportBundle()); - right.collaboration.ingest(left.collaboration.exportBundle()); + left.replica.ingest(right.replica.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); const move = [{ op: "move", from: "/fields/name", path: "/fields/other", }] as const; - expect(left.document.canPatch(move)).toEqual({ ok: true }); + expect(left.document.validatePatch(move)).toEqual({ ok: true }); expect(left.document.commit(move)).toMatchObject({ ok: true }); - right.collaboration.ingest(left.collaboration.exportBundle()); + right.replica.ingest(left.replica.exportBundle()); expect(left.document.value).toEqual({ fields: { @@ -761,7 +763,7 @@ describe("@interactive-os/json-document-collaboration", () => { }, }); expect(right.document.value).toEqual(left.document.value); - expect(left.collaboration.current().conflicts).toEqual([]); + expect(left.replica.status().conflicts).toEqual([]); }); test("keeps conflict cleanup effective when an alternative was concurrently removed", () => { @@ -782,8 +784,8 @@ describe("@interactive-os/json-document-collaboration", () => { resolver.document.commit([ { op: "replace", path: "/sequence", value: 2 }, ]); - resolver.collaboration.ingest(left.collaboration.exportBundle()); - resolver.collaboration.ingest(right.collaboration.exportBundle()); + resolver.replica.ingest(left.replica.exportBundle()); + resolver.replica.ingest(right.replica.exportBundle()); left.document.commit([ { op: "remove", path: "/fields/name" }, @@ -793,15 +795,15 @@ describe("@interactive-os/json-document-collaboration", () => { ]); const receiver = runtime("receiver", initial); - receiver.collaboration.ingest(left.collaboration.exportBundle()); - receiver.collaboration.ingest(right.collaboration.exportBundle()); - receiver.collaboration.ingest(resolver.collaboration.exportBundle()); + receiver.replica.ingest(left.replica.exportBundle()); + receiver.replica.ingest(right.replica.exportBundle()); + receiver.replica.ingest(resolver.replica.exportBundle()); expect(receiver.document.value).toEqual({ fields: {}, sequence: 2, }); - expect(receiver.collaboration.current().suppressed).toEqual([]); + expect(receiver.replica.status().suppressed).toEqual([]); }); test("does not report causally superseded writes as concurrent conflicts", () => { @@ -815,7 +817,7 @@ describe("@interactive-os/json-document-collaboration", () => { ]); expect(shared.document.value).toMatchObject({ title: "Second" }); - expect(shared.collaboration.current().conflicts).toEqual([]); + expect(shared.replica.status().conflicts).toEqual([]); }); test("converges after receiving the same three branches in different orders", () => { @@ -831,20 +833,20 @@ describe("@interactive-os/json-document-collaboration", () => { authors[2]?.document.commit([ { op: "replace", path: "/done", value: true }, ]); - const bundles = authors.map((author) => author.collaboration.exportBundle()); + const bundles = authors.map((author) => author.replica.exportBundle()); const first = runtime("receiver-a"); const second = runtime("receiver-b"); for (const index of [0, 1, 2]) { - first.collaboration.ingest(bundles[index]); + first.replica.ingest(bundles[index]); } for (const index of [2, 0, 1]) { - second.collaboration.ingest(bundles[index]); + second.replica.ingest(bundles[index]); } expect(first.document.value).toEqual(second.document.value); - expect(first.collaboration.current()).toEqual( - second.collaboration.current(), + expect(first.replica.status()).toEqual( + second.replica.status(), ); }); @@ -859,16 +861,16 @@ describe("@interactive-os/json-document-collaboration", () => { right.document.commit([ { op: "replace", path: "/title", value: "Other" }, ]); - const before = left.collaboration.current(); + const before = left.replica.status(); - expect(left.collaboration.ingest(right.collaboration.exportBundle())) + expect(left.replica.ingest(right.replica.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); + expect(left.replica.status()).toEqual(before); }); test("treats identical delivery as idempotent and rejects changed duplicate payloads", () => { @@ -877,17 +879,17 @@ describe("@interactive-os/json-document-collaboration", () => { source.document.commit([ { op: "replace", path: "/title", value: "Once" }, ]); - const bundle = source.collaboration.exportBundle(); + const bundle = source.replica.exportBundle(); - expect(target.collaboration.ingest(bundle)).toMatchObject({ ok: true }); - expect(target.collaboration.ingest(bundle)).toMatchObject({ + expect(target.replica.ingest(bundle)).toMatchObject({ ok: true }); + expect(target.replica.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({ + expect(target.replica.ingest({ epoch: bundle.epoch, changes: [{ ...change, @@ -909,9 +911,9 @@ describe("@interactive-os/json-document-collaboration", () => { test("rejects a non-initial actor counter before it can poison history", () => { const shared = runtime("actor-a"); - const before = shared.collaboration.current(); + const before = shared.replica.status(); const exhausted: CollaborationBundle = { - epoch: shared.collaboration.epoch, + epoch: shared.replica.epoch, changes: [{ changeId: { actorId: "actor-a", @@ -921,7 +923,7 @@ describe("@interactive-os/json-document-collaboration", () => { ops: [], }], }; - expect(shared.collaboration.ingest(exhausted)).toEqual({ + expect(shared.replica.ingest(exhausted)).toEqual({ ok: false, code: "actor_fork", reason: "one actorId must form one contiguous causal change chain", @@ -930,16 +932,16 @@ describe("@interactive-os/json-document-collaboration", () => { counter: Number.MAX_SAFE_INTEGER, }, }); - expect(shared.collaboration.current()).toEqual(before); - expect(shared.collaboration.exportBundle().changes).toEqual([]); + expect(shared.replica.status()).toEqual(before); + expect(shared.replica.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 before = target.replica.status(); const forked: CollaborationBundle = { - epoch: target.collaboration.epoch, + epoch: target.replica.epoch, changes: [ { changeId: { actorId: "actor-a", counter: 1 }, @@ -954,20 +956,20 @@ describe("@interactive-os/json-document-collaboration", () => { ], }; - expect(target.collaboration.ingest(forked)).toEqual({ + expect(target.replica.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([]); + expect(target.replica.status()).toEqual(before); + expect(target.replica.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, + epoch: target.replica.epoch, changes: [{ changeId: { actorId: "actor-a", counter: 2 }, deps: [{ actorId: "actor-b", counter: 1 }], @@ -975,19 +977,19 @@ describe("@interactive-os/json-document-collaboration", () => { }], }; - expect(target.collaboration.ingest(fork)).toEqual({ + expect(target.replica.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([]); + expect(target.replica.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, + epoch: target.replica.epoch, changes: [{ changeId: { actorId: "actor-a", counter: 1 }, deps: [{ actorId: "actor-a", counter: 2 }], @@ -995,14 +997,14 @@ describe("@interactive-os/json-document-collaboration", () => { }], }; - expect(target.collaboration.ingest(poisoned)).toEqual({ + expect(target.replica.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([]); + expect(target.replica.exportBundle().changes).toEqual([]); + expect(target.replica.status().pending).toEqual([]); }); test("does not let a restored actor fork while its own history is pending", () => { @@ -1013,7 +1015,7 @@ describe("@interactive-os/json-document-collaboration", () => { source.document.commit([ { op: "replace", path: "/title", value: "Second" }, ]); - const bundle = source.collaboration.exportBundle(); + const bundle = source.replica.exportBundle(); const first = bundle.changes[0]; const second = bundle.changes[1]; if (first === undefined || second === undefined) { @@ -1021,7 +1023,7 @@ describe("@interactive-os/json-document-collaboration", () => { } const restored = runtime("actor-a"); - expect(restored.collaboration.ingest({ + expect(restored.replica.ingest({ epoch: bundle.epoch, changes: [second], })).toMatchObject({ @@ -1033,22 +1035,22 @@ describe("@interactive-os/json-document-collaboration", () => { path: "/title", value: "Fork", }] as const; - expect(restored.document.canPatch(operation)).toEqual({ + expect(restored.document.validatePatch(operation)).toEqual({ ok: false, code: "actor_history_pending", reason: "cannot author while this actor has pending causal history", }); - expect(restored.collaboration.ingest({ + expect(restored.replica.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())) + expect(peer.replica.ingest(restored.replica.exportBundle())) .toMatchObject({ ok: true }); expect(peer.document.value).toMatchObject({ title: "Fork" }); - expect(peer.collaboration.current().conflicts).toEqual([]); + expect(peer.replica.status().conflicts).toEqual([]); }); }); diff --git a/packages/json-document-collaboration/tests/convergence-soak.test.ts b/packages/json-document-collaboration/tests/convergence-soak.test.ts index 4f3c40ca..b0c081dc 100644 --- a/packages/json-document-collaboration/tests/convergence-soak.test.ts +++ b/packages/json-document-collaboration/tests/convergence-soak.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from "vitest"; import { compactCollaborationCheckpoint, - createCollaborationHistoryRuntime, - restoreCollaborationHistoryRuntime, + createHistoryRuntime, + restoreHistoryRuntime, type CollaborationBundle, type CollaborationChange, - type CollaborationHistoryRuntime, + type HistoryRuntime, type CollaborationRulesetIdentity, } from "../src/history-index.js"; import { @@ -44,8 +44,8 @@ function runCase(seed: number): void { digest: "test/task-board-soak/v1", }; const epochId = `task-board-soak/${seed}/v1`; - const runtimes: CollaborationHistoryRuntime[] = actorIds.map((actorId) => ( - createCollaborationHistoryRuntime(taskBoardInitialValue(), { + const runtimes: HistoryRuntime[] = actorIds.map((actorId) => ( + createHistoryRuntime(taskBoardInitialValue(), { actorId, epochId, ruleset, @@ -58,7 +58,7 @@ function runCase(seed: number): void { for (let step = 0; step < stepCount; step += 1) { const actorIndex = integer(random, runtimes.length); authorRandomCommand( - runtimes[actorIndex] as CollaborationHistoryRuntime, + runtimes[actorIndex] as HistoryRuntime, hosts[actorIndex] as TaskBoardHost, actorIds[actorIndex] as string, step, @@ -73,11 +73,11 @@ function runCase(seed: number): void { converge(runtimes, random, seed, "before-restore"); assertConverged(runtimes, seed, "before-restore"); - const crashCheckpoint = runtimes[0]?.collaboration.exportCheckpoint(); + const crashCheckpoint = runtimes[0]?.replica.exportCheckpoint(); if (crashCheckpoint === undefined) { throw new Error(`seed ${seed}: missing crash checkpoint`); } - const restored = restoreCollaborationHistoryRuntime(crashCheckpoint, { + const restored = restoreHistoryRuntime(crashCheckpoint, { actorId: actorIds[0], ruleset, }); @@ -98,7 +98,7 @@ function runCase(seed: number): void { assertConverged(runtimes, seed, "after-restore"); const checkpoints = runtimes.map( - (runtime) => runtime.collaboration.exportCheckpoint(), + (runtime) => runtime.replica.exportCheckpoint(), ); for (const checkpoint of checkpoints.slice(1)) { expect( @@ -112,7 +112,7 @@ function runCase(seed: number): void { if (checkpoint === undefined) { throw new Error(`seed ${seed}: checkpoint ${index} is missing`); } - const roundTrip = restoreCollaborationHistoryRuntime(checkpoint, { + const roundTrip = restoreHistoryRuntime(checkpoint, { actorId: actorIds[index] as string, ruleset, }); @@ -124,16 +124,16 @@ function runCase(seed: number): void { } expect( roundTrip.runtime.document.value, - `seed ${seed}: checkpoint projection ${index}`, + `seed ${seed}: checkpoint document ${index}`, ).toEqual(runtimes[0]?.document.value); expect( - roundTrip.runtime.collaboration.current(), + roundTrip.runtime.replica.status(), `seed ${seed}: checkpoint causal snapshot ${index}`, - ).toEqual(runtimes[0]?.collaboration.current()); + ).toEqual(runtimes[0]?.replica.status()); expect( stableHistory(roundTrip.runtime), `seed ${seed}: checkpoint history ${index}`, - ).toEqual(stableHistory(runtimes[index] as CollaborationHistoryRuntime)); + ).toEqual(stableHistory(runtimes[index] as HistoryRuntime)); } const checkpoint = checkpoints[0]; @@ -158,7 +158,7 @@ function runCase(seed: number): void { expect(compacted.report.discardedChanges).toBeGreaterThan(0); expect(compacted.checkpoint.payload.changes).toEqual([]); - const compactedRestore = restoreCollaborationHistoryRuntime( + const compactedRestore = restoreHistoryRuntime( compacted.checkpoint, { actorId: "post-compaction", @@ -173,13 +173,13 @@ function runCase(seed: number): void { } expect( compactedRestore.runtime.document.value, - `seed ${seed}: compacted projection`, + `seed ${seed}: compacted document`, ).toEqual(runtimes[0]?.document.value); - expect(compactedRestore.runtime.collaboration.current().pending).toEqual([]); + expect(compactedRestore.runtime.replica.status().pending).toEqual([]); } function authorRandomCommand( - runtime: CollaborationHistoryRuntime, + runtime: HistoryRuntime, host: TaskBoardHost, actorId: string, step: number, @@ -265,7 +265,7 @@ function authorRandomCommand( } function deliverRandomSubset( - runtimes: ReadonlyArray, + runtimes: ReadonlyArray, random: () => number, seed: number, step: number, @@ -274,9 +274,9 @@ function deliverRandomSubset( const targetIndex = ( sourceIndex + 1 + integer(random, runtimes.length - 1) ) % runtimes.length; - const source = runtimes[sourceIndex] as CollaborationHistoryRuntime; - const target = runtimes[targetIndex] as CollaborationHistoryRuntime; - const exported = source.collaboration.exportBundle(); + const source = runtimes[sourceIndex] as HistoryRuntime; + const target = runtimes[targetIndex] as HistoryRuntime; + const exported = source.replica.exportBundle(); if (exported.changes.length === 0) return; let selected = exported.changes.filter(() => random() < 0.45); @@ -292,13 +292,13 @@ function deliverRandomSubset( changes: shuffle(selected, random), }; requireIngest( - target.collaboration.ingest(bundle), + target.replica.ingest(bundle), seed, `step ${step}: ${sourceIndex}->${targetIndex}`, ); if (random() < 0.25) { requireIngest( - target.collaboration.ingest(bundle), + target.replica.ingest(bundle), seed, `step ${step}: duplicate ${sourceIndex}->${targetIndex}`, ); @@ -306,16 +306,16 @@ function deliverRandomSubset( } function converge( - runtimes: ReadonlyArray, + runtimes: ReadonlyArray, random: () => number, seed: number, phase: string, ): void { const union = unionChanges(runtimes, seed); for (let index = 0; index < runtimes.length; index += 1) { - const runtime = runtimes[index] as CollaborationHistoryRuntime; - const result = runtime.collaboration.ingest({ - epoch: runtime.collaboration.epoch, + const runtime = runtimes[index] as HistoryRuntime; + const result = runtime.replica.ingest({ + epoch: runtime.replica.epoch, changes: shuffle(union, random), }); requireIngest(result, seed, `${phase}: converge replica ${index}`); @@ -328,12 +328,12 @@ function converge( } function unionChanges( - runtimes: ReadonlyArray, + runtimes: ReadonlyArray, seed: number, ): ReadonlyArray { const changes = new Map(); for (const runtime of runtimes) { - for (const change of runtime.collaboration.exportBundle().changes) { + for (const change of runtime.replica.exportBundle().changes) { const key = `${change.changeId.actorId}:${change.changeId.counter}`; const existing = changes.get(key); if ( @@ -349,7 +349,7 @@ function unionChanges( } function assertConverged( - runtimes: ReadonlyArray, + runtimes: ReadonlyArray, seed: number, phase: string, ): void { @@ -358,25 +358,25 @@ function assertConverged( throw new Error(`seed ${seed}: no replicas`); } for (let index = 1; index < runtimes.length; index += 1) { - const runtime = runtimes[index] as CollaborationHistoryRuntime; + const runtime = runtimes[index] as HistoryRuntime; expect( runtime.document.value, - `seed ${seed}: ${phase}: projection ${index}`, + `seed ${seed}: ${phase}: document ${index}`, ).toEqual(expected.document.value); expect( - runtime.collaboration.current(), + runtime.replica.status(), `seed ${seed}: ${phase}: causal snapshot ${index}`, - ).toEqual(expected.collaboration.current()); + ).toEqual(expected.replica.status()); } } -function stableHistory(runtime: CollaborationHistoryRuntime): { +function stableHistory(runtime: HistoryRuntime): { readonly undoTarget: unknown; readonly redoTarget: unknown; readonly undoDepth: number; readonly redoDepth: number; } { - const history = runtime.history.current(); + const history = runtime.history.status(); return { undoTarget: history.undoTarget, redoTarget: history.redoTarget, diff --git a/packages/json-document-collaboration/tests/foundation-parity.test.ts b/packages/json-document-collaboration/tests/foundation-parity.test.ts index cf8c470e..21b5b1b1 100644 --- a/packages/json-document-collaboration/tests/foundation-parity.test.ts +++ b/packages/json-document-collaboration/tests/foundation-parity.test.ts @@ -1,8 +1,8 @@ import type { JSONValue } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; -import { parseArrayIndex } from "../src/arrayIndex.js"; -import { jsonEqual } from "../src/jsonEqual.js"; +import { parseArrayIndex } from "../src/array-index.js"; +import { jsonEqual } from "../src/json-equal.js"; import rawVectors from "../../json-document/tests/conformance/v2/foundation-vectors.json" with { type: "json" }; interface FoundationVectors { diff --git a/packages/json-document-collaboration/tests/history.test.ts b/packages/json-document-collaboration/tests/history.test.ts index c77704a3..d181a790 100644 --- a/packages/json-document-collaboration/tests/history.test.ts +++ b/packages/json-document-collaboration/tests/history.test.ts @@ -5,7 +5,7 @@ import { type CollaborationBundle, type CollaborationRuntimeOptions, } from "../src/index.js"; -import { createCollaborationHistoryRuntime } from "../src/history-index.js"; +import { createHistoryRuntime } from "../src/history-index.js"; const baseOptions = { epochId: "shared-history-document/v1", @@ -23,7 +23,7 @@ function runtime( }, overrides: Partial = {}, ) { - return createCollaborationHistoryRuntime(initial, { + return createHistoryRuntime(initial, { ...baseOptions, actorId, ...overrides, @@ -49,13 +49,14 @@ function oneChangeBundle( } describe("@interactive-os/json-document-collaboration/history", () => { - test("adds history outside the unchanged six-member document", () => { + test("adds history outside the canonical JSON Document", () => { const shared = runtime("actor-a"); expect(Object.keys(shared).sort()).toEqual([ "collaboration", "document", "history", + "replica", ]); expect(Object.keys(shared.document).sort()).toEqual([ "at", @@ -63,6 +64,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { "commit", "query", "subscribe", + "validatePatch", "value", ]); }); @@ -82,10 +84,10 @@ describe("@interactive-os/json-document-collaboration/history", () => { value: "Right", }])).toMatchObject({ ok: true }); - expect(left.collaboration.ingest(right.collaboration.exportBundle())) + expect(left.replica.ingest(right.replica.exportBundle())) .toMatchObject({ ok: true }); expect(left.document.value).toMatchObject({ title: "Right" }); - expect(left.history.current().undoTarget).toEqual({ + expect(left.history.status().undoTarget).toEqual({ actorId: "actor-a", counter: 1, }); @@ -93,27 +95,27 @@ describe("@interactive-os/json-document-collaboration/history", () => { const documentListener = vi.fn(); const collaborationListener = vi.fn(); left.document.subscribe(documentListener); - left.collaboration.subscribe(collaborationListener); + left.replica.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({ + expect(left.history.status().redoTarget).toEqual({ actorId: "actor-a", counter: 1, }); - expect(left.collaboration.exportBundle().changes).toHaveLength(3); + expect(left.replica.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(left.replica.exportBundle().changes).toHaveLength(4); expect(documentListener).not.toHaveBeenCalled(); expect(collaborationListener).toHaveBeenCalledTimes(2); - expect(right.collaboration.ingest(left.collaboration.exportBundle())) + expect(right.replica.ingest(left.replica.exportBundle())) .toMatchObject({ ok: true }); expect(right.document.value).toEqual(left.document.value); }); @@ -127,29 +129,29 @@ describe("@interactive-os/json-document-collaboration/history", () => { path: "/cards/draft", value: { title: "Draft" }, }])).toMatchObject({ ok: true }); - expect(right.collaboration.ingest(left.collaboration.exportBundle())) + expect(right.replica.ingest(left.replica.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())) + expect(left.replica.ingest(right.replica.exportBundle())) .toMatchObject({ ok: true }); - const before = left.collaboration.exportBundle(); - expect(left.history.current().undoTarget).toEqual({ + const before = left.replica.exportBundle(); + expect(left.history.status().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({ + expect(left.replica.exportBundle()).toEqual(before); + expect(left.history.status()).toMatchObject({ undoTarget: { actorId: "actor-a", counter: 1 }, }); - expect(left.history.current().redoTarget).toBeNull(); + expect(left.history.status().redoTarget).toBeNull(); expect(left.document.value).toEqual({ cards: { draft: { @@ -160,7 +162,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { }); test("re-evaluates a schema-suppressed remote Change after undo", () => { - const accepts = (candidate: unknown) => { + const validate = (candidate: unknown) => { const value = candidate as { readonly local: boolean; readonly remote: boolean; @@ -178,7 +180,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { id: "test/exclusive-history-flags", digest: "test/exclusive-history-flags/v1", }, - accepts, + validate, }; const left = runtime( "actor-a", @@ -201,14 +203,14 @@ describe("@interactive-os/json-document-collaboration/history", () => { path: "/remote", value: true, }])).toMatchObject({ ok: true }); - expect(left.collaboration.ingest(right.collaboration.exportBundle())) + expect(left.replica.ingest(right.replica.exportBundle())) .toMatchObject({ ok: true }); expect(left.document.value).toEqual({ local: true, remote: false, }); - expect(left.collaboration.current().suppressed).toMatchObject([{ + expect(left.replica.status().suppressed).toMatchObject([{ changeId: { actorId: "actor-b", counter: 1 }, code: "schema_violation", }]); @@ -218,13 +220,13 @@ describe("@interactive-os/json-document-collaboration/history", () => { local: false, remote: true, }); - expect(left.collaboration.current().suppressed).toEqual([]); + expect(left.replica.status().suppressed).toEqual([]); - expect(right.collaboration.ingest(left.collaboration.exportBundle())) + expect(right.replica.ingest(left.replica.exportBundle())) .toMatchObject({ ok: true }); expect(right.document.value).toEqual(left.document.value); - expect(right.collaboration.current()).toEqual( - left.collaboration.current(), + expect(right.replica.status()).toEqual( + left.replica.status(), ); }); @@ -246,15 +248,15 @@ describe("@interactive-os/json-document-collaboration/history", () => { expect(author.history.redo()).toMatchObject({ ok: true }); expect(author.document.value).toEqual({}); - expect(author.collaboration.ingest(remote.collaboration.exportBundle())) + expect(author.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); expect(author.document.value).toEqual({}); - expect(author.collaboration.current().suppressed).toMatchObject([{ + expect(author.replica.status().suppressed).toMatchObject([{ changeId: { actorId: "actor-b", counter: 1 }, code: "target_not_found", }]); - expect(author.history.current()).toEqual({ + expect(author.history.status()).toEqual({ undoTarget: { actorId: "actor-a", counter: 1 }, redoTarget: null, undoDepth: 1, @@ -279,7 +281,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { expect(author.document.value).toEqual({ items: ["A", "C", "D", "B"], }); - expect(remote.collaboration.ingest(author.collaboration.exportBundle())) + expect(remote.replica.ingest(author.replica.exportBundle())) .toMatchObject({ ok: true }); expect(author.history.undo()).toMatchObject({ ok: true }); @@ -296,19 +298,19 @@ describe("@interactive-os/json-document-collaboration/history", () => { items: ["A", "C", "D", "X", "B"], }); - expect(author.collaboration.ingest(remote.collaboration.exportBundle())) + expect(author.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); expect(author.document.value).toEqual({ items: ["A", "B", "C", "D", "X"], }); - expect(author.collaboration.current().suppressed).toEqual([]); + expect(author.replica.status().suppressed).toEqual([]); - expect(remote.collaboration.ingest(author.collaboration.exportBundle())) + expect(remote.replica.ingest(author.replica.exportBundle())) .toMatchObject({ ok: true }); expect(remote.document.value).toEqual(author.document.value); - expect(remote.collaboration.current()).toEqual( - author.collaboration.current(), + expect(remote.replica.status()).toEqual( + author.replica.status(), ); }); @@ -326,35 +328,35 @@ describe("@interactive-os/json-document-collaboration/history", () => { path: "/done", value: true, }])).toMatchObject({ ok: true }); - expect(author.collaboration.ingest(remote.collaboration.exportBundle())) + expect(author.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); expect(author.history.undo()).toMatchObject({ ok: true }); - const complete = author.collaboration.exportBundle(); + const complete = author.replica.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)) + expect(inOrder.replica.ingest(complete)) .toMatchObject({ ok: true }); - expect(outOfOrder.collaboration.ingest(undo)) + expect(outOfOrder.replica.ingest(undo)) .toMatchObject({ ok: true, pending: [{ actorId: "actor-a", counter: 2 }], }); - expect(outOfOrder.collaboration.ingest(remoteEdit)) + expect(outOfOrder.replica.ingest(remoteEdit)) .toMatchObject({ ok: true }); - expect(outOfOrder.collaboration.ingest(localEdit)) + expect(outOfOrder.replica.ingest(localEdit)) .toMatchObject({ ok: true, pending: [] }); expect(outOfOrder.document.value).toEqual(inOrder.document.value); - expect(outOfOrder.collaboration.current()).toEqual( - inOrder.collaboration.current(), + expect(outOfOrder.replica.status()).toEqual( + inOrder.replica.status(), ); - expect(outOfOrder.collaboration.exportBundle()).toEqual( - inOrder.collaboration.exportBundle(), + expect(outOfOrder.replica.exportBundle()).toEqual( + inOrder.replica.exportBundle(), ); }); @@ -372,18 +374,18 @@ describe("@interactive-os/json-document-collaboration/history", () => { { ...baseOptions, actorId: "relay" }, ); expect("history" in relay).toBe(false); - expect(relay.collaboration.ingest( - author.collaboration.exportBundle(), + expect(relay.replica.ingest( + author.replica.exportBundle(), )).toMatchObject({ ok: true }); expect(relay.document.value).toEqual(author.document.value); const receiver = runtime("receiver"); - expect(receiver.collaboration.ingest( - relay.collaboration.exportBundle(), + expect(receiver.replica.ingest( + relay.replica.exportBundle(), )).toMatchObject({ ok: true }); expect(receiver.document.value).toEqual(author.document.value); - expect(receiver.collaboration.current()).toEqual( - author.collaboration.current(), + expect(receiver.replica.status()).toEqual( + author.replica.status(), ); }); @@ -400,7 +402,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { shared.document.subscribe(() => { documentValues.push(shared.document.value); }); - shared.collaboration.subscribe(collaborationListener); + shared.replica.subscribe(collaborationListener); expect(shared.history.canUndo()).toMatchObject({ ok: true }); expect(shared.history.undo()).toMatchObject({ ok: true }); @@ -422,7 +424,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { value: "First", }]); expect(shared.history.undo()).toMatchObject({ ok: true }); - expect(shared.history.current().redoTarget).toEqual({ + expect(shared.history.status().redoTarget).toEqual({ actorId: "actor-a", counter: 1, }); @@ -433,7 +435,7 @@ describe("@interactive-os/json-document-collaboration/history", () => { value: true, }]); - expect(shared.history.current().redoTarget).toBeNull(); + expect(shared.history.status().redoTarget).toBeNull(); expect(shared.history.canRedo()).toMatchObject({ ok: false, code: "nothing_to_redo", @@ -458,8 +460,8 @@ describe("@interactive-os/json-document-collaboration/history", () => { expect(shared.history.undo()).toMatchObject({ ok: true }); expect(shared.history.undo()).toMatchObject({ ok: true }); - const epoch = shared.collaboration.epoch; - expect(shared.collaboration.ingest({ + const epoch = shared.replica.epoch; + expect(shared.replica.ingest({ epoch, changes: [{ changeId: { actorId: "actor-a", counter: 5 }, @@ -478,11 +480,11 @@ describe("@interactive-os/json-document-collaboration/history", () => { first: 0, second: 0, }); - expect(shared.collaboration.current().suppressed).toMatchObject([{ + expect(shared.replica.status().suppressed).toMatchObject([{ changeId: { actorId: "actor-a", counter: 5 }, code: "redo_target_invalid", }]); - expect(shared.history.current()).toEqual({ + expect(shared.history.status()).toEqual({ undoTarget: null, redoTarget: { actorId: "actor-a", counter: 1 }, undoDepth: 0, diff --git a/packages/json-document-collaboration/tests/naming-compatibility.test.ts b/packages/json-document-collaboration/tests/naming-compatibility.test.ts new file mode 100644 index 00000000..ede2b4f2 --- /dev/null +++ b/packages/json-document-collaboration/tests/naming-compatibility.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; + +import { + createCollaborationRuntime, + type CollaborationControl, + type CollaborationReplica, + type CollaborationSnapshot, + type ReplicaStatus, +} from "../src/index.js"; +import { + createCollaborationHistoryRuntime, + createHistoryRuntime, + type CollaborationHistorySnapshot, + type HistoryStatus, +} from "../src/history-index.js"; +import { + createCollaborationTextRuntime, + createTextRuntime, + type CollaborationTextSelection, + type TextSelection, +} from "../src/text-index.js"; + +const options = { + actorId: "compatibility", + epochId: "naming-compatibility/v1", + ruleset: { id: "naming-compatibility", digest: "sha256:naming-compatibility" }, +}; + +describe("deprecated naming compatibility", () => { + test("canonical and compatibility runtime names share one implementation", () => { + expect(createCollaborationHistoryRuntime).toBe(createHistoryRuntime); + expect(createCollaborationTextRuntime).toBe(createTextRuntime); + + const runtime = createCollaborationRuntime({}, options); + expect(runtime.collaboration).toBe(runtime.replica); + const current = runtime.collaboration.current; + const status = runtime.replica.status; + expect(current()).toEqual(status()); + }); + + test("compatibility types are structural aliases", () => { + const replica = null as unknown as CollaborationReplica; + replica satisfies CollaborationControl; + + const status = null as unknown as ReplicaStatus; + status satisfies CollaborationSnapshot; + + const historyStatus = null as unknown as HistoryStatus; + historyStatus satisfies CollaborationHistorySnapshot; + + const selection = null as unknown as TextSelection; + selection satisfies CollaborationTextSelection; + }); +}); diff --git a/packages/json-document-collaboration/tests/provider-substitution.test.ts b/packages/json-document-collaboration/tests/provider-substitution.test.ts index a0f9173a..25baed59 100644 --- a/packages/json-document-collaboration/tests/provider-substitution.test.ts +++ b/packages/json-document-collaboration/tests/provider-substitution.test.ts @@ -50,16 +50,16 @@ describe("provider-neutral task-board host", () => { expect(left.moveCard("base", "doing")).toMatchObject({ ok: true }); expect(right.updateCardText("base", "Edited on the other replica")) .toMatchObject({ ok: true }); - const leftBundle = leftRuntime.collaboration.exportBundle(); - const rightBundle = rightRuntime.collaboration.exportBundle(); - expect(leftRuntime.collaboration.ingest(rightBundle)) + const leftBundle = leftRuntime.replica.exportBundle(); + const rightBundle = rightRuntime.replica.exportBundle(); + expect(leftRuntime.replica.ingest(rightBundle)) .toMatchObject({ ok: true, pending: [] }); - expect(rightRuntime.collaboration.ingest(leftBundle)) + expect(rightRuntime.replica.ingest(leftBundle)) .toMatchObject({ ok: true, pending: [] }); expect(rightRuntime.document.value).toEqual(leftRuntime.document.value); - expect(rightRuntime.collaboration.current()).toEqual( - leftRuntime.collaboration.current(), + expect(rightRuntime.replica.status()).toEqual( + leftRuntime.replica.status(), ); expect(left.card("base")).toEqual({ id: "base", diff --git a/packages/json-document-collaboration/tests/support/task-board-host.ts b/packages/json-document-collaboration/tests/support/task-board-host.ts index a2dc2e6b..2ed35363 100644 --- a/packages/json-document-collaboration/tests/support/task-board-host.ts +++ b/packages/json-document-collaboration/tests/support/task-board-host.ts @@ -63,7 +63,7 @@ export function taskBoardInitialValue(): JSONValue { } /** - * A product-shaped consumer which knows only the six-member JSONDocument + * A product-shaped consumer which knows only the seven-member JSONDocument * contract. Provider construction, collaboration, transport, and history stay * outside this module. */ @@ -72,8 +72,8 @@ export function createTaskBoardHost(document: JSONDocument): TaskBoardHost { command: string, operations: ReadonlyArray, ): JSONDocumentCommitResult => { - const capability = document.canPatch(operations); - if (!capability.ok) return capability; + const validation = document.validatePatch(operations); + if (!validation.ok) return validation; return document.commit(operations, { metadata: { host: "task-board", diff --git a/packages/json-document-collaboration/tests/text-wire.test.ts b/packages/json-document-collaboration/tests/text-wire.test.ts index 8284ba6a..12d5aad1 100644 --- a/packages/json-document-collaboration/tests/text-wire.test.ts +++ b/packages/json-document-collaboration/tests/text-wire.test.ts @@ -50,7 +50,7 @@ function initialTextIds( segments: ReadonlyArray, value: string, ) { - const seed = `initial:${shared.collaboration.epoch.baseDigest}`; + const seed = `initial:${shared.replica.epoch.baseDigest}`; const path = [ "value", ...segments.map((segment) => `${segment.length}:${segment}`), @@ -58,7 +58,7 @@ function initialTextIds( const textNode = createTextNodeId(seed, path); return { member: segments.length === 0 - ? `root:${shared.collaboration.epoch.baseDigest}` + ? `root:${shared.replica.epoch.baseDigest}` : `${seed}:member:${path}`, textNode, atoms: Array.from(value, (_, unitIndex) => ( @@ -85,7 +85,7 @@ function bundle( changes: ReadonlyArray, ): CollaborationBundle { return { - epoch: shared.collaboration.epoch, + epoch: shared.replica.epoch, changes, }; } @@ -126,14 +126,14 @@ describe("protocol v3 text-splice wire materialization", () => { }), ]); - expect(receiver.collaboration.ingest(bundle(receiver, [textChange]))) + expect(receiver.replica.ingest(bundle(receiver, [textChange]))) .toMatchObject({ ok: true }); expect(receiver.document.value).toEqual({ title: "aXb" }); - expect(receiver.collaboration.exportBundle().changes[0]).toEqual( + expect(receiver.replica.exportBundle().changes[0]).toEqual( textChange, ); - expect(relay.collaboration.ingest(receiver.collaboration.exportBundle())) + expect(relay.replica.ingest(receiver.replica.exportBundle())) .toMatchObject({ ok: true }); expect(relay.document.value).toEqual(receiver.document.value); }); @@ -152,11 +152,11 @@ describe("protocol v3 text-splice wire materialization", () => { 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]))) + expect(shared.replica.ingest(bundle(shared, [candidate]))) .toMatchObject({ ok: true }); } expect(shared.document.value).toEqual({ title: "aXYb" }); - expect(shared.collaboration.current().suppressed).toEqual([]); + expect(shared.replica.status().suppressed).toEqual([]); } }); @@ -182,11 +182,11 @@ describe("protocol v3 text-splice wire materialization", () => { const first = runtime("receiver-a", initial); const second = runtime("receiver-b", initial); - expect(first.collaboration.ingest(bundle(first, [ + expect(first.replica.ingest(bundle(first, [ replacement, concurrentInsert, ]))).toMatchObject({ ok: true }); - expect(second.collaboration.ingest(bundle(second, [ + expect(second.replica.ingest(bundle(second, [ concurrentInsert, replacement, ]))).toMatchObject({ ok: true }); @@ -208,12 +208,12 @@ describe("protocol v3 text-splice wire materialization", () => { ]); const shared = runtime("receiver", initial); - expect(shared.collaboration.ingest(bundle(shared, [ + expect(shared.replica.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([]); + expect(shared.replica.status().suppressed).toEqual([]); }); test("uses lossless scalar units without normalization or surrogate splitting", () => { @@ -231,7 +231,7 @@ describe("protocol v3 text-splice wire materialization", () => { }), ]); - expect(shared.collaboration.ingest(bundle(shared, [unicodeChange]))) + expect(shared.replica.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( @@ -266,15 +266,15 @@ describe("protocol v3 text-splice wire materialization", () => { 2, ); - expect(shared.collaboration.ingest(bundle(shared, [remove]))) + expect(shared.replica.ingest(bundle(shared, [remove]))) .toMatchObject({ ok: true, pending: [{ actorId: "actor-a", counter: 2 }], }); - expect(shared.collaboration.ingest(bundle(shared, [insert]))) + expect(shared.replica.ingest(bundle(shared, [insert]))) .toMatchObject({ ok: true }); expect(shared.document.value).toEqual({ title: "X" }); - expect(shared.collaboration.current().pending).toEqual([]); + expect(shared.replica.status().pending).toEqual([]); }); test("preserves text identity through an object rename", () => { @@ -291,8 +291,8 @@ describe("protocol v3 text-splice wire materialization", () => { ]); const shared = runtime("receiver", initial); - expect(shared.collaboration.ingest(bundle(shared, [ - ...mover.collaboration.exportBundle().changes, + expect(shared.replica.ingest(bundle(shared, [ + ...mover.replica.exportBundle().changes, textChange, ]))).toMatchObject({ ok: true }); expect(shared.document.value).toEqual({ renamed: "aXb" }); @@ -312,12 +312,12 @@ describe("protocol v3 text-splice wire materialization", () => { ]); const shared = runtime("receiver", initial); - expect(shared.collaboration.ingest(bundle(shared, [ - ...mover.collaboration.exportBundle().changes, + expect(shared.replica.ingest(bundle(shared, [ + ...mover.replica.exportBundle().changes, textChange, ]))).toMatchObject({ ok: true }); expect(shared.document.value).toBe("aXb"); - expect(shared.collaboration.current().suppressed).toEqual([]); + expect(shared.replica.status().suppressed).toEqual([]); }); test("gives copied text a fresh generation", () => { @@ -334,8 +334,8 @@ describe("protocol v3 text-splice wire materialization", () => { ]); const shared = runtime("receiver", initial); - expect(shared.collaboration.ingest(bundle(shared, [ - ...copier.collaboration.exportBundle().changes, + expect(shared.replica.ingest(bundle(shared, [ + ...copier.replica.exportBundle().changes, textChange, ]))).toMatchObject({ ok: true }); expect(shared.document.value).toEqual({ @@ -357,12 +357,12 @@ describe("protocol v3 text-splice wire materialization", () => { ]); const shared = runtime("receiver", initial); - expect(shared.collaboration.ingest(bundle(shared, [ - ...remover.collaboration.exportBundle().changes, + expect(shared.replica.ingest(bundle(shared, [ + ...remover.replica.exportBundle().changes, textChange, ]))).toMatchObject({ ok: true }); expect(shared.document.value).toEqual({}); - expect(shared.collaboration.current().suppressed).toMatchObject([{ + expect(shared.replica.status().suppressed).toMatchObject([{ changeId: { actorId: "actor-b", counter: 1 }, code: "text_target_deleted", }]); @@ -382,12 +382,12 @@ describe("protocol v3 text-splice wire materialization", () => { ]); const shared = runtime("receiver", initial); - expect(shared.collaboration.ingest(bundle(shared, [ - ...setter.collaboration.exportBundle().changes, + expect(shared.replica.ingest(bundle(shared, [ + ...setter.replica.exportBundle().changes, textChange, ]))).toMatchObject({ ok: true }); expect(shared.document.value).toEqual({ title: "reset" }); - expect(shared.collaboration.current().suppressed).toMatchObject([{ + expect(shared.replica.status().suppressed).toMatchObject([{ changeId: { actorId: "actor-b", counter: 1 }, code: "text_generation_mismatch", }]); @@ -412,15 +412,15 @@ describe("protocol v3 text-splice wire materialization", () => { }], }; - expect(shared.collaboration.ingest({ - epoch: shared.collaboration.epoch, + expect(shared.replica.ingest({ + epoch: shared.replica.epoch, changes: [malformed], })).toMatchObject({ ok: false, code: "invalid_bundle", }); expect(shared.document.value).toEqual(initial); - expect(shared.collaboration.exportBundle().changes).toEqual([]); + expect(shared.replica.exportBundle().changes).toEqual([]); }); }); @@ -429,7 +429,7 @@ describe("collaborative text authoring helpers", () => { const shared = runtime("basis", { title: "A😀B" }); const tree = createInitialTree( shared.document.value, - shared.collaboration.epoch.baseDigest, + shared.replica.epoch.baseDigest, ); const snapshot = resolveTextSnapshot(tree, ["title"]); expect(snapshot.ok).toBe(true); @@ -457,7 +457,7 @@ describe("collaborative text authoring helpers", () => { const shared = runtime("basis", { title: "A😀B" }); const tree = createInitialTree( shared.document.value, - shared.collaboration.epoch.baseDigest, + shared.replica.epoch.baseDigest, ); const snapshot = resolveTextSnapshot(tree, ["title"]); if (!snapshot.ok) throw new Error(snapshot.reason); @@ -485,7 +485,7 @@ describe("collaborative text authoring helpers", () => { const shared = runtime("basis", initial); const initialTree = createInitialTree( shared.document.value, - shared.collaboration.epoch.baseDigest, + shared.replica.epoch.baseDigest, ); const captured = resolveTextSnapshot(initialTree, ["title"]); if (!captured.ok) throw new Error(captured.reason); @@ -547,7 +547,7 @@ describe("collaborative text authoring helpers", () => { const shared = runtime("basis", { title: "ab" }); const tree = createInitialTree( shared.document.value, - shared.collaboration.epoch.baseDigest, + shared.replica.epoch.baseDigest, ); const patch = [{ op: "replace" as const, @@ -586,7 +586,7 @@ describe("collaborative text authoring helpers", () => { const shared = runtime("basis", { title: "ab" }); const tree = createInitialTree( shared.document.value, - shared.collaboration.epoch.baseDigest, + shared.replica.epoch.baseDigest, ); const compiled = compilePatchOperations( tree, @@ -633,7 +633,7 @@ describe("collaborative text authoring helpers", () => { }); const tree = createInitialTree( shared.document.value, - shared.collaboration.epoch.baseDigest, + shared.replica.epoch.baseDigest, ); const compiled = compilePatchOperations( tree, diff --git a/packages/json-document-collaboration/tests/text.test.ts b/packages/json-document-collaboration/tests/text.test.ts index 5f1f1fd9..f5dbf9cc 100644 --- a/packages/json-document-collaboration/tests/text.test.ts +++ b/packages/json-document-collaboration/tests/text.test.ts @@ -5,11 +5,11 @@ import { type CollaborationRuntimeOptions, } from "../src/index.js"; import { - restoreCollaborationHistoryRuntime, + restoreHistoryRuntime, } from "../src/history-index.js"; import { - createCollaborationTextRuntime, - restoreCollaborationTextRuntime, + createTextRuntime, + restoreTextRuntime, } from "../src/text-index.js"; const baseOptions = { @@ -25,7 +25,7 @@ function textRuntime( initial: unknown = { title: "ab" }, overrides: Partial = {}, ) { - return createCollaborationTextRuntime(initial, { + return createTextRuntime(initial, { ...baseOptions, actorId, ...overrides, @@ -33,13 +33,14 @@ function textRuntime( } describe("@interactive-os/json-document-collaboration/text", () => { - test("adds text authoring beside the unchanged six-member document", () => { + test("adds text authoring beside the canonical JSON Document", () => { const shared = textRuntime("actor-a"); expect(Object.keys(shared).sort()).toEqual([ "collaboration", "document", "history", + "replica", "text", ]); expect(Object.keys(shared.document).sort()).toEqual([ @@ -48,6 +49,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { "commit", "query", "subscribe", + "validatePatch", "value", ]); expect(Object.keys(shared.text).sort()).toEqual([ @@ -75,9 +77,9 @@ describe("@interactive-os/json-document-collaboration/text", () => { value: "aXb", }])).toMatchObject({ ok: true }); - expect(atomic.collaboration.exportBundle().changes[0]?.ops) + expect(atomic.replica.exportBundle().changes[0]?.ops) .toMatchObject([{ kind: "set" }]); - expect(collaborative.collaboration.exportBundle().changes[0]?.ops) + expect(collaborative.replica.exportBundle().changes[0]?.ops) .toMatchObject([{ kind: "text-splice" }]); }); @@ -100,14 +102,14 @@ describe("@interactive-os/json-document-collaboration/text", () => { value: "aYb", }])).toMatchObject({ ok: true }); - expect(left.collaboration.ingest(right.collaboration.exportBundle())) + expect(left.replica.ingest(right.replica.exportBundle())) .toMatchObject({ ok: true }); - expect(right.collaboration.ingest(left.collaboration.exportBundle())) + expect(right.replica.ingest(left.replica.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())) + expect(relay.replica.ingest(left.replica.exportBundle())) .toMatchObject({ ok: true }); expect(relay.document.value).toEqual(left.document.value); }); @@ -123,7 +125,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { path: "/title", value: "aXb", }])).toMatchObject({ ok: true }); - expect(local.collaboration.ingest(remote.collaboration.exportBundle())) + expect(local.replica.ingest(remote.replica.exportBundle())) .toMatchObject({ ok: true }); const planned = local.text.plan(captured.capture, { @@ -134,15 +136,15 @@ describe("@interactive-os/json-document-collaboration/text", () => { const documentListener = vi.fn(); const collaborationListener = vi.fn(); local.document.subscribe(documentListener); - local.collaboration.subscribe(collaborationListener); + local.replica.subscribe(collaborationListener); expect(local.text.commit(planned.plan)).toMatchObject({ ok: true, - projectionChanged: true, + didChangeDocument: true, value: "aYXb", selection: { anchor: 2, focus: 2 }, }); - const localChange = local.collaboration.exportBundle().changes.find( + const localChange = local.replica.exportBundle().changes.find( (change) => change.changeId.actorId === "actor-a", ); expect(localChange?.deps).toEqual([]); @@ -150,7 +152,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { expect(documentListener).toHaveBeenCalledTimes(1); expect(collaborationListener).toHaveBeenCalledTimes(1); - expect(remote.collaboration.ingest(local.collaboration.exportBundle())) + expect(remote.replica.ingest(local.replica.exportBundle())) .toMatchObject({ ok: true }); expect(remote.document.value).toEqual(local.document.value); }); @@ -167,7 +169,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { path: "/title", value: "aXb", }]); - local.collaboration.ingest(firstRemote.collaboration.exportBundle()); + local.replica.ingest(firstRemote.replica.exportBundle()); const planned = local.text.plan(captured.capture, { value: "aYb" }); if (!planned.ok) throw new Error(planned.reason); @@ -176,13 +178,13 @@ describe("@interactive-os/json-document-collaboration/text", () => { path: "/title", value: "aZb", }]); - local.collaboration.ingest(secondRemote.collaboration.exportBundle()); + local.replica.ingest(secondRemote.replica.exportBundle()); expect(local.text.commit(planned.plan)).toMatchObject({ ok: false, code: "stale_text_plan", }); expect( - local.collaboration.exportBundle().changes.some( + local.replica.exportBundle().changes.some( (change) => change.changeId.actorId === "actor-a", ), ).toBe(false); @@ -219,7 +221,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { path: "/title", value: "reset", }]); - local.collaboration.ingest(resetter.collaboration.exportBundle()); + local.replica.ingest(resetter.replica.exportBundle()); expect(local.text.plan(captured.capture, { value: "aXb" })) .toMatchObject({ @@ -253,7 +255,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { path: "/title", value: "aXb", }]); - local.collaboration.ingest(remote.collaboration.exportBundle()); + local.replica.ingest(remote.replica.exportBundle()); const planned = local.text.plan(captured.capture, { value: "ab", @@ -261,12 +263,12 @@ describe("@interactive-os/json-document-collaboration/text", () => { }); if (!planned.ok) throw new Error(planned.reason); const listener = vi.fn(); - local.collaboration.subscribe(listener); - expect(local.text.commit(planned.plan)).toEqual({ + local.replica.subscribe(listener); + expect(local.text.commit(planned.plan)).toMatchObject({ ok: true, change: { applied: [] }, changeId: null, - projectionChanged: false, + didChangeDocument: false, value: "aXb", selection: { anchor: 1, focus: 1 }, }); @@ -307,7 +309,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { value: "aXYb", }); if (!nextPlan.ok) throw new Error(nextPlan.reason); - const before = shared.collaboration.exportBundle(); + const before = shared.replica.exportBundle(); const circular: { self?: unknown } = {}; circular.self = circular; expect(shared.text.commit(nextPlan.plan, { @@ -316,7 +318,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { ok: false, code: "not_serializable", }); - expect(shared.collaboration.exportBundle()).toEqual(before); + expect(shared.replica.exportBundle()).toEqual(before); expect(shared.document.value).toEqual({ title: "aXb" }); }); @@ -333,10 +335,10 @@ describe("@interactive-os/json-document-collaboration/text", () => { path: "/title", value: "aYb", }]); - local.collaboration.ingest(remote.collaboration.exportBundle()); - const checkpoint = local.collaboration.exportCheckpoint(); + local.replica.ingest(remote.replica.exportBundle()); + const checkpoint = local.replica.exportCheckpoint(); - const textRestored = restoreCollaborationTextRuntime(checkpoint, { + const textRestored = restoreTextRuntime(checkpoint, { actorId: "actor-a", ruleset: baseOptions.ruleset, }); @@ -344,7 +346,7 @@ describe("@interactive-os/json-document-collaboration/text", () => { expect(textRestored.runtime.text.capture("/title")) .toMatchObject({ ok: true }); - const historyRestored = restoreCollaborationHistoryRuntime(checkpoint, { + const historyRestored = restoreHistoryRuntime(checkpoint, { actorId: "actor-a", ruleset: baseOptions.ruleset, }); diff --git a/packages/json-document/README.md b/packages/json-document/README.md index 1d7684b6..a99be67f 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -40,11 +40,11 @@ const document = createJSONDocument({ tasks: [{ id: "a", done: false }], }); -const capability = document.canPatch([ +const validation = document.validatePatch([ { op: "replace", path: "/tasks/0/done", value: true }, ]); -if (capability.ok) { +if (validation.ok) { const result = document.commit([ { op: "replace", path: "/tasks/0/done", value: true }, ], { @@ -65,7 +65,7 @@ Document의 필수 member는 여섯 개뿐입니다. | `value` | immutable current document value | | `at(pointer)` | 정확한 JSON Pointer 한 곳 읽기 | | `query(jsonPath)` | JSONPath를 Pointer 배열로 환원 | -| `canPatch(operations)` | state를 바꾸지 않는 patch validation | +| `validatePatch(operations)` | state를 바꾸지 않는 patch validation | | `commit(operations, options?)` | 유일한 stateful mutation | | `subscribe(listener)` | change notification 구독 | @@ -79,8 +79,9 @@ subscriber는 state가 commit된 뒤 호출된다. 한 subscriber의 예외는 ` ## Validation -Core는 특정 schema object를 받지 않습니다. Validator를 stable v2 `accepts` -callback으로 연결합니다. 반환된 parse value를 받지 않으므로 commit-time +Core는 특정 schema object를 받지 않습니다. Validator를 canonical `validate` +callback으로 연결합니다. Stable v2 `accepts`는 deprecated compatibility +alias입니다. 반환된 parse value를 받지 않으므로 commit-time transform이 state에 몰래 들어갈 수 없습니다. ```ts @@ -95,7 +96,7 @@ const Schema = z.object({ const document = createJSONDocument( { title: "Draft", tasks: [] }, { - accepts(candidate) { + validate(candidate) { const result = Schema.safeParse(candidate); return result.success ? { ok: true } @@ -115,7 +116,7 @@ Initial value와 patch payload, metadata, exposed document value/change는 docum ## 공개 root -Root는 20개 Kernel symbol만 공개합니다. +Root는 22개 public symbol만 공개합니다. ```txt values @@ -126,13 +127,14 @@ values types JSONValue, Pointer, JSONPatchOperation JSONAppliedChange, JSONPatchResult, JSONDocumentCommitResult - JSONCapabilityResult, JSONChangeMetadata, JSONDocumentCommitOptions + JSONPatchValidationResult, JSONCapabilityResult, JSONChangeMetadata + JSONDocumentOptions, JSONDocumentCommitOptions ReadResult, QueryResult, JSONDocument ``` `JSONDocument`는 application-owned structural contract입니다. Selection, history, clipboard, DOM lifecycle과 framework binding은 별도 host 또는 adapter가 -이 여섯 member를 조합합니다. 패키지는 `/session`이나 `/react` subpath를 +이 일곱 member를 조합합니다. 패키지는 `/session`이나 `/react` subpath를 공개하지 않습니다. ## 순수 core diff --git a/packages/json-document/public-contract.json b/packages/json-document/public-contract.json index 938251ff..4bb1d85c 100644 --- a/packages/json-document/public-contract.json +++ b/packages/json-document/public-contract.json @@ -15,10 +15,12 @@ "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", + "JSONDocumentOptions", "JSONDocumentCommitOptions", "JSONDocumentCommitResult", "JSONPatchOperation", "JSONPatchResult", + "JSONPatchValidationResult", "JSONValue", "Pointer", "QueryResult", diff --git a/packages/json-document/src/application/document/contract.ts b/packages/json-document/src/application/document/contract.ts index 84274e19..d0d12519 100644 --- a/packages/json-document/src/application/document/contract.ts +++ b/packages/json-document/src/application/document/contract.ts @@ -27,7 +27,7 @@ export interface JSONDocumentCommitOptions { readonly metadata?: JSONChangeMetadata; } -export type JSONCapabilityResult = +export type JSONPatchValidationResult = | { readonly ok: true } | { readonly ok: false; @@ -36,6 +36,15 @@ export type JSONCapabilityResult = readonly pointer?: Pointer; }; +/** @deprecated Use JSONPatchValidationResult. */ +export type JSONCapabilityResult = JSONPatchValidationResult; + +export interface JSONDocumentOptions { + readonly validate?: (candidate: JSONValue) => JSONPatchValidationResult; + /** @deprecated Use validate. */ + readonly accepts?: (candidate: JSONValue) => JSONCapabilityResult; +} + export type ReadResult = | { readonly ok: true; readonly path: Pointer; readonly value: JSONValue } | { @@ -78,6 +87,10 @@ export interface JSONDocument { readonly value: JSONValue; at(pointer: Pointer): ReadResult; query(jsonPath: string): QueryResult; + validatePatch( + operations: ReadonlyArray, + ): JSONPatchValidationResult; + /** @deprecated Use validatePatch. */ canPatch( operations: ReadonlyArray, ): JSONCapabilityResult; diff --git a/packages/json-document/src/application/document/create.ts b/packages/json-document/src/application/document/create.ts index 126b76c5..c182448c 100644 --- a/packages/json-document/src/application/document/create.ts +++ b/packages/json-document/src/application/document/create.ts @@ -1,15 +1,20 @@ -import { createProjection } from "../../domain/projection/index.js"; +import { createJSONDocumentState } from "../../domain/json-document/index.js"; import type { - JSONCapabilityResult, JSONDocument, + JSONDocumentOptions, + JSONPatchValidationResult, JSONValue, } from "./contract.js"; export function createJSONDocument( initial: unknown, - options: { - readonly accepts?: (candidate: JSONValue) => JSONCapabilityResult; - } = {}, + options: JSONDocumentOptions = {}, ): JSONDocument { - return createProjection(initial, options); + const validate = options.validate ?? options.accepts; + return createJSONDocumentState(initial, { + ...(validate === undefined ? {} : { + validate: (candidate: JSONValue): JSONPatchValidationResult => + validate(candidate), + }), + }); } diff --git a/packages/json-document/src/application/document/index.ts b/packages/json-document/src/application/document/index.ts index 529fb5e3..3aea8e9f 100644 --- a/packages/json-document/src/application/document/index.ts +++ b/packages/json-document/src/application/document/index.ts @@ -13,10 +13,12 @@ export type { JSONCapabilityResult, JSONChangeMetadata, JSONDocument, + JSONDocumentOptions, JSONDocumentCommitOptions, JSONDocumentCommitResult, JSONPatchOperation, JSONPatchResult, + JSONPatchValidationResult, JSONValue, Pointer, QueryResult, diff --git a/packages/json-document/src/application/document/protocol.ts b/packages/json-document/src/application/document/protocol.ts index d06308e2..01253847 100644 --- a/packages/json-document/src/application/document/protocol.ts +++ b/packages/json-document/src/application/document/protocol.ts @@ -6,7 +6,7 @@ import { parsePointer as parsePointerInternal, trackPointer as trackPointerInternal, tryParsePointer as tryParsePointerInternal, -} from "../../domain/projection/index.js"; +} from "../../domain/json-document/index.js"; import type { JSONPatchOperation, JSONPatchResult, diff --git a/packages/json-document/src/domain/projection/create.ts b/packages/json-document/src/domain/json-document/create.ts similarity index 75% rename from packages/json-document/src/domain/projection/create.ts rename to packages/json-document/src/domain/json-document/create.ts index 0093ac33..cd544beb 100644 --- a/packages/json-document/src/domain/projection/create.ts +++ b/packages/json-document/src/domain/json-document/create.ts @@ -8,7 +8,7 @@ import { queryJSONPath, readAt, type JSONAppliedChange, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONChangeMetadata, type JSONDocumentCommitOptions, type JSONDocumentCommitResult, @@ -19,17 +19,20 @@ import { type ReadResult, } from "../../foundation/protocol/index.js"; -interface ProjectionOptions { - readonly accepts?: (candidate: JSONValue) => JSONCapabilityResult; +interface JSONDocumentStateOptions { + readonly validate?: (candidate: JSONValue) => JSONPatchValidationResult; } -interface ProjectionDocument { +interface JSONDocumentState { readonly value: JSONValue; at(pointer: string): ReadResult; query(jsonPath: string): QueryResult; + validatePatch( + operations: ReadonlyArray, + ): JSONPatchValidationResult; canPatch( operations: ReadonlyArray, - ): JSONCapabilityResult; + ): JSONPatchValidationResult; commit( operations: ReadonlyArray, options?: JSONDocumentCommitOptions, @@ -37,10 +40,10 @@ interface ProjectionDocument { subscribe(listener: (change: JSONAppliedChange) => void): () => void; } -export function createProjection( +export function createJSONDocumentState( initial: unknown, - options: ProjectionOptions = {}, -): ProjectionDocument { + options: JSONDocumentStateOptions = {}, +): JSONDocumentState { const initialized = applyProtocolPatch(initial, []); if (!initialized.ok) { throw new TypeError( @@ -49,29 +52,29 @@ export function createProjection( } let state = initialized.value; - const initialAcceptance = acceptCandidate(options.accepts, state); - if (!initialAcceptance.ok) { + const initialValidation = validateCandidate(options.validate, state); + if (!initialValidation.ok) { throw new TypeError( - `Initial document value was rejected: ${initialAcceptance.reason ?? initialAcceptance.code}`, + `Initial document value was rejected: ${initialValidation.reason ?? initialValidation.code}`, ); } const listeners = new Set<(change: JSONAppliedChange) => void>(); - const publicationQueue: JSONAppliedChange[] = []; - let publishing = false; - let evaluatingAcceptance = false; + const notificationQueue: JSONAppliedChange[] = []; + let notifying = false; + let evaluatingValidation = false; const prepare = ( operations: ReadonlyArray, ): JSONPatchResult => { const patched = applyOwnedProtocolPatch(state, operations); if (!patched.ok) return patched; - evaluatingAcceptance = true; + evaluatingValidation = true; try { - const accepted = acceptCandidate(options.accepts, patched.value); - return accepted.ok ? patched : accepted; + const validation = validateCandidate(options.validate, patched.value); + return validation.ok ? patched : validation; } finally { - evaluatingAcceptance = false; + evaluatingValidation = false; } }; @@ -113,10 +116,17 @@ export function createProjection( throw error; } }, + validatePatch( + operations: ReadonlyArray, + ): JSONPatchValidationResult { + if (evaluatingValidation) return VALIDATION_REENTRANCY_FAILURE; + const result = prepare(operations); + return result.ok ? OK : result; + }, canPatch( operations: ReadonlyArray, - ): JSONCapabilityResult { - if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; + ): JSONPatchValidationResult { + if (evaluatingValidation) return VALIDATION_REENTRANCY_FAILURE; const result = prepare(operations); return result.ok ? OK : result; }, @@ -124,7 +134,7 @@ export function createProjection( operations: ReadonlyArray, commitOptions?: JSONDocumentCommitOptions, ): JSONDocumentCommitResult { - if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; + if (evaluatingValidation) return VALIDATION_REENTRANCY_FAILURE; const metadata = ownMetadata(commitOptions?.metadata); if (!metadata.ok) return metadata; @@ -140,7 +150,7 @@ export function createProjection( state = result.value; const change = createChange(result.change.applied, metadata.value); - publish(change); + notify(change); return Object.freeze({ ok: true, change }); }, subscribe(listener: (change: JSONAppliedChange) => void): () => void { @@ -152,18 +162,18 @@ export function createProjection( listeners.delete(listener); }; }, - } satisfies ProjectionDocument; + } satisfies JSONDocumentState; return Object.freeze(document); - function publish(change: JSONAppliedChange): void { - publicationQueue.push(change); - if (publishing) return; + function notify(change: JSONAppliedChange): void { + notificationQueue.push(change); + if (notifying) return; - publishing = true; + notifying = true; try { - while (publicationQueue.length > 0) { - const next = publicationQueue.shift() as JSONAppliedChange; + while (notificationQueue.length > 0) { + const next = notificationQueue.shift() as JSONAppliedChange; for (const listener of [...listeners]) { if (!listeners.has(listener)) continue; try { @@ -176,24 +186,24 @@ export function createProjection( } } } finally { - publishing = false; + notifying = false; } } } -const OK: JSONCapabilityResult = Object.freeze({ ok: true }); -const ACCEPTANCE_REENTRANCY_FAILURE = failure( +const OK: JSONPatchValidationResult = Object.freeze({ ok: true }); +const VALIDATION_REENTRANCY_FAILURE = failure( "acceptance_reentrancy", "acceptance callback cannot call canPatch or commit", ); -function acceptCandidate( - accepts: ProjectionOptions["accepts"], +function validateCandidate( + validate: JSONDocumentStateOptions["validate"], candidate: JSONValue, -): JSONCapabilityResult { - if (accepts === undefined) return OK; +): JSONPatchValidationResult { + if (validate === undefined) return OK; try { - const result = accepts(candidate); + const result = validate(candidate); if (result?.ok === true) return OK; if (result?.ok === false && typeof result.code === "string") { return Object.freeze({ diff --git a/packages/json-document/src/domain/projection/index.ts b/packages/json-document/src/domain/json-document/index.ts similarity index 88% rename from packages/json-document/src/domain/projection/index.ts rename to packages/json-document/src/domain/json-document/index.ts index b10295dc..78b9ebef 100644 --- a/packages/json-document/src/domain/projection/index.ts +++ b/packages/json-document/src/domain/json-document/index.ts @@ -1,5 +1,5 @@ export { - createProjection, + createJSONDocumentState, } from "./create.js"; export { diff --git a/packages/json-document/src/foundation/json/clone.ts b/packages/json-document/src/foundation/json/clone.ts index b1f60f9f..f02d36c9 100644 --- a/packages/json-document/src/foundation/json/clone.ts +++ b/packages/json-document/src/foundation/json/clone.ts @@ -1,6 +1,6 @@ import { buildPointer } from "../pointer/core.js"; import { isJsonArrayIndexKey } from "./classification.js"; -import { cloneTrustedPlainJson } from "./trustedClone.js"; +import { cloneTrustedPlainJson } from "./trusted-clone.js"; import { jsonSerializableErrorFast } from "./serializable.js"; type CloneJsonResult = diff --git a/packages/json-document/src/foundation/json/index.ts b/packages/json-document/src/foundation/json/index.ts index dd2e9920..48314fe7 100644 --- a/packages/json-document/src/foundation/json/index.ts +++ b/packages/json-document/src/foundation/json/index.ts @@ -1,3 +1,3 @@ export { cloneJsonSerializable } from "./clone.js"; export { jsonEqual } from "./equal.js"; -export { cloneTrustedPlainJson } from "./trustedClone.js"; +export { cloneTrustedPlainJson } from "./trusted-clone.js"; diff --git a/packages/json-document/src/foundation/json/trustedClone.ts b/packages/json-document/src/foundation/json/trusted-clone.ts similarity index 100% rename from packages/json-document/src/foundation/json/trustedClone.ts rename to packages/json-document/src/foundation/json/trusted-clone.ts diff --git a/packages/json-document/src/foundation/patch/container.ts b/packages/json-document/src/foundation/patch/container.ts index 8b632590..bcafbfff 100644 --- a/packages/json-document/src/foundation/patch/container.ts +++ b/packages/json-document/src/foundation/patch/container.ts @@ -1,7 +1,7 @@ // RFC 6902 patch 적용을 위한 내부 container helper. import { parsePointer, readAt, type Pointer, PointerSyntaxError } from "../pointer/core.js"; -import { parseArrayIndex } from "../pointer/arrayIndex.js"; +import { parseArrayIndex } from "../pointer/array-index.js"; import type { ErrorCode, JSONPatchOperation } from "./contract.js"; // RFC 6902 §4.1: `/-` 는 array append marker. 적용 시점의 array 길이로 concrete index 정규화. diff --git a/packages/json-document/src/foundation/patch/fast/apply.ts b/packages/json-document/src/foundation/patch/fast/apply.ts index eddc206a..bb679e9a 100644 --- a/packages/json-document/src/foundation/patch/fast/apply.ts +++ b/packages/json-document/src/foundation/patch/fast/apply.ts @@ -7,7 +7,7 @@ import { removedRootKeysMatchSuffix, } from "../object.js"; import { validateOperationShape } from "../apply.js"; -import { applySequentialReplacePatch } from "../sequentialReplace.js"; +import { applySequentialReplacePatch } from "../sequential-replace.js"; import { applyAppendOnlyAddPatch, applySameArrayStructuralPatch, @@ -48,7 +48,7 @@ export const trustedStrategies: readonly FastPatchStrategy[] = [ applySameArrayStructuralPatch, ]; -export const acceptedStrategies: readonly FastPatchStrategy[] = [ +export const validatedStrategies: readonly FastPatchStrategy[] = [ applyRootObjectRemovePatch, applyRootObjectAddPatch, applyRootObjectReplacePatch, diff --git a/packages/json-document/src/foundation/patch/fast/array.ts b/packages/json-document/src/foundation/patch/fast/array.ts index d82253c5..ab90786d 100644 --- a/packages/json-document/src/foundation/patch/fast/array.ts +++ b/packages/json-document/src/foundation/patch/fast/array.ts @@ -1,9 +1,9 @@ import { jsonSerializableError } from "../../json/serializable.js"; -import { cloneTrustedPlainJson } from "../../json/trustedClone.js"; +import { cloneTrustedPlainJson } from "../../json/trusted-clone.js"; import { appendSegment, type Pointer } from "../../pointer/core.js"; import { getValueAt, parseSafe } from "../container.js"; import { appendArrayIndexPath, arrayLocation, arrayRemoveLocation } from "../path.js"; -import { replaceValueAtSegments } from "../replaceValue.js"; +import { replaceValueAtSegments } from "../replace-value.js"; import { validateOperationShape } from "../apply.js"; import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; diff --git a/packages/json-document/src/foundation/patch/fast/replace.ts b/packages/json-document/src/foundation/patch/fast/replace.ts index ac1404eb..6280c719 100644 --- a/packages/json-document/src/foundation/patch/fast/replace.ts +++ b/packages/json-document/src/foundation/patch/fast/replace.ts @@ -11,7 +11,7 @@ import { parseKnownArrayNestedIndex, parseKnownArrayFieldIndex, } from "../path.js"; -import { replaceValueAtSegments } from "../replaceValue.js"; +import { replaceValueAtSegments } from "../replace-value.js"; import { validateOperationShape } from "../apply.js"; import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; import type { ArrayFieldPath, ArrayFieldText } from "../path.js"; diff --git a/packages/json-document/src/foundation/patch/path.ts b/packages/json-document/src/foundation/patch/path.ts index 0c721c48..41726564 100644 --- a/packages/json-document/src/foundation/patch/path.ts +++ b/packages/json-document/src/foundation/patch/path.ts @@ -1,5 +1,5 @@ import { buildPointer, parentPointer, type Pointer } from "../pointer/core.js"; -import { parseArrayIndex } from "../pointer/arrayIndex.js"; +import { parseArrayIndex } from "../pointer/array-index.js"; import { getValueAt, parseSafe } from "./container.js"; export interface ArrayFieldPath { diff --git a/packages/json-document/src/foundation/patch/replaceValue.ts b/packages/json-document/src/foundation/patch/replace-value.ts similarity index 94% rename from packages/json-document/src/foundation/patch/replaceValue.ts rename to packages/json-document/src/foundation/patch/replace-value.ts index 76481d23..1f2105e7 100644 --- a/packages/json-document/src/foundation/patch/replaceValue.ts +++ b/packages/json-document/src/foundation/patch/replace-value.ts @@ -1,4 +1,4 @@ -import { parseArrayIndex } from "../pointer/arrayIndex.js"; +import { parseArrayIndex } from "../pointer/array-index.js"; import { objectHasOwn } from "./object.js"; export function replaceValueAtSegments( diff --git a/packages/json-document/src/foundation/patch/sequentialReplace.ts b/packages/json-document/src/foundation/patch/sequential-replace.ts similarity index 98% rename from packages/json-document/src/foundation/patch/sequentialReplace.ts rename to packages/json-document/src/foundation/patch/sequential-replace.ts index 37636e7d..0afd1611 100644 --- a/packages/json-document/src/foundation/patch/sequentialReplace.ts +++ b/packages/json-document/src/foundation/patch/sequential-replace.ts @@ -1,5 +1,5 @@ import { jsonSerializableError } from "../json/serializable.js"; -import { parseArrayIndex } from "../pointer/arrayIndex.js"; +import { parseArrayIndex } from "../pointer/array-index.js"; import { validateOperationShape } from "./apply.js"; import { parseSafe } from "./container.js"; import type { FastPatchResult, JSONPatchOperation } from "./contract.js"; diff --git a/packages/json-document/src/foundation/patch/track.ts b/packages/json-document/src/foundation/patch/track.ts index eaba38b3..a81ccc94 100644 --- a/packages/json-document/src/foundation/patch/track.ts +++ b/packages/json-document/src/foundation/patch/track.ts @@ -8,7 +8,7 @@ import { tryParsePointer, type Pointer, } from "../pointer/core.js"; -import { parseArrayIndex } from "../pointer/arrayIndex.js"; +import { parseArrayIndex } from "../pointer/array-index.js"; import type { JSONPatchOperation } from "./contract.js"; function isArrayIndex(seg: string): boolean { diff --git a/packages/json-document/src/foundation/patch/trusted.ts b/packages/json-document/src/foundation/patch/trusted.ts index b62d9d96..f1b91a29 100644 --- a/packages/json-document/src/foundation/patch/trusted.ts +++ b/packages/json-document/src/foundation/patch/trusted.ts @@ -1,7 +1,7 @@ import { jsonSerializableError } from "../json/serializable.js"; import { applyOpRaw, validateOperationPointers, validateOperationShape } from "./apply.js"; import { normalizeAppliedOp, normalizeOp } from "./container.js"; -import { acceptedStrategies, applyFastPatchStrategies, trustedStrategies } from "./fast/apply.js"; +import { validatedStrategies, applyFastPatchStrategies, trustedStrategies } from "./fast/apply.js"; import { fail, ok } from "./result.js"; import { applyTrustedValueMutation } from "./value.js"; import type { @@ -52,18 +52,18 @@ export function applyTrustedPatch( return { state: cur as T, result: ok, applied: normalized }; } -export function applyAcceptedPatch( +export function applyValidatedPatch( state: T, ops: ReadonlyArray, ): TrustedApplyResult { if (!Array.isArray(ops)) return { state, result: fail("invalid_pointer", "patch must be an array"), applied: [] }; if (ops.length === 1 && 0 in ops) { - const single = applyAcceptedSingleTrustedValuePatch(state, ops[0]!); + const single = applyValidatedSingleTrustedValuePatch(state, ops[0]!); if (single !== null) return single as TrustedApplyResult; } - const fast = applyFastPatchStrategies(state, ops, acceptedStrategies, true); + const fast = applyFastPatchStrategies(state, ops, validatedStrategies, true); if (fast !== null) return { state: fast.state as T, result: ok, applied: fast.applied }; return applyTrustedPatch(state, ops, { valuesTrusted: true }); @@ -103,7 +103,7 @@ function applySingleTrustedValuePatch( return { state: applied.state, result: ok, applied: [normalized] }; } -function applyAcceptedSingleTrustedValuePatch( +function applyValidatedSingleTrustedValuePatch( state: unknown, op: JSONPatchOperation, ): TrustedApplyResult | null { diff --git a/packages/json-document/src/foundation/patch/value.ts b/packages/json-document/src/foundation/patch/value.ts index 61fcd028..7997cf41 100644 --- a/packages/json-document/src/foundation/patch/value.ts +++ b/packages/json-document/src/foundation/patch/value.ts @@ -1,5 +1,5 @@ import type { Pointer } from "../pointer/core.js"; -import { parseArrayIndex } from "../pointer/arrayIndex.js"; +import { parseArrayIndex } from "../pointer/array-index.js"; import { mutateContainer, parseSafe, diff --git a/packages/json-document/src/foundation/pointer/arrayIndex.ts b/packages/json-document/src/foundation/pointer/array-index.ts similarity index 100% rename from packages/json-document/src/foundation/pointer/arrayIndex.ts rename to packages/json-document/src/foundation/pointer/array-index.ts diff --git a/packages/json-document/src/foundation/pointer/core.ts b/packages/json-document/src/foundation/pointer/core.ts index 46d70ea5..57c682d2 100644 --- a/packages/json-document/src/foundation/pointer/core.ts +++ b/packages/json-document/src/foundation/pointer/core.ts @@ -1,7 +1,7 @@ // RFC 6901 — JSON Pointer. // 문자열과 segment 배열 사이의 변환은 lossless다. -import { parseArrayIndex } from "./arrayIndex.js"; +import { parseArrayIndex } from "./array-index.js"; export type Pointer = string; diff --git a/packages/json-document/src/foundation/protocol/apply.ts b/packages/json-document/src/foundation/protocol/apply.ts index e7c6a777..8fcf3ff6 100644 --- a/packages/json-document/src/foundation/protocol/apply.ts +++ b/packages/json-document/src/foundation/protocol/apply.ts @@ -4,7 +4,7 @@ import { } from "../json/index.js"; import type { JSONPatchOperation as AppliedPatchOperation } from "../patch/contract.js"; import { - applyAcceptedPatch, + applyValidatedPatch, applyTrustedPatch, } from "../patch/trusted.js"; import type { @@ -82,17 +82,17 @@ export function applyOwnedProtocolPatch( }); } - // Canonical operations own their payloads. Replaying only these accepted - // operations prevents caller-owned values from entering Projection state. + // Canonical operations own their payloads. Replaying only these validated + // operations prevents caller-owned values from entering JSON Document state. const applied = prepared.applied.map(ownAppliedOperation); Object.freeze(applied); - const accepted = applyAcceptedPatch( + const validated = applyValidatedPatch( value, applied as ReadonlyArray, ); - const ownedValue = accepted.result.ok - ? freezeJSON(accepted.state as JSONValue) - // The accepted replay should be equivalent by construction. Keep a safe + const ownedValue = validated.result.ok + ? freezeJSON(validated.state as JSONValue) + // The validated replay should be equivalent by construction. Keep a safe // isolation fallback if an internal optimization ever disagrees. : ownJSON(prepared.state as JSONValue); const change: JSONAppliedChange = Object.freeze({ applied }); diff --git a/packages/json-document/src/foundation/protocol/contract.ts b/packages/json-document/src/foundation/protocol/contract.ts index 5f3246c1..31234177 100644 --- a/packages/json-document/src/foundation/protocol/contract.ts +++ b/packages/json-document/src/foundation/protocol/contract.ts @@ -46,10 +46,13 @@ export type JSONDocumentCommitResult = | { readonly ok: true; readonly change: JSONAppliedChange } | JSONPatchFailure; -export type JSONCapabilityResult = +export type JSONPatchValidationResult = | { readonly ok: true } | JSONPatchFailure; +/** @deprecated Compatibility alias for the stable v2 public name. */ +export type JSONCapabilityResult = JSONPatchValidationResult; + export type ReadResult = | { readonly ok: true; diff --git a/packages/json-document/src/foundation/protocol/index.ts b/packages/json-document/src/foundation/protocol/index.ts index ff007673..b544d934 100644 --- a/packages/json-document/src/foundation/protocol/index.ts +++ b/packages/json-document/src/foundation/protocol/index.ts @@ -9,6 +9,7 @@ export { export type { JSONAppliedChange, JSONCapabilityResult, + JSONPatchValidationResult, JSONChangeMetadata, JSONDocumentCommitOptions, JSONDocumentCommitResult, diff --git a/packages/json-document/tests/foundation/durability-parity.test.ts b/packages/json-document/tests/foundation/durability-parity.test.ts index 587cd3d7..4e637b80 100644 --- a/packages/json-document/tests/foundation/durability-parity.test.ts +++ b/packages/json-document/tests/foundation/durability-parity.test.ts @@ -3,8 +3,8 @@ import { describe, expect, test } from "vitest"; import { cloneJsonSerializable } from "../../src/foundation/json/clone.js"; import { jsonEqual } from "../../src/foundation/json/equal.js"; import { jsonSerializableError } from "../../src/foundation/json/serializable.js"; -import { cloneTrustedPlainJson } from "../../src/foundation/json/trustedClone.js"; -import { parseArrayIndex } from "../../src/foundation/pointer/arrayIndex.js"; +import { cloneTrustedPlainJson } from "../../src/foundation/json/trusted-clone.js"; +import { parseArrayIndex } from "../../src/foundation/pointer/array-index.js"; import rawVectors from "../conformance/v2/foundation-vectors.json" with { type: "json" }; interface FoundationVectors { diff --git a/packages/json-document/tests/public/naming-compatibility.test.ts b/packages/json-document/tests/public/naming-compatibility.test.ts new file mode 100644 index 00000000..f6d31a5e --- /dev/null +++ b/packages/json-document/tests/public/naming-compatibility.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; + +import { + createJSONDocument, + type JSONCapabilityResult, + type JSONPatchValidationResult, +} from "@interactive-os/json-document"; + +describe("deprecated naming compatibility", () => { + test("validatePatch and canPatch preserve equivalent extracted methods", () => { + const document = createJSONDocument({ value: 1 }); + const operations = [ + { op: "replace" as const, path: "/value", value: 2 }, + ]; + + const validatePatch = document.validatePatch; + const canPatch = document.canPatch; + + expect(validatePatch(operations)).toEqual({ ok: true }); + expect(canPatch(operations)).toEqual({ ok: true }); + }); + + test("validation result compatibility is structural", () => { + const validation = { ok: true } as JSONPatchValidationResult; + validation satisfies JSONCapabilityResult; + }); +}); diff --git a/packages/json-document/tests/public/v2-signature-contract.test-d.ts b/packages/json-document/tests/public/v2-signature-contract.test-d.ts index ba78ea02..003de65d 100644 --- a/packages/json-document/tests/public/v2-signature-contract.test-d.ts +++ b/packages/json-document/tests/public/v2-signature-contract.test-d.ts @@ -11,10 +11,12 @@ import { type JSONCapabilityResult, type JSONChangeMetadata, type JSONDocument, + type JSONDocumentOptions, type JSONDocumentCommitOptions, type JSONDocumentCommitResult, type JSONPatchOperation, type JSONPatchResult, + type JSONPatchValidationResult, type JSONValue, type Pointer, type QueryResult, @@ -28,10 +30,10 @@ type Equal = : false; type Expect = T; -type ProjectionMembers = keyof JSONDocument; -type _ProjectionMembersAreExact = Expect>; interface Row { @@ -41,7 +43,7 @@ interface Row { const initial: Row = { id: "a", done: false }; const document = createJSONDocument(initial, { - accepts(candidate): JSONCapabilityResult { + validate(candidate): JSONPatchValidationResult { return typeof candidate === "object" && candidate !== null ? { ok: true } : { ok: false, code: "schema_violation" }; @@ -55,6 +57,8 @@ const operations: ReadonlyArray = [ ]; const pureResult = applyPatch(initial, operations); pureResult satisfies JSONPatchResult; +document.validatePatch(operations) satisfies JSONPatchValidationResult; +// Stable v2 compatibility aliases remain callable. document.canPatch(operations) satisfies JSONCapabilityResult; document.commit(operations, { metadata: { origin: "signature" }, @@ -68,6 +72,10 @@ document.subscribe((change: JSONAppliedChange) => { const metadata: JSONChangeMetadata = { nested: { stable: true } }; const options: JSONDocumentCommitOptions = { metadata }; options satisfies JSONDocumentCommitOptions; +const documentOptions: JSONDocumentOptions = { + validate: () => ({ ok: true }), +}; +documentOptions satisfies JSONDocumentOptions; const pointer: Pointer = appendSegment("", "items"); buildPointer(["items", 0]) satisfies Pointer; @@ -87,5 +95,5 @@ forwardCompatibleResult satisfies JSONPatchResult; // @ts-expect-error v2 patch payloads are JSON data, not arbitrary host values. const callablePatch: JSONPatchOperation = { op: "add", path: "/run", value: () => undefined }; -// @ts-expect-error Editing Session controls are not part of the six-member Kernel. +// @ts-expect-error Editing Session controls are not part of JSONDocument. document.undo(); diff --git a/packages/json-document/tests/smoke/package-smoke.mjs b/packages/json-document/tests/smoke/package-smoke.mjs index b90ab70c..94968909 100644 --- a/packages/json-document/tests/smoke/package-smoke.mjs +++ b/packages/json-document/tests/smoke/package-smoke.mjs @@ -123,8 +123,8 @@ try { if (packageJson.dependencies !== undefined) { throw new Error("The v2 kernel must not publish runtime dependencies."); } - if (rootValueExports.length !== 8 || rootTypeExports.length !== 12) { - throw new Error("The root contract must contain exactly 8 values and 12 types."); + if (rootValueExports.length !== 8 || rootTypeExports.length !== 14) { + throw new Error("The root contract must contain exactly 8 values and 14 types."); } const packResult = JSON.parse(run( @@ -240,7 +240,7 @@ try { 'const actual = Object.keys(api).sort();', 'if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`runtime exports: ${actual}`);', 'const document = api.createJSONDocument({ title: "draft", tags: [] });', - 'if (JSON.stringify(Object.keys(document).sort()) !== JSON.stringify(["at", "canPatch", "commit", "query", "subscribe", "value"])) throw new Error("document surface drifted");', + 'if (JSON.stringify(Object.keys(document).sort()) !== JSON.stringify(["at", "canPatch", "commit", "query", "subscribe", "validatePatch", "value"])) throw new Error("document surface drifted");', 'const changes = [];', 'document.subscribe((change) => changes.push(change));', 'const result = document.commit([{ op: "add", path: "/tags/-", value: "v2" }]);', @@ -263,7 +263,7 @@ try { `import type { ${rootTypeExports.join(", ")} } from "@interactive-os/json-document";`, 'const document: JSONDocument = createJSONDocument({ text: "a" });', 'type Members = keyof JSONDocument;', - 'const members: Members[] = ["value", "at", "query", "canPatch", "commit", "subscribe"];', + 'const members: Members[] = ["value", "at", "query", "validatePatch", "canPatch", "commit", "subscribe"];', 'const operation: JSONPatchOperation = { op: "replace", path: "/text", value: "b" };', 'const metadata: JSONChangeMetadata = { source: "type-smoke" };', 'const result: JSONDocumentCommitResult = document.commit([operation], { metadata });', @@ -271,7 +271,7 @@ try { 'const pointer: Pointer = appendSegment(buildPointer(["text"]), 0);', 'const values: JSONValue[] = [document.value, pointer, null];', 'void [members, result, pure, values, parsePointer, tryParsePointer, parentPointer, trackPointer];', - 'type Remaining = JSONAppliedChange | JSONCapabilityResult | JSONDocumentCommitOptions | QueryResult | ReadResult;', + 'type Remaining = JSONAppliedChange | JSONCapabilityResult | JSONDocumentOptions | JSONPatchValidationResult | JSONDocumentCommitOptions | QueryResult | ReadResult;', 'declare const remaining: Remaining;', 'void remaining;', ].join("\n"), diff --git a/scripts/evaluate-docs.mjs b/scripts/evaluate-docs.mjs index 8a1af75f..7274ca4f 100644 --- a/scripts/evaluate-docs.mjs +++ b/scripts/evaluate-docs.mjs @@ -23,6 +23,14 @@ function fileNames(path) { .sort(); } +function filesUnder(path) { + return readdirSync(join(root, path), { withFileTypes: true }) + .flatMap((entry) => { + const child = `${path}/${entry.name}`; + return entry.isDirectory() ? filesUnder(child) : [child]; + }); +} + function fail(message) { console.error(message); process.exitCode = 1; @@ -58,6 +66,30 @@ const generatedCatalog = readJson("docs/generated/repo-catalog.json"); const siteRoutes = readJson("apps/site/src/site-routes.json"); const siteHome = read("apps/site/src/routes/Home.tsx"); const docsRoute = read("apps/site/src/routes/Docs.tsx"); +const namingImplementation = { + coreDocument: read("packages/json-document/src/domain/json-document/create.ts"), + coreContract: read( + "packages/json-document/src/application/document/contract.ts", + ), + collaborationTypes: read( + "packages/json-document-collaboration/src/types.ts", + ), + collaborationRuntime: read( + "packages/json-document-collaboration/src/create.ts", + ), + historyEntrypoint: read( + "packages/json-document-collaboration/src/history-index.ts", + ), + textEntrypoint: read( + "packages/json-document-collaboration/src/text-index.ts", + ), + contenteditableTypes: read( + "packages/contenteditable-collaboration/src/types.ts", + ), + contenteditableEntrypoint: read( + "packages/contenteditable-collaboration/src/index.ts", + ), +}; const expectedPublicValues = [ "appendSegment", @@ -74,10 +106,12 @@ const expectedPublicTypes = [ "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", + "JSONDocumentOptions", "JSONDocumentCommitOptions", "JSONDocumentCommitResult", "JSONPatchOperation", "JSONPatchResult", + "JSONPatchValidationResult", "JSONValue", "Pointer", "QueryResult", @@ -231,19 +265,19 @@ const required = [ ["api", surfaces.api, /## Host와 adapter/], ["packageReadme", surfaces.packageReadme, /npm install @interactive-os\/json-document@2\.0\.0/], ["packageReadme", surfaces.packageReadme, /패키지는 `\/session`이나 `\/react` subpath를\s*공개하지 않습니다/], - ["collaborationReadme", surfaces.collaborationReadme, /same six-member\s+`JSONDocument` API/], + ["collaborationReadme", surfaces.collaborationReadme, /same seven-member\s+`JSONDocument` API/], ["collaborationReadme", surfaces.collaborationReadme, /Concept and Naming Standard/], ["collaborationReadme", surfaces.collaborationReadme, /contains no transport, presence,\s*storage, DOM, React, or server dependency/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /IME-safe native-input DOM lease/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /Concept and Naming Standard/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /does not activate or depend on the archived 1\.x DOM adapters/], ["llms", surfaces.llms, /2\.0\.0.*Stable/], - ["llms", surfaces.llms, /공개 Root는 정확히 다음 20개 symbol/], + ["llms", surfaces.llms, /공개 Root는 정확히 다음 22개 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, /root entrypoint 하나와 22개 symbol/], ["profile", profile, /Acceptance callback[\s\S]*`canPatch`[\s\S]*`commit`/], ["profile", profile, /Concept and Naming Standard/], ["docsReadme", docsReadme, /concept-and-naming-standard\.md/], @@ -256,6 +290,7 @@ const required = [ ["namingStandard", namingStandard, /## 함수 동사/], ["namingStandard", namingStandard, /## Boolean/], ["namingStandard", namingStandard, /## Collection과 축약/], + ["namingStandard", namingStandard, /## 파일과 경로/], ["namingStandard", namingStandard, /## 현재 이름 평가/], ["namingStandard", namingStandard, /## v2 compatibility map/], ["namingStandard", namingStandard, /## 새 concept admission/], @@ -323,6 +358,57 @@ for (const restrictedSuffix of [ } } +for (const [path, forbiddenName] of [ + ["packages/json-document/src/domain/projection/index.ts", "projection path"], + ["packages/json-document/src/domain/json-document", null], +]) { + if (forbiddenName === null && !exists(path)) { + fail(`naming implementation: missing canonical path ${path}.`); + } + if (forbiddenName !== null && exists(path)) { + fail(`naming implementation: deprecated ${forbiddenName} remains at ${path}.`); + } +} + +for (const path of filesUnder("packages")) { + const name = path.split("/").at(-1); + if ( + path.includes("/src/") + && name?.endsWith(".ts") + && /[a-z][A-Z]/.test(name) + ) { + fail(`naming implementation: module filename must be kebab-case: ${path}.`); + } +} + +for (const [name, source] of Object.entries(namingImplementation)) { + for (const forbiddenInternal of [ + "createProjection", + "publicationQueue", + "enqueuePublication", + "acceptCandidate", + "evaluatingAcceptance", + ]) { + if (source.includes(forbiddenInternal)) { + fail(`${name}: deprecated internal identifier remains: ${forbiddenInternal}.`); + } + } +} + +for (const [name, source, pattern] of [ + ["core contract", namingImplementation.coreContract, /JSONPatchValidationResult/], + ["core contract", namingImplementation.coreContract, /validatePatch/], + ["collaboration types", namingImplementation.collaborationTypes, /CollaborationReplica/], + ["collaboration types", namingImplementation.collaborationTypes, /ReplicaStatus/], + ["collaboration runtime", namingImplementation.collaborationRuntime, /replica:/], + ["history entrypoint", namingImplementation.historyEntrypoint, /createHistoryRuntime/], + ["text entrypoint", namingImplementation.textEntrypoint, /createTextRuntime/], + ["contenteditable types", namingImplementation.contenteditableTypes, /TextDOMAdapter/], + ["contenteditable entrypoint", namingImplementation.contenteditableEntrypoint, /createContentEditableAdapter/], +]) { + requirePattern(name, source, pattern); +} + for (const packageEntry of generatedCatalog.packages ?? []) { for (const publicExport of packageEntry.publicExports ?? []) { if (!namingStandard.includes(`\`${publicExport}\``)) { @@ -338,7 +424,7 @@ if ( || JSON.stringify(publicContract.root.values) !== JSON.stringify(expectedPublicValues) || JSON.stringify(publicContract.root.types) !== JSON.stringify(expectedPublicTypes) ) { - fail("public contract: v2 root must be the only exact 20-symbol entrypoint."); + fail("public contract: root must be the only exact 22-symbol entrypoint."); } if ( @@ -369,14 +455,14 @@ if ( || generatedCore?.status !== "core" || JSON.stringify(generatedCore?.entrypoints) !== JSON.stringify(["."]) || JSON.stringify(generatedCore?.publicExports) !== JSON.stringify(generatedExports) - || generatedCore?.publicExportCount !== 20 + || generatedCore?.publicExportCount !== 22 || 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 + || generatedCollaboration?.publicExportCount !== 43 || generatedContenteditableCollaboration?.path !== "packages/contenteditable-collaboration" || generatedContenteditableCollaboration?.name @@ -384,7 +470,7 @@ if ( || generatedContenteditableCollaboration?.status !== "companion" || JSON.stringify(generatedContenteditableCollaboration?.entrypoints) !== JSON.stringify(["."]) - || generatedContenteditableCollaboration?.publicExportCount !== 7 + || generatedContenteditableCollaboration?.publicExportCount !== 14 || generatedCatalog.officialExtensions !== undefined || generatedCatalog.labExtensions !== undefined || generatedCatalog.apps !== undefined @@ -427,7 +513,7 @@ for (const pattern of [ for (const pattern of [ /Implementation-neutral JSON editing/, - /six-member JSON Document/, + /seven-member JSON Document/, /npm install @interactive-os\/json-document@2\.0\.0/, /Rich editing belongs to host adapters/, ]) { diff --git a/scripts/evaluate-json-document-layers.mjs b/scripts/evaluate-json-document-layers.mjs index 03d79f6b..5af80b2a 100644 --- a/scripts/evaluate-json-document-layers.mjs +++ b/scripts/evaluate-json-document-layers.mjs @@ -10,8 +10,8 @@ const allowedLayerSeams = new Map([ [ "application->domain", new Set([ + "domain/json-document/index.ts", "domain/document/index.ts", - "domain/projection/index.ts", ]), ], [ diff --git a/scripts/evaluate-live-site.mjs b/scripts/evaluate-live-site.mjs index 92a064ce..0a0e1e30 100644 --- a/scripts/evaluate-live-site.mjs +++ b/scripts/evaluate-live-site.mjs @@ -71,7 +71,7 @@ async function checkOnce() { const llms = await fetchText("/llms.txt"); if ( !/^# json-document v2$/m.test(llms) - || !/공개 Root는 정확히 다음 20개 symbol/.test(llms) + || !/공개 Root는 정확히 다음 22개 symbol/.test(llms) || !/`JSONDocument`의 필수 member는 정확히 여섯 개다/.test(llms) || !/## Host adapter와 companion/.test(llms) || !/@interactive-os\/editable/.test(llms) diff --git a/scripts/evaluate-standardization.mjs b/scripts/evaluate-standardization.mjs index b6441704..ab797769 100644 --- a/scripts/evaluate-standardization.mjs +++ b/scripts/evaluate-standardization.mjs @@ -20,16 +20,18 @@ const EXPECTED_TYPES = [ "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", + "JSONDocumentOptions", "JSONDocumentCommitOptions", "JSONDocumentCommitResult", "JSONPatchOperation", "JSONPatchResult", + "JSONPatchValidationResult", "JSONValue", "Pointer", "QueryResult", "ReadResult", ]; -const EXPECTED_MEMBERS = [ +const V2_COMPATIBILITY_MEMBERS = [ "value", "at", "query", @@ -37,6 +39,15 @@ const EXPECTED_MEMBERS = [ "commit", "subscribe", ]; +const CURRENT_DOCUMENT_MEMBERS = [ + "value", + "at", + "query", + "validatePatch", + "canPatch", + "commit", + "subscribe", +]; const EXPECTED_CONFORMANCE = { projectionVectors: "packages/json-document/tests/conformance/v2/projection-vectors.json", projectionSuite: "packages/json-document/tests/conformance/v2/projection-suite.ts", @@ -198,14 +209,18 @@ if (manifest.formatVersion !== 1 || manifest.status !== "stable") { if (manifest.sourceContract !== "packages/json-document/public-contract.json#root") { fail("v2 manifest: sourceContract must point to the root public contract."); } -equal("v2 manifest projection members", manifest.projectionMembers, EXPECTED_MEMBERS); +equal( + "v2 manifest projection members", + manifest.projectionMembers, + V2_COMPATIBILITY_MEMBERS, +); equal("v2 manifest conformance paths", manifest.conformance, EXPECTED_CONFORMANCE); equal("v2 manifest binding values", manifest.binding?.values, EXPECTED_VALUES); equal("v2 manifest binding types", manifest.binding?.types, EXPECTED_TYPES); equal("v2 manifest counts", manifest.counts, { values: 8, - types: 12, - exports: 20, + types: 14, + exports: 22, projectionMembers: 6, }); equal("v2 package identity", { @@ -235,7 +250,7 @@ equal("root source types", sourceExports.types, [...EXPECTED_TYPES].sort()); equal( "JSONDocument members", interfaceMembers(contractSource, "JSONDocument"), - EXPECTED_MEMBERS, + CURRENT_DOCUMENT_MEMBERS, ); equal("package entrypoints", Object.keys(packageManifest.exports ?? {}), ["."]); @@ -286,7 +301,7 @@ for (const word of ["MUST", "SHOULD", "MAY"]) { } for (const pattern of [ /Pure Protocol -> Projection -> host adapter/, - /root entrypoint 하나와 20개 Kernel symbol/, + /root entrypoint 하나와 22개 symbol/, /runtime dependency와 peer dependency가 없다/, /archived 1\.x implementation은 production build와 tarball에\s*포함하지 않는다/, ]) { @@ -296,7 +311,7 @@ for (const pattern of [ requirePattern( "v2 signature exact document", signatureSource, - /"value" \| "at" \| "query" \| "canPatch" \| "commit" \| "subscribe"/, + /"value" \| "at" \| "query" \| "validatePatch" \| "canPatch" \| "commit" \| "subscribe"/, ); requirePattern( "v2 signature JSON boundary", @@ -356,7 +371,11 @@ for (const [label, vectors] of [ fail(`${label} vectors: metadata drifted.`); } } -equal("projection vector members", projectionVectors.projectionMembers, EXPECTED_MEMBERS); +equal( + "projection vector members", + projectionVectors.projectionMembers, + V2_COMPATIBILITY_MEMBERS, +); if (protocolVectors.function !== "applyPatch") { fail("protocol vectors: function must be applyPatch."); } @@ -670,6 +689,6 @@ if (failures.length > 0) { process.exitCode = 1; } else { console.log( - "json-document v2 standardization ok: 1 entrypoint, 20 exports, 6 document members, 0 runtime peers", + "json-document standardization ok: 1 entrypoint, 22 current exports, 6 v2 compatibility members, 0 runtime peers", ); } From 2217c3ba55376d71f8788d660c3aefea3d40d906 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: Thu, 30 Jul 2026 14:06:22 +0900 Subject: [PATCH 3/5] refactor!: remove legacy naming aliases --- README.md | 18 +- apps/site/index.html | 6 +- apps/site/public/site.webmanifest | 2 +- apps/site/src/routes/Home.tsx | 6 +- apps/site/src/site-routes.json | 6 +- apps/site/tests/docs-consistency.test.ts | 17 +- apps/site/tests/site-shell.test.tsx | 10 +- docs/README.md | 18 +- docs/changelog.md | 17 +- docs/generated/repo-catalog.json | 25 +- docs/public/api.md | 18 +- docs/public/overview.md | 17 +- docs/public/quickstart.md | 5 +- docs/standard/concept-and-naming-standard.md | 138 +++------ docs/standard/v2-public-surface.json | 103 ------- ...profile.md => v3-json-document-profile.md} | 137 +++++---- docs/standard/v3-public-surface.json | 102 +++++++ llms.txt | 37 ++- package-lock.json | 12 +- package.json | 2 +- .../contenteditable-collaboration/README.md | 5 +- .../package.json | 6 +- .../src/index.ts | 15 +- .../src/lease.ts | 1 - .../src/types.ts | 17 -- .../contenteditable-collaboration.test.ts | 6 +- .../tests/naming-compatibility.test.ts | 28 -- .../json-document-collaboration/README.md | 14 +- .../json-document-collaboration/package.json | 4 +- .../json-document-collaboration/src/change.ts | 2 +- .../src/checkpoint.ts | 6 +- .../src/compact.ts | 6 +- .../json-document-collaboration/src/create.ts | 51 ++-- .../src/history-index.ts | 15 +- .../json-document-collaboration/src/index.ts | 3 - .../src/materialize.ts | 4 +- .../src/restore.ts | 5 +- .../src/text-index.ts | 23 +- .../json-document-collaboration/src/types.ts | 79 +---- .../tests/checkpoint.test.ts | 18 +- .../tests/collaboration.test.ts | 26 +- .../tests/convergence-soak.test.ts | 2 +- .../tests/foundation-parity.test.ts | 2 +- .../tests/history.test.ts | 2 - ...t.ts => json-document-conformance.test.ts} | 50 ++-- .../tests/naming-compatibility.test.ts | 54 ---- .../tests/support/task-board-host.ts | 2 +- .../tests/text.test.ts | 2 - .../tsconfig.test.json | 4 +- packages/json-document/README.md | 21 +- packages/json-document/package.json | 2 +- packages/json-document/public-contract.json | 1 - .../src/application/document/contract.ts | 9 - .../src/application/document/create.ts | 2 +- .../src/application/document/index.ts | 1 - .../src/domain/json-document/create.ts | 16 +- .../src/foundation/protocol/contract.ts | 3 - .../src/foundation/protocol/index.ts | 1 - .../{v2 => v3}/foundation-vectors.json | 2 +- .../json-document-suite.ts} | 272 +++++++++--------- .../json-document-vectors.json} | 168 +++++------ .../conformance/{v2 => v3}/jsonpath-suite.ts | 22 +- .../conformance/{v2 => v3}/pointer-suite.ts | 2 +- .../{v2 => v3}/pointer-vectors.json | 4 +- .../conformance/{v2 => v3}/pressure-suite.ts | 40 +-- .../{v2 => v3}/pressure-vectors.json | 38 +-- .../conformance/{v2 => v3}/protocol-suite.ts | 2 +- .../{v2 => v3}/protocol-vectors.json | 52 ++-- .../conformance/{v2 => v3}/rfc6902-suite.ts | 2 +- .../foundation/durability-parity.test.ts | 4 +- ...projection-independent-conformance.test.ts | 15 - ...n-document-independent-conformance.test.ts | 15 + .../{v2-projection.ts => v3-json-document.ts} | 98 +++---- .../tests/public/naming-compatibility.test.ts | 27 -- ...son-document-standard-conformance.test.ts} | 102 +++---- ... v3-jsonpath-standard-conformance.test.ts} | 2 +- ...> v3-pointer-standard-conformance.test.ts} | 2 +- ... v3-protocol-standard-conformance.test.ts} | 2 +- ...> v3-rfc6902-standard-conformance.test.ts} | 2 +- ...t-d.ts => v3-signature-contract.test-d.ts} | 7 +- .../tests/smoke/package-smoke.mjs | 24 +- scripts/benchmark-core.mjs | 2 +- scripts/evaluate-archive-isolation.mjs | 12 +- scripts/evaluate-docs.mjs | 71 +++-- scripts/evaluate-live-site.mjs | 6 +- scripts/evaluate-standardization.mjs | 246 ++++++++-------- tests/browser/site.spec.ts | 2 +- 87 files changed, 1037 insertions(+), 1410 deletions(-) delete mode 100644 docs/standard/v2-public-surface.json rename docs/standard/{v2-projection-profile.md => v3-json-document-profile.md} (54%) create mode 100644 docs/standard/v3-public-surface.json delete mode 100644 packages/contenteditable-collaboration/tests/naming-compatibility.test.ts rename packages/json-document-collaboration/tests/{projection-conformance.test.ts => json-document-conformance.test.ts} (54%) delete mode 100644 packages/json-document-collaboration/tests/naming-compatibility.test.ts rename packages/json-document/tests/conformance/{v2 => v3}/foundation-vectors.json (96%) rename packages/json-document/tests/conformance/{v2/projection-suite.ts => v3/json-document-suite.ts} (65%) rename packages/json-document/tests/conformance/{v2/projection-vectors.json => v3/json-document-vectors.json} (89%) rename packages/json-document/tests/conformance/{v2 => v3}/jsonpath-suite.ts (90%) rename packages/json-document/tests/conformance/{v2 => v3}/pointer-suite.ts (98%) rename packages/json-document/tests/conformance/{v2 => v3}/pointer-vectors.json (97%) rename packages/json-document/tests/conformance/{v2 => v3}/pressure-suite.ts (72%) rename packages/json-document/tests/conformance/{v2 => v3}/pressure-vectors.json (94%) rename packages/json-document/tests/conformance/{v2 => v3}/protocol-suite.ts (99%) rename packages/json-document/tests/conformance/{v2 => v3}/protocol-vectors.json (92%) rename packages/json-document/tests/conformance/{v2 => v3}/rfc6902-suite.ts (97%) delete mode 100644 packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts create mode 100644 packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts rename packages/json-document/tests/independent/{v2-projection.ts => v3-json-document.ts} (90%) delete mode 100644 packages/json-document/tests/public/naming-compatibility.test.ts rename packages/json-document/tests/public/{v2-projection-standard-conformance.test.ts => v3-json-document-standard-conformance.test.ts} (79%) rename packages/json-document/tests/public/{v2-jsonpath-standard-conformance.test.ts => v3-jsonpath-standard-conformance.test.ts} (84%) rename packages/json-document/tests/public/{v2-pointer-standard-conformance.test.ts => v3-pointer-standard-conformance.test.ts} (89%) rename packages/json-document/tests/public/{v2-protocol-standard-conformance.test.ts => v3-protocol-standard-conformance.test.ts} (83%) rename packages/json-document/tests/public/{v2-rfc6902-standard-conformance.test.ts => v3-rfc6902-standard-conformance.test.ts} (83%) rename packages/json-document/tests/public/{v2-signature-contract.test-d.ts => v3-signature-contract.test-d.ts} (90%) diff --git a/README.md b/README.md index a77e2b13..75f83cab 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 implementation-neutral JSON 편집 API와 headless JSON Document입니다. -v2 root는 JSON, JSON Pointer, JSONPath, JSON Patch만 전제로 하며 Zod, React, +v3 root는 JSON, JSON Pointer, JSONPath, JSON Patch만 전제로 하며 Zod, React, selection, clipboard, history를 필수 계약에 넣지 않습니다. ```txt stateless JSON Patch |-> local implementation -----\ - | > same seven-member JSON Document + | > same six-member JSON Document `-> collaboration engine -----/ |-> optional history/text authoring `-> optional native-input DOM lease ``` @@ -30,30 +30,30 @@ stateless JSON Patch | 문서 구조 | [docs/README.md](docs/README.md) | | 변경 기록 | [docs/changelog.md](docs/changelog.md) | | 개념·이름 정본 | [docs/standard/concept-and-naming-standard.md](docs/standard/concept-and-naming-standard.md) | -| v2 compatibility profile | [docs/standard/v2-projection-profile.md](docs/standard/v2-projection-profile.md) | -| v2 공개 표면 manifest | [docs/standard/v2-public-surface.json](docs/standard/v2-public-surface.json) | +| v3 JSON Document profile | [docs/standard/v3-json-document-profile.md](docs/standard/v3-json-document-profile.md) | +| v3 공개 표면 manifest | [docs/standard/v3-public-surface.json](docs/standard/v3-public-surface.json) | | 1.x 기록 | [archive/v1/docs](archive/v1/docs) | ## 코드 지도 | 위치 | 역할 | | --- | --- | -| [packages/json-document](packages/json-document) | 배포되는 v2 Kernel | +| [packages/json-document](packages/json-document) | 배포되는 v3 Kernel | | [packages/json-document-collaboration](packages/json-document-collaboration) | transport-free causal collaboration engine | | [packages/contenteditable-collaboration](packages/contenteditable-collaboration) | collaborative string을 위한 optional native-input DOM lease | -| [apps/site](apps/site) | v2 Core 공개 문서 사이트 | +| [apps/site](apps/site) | v3 Core 공개 문서 사이트 | | [archive/v1](archive/v1) | 배포·workspace·검증에서 분리된 1.x 기록 | -v2 Kernel release는 `@interactive-os/json-document` 하나이며 dependency-free +v3 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` 위에서 조합합니다. +lifecycle은 host adapter가 여섯-member `JSONDocument` 위에서 조합합니다. 일반 DOM과 Input Events 정규화가 필요한 제품은 별도 수명 주기의 `@interactive-os/editable`도 검토할 수 있습니다. ## 경계 -v2 Kernel이 제공하는 최소 계약: +v3 Kernel이 제공하는 최소 계약: - immutable document value - JSON Pointer read와 JSONPath query diff --git a/apps/site/index.html b/apps/site/index.html index 02bd398b..f95cb15a 100644 --- a/apps/site/index.html +++ b/apps/site/index.html @@ -5,7 +5,7 @@ @@ -13,14 +13,14 @@ diff --git a/apps/site/public/site.webmanifest b/apps/site/public/site.webmanifest index 38c1f5a2..71e3aa4b 100644 --- a/apps/site/public/site.webmanifest +++ b/apps/site/public/site.webmanifest @@ -1,7 +1,7 @@ { "name": "@interactive-os/json-document", "short_name": "@interactive-os/json-document", - "description": "Provider-neutral JSON editing protocol and headless document projection.", + "description": "Implementation-neutral JSON editing and a headless JSON Document.", "start_url": ".", "display": "standalone", "background_color": "#fafaf9", diff --git a/apps/site/src/routes/Home.tsx b/apps/site/src/routes/Home.tsx index 261666d1..50f79674 100644 --- a/apps/site/src/routes/Home.tsx +++ b/apps/site/src/routes/Home.tsx @@ -24,7 +24,7 @@ export function Home() { json-document

- A headless JSON API and seven-member JSON Document for + A headless JSON API and six-member JSON Document for documents, tables, slides, canvases, and notes.

@@ -45,7 +45,7 @@ export function Home() {
Install
-
npm install @interactive-os/json-document@2.0.0
+
npm install @interactive-os/json-document@3.0.0
Start
{`import { createJSONDocument } from "@interactive-os/json-document";`}
@@ -79,7 +79,7 @@ export function Home() {

Boundary

  • Root Kernel is React- and Zod-free.
  • -
  • Rich editing belongs to host adapters composed over the seven-member document.
  • +
  • Rich editing belongs to host adapters composed over the six-member document.
  • Mutation inputs are JSON Patch with JSON Pointer paths.
  • JSONPath is search-only and returns pointers.
diff --git a/apps/site/src/site-routes.json b/apps/site/src/site-routes.json index e3e18469..ec5d81dc 100644 --- a/apps/site/src/site-routes.json +++ b/apps/site/src/site-routes.json @@ -3,14 +3,14 @@ "path": "/", "label": "Overview", "title": "json-document - Headless JSON editing", - "description": "Implementation-neutral JSON editing and a seven-member headless JSON Document for JSON Patch, JSON Pointer, and JSONPath.", + "description": "Implementation-neutral JSON editing and a six-member headless JSON Document for JSON Patch, JSON Pointer, and JSONPath.", "group": "Start" }, { "path": "/docs", "label": "Docs", "title": "json-document Docs - json-document", - "description": "User guide to stateless JSON Patch, the v2 JSON Document, validation, and host adapter boundaries.", + "description": "User guide to stateless JSON Patch, the v3 JSON Document, validation, and host adapter boundaries.", "group": "Start" }, { @@ -24,7 +24,7 @@ "path": "/docs/api", "label": "API reference", "title": "json-document API - json-document", - "description": "Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath.", + "description": "Public v3 Kernel API reference for the exact six-member JSON Document surface, JSON Patch, Pointer, and JSONPath.", "group": "Start" } ] diff --git a/apps/site/tests/docs-consistency.test.ts b/apps/site/tests/docs-consistency.test.ts index ad6a7335..a527c99e 100644 --- a/apps/site/tests/docs-consistency.test.ts +++ b/apps/site/tests/docs-consistency.test.ts @@ -32,7 +32,7 @@ const docs = { docsReadme: read("docs/README.md"), readme: read("packages/json-document/README.md"), naming: read("docs/standard/concept-and-naming-standard.md"), - profile: read("docs/standard/v2-projection-profile.md"), + profile: read("docs/standard/v3-json-document-profile.md"), llms: read("llms.txt"), site: Object.values(publicDocs).join("\n\n"), ...publicDocs, @@ -55,8 +55,8 @@ describe("public docs consistency", () => { ]); expect(readdirSync(join(root, "docs/standard")).sort()).toEqual([ "concept-and-naming-standard.md", - "v2-projection-profile.md", - "v2-public-surface.json", + "v3-json-document-profile.md", + "v3-public-surface.json", ]); expect(exists("apps/site/src/docs/json-document-concepts.md")).toBe(false); @@ -67,7 +67,7 @@ describe("public docs consistency", () => { expect(exists("docs/standard/json-document-spec.md")).toBe(false); }); - test("keeps the active site on the v2 core routes", () => { + test("keeps the active site on the v3 core routes", () => { expect(siteRoutes.map((route) => route.path)).toEqual([ "/", "/docs", @@ -125,9 +125,9 @@ describe("public docs consistency", () => { expect(docs.quickstart).toMatch(/튜토리얼: 작은 카드 편집기 만들기/); expect(docs.api).toMatch(/## 작업별 진입점/); expect(docs.api).toMatch(/ReadResult/); - expect(docs.readme).toMatch(/npm install @interactive-os\/json-document@2\.0\.0/); + expect(docs.readme).toMatch(/npm install @interactive-os\/json-document@3\.0\.0/); expect(docs.readme).toMatch(/implementation-neutral/); - expect(docs.llms).toMatch(/2\.0\.0.*Stable/); + expect(docs.llms).toMatch(/3\.0\.0.*Stable/); }); test("locks one canonical concept and naming standard", () => { @@ -138,7 +138,7 @@ describe("public docs consistency", () => { expect(docs.naming).toMatch(/## 접미어/); expect(docs.naming).toMatch(/## 함수 동사/); expect(docs.naming).toMatch(/## Boolean/); - expect(docs.naming).toMatch(/## v2 compatibility map/); + expect(docs.naming).toMatch(/## Protocol vocabulary boundary/); expect(docs.naming).toMatch(/## Current public surface decisions/); expect(docs.naming).toMatch(/## 새 concept admission/); @@ -186,7 +186,7 @@ describe("public docs consistency", () => { } }); - test("locks the documented v2 root contract", () => { + test("locks the documented v3 root contract", () => { expect(Object.keys(publicContract)).toEqual(["root"]); expect(publicContract.root.values).toEqual([ "appendSegment", @@ -200,7 +200,6 @@ describe("public docs consistency", () => { ]); expect(publicContract.root.types).toEqual([ "JSONAppliedChange", - "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", "JSONDocumentOptions", diff --git a/apps/site/tests/site-shell.test.tsx b/apps/site/tests/site-shell.test.tsx index 7221601e..78978472 100644 --- a/apps/site/tests/site-shell.test.tsx +++ b/apps/site/tests/site-shell.test.tsx @@ -28,7 +28,7 @@ describe("official site shell", () => { expect(screen.getByRole("link", { name: "Skip to content" }).getAttribute("href")).toBe("#main-content"); expect(screen.getByRole("heading", { level: 1, name: "json-document" })).toBeTruthy(); expect(screen.getByText(/Implementation-neutral JSON editing/)).toBeTruthy(); - expect(screen.getByText("npm install @interactive-os/json-document@2.0.0")).toBeTruthy(); + expect(screen.getByText("npm install @interactive-os/json-document@3.0.0")).toBeTruthy(); expect(screen.getByText('import { createJSONDocument } from "@interactive-os/json-document";')).toBeTruthy(); expect(screen.getByRole("link", { name: "npm" }).getAttribute("href")).toBe("https://www.npmjs.com/package/@interactive-os/json-document"); expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe("https://github.com/developer-1px/json-document"); @@ -46,7 +46,7 @@ describe("official site shell", () => { await user.click(nav.getByRole("link", { name: "Docs" })); await waitFor(() => expect(document.title).toBe("json-document Docs - json-document")); - expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("User guide to stateless JSON Patch, the v2 JSON Document, validation, and host adapter boundaries."); + expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("User guide to stateless JSON Patch, the v3 JSON Document, validation, and host adapter boundaries."); expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute("href")).toBe("https://developer-1px.github.io/json-document/docs"); expect(await screen.findByRole("heading", { level: 1, name: "json-document Docs" }, { timeout: 10000 })).toBeTruthy(); expect(screen.getByRole("heading", { name: "배경" })).toBeTruthy(); @@ -58,11 +58,11 @@ describe("official site shell", () => { await user.click(nav.getByRole("link", { name: "API reference" })); await waitFor(() => expect(document.title).toBe("json-document API - json-document")); - expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath."); - expect(document.head.querySelector('meta[property="og:description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath."); + expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("Public v3 Kernel API reference for the exact six-member JSON Document surface, JSON Patch, Pointer, and JSONPath."); + expect(document.head.querySelector('meta[property="og:description"]')?.getAttribute("content")).toBe("Public v3 Kernel API reference for the exact six-member JSON Document surface, JSON Patch, Pointer, and JSONPath."); expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute("href")).toBe("https://developer-1px.github.io/json-document/docs/api"); expect(document.head.querySelector('meta[name="twitter:title"]')?.getAttribute("content")).toBe("json-document API - json-document"); - expect(document.head.querySelector('meta[name="twitter:description"]')?.getAttribute("content")).toBe("Public v2 Kernel API reference for the exact six-member JSON Document compatibility surface, JSON Patch, Pointer, and JSONPath."); + expect(document.head.querySelector('meta[name="twitter:description"]')?.getAttribute("content")).toBe("Public v3 Kernel API reference for the exact six-member JSON Document surface, JSON Patch, Pointer, and JSONPath."); expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute("content")).toBe("https://developer-1px.github.io/json-document/docs/api"); expect(await screen.findByRole("heading", { level: 1, name: "json-document API" })).toBeTruthy(); expect(screen.getByRole("heading", { name: "작업별 진입점" })).toBeTruthy(); diff --git a/docs/README.md b/docs/README.md index af339a04..eed38371 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ docs |-- changelog.md # 사용자 영향 중심 변경 기록 |-- generated -| `-- repo-catalog.json # v2 Core와 optional companion catalog +| `-- repo-catalog.json # v3 Core와 optional companion catalog |-- issues | `-- 245-concept-dictionary-naming-ssot.md # issue #245 raw 실행 계약 |-- public @@ -17,8 +17,8 @@ docs | `-- api.md # 공개 API `-- standard |-- concept-and-naming-standard.md # 전체 repository 개념·이름 정본 - |-- v2-projection-profile.md # 현재 v2 root compatibility profile - `-- v2-public-surface.json # v2 공개 binding + |-- v3-json-document-profile.md # 현재 v3 root compatibility profile + `-- v3-public-surface.json # v3 공개 binding ``` 1.x public 문서, 표준, 기능 taxonomy 연구 기록은 코드와 함께 @@ -28,10 +28,10 @@ generated catalog의 입력이 아닙니다. ## 규범 우선순위 Repository 전체의 개념과 이름 정본은 -`concept-and-naming-standard.md`입니다. 현재 v2 portable root의 compatibility -정본은 `v2-projection-profile.md`, `v2-public-surface.json`, 그리고 profile이 -지정한 conformance vector와 language binding입니다. 이름 정본은 stable v2 -identifier나 동작을 바꾸지 않으며, Archive의 1.x 문서는 v2 exact +`concept-and-naming-standard.md`입니다. 현재 v3 portable root의 compatibility +정본은 `v3-json-document-profile.md`, `v3-public-surface.json`, 그리고 profile이 +지정한 conformance vector와 language binding입니다. 이름 정본은 stable v3 +identifier나 동작을 바꾸지 않으며, Archive의 1.x 문서는 v3 exact 20-symbol·six-member 계약을 확장하지 않습니다. ## 책임 기준 @@ -39,10 +39,10 @@ identifier나 동작을 바꾸지 않으며, Archive의 1.x 문서는 v2 exact | 위치 | 책임 | 독자 | | --- | --- | --- | | `changelog.md` | 사용자 영향 중심 변경 기록 | 외부 사용자, 릴리스 확인자 | -| `generated/` | v2 Core와 명시적으로 등록한 companion 표면으로 만든 reference data. 직접 편집하지 않는다. | evaluator | +| `generated/` | v3 Core와 명시적으로 등록한 companion 표면으로 만든 reference data. 직접 편집하지 않는다. | evaluator | | `issues/` | Context recovery를 위해 보존한 issue raw 실행 계약. Concept authority는 아니다. | maintainer, 실행 agent | | `public/` | 사용법과 프로젝트 이해를 위한 공식 문서 원천 | 외부 사용자, LLM, 사이트 방문자 | -| `standard/` | Repository 개념·이름 정본과 v2 root compatibility 정본 | 표준화 검토자, 대체 구현 작성자 | +| `standard/` | Repository 개념·이름 정본과 v3 root compatibility 정본 | 표준화 검토자, 대체 구현 작성자 | ## 작성 원칙 diff --git a/docs/changelog.md b/docs/changelog.md index 50c13010..8eab8d27 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,9 +1,22 @@ # Changelog -All notable changes to the active v2 packages are documented here. The complete +All notable changes to the active v3 packages are documented here. The complete 1.x history is preserved under `archive/v1/docs/changelog.md`. -## Unreleased +## 3.0.0 — Unreleased + +- **Breaking:** Removed every deprecated public naming alias and option fallback. + The canonical API now exclusively uses `validatePatch`, + `JSONPatchValidationResult`, `validate`, `CollaborationReplica`, + `ReplicaStatus`, `History*`, `Text*`, `didChangeDocument`, + `ContentEditable*`, `TextDOMAdapter`, and `DOMObservation`. +- Replaced Projection-named active standards and conformance artifacts with the + v3 JSON Document profile, 21-symbol root manifest, and six-member + `JSONDocument` contract. +- Raised companion peer ranges to Core `^3.0.0`; no wire field, protocol + meaning, runtime algorithm, or package boundary changed. + +## 2.0.0 - Made the collaborative text profile include actor-local selective history, and made capture-time text commits return the canonical owned diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index edae8290..e73d4375 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -12,7 +12,7 @@ "status": "core", "private": false, "publishable": true, - "version": "2.0.0", + "version": "3.0.0", "description": "Implementation-neutral JSON Patch API and headless JSON Document.", "license": "MIT", "summary": "문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는\nimplementation-neutral JSON 편집 API와 headless JSON Document입니다.", @@ -21,7 +21,6 @@ ], "publicExports": [ "JSONAppliedChange", - "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", "JSONDocumentCommitOptions", @@ -43,7 +42,7 @@ "trackPointer", "tryParsePointer" ], - "publicExportCount": 22, + "publicExportCount": 21, "keywords": [ "document", "editor", @@ -61,10 +60,10 @@ "status": "companion", "private": false, "publishable": true, - "version": "0.1.0-rc.2", + "version": "0.2.0-rc.1", "description": "Transport-free causal collaboration engine for @interactive-os/json-document.", "license": "MIT", - "summary": "Transport-free causal collaboration engine for the seven-member\n`@interactive-os/json-document` JSON Document contract.", + "summary": "Transport-free causal collaboration engine for the six-member\n`@interactive-os/json-document` JSON Document contract.", "entrypoints": [ ".", "./history", @@ -74,7 +73,6 @@ "ActorId", "ArrayPlacement", "ChangeId", - "CollaborationAcceptance", "CollaborationBundle", "CollaborationChange", "CollaborationCheckpoint", @@ -83,7 +81,6 @@ "CollaborationCompactionReport", "CollaborationCompactionResult", "CollaborationConflict", - "CollaborationControl", "CollaborationEpoch", "CollaborationEpochParent", "CollaborationIngestFailure", @@ -97,7 +94,6 @@ "CollaborationRulesetIdentity", "CollaborationRuntime", "CollaborationRuntimeOptions", - "CollaborationSnapshot", "CollaborationValidation", "ContainerNodeId", "MemberId", @@ -115,7 +111,7 @@ "createCollaborationRuntime", "restoreCollaborationRuntime" ], - "publicExportCount": 43, + "publicExportCount": 40, "keywords": [] }, { @@ -124,7 +120,7 @@ "status": "companion", "private": false, "publishable": true, - "version": "0.1.0-rc.1", + "version": "0.2.0-rc.1", "description": "IME-safe native-input DOM lease for json-document collaboration text.", "license": "MIT", "summary": "IME-safe native-input DOM lease for\n`@interactive-os/json-document-collaboration/text`.", @@ -132,22 +128,15 @@ "." ], "publicExports": [ - "CollaborationContentEditableAdapter", - "CollaborationContentEditableOptions", - "CollaborationContentEditableResult", - "CollaborationTextDOM", - "CollaborationTextDOMObservation", "ContentEditableAdapter", "ContentEditableOptions", "ContentEditableResult", "DOMObservation", "TextDOMAdapter", - "createCollaborationContentEditableAdapter", "createContentEditableAdapter", - "plainTextCollaborationDOM", "plainTextDOMAdapter" ], - "publicExportCount": 14, + "publicExportCount": 7, "keywords": [ "@interactive-os/json-document", "collaboration", diff --git a/docs/public/api.md b/docs/public/api.md index 13b6e294..f433c8b9 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -1,13 +1,13 @@ # json-document API -이 문서는 v2 루트 Kernel의 정본 API를 설명합니다. +이 문서는 v3 루트 Kernel의 정본 API를 설명합니다. ```txt @interactive-os/json-document |-- createJSONDocument |-- applyPatch |-- Pointer helpers -`-- seven-member JSONDocument +`-- six-member JSONDocument ``` ## 기준 @@ -237,9 +237,8 @@ Subscriber는 이미 publish된 `JSONAppliedChange`를 받습니다. Unsubscribe ## Validation -`createJSONDocument`의 canonical `validate` option은 특정 schema object를 -요구하지 않는 implementation-neutral validation boundary입니다. Stable v2 -`accepts`는 deprecated compatibility alias입니다. +`createJSONDocument`의 `validate` option은 특정 schema object를 요구하지 않는 +implementation-neutral validation boundary입니다. ```ts import * as z from "zod"; @@ -275,8 +274,7 @@ parse한 변환값은 Core state로 채택되지 않으며, normalization이 필 변경을 JSON Patch에 명시합니다. Canonical concept와 result는 validation과 `JSONPatchValidationResult`입니다. -`accepts`와 `JSONCapabilityResult`는 deprecated stable v2 compatibility -identifier입니다. Naming 기준은 +Naming 기준은 [Concept and Naming Standard](https://github.com/developer-1px/json-document/blob/main/docs/standard/concept-and-naming-standard.md)를 따릅니다. @@ -298,7 +296,7 @@ exact key 집합이나 exhaustive code union에 의존하지 않습니다. ## 공개 root -Root는 22개 public symbol만 공개합니다. +Root는 21개 public symbol만 공개합니다. ```txt values @@ -309,7 +307,7 @@ values types JSONValue, Pointer, JSONPatchOperation JSONAppliedChange, JSONPatchResult, JSONDocumentCommitResult - JSONPatchValidationResult, JSONCapabilityResult, JSONChangeMetadata + JSONPatchValidationResult, JSONChangeMetadata JSONDocumentOptions, JSONDocumentCommitOptions ReadResult, QueryResult, JSONDocument ``` @@ -318,4 +316,4 @@ types 패키지는 `/session`이나 `/react` subpath를 공개하지 않습니다. Selection, clipboard, history, schema introspection, DOM lifecycle과 framework binding은 -host 또는 별도 adapter가 일곱-member `JSONDocument`를 조합해 구현합니다. +host 또는 별도 adapter가 여섯-member `JSONDocument`를 조합해 구현합니다. diff --git a/docs/public/overview.md b/docs/public/overview.md index 3f484b67..71c898ea 100644 --- a/docs/public/overview.md +++ b/docs/public/overview.md @@ -1,6 +1,6 @@ # json-document Docs -json-document v2는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 +json-document v3는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 implementation-neutral JSON 편집 API와 headless JSON Document입니다. 루트 package는 JSON, JSON Pointer, JSONPath, JSON Patch만 전제로 하며 Zod, React, selection, clipboard, history를 필수 계약에 넣지 않습니다. @@ -8,7 +8,7 @@ React, selection, clipboard, history를 필수 계약에 넣지 않습니다. ```txt stateless JSON Patch |-> local implementation -----\ - | > same seven-member JSON Document + | > same six-member JSON Document `-> collaboration engine -----/ |-> optional history/text authoring `-> optional native-input DOM lease ``` @@ -40,16 +40,13 @@ CRDT와 OT는 host 또는 adapter 책임입니다. | JSONPath | 여러 위치를 찾는 query. 결과는 Pointer 목록 | | JSON Patch | ordered atomic mutation 형식 | | Stateless JSON Patch | 현재 document instance 없이 JSON Patch를 적용하는 함수 | -| JSON Document | 현재 document value에 read, validation, commit, notification을 연결한 일곱-member port | +| JSON Document | 현재 document value에 read, validation, commit, notification을 연결한 여섯-member port | | validation | Candidate document를 commit 전에 검사하는 implementation-neutral callback | | Host adapter | selection, clipboard, history, DOM과 고수준 편집 동사를 소유하는 별도 계층 | -전체 canonical concept, 접두어·접미어·동사·boolean 규칙과 stable v2 이름의 -compatibility map은 +전체 canonical concept, 접두어·접미어·동사·boolean 규칙은 [Concept and Naming Standard](https://github.com/developer-1px/json-document/blob/main/docs/standard/concept-and-naming-standard.md)가 -정의합니다. `Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 -deprecated stable v2 identifier 또는 compatibility label이며 별도 canonical -concept가 아닙니다. +정의합니다. Public API는 canonical identifier만 제공합니다. 가장 중요한 경계는 query와 mutation을 섞지 않는 것입니다. @@ -118,7 +115,7 @@ if (result.ok) { | 표면 | 상태 | 책임 | | --- | --- | --- | -| `@interactive-os/json-document` | v2 Kernel | Stateless JSON Patch와 일곱-member JSON Document | +| `@interactive-os/json-document` | v3 Kernel | Stateless JSON Patch와 여섯-member JSON Document | | `@interactive-os/json-document-collaboration` | optional companion | 같은 JSON Document 뒤의 transport-free causal engine | | `@interactive-os/json-document-contenteditable-collaboration` | optional companion | collaborative string의 native-input DOM lease | @@ -140,7 +137,7 @@ compatibility를 검증합니다. `@interactive-os/editable`은 DOM과 Input Events 정규화를 담당하는 별도 companion 예시입니다. `JSONDocument`는 canonical headless JSON state로 남고, editable은 contenteditable lifecycle을 소유하며, 문서별 의미는 adapter가 연결합니다. -이 companion은 json-document v2 release catalog에 포함되지 않습니다. +이 companion은 json-document v3 release catalog에 포함되지 않습니다. ## 자주 쓰는 작업 diff --git a/docs/public/quickstart.md b/docs/public/quickstart.md index 38126e57..dc28a04f 100644 --- a/docs/public/quickstart.md +++ b/docs/public/quickstart.md @@ -1,6 +1,6 @@ # 튜토리얼: 작은 카드 편집기 만들기 -작은 board state를 일곱-member Core로 읽고, 검증하고, 변경하고, 구독합니다. +작은 board state를 여섯-member Core로 읽고, 검증하고, 변경하고, 구독합니다. 루트 package에는 schema provider나 UI framework가 필요하지 않습니다. ## 1. JSON document 만들기 @@ -128,8 +128,7 @@ if (preview.ok) { ## 6. 선택한 validator 연결하기 Core는 Zod를 요구하지 않습니다. 어떤 validator든 canonical `validate` -callback으로 연결할 수 있습니다. Stable v2 `accepts`는 deprecated -compatibility identifier입니다. +callback으로 연결할 수 있습니다. ```ts import * as z from "zod"; diff --git a/docs/standard/concept-and-naming-standard.md b/docs/standard/concept-and-naming-standard.md index 17cb84c9..83fad2f1 100644 --- a/docs/standard/concept-and-naming-standard.md +++ b/docs/standard/concept-and-naming-standard.md @@ -3,13 +3,12 @@ 상태: Canonical 이 문서는 현재 `json-document` repository의 개념과 이름 문법을 정의하는 유일한 -사람 작성 정본이다. 개요, API 문서, v2 compatibility profile, package README, +사람 작성 정본이다. 개요, API 문서, versioned profile, package README, generated catalog와 구현 이름은 이 문서를 참조한다. Generated artifact와 현재 구현은 이 문서의 개념 의미를 덮어쓸 수 없다. -이 문서는 runtime 동작, protocol 의미, wire 형식과 stable v2 public API를 -변경하지 않는다. Canonical term과 v2 compatibility identifier가 다르면 문서는 -canonical term을 먼저 설명하고 실제 identifier를 code font로 병기한다. +이 문서는 runtime 동작, protocol 의미와 wire 형식을 변경하지 않는다. Public +TypeScript API와 내부 identifier는 canonical term만 사용한다. ## 이름 권위 @@ -44,9 +43,8 @@ position, text splice, adapter와 transport-agnostic engine이다. | JSON Patch | RFC 6902 ordered operation batch | Merge Patch, semantic operation | | Patch application | JSON value에 JSON Patch를 원자적으로 적용하는 stateless 연산 | Stateful commit, notification, collaboration | -`Pure Protocol`은 별도 canonical concept가 아니다. v2 문맥에서 이 이름을 -인용해야 할 때는 **v2 Pure Protocol compatibility label**이라고 쓰고, 일반 -설명에서는 **stateless JSON Patch application**을 쓴다. +`Pure Protocol`은 retired vocabulary이며 별도 canonical concept가 아니다. +일반 설명과 identifier에서는 **stateless JSON Patch application**을 쓴다. ### JSON Document @@ -74,9 +72,9 @@ publication -> change notification ``` -`Projection`, `canPatch`, `JSONCapabilityResult`, `accepts`는 deprecated stable -v2 compatibility identifier로만 남는다. Canonical code는 `JSONDocument`, -`validatePatch`, `JSONPatchValidationResult`, `validate`를 사용한다. +`Projection`, capability probe, acceptance callback 같은 이전 synonym은 public +identifier로 남기지 않는다. Canonical code는 `JSONDocument`, `validatePatch`, +`JSONPatchValidationResult`, `validate`만 사용한다. ### Collaboration @@ -99,8 +97,7 @@ v2 compatibility identifier로만 남는다. Canonical code는 `JSONDocument`, `provider`는 실제 network, storage, schema 또는 host provider에만 쓴다. Transport를 소유하지 않는 collaboration package는 **collaboration engine**이다. -`CollaborationSnapshot`은 deprecated compatibility alias다. Canonical type은 -`ReplicaStatus`이며 snapshot이나 checkpoint로 설명하지 않는다. +Canonical type은 `ReplicaStatus`이며 snapshot이나 checkpoint로 설명하지 않는다. ### Lifecycle @@ -212,7 +209,7 @@ History, text, status, DOM type마다 반복하지 않는다. ```text prefer TextRuntime from /text next CollaborativeTextRuntime -avoid CollaborationTextRuntime +avoid TextRuntime ``` ### `DOM` @@ -277,8 +274,7 @@ Data Info ``` -`Data`가 JSON data처럼 정확한 표준 용어일 때는 예외다. 기존 compatibility -identifier는 versioned migration 없이 제거하지 않는다. +`Data`가 JSON data처럼 정확한 표준 용어일 때는 예외다. 책임 이름으로 대체한다. @@ -351,13 +347,7 @@ didChangeDocument `ok`는 Result discriminant이므로 유지한다. `active`, `available`, `valid`, `accepted`, `changed` 같은 무접두 public boolean은 새로 만들지 않는다. -Canonical replacement: - -```text -projectionChanged (deprecated alias) -> didChangeDocument -``` - -Canonical field와 compatibility field는 같은 boolean을 반환한다. +Completed operation의 document 변화 여부는 `didChangeDocument`만 사용한다. ## Collection과 축약 @@ -392,8 +382,8 @@ Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. `PascalCase`를 허용한다. - Package entrypoint는 export subpath와 맞춘 `index.ts` 또는 `-index.ts`를 사용한다. -- Compatibility profile, vector, fixture와 test file은 호환 대상의 기존 이름을 - 포함할 수 있지만 경로 자체가 compatibility artifact임을 드러내야 한다. +- Versioned profile, vector, fixture와 test file도 해당 버전의 canonical + vocabulary를 사용한다. - Canonical domain path는 `domain/json-document`다. `domain/projection`은 사용하지 않는다. @@ -404,89 +394,34 @@ Identifier의 `Id`는 앞선 TypeScript casing 규칙을 따른다. | `JSONDocument` | Stateful document API | Exact domain | 없음 | keep | `JSONDocument` | | Document Projection | 같은 stateful document API | Weak/local | DB projection과 충돌 | merge | JSON Document | | Pure Protocol | Stateless Patch application | Weak/local | Wire protocol과 충돌 | merge | stateless JSON Patch | -| `canPatch` | Patch와 candidate validation | Near | Capability/probe synonym | compatibility alias | `validatePatch` | -| `JSONCapabilityResult` | Patch validation result | None | 지나치게 넓음 | compatibility alias | `JSONPatchValidationResult` | -| `accepts` | Candidate validator | Near | Boolean처럼 보이나 Result 반환 | compatibility alias | `validate` | +| `validatePatch` | Patch와 candidate validation | Exact responsibility | 없음 | keep | `validatePatch` | +| `JSONPatchValidationResult` | Patch validation result | Exact responsibility | 없음 | keep | `JSONPatchValidationResult` | +| `validate` | Candidate validator | Exact responsibility | 없음 | keep | `validate` | | acceptance | Candidate validation | Near | Validation과 중복 | merge | validation | | publication | Subscriber notification | Near | Process/mechanism 모호 | merge | change notification | | `JSONAppliedChange` | Applied canonical operations | Exact | 없음 | keep | `JSONAppliedChange` | | collaboration provider | Transport-free causal engine | Mismatch | Provider는 connector를 암시 | rename prose | collaboration engine | -| `CollaborationControl` | Replica status/sync/checkpoint API | None | Vague responsibility | compatibility alias | `CollaborationReplica` | -| `runtime.collaboration` | Replica surface | Near | Package 이름이지 역할이 아님 | compatibility alias | `runtime.replica` | -| `CollaborationSnapshot` | Current causal diagnostics | Mismatch | Snapshot/checkpoint 충돌 | compatibility alias | `ReplicaStatus` | -| `current()` | Replica status 반환 | Weak | Return 의미가 숨음 | compatibility alias | `status()` | +| `CollaborationReplica` | Replica status/sync/checkpoint API | Exact responsibility | 없음 | keep | `CollaborationReplica` | +| `runtime.replica` | Replica surface | Exact responsibility | 없음 | keep | `runtime.replica` | +| `ReplicaStatus` | Current causal diagnostics | Exact responsibility | 없음 | keep | `ReplicaStatus` | +| `status()` | Replica status 반환 | Exact responsibility | 없음 | keep | `status()` | | `CollaborationBundle` | Epoch/change exchange artifact | Near | Full/incremental 불명확 | keep | `CollaborationBundle` | | `SuppressedChange` | Known inactive contribution | Exact local | Rejection과 구분 필요 | keep | `SuppressedChange` | | `materializeChanges` | DAG에서 current document 파생 | Exact de-facto | 없음 | keep | `materializeChanges` | -| `projectionChanged` | Visible document change outcome | Weak | 제거할 concept 누출 | compatibility alias | `didChangeDocument` | -| `CollaborationTextDOM` | DOM observe/render/selection boundary | Mismatch | DOM state처럼 보임 | compatibility alias | `TextDOMAdapter` | +| `didChangeDocument` | Visible document change outcome | Exact predicate | 없음 | keep | `didChangeDocument` | +| `TextDOMAdapter` | DOM observe/render/selection boundary | Exact responsibility | 없음 | keep | `TextDOMAdapter` | | publication lease | Native input 중 DOM rendering 유예 | Local | Sync lock으로 오해 | rename prose | native-input DOM lease | -## v2 compatibility map +## Protocol vocabulary boundary -| Canonical term | Stable v2 identifier 또는 label | 사용 규칙 | -| --- | --- | --- | -| JSON Document | `JSONDocument`, Document Projection, Projection | `JSONDocument`는 유지하고 Projection은 v2 compatibility 문맥에서만 사용 | -| Stateless JSON Patch | Pure Protocol, `applyPatch` | `applyPatch`는 유지하고 Pure Protocol을 새 canonical concept로 사용하지 않음 | -| Patch validation | `canPatch`, `JSONCapabilityResult` | Deprecated alias; code는 `validatePatch`, `JSONPatchValidationResult` 사용 | -| Validation | acceptance, `accepts` | Wire label과 deprecated alias; code는 `validate` 사용 | -| Applied change | `JSONAppliedChange` | 그대로 사용 | -| Change notification | publication, `subscribe` | `subscribe`는 유지하고 publication을 notification으로 설명 | -| Collaboration engine | collaboration provider package description | Transport-free engine으로 설명 | -| Replica | `CollaborationControl`, `runtime.collaboration` | Deprecated alias; `CollaborationReplica`, `runtime.replica` 사용 | -| Replica status | `CollaborationSnapshot`, `current()` | Deprecated alias; `ReplicaStatus`, `status()` 사용 | -| History status | `CollaborationHistorySnapshot` | Deprecated alias; `HistoryStatus` 사용 | -| Native-input DOM lease | DOM/IME publication lease | Rendering만 유예한다고 명시 | -| DOM adapter | `CollaborationTextDOM` | Deprecated alias; `TextDOMAdapter` 사용 | +Wire fields와 error code인 `acceptance`, `acceptance_required`, +`acceptance_reentrancy`, `deps`, `ops`는 versioned protocol 영역이므로 +TypeScript identifier 문법과 별도로 유지한다. Public TypeScript API와 내부 +identifier에는 canonical vocabulary만 사용한다. ## Current public surface decisions -## Compatibility migration - -Compatibility alias는 기존 runtime object 또는 export와 같은 값을 가리키며 별도 -동작이나 개념을 만들지 않는다. - -```text -Core - canPatch -> validatePatch - JSONCapabilityResult -> JSONPatchValidationResult - options.accepts -> options.validate - -Collaboration - runtime.collaboration -> runtime.replica - CollaborationControl -> CollaborationReplica - CollaborationSnapshot -> ReplicaStatus - replica.current() -> replica.status() - CollaborationAcceptance -> CollaborationValidation - -History subpath - CollaborationHistory* -> History* - createCollaborationHistoryRuntime -> createHistoryRuntime - restoreCollaborationHistoryRuntime -> restoreHistoryRuntime - history.current() -> history.status() - -Text subpath - CollaborationText* -> Text* - createCollaborationTextRuntime -> createTextRuntime - restoreCollaborationTextRuntime -> restoreTextRuntime - -Contenteditable package - CollaborationContentEditable* -> ContentEditable* - createCollaborationContentEditableAdapter -> createContentEditableAdapter - CollaborationTextDOM* -> TextDOMAdapter / DOMObservation - plainTextCollaborationDOM -> plainTextDOMAdapter - -Boolean result - projectionChanged -> didChangeDocument -``` - -Wire fields와 error code인 `acceptance`, `acceptance_required`, -`acceptance_reentrancy`, `deps`, `ops`는 protocol compatibility 영역이므로 -이 migration의 identifier 대상이 아니다. - -아래 표는 현재 public export를 canonical 또는 compatibility alias로 분류한다. -Canonical 이름은 새 코드와 문서가 사용한다. Compatibility alias는 기존 소비자를 -보호하지만 새 코드에서 사용하지 않으며 `@deprecated`로 표시한다. +아래 표는 현재 public export를 canonical vocabulary로 분류한다. ### Core root @@ -495,7 +430,6 @@ Canonical 이름은 새 코드와 문서가 사용한다. Compatibility alias는 | keep | `JSONValue`, `Pointer`, `JSONPatchOperation`, `JSONAppliedChange`, `JSONPatchResult`, `JSONDocument`, `JSONDocumentOptions`, `JSONDocumentCommitOptions`, `JSONDocumentCommitResult`, `JSONPatchValidationResult`, `ReadResult`, `QueryResult` | Standard JSON 또는 책임 suffix와 일치 | | keep | `createJSONDocument`, `applyPatch`, `parsePointer`, `tryParsePointer`, `buildPointer`, `parentPointer`, `appendSegment`, `trackPointer` | Function verb grammar와 일치 | | merge | `JSONChangeMetadata` | Applied change의 metadata이며 별도 domain concept를 만들지 않음 | -| compatibility alias | `JSONCapabilityResult`, `canPatch`, `accepts` | 각각 `JSONPatchValidationResult`, `validatePatch`, `validate`로 대체 | ### Collaboration root @@ -510,28 +444,24 @@ Canonical 이름은 새 코드와 문서가 사용한다. Compatibility alias는 | keep | `CollaborationIngestSuccess`, `CollaborationIngestFailure`, `CollaborationIngestResult` | Result variant grammar와 일치 | | keep | `CollaborationRuntime`, `CollaborationRuntimeOptions`, `CollaborationRestoreOptions`, `CollaborationRestoreResult`, `CollaborationCompactionOptions`, `CollaborationCompactionReport`, `CollaborationCompactionResult`, `CollaborationReplica`, `ReplicaStatus`, `CollaborationValidation` | Runtime/Options/Result/Report/Replica/Status grammar와 일치 | | keep | `createCollaborationRuntime`, `restoreCollaborationRuntime`, `compactCollaborationCheckpoint` | Function verb grammar와 일치 | -| compatibility alias | `CollaborationAcceptance`, `CollaborationControl`, `CollaborationSnapshot`, `runtime.collaboration`, `current()` | 각각 `CollaborationValidation`, `CollaborationReplica`, `ReplicaStatus`, `runtime.replica`, `status()`로 대체 | ### History subpath | Decision | Current public exports | Canonical rule | | --- | --- | --- | | keep | `History`, `HistoryStatus`, `HistoryResult`, `HistoryRuntime`, `HistoryRestoreResult`, `createHistoryRuntime`, `restoreHistoryRuntime` | Subpath namespace와 책임 suffix가 일치 | -| compatibility alias | `CollaborationHistoryControl`, `CollaborationHistorySnapshot`, `CollaborationHistoryResult`, `CollaborationHistoryRuntime`, `CollaborationHistoryRestoreResult`, `createCollaborationHistoryRuntime`, `restoreCollaborationHistoryRuntime`, `current()` | Canonical `History*`, `createHistoryRuntime`, `restoreHistoryRuntime`, `status()`로 대체 | ### Text subpath | Decision | Current public exports | Canonical rule | | --- | --- | --- | | keep | `Text`, `TextSelection`, `TextObservation`, `TextCapture`, `TextCaptureResult`, `TextPlan`, `TextPlanResult`, `TextCommitResult`, `TextRuntime`, `TextRestoreResult`, `createTextRuntime`, `restoreTextRuntime` | Subpath namespace와 capture/plan/commit 책임이 일치 | -| compatibility alias | `CollaborationTextControl`, `CollaborationTextSelection`, `CollaborationTextObservation`, `CollaborationTextCapture`, `CollaborationTextCaptureResult`, `CollaborationTextPlan`, `CollaborationTextPlanResult`, `CollaborationTextCommitResult`, `CollaborationTextRuntime`, `CollaborationTextRestoreResult`, `createCollaborationTextRuntime`, `restoreCollaborationTextRuntime` | Canonical `Text*`, `createTextRuntime`, `restoreTextRuntime`로 대체 | ### Contenteditable package | Decision | Current public exports | Canonical rule | | --- | --- | --- | | keep | `ContentEditableAdapter`, `ContentEditableOptions`, `ContentEditableResult`, `createContentEditableAdapter`, `TextDOMAdapter`, `DOMObservation`, `plainTextDOMAdapter` | Package namespace와 DOM Adapter 책임이 일치 | -| compatibility alias | `CollaborationContentEditableAdapter`, `CollaborationContentEditableOptions`, `CollaborationContentEditableResult`, `CollaborationTextDOM`, `CollaborationTextDOMObservation`, `createCollaborationContentEditableAdapter`, `plainTextCollaborationDOM` | Canonical 이름으로 대체 | ## Target vocabulary @@ -607,10 +537,10 @@ Contenteditable package ## 변경 절차 1. Naming evaluation table에 책임, 근거, 안정성, 일관성, 모호성, concept - reduction과 compatibility 영향을 기록한다. + reduction과 public/wire 영향을 기록한다. 2. Canonical prose 변경과 code identifier 변경을 분리한다. -3. Stable identifier나 wire field 변경은 별도 versioned migration으로 승인받는다. -4. Overview, API, compatibility profile, collaboration/history/text, +3. Public identifier나 wire field 변경은 별도 versioned evolution으로 승인받는다. +4. Overview, API, versioned profile, collaboration/history/text, contenteditable와 contributor 문서를 함께 갱신한다. 5. Generated artifact는 이 정본을 복제하지 않고 참조 또는 검증한다. 6. Naming drift check와 관련 test를 통과한다. @@ -633,7 +563,7 @@ Contenteditable package 11. Public boolean은 `is`, `has`, `can`, `should`, `did`로 읽히게 한다. 12. 새 public field를 불필요하게 축약하지 않는다. 13. 같은 validation 책임에는 하나의 동사를 사용한다. -14. Public canonical concept는 JSON Document이며 Projection은 v2 - compatibility label로만 남긴다. +14. Public canonical concept는 JSON Document이며 Projection은 public 또는 + internal identifier로 사용하지 않는다. 15. 이름 변경은 runtime logic, protocol semantics 또는 wire behavior 변경을 승인하지 않는다. diff --git a/docs/standard/v2-public-surface.json b/docs/standard/v2-public-surface.json deleted file mode 100644 index 37623046..00000000 --- a/docs/standard/v2-public-surface.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "formatVersion": 1, - "status": "stable", - "sourceContract": "packages/json-document/public-contract.json#root", - "package": { - "name": "@interactive-os/json-document", - "version": "2.0.0", - "entrypoint": ".", - "excludedEntrypoints": [ - "./session", - "./react" - ], - "runtimeDependencies": [], - "peerDependencies": [] - }, - "conformance": { - "projectionVectors": "packages/json-document/tests/conformance/v2/projection-vectors.json", - "projectionSuite": "packages/json-document/tests/conformance/v2/projection-suite.ts", - "projectionBinding": "packages/json-document/tests/public/v2-projection-standard-conformance.test.ts", - "protocolVectors": "packages/json-document/tests/conformance/v2/protocol-vectors.json", - "protocolSuite": "packages/json-document/tests/conformance/v2/protocol-suite.ts", - "protocolBinding": "packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts", - "pointerVectors": "packages/json-document/tests/conformance/v2/pointer-vectors.json", - "pointerSuite": "packages/json-document/tests/conformance/v2/pointer-suite.ts", - "pointerBinding": "packages/json-document/tests/public/v2-pointer-standard-conformance.test.ts", - "rfc6902Suite": "packages/json-document/tests/conformance/v2/rfc6902-suite.ts", - "rfc6902Binding": "packages/json-document/tests/public/v2-rfc6902-standard-conformance.test.ts", - "jsonPathSuite": "packages/json-document/tests/conformance/v2/jsonpath-suite.ts", - "jsonPathBinding": "packages/json-document/tests/public/v2-jsonpath-standard-conformance.test.ts", - "foundationVectors": "packages/json-document/tests/conformance/v2/foundation-vectors.json", - "pressureVectors": "packages/json-document/tests/conformance/v2/pressure-vectors.json", - "pressureSuite": "packages/json-document/tests/conformance/v2/pressure-suite.ts", - "independentProjectionImplementation": "packages/json-document/tests/independent/v2-projection.ts", - "independentProjectionBinding": "packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts", - "collaborationProjectionBinding": "packages/json-document-collaboration/tests/projection-conformance.test.ts" - }, - "requirements": [ - "JD2-GOV-001", - "JD2-DATA-001", - "JD2-DATA-002", - "JD2-PROJECTION-001", - "JD2-ADDRESS-001", - "JD2-QUERY-001", - "JD2-PATCH-001", - "JD2-PATCH-002", - "JD2-CAPABILITY-001", - "JD2-COMMIT-001", - "JD2-COMMIT-002", - "JD2-PUBLISH-001", - "JD2-RESULT-001", - "JD2-RESULT-002", - "JD2-SCHEMA-001", - "JD2-SCHEMA-002", - "JD2-SESSION-001", - "JD2-HOST-001", - "JD2-CONFORMANCE-001", - "JD2-CONFORMANCE-002", - "JD2-BINDING-001" - ], - "projectionMembers": [ - "value", - "at", - "query", - "canPatch", - "commit", - "subscribe" - ], - "binding": { - "entrypoint": ".", - "values": [ - "appendSegment", - "applyPatch", - "buildPointer", - "createJSONDocument", - "parentPointer", - "parsePointer", - "trackPointer", - "tryParsePointer" - ], - "types": [ - "JSONAppliedChange", - "JSONCapabilityResult", - "JSONChangeMetadata", - "JSONDocument", - "JSONDocumentOptions", - "JSONDocumentCommitOptions", - "JSONDocumentCommitResult", - "JSONPatchOperation", - "JSONPatchResult", - "JSONPatchValidationResult", - "JSONValue", - "Pointer", - "QueryResult", - "ReadResult" - ] - }, - "counts": { - "values": 8, - "types": 14, - "exports": 22, - "projectionMembers": 6 - } -} diff --git a/docs/standard/v2-projection-profile.md b/docs/standard/v3-json-document-profile.md similarity index 54% rename from docs/standard/v2-projection-profile.md rename to docs/standard/v3-json-document-profile.md index b294ff29..62cd1c5a 100644 --- a/docs/standard/v2-projection-profile.md +++ b/docs/standard/v3-json-document-profile.md @@ -1,15 +1,12 @@ -# json-document v2 Projection Profile +# json-document v3 JSON Document Profile -상태: Stable (`2.0.0`). TypeScript root binding, reference implementation, +상태: Stable candidate (`3.0.0`). TypeScript root binding, reference implementation, 독립 test implementation, 다섯 pressure vertical이 stable gate를 통과했다. -`Projection`, Pure Protocol, acceptance, capability와 publication은 이 stable -v2 compatibility profile의 기존 label이다. Repository 전체의 canonical -concept와 naming grammar는 +이 profile은 JSON Document, stateless JSON Patch, validation과 change +notification이라는 canonical vocabulary만 사용한다. Repository 전체의 concept와 +naming grammar는 [Concept and Naming Standard](concept-and-naming-standard.md)가 정의한다. -이 profile의 identifier와 requirement wording은 v2 compatibility를 위해 -유지하며, 새 문서에서는 각각 JSON Document, stateless JSON Patch, validation과 -change notification으로 설명한다. 이 profile은 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 의존할 수 있는 최소 JSON 편집 계약을 정의한다. 구현체는 더 많은 기능을 제공할 수 @@ -21,16 +18,16 @@ change notification으로 설명한다. conformance vector, 언어별 binding, reference implementation 순서다. reference implementation의 현재 동작은 상위 계약을 덮어쓸 수 없다. -Pure Protocol은 JSON value, JSON Pointer, JSONPath, JSON Patch와 result의 -의미만 정의한다. Projection은 그 protocol을 현재 문서에 연결하는 stateful +stateless JSON Patch은 JSON value, JSON Pointer, JSONPath, JSON Patch와 result의 +의미만 정의한다. JSON Document은 그 protocol을 현재 문서에 연결하는 stateful port다. Selection, history, clipboard, DOM과 framework lifecycle은 Host -adapter가 Projection과 조합한다. +adapter가 JSON Document과 조합한다. ```txt -Pure Protocol -> Projection -> host adapter +stateless JSON Patch -> JSON Document -> host adapter ``` -## Canonical 일곱 member와 v2 여섯-member compatibility +## Canonical 여섯-member JSON Document 아래 TypeScript는 root package가 공개하는 application-owned 구조 계약이다. @@ -43,10 +40,6 @@ interface JSONDocument { validatePatch( operations: ReadonlyArray, ): JSONPatchValidationResult; - /** @deprecated Use validatePatch. */ - canPatch( - operations: ReadonlyArray, - ): JSONCapabilityResult; commit( operations: ReadonlyArray, options?: JSONDocumentCommitOptions, @@ -57,13 +50,13 @@ interface JSONDocument { `at`은 정확한 주소 한 곳을 읽고, `query`는 여러 주소를 찾는다. `validatePatch`는 같은 mutation을 실행하지 않고 검증하며, `commit`만 core -state를 바꾼다. `canPatch`는 v2 portable consumer를 위한 deprecated alias다. -`subscribe`는 이미 publish된 변경만 전달한다. +state를 바꾼다. +`subscribe`는 이미 전달된 변경만 전달한다. 아직 result를 돌려줄 document가 없는 construction 단계에서 initial value가 -JSON이 아니거나 acceptance에 거부되면 TypeScript reference binding은 -`TypeError`를 throw한다. 생성 이후 acceptance callback의 예외는 -`schema_violation` failure로 바꾸며 state와 publication을 만들지 않는다. +JSON이 아니거나 validation에 거부되면 TypeScript reference binding은 +`TypeError`를 throw한다. 생성 이후 validation callback의 예외는 +`schema_violation` failure로 바꾸며 state와 change notification을 만들지 않는다. ## 규범 요구사항 @@ -71,27 +64,27 @@ JSON이 아니거나 acceptance에 거부되면 TypeScript reference binding은 | ID | 요구사항 | | --- | --- | -| JD2-GOV-001 | 구현체와 binding은 외부 표준, versioned normative profile, machine-readable conformance vector의 의미를 MUST 준수해야 한다. reference implementation의 우연한 동작은 이 계약을 변경할 수 없다. | -| JD2-DATA-001 | document state, patch payload, protocol metadata는 RFC 8259 JSON data여야 하며 `undefined`, function, symbol, cyclic reference 같은 host value를 MUST NOT 포함해야 한다. | -| JD2-DATA-002 | 공개 snapshot과 입력 reference는 격리되어야 한다. consumer가 이전 `value`, initial value, patch payload를 변경해 committed state를 바꿀 수 있어서는 안 된다. 같은 revision의 object identity 재사용은 MAY 허용하지만 계약은 아니다. | -| JD2-PROJECTION-001 | 호환 Projection은 `value`, `at`, `query`, `canPatch`, `commit`, `subscribe`를 정확한 필수 member로 제공해야 한다. 추가 member는 MAY 제공하지만 여섯 member의 의미를 바꾸거나 portable consumer의 전제가 되어서는 안 된다. | -| JD2-ADDRESS-001 | 정확한 주소와 mutation target은 RFC 6901 JSON Pointer여야 한다. JSONPath를 mutation target으로 받아들이면 안 되며 query 결과는 Pointer로 정규화해야 한다. | -| JD2-QUERY-001 | `query`는 RFC 9535 JSONPath를 받아 document를 바꾸지 않고 deterministic한 Pointer 배열 또는 stable error code를 포함한 실패 result를 반환해야 한다. | -| JD2-PATCH-001 | mutation은 RFC 6902 operation의 ordered batch여야 한다. 순수 patch 적용과 `commit`은 전체 batch를 순서대로 적용하거나 아무것도 적용하지 않아야 하며 성공 시 실제 적용된 canonical operation 순서를 보존해야 한다. | -| JD2-PATCH-002 | Pure Patch 연산은 schema provider, UI framework, mutable session 없이 실행 가능해야 한다. schema acceptance, history, selection과 publication은 이 순수 연산과 조합하되 그 의미를 바꾸면 안 된다. | -| JD2-CAPABILITY-001 | `canPatch`는 state와 subscriber를 바꾸지 않고 `commit`과 같은 JSON, Pointer, Patch, schema acceptance 의미를 사용해야 한다. state와 acceptance rule이 사이에 변하지 않았다면 성공한 probe와 같은 입력의 commit이 같은 검증 원인으로 실패하면 안 된다. | -| JD2-COMMIT-001 | `commit`은 Projection의 유일한 stateful mutation primitive여야 하고 local operation을 동기적·원자적으로 publish해야 한다. expected input failure는 throw나 rejected Promise가 아니라 result이며 partial state나 partial applied patch를 노출하면 안 된다. | -| JD2-COMMIT-002 | 성공한 `commit`은 `JSONAppliedChange`를 담은 `JSONDocumentCommitResult`를 반환해야 한다. 그 change는 실제 적용된 canonical operation과 JSON-safe metadata만 포함하며 post-commit value는 `value`에서 읽는다. | -| JD2-PUBLISH-001 | state-changing commit은 같은 인과 순서의 operation과 metadata를 가진 value-equivalent `JSONAppliedChange`를 subscriber마다 정확히 한 번 전달해야 한다. object identity는 계약이 아니다. 재진입 commit도 모든 subscriber에게 같은 순서로 전달해야 한다. 한 listener의 예외는 다른 listener 전달을 막거나 `commit` 밖으로 전파되거나 성공 result를 바꾸면 안 된다. 실패와 state-equivalent no-op은 notification을 만들면 안 되며 unsubscribe 이후에는 notification을 전달하면 안 된다. | -| JD2-RESULT-001 | public result는 boolean `ok` discriminant를 가져야 하고 실패는 stable string `code`를 가져야 한다. expected read, query, capability, commit failure를 예외로만 표현해서는 안 된다. | -| JD2-RESULT-002 | consumer는 모르는 result field와 error code를 일반적으로 처리할 수 있어야 한다. minor release는 기존 required field나 code의 의미를 바꾸면 안 되지만 optional field와 새 code는 MAY 추가할 수 있으므로 exact `Object.keys` 집합은 계약이 아니다. | -| JD2-SCHEMA-001 | Projection이 acceptance rule을 구성하면 initial state와 commit candidate를 publish 전에 검사해야 한다. 이 boundary는 provider-neutral이어야 하며 `_zod`, provider issue, private schema object를 Projection 적합성에 요구하면 안 된다. 제약 없는 acceptance는 MAY 허용한다. Acceptance callback은 같은 Projection의 `canPatch`나 `commit`을 재진입해서는 안 되며, binding은 그런 호출을 failure로 차단해 state와 publication을 보존해야 한다. | -| JD2-SCHEMA-002 | import나 initial parse는 commit 전에 명시적으로 값을 변환할 수 있다. commit-time acceptance는 candidate를 몰래 변환하면 안 되며 normalization이 필요하면 최종 value를 만드는 operation이 applied change에 명시되어야 한다. | -| JD2-SESSION-001 | insert, replace, delete, move, duplicate, selection, clipboard, history, schema introspection과 그 capability probe는 host 또는 별도 adapter 책임이다. Kernel package의 export나 Projection 적합성에 요구하면 안 된다. | -| JD2-HOST-001 | rendering, DOM focus, geometry, keyboard policy, system clipboard, filesystem, network, formula engine, CRDT와 OT는 host 또는 extension이 소유해야 하며 Core Projection의 필수 data나 member가 되어서는 안 된다. | -| JD2-CONFORMANCE-001 | conformance는 public factory 또는 injected harness만 사용하는 machine-readable black-box vector로 성공, 실패, atomicity, immutability, probe/commit parity, publication을 검증해야 한다. private source path, provider object, 특정 dist layout을 요구하면 안 된다. | -| JD2-CONFORMANCE-002 | 이 profile을 stable이라고 선언하려면 같은 suite가 reference implementation과 최소 한 개의 독립 구현을 통과하고 form, table/data-grid, outliner/tree, rich text, storage/collaboration의 다섯 pressure vertical에서 같은 제약이 확인되어야 한다. | -| JD2-BINDING-001 | package export와 TypeScript declaration은 언어별 binding contract이며 보편 protocol과 별도로 versioning해야 한다. v2 package는 root entrypoint와 20개 Kernel symbol만 공개하고 runtime·peer dependency 없이 빌드되어야 한다. public Projection declaration은 application-owned structural contract여야 하고 archived session, framework binding, implementation runtime alias나 private declaration path를 노출하면 안 된다. | +| JD3-GOV-001 | 구현체와 binding은 외부 표준, versioned normative profile, machine-readable conformance vector의 의미를 MUST 준수해야 한다. reference implementation의 우연한 동작은 이 계약을 변경할 수 없다. | +| JD3-DATA-001 | document state, patch payload, protocol metadata는 RFC 8259 JSON data여야 하며 `undefined`, function, symbol, cyclic reference 같은 host value를 MUST NOT 포함해야 한다. | +| JD3-DATA-002 | 공개 snapshot과 입력 reference는 격리되어야 한다. consumer가 이전 `value`, initial value, patch payload를 변경해 committed state를 바꿀 수 있어서는 안 된다. 같은 revision의 object identity 재사용은 MAY 허용하지만 계약은 아니다. | +| JD3-DOCUMENT-001 | 호환 JSON Document은 `value`, `at`, `query`, `validatePatch`, `commit`, `subscribe`를 정확한 필수 member로 제공해야 한다. 추가 member는 MAY 제공하지만 여섯 member의 의미를 바꾸거나 portable consumer의 전제가 되어서는 안 된다. | +| JD3-ADDRESS-001 | 정확한 주소와 mutation target은 RFC 6901 JSON Pointer여야 한다. JSONPath를 mutation target으로 받아들이면 안 되며 query 결과는 Pointer로 정규화해야 한다. | +| JD3-QUERY-001 | `query`는 RFC 9535 JSONPath를 받아 document를 바꾸지 않고 deterministic한 Pointer 배열 또는 stable error code를 포함한 실패 result를 반환해야 한다. | +| JD3-PATCH-001 | mutation은 RFC 6902 operation의 ordered batch여야 한다. 순수 patch 적용과 `commit`은 전체 batch를 순서대로 적용하거나 아무것도 적용하지 않아야 하며 성공 시 실제 적용된 canonical operation 순서를 보존해야 한다. | +| JD3-PATCH-002 | Pure Patch 연산은 schema provider, UI framework, mutable session 없이 실행 가능해야 한다. validation, history, selection과 change notification은 이 순수 연산과 조합하되 그 의미를 바꾸면 안 된다. | +| JD3-VALIDATION-001 | `validatePatch`는 state와 subscriber를 바꾸지 않고 `commit`과 같은 JSON, Pointer, Patch, validation 의미를 사용해야 한다. state와 validation rule이 사이에 변하지 않았다면 성공한 probe와 같은 입력의 commit이 같은 검증 원인으로 실패하면 안 된다. | +| JD3-COMMIT-001 | `commit`은 JSON Document의 유일한 stateful mutation primitive여야 하고 local operation을 동기적·원자적으로 notify해야 한다. expected input failure는 throw나 rejected Promise가 아니라 result이며 partial state나 partial applied patch를 노출하면 안 된다. | +| JD3-COMMIT-002 | 성공한 `commit`은 `JSONAppliedChange`를 담은 `JSONDocumentCommitResult`를 반환해야 한다. 그 change는 실제 적용된 canonical operation과 JSON-safe metadata만 포함하며 post-commit value는 `value`에서 읽는다. | +| JD3-NOTIFICATION-001 | state-changing commit은 같은 인과 순서의 operation과 metadata를 가진 value-equivalent `JSONAppliedChange`를 subscriber마다 정확히 한 번 전달해야 한다. object identity는 계약이 아니다. 재진입 commit도 모든 subscriber에게 같은 순서로 전달해야 한다. 한 listener의 예외는 다른 listener 전달을 막거나 `commit` 밖으로 전파되거나 성공 result를 바꾸면 안 된다. 실패와 state-equivalent no-op은 notification을 만들면 안 되며 unsubscribe 이후에는 notification을 전달하면 안 된다. | +| JD3-RESULT-001 | public result는 boolean `ok` discriminant를 가져야 하고 실패는 stable string `code`를 가져야 한다. expected read, query, validation, commit failure를 예외로만 표현해서는 안 된다. | +| JD3-RESULT-002 | consumer는 모르는 result field와 error code를 일반적으로 처리할 수 있어야 한다. minor release는 기존 required field나 code의 의미를 바꾸면 안 되지만 optional field와 새 code는 MAY 추가할 수 있으므로 exact `Object.keys` 집합은 계약이 아니다. | +| JD3-SCHEMA-001 | JSON Document이 validation rule을 구성하면 initial state와 commit candidate를 notification 전에 검사해야 한다. 이 boundary는 provider-neutral이어야 하며 `_zod`, provider issue, private schema object를 JSON Document 적합성에 요구하면 안 된다. 제약 없는 validation는 MAY 허용한다. Validation callback은 같은 JSON Document의 `validatePatch`나 `commit`을 재진입해서는 안 되며, binding은 그런 호출을 failure로 차단해 state와 change notification을 보존해야 한다. | +| JD3-SCHEMA-002 | import나 initial parse는 commit 전에 명시적으로 값을 변환할 수 있다. commit-time validation는 candidate를 몰래 변환하면 안 되며 normalization이 필요하면 최종 value를 만드는 operation이 applied change에 명시되어야 한다. | +| JD3-SESSION-001 | insert, replace, delete, move, duplicate, selection, clipboard, history, schema introspection과 그 patch validation는 host 또는 별도 adapter 책임이다. Kernel package의 export나 JSON Document 적합성에 요구하면 안 된다. | +| JD3-HOST-001 | rendering, DOM focus, geometry, keyboard policy, system clipboard, filesystem, network, formula engine, CRDT와 OT는 host 또는 extension이 소유해야 하며 Core JSON Document의 필수 data나 member가 되어서는 안 된다. | +| JD3-CONFORMANCE-001 | conformance는 public factory 또는 injected harness만 사용하는 machine-readable black-box vector로 성공, 실패, atomicity, immutability, probe/commit parity, change notification을 검증해야 한다. private source path, provider object, 특정 dist layout을 요구하면 안 된다. | +| JD3-CONFORMANCE-002 | 이 profile을 stable이라고 선언하려면 같은 suite가 reference implementation과 최소 한 개의 독립 구현을 통과하고 form, table/data-grid, outliner/tree, rich text, storage/collaboration의 다섯 pressure vertical에서 같은 제약이 확인되어야 한다. | +| JD3-BINDING-001 | package export와 TypeScript declaration은 언어별 binding contract이며 보편 protocol과 별도로 versioning해야 한다. v3 package는 root entrypoint와 21개 Kernel symbol만 공개하고 runtime·peer dependency 없이 빌드되어야 한다. public JSON Document declaration은 application-owned structural contract여야 하고 archived session, framework binding, implementation runtime alias나 private declaration path를 노출하면 안 된다. | ## Result 초안 @@ -144,8 +137,8 @@ declare function applyPatch( TypeScript binding은 non-JSON input도 expected failure result로 돌려주기 위해 `createJSONDocument`와 `applyPatch`의 입력을 `unknown`으로 받으며 runtime에서 RFC 8259 boundary를 검사한다. 임의 JSON Patch는 root -value의 shape까지 바꿀 수 있으므로 Projection snapshot은 거짓 generic을 -노출하지 않고 항상 `JSONValue`다. 타입이 보장된 domain model은 acceptance를 +value의 shape까지 바꿀 수 있으므로 JSON Document snapshot은 거짓 generic을 +노출하지 않고 항상 `JSONValue`다. 타입이 보장된 domain model은 validation를 소유하는 host adapter의 책임이다. `JSONChangeMetadata`의 selection과 history 전용 field는 host metadata로 분리하고, core metadata는 JSON object boundary만 정한다. Pure `applyPatch`는 새 value를 반환하지만 stateful `commit`은 @@ -154,30 +147,30 @@ unknown code를 허용하는 forward-compatibility fixture를 포함한다. ## Conformance artifact -black-box suite는 여섯-member Projection, pure Patch, Pointer, 외부 RFC +black-box suite는 여섯-member JSON Document, pure Patch, Pointer, 외부 RFC conformance corpus의 public-root binding을 서로 분리한다. | Artifact | 책임 | | --- | --- | -| `packages/json-document/tests/conformance/v2/projection-vectors.json` | provider object가 없는 machine-readable 입력과 기대값 | -| `packages/json-document/tests/conformance/v2/projection-suite.ts` | 여섯 member만 아는 injected harness runner | -| `packages/json-document/tests/public/v2-projection-standard-conformance.test.ts` | public root factory reference binding | -| `packages/json-document/tests/conformance/v2/protocol-vectors.json` | schema-free pure Patch 입력과 기대값 | -| `packages/json-document/tests/conformance/v2/protocol-suite.ts` | `applyPatch`만 아는 injected harness runner | -| `packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts` | public root pure Protocol binding | -| `packages/json-document/tests/conformance/v2/pointer-vectors.json` | RFC 6901 조합과 applied Patch 기반 pointer tracking 기대값 | -| `packages/json-document/tests/conformance/v2/pointer-suite.ts` | Pointer helper만 아는 injected harness runner | -| `packages/json-document/tests/public/v2-pointer-standard-conformance.test.ts` | 여섯 public Pointer helper의 root binding | -| `packages/json-document/tests/conformance/v2/rfc6902-suite.ts` | vendored RFC 6902 corpus를 실행하는 provider-neutral runner | -| `packages/json-document/tests/public/v2-rfc6902-standard-conformance.test.ts` | public root `applyPatch`의 전체 RFC 6902 corpus binding | -| `packages/json-document/tests/conformance/v2/jsonpath-suite.ts` | vendored RFC 9535 CTS를 `query`와 `at`으로 검증하는 runner | -| `packages/json-document/tests/public/v2-jsonpath-standard-conformance.test.ts` | public root Projection의 전체 RFC 9535 CTS binding | -| `packages/json-document/tests/conformance/v2/foundation-vectors.json` | Core와 collaboration package-local primitive의 array index·equality parity 및 JSON boundary fixture | -| `packages/json-document/tests/conformance/v2/pressure-vectors.json` | form, table/data-grid, outliner/tree, rich text, storage/collaboration 시나리오 | -| `packages/json-document/tests/conformance/v2/pressure-suite.ts` | 여섯 member만으로 다섯 vertical을 실행하는 injected runner | -| `packages/json-document/tests/independent/v2-projection.ts` | reference runtime을 import하지 않는 독립 6-member test implementation | -| `packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts` | 독립 구현에 Projection과 pressure suite를 함께 주입하는 binding | -| `packages/json-document-collaboration/tests/projection-conformance.test.ts` | collaboration public root에 같은 두 suite를 주입하는 추가 binding | +| `packages/json-document/tests/conformance/v3/json-document-vectors.json` | provider object가 없는 machine-readable 입력과 기대값 | +| `packages/json-document/tests/conformance/v3/json-document-suite.ts` | 여섯 member만 아는 injected harness runner | +| `packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts` | public root factory reference binding | +| `packages/json-document/tests/conformance/v3/protocol-vectors.json` | schema-free pure Patch 입력과 기대값 | +| `packages/json-document/tests/conformance/v3/protocol-suite.ts` | `applyPatch`만 아는 injected harness runner | +| `packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts` | public root pure Protocol binding | +| `packages/json-document/tests/conformance/v3/pointer-vectors.json` | RFC 6901 조합과 applied Patch 기반 pointer tracking 기대값 | +| `packages/json-document/tests/conformance/v3/pointer-suite.ts` | Pointer helper만 아는 injected harness runner | +| `packages/json-document/tests/public/v3-pointer-standard-conformance.test.ts` | 여섯 public Pointer helper의 root binding | +| `packages/json-document/tests/conformance/v3/rfc6902-suite.ts` | vendored RFC 6902 corpus를 실행하는 provider-neutral runner | +| `packages/json-document/tests/public/v3-rfc6902-standard-conformance.test.ts` | public root `applyPatch`의 전체 RFC 6902 corpus binding | +| `packages/json-document/tests/conformance/v3/jsonpath-suite.ts` | vendored RFC 9535 CTS를 `query`와 `at`으로 검증하는 runner | +| `packages/json-document/tests/public/v3-jsonpath-standard-conformance.test.ts` | public root JSON Document의 전체 RFC 9535 CTS binding | +| `packages/json-document/tests/conformance/v3/foundation-vectors.json` | Core와 collaboration package-local primitive의 array index·equality parity 및 JSON boundary fixture | +| `packages/json-document/tests/conformance/v3/pressure-vectors.json` | form, table/data-grid, outliner/tree, rich text, storage/collaboration 시나리오 | +| `packages/json-document/tests/conformance/v3/pressure-suite.ts` | 여섯 member만으로 다섯 vertical을 실행하는 injected runner | +| `packages/json-document/tests/independent/v3-json-document.ts` | reference runtime을 import하지 않는 독립 6-member test implementation | +| `packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts` | 독립 구현에 JSON Document과 pressure suite를 함께 주입하는 binding | +| `packages/json-document-collaboration/tests/json-document-conformance.test.ts` | collaboration public root에 같은 두 suite를 주입하는 추가 binding | suite가 export하는 structural type은 test harness 내부 계약이며 package public export가 아니다. Vendored RFC 6902 corpus 112건 중 110건을 실행한다. 남은 2건은 @@ -186,8 +179,8 @@ member만 남아 public operation object로는 그 입력을 표현할 수 없 명시적인 `disabledReason`을 기록한다. 21개 요구사항의 현재 증거 상태는 runtime 15개, static 6개, deferred 0개다. -schema-free `applyPatch`와 acceptance transform identity는 reference와 독립 -구현에서 같은 vector로 검증한다. 같은 Projection suite와 pressure suite는 +schema-free `applyPatch`와 validation transform identity는 reference와 독립 +구현에서 같은 vector로 검증한다. 같은 JSON Document suite와 pressure suite는 reference와 독립 구현을 모두 통과하며, collaboration public binding도 같은 다섯 vertical을 통과한다. collaboration 구현은 Core protocol을 조합하므로 독립 구현 수에는 포함하지 않는다. @@ -210,18 +203,18 @@ array-property 분류만 공통 leaf에 두고, parity test가 untrusted boundar ## Package binding -`@interactive-os/json-document`는 root entrypoint 하나와 22개 symbol을 -공개한다. `JSONDocument`의 canonical member는 일곱 개이며, 기존 v2 portable +`@interactive-os/json-document`는 root entrypoint 하나와 21개 symbol을 +공개한다. `JSONDocument`의 canonical member는 일곱 개이며, 기존 v3 portable consumer가 의존하는 여섯 member는 그대로 유지된다. ```txt values 8 -types 14 -total 22 +types 13 +total 21 ``` 패키지는 runtime dependency와 peer dependency가 없다. `/session`과 `/react`는 export가 아니며, archived 1.x implementation은 production build와 tarball에 포함하지 않는다. 저장소에 남은 archived source와 regression test는 공개 계약이 -아니다. 별도 `Projection` public type은 외부 구현에서 같은 structural port가 +아니다. 별도 `JSON Document` public type은 외부 구현에서 같은 structural port가 반복되기 전에는 추가하지 않는다. diff --git a/docs/standard/v3-public-surface.json b/docs/standard/v3-public-surface.json new file mode 100644 index 00000000..b76c2034 --- /dev/null +++ b/docs/standard/v3-public-surface.json @@ -0,0 +1,102 @@ +{ + "formatVersion": 1, + "status": "stable", + "sourceContract": "packages/json-document/public-contract.json#root", + "package": { + "name": "@interactive-os/json-document", + "version": "3.0.0", + "entrypoint": ".", + "excludedEntrypoints": [ + "./session", + "./react" + ], + "runtimeDependencies": [], + "peerDependencies": [] + }, + "conformance": { + "jsonDocumentVectors": "packages/json-document/tests/conformance/v3/json-document-vectors.json", + "jsonDocumentSuite": "packages/json-document/tests/conformance/v3/json-document-suite.ts", + "jsonDocumentBinding": "packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts", + "protocolVectors": "packages/json-document/tests/conformance/v3/protocol-vectors.json", + "protocolSuite": "packages/json-document/tests/conformance/v3/protocol-suite.ts", + "protocolBinding": "packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts", + "pointerVectors": "packages/json-document/tests/conformance/v3/pointer-vectors.json", + "pointerSuite": "packages/json-document/tests/conformance/v3/pointer-suite.ts", + "pointerBinding": "packages/json-document/tests/public/v3-pointer-standard-conformance.test.ts", + "rfc6902Suite": "packages/json-document/tests/conformance/v3/rfc6902-suite.ts", + "rfc6902Binding": "packages/json-document/tests/public/v3-rfc6902-standard-conformance.test.ts", + "jsonPathSuite": "packages/json-document/tests/conformance/v3/jsonpath-suite.ts", + "jsonPathBinding": "packages/json-document/tests/public/v3-jsonpath-standard-conformance.test.ts", + "foundationVectors": "packages/json-document/tests/conformance/v3/foundation-vectors.json", + "pressureVectors": "packages/json-document/tests/conformance/v3/pressure-vectors.json", + "pressureSuite": "packages/json-document/tests/conformance/v3/pressure-suite.ts", + "independentJSONDocumentImplementation": "packages/json-document/tests/independent/v3-json-document.ts", + "independentJSONDocumentBinding": "packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts", + "collaborationJSONDocumentBinding": "packages/json-document-collaboration/tests/json-document-conformance.test.ts" + }, + "requirements": [ + "JD3-GOV-001", + "JD3-DATA-001", + "JD3-DATA-002", + "JD3-DOCUMENT-001", + "JD3-ADDRESS-001", + "JD3-QUERY-001", + "JD3-PATCH-001", + "JD3-PATCH-002", + "JD3-VALIDATION-001", + "JD3-COMMIT-001", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001", + "JD3-RESULT-001", + "JD3-RESULT-002", + "JD3-SCHEMA-001", + "JD3-SCHEMA-002", + "JD3-SESSION-001", + "JD3-HOST-001", + "JD3-CONFORMANCE-001", + "JD3-CONFORMANCE-002", + "JD3-BINDING-001" + ], + "documentMembers": [ + "value", + "at", + "query", + "validatePatch", + "commit", + "subscribe" + ], + "binding": { + "entrypoint": ".", + "values": [ + "appendSegment", + "applyPatch", + "buildPointer", + "createJSONDocument", + "parentPointer", + "parsePointer", + "trackPointer", + "tryParsePointer" + ], + "types": [ + "JSONAppliedChange", + "JSONChangeMetadata", + "JSONDocument", + "JSONDocumentOptions", + "JSONDocumentCommitOptions", + "JSONDocumentCommitResult", + "JSONPatchOperation", + "JSONPatchResult", + "JSONPatchValidationResult", + "JSONValue", + "Pointer", + "QueryResult", + "ReadResult" + ] + }, + "counts": { + "values": 8, + "types": 13, + "exports": 21, + "documentMembers": 6 + } +} diff --git a/llms.txt b/llms.txt index b00f24f5..a6c973ad 100644 --- a/llms.txt +++ b/llms.txt @@ -1,9 +1,9 @@ -# json-document v2 +# json-document v3 json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 사용할 수 있는 implementation-neutral JSON 편집 Kernel이다. UI component library가 아니다. -현재 package version과 표준 상태는 `2.0.0` Stable이다. Reference와 독립 +현재 package version과 표준 상태는 `3.0.0` Stable이다. Reference와 독립 implementation이 같은 conformance suite를 통과하고, 다섯 pressure vertical을 reference·독립·collaboration binding에서 검증한다. @@ -23,7 +23,7 @@ import { ``` Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다. -공개 Root는 정확히 다음 22개 symbol이다. +공개 Root는 정확히 다음 21개 symbol이다. ```txt values @@ -31,7 +31,7 @@ values parentPointer, parsePointer, trackPointer, tryParsePointer types - JSONAppliedChange, JSONPatchValidationResult, JSONCapabilityResult + JSONAppliedChange, JSONPatchValidationResult JSONChangeMetadata, JSONDocument, JSONDocumentOptions JSONDocumentCommitOptions, JSONDocumentCommitResult JSONPatchOperation, JSONPatchResult, JSONValue, Pointer @@ -46,7 +46,7 @@ React hook, host service를 추가하지 않는다. ```txt stateless JSON Patch |-> local implementation -----\ - | > same seven-member JSON Document + | > same six-member JSON Document `-> collaboration engine -----/ |-> optional history/text authoring `-> optional native-input DOM lease ``` @@ -55,7 +55,7 @@ Stateless JSON Patch는 JSON value, RFC 6901 JSON Pointer, RFC 9535 JSONPath, RFC 6902 JSON Patch, Result 의미를 소유한다. JSON Document는 현재 document value와 change notification을 연결한다. -Canonical concept와 stable v2 compatibility identifier의 관계는 +Canonical concept와 identifier 문법은 `docs/standard/concept-and-naming-standard.md`가 정의한다. `JSONDocument`의 필수 member는 정확히 여섯 개다. @@ -153,9 +153,8 @@ if (result.ok) { ## Validation -Schema provider는 Root contract가 아니다. 필요하면 validator를 canonical -`validate` callback으로 조합한다. `accepts`는 deprecated stable v2 -compatibility alias다. +Schema provider는 Root contract가 아니다. 필요하면 validator를 +`validate` callback으로 조합한다. ```ts import { z } from "zod"; @@ -198,9 +197,9 @@ Minor release에서 optional diagnostic field와 새 code가 추가될 수 있 패키지는 root entrypoint만 공개한다. Selection, clipboard, history, schema introspection, DOM과 framework lifecycle은 host 또는 별도 adapter가 -일곱-member `JSONDocument`를 조합해 소유한다. +여섯-member `JSONDocument`를 조합해 소유한다. -v2 Kernel release는 dependency-free Core package 하나다. Local-only consumer는 +v3 Kernel release는 dependency-free Core package 하나다. Local-only consumer는 Core만 설치한다. 이 저장소의 `@interactive-os/json-document-collaboration`은 같은 canonical JSON Document을 제공하는 independently versioned, transport-free provider이고, @@ -228,14 +227,14 @@ indent/outdent, visible-row focus, slide selection box, grid coordinate와 같 ## 표준과 검증 -- v2 profile: `docs/standard/v2-projection-profile.md` -- machine manifest: `docs/standard/v2-public-surface.json` -- v2 signature fixture: - `packages/json-document/tests/public/v2-signature-contract.test-d.ts` -- Projection black-box binding: - `packages/json-document/tests/public/v2-projection-standard-conformance.test.ts` -- Stateless JSON Patch black-box binding (v2 Pure Protocol compatibility artifact): - `packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts` +- v3 profile: `docs/standard/v3-json-document-profile.md` +- machine manifest: `docs/standard/v3-public-surface.json` +- v3 signature fixture: + `packages/json-document/tests/public/v3-signature-contract.test-d.ts` +- JSON Document black-box binding: + `packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts` +- Stateless JSON Patch black-box binding: + `packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts` Reference implementation의 우연한 동작이 profile과 conformance vector보다 높은 정본이 되지 않게 한다. diff --git a/package-lock.json b/package-lock.json index b988e3cd..3d6752af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5028,7 +5028,7 @@ }, "packages/contenteditable-collaboration": { "name": "@interactive-os/json-document-contenteditable-collaboration", - "version": "0.1.0-rc.1", + "version": "0.2.0-rc.1", "license": "MIT", "devDependencies": { "@interactive-os/json-document": "*", @@ -5039,13 +5039,13 @@ "vitest": "^4.1.7" }, "peerDependencies": { - "@interactive-os/json-document": "^2.0.0", - "@interactive-os/json-document-collaboration": "^0.1.0-rc.1" + "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-collaboration": "^0.2.0-rc.1" } }, "packages/json-document": { "name": "@interactive-os/json-document", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "devDependencies": { "@types/node": "^25.9.0", @@ -5055,7 +5055,7 @@ }, "packages/json-document-collaboration": { "name": "@interactive-os/json-document-collaboration", - "version": "0.1.0-rc.2", + "version": "0.2.0-rc.1", "license": "MIT", "devDependencies": { "@interactive-os/json-document": "*", @@ -5064,7 +5064,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { - "@interactive-os/json-document": "^2.0.0" + "@interactive-os/json-document": "^3.0.0" } } } diff --git a/package.json b/package.json index 180c0787..9d8a90e0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "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 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-", + "standard:check": "node scripts/evaluate-standardization.mjs && npm test -w @interactive-os/json-document -- v3-", "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 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", diff --git a/packages/contenteditable-collaboration/README.md b/packages/contenteditable-collaboration/README.md index c53214ae..1f57066e 100644 --- a/packages/contenteditable-collaboration/README.md +++ b/packages/contenteditable-collaboration/README.md @@ -4,14 +4,13 @@ IME-safe native-input DOM lease for `@interactive-os/json-document-collaboration/text`. The adapter binds one collaborative string pointer to one contenteditable -root. Collaboration ingestion and the seven-member document model always update +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. Canonical DOM adapter와 lease vocabulary는 [Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 -정의합니다. `TextDOMAdapter`는 canonical public identifier이고 -`CollaborationTextDOM`은 deprecated compatibility alias입니다. +정의합니다. Public API는 `TextDOMAdapter`와 `DOMObservation`을 사용합니다. ```ts import { diff --git a/packages/contenteditable-collaboration/package.json b/packages/contenteditable-collaboration/package.json index a4f33220..3fa56d4f 100644 --- a/packages/contenteditable-collaboration/package.json +++ b/packages/contenteditable-collaboration/package.json @@ -1,6 +1,6 @@ { "name": "@interactive-os/json-document-contenteditable-collaboration", - "version": "0.1.0-rc.1", + "version": "0.2.0-rc.1", "description": "IME-safe native-input DOM lease for json-document collaboration text.", "type": "module", "license": "MIT", @@ -46,8 +46,8 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { - "@interactive-os/json-document": "^2.0.0", - "@interactive-os/json-document-collaboration": "^0.1.0-rc.1" + "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-collaboration": "^0.2.0-rc.1" }, "devDependencies": { "@interactive-os/json-document": "*", diff --git a/packages/contenteditable-collaboration/src/index.ts b/packages/contenteditable-collaboration/src/index.ts index 80fd5095..f9331c3d 100644 --- a/packages/contenteditable-collaboration/src/index.ts +++ b/packages/contenteditable-collaboration/src/index.ts @@ -1,20 +1,9 @@ -export { - createContentEditableAdapter, - createContentEditableAdapter as createCollaborationContentEditableAdapter, -} from "./lease.js"; -export { - plainTextDOMAdapter, - plainTextDOMAdapter as plainTextCollaborationDOM, -} from "./dom/plain-text.js"; +export { createContentEditableAdapter } from "./lease.js"; +export { plainTextDOMAdapter } from "./dom/plain-text.js"; export type { ContentEditableAdapter, ContentEditableOptions, ContentEditableResult, DOMObservation, - CollaborationContentEditableAdapter, - CollaborationContentEditableOptions, - CollaborationContentEditableResult, - CollaborationTextDOM, - CollaborationTextDOMObservation, TextDOMAdapter, } from "./types.js"; diff --git a/packages/contenteditable-collaboration/src/lease.ts b/packages/contenteditable-collaboration/src/lease.ts index 11891f37..12dcb03f 100644 --- a/packages/contenteditable-collaboration/src/lease.ts +++ b/packages/contenteditable-collaboration/src/lease.ts @@ -255,7 +255,6 @@ export function createContentEditableAdapter({ kind: "committed", changeId: committed.changeId, didChangeDocument: committed.didChangeDocument, - projectionChanged: committed.didChangeDocument, selection: committed.selection, }); }; diff --git a/packages/contenteditable-collaboration/src/types.ts b/packages/contenteditable-collaboration/src/types.ts index 66176f60..cc0ae1f2 100644 --- a/packages/contenteditable-collaboration/src/types.ts +++ b/packages/contenteditable-collaboration/src/types.ts @@ -10,9 +10,6 @@ export interface DOMObservation { readonly selection: TextSelection | null; } -/** @deprecated Use DOMObservation. */ -export type CollaborationTextDOMObservation = DOMObservation; - /** * The DOM adapter for one collaborative string field. * @@ -28,9 +25,6 @@ export interface TextDOMAdapter { ): boolean; } -/** @deprecated Use TextDOMAdapter. */ -export type CollaborationTextDOM = TextDOMAdapter; - export interface ContentEditableOptions { readonly runtime: TextRuntime; readonly pointer: Pointer; @@ -41,9 +35,6 @@ export interface ContentEditableOptions { ) => void; } -/** @deprecated Use ContentEditableOptions. */ -export type CollaborationContentEditableOptions = ContentEditableOptions; - export type ContentEditableResult = | { readonly ok: true; @@ -58,8 +49,6 @@ export type ContentEditableResult = readonly kind: "committed"; readonly changeId: ChangeId | null; readonly didChangeDocument: boolean; - /** @deprecated Use didChangeDocument. */ - readonly projectionChanged: boolean; readonly selection: TextSelection | null; } | { @@ -68,15 +57,9 @@ export type ContentEditableResult = readonly reason: string; }; -/** @deprecated Use ContentEditableResult. */ -export type CollaborationContentEditableResult = ContentEditableResult; - export interface ContentEditableAdapter { bind(): () => void; handle(event: Event): ContentEditableResult; cancel(): ContentEditableResult; reset(): void; } - -/** @deprecated Use ContentEditableAdapter. */ -export type CollaborationContentEditableAdapter = ContentEditableAdapter; diff --git a/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts b/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts index 07bde7a4..11093516 100644 --- a/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts +++ b/packages/contenteditable-collaboration/tests/contenteditable-collaboration.test.ts @@ -99,7 +99,7 @@ afterEach(() => { }); describe("@interactive-os/json-document-contenteditable-collaboration", () => { - test("ingests remote text immediately while leasing only DOM publication", () => { + test("ingests remote text immediately while leasing only DOM notification", () => { vi.useFakeTimers(); const local = textRuntime("actor-a"); const remote = textRuntime("actor-b"); @@ -298,7 +298,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { }); } - test("leases one surface without delaying publication to another", () => { + test("leases one surface without delaying notification to another", () => { const initial = { title: "ab", note: "cd" }; const local = textRuntime("actor-a", initial); const remote = textRuntime("actor-b", initial); @@ -336,7 +336,7 @@ describe("@interactive-os/json-document-contenteditable-collaboration", () => { expect(titleRoot.textContent).toBe("ab"); }); - test("clamps selection direction and surrogate boundaries after publication", () => { + test("clamps selection direction and surrogate boundaries after notification", () => { const local = textRuntime("actor-a", { title: "A😀B" }); const remote = atomicRuntime("actor-b", { title: "A😀B" }); const root = createRoot(); diff --git a/packages/contenteditable-collaboration/tests/naming-compatibility.test.ts b/packages/contenteditable-collaboration/tests/naming-compatibility.test.ts deleted file mode 100644 index 1e330801..00000000 --- a/packages/contenteditable-collaboration/tests/naming-compatibility.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - createCollaborationContentEditableAdapter, - createContentEditableAdapter, - plainTextCollaborationDOM, - plainTextDOMAdapter, - type CollaborationContentEditableAdapter, - type CollaborationTextDOM, - type ContentEditableAdapter, - type TextDOMAdapter, -} from "../src/index.js"; - -describe("deprecated naming compatibility", () => { - test("canonical and compatibility values share one implementation", () => { - expect(createCollaborationContentEditableAdapter) - .toBe(createContentEditableAdapter); - expect(plainTextCollaborationDOM).toBe(plainTextDOMAdapter); - }); - - test("compatibility types are structural aliases", () => { - const adapter = null as unknown as ContentEditableAdapter; - adapter satisfies CollaborationContentEditableAdapter; - - const dom = null as unknown as TextDOMAdapter; - dom satisfies CollaborationTextDOM; - }); -}); diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index 53e36204..8c2d66a4 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -1,9 +1,9 @@ # @interactive-os/json-document-collaboration -Transport-free causal collaboration engine for the seven-member +Transport-free causal collaboration engine for the six-member `@interactive-os/json-document` JSON Document contract. -Local and collaborative implementations expose the same seven-member +Local and collaborative implementations expose the same six-member `JSONDocument` API. Canonical concept와 stable compatibility identifier의 관계는 @@ -124,13 +124,13 @@ const restored = restoreCollaborationRuntime(checkpoint, { const compacted = compactCollaborationCheckpoint(checkpoint, { mode: "new-epoch", - nextEpochId: "document-42/v2", + nextEpochId: "document-42/v3", 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 +epoch. It requires no pending Changes, uses the current valid JSON document 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 @@ -159,7 +159,7 @@ 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 seven-member `document` port and keep its schema, +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 @@ -173,7 +173,7 @@ leased surface. ## Release evidence The ordinary package suite includes an implementation-neutral task-board host that -depends only on the seven-member `JSONDocument`. The same host commands run +depends only on the six-member `JSONDocument`. The same host commands run unchanged against the local implementation and collaboration engine, including a concurrent card edit that follows a structural move by stable identity. @@ -216,7 +216,7 @@ The implemented profile includes: - opt-in actor-local selective undo/redo with causal history reconstruction; - whole-Change validation and deterministic suppression; - canonical membership, SHA-256 checkpoints, restore, and new-epoch compaction; -- valid JSON change notification through the standard seven-member `JSONDocument`; +- valid JSON change notification through the standard six-member `JSONDocument`; - conflict and suppression sidecars outside `document.value`. Transport, presence, storage, signing keys, server coordination, and concrete diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index 167ea744..01bfd08c 100644 --- a/packages/json-document-collaboration/package.json +++ b/packages/json-document-collaboration/package.json @@ -1,6 +1,6 @@ { "name": "@interactive-os/json-document-collaboration", - "version": "0.1.0-rc.2", + "version": "0.2.0-rc.1", "description": "Transport-free causal collaboration engine for @interactive-os/json-document.", "type": "module", "license": "MIT", @@ -47,7 +47,7 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { - "@interactive-os/json-document": "^2.0.0" + "@interactive-os/json-document": "^3.0.0" }, "devDependencies": { "@interactive-os/json-document": "*", diff --git a/packages/json-document-collaboration/src/change.ts b/packages/json-document-collaboration/src/change.ts index b576e242..e090ce12 100644 --- a/packages/json-document-collaboration/src/change.ts +++ b/packages/json-document-collaboration/src/change.ts @@ -39,7 +39,7 @@ export function createEpoch( id: options.ruleset.id, digest: options.ruleset.digest, }), - acceptance: (options.validate ?? options.accepts) === undefined + acceptance: options.validate === undefined ? "none" : "custom", baseDigest: fingerprintJSON(initial), diff --git a/packages/json-document-collaboration/src/checkpoint.ts b/packages/json-document-collaboration/src/checkpoint.ts index 1f1bab07..58c39959 100644 --- a/packages/json-document-collaboration/src/checkpoint.ts +++ b/packages/json-document-collaboration/src/checkpoint.ts @@ -1,6 +1,6 @@ import { applyPatch, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONValue, } from "@interactive-os/json-document"; @@ -24,7 +24,7 @@ type PreparedCheckpoint = type CheckpointVerifier = ( checkpoint: CollaborationCheckpoint, -) => JSONCapabilityResult; +) => JSONPatchValidationResult; type CheckpointVerificationFailure = { readonly ok: false; @@ -189,7 +189,7 @@ export function verifyCheckpointProof( } return verificationFailure( "checkpoint_verification_failed", - "checkpoint verifier must return a capability result", + "checkpoint verifier must return a validation result", ); } catch (error) { return verificationFailure( diff --git a/packages/json-document-collaboration/src/compact.ts b/packages/json-document-collaboration/src/compact.ts index 1d7b25fb..3990aff3 100644 --- a/packages/json-document-collaboration/src/compact.ts +++ b/packages/json-document-collaboration/src/compact.ts @@ -42,7 +42,7 @@ export function compactCollaborationCheckpoint( if (typeof options !== "object" || options === null) { return failure("invalid_options", "compaction options must be an object"); } - const validate = options.validate ?? options.accepts; + const validate = options.validate; if ( checkpoint.payload.epoch.acceptance === "custom" && validate === undefined @@ -213,7 +213,6 @@ export function compactCollaborationCheckpoint( discardedConflicts: materialized.conflicts.length, discardedSuppressed: materialized.suppressed.length, discardedHistoryChanges, - discardedHistoryControls: discardedHistoryChanges, }), }); } @@ -292,12 +291,11 @@ function effectiveNextValidation( options: CollaborationCompactionOptions, ): CollaborationCompactionOptions["nextValidate"] { if (options.nextValidate !== undefined) return options.nextValidate; - 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.validate ?? options.accepts + ? options.validate : undefined; } diff --git a/packages/json-document-collaboration/src/create.ts b/packages/json-document-collaboration/src/create.ts index b5c505f2..907002a1 100644 --- a/packages/json-document-collaboration/src/create.ts +++ b/packages/json-document-collaboration/src/create.ts @@ -154,7 +154,6 @@ export function createCollaborationRuntime( return Object.freeze({ document: runtime.document, replica: runtime.replica, - collaboration: runtime.collaboration, }); } @@ -169,7 +168,6 @@ export function createTextRuntime( return Object.freeze({ document: runtime.document, replica: runtime.replica, - collaboration: runtime.collaboration, history: runtime.history, text: runtime.text, }); @@ -202,7 +200,6 @@ export function createRestoredTextRuntime( return Object.freeze({ document: runtime.document, replica: runtime.replica, - collaboration: runtime.collaboration, history: runtime.history, text: runtime.text, }); @@ -244,7 +241,7 @@ function createRuntime( } let evaluatingValidation = false; - const validate = options.validate ?? options.accepts; + const validate = options.validate; const evaluateValidation = ( candidate: JSONValue, ): JSONPatchValidationResult => { @@ -266,7 +263,7 @@ function createRuntime( const documentStore = createJSONDocument(initialProjected.value); const actorId = options.actorId; const documentListeners = new Set<(change: JSONAppliedChange) => void>(); - const collaborationListeners = new Set< + const replicaStatusListeners = new Set< (status: ReplicaStatus) => void >(); const notificationQueue: NotificationEvent[] = []; @@ -384,12 +381,6 @@ function createRuntime( const prepared = prepareLocal(operations); return prepared.ok ? OK : prepared; }, - canPatch( - operations: ReadonlyArray, - ): JSONPatchValidationResult { - const prepared = prepareLocal(operations); - return prepared.ok ? OK : prepared; - }, commit( operations: ReadonlyArray, commitOptions?: JSONDocumentCommitOptions, @@ -427,10 +418,9 @@ function createRuntime( }, } satisfies JSONDocument); - const collaboration = Object.freeze({ + const replica = Object.freeze({ epoch, status: currentReplicaStatus, - current: currentReplicaStatus, exportBundle(): CollaborationBundle { return Object.freeze({ epoch, @@ -450,7 +440,7 @@ function createRuntime( return { ok: false, code: "acceptance_reentrancy", - reason: "acceptance callback cannot ingest collaboration changes", + reason: "validation callback cannot ingest collaboration changes", }; } const prepared = prepareBundle(input); @@ -569,7 +559,7 @@ function createRuntime( }]); if (!documentCommit.ok) { throw new Error( - `collaboration projection publication failed: ${documentCommit.reason ?? documentCommit.code}`, + `collaboration document commit failed: ${documentCommit.reason ?? documentCommit.code}`, ); } documentChange = documentCommit.change; @@ -593,12 +583,12 @@ function createRuntime( subscribe( listener: (status: ReplicaStatus) => void, ): () => void { - collaborationListeners.add(listener); + replicaStatusListeners.add(listener); let active = true; return () => { if (!active) return; active = false; - collaborationListeners.delete(listener); + replicaStatusListeners.delete(listener); }; }, } satisfies CollaborationReplica); @@ -607,9 +597,6 @@ function createRuntime( status(): HistoryStatus { return resolveHistoryState().status; }, - current(): HistoryStatus { - return resolveHistoryState().status; - }, canUndo(): JSONPatchValidationResult { const prepared = prepareHistoryChange("undo"); return prepared.ok ? OK : prepared; @@ -632,7 +619,7 @@ function createRuntime( if (evaluatingValidation) { return textFailure( "acceptance_reentrancy", - "acceptance callback cannot capture collaborative text", + "validation callback cannot capture collaborative text", ); } let segments: string[]; @@ -673,7 +660,7 @@ function createRuntime( if (evaluatingValidation) { return textFailure( "acceptance_reentrancy", - "acceptance callback cannot plan collaborative text", + "validation callback cannot plan collaborative text", ); } const captured = textCaptures.get(capture); @@ -805,7 +792,7 @@ function createRuntime( if (evaluatingValidation) { return textFailure( "acceptance_reentrancy", - "acceptance callback cannot commit collaborative text", + "validation callback cannot commit collaborative text", ); } const planned = textPlans.get(plan); @@ -857,7 +844,6 @@ function createRuntime( change: metadataProbe.change, changeId: null, didChangeDocument: false, - projectionChanged: false, value, selection: resolvePlannedSelection(planned, textState), }); @@ -921,7 +907,7 @@ function createRuntime( }); if (!documentCommit.ok) { throw new Error( - `text projection publication failed: ${documentCommit.reason ?? documentCommit.code}`, + `text document commit failed: ${documentCommit.reason ?? documentCommit.code}`, ); } documentChange = documentCommit.change; @@ -942,7 +928,6 @@ function createRuntime( change: documentChange ?? metadataProbe.change, changeId: freezeChangeId(changeId), didChangeDocument, - projectionChanged: didChangeDocument, value: projectText(textState), selection: resolvePlannedSelection(planned, textState), }); @@ -952,8 +937,7 @@ function createRuntime( return Object.freeze({ document, - replica: collaboration, - collaboration, + replica, history, ...(text === undefined ? {} : { text }), }); @@ -1030,7 +1014,7 @@ function createRuntime( if (evaluatingValidation) { return failure( "acceptance_reentrancy", - "acceptance callback cannot author history changes", + "validation callback cannot author history changes", ); } if (graph.pending.some((row) => row.changeId.actorId === actorId)) { @@ -1136,7 +1120,7 @@ function createRuntime( }]); if (!documentCommit.ok) { throw new Error( - `history projection publication failed: ${documentCommit.reason ?? documentCommit.code}`, + `history document commit failed: ${documentCommit.reason ?? documentCommit.code}`, ); } documentChange = documentCommit.change; @@ -1151,7 +1135,6 @@ function createRuntime( changeId: freezeChangeId(prepared.value.change.changeId), target: freezeChangeId(prepared.value.target), didChangeDocument: prepared.value.didChangeDocument, - projectionChanged: prepared.value.didChangeDocument, }); } @@ -1185,8 +1168,8 @@ function createRuntime( } } } - for (const listener of [...collaborationListeners]) { - if (!collaborationListeners.has(listener)) continue; + for (const listener of [...replicaStatusListeners]) { + if (!replicaStatusListeners.has(listener)) continue; try { listener(next.replicaStatus); } catch { @@ -1517,5 +1500,5 @@ function failure( const OK: JSONPatchValidationResult = Object.freeze({ ok: true }); const ACCEPTANCE_REENTRANCY_FAILURE = failure( "acceptance_reentrancy", - "acceptance callback cannot call canPatch or commit", + "validation callback cannot call validatePatch or commit", ); diff --git a/packages/json-document-collaboration/src/history-index.ts b/packages/json-document-collaboration/src/history-index.ts index f0045da4..e5686131 100644 --- a/packages/json-document-collaboration/src/history-index.ts +++ b/packages/json-document-collaboration/src/history-index.ts @@ -1,18 +1,7 @@ export * from "./index.js"; -export { - createHistoryRuntime, - createHistoryRuntime as createCollaborationHistoryRuntime, -} from "./create.js"; -export { - restoreHistoryRuntime, - restoreHistoryRuntime as restoreCollaborationHistoryRuntime, -} from "./restore.js"; +export { createHistoryRuntime } from "./create.js"; +export { restoreHistoryRuntime } from "./restore.js"; export type { - CollaborationHistoryControl, - CollaborationHistoryResult, - CollaborationHistoryRestoreResult, - CollaborationHistoryRuntime, - CollaborationHistorySnapshot, History, HistoryRestoreResult, HistoryResult, diff --git a/packages/json-document-collaboration/src/index.ts b/packages/json-document-collaboration/src/index.ts index 944dc052..12a4cfce 100644 --- a/packages/json-document-collaboration/src/index.ts +++ b/packages/json-document-collaboration/src/index.ts @@ -5,7 +5,6 @@ export type { ActorId, ArrayPlacement, ChangeId, - CollaborationAcceptance, CollaborationBundle, CollaborationChange, CollaborationCheckpoint, @@ -14,7 +13,6 @@ export type { CollaborationCompactionReport, CollaborationCompactionResult, CollaborationConflict, - CollaborationControl, CollaborationEpoch, CollaborationEpochParent, CollaborationIngestFailure, @@ -29,7 +27,6 @@ export type { CollaborationRuntime, CollaborationRuntimeOptions, CollaborationValidation, - CollaborationSnapshot, ContainerNodeId, MemberId, MemberPlacement, diff --git a/packages/json-document-collaboration/src/materialize.ts b/packages/json-document-collaboration/src/materialize.ts index 73b7604c..8739ecb3 100644 --- a/packages/json-document-collaboration/src/materialize.ts +++ b/packages/json-document-collaboration/src/materialize.ts @@ -279,12 +279,12 @@ export function validateCandidate( } return failure( "schema_violation", - "acceptance callback must return a result with an ok discriminant", + "validation callback must return a result with an ok discriminant", ); } catch (error) { return failure( "schema_violation", - error instanceof Error ? error.message : "acceptance callback failed", + error instanceof Error ? error.message : "validation callback failed", ); } } diff --git a/packages/json-document-collaboration/src/restore.ts b/packages/json-document-collaboration/src/restore.ts index 50fddb1e..0ea41039 100644 --- a/packages/json-document-collaboration/src/restore.ts +++ b/packages/json-document-collaboration/src/restore.ts @@ -37,7 +37,6 @@ export function restoreCollaborationRuntime( const runtime: CollaborationRuntime = Object.freeze({ document: restored.runtime.document, replica: restored.runtime.replica, - collaboration: restored.runtime.collaboration, }); return Object.freeze({ ok: true, runtime }); } @@ -76,7 +75,7 @@ function restoreProfileRuntime( return failure("invalid_checkpoint", prepared.reason); } const checkpoint = prepared.checkpoint; - const validate = options.validate ?? options.accepts; + const validate = options.validate; if ( checkpoint.payload.epoch.acceptance === "custom" && validate === undefined @@ -132,7 +131,7 @@ function restoreProfileRuntime( restoreOptions, checkpoint.payload.epoch, ); - const ingested = restored.collaboration.ingest({ + const ingested = restored.replica.ingest({ epoch: checkpoint.payload.epoch, changes: checkpoint.payload.changes, }); diff --git a/packages/json-document-collaboration/src/text-index.ts b/packages/json-document-collaboration/src/text-index.ts index bff96c53..f82250b7 100644 --- a/packages/json-document-collaboration/src/text-index.ts +++ b/packages/json-document-collaboration/src/text-index.ts @@ -1,26 +1,7 @@ export * from "./index.js"; -export { - createTextRuntime, - createTextRuntime as createCollaborationTextRuntime, -} from "./create.js"; -export { - restoreTextRuntime, - restoreTextRuntime as restoreCollaborationTextRuntime, -} from "./restore.js"; +export { createTextRuntime } from "./create.js"; +export { restoreTextRuntime } from "./restore.js"; export type { - CollaborationHistoryControl, - CollaborationHistoryResult, - CollaborationHistorySnapshot, - CollaborationTextCapture, - CollaborationTextCaptureResult, - CollaborationTextCommitResult, - CollaborationTextControl, - CollaborationTextObservation, - CollaborationTextPlan, - CollaborationTextPlanResult, - CollaborationTextRestoreResult, - CollaborationTextRuntime, - CollaborationTextSelection, History, HistoryResult, HistoryStatus, diff --git a/packages/json-document-collaboration/src/types.ts b/packages/json-document-collaboration/src/types.ts index 0786d218..656ef9ab 100644 --- a/packages/json-document-collaboration/src/types.ts +++ b/packages/json-document-collaboration/src/types.ts @@ -1,6 +1,5 @@ import type { JSONAppliedChange, - JSONCapabilityResult, JSONDocument, JSONDocumentCommitOptions, JSONPatchValidationResult, @@ -187,9 +186,6 @@ export interface ReplicaStatus { readonly suppressed: ReadonlyArray; } -/** @deprecated Use ReplicaStatus. */ -export type CollaborationSnapshot = ReplicaStatus; - export interface CollaborationIngestSuccess { readonly ok: true; readonly integrated: ReadonlyArray; @@ -221,17 +217,12 @@ export type CollaborationIngestResult = export interface CollaborationReplica { readonly epoch: CollaborationEpoch; status(): ReplicaStatus; - /** @deprecated Use status. */ - current(): ReplicaStatus; exportBundle(): CollaborationBundle; exportCheckpoint(): CollaborationCheckpoint; ingest(bundle: unknown): CollaborationIngestResult; subscribe(listener: (status: ReplicaStatus) => void): () => void; } -/** @deprecated Use CollaborationReplica. */ -export type CollaborationControl = CollaborationReplica; - export interface HistoryStatus { readonly undoTarget: ChangeId | null; readonly redoTarget: ChangeId | null; @@ -240,17 +231,12 @@ export interface HistoryStatus { readonly revision: number; } -/** @deprecated Use HistoryStatus. */ -export type CollaborationHistorySnapshot = HistoryStatus; - export type HistoryResult = | { readonly ok: true; readonly changeId: ChangeId; readonly target: ChangeId; readonly didChangeDocument: boolean; - /** @deprecated Use didChangeDocument. */ - readonly projectionChanged: boolean; } | { readonly ok: false; @@ -258,52 +244,33 @@ export type HistoryResult = readonly reason?: string; }; -/** @deprecated Use HistoryResult. */ -export type CollaborationHistoryResult = HistoryResult; - export interface History { status(): HistoryStatus; - /** @deprecated Use status. */ - current(): HistoryStatus; canUndo(): JSONPatchValidationResult; undo(): HistoryResult; canRedo(): JSONPatchValidationResult; redo(): HistoryResult; } -/** @deprecated Use History. */ -export type CollaborationHistoryControl = History; - export interface CollaborationRuntime { readonly document: JSONDocument; readonly replica: CollaborationReplica; - /** @deprecated Use replica. */ - readonly collaboration: CollaborationReplica; } export interface HistoryRuntime extends CollaborationRuntime { readonly history: History; } -/** @deprecated Import HistoryRuntime from the /history subpath. */ -export type CollaborationHistoryRuntime = HistoryRuntime; - export interface TextSelection { readonly anchor: number; readonly focus: number; } -/** @deprecated Use TextSelection from the /text subpath. */ -export type CollaborationTextSelection = TextSelection; - export interface TextObservation { readonly value: string; readonly selection?: TextSelection; } -/** @deprecated Use TextObservation from the /text subpath. */ -export type CollaborationTextObservation = TextObservation; - export interface TextCapture { readonly pointer: string; readonly target: MemberId; @@ -311,18 +278,12 @@ export interface TextCapture { readonly value: string; } -/** @deprecated Use TextCapture from the /text subpath. */ -export type CollaborationTextCapture = TextCapture; - export interface TextPlan { readonly pointer: string; readonly value: string; readonly selection?: TextSelection; } -/** @deprecated Use TextPlan from the /text subpath. */ -export type CollaborationTextPlan = TextPlan; - export type TextCaptureResult = | { readonly ok: true; @@ -334,9 +295,6 @@ export type TextCaptureResult = readonly reason: string; }; -/** @deprecated Use TextCaptureResult from the /text subpath. */ -export type CollaborationTextCaptureResult = TextCaptureResult; - export type TextPlanResult = | { readonly ok: true; @@ -348,17 +306,12 @@ export type TextPlanResult = readonly reason: string; }; -/** @deprecated Use TextPlanResult from the /text subpath. */ -export type CollaborationTextPlanResult = TextPlanResult; - export type TextCommitResult = | { readonly ok: true; readonly change: JSONAppliedChange; readonly changeId: ChangeId | null; readonly didChangeDocument: boolean; - /** @deprecated Use didChangeDocument. */ - readonly projectionChanged: boolean; readonly value: string; readonly selection: TextSelection | null; } @@ -368,9 +321,6 @@ export type TextCommitResult = readonly reason: string; }; -/** @deprecated Use TextCommitResult from the /text subpath. */ -export type CollaborationTextCommitResult = TextCommitResult; - export interface Text { capture(pointer: string): TextCaptureResult; plan( @@ -383,16 +333,10 @@ export interface Text { ): TextCommitResult; } -/** @deprecated Use Text from the /text subpath. */ -export type CollaborationTextControl = Text; - export interface TextRuntime extends HistoryRuntime { readonly text: Text; } -/** @deprecated Use TextRuntime from the /text subpath. */ -export type CollaborationTextRuntime = TextRuntime; - /** * A convergence-critical validation rule. * @@ -405,28 +349,21 @@ export type CollaborationValidation = ( candidate: JSONValue, ) => JSONPatchValidationResult; -/** @deprecated Use CollaborationValidation. */ -export type CollaborationAcceptance = CollaborationValidation; - export interface CollaborationRuntimeOptions { readonly actorId: ActorId; readonly epochId: string; readonly ruleset: CollaborationRulesetIdentity; readonly membership?: CollaborationMembership; readonly validate?: CollaborationValidation; - /** @deprecated Use validate. */ - readonly accepts?: CollaborationAcceptance; } export interface CollaborationRestoreOptions { readonly actorId: ActorId; readonly ruleset: CollaborationRulesetIdentity; readonly validate?: CollaborationValidation; - /** @deprecated Use validate. */ - readonly accepts?: CollaborationAcceptance; readonly verify?: ( checkpoint: CollaborationCheckpoint, - ) => JSONCapabilityResult; + ) => JSONPatchValidationResult; } export type CollaborationRestoreResult = @@ -451,9 +388,6 @@ export type HistoryRestoreResult = readonly reason: string; }; -/** @deprecated Use HistoryRestoreResult from the /history subpath. */ -export type CollaborationHistoryRestoreResult = HistoryRestoreResult; - export type TextRestoreResult = | { readonly ok: true; @@ -465,9 +399,6 @@ export type TextRestoreResult = readonly reason: string; }; -/** @deprecated Use TextRestoreResult from the /text subpath. */ -export type CollaborationTextRestoreResult = TextRestoreResult; - export interface CollaborationCompactionOptions { readonly mode: "new-epoch"; readonly nextEpochId: string; @@ -477,14 +408,10 @@ export interface CollaborationCompactionOptions { */ readonly nextMembership?: CollaborationMembership | null; readonly validate?: CollaborationValidation; - /** @deprecated Use validate. */ - readonly accepts?: CollaborationAcceptance; readonly nextValidate?: CollaborationValidation; - /** @deprecated Use nextValidate. */ - readonly nextAccepts?: CollaborationAcceptance; readonly verify?: ( checkpoint: CollaborationCheckpoint, - ) => JSONCapabilityResult; + ) => JSONPatchValidationResult; } export interface CollaborationCompactionReport { @@ -492,8 +419,6 @@ export interface CollaborationCompactionReport { readonly discardedConflicts: number; readonly discardedSuppressed: number; readonly discardedHistoryChanges: number; - /** @deprecated Use discardedHistoryChanges. */ - readonly discardedHistoryControls: number; } export type CollaborationCompactionResult = diff --git a/packages/json-document-collaboration/tests/checkpoint.test.ts b/packages/json-document-collaboration/tests/checkpoint.test.ts index d7f7619f..8911c237 100644 --- a/packages/json-document-collaboration/tests/checkpoint.test.ts +++ b/packages/json-document-collaboration/tests/checkpoint.test.ts @@ -357,7 +357,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { }); expect(compactCollaborationCheckpoint(checkpoint, { mode: "new-epoch", - nextEpochId: "checkpoint-suppressed/missing-acceptance/v2", + nextEpochId: "checkpoint-suppressed/missing-validation/v3", nextRuleset: ruleset, })).toMatchObject({ ok: false, @@ -483,7 +483,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { receiver.replica.exportCheckpoint(), { mode: "new-epoch", - nextEpochId: "checkpoint-pending-compaction/v2", + nextEpochId: "checkpoint-pending-compaction/v3", nextRuleset: ruleset, }, )).toMatchObject({ @@ -516,7 +516,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { }); expect(compactCollaborationCheckpoint(forged, { mode: "new-epoch", - nextEpochId: "checkpoint-member-laundering/v2", + nextEpochId: "checkpoint-member-laundering/v3", nextRuleset: ruleset, })).toMatchObject({ ok: false, @@ -571,7 +571,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { expect(compactCollaborationCheckpoint(forged, { mode: "new-epoch", - nextEpochId: `checkpoint-member-reference-compaction/v2/${name}`, + nextEpochId: `checkpoint-member-reference-compaction/v3/${name}`, nextRuleset: ruleset, })).toMatchObject({ ok: false, @@ -605,7 +605,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { expect(compactCollaborationCheckpoint(forged, { mode: "new-epoch", - nextEpochId: "checkpoint-duplicate/v2", + nextEpochId: "checkpoint-duplicate/v3", nextRuleset: ruleset, })).toMatchObject({ ok: false }); }); @@ -640,7 +640,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { }); expect(compactCollaborationCheckpoint(reordered, { mode: "new-epoch", - nextEpochId: "checkpoint-change-order/v2", + nextEpochId: "checkpoint-change-order/v3", nextRuleset: ruleset, })).toMatchObject({ ok: false, @@ -675,7 +675,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { }); expect(compactCollaborationCheckpoint(duplicated, { mode: "new-epoch", - nextEpochId: "checkpoint-change-duplicate/v2", + nextEpochId: "checkpoint-change-duplicate/v3", nextRuleset: ruleset, })).toMatchObject({ ok: false, @@ -699,7 +699,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { source.replica.exportCheckpoint(), { mode: "new-epoch", - nextEpochId: "checkpoint-compaction/v2", + nextEpochId: "checkpoint-compaction/v3", nextRuleset: ruleset, }, ); @@ -713,7 +713,7 @@ describe("@interactive-os/json-document-collaboration checkpoints", () => { )); expect(restored.document.value).toEqual(source.document.value); expect(restored.replica.epoch.epochId).toBe( - "checkpoint-compaction/v2", + "checkpoint-compaction/v3", ); expect(restored.replica.exportBundle().changes).toEqual([]); diff --git a/packages/json-document-collaboration/tests/collaboration.test.ts b/packages/json-document-collaboration/tests/collaboration.test.ts index f1c1f98d..14aa9d03 100644 --- a/packages/json-document-collaboration/tests/collaboration.test.ts +++ b/packages/json-document-collaboration/tests/collaboration.test.ts @@ -39,17 +39,15 @@ describe("@interactive-os/json-document-collaboration", () => { ); }); - test("exposes the canonical seven-member JSON Document API", () => { + test("exposes the canonical six-member JSON Document API", () => { const shared = runtime("actor-a"); expect(Object.keys(shared).sort()).toEqual([ - "collaboration", "document", "replica", ]); expect(Object.keys(shared.document).sort()).toEqual([ "at", - "canPatch", "commit", "query", "subscribe", @@ -364,8 +362,8 @@ describe("@interactive-os/json-document-collaboration", () => { }); expect(target.document.value).toMatchObject({ title: "Draft" }); - const publications: unknown[] = []; - target.document.subscribe((change) => publications.push(change)); + const notifications: unknown[] = []; + target.document.subscribe((change) => notifications.push(change)); expect(target.replica.ingest({ epoch: exported.epoch, changes: [first], @@ -378,7 +376,7 @@ describe("@interactive-os/json-document-collaboration", () => { pending: [], }); expect(target.document.value).toMatchObject({ title: "Second" }); - expect(publications).toHaveLength(1); + expect(notifications).toHaveLength(1); }); test("publishes immutable collaboration snapshots once per state-adding ingest", () => { @@ -397,7 +395,7 @@ describe("@interactive-os/json-document-collaboration", () => { throw new Error("expected two changes"); } - const snapshots: ReturnType[] = []; + const snapshots: ReturnType[] = []; const unsubscribe = target.replica.subscribe((snapshot) => { snapshots.push(snapshot); }); @@ -428,7 +426,7 @@ describe("@interactive-os/json-document-collaboration", () => { expect(target.document.value).toMatchObject({ title: "Second" }); }); - test("queues reentrant collaboration publications in causal order", () => { + test("queues reentrant collaboration notifications in causal order", () => { const source = runtime("actor-a"); const target = runtime("actor-b"); source.document.commit([ @@ -623,8 +621,8 @@ describe("@interactive-os/json-document-collaboration", () => { ]); }); - test("isolates acceptance candidates from mutation during remote materialization", () => { - const mutatingAcceptance = (candidate: unknown) => { + test("isolates validation candidates from mutation during remote materialization", () => { + const mutatingValidation = (candidate: unknown) => { const value = candidate as { readonly forbidden: boolean }; if (value.forbidden) { Reflect.set(value, "forbidden", false); @@ -635,20 +633,20 @@ describe("@interactive-os/json-document-collaboration", () => { }; const overrides = { ruleset: { - id: "test/immutable-acceptance", - digest: "test/immutable-acceptance/v1", + id: "test/immutable-validation", + digest: "test/immutable-validation/v1", }, }; expect(() => runtime( "invalid-initial", { forbidden: true }, - { ...overrides, validate: mutatingAcceptance }, + { ...overrides, validate: mutatingValidation }, )).toThrow("Initial document value was rejected"); const source = runtime("actor-a", { forbidden: false }, overrides); const target = runtime("actor-b", { forbidden: false }, { ...overrides, - validate: mutatingAcceptance, + validate: mutatingValidation, }); source.document.commit([ { op: "replace", path: "/forbidden", value: true }, diff --git a/packages/json-document-collaboration/tests/convergence-soak.test.ts b/packages/json-document-collaboration/tests/convergence-soak.test.ts index b0c081dc..8084056d 100644 --- a/packages/json-document-collaboration/tests/convergence-soak.test.ts +++ b/packages/json-document-collaboration/tests/convergence-soak.test.ts @@ -146,7 +146,7 @@ function runCase(seed: number): void { }; const compacted = compactCollaborationCheckpoint(checkpoint, { mode: "new-epoch", - nextEpochId: `task-board-soak/${seed}/v2`, + nextEpochId: `task-board-soak/${seed}/v3`, nextRuleset, }); if (!compacted.ok) { diff --git a/packages/json-document-collaboration/tests/foundation-parity.test.ts b/packages/json-document-collaboration/tests/foundation-parity.test.ts index 21b5b1b1..797636ec 100644 --- a/packages/json-document-collaboration/tests/foundation-parity.test.ts +++ b/packages/json-document-collaboration/tests/foundation-parity.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { parseArrayIndex } from "../src/array-index.js"; import { jsonEqual } from "../src/json-equal.js"; -import rawVectors from "../../json-document/tests/conformance/v2/foundation-vectors.json" with { type: "json" }; +import rawVectors from "../../json-document/tests/conformance/v3/foundation-vectors.json" with { type: "json" }; interface FoundationVectors { readonly arrayIndexes: ReadonlyArray<{ diff --git a/packages/json-document-collaboration/tests/history.test.ts b/packages/json-document-collaboration/tests/history.test.ts index d181a790..c95b7a40 100644 --- a/packages/json-document-collaboration/tests/history.test.ts +++ b/packages/json-document-collaboration/tests/history.test.ts @@ -53,14 +53,12 @@ describe("@interactive-os/json-document-collaboration/history", () => { const shared = runtime("actor-a"); expect(Object.keys(shared).sort()).toEqual([ - "collaboration", "document", "history", "replica", ]); expect(Object.keys(shared.document).sort()).toEqual([ "at", - "canPatch", "commit", "query", "subscribe", diff --git a/packages/json-document-collaboration/tests/projection-conformance.test.ts b/packages/json-document-collaboration/tests/json-document-conformance.test.ts similarity index 54% rename from packages/json-document-collaboration/tests/projection-conformance.test.ts rename to packages/json-document-collaboration/tests/json-document-conformance.test.ts index 2389a9c4..4c909165 100644 --- a/packages/json-document-collaboration/tests/projection-conformance.test.ts +++ b/packages/json-document-collaboration/tests/json-document-conformance.test.ts @@ -1,47 +1,47 @@ import { - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONValue, } from "@interactive-os/json-document"; import { createCollaborationRuntime } from "@interactive-os/json-document-collaboration"; import { - runProjectionConformance, - type Projection, - type ProjectionAcceptance, - type ProjectionHarness, -} from "../../json-document/tests/conformance/v2/projection-suite.js"; -import { runPressureConformance } from "../../json-document/tests/conformance/v2/pressure-suite.js"; + runJSONDocumentConformance, + type JSONDocument, + type JSONDocumentValidation, + type JSONDocumentHarness, +} from "../../json-document/tests/conformance/v3/json-document-suite.js"; +import { runPressureConformance } from "../../json-document/tests/conformance/v3/pressure-suite.js"; -function createProjection( - acceptance: ProjectionAcceptance, +function createJSONDocument( + validation: JSONDocumentValidation, initial: JSONValue, -): Projection { - const accepts = acceptance === "task-list" - ? taskListAcceptance - : acceptance === "attempt-transform" - ? attemptTransformAcceptance +): JSONDocument { + const validate = validation === "task-list" + ? taskListValidation + : validation === "attempt-transform" + ? attemptTransformValidation : undefined; return createCollaborationRuntime(initial, { actorId: "conformance", - epochId: "projection-conformance/v1", + epochId: "document-conformance/v1", ruleset: { - id: `projection-conformance/${acceptance}`, - digest: `projection-conformance/${acceptance}/v1`, + id: `document-conformance/${validation}`, + digest: `document-conformance/${validation}/v1`, }, - ...(accepts === undefined ? {} : { accepts }), + ...(validate === undefined ? {} : { validate }), }).document; } -function attemptTransformAcceptance( +function attemptTransformValidation( candidate: JSONValue, -): JSONCapabilityResult { +): JSONPatchValidationResult { if (isRecord(candidate)) { Reflect.set(candidate, "title", "Implicit"); } return { ok: true }; } -function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult { +function taskListValidation(candidate: JSONValue): JSONPatchValidationResult { const valid = isRecord(candidate) && typeof candidate.title === "string" && Array.isArray(candidate.items) @@ -57,7 +57,7 @@ function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult { : { ok: false, code: "schema_violation", - reason: "candidate does not satisfy the task-list acceptance rule", + reason: "candidate does not satisfy the task-list validation rule", }; } @@ -67,9 +67,9 @@ function isRecord( return value !== null && typeof value === "object" && !Array.isArray(value); } -const harness: ProjectionHarness = { - create: createProjection, +const harness: JSONDocumentHarness = { + create: createJSONDocument, }; -runProjectionConformance(harness); +runJSONDocumentConformance(harness); runPressureConformance(harness); diff --git a/packages/json-document-collaboration/tests/naming-compatibility.test.ts b/packages/json-document-collaboration/tests/naming-compatibility.test.ts deleted file mode 100644 index ede2b4f2..00000000 --- a/packages/json-document-collaboration/tests/naming-compatibility.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - createCollaborationRuntime, - type CollaborationControl, - type CollaborationReplica, - type CollaborationSnapshot, - type ReplicaStatus, -} from "../src/index.js"; -import { - createCollaborationHistoryRuntime, - createHistoryRuntime, - type CollaborationHistorySnapshot, - type HistoryStatus, -} from "../src/history-index.js"; -import { - createCollaborationTextRuntime, - createTextRuntime, - type CollaborationTextSelection, - type TextSelection, -} from "../src/text-index.js"; - -const options = { - actorId: "compatibility", - epochId: "naming-compatibility/v1", - ruleset: { id: "naming-compatibility", digest: "sha256:naming-compatibility" }, -}; - -describe("deprecated naming compatibility", () => { - test("canonical and compatibility runtime names share one implementation", () => { - expect(createCollaborationHistoryRuntime).toBe(createHistoryRuntime); - expect(createCollaborationTextRuntime).toBe(createTextRuntime); - - const runtime = createCollaborationRuntime({}, options); - expect(runtime.collaboration).toBe(runtime.replica); - const current = runtime.collaboration.current; - const status = runtime.replica.status; - expect(current()).toEqual(status()); - }); - - test("compatibility types are structural aliases", () => { - const replica = null as unknown as CollaborationReplica; - replica satisfies CollaborationControl; - - const status = null as unknown as ReplicaStatus; - status satisfies CollaborationSnapshot; - - const historyStatus = null as unknown as HistoryStatus; - historyStatus satisfies CollaborationHistorySnapshot; - - const selection = null as unknown as TextSelection; - selection satisfies CollaborationTextSelection; - }); -}); diff --git a/packages/json-document-collaboration/tests/support/task-board-host.ts b/packages/json-document-collaboration/tests/support/task-board-host.ts index 2ed35363..4f77cdf1 100644 --- a/packages/json-document-collaboration/tests/support/task-board-host.ts +++ b/packages/json-document-collaboration/tests/support/task-board-host.ts @@ -63,7 +63,7 @@ export function taskBoardInitialValue(): JSONValue { } /** - * A product-shaped consumer which knows only the seven-member JSONDocument + * A product-shaped consumer which knows only the six-member JSONDocument * contract. Provider construction, collaboration, transport, and history stay * outside this module. */ diff --git a/packages/json-document-collaboration/tests/text.test.ts b/packages/json-document-collaboration/tests/text.test.ts index f5dbf9cc..66e41187 100644 --- a/packages/json-document-collaboration/tests/text.test.ts +++ b/packages/json-document-collaboration/tests/text.test.ts @@ -37,7 +37,6 @@ describe("@interactive-os/json-document-collaboration/text", () => { const shared = textRuntime("actor-a"); expect(Object.keys(shared).sort()).toEqual([ - "collaboration", "document", "history", "replica", @@ -45,7 +44,6 @@ describe("@interactive-os/json-document-collaboration/text", () => { ]); expect(Object.keys(shared.document).sort()).toEqual([ "at", - "canPatch", "commit", "query", "subscribe", diff --git a/packages/json-document-collaboration/tsconfig.test.json b/packages/json-document-collaboration/tsconfig.test.json index 61df74f4..54051e7e 100644 --- a/packages/json-document-collaboration/tsconfig.test.json +++ b/packages/json-document-collaboration/tsconfig.test.json @@ -21,8 +21,8 @@ "include": [ "src/**/*.ts", "tests/**/*.ts", - "../json-document/tests/conformance/v2/projection-suite.ts", - "../json-document/tests/conformance/v2/pressure-suite.ts", + "../json-document/tests/conformance/v3/json-document-suite.ts", + "../json-document/tests/conformance/v3/pressure-suite.ts", "vitest.config.ts" ] } diff --git a/packages/json-document/README.md b/packages/json-document/README.md index a99be67f..e6ccea0a 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -3,7 +3,7 @@ 문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 implementation-neutral JSON 편집 API와 headless JSON Document입니다. -v2 root는 JSON, RFC 6901 JSON Pointer, RFC 9535 JSONPath, RFC 6902 JSON +v3 root는 JSON, RFC 6901 JSON Pointer, RFC 9535 JSONPath, RFC 6902 JSON Patch만 전제로 합니다. UI framework, schema provider, history, selection, clipboard는 core 계약이 아닙니다. @@ -11,23 +11,23 @@ clipboard는 core 계약이 아닙니다. stateless JSON Patch -> JSON Document -> host adapter ``` -Canonical concept와 stable v2 identifier의 관계는 +Canonical concept와 stable v3 identifier의 관계는 [Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 정의합니다. -현재 버전은 `2.0.0`입니다. reference와 독립 구현이 같은 conformance suite를 +현재 버전은 `3.0.0`입니다. reference와 독립 구현이 같은 conformance suite를 통과했고, form·table/data-grid·outliner/tree·rich text·storage/collaboration pressure gate까지 검증한 Stable profile입니다. - 공식 사이트: https://developer-1px.github.io/json-document/ -- 표준 profile: `docs/standard/v2-projection-profile.md` +- 표준 profile: `docs/standard/v3-json-document-profile.md` ## 설치 Core만 쓸 때 필수 dependency가 없습니다. ```sh -npm install @interactive-os/json-document@2.0.0 +npm install @interactive-os/json-document@3.0.0 ``` ## 60초 시작 @@ -79,9 +79,8 @@ subscriber는 state가 commit된 뒤 호출된다. 한 subscriber의 예외는 ` ## Validation -Core는 특정 schema object를 받지 않습니다. Validator를 canonical `validate` -callback으로 연결합니다. Stable v2 `accepts`는 deprecated compatibility -alias입니다. 반환된 parse value를 받지 않으므로 commit-time +Core는 특정 schema object를 받지 않습니다. Validator를 `validate` +callback으로 연결합니다. 반환된 parse value를 받지 않으므로 commit-time transform이 state에 몰래 들어갈 수 없습니다. ```ts @@ -116,7 +115,7 @@ Initial value와 patch payload, metadata, exposed document value/change는 docum ## 공개 root -Root는 22개 public symbol만 공개합니다. +Root는 21개 public symbol만 공개합니다. ```txt values @@ -127,14 +126,14 @@ values types JSONValue, Pointer, JSONPatchOperation JSONAppliedChange, JSONPatchResult, JSONDocumentCommitResult - JSONPatchValidationResult, JSONCapabilityResult, JSONChangeMetadata + JSONPatchValidationResult, JSONChangeMetadata JSONDocumentOptions, JSONDocumentCommitOptions ReadResult, QueryResult, JSONDocument ``` `JSONDocument`는 application-owned structural contract입니다. Selection, history, clipboard, DOM lifecycle과 framework binding은 별도 host 또는 adapter가 -이 일곱 member를 조합합니다. 패키지는 `/session`이나 `/react` subpath를 +이 여섯 member를 조합합니다. 패키지는 `/session`이나 `/react` subpath를 공개하지 않습니다. ## 순수 core diff --git a/packages/json-document/package.json b/packages/json-document/package.json index 66d913c6..7e1f2dc6 100644 --- a/packages/json-document/package.json +++ b/packages/json-document/package.json @@ -1,6 +1,6 @@ { "name": "@interactive-os/json-document", - "version": "2.0.0", + "version": "3.0.0", "description": "Implementation-neutral JSON Patch API and headless JSON Document.", "type": "module", "license": "MIT", diff --git a/packages/json-document/public-contract.json b/packages/json-document/public-contract.json index 4bb1d85c..4f1639db 100644 --- a/packages/json-document/public-contract.json +++ b/packages/json-document/public-contract.json @@ -12,7 +12,6 @@ ], "types": [ "JSONAppliedChange", - "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", "JSONDocumentOptions", diff --git a/packages/json-document/src/application/document/contract.ts b/packages/json-document/src/application/document/contract.ts index d0d12519..99249006 100644 --- a/packages/json-document/src/application/document/contract.ts +++ b/packages/json-document/src/application/document/contract.ts @@ -36,13 +36,8 @@ export type JSONPatchValidationResult = readonly pointer?: Pointer; }; -/** @deprecated Use JSONPatchValidationResult. */ -export type JSONCapabilityResult = JSONPatchValidationResult; - export interface JSONDocumentOptions { readonly validate?: (candidate: JSONValue) => JSONPatchValidationResult; - /** @deprecated Use validate. */ - readonly accepts?: (candidate: JSONValue) => JSONCapabilityResult; } export type ReadResult = @@ -90,10 +85,6 @@ export interface JSONDocument { validatePatch( operations: ReadonlyArray, ): JSONPatchValidationResult; - /** @deprecated Use validatePatch. */ - canPatch( - operations: ReadonlyArray, - ): JSONCapabilityResult; commit( operations: ReadonlyArray, options?: JSONDocumentCommitOptions, diff --git a/packages/json-document/src/application/document/create.ts b/packages/json-document/src/application/document/create.ts index c182448c..33b36a6a 100644 --- a/packages/json-document/src/application/document/create.ts +++ b/packages/json-document/src/application/document/create.ts @@ -10,7 +10,7 @@ export function createJSONDocument( initial: unknown, options: JSONDocumentOptions = {}, ): JSONDocument { - const validate = options.validate ?? options.accepts; + const validate = options.validate; return createJSONDocumentState(initial, { ...(validate === undefined ? {} : { validate: (candidate: JSONValue): JSONPatchValidationResult => diff --git a/packages/json-document/src/application/document/index.ts b/packages/json-document/src/application/document/index.ts index 3aea8e9f..a1763656 100644 --- a/packages/json-document/src/application/document/index.ts +++ b/packages/json-document/src/application/document/index.ts @@ -10,7 +10,6 @@ export { } from "./protocol.js"; export type { JSONAppliedChange, - JSONCapabilityResult, JSONChangeMetadata, JSONDocument, JSONDocumentOptions, diff --git a/packages/json-document/src/domain/json-document/create.ts b/packages/json-document/src/domain/json-document/create.ts index cd544beb..5a428b00 100644 --- a/packages/json-document/src/domain/json-document/create.ts +++ b/packages/json-document/src/domain/json-document/create.ts @@ -30,9 +30,6 @@ interface JSONDocumentState { validatePatch( operations: ReadonlyArray, ): JSONPatchValidationResult; - canPatch( - operations: ReadonlyArray, - ): JSONPatchValidationResult; commit( operations: ReadonlyArray, options?: JSONDocumentCommitOptions, @@ -123,13 +120,6 @@ export function createJSONDocumentState( const result = prepare(operations); return result.ok ? OK : result; }, - canPatch( - operations: ReadonlyArray, - ): JSONPatchValidationResult { - if (evaluatingValidation) return VALIDATION_REENTRANCY_FAILURE; - const result = prepare(operations); - return result.ok ? OK : result; - }, commit( operations: ReadonlyArray, commitOptions?: JSONDocumentCommitOptions, @@ -194,7 +184,7 @@ export function createJSONDocumentState( const OK: JSONPatchValidationResult = Object.freeze({ ok: true }); const VALIDATION_REENTRANCY_FAILURE = failure( "acceptance_reentrancy", - "acceptance callback cannot call canPatch or commit", + "validation callback cannot call validatePatch or commit", ); function validateCandidate( @@ -215,12 +205,12 @@ function validateCandidate( } return failure( "schema_violation", - "acceptance callback must return a result with an ok discriminant", + "validation callback must return a result with an ok discriminant", ); } catch (error) { return failure( "schema_violation", - error instanceof Error ? error.message : "acceptance callback failed", + error instanceof Error ? error.message : "validation callback failed", ); } } diff --git a/packages/json-document/src/foundation/protocol/contract.ts b/packages/json-document/src/foundation/protocol/contract.ts index 31234177..b089cb2c 100644 --- a/packages/json-document/src/foundation/protocol/contract.ts +++ b/packages/json-document/src/foundation/protocol/contract.ts @@ -50,9 +50,6 @@ export type JSONPatchValidationResult = | { readonly ok: true } | JSONPatchFailure; -/** @deprecated Compatibility alias for the stable v2 public name. */ -export type JSONCapabilityResult = JSONPatchValidationResult; - export type ReadResult = | { readonly ok: true; diff --git a/packages/json-document/src/foundation/protocol/index.ts b/packages/json-document/src/foundation/protocol/index.ts index b544d934..e9872331 100644 --- a/packages/json-document/src/foundation/protocol/index.ts +++ b/packages/json-document/src/foundation/protocol/index.ts @@ -8,7 +8,6 @@ export { } from "./apply.js"; export type { JSONAppliedChange, - JSONCapabilityResult, JSONPatchValidationResult, JSONChangeMetadata, JSONDocumentCommitOptions, diff --git a/packages/json-document/tests/conformance/v2/foundation-vectors.json b/packages/json-document/tests/conformance/v3/foundation-vectors.json similarity index 96% rename from packages/json-document/tests/conformance/v2/foundation-vectors.json rename to packages/json-document/tests/conformance/v3/foundation-vectors.json index a061646e..5e4ebdb8 100644 --- a/packages/json-document/tests/conformance/v2/foundation-vectors.json +++ b/packages/json-document/tests/conformance/v3/foundation-vectors.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "status": "stable", - "profile": "docs/standard/v2-projection-profile.md", + "profile": "docs/standard/v3-json-document-profile.md", "arrayIndexes": [ { "segment": "0", "value": 0 }, { "segment": "1", "value": 1 }, diff --git a/packages/json-document/tests/conformance/v2/projection-suite.ts b/packages/json-document/tests/conformance/v3/json-document-suite.ts similarity index 65% rename from packages/json-document/tests/conformance/v2/projection-suite.ts rename to packages/json-document/tests/conformance/v3/json-document-suite.ts index aee2308b..c4f3bf25 100644 --- a/packages/json-document/tests/conformance/v2/projection-suite.ts +++ b/packages/json-document/tests/conformance/v3/json-document-suite.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import rawVectors from "./projection-vectors.json" with { type: "json" }; +import rawVectors from "./json-document-vectors.json" with { type: "json" }; export type JSONValue = | null @@ -18,71 +18,71 @@ export type JSONPatchOperation = | { readonly op: "copy"; readonly from: string; readonly path: string } | { readonly op: "test"; readonly path: string; readonly value: JSONValue }; -export type ProjectionAcceptance = +export type JSONDocumentValidation = | "json" | "task-list" | "attempt-transform"; -export type ProjectionMetadata = Readonly>; +export type JSONDocumentMetadata = Readonly>; -export interface ProjectionChange { +export interface JSONDocumentChange { readonly applied: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; } -export interface ProjectionCommitOptions { - readonly metadata?: ProjectionMetadata; +export interface JSONDocumentCommitOptions { + readonly metadata?: JSONDocumentMetadata; } -export interface ProjectionSuccessResult { +export interface JSONDocumentSuccessResult { readonly ok: true; } -export interface ProjectionFailureResult { +export interface JSONDocumentFailureResult { readonly ok: false; readonly code: string; readonly reason?: string; readonly pointer?: string; } -export type ProjectionCapabilityResult = - | ProjectionSuccessResult - | ProjectionFailureResult; +export type JSONDocumentValidationResult = + | JSONDocumentSuccessResult + | JSONDocumentFailureResult; -export type ProjectionReadResult = +export type JSONDocumentReadResult = | { readonly ok: true; readonly path: string; readonly value: JSONValue } - | ProjectionFailureResult; + | JSONDocumentFailureResult; -export type ProjectionQueryResult = +export type JSONDocumentQueryResult = | { readonly ok: true; readonly query: string; readonly pointers: ReadonlyArray; } - | ProjectionFailureResult; + | JSONDocumentFailureResult; -export type ProjectionCommitResult = - | (ProjectionSuccessResult & { readonly change: ProjectionChange }) - | ProjectionFailureResult; +export type JSONDocumentCommitResult = + | (JSONDocumentSuccessResult & { readonly change: JSONDocumentChange }) + | JSONDocumentFailureResult; -export interface Projection { +export interface JSONDocument { readonly value: JSONValue; - at(pointer: string): ProjectionReadResult; - query(jsonPath: string): ProjectionQueryResult; - canPatch( + at(pointer: string): JSONDocumentReadResult; + query(jsonPath: string): JSONDocumentQueryResult; + validatePatch( operations: ReadonlyArray, - ): ProjectionCapabilityResult; + ): JSONDocumentValidationResult; commit( operations: ReadonlyArray, - options?: ProjectionCommitOptions, - ): ProjectionCommitResult; - subscribe(listener: (change: ProjectionChange) => void): () => void; + options?: JSONDocumentCommitOptions, + ): JSONDocumentCommitResult; + subscribe(listener: (change: JSONDocumentChange) => void): () => void; } -export interface ProjectionHarness { +export interface JSONDocumentHarness { create( - acceptance: ProjectionAcceptance, + validation: JSONDocumentValidation, initial: JSONValue, - ): Projection; + ): JSONDocument; } type ExpectedObject = Readonly>; @@ -111,9 +111,9 @@ interface ReadVector extends VectorBase { interface CommitVector extends VectorBase { readonly kind: "commit"; - readonly acceptance?: ProjectionAcceptance; + readonly validation?: JSONDocumentValidation; readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; readonly expect: { readonly probe: ExpectedObject; readonly commit: ExpectedObject; @@ -126,7 +126,7 @@ interface UnsubscribeVector extends VectorBase { readonly kind: "unsubscribe"; readonly commits: ReadonlyArray<{ readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; readonly expect: ExpectedObject; }>; readonly unsubscribeAfter: number; @@ -136,10 +136,10 @@ interface UnsubscribeVector extends VectorBase { }; } -interface UnsubscribeDuringPublicationVector extends VectorBase { - readonly kind: "unsubscribe-during-publication"; +interface UnsubscribeDuringNotificationVector extends VectorBase { + readonly kind: "unsubscribe-during-notification"; readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; readonly expect: { readonly commit: ExpectedObject; readonly firstSubscriber: ReadonlyArray; @@ -156,7 +156,7 @@ interface MutationAttempt { interface IsolationVector extends VectorBase { readonly kind: "isolation"; readonly operations: ReadonlyArray; - readonly metadata: ProjectionMetadata; + readonly metadata: JSONDocumentMetadata; readonly nextOperations: ReadonlyArray; readonly mutations: { readonly initial: MutationAttempt; @@ -188,15 +188,15 @@ interface NonJSONVector extends VectorBase { }; } -interface ReentrantPublicationVector extends VectorBase { - readonly kind: "reentrant-publication"; +interface ReentrantNotificationVector extends VectorBase { + readonly kind: "reentrant-notification"; readonly outer: { readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; }; readonly nested: { readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; }; readonly expect: { readonly outer: ExpectedObject; @@ -210,7 +210,7 @@ interface ReentrantPublicationVector extends VectorBase { interface SubscriberErrorVector extends VectorBase { readonly kind: "subscriber-error"; readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; readonly expect: { readonly commit: ExpectedObject; readonly delivered: ReadonlyArray; @@ -218,29 +218,29 @@ interface SubscriberErrorVector extends VectorBase { }; } -type ProjectionVector = +type JSONDocumentVector = | SurfaceVector | ReadVector | CommitVector | UnsubscribeVector - | UnsubscribeDuringPublicationVector + | UnsubscribeDuringNotificationVector | IsolationVector | NonJSONVector - | ReentrantPublicationVector + | ReentrantNotificationVector | SubscriberErrorVector; -interface ProjectionManifest { +interface JSONDocumentManifest { readonly initial: JSONValue; - readonly vectors: ReadonlyArray; + readonly vectors: ReadonlyArray; } interface Observation { - readonly changes: ProjectionChange[]; + readonly changes: JSONDocumentChange[]; readonly values: JSONValue[]; readonly unsubscribe: () => void; } -const manifest = rawVectors as unknown as ProjectionManifest; +const manifest = rawVectors as unknown as JSONDocumentManifest; function cloneJSON(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -250,18 +250,18 @@ function expectRequired(actual: unknown, expected: ExpectedObject): void { expect(actual).toMatchObject(expected); } -function observe(projection: Projection): Observation { - const changes: ProjectionChange[] = []; +function observe(document: JSONDocument): Observation { + const changes: JSONDocumentChange[] = []; const values: JSONValue[] = []; - const unsubscribe = projection.subscribe((change) => { + const unsubscribe = document.subscribe((change) => { changes.push(change); - values.push(cloneJSON(projection.value)); + values.push(cloneJSON(document.value)); }); return { changes, values, unsubscribe }; } function expectNotifications( - actual: ReadonlyArray, + actual: ReadonlyArray, expected: ReadonlyArray, ): void { expect(actual).toHaveLength(expected.length); @@ -295,11 +295,11 @@ function attemptMutation( } function runSurfaceVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: SurfaceVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const surface = projection as unknown as Record; + const document = harness.create("json", cloneJSON(manifest.initial)); + const surface = document as unknown as Record; for (const member of vector.members) { expect(member in surface).toBe(true); if (member !== "value") expect(typeof surface[member]).toBe("function"); @@ -307,46 +307,46 @@ function runSurfaceVector( } function runReadVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: ReadVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const before = cloneJSON(projection.value); - const observation = observe(projection); + const document = harness.create("json", cloneJSON(manifest.initial)); + const before = cloneJSON(document.value); + const observation = observe(document); for (const row of vector.at) { - expectRequired(projection.at(row.pointer), row.expect); + expectRequired(document.at(row.pointer), row.expect); } for (const row of vector.query) { - expectRequired(projection.query(row.jsonPath), row.expect); + expectRequired(document.query(row.jsonPath), row.expect); } - expect(projection.value).toEqual(before); + expect(document.value).toEqual(before); expect(observation.changes).toEqual([]); } function runCommitVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: CommitVector, ): void { - const projection = harness.create( - vector.acceptance ?? "json", + const document = harness.create( + vector.validation ?? "json", cloneJSON(manifest.initial), ); - const observation = observe(projection); - const beforeProbe = cloneJSON(projection.value); + const observation = observe(document); + const beforeProbe = cloneJSON(document.value); const operations = cloneJSON(vector.operations); - const probe = projection.canPatch(operations); + const probe = document.validatePatch(operations); expectRequired(probe, vector.expect.probe); - expect(projection.value).toEqual(beforeProbe); + expect(document.value).toEqual(beforeProbe); expect(observation.changes).toEqual([]); - const commit = projection.commit( + const commit = document.commit( operations, vector.metadata === undefined ? undefined : { metadata: cloneJSON(vector.metadata) }, ); expectRequired(commit, vector.expect.commit); - expect(projection.value).toEqual(vector.expect.value); + expect(document.value).toEqual(vector.expect.value); expectNotifications(observation.changes, vector.expect.notifications); for (const observedValue of observation.values) { expect(observedValue).toEqual(vector.expect.value); @@ -355,13 +355,13 @@ function runCommitVector( } function runUnsubscribeVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: UnsubscribeVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const observation = observe(projection); + const document = harness.create("json", cloneJSON(manifest.initial)); + const observation = observe(document); for (const [index, row] of vector.commits.entries()) { - const result = projection.commit( + const result = document.commit( cloneJSON(row.operations), row.metadata === undefined ? undefined @@ -370,28 +370,28 @@ function runUnsubscribeVector( expectRequired(result, row.expect); if (index === vector.unsubscribeAfter) observation.unsubscribe(); } - expect(projection.value).toEqual(vector.expect.value); + expect(document.value).toEqual(vector.expect.value); expectNotifications(observation.changes, vector.expect.notifications); } -function runUnsubscribeDuringPublicationVector( - harness: ProjectionHarness, - vector: UnsubscribeDuringPublicationVector, +function runUnsubscribeDuringNotificationVector( + harness: JSONDocumentHarness, + vector: UnsubscribeDuringNotificationVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const firstSubscriber: ProjectionChange[] = []; - const secondSubscriber: ProjectionChange[] = []; + const document = harness.create("json", cloneJSON(manifest.initial)); + const firstSubscriber: JSONDocumentChange[] = []; + const secondSubscriber: JSONDocumentChange[] = []; let unsubscribeSecond = (): void => undefined; - projection.subscribe((change) => { + document.subscribe((change) => { firstSubscriber.push(change); unsubscribeSecond(); }); - unsubscribeSecond = projection.subscribe((change) => { + unsubscribeSecond = document.subscribe((change) => { secondSubscriber.push(change); }); - const result = projection.commit( + const result = document.commit( cloneJSON(vector.operations), commitOptions(vector.metadata), ); @@ -399,28 +399,28 @@ function runUnsubscribeDuringPublicationVector( expectRequired(result, vector.expect.commit); expectNotifications(firstSubscriber, vector.expect.firstSubscriber); expectNotifications(secondSubscriber, vector.expect.secondSubscriber); - expect(projection.value).toEqual(vector.expect.value); + expect(document.value).toEqual(vector.expect.value); } function runIsolationVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: IsolationVector, ): void { const initial = cloneJSON(manifest.initial); - const projection = harness.create("json", initial); - const previousSnapshot = projection.value; + const document = harness.create("json", initial); + const previousSnapshot = document.value; attemptMutation(initial, vector.mutations.initial); - expect(projection.value).toEqual(vector.expect.previousSnapshot); + expect(document.value).toEqual(vector.expect.previousSnapshot); const operations = cloneJSON(vector.operations); const metadata = cloneJSON(vector.metadata); - const observation = observe(projection); - const first = projection.commit(operations, { metadata }); + const observation = observe(document); + const first = document.commit(operations, { metadata }); expectRequired(first, { ok: true }); - expect(projection.value).toEqual(vector.expect.afterFirst); + expect(document.value).toEqual(vector.expect.afterFirst); expectNotifications(observation.changes, [vector.expect.firstChange]); - const read = projection.at(vector.readPointer); + const read = document.at(vector.readPointer); expectRequired(read, { ok: true, path: vector.readPointer }); attemptMutation(operations, vector.mutations.operations); attemptMutation(metadata, vector.mutations.metadata); @@ -428,24 +428,24 @@ function runIsolationVector( if (read.ok) attemptMutation(read.value, vector.mutations.read); attemptMutation(observation.changes[0], vector.mutations.change); - expect(projection.value).toEqual(vector.expect.afterFirst); + expect(document.value).toEqual(vector.expect.afterFirst); expect(previousSnapshot).toEqual(vector.expect.previousSnapshot); expectRequired(observation.changes[0], vector.expect.firstChange); - const second = projection.commit(cloneJSON(vector.nextOperations)); + const second = document.commit(cloneJSON(vector.nextOperations)); expectRequired(second, { ok: true }); - expect(projection.value).toEqual(vector.expect.afterSecond); + expect(document.value).toEqual(vector.expect.afterSecond); expect(previousSnapshot).toEqual(vector.expect.previousSnapshot); expectRequired(observation.changes[0], vector.expect.firstChange); expect(observation.changes).toHaveLength(vector.expect.notificationCount); } function runNonJSONVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: NonJSONVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const observation = observe(projection); + const document = harness.create("json", cloneJSON(manifest.initial)); + const observation = observe(document); const hostValue = vector.hostValue === "callable" ? () => undefined : undefined; @@ -455,46 +455,46 @@ function runNonJSONVector( value: hostValue, }] as unknown as ReadonlyArray; - const probe = projection.canPatch(operations); + const probe = document.validatePatch(operations); expectRequired(probe, vector.expect.probe); - const commit = projection.commit(operations); + const commit = document.commit(operations); expectRequired(commit, vector.expect.commit); if (!probe.ok && !commit.ok) expect(commit.code).toBe(probe.code); - expect(projection.value).toEqual(vector.expect.value); + expect(document.value).toEqual(vector.expect.value); expectNotifications(observation.changes, vector.expect.notifications); } function commitOptions( - metadata: ProjectionMetadata | undefined, -): ProjectionCommitOptions | undefined { + metadata: JSONDocumentMetadata | undefined, +): JSONDocumentCommitOptions | undefined { return metadata === undefined ? undefined : { metadata: cloneJSON(metadata) }; } -function runReentrantPublicationVector( - harness: ProjectionHarness, - vector: ReentrantPublicationVector, +function runReentrantNotificationVector( + harness: JSONDocumentHarness, + vector: ReentrantNotificationVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const firstSubscriber: ProjectionChange[] = []; - const secondSubscriber: ProjectionChange[] = []; - let nestedResult: ProjectionCommitResult | undefined; + const document = harness.create("json", cloneJSON(manifest.initial)); + const firstSubscriber: JSONDocumentChange[] = []; + const secondSubscriber: JSONDocumentChange[] = []; + let nestedResult: JSONDocumentCommitResult | undefined; - projection.subscribe((change) => { + document.subscribe((change) => { firstSubscriber.push(change); if (firstSubscriber.length === 1) { - nestedResult = projection.commit( + nestedResult = document.commit( cloneJSON(vector.nested.operations), commitOptions(vector.nested.metadata), ); } }); - projection.subscribe((change) => { + document.subscribe((change) => { secondSubscriber.push(change); }); - const outerResult = projection.commit( + const outerResult = document.commit( cloneJSON(vector.outer.operations), commitOptions(vector.outer.metadata), ); @@ -509,38 +509,38 @@ function runReentrantPublicationVector( secondSubscriber, vector.expect.secondSubscriber, ); - expect(projection.value).toEqual(vector.expect.value); + expect(document.value).toEqual(vector.expect.value); } function runSubscriberErrorVector( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vector: SubscriberErrorVector, ): void { - const projection = harness.create("json", cloneJSON(manifest.initial)); - const delivered: ProjectionChange[] = []; - let commitResult: ProjectionCommitResult | undefined; + const document = harness.create("json", cloneJSON(manifest.initial)); + const delivered: JSONDocumentChange[] = []; + let commitResult: JSONDocumentCommitResult | undefined; - projection.subscribe(() => { + document.subscribe(() => { throw new Error("subscriber failed"); }); - projection.subscribe((change) => { + document.subscribe((change) => { delivered.push(change); }); expect(() => { - commitResult = projection.commit( + commitResult = document.commit( cloneJSON(vector.operations), commitOptions(vector.metadata), ); }).not.toThrow(); expectRequired(commitResult, vector.expect.commit); expectNotifications(delivered, vector.expect.delivered); - expect(projection.value).toEqual(vector.expect.value); + expect(document.value).toEqual(vector.expect.value); } function runVector( - harness: ProjectionHarness, - vector: ProjectionVector, + harness: JSONDocumentHarness, + vector: JSONDocumentVector, ): void { switch (vector.kind) { case "surface": @@ -555,8 +555,8 @@ function runVector( case "unsubscribe": runUnsubscribeVector(harness, vector); return; - case "unsubscribe-during-publication": - runUnsubscribeDuringPublicationVector(harness, vector); + case "unsubscribe-during-notification": + runUnsubscribeDuringNotificationVector(harness, vector); return; case "isolation": runIsolationVector(harness, vector); @@ -564,18 +564,18 @@ function runVector( case "non-json": runNonJSONVector(harness, vector); return; - case "reentrant-publication": - runReentrantPublicationVector(harness, vector); + case "reentrant-notification": + runReentrantNotificationVector(harness, vector); return; case "subscriber-error": runSubscriberErrorVector(harness, vector); } } -export function runProjectionConformance( - harness: ProjectionHarness, +export function runJSONDocumentConformance( + harness: JSONDocumentHarness, ): void { - describe("json-document v2 Projection black-box conformance", () => { + describe("json-document v3 JSONDocument black-box conformance", () => { for (const vector of manifest.vectors) { test(`[${vector.id}]`, () => runVector(harness, vector)); } diff --git a/packages/json-document/tests/conformance/v2/projection-vectors.json b/packages/json-document/tests/conformance/v3/json-document-vectors.json similarity index 89% rename from packages/json-document/tests/conformance/v2/projection-vectors.json rename to packages/json-document/tests/conformance/v3/json-document-vectors.json index d69e06ae..4025fa56 100644 --- a/packages/json-document/tests/conformance/v2/projection-vectors.json +++ b/packages/json-document/tests/conformance/v3/json-document-vectors.json @@ -1,12 +1,12 @@ { "formatVersion": 1, "status": "stable", - "profile": "docs/standard/v2-projection-profile.md", - "projectionMembers": [ + "profile": "docs/standard/v3-json-document-profile.md", + "documentMembers": [ "value", "at", "query", - "canPatch", + "validatePatch", "commit", "subscribe" ], @@ -33,16 +33,16 @@ }, "vectors": [ { - "id": "projection-members", + "id": "document-members", "kind": "surface", "requirements": [ - "JD2-PROJECTION-001" + "JD3-DOCUMENT-001" ], "members": [ "value", "at", "query", - "canPatch", + "validatePatch", "commit", "subscribe" ] @@ -51,9 +51,9 @@ "id": "pointer-read-and-jsonpath-query", "kind": "read", "requirements": [ - "JD2-ADDRESS-001", - "JD2-QUERY-001", - "JD2-RESULT-001" + "JD3-ADDRESS-001", + "JD3-QUERY-001", + "JD3-RESULT-001" ], "at": [ { @@ -103,16 +103,16 @@ ] }, { - "id": "ordered-commit-and-publication", + "id": "ordered-commit-and-notification", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-CAPABILITY-001", - "JD2-COMMIT-001", - "JD2-COMMIT-002", - "JD2-PUBLISH-001" + "JD3-PATCH-001", + "JD3-VALIDATION-001", + "JD3-COMMIT-001", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001" ], - "acceptance": "task-list", + "validation": "task-list", "operations": [ { "op": "replace", @@ -224,11 +224,11 @@ "id": "failed-batch-is-atomic", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-CAPABILITY-001", - "JD2-COMMIT-001", - "JD2-PUBLISH-001", - "JD2-RESULT-001" + "JD3-PATCH-001", + "JD3-VALIDATION-001", + "JD3-COMMIT-001", + "JD3-NOTIFICATION-001", + "JD3-RESULT-001" ], "operations": [ { @@ -279,10 +279,10 @@ "id": "jsonpath-is-not-a-mutation-target", "kind": "commit", "requirements": [ - "JD2-ADDRESS-001", - "JD2-CAPABILITY-001", - "JD2-COMMIT-001", - "JD2-RESULT-001" + "JD3-ADDRESS-001", + "JD3-VALIDATION-001", + "JD3-COMMIT-001", + "JD3-RESULT-001" ], "operations": [ { @@ -320,15 +320,15 @@ } }, { - "id": "acceptance-rejection-has-probe-commit-parity", + "id": "validation-rejection-has-probe-commit-parity", "kind": "commit", "requirements": [ - "JD2-CAPABILITY-001", - "JD2-COMMIT-001", - "JD2-RESULT-001", - "JD2-SCHEMA-001" + "JD3-VALIDATION-001", + "JD3-COMMIT-001", + "JD3-RESULT-001", + "JD3-SCHEMA-001" ], - "acceptance": "task-list", + "validation": "task-list", "operations": [ { "op": "replace", @@ -365,12 +365,12 @@ } }, { - "id": "state-equivalent-commit-does-not-publish", + "id": "state-equivalent-commit-does-not-notify", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-COMMIT-002", - "JD2-PUBLISH-001" + "JD3-PATCH-001", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -409,12 +409,12 @@ } }, { - "id": "acceptance-cannot-transform-the-candidate", + "id": "validation-cannot-transform-the-candidate", "kind": "commit", "requirements": [ - "JD2-SCHEMA-002" + "JD3-SCHEMA-002" ], - "acceptance": "attempt-transform", + "validation": "attempt-transform", "operations": [ { "op": "replace", @@ -468,11 +468,11 @@ } }, { - "id": "same-scalar-replace-does-not-publish", + "id": "same-scalar-replace-does-not-notify", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-PUBLISH-001" + "JD3-PATCH-001", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -511,11 +511,11 @@ } }, { - "id": "same-object-replace-does-not-publish", + "id": "same-object-replace-does-not-notify", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-PUBLISH-001" + "JD3-PATCH-001", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -556,12 +556,12 @@ } }, { - "id": "canceling-batch-does-not-publish", + "id": "canceling-batch-does-not-notify", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-COMMIT-001", - "JD2-PUBLISH-001" + "JD3-PATCH-001", + "JD3-COMMIT-001", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -605,11 +605,11 @@ } }, { - "id": "same-path-move-does-not-publish", + "id": "same-path-move-does-not-notify", "kind": "commit", "requirements": [ - "JD2-PATCH-001", - "JD2-PUBLISH-001" + "JD3-PATCH-001", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -648,12 +648,12 @@ } }, { - "id": "unsubscribe-stops-future-publication", + "id": "unsubscribe-stops-future-notification", "kind": "unsubscribe", "requirements": [ - "JD2-COMMIT-001", - "JD2-COMMIT-002", - "JD2-PUBLISH-001" + "JD3-COMMIT-001", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001" ], "commits": [ { @@ -746,11 +746,11 @@ } }, { - "id": "unsubscribe-during-publication-stops-current-delivery", - "kind": "unsubscribe-during-publication", + "id": "unsubscribe-during-notification-stops-current-delivery", + "kind": "unsubscribe-during-notification", "requirements": [ - "JD2-COMMIT-001", - "JD2-PUBLISH-001" + "JD3-COMMIT-001", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -760,7 +760,7 @@ } ], "metadata": { - "origin": "intra-publication-unsubscribe" + "origin": "intra-notification-unsubscribe" }, "expect": { "commit": { @@ -774,7 +774,7 @@ } ], "metadata": { - "origin": "intra-publication-unsubscribe" + "origin": "intra-notification-unsubscribe" } } }, @@ -788,7 +788,7 @@ } ], "metadata": { - "origin": "intra-publication-unsubscribe" + "origin": "intra-notification-unsubscribe" } } ], @@ -815,9 +815,9 @@ "id": "references-are-isolated", "kind": "isolation", "requirements": [ - "JD2-DATA-002", - "JD2-COMMIT-001", - "JD2-PUBLISH-001" + "JD3-DATA-002", + "JD3-COMMIT-001", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -951,10 +951,10 @@ "id": "non-json-payload-is-rejected", "kind": "non-json", "requirements": [ - "JD2-DATA-001", - "JD2-CAPABILITY-001", - "JD2-COMMIT-001", - "JD2-RESULT-001" + "JD3-DATA-001", + "JD3-VALIDATION-001", + "JD3-COMMIT-001", + "JD3-RESULT-001" ], "path": "/meta/callback", "hostValue": "callable", @@ -987,12 +987,12 @@ } }, { - "id": "reentrant-publication-preserves-causal-order", - "kind": "reentrant-publication", + "id": "reentrant-notification-preserves-causal-order", + "kind": "reentrant-notification", "requirements": [ - "JD2-COMMIT-001", - "JD2-COMMIT-002", - "JD2-PUBLISH-001" + "JD3-COMMIT-001", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001" ], "outer": { "operations": [ @@ -1123,9 +1123,9 @@ "id": "subscriber-errors-do-not-escape-commit", "kind": "subscriber-error", "requirements": [ - "JD2-COMMIT-001", - "JD2-COMMIT-002", - "JD2-PUBLISH-001" + "JD3-COMMIT-001", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001" ], "operations": [ { @@ -1188,32 +1188,32 @@ ], "nonRuntimeRequirements": [ { - "id": "JD2-GOV-001", + "id": "JD3-GOV-001", "mode": "static", "reason": "The standard evaluator locks the profile, manifest, vectors, and reference test together." }, { - "id": "JD2-RESULT-002", + "id": "JD3-RESULT-002", "mode": "static", "reason": "The generic runner matches required fields only and never exact result keys." }, { - "id": "JD2-SESSION-001", + "id": "JD3-SESSION-001", "mode": "static", - "reason": "The generic runner depends on only the six Projection members." + "reason": "The generic runner depends on only the six JSON Document members." }, { - "id": "JD2-HOST-001", + "id": "JD3-HOST-001", "mode": "static", "reason": "The generic runner has no DOM, geometry, clipboard, storage, network, formula, CRDT, or OT dependency." }, { - "id": "JD2-CONFORMANCE-001", + "id": "JD3-CONFORMANCE-001", "mode": "static", "reason": "The vectors run through an injected structural harness without private implementation imports." }, { - "id": "JD2-BINDING-001", + "id": "JD3-BINDING-001", "mode": "static", "reason": "Both reference bindings import only the public package root." } diff --git a/packages/json-document/tests/conformance/v2/jsonpath-suite.ts b/packages/json-document/tests/conformance/v3/jsonpath-suite.ts similarity index 90% rename from packages/json-document/tests/conformance/v2/jsonpath-suite.ts rename to packages/json-document/tests/conformance/v3/jsonpath-suite.ts index fc04f860..9598977f 100644 --- a/packages/json-document/tests/conformance/v2/jsonpath-suite.ts +++ b/packages/json-document/tests/conformance/v3/jsonpath-suite.ts @@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest"; import cts from "../../public/jsonpath/conformance/jsonpath-cts.json" with { type: "json" }; import type { JSONValue } from "./protocol-suite.js"; import type { - ProjectionQueryResult, - ProjectionReadResult, -} from "./projection-suite.js"; + JSONDocumentQueryResult, + JSONDocumentReadResult, +} from "./json-document-suite.js"; interface JSONPathCase { readonly name: string; @@ -24,13 +24,13 @@ interface JSONPathFailure { readonly tags?: ReadonlyArray; } -interface JSONPathProjection { - at(pointer: string): ProjectionReadResult; - query(jsonPath: string): ProjectionQueryResult; +interface JSONPathJSONDocument { + at(pointer: string): JSONDocumentReadResult; + query(jsonPath: string): JSONDocumentQueryResult; } export interface JSONPathHarness { - create(initial: JSONValue): JSONPathProjection; + create(initial: JSONValue): JSONPathJSONDocument; } const EXPECTED_TOTAL = 703; @@ -65,17 +65,17 @@ const pointerCases = [ }>; export function runJSONPathConformance(harness: JSONPathHarness): void { - describe("json-document v2 RFC 9535 JSONPath CTS black-box conformance", () => { + describe("json-document v3 RFC 9535 JSONPath CTS black-box conformance", () => { test("full conformance does not regress", () => { const failures: JSONPathFailure[] = []; let invalidSelectors = 0; let passed = 0; for (const testCase of suite.tests) { - const projection = harness.create( + const document = harness.create( "document" in testCase ? testCase.document ?? null : null, ); - const queried = projection.query(testCase.selector); + const queried = document.query(testCase.selector); if (testCase.invalid_selector) { invalidSelectors += 1; if (!queried.ok) { @@ -95,7 +95,7 @@ export function runJSONPathConformance(harness: JSONPathHarness): void { } const result = queried.pointers.map((pointer) => { - const read = projection.at(pointer); + const read = document.at(pointer); if (!read.ok) { throw new Error(`query returned unreadable pointer: ${pointer}`); } diff --git a/packages/json-document/tests/conformance/v2/pointer-suite.ts b/packages/json-document/tests/conformance/v3/pointer-suite.ts similarity index 98% rename from packages/json-document/tests/conformance/v2/pointer-suite.ts rename to packages/json-document/tests/conformance/v3/pointer-suite.ts index da29bf0c..844e88c0 100644 --- a/packages/json-document/tests/conformance/v2/pointer-suite.ts +++ b/packages/json-document/tests/conformance/v3/pointer-suite.ts @@ -56,7 +56,7 @@ interface PointerManifest { const manifest = rawVectors as unknown as PointerManifest; export function runPointerConformance(harness: PointerHarness): void { - describe("json-document v2 Pointer black-box conformance", () => { + describe("json-document v3 Pointer black-box conformance", () => { for (const vector of manifest.parse) { test(`[${vector.id}]`, () => { expect(harness.parsePointer(vector.pointer)).toEqual(vector.segments); diff --git a/packages/json-document/tests/conformance/v2/pointer-vectors.json b/packages/json-document/tests/conformance/v3/pointer-vectors.json similarity index 97% rename from packages/json-document/tests/conformance/v2/pointer-vectors.json rename to packages/json-document/tests/conformance/v3/pointer-vectors.json index dcceeda2..080573a9 100644 --- a/packages/json-document/tests/conformance/v2/pointer-vectors.json +++ b/packages/json-document/tests/conformance/v3/pointer-vectors.json @@ -1,8 +1,8 @@ { "formatVersion": 1, "status": "stable", - "profile": "docs/standard/v2-projection-profile.md", - "requirements": ["JD2-ADDRESS-001", "JD2-PATCH-001"], + "profile": "docs/standard/v3-json-document-profile.md", + "requirements": ["JD3-ADDRESS-001", "JD3-PATCH-001"], "parse": [ { "id": "pointer-root", "pointer": "", "segments": [] }, { "id": "pointer-empty-member", "pointer": "/", "segments": [""] }, diff --git a/packages/json-document/tests/conformance/v2/pressure-suite.ts b/packages/json-document/tests/conformance/v3/pressure-suite.ts similarity index 72% rename from packages/json-document/tests/conformance/v2/pressure-suite.ts rename to packages/json-document/tests/conformance/v3/pressure-suite.ts index d50e5541..61b72fa9 100644 --- a/packages/json-document/tests/conformance/v2/pressure-suite.ts +++ b/packages/json-document/tests/conformance/v3/pressure-suite.ts @@ -3,18 +3,18 @@ import { describe, expect, test } from "vitest"; import type { JSONPatchOperation, JSONValue, - ProjectionAcceptance, - ProjectionChange, - ProjectionHarness, - ProjectionMetadata, -} from "./projection-suite.js"; + JSONDocumentValidation, + JSONDocumentChange, + JSONDocumentHarness, + JSONDocumentMetadata, +} from "./json-document-suite.js"; import rawVectors from "./pressure-vectors.json" with { type: "json" }; type ExpectedObject = Readonly>; interface PressureStep { readonly operations: ReadonlyArray; - readonly metadata?: ProjectionMetadata; + readonly metadata?: JSONDocumentMetadata; readonly expect: { readonly probe: ExpectedObject; readonly commit: ExpectedObject; @@ -26,7 +26,7 @@ interface PressureStep { interface PressureVertical { readonly id: string; readonly requirements: ReadonlyArray; - readonly acceptance: ProjectionAcceptance; + readonly validation: JSONDocumentValidation; readonly initial: JSONValue; readonly steps: ReadonlyArray; readonly replay?: boolean; @@ -47,9 +47,9 @@ interface PressureManifest { const manifest = rawVectors as unknown as PressureManifest; export function runPressureConformance( - harness: ProjectionHarness, + harness: JSONDocumentHarness, ): void { - describe("json-document v2 pressure verticals", () => { + describe("json-document v3 pressure verticals", () => { for (const vertical of manifest.verticals) { test(`[${vertical.id}]`, () => runVertical(harness, vertical)); } @@ -57,13 +57,13 @@ export function runPressureConformance( } function runVertical( - harness: ProjectionHarness, + harness: JSONDocumentHarness, vertical: PressureVertical, ): void { const initial = cloneJSON(vertical.initial); - const projection = harness.create(vertical.acceptance, initial); - const changes: ProjectionChange[] = []; - projection.subscribe((change) => { + const document = harness.create(vertical.validation, initial); + const changes: JSONDocumentChange[] = []; + document.subscribe((change) => { changes.push(cloneJSON(change)); }); @@ -72,26 +72,26 @@ function runVertical( const metadata = step.metadata === undefined ? undefined : cloneJSON(step.metadata); - const probe = projection.canPatch(operations); + const probe = document.validatePatch(operations); expect(probe).toMatchObject(step.expect.probe); - const committed = projection.commit( + const committed = document.commit( operations, metadata === undefined ? undefined : { metadata }, ); expect(committed).toMatchObject(step.expect.commit); - expect(projection.value).toEqual(step.expect.value); + expect(document.value).toEqual(step.expect.value); expect(changes).toHaveLength(step.expect.notificationCount); } for (const read of vertical.reads ?? []) { - expect(projection.at(read.pointer)).toMatchObject(read.expect); + expect(document.at(read.pointer)).toMatchObject(read.expect); } for (const query of vertical.queries ?? []) { - expect(projection.query(query.jsonPath)).toMatchObject(query.expect); + expect(document.query(query.jsonPath)).toMatchObject(query.expect); } if (vertical.replay === true) { - const replay = harness.create(vertical.acceptance, cloneJSON(vertical.initial)); + const replay = harness.create(vertical.validation, cloneJSON(vertical.initial)); for (const change of changes) { const result = replay.commit( cloneJSON(change.applied), @@ -101,7 +101,7 @@ function runVertical( ); expect(result).toMatchObject({ ok: true }); } - expect(replay.value).toEqual(projection.value); + expect(replay.value).toEqual(document.value); } } diff --git a/packages/json-document/tests/conformance/v2/pressure-vectors.json b/packages/json-document/tests/conformance/v3/pressure-vectors.json similarity index 94% rename from packages/json-document/tests/conformance/v2/pressure-vectors.json rename to packages/json-document/tests/conformance/v3/pressure-vectors.json index 4c374c67..2f727c7f 100644 --- a/packages/json-document/tests/conformance/v2/pressure-vectors.json +++ b/packages/json-document/tests/conformance/v3/pressure-vectors.json @@ -1,15 +1,15 @@ { "formatVersion": 1, "status": "stable", - "profile": "docs/standard/v2-projection-profile.md", + "profile": "docs/standard/v3-json-document-profile.md", "verticals": [ { "id": "form", "requirements": [ - "JD2-SCHEMA-001", - "JD2-CONFORMANCE-002" + "JD3-SCHEMA-001", + "JD3-CONFORMANCE-002" ], - "acceptance": "task-list", + "validation": "task-list", "initial": { "title": "Draft", "items": [ @@ -70,10 +70,10 @@ { "id": "table-data-grid", "requirements": [ - "JD2-PATCH-001", - "JD2-CONFORMANCE-002" + "JD3-PATCH-001", + "JD3-CONFORMANCE-002" ], - "acceptance": "json", + "validation": "json", "initial": { "rows": [ { "id": "a", "cells": { "score": 1 } }, @@ -156,11 +156,11 @@ { "id": "outliner-tree", "requirements": [ - "JD2-ADDRESS-001", - "JD2-PATCH-001", - "JD2-CONFORMANCE-002" + "JD3-ADDRESS-001", + "JD3-PATCH-001", + "JD3-CONFORMANCE-002" ], - "acceptance": "json", + "validation": "json", "initial": { "nodes": [ { @@ -234,10 +234,10 @@ { "id": "rich-text", "requirements": [ - "JD2-COMMIT-002", - "JD2-CONFORMANCE-002" + "JD3-COMMIT-002", + "JD3-CONFORMANCE-002" ], - "acceptance": "json", + "validation": "json", "initial": { "doc": { "blocks": [ @@ -301,12 +301,12 @@ { "id": "storage-collaboration", "requirements": [ - "JD2-DATA-002", - "JD2-COMMIT-002", - "JD2-PUBLISH-001", - "JD2-CONFORMANCE-002" + "JD3-DATA-002", + "JD3-COMMIT-002", + "JD3-NOTIFICATION-001", + "JD3-CONFORMANCE-002" ], - "acceptance": "json", + "validation": "json", "initial": { "counter": 0, "notes": [] diff --git a/packages/json-document/tests/conformance/v2/protocol-suite.ts b/packages/json-document/tests/conformance/v3/protocol-suite.ts similarity index 99% rename from packages/json-document/tests/conformance/v2/protocol-suite.ts rename to packages/json-document/tests/conformance/v3/protocol-suite.ts index 09ec9dea..542eff5f 100644 --- a/packages/json-document/tests/conformance/v2/protocol-suite.ts +++ b/packages/json-document/tests/conformance/v3/protocol-suite.ts @@ -342,7 +342,7 @@ function runVector( export function runProtocolConformance( harness: ProtocolHarness, ): void { - describe("json-document v2 pure Protocol black-box conformance", () => { + describe("json-document v3 pure Protocol black-box conformance", () => { for (const vector of manifest.vectors) { test(`[${vector.id}]`, () => runVector(harness, vector)); } diff --git a/packages/json-document/tests/conformance/v2/protocol-vectors.json b/packages/json-document/tests/conformance/v3/protocol-vectors.json similarity index 92% rename from packages/json-document/tests/conformance/v2/protocol-vectors.json rename to packages/json-document/tests/conformance/v3/protocol-vectors.json index 14c40657..4d73de02 100644 --- a/packages/json-document/tests/conformance/v2/protocol-vectors.json +++ b/packages/json-document/tests/conformance/v3/protocol-vectors.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "status": "stable", - "profile": "docs/standard/v2-projection-profile.md", + "profile": "docs/standard/v3-json-document-profile.md", "function": "applyPatch", "initial": { "title": "Draft", @@ -24,9 +24,9 @@ "id": "ordered-patch-canonicalizes-append", "kind": "patch", "requirements": [ - "JD2-PATCH-001", - "JD2-PATCH-002", - "JD2-RESULT-001" + "JD3-PATCH-001", + "JD3-PATCH-002", + "JD3-RESULT-001" ], "operations": [ { @@ -98,8 +98,8 @@ "id": "mid-batch-failure-is-atomic", "kind": "patch", "requirements": [ - "JD2-PATCH-001", - "JD2-RESULT-001" + "JD3-PATCH-001", + "JD3-RESULT-001" ], "operations": [ { @@ -128,9 +128,9 @@ "id": "jsonpath-is-not-a-patch-target", "kind": "patch", "requirements": [ - "JD2-ADDRESS-001", - "JD2-PATCH-002", - "JD2-RESULT-001" + "JD3-ADDRESS-001", + "JD3-PATCH-002", + "JD3-RESULT-001" ], "operations": [ { @@ -149,8 +149,8 @@ "id": "successful-test-is-preserved", "kind": "patch", "requirements": [ - "JD2-PATCH-001", - "JD2-PATCH-002" + "JD3-PATCH-001", + "JD3-PATCH-002" ], "operations": [ { @@ -192,8 +192,8 @@ "id": "same-value-pure-operation-is-preserved", "kind": "patch", "requirements": [ - "JD2-PATCH-001", - "JD2-PATCH-002" + "JD3-PATCH-001", + "JD3-PATCH-002" ], "operations": [ { @@ -235,8 +235,8 @@ "id": "canonical-operation-drops-extra-fields", "kind": "extra-fields", "requirements": [ - "JD2-PATCH-001", - "JD2-DATA-001" + "JD3-PATCH-001", + "JD3-DATA-001" ], "operations": [ { @@ -287,9 +287,9 @@ "id": "non-json-state-is-rejected", "kind": "non-json-state", "requirements": [ - "JD2-DATA-001", - "JD2-PATCH-002", - "JD2-RESULT-001" + "JD3-DATA-001", + "JD3-PATCH-002", + "JD3-RESULT-001" ], "property": "callback", "hostValue": "callable", @@ -302,9 +302,9 @@ "id": "non-json-payload-is-rejected", "kind": "non-json-payload", "requirements": [ - "JD2-DATA-001", - "JD2-PATCH-002", - "JD2-RESULT-001" + "JD3-DATA-001", + "JD3-PATCH-002", + "JD3-RESULT-001" ], "operation": { "op": "add", @@ -320,9 +320,9 @@ "id": "earlier-runtime-failure-wins-over-later-callable", "kind": "precedence", "requirements": [ - "JD2-DATA-001", - "JD2-PATCH-001", - "JD2-RESULT-001" + "JD3-DATA-001", + "JD3-PATCH-001", + "JD3-RESULT-001" ], "operations": [ { @@ -346,8 +346,8 @@ "id": "input-operation-and-result-are-isolated", "kind": "isolation", "requirements": [ - "JD2-DATA-002", - "JD2-PATCH-002" + "JD3-DATA-002", + "JD3-PATCH-002" ], "operations": [ { diff --git a/packages/json-document/tests/conformance/v2/rfc6902-suite.ts b/packages/json-document/tests/conformance/v3/rfc6902-suite.ts similarity index 97% rename from packages/json-document/tests/conformance/v2/rfc6902-suite.ts rename to packages/json-document/tests/conformance/v3/rfc6902-suite.ts index 9875456e..277e4edf 100644 --- a/packages/json-document/tests/conformance/v2/rfc6902-suite.ts +++ b/packages/json-document/tests/conformance/v3/rfc6902-suite.ts @@ -30,7 +30,7 @@ const allCases = [ ]; export function runRFC6902Conformance(harness: RFC6902Harness): void { - describe("json-document v2 RFC 6902 vendor black-box conformance", () => { + describe("json-document v3 RFC 6902 vendor black-box conformance", () => { const disabled = allCases.filter((testCase) => testCase.disabled).length; for (const [index, testCase] of allCases.entries()) { diff --git a/packages/json-document/tests/foundation/durability-parity.test.ts b/packages/json-document/tests/foundation/durability-parity.test.ts index 4e637b80..05b3c34e 100644 --- a/packages/json-document/tests/foundation/durability-parity.test.ts +++ b/packages/json-document/tests/foundation/durability-parity.test.ts @@ -5,7 +5,7 @@ import { jsonEqual } from "../../src/foundation/json/equal.js"; import { jsonSerializableError } from "../../src/foundation/json/serializable.js"; import { cloneTrustedPlainJson } from "../../src/foundation/json/trusted-clone.js"; import { parseArrayIndex } from "../../src/foundation/pointer/array-index.js"; -import rawVectors from "../conformance/v2/foundation-vectors.json" with { type: "json" }; +import rawVectors from "../conformance/v3/foundation-vectors.json" with { type: "json" }; interface FoundationVectors { readonly arrayIndexes: ReadonlyArray<{ @@ -22,7 +22,7 @@ interface FoundationVectors { const vectors = rawVectors as FoundationVectors; -describe("v2 durability primitive parity", () => { +describe("v3 durability primitive parity", () => { test("one Core parser owns canonical array-index classification", () => { for (const vector of vectors.arrayIndexes) { expect(parseArrayIndex(vector.segment), vector.segment) diff --git a/packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts b/packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts deleted file mode 100644 index 9a792d1d..00000000 --- a/packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - runPressureConformance, -} from "../conformance/v2/pressure-suite.js"; -import { - runProjectionConformance, - type ProjectionHarness, -} from "../conformance/v2/projection-suite.js"; -import { createIndependentProjection } from "./v2-projection.js"; - -const independentHarness: ProjectionHarness = { - create: createIndependentProjection, -}; - -runProjectionConformance(independentHarness); -runPressureConformance(independentHarness); diff --git a/packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts b/packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts new file mode 100644 index 00000000..b43ee310 --- /dev/null +++ b/packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts @@ -0,0 +1,15 @@ +import { + runPressureConformance, +} from "../conformance/v3/pressure-suite.js"; +import { + runJSONDocumentConformance, + type JSONDocumentHarness, +} from "../conformance/v3/json-document-suite.js"; +import { createIndependentJSONDocument } from "./v3-json-document.js"; + +const independentHarness: JSONDocumentHarness = { + create: createIndependentJSONDocument, +}; + +runJSONDocumentConformance(independentHarness); +runPressureConformance(independentHarness); diff --git a/packages/json-document/tests/independent/v2-projection.ts b/packages/json-document/tests/independent/v3-json-document.ts similarity index 90% rename from packages/json-document/tests/independent/v2-projection.ts rename to packages/json-document/tests/independent/v3-json-document.ts index 33ac0a91..0b91b164 100644 --- a/packages/json-document/tests/independent/v2-projection.ts +++ b/packages/json-document/tests/independent/v3-json-document.ts @@ -1,16 +1,16 @@ import type { JSONPatchOperation, JSONValue, - Projection, - ProjectionAcceptance, - ProjectionCapabilityResult, - ProjectionChange, - ProjectionCommitOptions, - ProjectionCommitResult, - ProjectionMetadata, - ProjectionQueryResult, - ProjectionReadResult, -} from "../conformance/v2/projection-suite.js"; + JSONDocument, + JSONDocumentValidation, + JSONDocumentValidationResult, + JSONDocumentChange, + JSONDocumentCommitOptions, + JSONDocumentCommitResult, + JSONDocumentMetadata, + JSONDocumentQueryResult, + JSONDocumentReadResult, +} from "../conformance/v3/json-document-suite.js"; interface PreparedCommit { readonly value: JSONValue; @@ -22,21 +22,21 @@ interface OperationResult { readonly applied: JSONPatchOperation; } -export function createIndependentProjection( - acceptance: ProjectionAcceptance, +export function createIndependentJSONDocument( + validation: JSONDocumentValidation, initial: JSONValue, -): Projection { +): JSONDocument { let state = freezeJSON(cloneJSON(initial)); - accept(acceptance, state); + accept(validation, state); - const listeners = new Set<(change: ProjectionChange) => void>(); - const publicationQueue: ProjectionChange[] = []; + const listeners = new Set<(change: JSONDocumentChange) => void>(); + const notificationQueue: JSONDocumentChange[] = []; let publishing = false; - let evaluatingAcceptance = false; + let evaluatingValidation = false; const prepare = ( operations: ReadonlyArray, - ): PreparedCommit | ProjectionFailure => { + ): PreparedCommit | JSONDocumentFailure => { try { let value = cloneJSON(state); const applied: JSONPatchOperation[] = []; @@ -48,11 +48,11 @@ export function createIndependentProjection( ); } value = freezeJSON(value); - evaluatingAcceptance = true; + evaluatingValidation = true; try { - accept(acceptance, value); + accept(validation, value); } finally { - evaluatingAcceptance = false; + evaluatingValidation = false; } return { value, applied }; } catch (error) { @@ -60,11 +60,11 @@ export function createIndependentProjection( } }; - const projection: Projection = { + const document: JSONDocument = { get value() { return state; }, - at(pointer: string): ProjectionReadResult { + at(pointer: string): JSONDocumentReadResult { try { const value = readAt(state, parsePointer(pointer)); return Object.freeze({ ok: true, path: pointer, value }); @@ -75,7 +75,7 @@ export function createIndependentProjection( : Object.freeze({ ...failed, pointer }); } }, - query(jsonPath: string): ProjectionQueryResult { + query(jsonPath: string): JSONDocumentQueryResult { try { return Object.freeze({ ok: true, @@ -93,24 +93,24 @@ export function createIndependentProjection( }); } }, - canPatch( + validatePatch( operations: ReadonlyArray, - ): ProjectionCapabilityResult { - if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; + ): JSONDocumentValidationResult { + if (evaluatingValidation) return VALIDATION_REENTRANCY_FAILURE; const prepared = prepare(operations); return "ok" in prepared ? prepared : OK; }, commit( operations: ReadonlyArray, - options?: ProjectionCommitOptions, - ): ProjectionCommitResult { - if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; + options?: JSONDocumentCommitOptions, + ): JSONDocumentCommitResult { + if (evaluatingValidation) return VALIDATION_REENTRANCY_FAILURE; - let metadata: ProjectionMetadata | undefined; + let metadata: JSONDocumentMetadata | undefined; try { metadata = options?.metadata === undefined ? undefined - : freezeJSON(cloneJSON(options.metadata)) as ProjectionMetadata; + : freezeJSON(cloneJSON(options.metadata)) as JSONDocumentMetadata; } catch (error) { return failureFrom(error); } @@ -129,7 +129,7 @@ export function createIndependentProjection( publish(change); return Object.freeze({ ok: true, change }); }, - subscribe(listener: (change: ProjectionChange) => void): () => void { + subscribe(listener: (change: JSONDocumentChange) => void): () => void { listeners.add(listener); let active = true; return () => { @@ -140,22 +140,22 @@ export function createIndependentProjection( }, }; - return Object.freeze(projection); + return Object.freeze(document); - function publish(change: ProjectionChange): void { - publicationQueue.push(change); + function publish(change: JSONDocumentChange): void { + notificationQueue.push(change); if (publishing) return; publishing = true; try { - while (publicationQueue.length > 0) { - const next = publicationQueue.shift() as ProjectionChange; + while (notificationQueue.length > 0) { + const next = notificationQueue.shift() as JSONDocumentChange; for (const listener of [...listeners]) { if (!listeners.has(listener)) continue; try { listener(next); } catch { - // Publication is post-commit. A listener cannot roll state back or + // Notification is post-commit. A listener cannot roll state back or // prevent delivery to the remaining listeners. } } @@ -166,7 +166,7 @@ export function createIndependentProjection( } } -interface ProjectionFailure { +interface JSONDocumentFailure { readonly ok: false; readonly code: string; readonly reason?: string; @@ -184,13 +184,13 @@ class IndependentError extends Error { } const OK = Object.freeze({ ok: true as const }); -const ACCEPTANCE_REENTRANCY_FAILURE = Object.freeze({ +const VALIDATION_REENTRANCY_FAILURE = Object.freeze({ ok: false as const, code: "acceptance_reentrancy", - reason: "acceptance callback cannot call canPatch or commit", + reason: "validation callback cannot call validatePatch or commit", }); -function failureFrom(error: unknown): ProjectionFailure { +function failureFrom(error: unknown): JSONDocumentFailure { if (error instanceof IndependentError) { return Object.freeze({ ok: false, @@ -207,11 +207,11 @@ function failureFrom(error: unknown): ProjectionFailure { } function accept( - acceptance: ProjectionAcceptance, + validation: JSONDocumentValidation, candidate: JSONValue, ): void { - if (acceptance === "json") return; - if (acceptance === "attempt-transform") { + if (validation === "json") return; + if (validation === "attempt-transform") { if (isRecord(candidate)) { Reflect.set(candidate, "title", "Implicit"); } @@ -233,7 +233,7 @@ function accept( } throw new IndependentError( "schema_violation", - "candidate does not satisfy the task-list acceptance rule", + "candidate does not satisfy the task-list validation rule", ); } @@ -618,8 +618,8 @@ function parseQuery(query: string): string[] { function createChange( applied: ReadonlyArray, - metadata: ProjectionMetadata | undefined, -): ProjectionChange { + metadata: JSONDocumentMetadata | undefined, +): JSONDocumentChange { const ownedApplied = freezeJSON( cloneJSON(applied) as unknown as JSONValue, ) as unknown as ReadonlyArray; diff --git a/packages/json-document/tests/public/naming-compatibility.test.ts b/packages/json-document/tests/public/naming-compatibility.test.ts deleted file mode 100644 index f6d31a5e..00000000 --- a/packages/json-document/tests/public/naming-compatibility.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - createJSONDocument, - type JSONCapabilityResult, - type JSONPatchValidationResult, -} from "@interactive-os/json-document"; - -describe("deprecated naming compatibility", () => { - test("validatePatch and canPatch preserve equivalent extracted methods", () => { - const document = createJSONDocument({ value: 1 }); - const operations = [ - { op: "replace" as const, path: "/value", value: 2 }, - ]; - - const validatePatch = document.validatePatch; - const canPatch = document.canPatch; - - expect(validatePatch(operations)).toEqual({ ok: true }); - expect(canPatch(operations)).toEqual({ ok: true }); - }); - - test("validation result compatibility is structural", () => { - const validation = { ok: true } as JSONPatchValidationResult; - validation satisfies JSONCapabilityResult; - }); -}); diff --git a/packages/json-document/tests/public/v2-projection-standard-conformance.test.ts b/packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts similarity index 79% rename from packages/json-document/tests/public/v2-projection-standard-conformance.test.ts rename to packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts index b07f29c0..01bdd2a4 100644 --- a/packages/json-document/tests/public/v2-projection-standard-conformance.test.ts +++ b/packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts @@ -1,20 +1,20 @@ import { createJSONDocument, - type JSONCapabilityResult, + type JSONPatchValidationResult, type JSONPatchOperation, type JSONValue, } from "@interactive-os/json-document"; import { expect, test } from "vitest"; import { - runProjectionConformance, - type Projection, - type ProjectionAcceptance, - type ProjectionHarness, -} from "../conformance/v2/projection-suite.js"; -import { runPressureConformance } from "../conformance/v2/pressure-suite.js"; - -function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult { + runJSONDocumentConformance, + type JSONDocument, + type JSONDocumentValidation, + type JSONDocumentHarness, +} from "../conformance/v3/json-document-suite.js"; +import { runPressureConformance } from "../conformance/v3/pressure-suite.js"; + +function taskListValidation(candidate: JSONValue): JSONPatchValidationResult { const valid = isRecord(candidate) && typeof candidate.title === "string" && Array.isArray(candidate.items) @@ -30,28 +30,28 @@ function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult { : { ok: false, code: "schema_violation", - reason: "candidate does not satisfy the task-list acceptance rule", + reason: "candidate does not satisfy the task-list validation rule", }; } -function createReferenceProjection( - acceptance: ProjectionAcceptance, +function createReferenceJSONDocument( + validation: JSONDocumentValidation, initial: JSONValue, -): Projection { - const accepts = acceptance === "task-list" - ? taskListAcceptance - : acceptance === "attempt-transform" - ? attemptTransformAcceptance +): JSONDocument { + const validate = validation === "task-list" + ? taskListValidation + : validation === "attempt-transform" + ? attemptTransformValidation : undefined; return createJSONDocument( initial, - accepts === undefined ? {} : { accepts }, + validate === undefined ? {} : { validate }, ); } -function attemptTransformAcceptance( +function attemptTransformValidation( candidate: JSONValue, -): JSONCapabilityResult { +): JSONPatchValidationResult { if (isRecord(candidate)) { Reflect.set(candidate, "title", "Implicit"); } @@ -64,14 +64,14 @@ function isRecord( return value !== null && typeof value === "object" && !Array.isArray(value); } -const referenceHarness: ProjectionHarness = { - create: createReferenceProjection, +const referenceHarness: JSONDocumentHarness = { + create: createReferenceJSONDocument, }; -runProjectionConformance(referenceHarness); +runJSONDocumentConformance(referenceHarness); runPressureConformance(referenceHarness); -test("nested commits preserve one causal publication order for every subscriber", () => { +test("nested commits preserve one causal notification order for every subscriber", () => { const document = createJSONDocument({ value: 0 }); const first: JSONValue[] = []; const second: JSONValue[] = []; @@ -205,10 +205,10 @@ test("owned preparation keeps earlier failure precedence and commit atomicity", { op: "test", path: "/value", value: 2 }, { op: "add", path: "/callback", value: () => undefined }, ] as unknown as ReadonlyArray; - const publications: JSONValue[] = []; - document.subscribe(() => publications.push(document.value)); + const notifications: JSONValue[] = []; + document.subscribe(() => notifications.push(document.value)); - expect(document.canPatch(operations)).toMatchObject({ + expect(document.validatePatch(operations)).toMatchObject({ ok: false, code: "test_failed", pointer: "/value", @@ -219,25 +219,25 @@ test("owned preparation keeps earlier failure precedence and commit atomicity", pointer: "/value", }); expect(document.value).toEqual({ value: 1 }); - expect(publications).toEqual([]); + expect(notifications).toEqual([]); }); -test("initial and acceptance boundaries fail without publishing invalid state", () => { +test("initial and validation boundaries fail without publishing invalid state", () => { expect(() => createJSONDocument(() => undefined)).toThrow(TypeError); expect(() => createJSONDocument( { value: 0 }, { - accepts: () => ({ + validate: () => ({ ok: false, code: "schema_violation", }), }, )).toThrow(TypeError); - const throwingAcceptance = createJSONDocument( + const throwingValidationDocument = createJSONDocument( { value: 0 }, { - accepts(candidate) { + validate(candidate) { if ( typeof candidate === "object" && candidate !== null @@ -251,29 +251,29 @@ test("initial and acceptance boundaries fail without publishing invalid state", }, ); const observed: unknown[] = []; - throwingAcceptance.subscribe((change) => observed.push(change)); + throwingValidationDocument.subscribe((change) => observed.push(change)); - expect(throwingAcceptance.commit([ + expect(throwingValidationDocument.commit([ { op: "replace", path: "/value", value: 1 }, ])).toMatchObject({ ok: false, code: "schema_violation", reason: "provider failed", }); - expect(throwingAcceptance.value).toEqual({ value: 0 }); + expect(throwingValidationDocument.value).toEqual({ value: 0 }); expect(observed).toEqual([]); }); -test("malformed acceptance results fail closed without changing state", () => { +test("malformed validation results fail closed without changing state", () => { expect(() => createJSONDocument( { value: 0 }, - { accepts: () => undefined as never }, + { validate: () => undefined as never }, )).toThrow(TypeError); const document = createJSONDocument( { value: 0 }, { - accepts(candidate) { + validate(candidate) { return isRecord(candidate) && candidate.value === 1 ? undefined as never : { ok: true }; @@ -288,7 +288,7 @@ test("malformed acceptance results fail closed without changing state", () => { value: 1, }] as const; - expect(document.canPatch(operations)).toMatchObject({ + expect(document.validatePatch(operations)).toMatchObject({ ok: false, code: "schema_violation", }); @@ -300,10 +300,10 @@ test("malformed acceptance results fail closed without changing state", () => { expect(observed).toEqual([]); }); -test("acceptance evaluation blocks reentrant probes and commits without changing state", () => { +test("validation evaluation blocks reentrant probes and commits without changing state", () => { let document: ReturnType | undefined; const nestedResults: unknown[] = []; - const publications: Array<{ + const notifications: Array<{ readonly change: unknown; readonly value: JSONValue; }> = []; @@ -311,13 +311,13 @@ test("acceptance evaluation blocks reentrant probes and commits without changing document = createJSONDocument( { a: 0, b: 0 }, { - accepts(candidate) { + validate(candidate) { if ( document !== undefined && isRecord(candidate) && candidate.a === 1 ) { - nestedResults.push(document.canPatch([ + nestedResults.push(document.validatePatch([ { op: "replace", path: "/b", value: 2 }, ])); nestedResults.push(document.commit([ @@ -329,14 +329,14 @@ test("acceptance evaluation blocks reentrant probes and commits without changing }, ); document.subscribe((change) => { - publications.push({ change, value: document!.value }); + notifications.push({ change, value: document!.value }); }); - expect(document.canPatch([ + expect(document.validatePatch([ { op: "replace", path: "/a", value: 1 }, ])).toEqual({ ok: true }); expect(document.value).toEqual({ a: 0, b: 0 }); - expect(publications).toEqual([]); + expect(notifications).toEqual([]); expect(document.commit([ { op: "replace", path: "/a", value: 1 }, @@ -352,11 +352,11 @@ test("acceptance evaluation blocks reentrant probes and commits without changing expect(result).toEqual({ ok: false, code: "acceptance_reentrancy", - reason: "acceptance callback cannot call canPatch or commit", + reason: "validation callback cannot call validatePatch or commit", }); } expect(document.value).toEqual({ a: 1, b: 0 }); - expect(publications).toEqual([{ + expect(notifications).toEqual([{ change: { applied: [{ op: "replace", path: "/a", value: 1 }], }, @@ -366,8 +366,8 @@ test("acceptance evaluation blocks reentrant probes and commits without changing test("state-equivalent commits expose a frozen empty applied list", () => { const document = createJSONDocument({ value: 1 }); - const publications: unknown[] = []; - document.subscribe((change) => publications.push(change)); + const notifications: unknown[] = []; + document.subscribe((change) => notifications.push(change)); const result = document.commit([ { op: "replace", path: "/value", value: 1 }, @@ -387,5 +387,5 @@ test("state-equivalent commits expose a frozen empty applied list", () => { )).toBe(false); expect(result.change.applied).toEqual([]); expect(document.value).toEqual({ value: 1 }); - expect(publications).toEqual([]); + expect(notifications).toEqual([]); }); diff --git a/packages/json-document/tests/public/v2-jsonpath-standard-conformance.test.ts b/packages/json-document/tests/public/v3-jsonpath-standard-conformance.test.ts similarity index 84% rename from packages/json-document/tests/public/v2-jsonpath-standard-conformance.test.ts rename to packages/json-document/tests/public/v3-jsonpath-standard-conformance.test.ts index 40690640..4a410f6d 100644 --- a/packages/json-document/tests/public/v2-jsonpath-standard-conformance.test.ts +++ b/packages/json-document/tests/public/v3-jsonpath-standard-conformance.test.ts @@ -3,7 +3,7 @@ import { createJSONDocument } from "@interactive-os/json-document"; import { runJSONPathConformance, type JSONPathHarness, -} from "../conformance/v2/jsonpath-suite.js"; +} from "../conformance/v3/jsonpath-suite.js"; const referenceHarness: JSONPathHarness = { create: createJSONDocument, diff --git a/packages/json-document/tests/public/v2-pointer-standard-conformance.test.ts b/packages/json-document/tests/public/v3-pointer-standard-conformance.test.ts similarity index 89% rename from packages/json-document/tests/public/v2-pointer-standard-conformance.test.ts rename to packages/json-document/tests/public/v3-pointer-standard-conformance.test.ts index a4d80d22..074cc710 100644 --- a/packages/json-document/tests/public/v2-pointer-standard-conformance.test.ts +++ b/packages/json-document/tests/public/v3-pointer-standard-conformance.test.ts @@ -10,7 +10,7 @@ import { import { runPointerConformance, type PointerHarness, -} from "../conformance/v2/pointer-suite.js"; +} from "../conformance/v3/pointer-suite.js"; const referenceHarness: PointerHarness = { appendSegment, diff --git a/packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts b/packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts similarity index 83% rename from packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts rename to packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts index 6420472a..036d58ad 100644 --- a/packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts +++ b/packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts @@ -3,7 +3,7 @@ import { applyPatch } from "@interactive-os/json-document"; import { runProtocolConformance, type ProtocolHarness, -} from "../conformance/v2/protocol-suite.js"; +} from "../conformance/v3/protocol-suite.js"; const referenceHarness: ProtocolHarness = { applyPatch, diff --git a/packages/json-document/tests/public/v2-rfc6902-standard-conformance.test.ts b/packages/json-document/tests/public/v3-rfc6902-standard-conformance.test.ts similarity index 83% rename from packages/json-document/tests/public/v2-rfc6902-standard-conformance.test.ts rename to packages/json-document/tests/public/v3-rfc6902-standard-conformance.test.ts index 71afabb8..2d8e323c 100644 --- a/packages/json-document/tests/public/v2-rfc6902-standard-conformance.test.ts +++ b/packages/json-document/tests/public/v3-rfc6902-standard-conformance.test.ts @@ -3,7 +3,7 @@ import { applyPatch } from "@interactive-os/json-document"; import { runRFC6902Conformance, type RFC6902Harness, -} from "../conformance/v2/rfc6902-suite.js"; +} from "../conformance/v3/rfc6902-suite.js"; const referenceHarness: RFC6902Harness = { applyPatch, diff --git a/packages/json-document/tests/public/v2-signature-contract.test-d.ts b/packages/json-document/tests/public/v3-signature-contract.test-d.ts similarity index 90% rename from packages/json-document/tests/public/v2-signature-contract.test-d.ts rename to packages/json-document/tests/public/v3-signature-contract.test-d.ts index 003de65d..4743ac18 100644 --- a/packages/json-document/tests/public/v2-signature-contract.test-d.ts +++ b/packages/json-document/tests/public/v3-signature-contract.test-d.ts @@ -8,7 +8,6 @@ import { trackPointer, tryParsePointer, type JSONAppliedChange, - type JSONCapabilityResult, type JSONChangeMetadata, type JSONDocument, type JSONDocumentOptions, @@ -33,7 +32,7 @@ type Expect = T; type DocumentMembers = keyof JSONDocument; type _DocumentMembersAreExact = Expect>; interface Row { @@ -58,8 +57,6 @@ const operations: ReadonlyArray = [ const pureResult = applyPatch(initial, operations); pureResult satisfies JSONPatchResult; document.validatePatch(operations) satisfies JSONPatchValidationResult; -// Stable v2 compatibility aliases remain callable. -document.canPatch(operations) satisfies JSONCapabilityResult; document.commit(operations, { metadata: { origin: "signature" }, }) satisfies JSONDocumentCommitResult; @@ -92,7 +89,7 @@ const futureFailure = { const forwardCompatibleResult: JSONPatchResult = futureFailure; forwardCompatibleResult satisfies JSONPatchResult; -// @ts-expect-error v2 patch payloads are JSON data, not arbitrary host values. +// @ts-expect-error v3 patch payloads are JSON data, not arbitrary host values. const callablePatch: JSONPatchOperation = { op: "add", path: "/run", value: () => undefined }; // @ts-expect-error Editing Session controls are not part of JSONDocument. diff --git a/packages/json-document/tests/smoke/package-smoke.mjs b/packages/json-document/tests/smoke/package-smoke.mjs index 94968909..26c0c6b4 100644 --- a/packages/json-document/tests/smoke/package-smoke.mjs +++ b/packages/json-document/tests/smoke/package-smoke.mjs @@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); -const workspace = await mkdtemp(join(tmpdir(), "json-document-v2-package-")); +const workspace = await mkdtemp(join(tmpdir(), "json-document-v3-package-")); const npmCache = join(workspace, ".npm-cache"); const npmEnv = { ...process.env, @@ -115,16 +115,16 @@ try { throw new Error("The published package must expose only the root entrypoint."); } if (packageJson.peerDependencies !== undefined) { - throw new Error("The v2 kernel must not publish peer dependencies."); + throw new Error("The v3 kernel must not publish peer dependencies."); } if (packageJson.peerDependenciesMeta !== undefined) { - throw new Error("The v2 kernel must not publish peer dependency metadata."); + throw new Error("The v3 kernel must not publish peer dependency metadata."); } if (packageJson.dependencies !== undefined) { - throw new Error("The v2 kernel must not publish runtime dependencies."); + throw new Error("The v3 kernel must not publish runtime dependencies."); } - if (rootValueExports.length !== 8 || rootTypeExports.length !== 14) { - throw new Error("The root contract must contain exactly 8 values and 14 types."); + if (rootValueExports.length !== 8 || rootTypeExports.length !== 13) { + throw new Error("The root contract must contain exactly 8 values and 13 types."); } const packResult = JSON.parse(run( @@ -143,7 +143,7 @@ try { throw new Error("Packed file count does not match npm metadata."); } if (packResult.bundled.length !== 0) { - throw new Error("The v2 kernel must not bundle dependencies."); + throw new Error("The v3 kernel must not bundle dependencies."); } const packedFiles = packResult.files.map((file) => file.path); @@ -240,11 +240,11 @@ try { 'const actual = Object.keys(api).sort();', 'if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`runtime exports: ${actual}`);', 'const document = api.createJSONDocument({ title: "draft", tags: [] });', - 'if (JSON.stringify(Object.keys(document).sort()) !== JSON.stringify(["at", "canPatch", "commit", "query", "subscribe", "validatePatch", "value"])) throw new Error("document surface drifted");', + 'if (JSON.stringify(Object.keys(document).sort()) !== JSON.stringify(["at", "commit", "query", "subscribe", "validatePatch", "value"])) throw new Error("document surface drifted");', 'const changes = [];', 'document.subscribe((change) => changes.push(change));', - 'const result = document.commit([{ op: "add", path: "/tags/-", value: "v2" }]);', - 'if (!result.ok || document.value.tags[0] !== "v2" || changes.length !== 1) throw new Error("commit failed");', + 'const result = document.commit([{ op: "add", path: "/tags/-", value: "v3" }]);', + 'if (!result.ok || document.value.tags[0] !== "v3" || changes.length !== 1) throw new Error("commit failed");', 'const patched = api.applyPatch(document.value, [{ op: "replace", path: "/title", value: "stable" }]);', 'if (!patched.ok || patched.value.title !== "stable") throw new Error("pure patch failed");', ].join("\n"), @@ -263,7 +263,7 @@ try { `import type { ${rootTypeExports.join(", ")} } from "@interactive-os/json-document";`, 'const document: JSONDocument = createJSONDocument({ text: "a" });', 'type Members = keyof JSONDocument;', - 'const members: Members[] = ["value", "at", "query", "validatePatch", "canPatch", "commit", "subscribe"];', + 'const members: Members[] = ["value", "at", "query", "validatePatch", "commit", "subscribe"];', 'const operation: JSONPatchOperation = { op: "replace", path: "/text", value: "b" };', 'const metadata: JSONChangeMetadata = { source: "type-smoke" };', 'const result: JSONDocumentCommitResult = document.commit([operation], { metadata });', @@ -271,7 +271,7 @@ try { 'const pointer: Pointer = appendSegment(buildPointer(["text"]), 0);', 'const values: JSONValue[] = [document.value, pointer, null];', 'void [members, result, pure, values, parsePointer, tryParsePointer, parentPointer, trackPointer];', - 'type Remaining = JSONAppliedChange | JSONCapabilityResult | JSONDocumentOptions | JSONPatchValidationResult | JSONDocumentCommitOptions | QueryResult | ReadResult;', + 'type Remaining = JSONAppliedChange | JSONPatchValidationResult | JSONDocumentOptions | JSONPatchValidationResult | JSONDocumentCommitOptions | QueryResult | ReadResult;', 'declare const remaining: Remaining;', 'void remaining;', ].join("\n"), diff --git a/scripts/benchmark-core.mjs b/scripts/benchmark-core.mjs index 14e442c2..d0011fc5 100644 --- a/scripts/benchmark-core.mjs +++ b/scripts/benchmark-core.mjs @@ -26,7 +26,7 @@ const commitBudgetPerTenThousandMs = envPositiveNumber( 6, ); -console.log("json-document v2 public-root benchmark"); +console.log("json-document v3 public-root benchmark"); console.log(`items=${sizes.join(",")} rounds=${rounds} warmups=${warmups}`); for (const size of sizes) { diff --git a/scripts/evaluate-archive-isolation.mjs b/scripts/evaluate-archive-isolation.mjs index 2853d5f9..de567f8f 100644 --- a/scripts/evaluate-archive-isolation.mjs +++ b/scripts/evaluate-archive-isolation.mjs @@ -23,7 +23,7 @@ if (JSON.stringify(rootPackage.workspaces) !== JSON.stringify(expectedWorkspaces } if (JSON.stringify(Object.keys(kernelPackage.exports ?? {})) !== JSON.stringify(["."])) { - failures.push("the v2 package must expose only its root entrypoint"); + failures.push("the v3 package must expose only its root entrypoint"); } if ( @@ -32,7 +32,7 @@ if ( || JSON.stringify(Object.keys(collaborationPackage.exports ?? {})) !== JSON.stringify([".", "./history", "./text"]) || collaborationPackage.peerDependencies?.["@interactive-os/json-document"] - !== "^2.0.0" + !== "^3.0.0" ) { failures.push("the collaboration companion package surface is invalid"); } @@ -45,10 +45,10 @@ if ( ) !== JSON.stringify(["."]) || contenteditableCollaborationPackage.peerDependencies?.[ "@interactive-os/json-document" - ] !== "^2.0.0" + ] !== "^3.0.0" || contenteditableCollaborationPackage.peerDependencies?.[ "@interactive-os/json-document-collaboration" - ] !== "^0.1.0-rc.1" + ] !== "^0.2.0-rc.1" ) { failures.push("the contenteditable collaboration companion surface is invalid"); } @@ -92,14 +92,14 @@ if ( if (failures.length > 0) { console.error( - `v2 archive isolation failed:\n${failures + `v3 archive isolation failed:\n${failures .map((failure) => `- ${failure}`) .join("\n")}`, ); process.exitCode = 1; } else { console.log( - "v2 archive isolation ok: 4 active workspaces, 1 Kernel and 2 optional companions", + "v3 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 7274ca4f..4808aa8d 100644 --- a/scripts/evaluate-docs.mjs +++ b/scripts/evaluate-docs.mjs @@ -58,8 +58,8 @@ const surfaces = { llms: read("llms.txt"), ...publicDocs, }; -const profile = read("docs/standard/v2-projection-profile.md"); -const publicSurface = readJson("docs/standard/v2-public-surface.json"); +const profile = read("docs/standard/v3-json-document-profile.md"); +const publicSurface = readJson("docs/standard/v3-public-surface.json"); const publicContract = readJson("packages/json-document/public-contract.json"); const packageJson = readJson("packages/json-document/package.json"); const generatedCatalog = readJson("docs/generated/repo-catalog.json"); @@ -103,7 +103,6 @@ const expectedPublicValues = [ ]; const expectedPublicTypes = [ "JSONAppliedChange", - "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", "JSONDocumentOptions", @@ -127,15 +126,15 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ "overview.md", "quickstart.md", ])) { - fail("docs/public: only the three active v2 guides may remain."); + fail("docs/public: only the three active v3 guides may remain."); } if (JSON.stringify(fileNames("docs/standard")) !== JSON.stringify([ "concept-and-naming-standard.md", - "v2-projection-profile.md", - "v2-public-surface.json", + "v3-json-document-profile.md", + "v3-public-surface.json", ])) { - fail("docs/standard: naming SSOT, v2 profile, and machine-readable surface must be the only active standards."); + fail("docs/standard: naming SSOT, v3 profile, and machine-readable surface must be the only active standards."); } if (JSON.stringify(fileNames("docs/generated")) !== JSON.stringify([ @@ -263,22 +262,22 @@ const required = [ ["api", surfaces.api, /Root document Pointer는 빈 문자열 `""`/], ["api", surfaces.api, /function asPointer/], ["api", surfaces.api, /## Host와 adapter/], - ["packageReadme", surfaces.packageReadme, /npm install @interactive-os\/json-document@2\.0\.0/], + ["packageReadme", surfaces.packageReadme, /npm install @interactive-os\/json-document@3\.0\.0/], ["packageReadme", surfaces.packageReadme, /패키지는 `\/session`이나 `\/react` subpath를\s*공개하지 않습니다/], - ["collaborationReadme", surfaces.collaborationReadme, /same seven-member\s+`JSONDocument` API/], + ["collaborationReadme", surfaces.collaborationReadme, /same six-member\s+`JSONDocument` API/], ["collaborationReadme", surfaces.collaborationReadme, /Concept and Naming Standard/], ["collaborationReadme", surfaces.collaborationReadme, /contains no transport, presence,\s*storage, DOM, React, or server dependency/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /IME-safe native-input DOM lease/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /Concept and Naming Standard/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /does not activate or depend on the archived 1\.x DOM adapters/], - ["llms", surfaces.llms, /2\.0\.0.*Stable/], - ["llms", surfaces.llms, /공개 Root는 정확히 다음 22개 symbol/], + ["llms", surfaces.llms, /3\.0\.0.*Stable/], + ["llms", surfaces.llms, /공개 Root는 정확히 다음 21개 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 하나와 22개 symbol/], - ["profile", profile, /Acceptance callback[\s\S]*`canPatch`[\s\S]*`commit`/], + ["profile", profile, /root entrypoint 하나와 21개 symbol/], + ["profile", profile, /Validation callback[\s\S]*`validatePatch`[\s\S]*`commit`/], ["profile", profile, /Concept and Naming Standard/], ["docsReadme", docsReadme, /concept-and-naming-standard\.md/], ["namingStandard", namingStandard, /상태: Canonical/], @@ -292,7 +291,7 @@ const required = [ ["namingStandard", namingStandard, /## Collection과 축약/], ["namingStandard", namingStandard, /## 파일과 경로/], ["namingStandard", namingStandard, /## 현재 이름 평가/], - ["namingStandard", namingStandard, /## v2 compatibility map/], + ["namingStandard", namingStandard, /## Protocol vocabulary boundary/], ["namingStandard", namingStandard, /## 새 concept admission/], ["namingStandard", namingStandard, /runtime logic, protocol semantics 또는 wire behavior/], ]; @@ -381,6 +380,30 @@ for (const path of filesUnder("packages")) { } } +const forbiddenLegacyIdentifiers = [ + "JSONCapabilityResult", + "canPatch", + "accepts", + "CollaborationControl", + "CollaborationSnapshot", + "CollaborationAcceptance", + "CollaborationHistory", + "CollaborationText", + "CollaborationContentEditable", + "projectionChanged", + "nextAccepts", + "discardedHistoryControls", +]; +for (const path of filesUnder("packages")) { + if (!path.includes("/src/") || !path.endsWith(".ts")) continue; + const source = read(path); + for (const identifier of forbiddenLegacyIdentifiers) { + if (source.includes(identifier)) { + fail(`naming implementation: legacy identifier remains in ${path}: ${identifier}.`); + } + } +} + for (const [name, source] of Object.entries(namingImplementation)) { for (const forbiddenInternal of [ "createProjection", @@ -424,14 +447,14 @@ if ( || JSON.stringify(publicContract.root.values) !== JSON.stringify(expectedPublicValues) || JSON.stringify(publicContract.root.types) !== JSON.stringify(expectedPublicTypes) ) { - fail("public contract: root must be the only exact 22-symbol entrypoint."); + fail("public contract: root must be the only exact 21-symbol entrypoint."); } if ( JSON.stringify(publicSurface.binding?.values) !== JSON.stringify(expectedPublicValues) || JSON.stringify(publicSurface.binding?.types) !== JSON.stringify(expectedPublicTypes) ) { - fail("v2 machine surface: package contract and standard manifest disagree."); + fail("v3 machine surface: package contract and standard manifest disagree."); } if ( @@ -440,7 +463,7 @@ if ( || packageJson.homepage !== "https://developer-1px.github.io/json-document/" || packageJson.repository?.directory !== "packages/json-document" ) { - fail("package metadata: v2 must ship one dependency-free root entrypoint."); + fail("package metadata: v3 must ship one dependency-free root entrypoint."); } const generatedCore = generatedCatalog.packages?.[0]; @@ -455,14 +478,14 @@ if ( || generatedCore?.status !== "core" || JSON.stringify(generatedCore?.entrypoints) !== JSON.stringify(["."]) || JSON.stringify(generatedCore?.publicExports) !== JSON.stringify(generatedExports) - || generatedCore?.publicExportCount !== 22 + || generatedCore?.publicExportCount !== 21 || 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 !== 43 + || generatedCollaboration?.publicExportCount !== 40 || generatedContenteditableCollaboration?.path !== "packages/contenteditable-collaboration" || generatedContenteditableCollaboration?.name @@ -470,7 +493,7 @@ if ( || generatedContenteditableCollaboration?.status !== "companion" || JSON.stringify(generatedContenteditableCollaboration?.entrypoints) !== JSON.stringify(["."]) - || generatedContenteditableCollaboration?.publicExportCount !== 14 + || generatedContenteditableCollaboration?.publicExportCount !== 7 || generatedCatalog.officialExtensions !== undefined || generatedCatalog.labExtensions !== undefined || generatedCatalog.apps !== undefined @@ -480,7 +503,7 @@ if ( companions: 2, }) ) { - fail("generated catalog: active scope must contain one v2 Core and two exact companions."); + fail("generated catalog: active scope must contain one v3 Core and two exact companions."); } const expectedRoutePaths = ["/", "/docs", "/docs/tutorial", "/docs/api"]; @@ -488,7 +511,7 @@ if ( JSON.stringify(siteRoutes.map((route) => route.path)) !== JSON.stringify(expectedRoutePaths) || siteRoutes.some((route) => route.group !== "Start") ) { - fail("site routes: the public site must expose only the v2 core routes."); + fail("site routes: the public site must expose only the v3 core routes."); } for (const pattern of [ @@ -513,8 +536,8 @@ for (const pattern of [ for (const pattern of [ /Implementation-neutral JSON editing/, - /seven-member JSON Document/, - /npm install @interactive-os\/json-document@2\.0\.0/, + /six-member JSON Document/, + /npm install @interactive-os\/json-document@3\.0\.0/, /Rich editing belongs to host adapters/, ]) { requirePattern("site home", siteHome, pattern); diff --git a/scripts/evaluate-live-site.mjs b/scripts/evaluate-live-site.mjs index 0a0e1e30..2a5c8e84 100644 --- a/scripts/evaluate-live-site.mjs +++ b/scripts/evaluate-live-site.mjs @@ -70,13 +70,13 @@ async function checkOnce() { const llms = await fetchText("/llms.txt"); if ( - !/^# json-document v2$/m.test(llms) - || !/공개 Root는 정확히 다음 22개 symbol/.test(llms) + !/^# json-document v3$/m.test(llms) + || !/공개 Root는 정확히 다음 21개 symbol/.test(llms) || !/`JSONDocument`의 필수 member는 정확히 여섯 개다/.test(llms) || !/## Host adapter와 companion/.test(llms) || !/@interactive-os\/editable/.test(llms) ) { - fail("live llms.txt is missing the v2 Core contract."); + fail("live llms.txt is missing the v3 Core contract."); } const packageReferences = llms.match(/@interactive-os\/json-document-[a-z0-9-]+/g) ?? []; if ( diff --git a/scripts/evaluate-standardization.mjs b/scripts/evaluate-standardization.mjs index ab797769..c56c84c7 100644 --- a/scripts/evaluate-standardization.mjs +++ b/scripts/evaluate-standardization.mjs @@ -17,7 +17,6 @@ const EXPECTED_VALUES = [ ]; const EXPECTED_TYPES = [ "JSONAppliedChange", - "JSONCapabilityResult", "JSONChangeMetadata", "JSONDocument", "JSONDocumentOptions", @@ -31,43 +30,34 @@ const EXPECTED_TYPES = [ "QueryResult", "ReadResult", ]; -const V2_COMPATIBILITY_MEMBERS = [ - "value", - "at", - "query", - "canPatch", - "commit", - "subscribe", -]; -const CURRENT_DOCUMENT_MEMBERS = [ +const DOCUMENT_MEMBERS = [ "value", "at", "query", "validatePatch", - "canPatch", "commit", "subscribe", ]; const EXPECTED_CONFORMANCE = { - projectionVectors: "packages/json-document/tests/conformance/v2/projection-vectors.json", - projectionSuite: "packages/json-document/tests/conformance/v2/projection-suite.ts", - projectionBinding: "packages/json-document/tests/public/v2-projection-standard-conformance.test.ts", - protocolVectors: "packages/json-document/tests/conformance/v2/protocol-vectors.json", - protocolSuite: "packages/json-document/tests/conformance/v2/protocol-suite.ts", - protocolBinding: "packages/json-document/tests/public/v2-protocol-standard-conformance.test.ts", - pointerVectors: "packages/json-document/tests/conformance/v2/pointer-vectors.json", - pointerSuite: "packages/json-document/tests/conformance/v2/pointer-suite.ts", - pointerBinding: "packages/json-document/tests/public/v2-pointer-standard-conformance.test.ts", - rfc6902Suite: "packages/json-document/tests/conformance/v2/rfc6902-suite.ts", - rfc6902Binding: "packages/json-document/tests/public/v2-rfc6902-standard-conformance.test.ts", - jsonPathSuite: "packages/json-document/tests/conformance/v2/jsonpath-suite.ts", - jsonPathBinding: "packages/json-document/tests/public/v2-jsonpath-standard-conformance.test.ts", - foundationVectors: "packages/json-document/tests/conformance/v2/foundation-vectors.json", - pressureVectors: "packages/json-document/tests/conformance/v2/pressure-vectors.json", - pressureSuite: "packages/json-document/tests/conformance/v2/pressure-suite.ts", - independentProjectionImplementation: "packages/json-document/tests/independent/v2-projection.ts", - independentProjectionBinding: "packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts", - collaborationProjectionBinding: "packages/json-document-collaboration/tests/projection-conformance.test.ts", + jsonDocumentVectors: "packages/json-document/tests/conformance/v3/json-document-vectors.json", + jsonDocumentSuite: "packages/json-document/tests/conformance/v3/json-document-suite.ts", + jsonDocumentBinding: "packages/json-document/tests/public/v3-json-document-standard-conformance.test.ts", + protocolVectors: "packages/json-document/tests/conformance/v3/protocol-vectors.json", + protocolSuite: "packages/json-document/tests/conformance/v3/protocol-suite.ts", + protocolBinding: "packages/json-document/tests/public/v3-protocol-standard-conformance.test.ts", + pointerVectors: "packages/json-document/tests/conformance/v3/pointer-vectors.json", + pointerSuite: "packages/json-document/tests/conformance/v3/pointer-suite.ts", + pointerBinding: "packages/json-document/tests/public/v3-pointer-standard-conformance.test.ts", + rfc6902Suite: "packages/json-document/tests/conformance/v3/rfc6902-suite.ts", + rfc6902Binding: "packages/json-document/tests/public/v3-rfc6902-standard-conformance.test.ts", + jsonPathSuite: "packages/json-document/tests/conformance/v3/jsonpath-suite.ts", + jsonPathBinding: "packages/json-document/tests/public/v3-jsonpath-standard-conformance.test.ts", + foundationVectors: "packages/json-document/tests/conformance/v3/foundation-vectors.json", + pressureVectors: "packages/json-document/tests/conformance/v3/pressure-vectors.json", + pressureSuite: "packages/json-document/tests/conformance/v3/pressure-suite.ts", + independentJSONDocumentImplementation: "packages/json-document/tests/independent/v3-json-document.ts", + independentJSONDocumentBinding: "packages/json-document/tests/independent/v3-json-document-independent-conformance.test.ts", + collaborationJSONDocumentBinding: "packages/json-document-collaboration/tests/json-document-conformance.test.ts", }; function read(path) { @@ -190,40 +180,40 @@ function assertPublicBinding(label, source, pattern) { requirePattern(label, source, pattern); } -const manifest = json("docs/standard/v2-public-surface.json"); -const profile = read("docs/standard/v2-projection-profile.md"); +const manifest = json("docs/standard/v3-public-surface.json"); +const profile = read("docs/standard/v3-json-document-profile.md"); const packageContract = json("packages/json-document/public-contract.json"); const packageManifest = json("packages/json-document/package.json"); const buildConfig = json("packages/json-document/tsconfig.json"); const rootSource = read("packages/json-document/src/application/document/index.ts"); const contractSource = read("packages/json-document/src/application/document/contract.ts"); const signatureSource = read( - "packages/json-document/tests/public/v2-signature-contract.test-d.ts", + "packages/json-document/tests/public/v3-signature-contract.test-d.ts", ); const packageSmoke = read("packages/json-document/tests/smoke/package-smoke.mjs"); const coreBenchmark = read("scripts/benchmark-core.mjs"); if (manifest.formatVersion !== 1 || manifest.status !== "stable") { - fail("v2 manifest: expected formatVersion 1 and stable status."); + fail("v3 manifest: expected formatVersion 1 and stable status."); } if (manifest.sourceContract !== "packages/json-document/public-contract.json#root") { - fail("v2 manifest: sourceContract must point to the root public contract."); + fail("v3 manifest: sourceContract must point to the root public contract."); } equal( - "v2 manifest projection members", - manifest.projectionMembers, - V2_COMPATIBILITY_MEMBERS, + "v3 manifest JSON Document members", + manifest.documentMembers, + DOCUMENT_MEMBERS, ); -equal("v2 manifest conformance paths", manifest.conformance, EXPECTED_CONFORMANCE); -equal("v2 manifest binding values", manifest.binding?.values, EXPECTED_VALUES); -equal("v2 manifest binding types", manifest.binding?.types, EXPECTED_TYPES); -equal("v2 manifest counts", manifest.counts, { +equal("v3 manifest conformance paths", manifest.conformance, EXPECTED_CONFORMANCE); +equal("v3 manifest binding values", manifest.binding?.values, EXPECTED_VALUES); +equal("v3 manifest binding types", manifest.binding?.types, EXPECTED_TYPES); +equal("v3 manifest counts", manifest.counts, { values: 8, - types: 14, - exports: 22, - projectionMembers: 6, + types: 13, + exports: 21, + documentMembers: 6, }); -equal("v2 package identity", { +equal("v3 package identity", { name: manifest.package?.name, version: manifest.package?.version, entrypoint: manifest.package?.entrypoint, @@ -233,12 +223,12 @@ equal("v2 package identity", { entrypoint: ".", }); equal( - "v2 excluded entrypoints", + "v3 excluded entrypoints", manifest.package?.excludedEntrypoints, ["./session", "./react"], ); -equal("v2 runtime dependencies", manifest.package?.runtimeDependencies, []); -equal("v2 peer dependencies", manifest.package?.peerDependencies, []); +equal("v3 runtime dependencies", manifest.package?.runtimeDependencies, []); +equal("v3 peer dependencies", manifest.package?.peerDependencies, []); equal("public contract entrypoints", Object.keys(packageContract), ["root"]); equal("public contract values", packageContract.root?.values, EXPECTED_VALUES); @@ -250,7 +240,7 @@ equal("root source types", sourceExports.types, [...EXPECTED_TYPES].sort()); equal( "JSONDocument members", interfaceMembers(contractSource, "JSONDocument"), - CURRENT_DOCUMENT_MEMBERS, + DOCUMENT_MEMBERS, ); equal("package entrypoints", Object.keys(packageManifest.exports ?? {}), ["."]); @@ -271,7 +261,7 @@ for (const field of [ "peerDependenciesMeta", ]) { if (packageManifest[field] !== undefined) { - fail(`package: ${field} must be absent from the v2 kernel.`); + fail(`package: ${field} must be absent from the v3 kernel.`); } } equal( @@ -286,35 +276,35 @@ equal( ); const documentedRequirements = [ - ...profile.matchAll(/\bJD2-[A-Z]+-\d{3}\b/g), + ...profile.matchAll(/\bJD3-[A-Z]+-\d{3}\b/g), ].map((match) => match[0]); if (documentedRequirements.length !== new Set(documentedRequirements).size) { - fail("v2 profile: every requirement ID must appear exactly once."); + fail("v3 profile: every requirement ID must appear exactly once."); } setEqual( - "v2 requirements", + "v3 requirements", documentedRequirements, manifest.requirements ?? [], ); for (const word of ["MUST", "SHOULD", "MAY"]) { - requirePattern("v2 profile normative language", profile, new RegExp(`\\b${word}\\b`)); + requirePattern("v3 profile normative language", profile, new RegExp(`\\b${word}\\b`)); } for (const pattern of [ - /Pure Protocol -> Projection -> host adapter/, - /root entrypoint 하나와 22개 symbol/, + /stateless JSON Patch -> JSON Document -> host adapter/, + /root entrypoint 하나와 21개 symbol/, /runtime dependency와 peer dependency가 없다/, /archived 1\.x implementation은 production build와 tarball에\s*포함하지 않는다/, ]) { - requirePattern("v2 profile package closure", profile, pattern); + requirePattern("v3 profile package closure", profile, pattern); } requirePattern( - "v2 signature exact document", + "v3 signature exact document", signatureSource, - /"value" \| "at" \| "query" \| "validatePatch" \| "canPatch" \| "commit" \| "subscribe"/, + /"value" \| "at" \| "query" \| "validatePatch" \| "commit" \| "subscribe"/, ); requirePattern( - "v2 signature JSON boundary", + "v3 signature JSON boundary", signatureSource, /document\.value satisfies JSONValue/, ); @@ -323,18 +313,18 @@ if ( signatureSource, ) ) { - fail("v2 signature: archived entrypoint or unsound document generic leaked."); + fail("v3 signature: archived entrypoint or unsound document generic leaked."); } for (const path of Object.values(EXPECTED_CONFORMANCE)) { - if (!existsSync(join(repoRoot, path))) fail(`v2 conformance artifact missing: ${path}.`); + if (!existsSync(join(repoRoot, path))) fail(`v3 conformance artifact missing: ${path}.`); } -const projectionVectors = json(manifest.conformance.projectionVectors); +const jsonDocumentVectors = json(manifest.conformance.jsonDocumentVectors); const protocolVectors = json(manifest.conformance.protocolVectors); const pointerVectors = json(manifest.conformance.pointerVectors); -const projectionSuite = read(manifest.conformance.projectionSuite); -const projectionBinding = read(manifest.conformance.projectionBinding); +const jsonDocumentSuite = read(manifest.conformance.jsonDocumentSuite); +const jsonDocumentBinding = read(manifest.conformance.jsonDocumentBinding); const protocolSuite = read(manifest.conformance.protocolSuite); const protocolBinding = read(manifest.conformance.protocolBinding); const pointerSuite = read(manifest.conformance.pointerSuite); @@ -346,18 +336,18 @@ const jsonPathBinding = read(manifest.conformance.jsonPathBinding); const foundationVectors = json(manifest.conformance.foundationVectors); const pressureVectors = json(manifest.conformance.pressureVectors); const pressureSuite = read(manifest.conformance.pressureSuite); -const independentProjectionImplementation = read( - manifest.conformance.independentProjectionImplementation, +const independentJSONDocumentImplementation = read( + manifest.conformance.independentJSONDocumentImplementation, ); -const independentProjectionBinding = read( - manifest.conformance.independentProjectionBinding, +const independentJSONDocumentBinding = read( + manifest.conformance.independentJSONDocumentBinding, ); -const collaborationProjectionBinding = read( - manifest.conformance.collaborationProjectionBinding, +const collaborationJSONDocumentBinding = read( + manifest.conformance.collaborationJSONDocumentBinding, ); for (const [label, vectors] of [ - ["projection", projectionVectors], + ["json-document", jsonDocumentVectors], ["protocol", protocolVectors], ["pointer", pointerVectors], ["foundation", foundationVectors], @@ -366,15 +356,15 @@ for (const [label, vectors] of [ if ( vectors.formatVersion !== 1 || vectors.status !== "stable" - || vectors.profile !== "docs/standard/v2-projection-profile.md" + || vectors.profile !== "docs/standard/v3-json-document-profile.md" ) { fail(`${label} vectors: metadata drifted.`); } } equal( - "projection vector members", - projectionVectors.projectionMembers, - V2_COMPATIBILITY_MEMBERS, + "json-document vector members", + jsonDocumentVectors.documentMembers, + DOCUMENT_MEMBERS, ); if (protocolVectors.function !== "applyPatch") { fail("protocol vectors: function must be applyPatch."); @@ -382,7 +372,7 @@ if (protocolVectors.function !== "applyPatch") { equal( "pointer vector requirements", pointerVectors.requirements, - ["JD2-ADDRESS-001", "JD2-PATCH-001"], + ["JD3-ADDRESS-001", "JD3-PATCH-001"], ); for (const group of ["parse", "invalid", "build", "append", "parent", "track"]) { if (!Array.isArray(pointerVectors[group]) || pointerVectors[group].length === 0) { @@ -393,15 +383,15 @@ for (const group of ["parse", "invalid", "build", "append", "parent", "track"]) const requirementSet = new Set(manifest.requirements ?? []); const runtimeRequirements = new Set(); const allowedVectorKinds = { - projection: new Set([ + "json-document": new Set([ "surface", "read", "commit", "unsubscribe", - "unsubscribe-during-publication", + "unsubscribe-during-notification", "isolation", "non-json", - "reentrant-publication", + "reentrant-notification", "subscriber-error", ]), protocol: new Set([ @@ -414,7 +404,7 @@ const allowedVectorKinds = { ]), }; for (const [suiteName, vectors] of [ - ["projection", projectionVectors.vectors ?? []], + ["json-document", jsonDocumentVectors.vectors ?? []], ["protocol", protocolVectors.vectors ?? []], ]) { const vectorIds = new Set(); @@ -455,9 +445,9 @@ setEqual( for (const vertical of pressureVerticals) { if ( !Array.isArray(vertical.requirements) - || !vertical.requirements.includes("JD2-CONFORMANCE-002") + || !vertical.requirements.includes("JD3-CONFORMANCE-002") ) { - fail(`pressure vectors: ${vertical.id} must trace JD2-CONFORMANCE-002.`); + fail(`pressure vectors: ${vertical.id} must trace JD3-CONFORMANCE-002.`); } for (const id of vertical.requirements ?? []) { if (!requirementSet.has(id)) { @@ -479,71 +469,71 @@ if ( const nonRuntimeRequirements = new Map(); const nonRuntimeCounts = { static: 0, deferred: 0 }; -for (const entry of projectionVectors.nonRuntimeRequirements ?? []) { +for (const entry of jsonDocumentVectors.nonRuntimeRequirements ?? []) { if (!requirementSet.has(entry.id)) { - fail(`v2 vectors: unknown non-runtime requirement ${entry.id}.`); + fail(`v3 vectors: unknown non-runtime requirement ${entry.id}.`); } if (runtimeRequirements.has(entry.id) || nonRuntimeRequirements.has(entry.id)) { - fail(`v2 vectors: duplicate requirement coverage ${entry.id}.`); + fail(`v3 vectors: duplicate requirement coverage ${entry.id}.`); } if (!(entry.mode in nonRuntimeCounts)) { - fail(`v2 vectors: invalid coverage mode ${entry.mode}.`); + fail(`v3 vectors: invalid coverage mode ${entry.mode}.`); } else { nonRuntimeCounts[entry.mode] += 1; } if (typeof entry.reason !== "string" || entry.reason.trim() === "") { - fail(`v2 vectors: ${entry.id} needs a reason.`); + fail(`v3 vectors: ${entry.id} needs a reason.`); } nonRuntimeRequirements.set(entry.id, entry.mode); } setEqual( - "v2 requirement coverage", + "v3 requirement coverage", [...runtimeRequirements, ...nonRuntimeRequirements.keys()], manifest.requirements, ); -equal("v2 coverage", projectionVectors.coverage, { +equal("v3 coverage", jsonDocumentVectors.coverage, { runtime: runtimeRequirements.size, static: nonRuntimeCounts.static, deferred: nonRuntimeCounts.deferred, }); if (/"_zod"\s*:|"safeParse"\s*:/.test(JSON.stringify([ - projectionVectors, + jsonDocumentVectors, protocolVectors, ]))) { - fail("v2 vectors: provider object leaked."); + fail("v3 vectors: provider object leaked."); } assertGenericSuite( - "projection suite", - projectionSuite, - /ProjectionHarness[\s\S]*runProjectionConformance/, + "json-document suite", + jsonDocumentSuite, + /JSONDocumentHarness[\s\S]*runJSONDocumentConformance/, ); for (const pattern of [ - /readonly value[\s\S]*at\([\s\S]*query\([\s\S]*canPatch\([\s\S]*commit\([\s\S]*subscribe\(/, + /readonly value[\s\S]*at\([\s\S]*query\([\s\S]*validatePatch\([\s\S]*commit\([\s\S]*subscribe\(/, /toMatchObject/, ]) { - requirePattern("projection suite", projectionSuite, pattern); + requirePattern("json-document suite", jsonDocumentSuite, pattern); } -if (/Object\.keys\([^)]*(?:result|change)/.test(projectionSuite)) { - fail("projection suite: exact result or change keys must not be asserted."); +if (/Object\.keys\([^)]*(?:result|change)/.test(jsonDocumentSuite)) { + fail("json-document suite: exact result or change keys must not be asserted."); } assertPublicBinding( - "projection binding", - projectionBinding, - /createJSONDocument[\s\S]*runProjectionConformance/, + "json-document binding", + jsonDocumentBinding, + /createJSONDocument[\s\S]*runJSONDocumentConformance/, ); requirePattern( - "projection binding", - projectionBinding, + "json-document binding", + jsonDocumentBinding, /runPressureConformance\(referenceHarness\)/, ); requirePattern( - "projection binding", - projectionBinding, + "json-document binding", + jsonDocumentBinding, /return createJSONDocument\(/, ); -if (/\.(?:lastPatch|patch|find|canFind|canQuery)\b/.test(projectionBinding)) { - fail("projection binding: archived compatibility member leaked."); +if (/\.(?:lastPatch|patch|find|canFind|canQuery)\b/.test(jsonDocumentBinding)) { + fail("json-document binding: non-canonical member leaked."); } assertGenericSuite( "protocol suite", @@ -561,7 +551,7 @@ assertPublicBinding( assertGenericSuite( "pressure suite", pressureSuite, - /ProjectionHarness[\s\S]*runPressureConformance/, + /JSONDocumentHarness[\s\S]*runPressureConformance/, ); for (const vertical of [ "form", @@ -578,45 +568,45 @@ for (const vertical of [ } if ( /@interactive-os\/json-document|\/src\//.test( - independentProjectionImplementation, + independentJSONDocumentImplementation, ) ) { - fail("independent projection: reference package or private source import leaked."); + fail("independent JSON Document: reference package or private source import leaked."); } for (const pattern of [ /get value\(\)/, /\bat\(/, /\bquery\(/, - /\bcanPatch\(/, + /\bvalidatePatch\(/, /\bcommit\(/, /\bsubscribe\(/, ]) { requirePattern( - "independent projection", - independentProjectionImplementation, + "independent JSON Document", + independentJSONDocumentImplementation, pattern, ); } if ( - /@interactive-os\/json-document|\/src\//.test(independentProjectionBinding) + /@interactive-os\/json-document|\/src\//.test(independentJSONDocumentBinding) ) { - fail("independent projection binding must not import the reference implementation."); + fail("independent JSON Document binding must not import the reference implementation."); } for (const pattern of [ - /createIndependentProjection/, - /runProjectionConformance\(independentHarness\)/, + /createIndependentJSONDocument/, + /runJSONDocumentConformance\(independentHarness\)/, /runPressureConformance\(independentHarness\)/, ]) { - requirePattern("independent projection binding", independentProjectionBinding, pattern); + requirePattern("independent JSON Document binding", independentJSONDocumentBinding, pattern); } for (const pattern of [ /from "@interactive-os\/json-document-collaboration"/, - /runProjectionConformance\(harness\)/, + /runJSONDocumentConformance\(harness\)/, /runPressureConformance\(harness\)/, ]) { requirePattern( - "collaboration projection binding", - collaborationProjectionBinding, + "collaboration JSON Document binding", + collaborationJSONDocumentBinding, pattern, ); } @@ -668,7 +658,7 @@ for (const pattern of [ /application\/react-document/, /Archived implementation leaked into dist/, ]) { - requirePattern("package smoke v2 closure", packageSmoke, pattern); + requirePattern("package smoke v3 closure", packageSmoke, pattern); } for (const pattern of [ /dist\/application\/document\/index\.js/, @@ -676,19 +666,19 @@ for (const pattern of [ /applyPatch/, /commit single leaf replace/, ]) { - requirePattern("v2 core benchmark", coreBenchmark, pattern); + requirePattern("v3 core benchmark", coreBenchmark, pattern); } if (/application\/session|from\s+["']zod["']/.test(coreBenchmark)) { - fail("v2 core benchmark: archived session or schema provider leaked."); + fail("v3 core benchmark: archived session or schema provider leaked."); } if (failures.length > 0) { console.error( - `json-document v2 standardization failed:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, + `json-document v3 standardization failed:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, ); process.exitCode = 1; } else { console.log( - "json-document standardization ok: 1 entrypoint, 22 current exports, 6 v2 compatibility members, 0 runtime peers", + "json-document standardization ok: 1 entrypoint, 21 exports, 6 JSON Document members, 0 runtime peers", ); } diff --git a/tests/browser/site.spec.ts b/tests/browser/site.spec.ts index e403282a..bdaebefb 100644 --- a/tests/browser/site.spec.ts +++ b/tests/browser/site.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; -test("official overview exposes only the v2 core documentation", async ({ page }) => { +test("official overview exposes only the v3 core documentation", async ({ page }) => { const requests: string[] = []; page.on("request", (request) => requests.push(request.url())); From 644bf4801ebb9366e68b24724d12b33df9ac349a 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: Thu, 30 Jul 2026 14:08:36 +0900 Subject: [PATCH 4/5] chore: align workflow labels with v3 --- .github/workflows/ci.yml | 2 +- .github/workflows/lab-extensions.yml | 2 +- .github/workflows/publish.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a29956d7..2d551cfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies if: steps.scope.outputs.package_any == 'true' run: npm ci --no-audit --no-fund - - name: Verify active v2 packages + - name: Verify active v3 packages if: steps.scope.outputs.package_full == 'true' run: npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate - name: Verify package documentation diff --git a/.github/workflows/lab-extensions.yml b/.github/workflows/lab-extensions.yml index 33e15a78..93ae9e7b 100644 --- a/.github/workflows/lab-extensions.yml +++ b/.github/workflows/lab-extensions.yml @@ -17,5 +17,5 @@ jobs: - uses: actions/setup-node@v6 with: node-version: "22" - - name: Verify archived code is outside the v2 release graph + - name: Verify archived code is outside the v3 release graph run: npm run archive:check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 50c5fbeb..1edbcdc2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish v2 package +name: Publish v3 package on: push: @@ -12,7 +12,7 @@ permissions: id-token: write concurrency: - group: npm-v2-package-${{ github.ref }} + group: npm-v3-package-${{ github.ref }} cancel-in-progress: false jobs: @@ -82,7 +82,7 @@ jobs: ' - name: Install dependencies run: npm ci --no-audit --no-fund - - name: Verify v2 release graph + - name: Verify v3 release graph run: npm run release:check - name: Publish package env: From 683e0c80f2c92e835aa0620c648bc4925c396121 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: Thu, 30 Jul 2026 15:15:52 +0900 Subject: [PATCH 5/5] docs: preserve v3 release contract --- apps/site/src/routes/Home.tsx | 4 +- apps/site/tests/docs-consistency.test.ts | 4 +- apps/site/tests/site-shell.test.tsx | 2 +- .../245-concept-dictionary-naming-ssot.md | 66 +++++++++++++++++++ docs/standard/v3-json-document-profile.md | 5 +- llms.txt | 7 +- packages/json-document/README.md | 8 ++- scripts/evaluate-docs.mjs | 18 ++++- 8 files changed, 100 insertions(+), 14 deletions(-) diff --git a/apps/site/src/routes/Home.tsx b/apps/site/src/routes/Home.tsx index 50f79674..f73a5b22 100644 --- a/apps/site/src/routes/Home.tsx +++ b/apps/site/src/routes/Home.tsx @@ -44,8 +44,8 @@ export function Home() {
-
Install
-
npm install @interactive-os/json-document@3.0.0
+
Release
+
3.0.0 · npm publication pending
Start
{`import { createJSONDocument } from "@interactive-os/json-document";`}
diff --git a/apps/site/tests/docs-consistency.test.ts b/apps/site/tests/docs-consistency.test.ts index a527c99e..90438574 100644 --- a/apps/site/tests/docs-consistency.test.ts +++ b/apps/site/tests/docs-consistency.test.ts @@ -127,7 +127,9 @@ describe("public docs consistency", () => { expect(docs.api).toMatch(/ReadResult/); expect(docs.readme).toMatch(/npm install @interactive-os\/json-document@3\.0\.0/); expect(docs.readme).toMatch(/implementation-neutral/); - expect(docs.llms).toMatch(/3\.0\.0.*Stable/); + expect(docs.llms).toMatch(/v3 표준 상태는 Stable/); + expect(docs.llms).toMatch(/source release version은 `3\.0\.0`/); + expect(docs.llms).toMatch(/npm에는\s+아직 publication되지 않았다/); }); test("locks one canonical concept and naming standard", () => { diff --git a/apps/site/tests/site-shell.test.tsx b/apps/site/tests/site-shell.test.tsx index 78978472..34e2ef1b 100644 --- a/apps/site/tests/site-shell.test.tsx +++ b/apps/site/tests/site-shell.test.tsx @@ -28,7 +28,7 @@ describe("official site shell", () => { expect(screen.getByRole("link", { name: "Skip to content" }).getAttribute("href")).toBe("#main-content"); expect(screen.getByRole("heading", { level: 1, name: "json-document" })).toBeTruthy(); expect(screen.getByText(/Implementation-neutral JSON editing/)).toBeTruthy(); - expect(screen.getByText("npm install @interactive-os/json-document@3.0.0")).toBeTruthy(); + expect(screen.getByText("3.0.0 · npm publication pending")).toBeTruthy(); expect(screen.getByText('import { createJSONDocument } from "@interactive-os/json-document";')).toBeTruthy(); expect(screen.getByRole("link", { name: "npm" }).getAttribute("href")).toBe("https://www.npmjs.com/package/@interactive-os/json-document"); expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe("https://github.com/developer-1px/json-document"); diff --git a/docs/issues/245-concept-dictionary-naming-ssot.md b/docs/issues/245-concept-dictionary-naming-ssot.md index e1908034..572c4f03 100644 --- a/docs/issues/245-concept-dictionary-naming-ssot.md +++ b/docs/issues/245-concept-dictionary-naming-ssot.md @@ -46,6 +46,72 @@ application—not classification alone—is the intended outcome. compatibility aliases and a documented migration path rather than silently removing their identifiers. +## Contract delta — canonical-only breaking public surface + +Authorized by the user on 2026-07-30: “싹 고치자 npm에 배포하긴 했지만 나 말고 +쓰는 사람도 없고 이게 이후 만들 editable과 canvas와 ppt sheet등의 모든 베이스가 +되는 라이브러리라서.” + +Reason: preserving deprecated aliases kept the duplicate concepts that this work +was intended to remove. The user explicitly accepted a breaking public rename so +future editor, canvas, presentation, and sheet systems can start from one +canonical vocabulary. + +### Before + +- Canonical names are primary. +- Previously published names remain as deprecated compatibility aliases. +- Stable v2 public API names are preserved. + +### After + +- Canonical names are the only public and internal code vocabulary. +- Deprecated public type, function, member, property, and option aliases are + removed. +- Compatibility-only tests and manifests are replaced with canonical-surface + assertions. +- Historical issue records and necessary version-history prose may retain old + spellings only as migration evidence. + +### Invariants + +- Runtime algorithms and state transitions do not change. +- Protocol meaning, serialized wire fields, error codes, and package boundaries + do not change. +- Existing wire labels such as `acceptance`, `deps`, and `ops` remain versioned + protocol terms. +- npm publication and merge require separate authorization. + +## Contract delta — canonical-only v3 version boundary + +Authorized by the same user direction to remove the published v2 aliases as the +foundation for future packages. + +Reason: removing stable v2 public identifiers is a breaking API change. Calling +the resulting surface v2 would violate SemVer and make the versioned profile +ambiguous. + +### Before + +- Core and its active profile are versioned as v2. +- Companion peer ranges accept Core v2. + +### After + +- Core source release version and active profile are `3.0.0` and v3/JD3. +- Collaboration and contenteditable companion source releases are + `0.2.0-rc.1` and require Core `^3.0.0`. +- Active manifests, conformance suites, fixtures, and requirement IDs use the + same v3 boundary. +- Package publication remains out of scope; these versions are release + preparation metadata until their tags are explicitly published. + +### Invariants + +- The collaboration wire protocol version does not change. +- Runtime behavior, protocol meaning, serialized fields, error codes, package + boundaries, and `archive/v1` do not change. + ## Why The repository currently has a sound implementation model, but its vocabulary is diff --git a/docs/standard/v3-json-document-profile.md b/docs/standard/v3-json-document-profile.md index 62cd1c5a..869639bb 100644 --- a/docs/standard/v3-json-document-profile.md +++ b/docs/standard/v3-json-document-profile.md @@ -1,7 +1,8 @@ # json-document v3 JSON Document Profile -상태: Stable candidate (`3.0.0`). TypeScript root binding, reference implementation, -독립 test implementation, 다섯 pressure vertical이 stable gate를 통과했다. +표준 상태: Stable. TypeScript root binding, reference implementation, 독립 test +implementation, 다섯 pressure vertical이 stable gate를 통과했다. 대응하는 package +source release는 `3.0.0`이며 npm publication 전까지 release-prepared 상태다. 이 profile은 JSON Document, stateless JSON Patch, validation과 change notification이라는 canonical vocabulary만 사용한다. Repository 전체의 concept와 diff --git a/llms.txt b/llms.txt index a6c973ad..4eeeea37 100644 --- a/llms.txt +++ b/llms.txt @@ -3,9 +3,10 @@ json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 사용할 수 있는 implementation-neutral JSON 편집 Kernel이다. UI component library가 아니다. -현재 package version과 표준 상태는 `3.0.0` Stable이다. Reference와 독립 -implementation이 같은 conformance suite를 통과하고, 다섯 pressure vertical을 -reference·독립·collaboration binding에서 검증한다. +v3 표준 상태는 Stable이다. 현재 source release version은 `3.0.0`이며 npm에는 +아직 publication되지 않았다. Reference와 독립 implementation이 같은 conformance +suite를 통과하고, 다섯 pressure vertical을 reference·독립·collaboration +binding에서 검증한다. 공식 사이트: https://developer-1px.github.io/json-document/ diff --git a/packages/json-document/README.md b/packages/json-document/README.md index e6ccea0a..d9832822 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -15,16 +15,18 @@ Canonical concept와 stable v3 identifier의 관계는 [Concept and Naming Standard](../../docs/standard/concept-and-naming-standard.md)가 정의합니다. -현재 버전은 `3.0.0`입니다. reference와 독립 구현이 같은 conformance suite를 +v3 표준 상태는 Stable입니다. 현재 source release version은 `3.0.0`이며 npm에는 +아직 배포되지 않았습니다. reference와 독립 구현이 같은 conformance suite를 통과했고, form·table/data-grid·outliner/tree·rich text·storage/collaboration -pressure gate까지 검증한 Stable profile입니다. +pressure gate까지 검증했습니다. - 공식 사이트: https://developer-1px.github.io/json-document/ - 표준 profile: `docs/standard/v3-json-document-profile.md` ## 설치 -Core만 쓸 때 필수 dependency가 없습니다. +Core만 쓸 때 필수 dependency가 없습니다. `3.0.0`이 npm에 배포된 뒤 다음 +명령을 사용합니다. ```sh npm install @interactive-os/json-document@3.0.0 diff --git a/scripts/evaluate-docs.mjs b/scripts/evaluate-docs.mjs index 4808aa8d..e4d010a2 100644 --- a/scripts/evaluate-docs.mjs +++ b/scripts/evaluate-docs.mjs @@ -47,6 +47,9 @@ const publicDocs = { }; const docsReadme = read("docs/README.md"); const namingStandard = read("docs/standard/concept-and-naming-standard.md"); +const namingIssueRaw = read( + "docs/issues/245-concept-dictionary-naming-ssot.md", +); const surfaces = { rootReadme: read("README.md"), docsReadme, @@ -91,6 +94,15 @@ const namingImplementation = { ), }; +for (const pattern of [ + /Contract delta — canonical-only breaking public surface/, + /Canonical names are the only public and internal code vocabulary/, + /Contract delta — canonical-only v3 version boundary/, + /Package publication remains out of scope/, +]) { + requirePattern("naming issue raw", namingIssueRaw, pattern); +} + const expectedPublicValues = [ "appendSegment", "applyPatch", @@ -270,7 +282,9 @@ const required = [ ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /IME-safe native-input DOM lease/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /Concept and Naming Standard/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /does not activate or depend on the archived 1\.x DOM adapters/], - ["llms", surfaces.llms, /3\.0\.0.*Stable/], + ["llms", surfaces.llms, /v3 표준 상태는 Stable/], + ["llms", surfaces.llms, /source release version은 `3\.0\.0`/], + ["llms", surfaces.llms, /npm에는\s+아직 publication되지 않았다/], ["llms", surfaces.llms, /공개 Root는 정확히 다음 21개 symbol/], ["llms", surfaces.llms, /## Host adapter와 companion/], ["llms", surfaces.llms, /@interactive-os\/json-document-collaboration/], @@ -537,7 +551,7 @@ for (const pattern of [ for (const pattern of [ /Implementation-neutral JSON editing/, /six-member JSON Document/, - /npm install @interactive-os\/json-document@3\.0\.0/, + /3\.0\.0 · npm publication pending/, /Rich editing belongs to host adapters/, ]) { requirePattern("site home", siteHome, pattern);