diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7aaac7a8..50c5fbeb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,11 @@ -name: Publish v2 prerelease package +name: Publish v2 package on: push: tags: - - "json-document-v*-rc.*" - - "json-document-collaboration-v*-rc.*" - - "json-document-contenteditable-collaboration-v*-rc.*" + - "json-document-v*" + - "json-document-collaboration-v*" + - "json-document-contenteditable-collaboration-v*" permissions: contents: read @@ -32,7 +32,7 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" package-manager-cache: false - - name: Resolve prerelease package + - name: Resolve release package id: release env: RELEASE_TAG: ${{ github.ref_name }} @@ -68,19 +68,24 @@ jobs: if (process.env.RELEASE_TAG !== expectedTag) { throw new Error(`release tag ${process.env.RELEASE_TAG} does not match ${expectedTag}`); } - if (!/^\d+\.\d+\.\d+-rc\.\d+$/.test(pkg.version)) { - throw new Error(`package version is not an rc prerelease: ${pkg.version}`); + const prerelease = /^\d+\.\d+\.\d+-rc\.\d+$/.test(pkg.version); + const stable = /^\d+\.\d+\.\d+$/.test(pkg.version); + if (!prerelease && !stable) { + throw new Error(`package version is not a supported stable or rc version: ${pkg.version}`); } - if (pkg.publishConfig?.tag !== "next") { - throw new Error("publishConfig.tag must remain next for prereleases"); + const distTag = prerelease ? "next" : "latest"; + if (pkg.publishConfig?.tag !== distTag) { + throw new Error(`publishConfig.tag must be ${distTag} for ${pkg.version}`); } appendFileSync(process.env.GITHUB_OUTPUT, `workspace=${release.workspace}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `dist_tag=${distTag}\n`); ' - name: Install dependencies run: npm ci --no-audit --no-fund - - name: Verify v2 prerelease graph + - name: Verify v2 release graph run: npm run release:check - - name: Publish prerelease package + - name: Publish package env: RELEASE_WORKSPACE: ${{ steps.release.outputs.workspace }} - run: npm publish -w "$RELEASE_WORKSPACE" --tag next --access public --provenance + RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} + run: npm publish -w "$RELEASE_WORKSPACE" --tag "$RELEASE_DIST_TAG" --access public --provenance diff --git a/apps/site/src/routes/Home.tsx b/apps/site/src/routes/Home.tsx index 8ab8e720..f6bba8a3 100644 --- a/apps/site/src/routes/Home.tsx +++ b/apps/site/src/routes/Home.tsx @@ -45,7 +45,7 @@ export function Home() {
Install
-
npm install @interactive-os/json-document@2.0.0-rc.0
+
npm install @interactive-os/json-document@2.0.0
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 e4b78bc7..a658adf9 100644 --- a/apps/site/tests/docs-consistency.test.ts +++ b/apps/site/tests/docs-consistency.test.ts @@ -121,9 +121,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-rc\.0/); + expect(docs.readme).toMatch(/npm install @interactive-os\/json-document@2\.0\.0/); expect(docs.readme).toMatch(/provider-neutral/); - expect(docs.llms).toMatch(/2\.0\.0-rc\.0.*Candidate/); + expect(docs.llms).toMatch(/2\.0\.0.*Stable/); }); test("keeps JSONPath scoped to search and JSON Pointer scoped to mutation", () => { diff --git a/apps/site/tests/site-shell.test.tsx b/apps/site/tests/site-shell.test.tsx index 43029a78..e12ca9f4 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(/Provider-neutral JSON editing/)).toBeTruthy(); - expect(screen.getByText("npm install @interactive-os/json-document@2.0.0-rc.0")).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"); expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe("https://github.com/developer-1px/json-document"); diff --git a/docs/changelog.md b/docs/changelog.md index 6f3348cb..da6d5f09 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,14 @@ All notable changes to the active v2 packages are documented here. The complete ## Unreleased +- Declared the `2.0.0` Projection Profile Stable after the same black-box suite + passed the public reference binding and a reference-free independent + implementation across form, table/data-grid, outliner/tree, rich text, and + storage/collaboration pressure vectors. +- Converged Core JSON equality and canonical array-index parsing to one + primitive each, locked collaboration package-local equivalents to shared + vectors, and recorded the intentional validation/owning-clone/trusted-clone + responsibility split. - Added independent prerelease tag publishing for the Core and both optional collaboration packages, and kept the live-site archive gate aware of the two supported v2 companions. diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 5b420a8a..1f43539f 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-rc.0", + "version": "2.0.0", "description": "Provider-neutral JSON editing protocol and headless document projection.", "license": "MIT", "summary": "문서, 표, 슬라이드, 캔버스, 노트 편집기가 함께 쓸 수 있는 provider-neutral\nJSON 편집 protocol과 headless document projection입니다.", @@ -59,7 +59,7 @@ "status": "companion", "private": false, "publishable": true, - "version": "0.1.0-rc.0", + "version": "0.1.0-rc.1", "description": "Transport-free causal collaboration provider for @interactive-os/json-document.", "license": "MIT", "summary": "Transport-free causal collaboration provider for the six-member\n`@interactive-os/json-document` Projection contract.", @@ -119,7 +119,7 @@ "status": "companion", "private": false, "publishable": true, - "version": "0.1.0-rc.0", + "version": "0.1.0-rc.1", "description": "IME-safe contenteditable publication lease for json-document collaboration text.", "license": "MIT", "summary": "IME-safe DOM publication lease for\n`@interactive-os/json-document-collaboration/text`.", diff --git a/docs/standard/v2-projection-profile.md b/docs/standard/v2-projection-profile.md index de0fdd55..b72061a7 100644 --- a/docs/standard/v2-projection-profile.md +++ b/docs/standard/v2-projection-profile.md @@ -1,7 +1,7 @@ # json-document v2 Projection Profile -상태: Candidate (`2.0.0-rc.0`). TypeScript root binding과 reference -implementation은 구현됐지만 stable gate는 아직 통과하지 않았다. +상태: Stable (`2.0.0`). TypeScript root binding, reference implementation, +독립 test implementation, 다섯 pressure vertical이 stable gate를 통과했다. 이 profile은 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 의존할 수 있는 최소 JSON 편집 계약을 정의한다. 구현체는 더 많은 기능을 제공할 수 @@ -159,6 +159,12 @@ conformance corpus의 public-root binding을 서로 분리한다. | `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 | suite가 export하는 structural type은 test harness 내부 계약이며 package public export가 아니다. Vendored RFC 6902 corpus 112건 중 110건을 실행한다. 남은 2건은 @@ -166,10 +172,28 @@ export가 아니다. Vendored RFC 6902 corpus 112건 중 110건을 실행한다. member만 남아 public operation object로는 그 입력을 표현할 수 없어, 각 fixture에 명시적인 `disabledReason`을 기록한다. -21개 요구사항의 현재 증거 상태는 runtime 13개, static 6개, deferred 2개다. -schema-free `applyPatch`는 구현됐다. transform identity의 two-provider 검증, -독립 구현과 다섯 pressure vertical 통과는 아직 deferred이므로 Candidate를 -stable이라고 선언할 수 없다. +21개 요구사항의 현재 증거 상태는 runtime 15개, static 6개, deferred 0개다. +schema-free `applyPatch`와 acceptance transform identity는 reference와 독립 +구현에서 같은 vector로 검증한다. 같은 Projection suite와 pressure suite는 +reference와 독립 구현을 모두 통과하며, collaboration public binding도 같은 +다섯 vertical을 통과한다. collaboration 구현은 Core protocol을 조합하므로 +독립 구현 수에는 포함하지 않는다. + +## Durability primitive boundary + +Core의 JSON equality는 하나의 equality leaf가 소유한다. canonical array index +`[0]|[1-9][0-9]*`와 JavaScript safe-integer 경계도 하나의 Pointer leaf가 +소유하며, RFC 6902 append marker `-`는 write 문맥에서만 별도로 처리한다. +collaboration package는 Core private path를 deep import하지 않고 package-local +두 primitive를 유지하며 `foundation-vectors.json`을 함께 실행해 의미 차이를 +드러낸다. + +JSON validation, owning clone, trusted clone은 하나의 traversal abstraction으로 +합치지 않는다. validation은 오류 위치를 설명하고, owning clone은 검증과 +reference 격리를 함께 수행하며, trusted clone은 이미 검증된 값만 받는 +precondition을 가진다. validation과 owning clone이 공유해야 하는 +array-property 분류만 공통 leaf에 두고, parity test가 untrusted boundary의 +동일한 성공·실패와 trusted clone의 소유권 분리를 고정한다. ## Package binding diff --git a/docs/standard/v2-public-surface.json b/docs/standard/v2-public-surface.json index 8c665faa..09d1b00d 100644 --- a/docs/standard/v2-public-surface.json +++ b/docs/standard/v2-public-surface.json @@ -1,10 +1,10 @@ { "formatVersion": 1, - "status": "candidate", + "status": "stable", "sourceContract": "packages/json-document/public-contract.json#root", "package": { "name": "@interactive-os/json-document", - "version": "2.0.0-rc.0", + "version": "2.0.0", "entrypoint": ".", "excludedEntrypoints": [ "./session", @@ -26,7 +26,13 @@ "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" + "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", diff --git a/llms.txt b/llms.txt index ca27ccc4..90a53255 100644 --- a/llms.txt +++ b/llms.txt @@ -3,9 +3,9 @@ json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 사용할 수 있는 provider-neutral JSON 편집 Kernel이다. UI component library가 아니다. -현재 package version과 표준 상태는 `2.0.0-rc.0` Candidate다. Root binding은 -구현됐지만 독립 구현과 다섯 pressure vertical의 conformance gate가 남아 있으므로 -stable이라고 부르지 않는다. +현재 package version과 표준 상태는 `2.0.0` Stable이다. Reference와 독립 +implementation이 같은 conformance suite를 통과하고, 다섯 pressure vertical을 +reference·독립·collaboration binding에서 검증한다. 공식 사이트: https://developer-1px.github.io/json-document/ diff --git a/package-lock.json b/package-lock.json index f328f0d0..98712f8f 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.0", + "version": "0.1.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-rc.0", - "@interactive-os/json-document-collaboration": "^0.1.0-rc.0" + "@interactive-os/json-document": "^2.0.0", + "@interactive-os/json-document-collaboration": "^0.1.0-rc.1" } }, "packages/json-document": { "name": "@interactive-os/json-document", - "version": "2.0.0-rc.0", + "version": "2.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.0", + "version": "0.1.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-rc.0" + "@interactive-os/json-document": "^2.0.0" } } } diff --git a/packages/contenteditable-collaboration/package.json b/packages/contenteditable-collaboration/package.json index 88758189..ae5cbbe7 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.0", + "version": "0.1.0-rc.1", "description": "IME-safe contenteditable publication 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-rc.0", - "@interactive-os/json-document-collaboration": "^0.1.0-rc.0" + "@interactive-os/json-document": "^2.0.0", + "@interactive-os/json-document-collaboration": "^0.1.0-rc.1" }, "devDependencies": { "@interactive-os/json-document": "*", diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index 79dfa737..f13b397c 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.0", + "version": "0.1.0-rc.1", "description": "Transport-free causal collaboration provider for @interactive-os/json-document.", "type": "module", "license": "MIT", @@ -46,7 +46,7 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { - "@interactive-os/json-document": "^2.0.0-rc.0" + "@interactive-os/json-document": "^2.0.0" }, "devDependencies": { "@interactive-os/json-document": "*", diff --git a/packages/json-document-collaboration/src/arrayIndex.ts b/packages/json-document-collaboration/src/arrayIndex.ts new file mode 100644 index 00000000..546e5cad --- /dev/null +++ b/packages/json-document-collaboration/src/arrayIndex.ts @@ -0,0 +1,5 @@ +export function parseArrayIndex(segment: string): number | null { + if (!/^(0|[1-9]\d*)$/.test(segment)) return null; + const index = Number(segment); + return Number.isSafeInteger(index) ? index : null; +} diff --git a/packages/json-document-collaboration/src/create.ts b/packages/json-document-collaboration/src/create.ts index d58c96b7..4361efff 100644 --- a/packages/json-document-collaboration/src/create.ts +++ b/packages/json-document-collaboration/src/create.ts @@ -29,6 +29,7 @@ import { prepareGraph, type PreparedGraph, } from "./change.js"; +import { jsonEqual } from "./jsonEqual.js"; import { createCheckpoint } from "./checkpoint.js"; import { acceptCandidate, @@ -1455,10 +1456,6 @@ function textFailure( return Object.freeze({ ok: false, code, reason }); } -function jsonEqual(left: JSONValue, right: JSONValue): boolean { - return canonicalStringify(left) === canonicalStringify(right); -} - function failure( code: string, reason?: string, diff --git a/packages/json-document-collaboration/src/jsonEqual.ts b/packages/json-document-collaboration/src/jsonEqual.ts new file mode 100644 index 00000000..61c95af0 --- /dev/null +++ b/packages/json-document-collaboration/src/jsonEqual.ts @@ -0,0 +1,7 @@ +import type { JSONValue } from "@interactive-os/json-document"; + +import { canonicalStringify } from "./change.js"; + +export function jsonEqual(left: JSONValue, right: JSONValue): boolean { + return canonicalStringify(left) === canonicalStringify(right); +} diff --git a/packages/json-document-collaboration/src/translate.ts b/packages/json-document-collaboration/src/translate.ts index 2b741e8d..17c6e679 100644 --- a/packages/json-document-collaboration/src/translate.ts +++ b/packages/json-document-collaboration/src/translate.ts @@ -4,6 +4,7 @@ import { } from "@interactive-os/json-document"; import { changeIdKey } from "./change.js"; +import { parseArrayIndex } from "./arrayIndex.js"; import { applyAuthoredOperation, arrayMembers, @@ -404,12 +405,6 @@ function authoredMemberId( return `member:${changeIdKey(changeId)}:${opIndex}`; } -function parseArrayIndex(segment: string): number | null { - if (!/^(0|[1-9]\d*)$/.test(segment)) return null; - const index = Number(segment); - return Number.isSafeInteger(index) ? index : null; -} - function failure(code: string, reason: string): TreeFailure { return { ok: false, code, reason }; } diff --git a/packages/json-document-collaboration/tests/foundation-parity.test.ts b/packages/json-document-collaboration/tests/foundation-parity.test.ts new file mode 100644 index 00000000..cf8c470e --- /dev/null +++ b/packages/json-document-collaboration/tests/foundation-parity.test.ts @@ -0,0 +1,36 @@ +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 rawVectors from "../../json-document/tests/conformance/v2/foundation-vectors.json" with { type: "json" }; + +interface FoundationVectors { + readonly arrayIndexes: ReadonlyArray<{ + readonly segment: string; + readonly value: number | null; + }>; + readonly equalities: ReadonlyArray<{ + readonly left: JSONValue; + readonly right: JSONValue; + readonly equal: boolean; + }>; +} + +const vectors = rawVectors as FoundationVectors; + +describe("collaboration durability primitive parity", () => { + test("package-local array indexes match the shared Core vectors", () => { + for (const vector of vectors.arrayIndexes) { + expect(parseArrayIndex(vector.segment), vector.segment) + .toBe(vector.value); + } + }); + + test("package-local equality matches the shared Core vectors", () => { + for (const vector of vectors.equalities) { + expect(jsonEqual(vector.left, vector.right)) + .toBe(vector.equal); + } + }); +}); diff --git a/packages/json-document-collaboration/tests/projection-conformance.test.ts b/packages/json-document-collaboration/tests/projection-conformance.test.ts index a46aa141..2389a9c4 100644 --- a/packages/json-document-collaboration/tests/projection-conformance.test.ts +++ b/packages/json-document-collaboration/tests/projection-conformance.test.ts @@ -3,18 +3,24 @@ import { type JSONValue, } from "@interactive-os/json-document"; -import { createCollaborationRuntime } from "../src/index.js"; +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"; function createProjection( acceptance: ProjectionAcceptance, initial: JSONValue, ): Projection { + const accepts = acceptance === "task-list" + ? taskListAcceptance + : acceptance === "attempt-transform" + ? attemptTransformAcceptance + : undefined; return createCollaborationRuntime(initial, { actorId: "conformance", epochId: "projection-conformance/v1", @@ -22,12 +28,19 @@ function createProjection( id: `projection-conformance/${acceptance}`, digest: `projection-conformance/${acceptance}/v1`, }, - ...(acceptance === "task-list" - ? { accepts: taskListAcceptance } - : {}), + ...(accepts === undefined ? {} : { accepts }), }).document; } +function attemptTransformAcceptance( + candidate: JSONValue, +): JSONCapabilityResult { + if (isRecord(candidate)) { + Reflect.set(candidate, "title", "Implicit"); + } + return { ok: true }; +} + function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult { const valid = isRecord(candidate) && typeof candidate.title === "string" @@ -59,3 +72,4 @@ const harness: ProjectionHarness = { }; runProjectionConformance(harness); +runPressureConformance(harness); diff --git a/packages/json-document-collaboration/tsconfig.test.json b/packages/json-document-collaboration/tsconfig.test.json index 78864572..61df74f4 100644 --- a/packages/json-document-collaboration/tsconfig.test.json +++ b/packages/json-document-collaboration/tsconfig.test.json @@ -5,6 +5,9 @@ "paths": { "@interactive-os/json-document": [ "../json-document/src/application/document/index.ts" + ], + "@interactive-os/json-document-collaboration": [ + "./src/index.ts" ] }, "types": [ @@ -19,6 +22,7 @@ "src/**/*.ts", "tests/**/*.ts", "../json-document/tests/conformance/v2/projection-suite.ts", + "../json-document/tests/conformance/v2/pressure-suite.ts", "vitest.config.ts" ] } diff --git a/packages/json-document-collaboration/vitest.config.ts b/packages/json-document-collaboration/vitest.config.ts index 0a8ed5cc..8b78f010 100644 --- a/packages/json-document-collaboration/vitest.config.ts +++ b/packages/json-document-collaboration/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ "../json-document/src/application/document/index.ts", import.meta.url, ).pathname, + "@interactive-os/json-document-collaboration": new URL( + "./src/index.ts", + import.meta.url, + ).pathname, }, }, test: { diff --git a/packages/json-document/README.md b/packages/json-document/README.md index ef6fe448..4b19c09b 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -11,9 +11,9 @@ clipboard는 core 계약이 아닙니다. Pure Protocol -> Document Projection -> host adapter ``` -현재 버전은 `2.0.0-rc.0`입니다. root v2 binding은 구현됐지만, 독립 구현과 다섯 -편집 vertical의 conformance gate를 통과하기 전까지 표준 profile은 Candidate로 -유지합니다. +현재 버전은 `2.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` @@ -23,7 +23,7 @@ Pure Protocol -> Document Projection -> host adapter Core만 쓸 때 필수 dependency가 없습니다. ```sh -npm install @interactive-os/json-document@2.0.0-rc.0 +npm install @interactive-os/json-document@2.0.0 ``` ## 60초 시작 diff --git a/packages/json-document/package.json b/packages/json-document/package.json index e19c3a8f..88e12d98 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-rc.0", + "version": "2.0.0", "description": "Provider-neutral JSON editing protocol and headless document projection.", "type": "module", "license": "MIT", @@ -19,7 +19,7 @@ "publishConfig": { "access": "public", "provenance": true, - "tag": "next" + "tag": "latest" }, "keywords": [ "json", diff --git a/packages/json-document/src/foundation/json/classification.ts b/packages/json-document/src/foundation/json/classification.ts new file mode 100644 index 00000000..f76ed106 --- /dev/null +++ b/packages/json-document/src/foundation/json/classification.ts @@ -0,0 +1,8 @@ +export function isJsonArrayIndexKey(key: string): boolean { + if (key === "") return false; + const index = Number(key); + return Number.isInteger(index) + && index >= 0 + && index < 2 ** 32 - 1 + && String(index) === key; +} diff --git a/packages/json-document/src/foundation/json/clone.ts b/packages/json-document/src/foundation/json/clone.ts index 8c7f83c1..b1f60f9f 100644 --- a/packages/json-document/src/foundation/json/clone.ts +++ b/packages/json-document/src/foundation/json/clone.ts @@ -1,4 +1,5 @@ import { buildPointer } from "../pointer/core.js"; +import { isJsonArrayIndexKey } from "./classification.js"; import { cloneTrustedPlainJson } from "./trustedClone.js"; import { jsonSerializableErrorFast } from "./serializable.js"; @@ -202,7 +203,7 @@ function cloneJsonSerializableDetailed(value: T): CloneJsonResult { if (Object.getOwnPropertyNames(v).length !== v.length + 1) { for (const key of Object.getOwnPropertyNames(v)) { - if (key === "length" || isArrayIndexKey(key)) continue; + if (key === "length" || isJsonArrayIndexKey(key)) continue; path.push(key); const reason = `${at()}: non-index array property is not JSON`; path.pop(); @@ -257,9 +258,3 @@ function cloneJsonSerializableDetailed(value: T): CloneJsonResult { const cloned = visit(value); return error === null ? { ok: true, value: cloned as T } : { ok: false, reason: error }; } - -function isArrayIndexKey(key: string): boolean { - if (key === "") return false; - const index = Number(key); - return Number.isInteger(index) && index >= 0 && index < 2 ** 32 - 1 && String(index) === key; -} diff --git a/packages/json-document/src/foundation/json/serializable.ts b/packages/json-document/src/foundation/json/serializable.ts index 582f9593..f2b03109 100644 --- a/packages/json-document/src/foundation/json/serializable.ts +++ b/packages/json-document/src/foundation/json/serializable.ts @@ -1,4 +1,5 @@ import { buildPointer } from "../pointer/core.js"; +import { isJsonArrayIndexKey } from "./classification.js"; export function jsonSerializableError(value: unknown): string | null { return jsonSerializableErrorFast(value) === null ? null : jsonSerializableErrorDetailed(value); @@ -130,7 +131,7 @@ function jsonSerializableErrorDetailed(value: unknown): string | null { } if (Object.getOwnPropertyNames(v).length !== v.length + 1) { for (const key of Object.getOwnPropertyNames(v)) { - if (key === "length" || isArrayIndexKey(key)) continue; + if (key === "length" || isJsonArrayIndexKey(key)) continue; path.push(key); const message = `${at()}: non-index array property is not JSON`; path.pop(); @@ -171,9 +172,3 @@ function jsonSerializableErrorDetailed(value: unknown): string | null { return visit(value); } - -function isArrayIndexKey(key: string): boolean { - if (key === "") return false; - const index = Number(key); - return Number.isInteger(index) && index >= 0 && index < 2 ** 32 - 1 && String(index) === key; -} diff --git a/packages/json-document/src/foundation/patch/apply.ts b/packages/json-document/src/foundation/patch/apply.ts index 3561fd3f..f4774ab2 100644 --- a/packages/json-document/src/foundation/patch/apply.ts +++ b/packages/json-document/src/foundation/patch/apply.ts @@ -2,11 +2,11 @@ import { isPrefix, parsePointer, type Pointer } from "../pointer/core.js"; import { cloneJson } from "../json/clone.js"; +import { jsonEqual } from "../json/equal.js"; import { jsonSerializableError } from "../json/serializable.js"; import type { ErrorCode, JSONPatchOperation } from "./contract.js"; import { attachPointer, - deepEqual, getValueAt, mutateContainer, parseSafe, @@ -133,7 +133,7 @@ export function applyOpRaw(state: unknown, op: JSONPatchOperation): RawResult { case "test": { const got = getValueAt(state, segments); if (!got.ok) return attachPointer(got, op.path); - if (!deepEqual(got.value, op.value)) return { error: "test_failed", reason: "value mismatch", pointer: op.path }; + if (!jsonEqual(got.value, op.value)) return { error: "test_failed", reason: "value mismatch", pointer: op.path }; return { state }; } case "copy": diff --git a/packages/json-document/src/foundation/patch/container.ts b/packages/json-document/src/foundation/patch/container.ts index 2c1dde4a..7ee68016 100644 --- a/packages/json-document/src/foundation/patch/container.ts +++ b/packages/json-document/src/foundation/patch/container.ts @@ -1,6 +1,7 @@ // patch.ts 내부 헬퍼 — public API 아님. docs/standard/json-document-spec.md §3 의 RFC 6902 구현 디테일. import { parsePointer, readAt, type Pointer, PointerSyntaxError } from "../pointer/core.js"; +import { parseArrayIndex } from "../pointer/arrayIndex.js"; import type { ErrorCode, JSONPatchOperation } from "./contract.js"; // RFC 6902 §4.1: `/-` 는 array append marker. 적용 시점의 array 길이로 concrete index 정규화. @@ -47,39 +48,6 @@ export function normalizeAppliedOp(op: JSONPatchOperation, after: unknown): JSON export type ContainerError = { error: ErrorCode; reason?: string }; export type ParseSafeResult = { ok: true; segs: string[] } | { error: ErrorCode; reason: string; pointer: Pointer }; -export function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; - if (Array.isArray(a) !== Array.isArray(b)) return false; - if (Array.isArray(a)) { - if (a.length !== (b as unknown[]).length) return false; - for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], (b as unknown[])[i])) return false; - return true; - } - const ao = a as Record; - const bo = b as Record; - const ak = Object.keys(ao); - if (ak.length !== Object.keys(bo).length) return false; - for (const k of ak) { - if (!Object.prototype.hasOwnProperty.call(bo, k)) return false; - if (!deepEqual(ao[k], bo[k])) return false; - } - return true; -} - -function parseArrayIndex(seg: string): number | null { - if (seg === "-") return -1; // RFC 6901 §4 append marker - if (seg.length === 0) return null; - const first = seg.charCodeAt(0); - if (first === 48) return seg.length === 1 ? 0 : null; - if (first < 49 || first > 57) return null; - for (let index = 1; index < seg.length; index += 1) { - const code = seg.charCodeAt(index); - if (code < 48 || code > 57) return null; - } - return Number(seg); -} - export function attachPointer(e: ContainerError, pointer: Pointer): ContainerError & { pointer: Pointer } { return { ...e, pointer }; } @@ -100,7 +68,7 @@ export function getValueAt(state: unknown, segments: string[]): { ok: true; valu if (cur === null || cur === undefined) return fail(`segment ${i}: ${seg}`); if (Array.isArray(cur)) { const idx = parseArrayIndex(seg); - if (idx === null || idx === -1 || idx >= cur.length) return fail(`segment ${i}: ${seg}`); + if (idx === null || idx >= cur.length) return fail(`segment ${i}: ${seg}`); cur = cur[idx]; } else if (typeof cur === "object") { if (!Object.prototype.hasOwnProperty.call(cur, seg)) return fail(`segment ${i}: ${seg}`); @@ -127,7 +95,7 @@ export function withMutated( if (cur === null || cur === undefined) return { error: "path_not_found", reason: `segment ${i}` }; if (Array.isArray(cur)) { const idx = parseArrayIndex(seg); - if (idx === null || idx === -1 || idx >= cur.length) return { error: "path_not_found", reason: `segment ${i}: ${seg}` }; + if (idx === null || idx >= cur.length) return { error: "path_not_found", reason: `segment ${i}: ${seg}` }; parents.push({ container: cur, key: String(idx) }); cur = cur[idx]; } else if (typeof cur === "object") { @@ -160,7 +128,7 @@ export function mutateContainer(parent: unknown, key: string, verb: Verb, value? if (Array.isArray(parent)) { if (verb === "set" && key === "-") return { value: parent.concat([value]) }; const idx = parseArrayIndex(key); - if (idx === null || idx === -1) return { error: "path_not_found", reason: `array index: ${key}` }; + if (idx === null) return { error: "path_not_found", reason: `array index: ${key}` }; if (verb === "set") { if (idx > parent.length) return { error: "path_not_found", reason: `out of range: ${key}` }; if (idx === parent.length) return { value: parent.concat([value]) }; diff --git a/packages/json-document/src/foundation/patch/path.ts b/packages/json-document/src/foundation/patch/path.ts index dff31c51..96041b0e 100644 --- a/packages/json-document/src/foundation/patch/path.ts +++ b/packages/json-document/src/foundation/patch/path.ts @@ -1,4 +1,5 @@ import { buildPointer, parentPointer, type Pointer } from "../pointer/core.js"; +import { parseArrayIndex } from "../pointer/arrayIndex.js"; import { getValueAt, parseSafe } from "./container.js"; export interface ArrayFieldPath { @@ -28,7 +29,7 @@ export function arrayLocation(path: Pointer): { parent: Pointer; index: number | if (!("ok" in parsed)) return null; const segment = parsed.segs[parsed.segs.length - 1]; if (segment === undefined) return null; - const index = segment === "-" ? "-" : numericSegment(segment); + const index = segment === "-" ? "-" : parseArrayIndex(segment); return index === null ? null : { parent, index }; } @@ -42,18 +43,6 @@ export function arrayRemoveLocation(path: Pointer): { parent: Pointer; index: nu : { parent: location.parent, index: location.index }; } -export function numericSegment(segment: string): number | null { - if (segment.length === 0) return null; - const first = segment.charCodeAt(0); - if (first === 48) return segment.length === 1 ? 0 : null; - if (first < 49 || first > 57) return null; - for (let index = 1; index < segment.length; index += 1) { - const code = segment.charCodeAt(index); - if (code < 48 || code > 57) return null; - } - return Number(segment); -} - export function appendArrayIndexPath(parent: Pointer, index: number): Pointer { return parent === "" ? `/${index}` : `${parent}/${index}`; } @@ -69,7 +58,7 @@ export function parseArrayFieldPath(path: Pointer): ArrayFieldPath | null { const parsed = parseSafe(path); if (!("ok" in parsed) || parsed.segs.length < 2) return null; const key = parsed.segs[parsed.segs.length - 1]!; - const index = numericSegment(parsed.segs[parsed.segs.length - 2]!); + const index = parseArrayIndex(parsed.segs[parsed.segs.length - 2]!); return index === null ? null : { arrayPath: buildPointer(parsed.segs.slice(0, -2)), index, key }; @@ -91,7 +80,7 @@ export function parseKnownArrayFieldIndex(path: Pointer, text: ArrayFieldText): if (!path.startsWith(text.prefixText) || !path.endsWith(text.suffixText)) return null; const indexEnd = path.length - text.suffixText.length; const indexText = path.slice(text.prefixText.length, indexEnd); - return indexText.includes("/") ? null : numericSegment(indexText); + return indexText.includes("/") ? null : parseArrayIndex(indexText); } export function parseFirstArrayNestedPath(state: unknown, path: Pointer): ArrayNestedPath | null { @@ -99,7 +88,7 @@ export function parseFirstArrayNestedPath(state: unknown, path: Pointer): ArrayN if (!("ok" in parsed) || parsed.segs.length < 3) return null; for (let index = 0; index < parsed.segs.length - 1; index += 1) { - const rowIndex = numericSegment(parsed.segs[index]!); + const rowIndex = parseArrayIndex(parsed.segs[index]!); if (rowIndex === null) continue; const arraySegments = parsed.segs.slice(0, index); @@ -142,7 +131,7 @@ export function parseKnownArrayNestedIndex( const arraySegments = parsed.segs.slice(0, arraySegmentsLength); if (buildPointer(arraySegments) !== arrayPath) return null; - return numericSegment(parsed.segs[arraySegmentsLength]!); + return parseArrayIndex(parsed.segs[arraySegmentsLength]!); } function parseKnownArrayNestedIndexText( @@ -153,7 +142,7 @@ function parseKnownArrayNestedIndexText( if (!path.startsWith(prefixText) || !path.endsWith(suffixText)) return null; const indexEnd = path.length - suffixText.length; const indexText = path.slice(prefixText.length, indexEnd); - return indexText.includes("/") ? null : numericSegment(indexText); + return indexText.includes("/") ? null : parseArrayIndex(indexText); } function parseSimpleArrayFieldPath(path: Pointer): ArrayFieldPath | null { @@ -163,7 +152,7 @@ function parseSimpleArrayFieldPath(path: Pointer): ArrayFieldPath | null { const indexSlash = path.lastIndexOf("/", keySlash - 1); if (indexSlash < 0) return null; - const index = numericSegment(path.slice(indexSlash + 1, keySlash)); + const index = parseArrayIndex(path.slice(indexSlash + 1, keySlash)); if (index === null) return null; return { arrayPath: path.slice(0, indexSlash), index, key: path.slice(keySlash + 1) }; @@ -174,7 +163,7 @@ function parseSimpleArrayElementPath(path: Pointer): { parent: Pointer; index: n const indexSlash = path.lastIndexOf("/"); if (indexSlash < 0) return null; - const index = numericSegment(path.slice(indexSlash + 1)); + const index = parseArrayIndex(path.slice(indexSlash + 1)); return index === null ? null : { parent: path.slice(0, indexSlash), index }; diff --git a/packages/json-document/src/foundation/patch/replaceValue.ts b/packages/json-document/src/foundation/patch/replaceValue.ts index 0a6ae528..76481d23 100644 --- a/packages/json-document/src/foundation/patch/replaceValue.ts +++ b/packages/json-document/src/foundation/patch/replaceValue.ts @@ -1,5 +1,5 @@ +import { parseArrayIndex } from "../pointer/arrayIndex.js"; import { objectHasOwn } from "./object.js"; -import { numericSegment } from "./path.js"; export function replaceValueAtSegments( current: unknown, @@ -12,7 +12,7 @@ export function replaceValueAtSegments( const segment = segments[index]!; if (Array.isArray(current)) { - const childIndex = numericSegment(segment); + const childIndex = parseArrayIndex(segment); if (childIndex === null || childIndex >= current.length) return null; const child = replaceValueAtSegments(current[childIndex], segments, index + 1, value); if (child === null) return null; diff --git a/packages/json-document/src/foundation/patch/sequentialReplace.ts b/packages/json-document/src/foundation/patch/sequentialReplace.ts index 9bf2cdfe..1f085eab 100644 --- a/packages/json-document/src/foundation/patch/sequentialReplace.ts +++ b/packages/json-document/src/foundation/patch/sequentialReplace.ts @@ -1,9 +1,9 @@ import { jsonSerializableError } from "../json/serializable.js"; +import { parseArrayIndex } from "../pointer/arrayIndex.js"; import { applyOpRaw, validateOperationShape } from "./apply.js"; import { parseSafe } from "./container.js"; import type { FastPatchResult, JSONPatchOperation } from "./contract.js"; import { objectHasOwn } from "./object.js"; -import { numericSegment } from "./path.js"; type ReplaceOperation = Extract; @@ -170,7 +170,7 @@ function readDraftChild( segment: string, ): { ok: true; key: number | string; value: unknown } | { ok: false } { if (Array.isArray(container)) { - const index = numericSegment(segment); + const index = parseArrayIndex(segment); return index === null || index >= container.length ? { ok: false } : { ok: true, key: index, value: container[index] }; diff --git a/packages/json-document/src/foundation/patch/track.ts b/packages/json-document/src/foundation/patch/track.ts index 92a41f4d..8113d0cc 100644 --- a/packages/json-document/src/foundation/patch/track.ts +++ b/packages/json-document/src/foundation/patch/track.ts @@ -3,8 +3,8 @@ // 출력: 새 Pointer (또는 null = cascading drop) import { tryParsePointer, buildPointer, isPrefix, parentPointer, lastSegmentIndex, withLastSegment, readAt, type Pointer } from "../pointer/core.js"; +import { parseArrayIndex } from "../pointer/arrayIndex.js"; import type { JSONPatchOperation } from "./contract.js"; -import { arrayIndexValue } from "../pointer/array.js"; export function exists(state: unknown, pointer: Pointer): boolean { const segments = tryParsePointer(pointer); @@ -42,7 +42,7 @@ export function recoverLostPointer( } function isArrayIndex(seg: string): boolean { - return arrayIndexValue(seg) !== null; + return parseArrayIndex(seg) !== null; } // at = parent + [pivotSeg]. target 이 같은 array 부모를 공유하고 그 위치의 인덱스가 diff --git a/packages/json-document/src/foundation/patch/value.ts b/packages/json-document/src/foundation/patch/value.ts index 2caf4796..61fcd028 100644 --- a/packages/json-document/src/foundation/patch/value.ts +++ b/packages/json-document/src/foundation/patch/value.ts @@ -1,11 +1,11 @@ import type { Pointer } from "../pointer/core.js"; +import { parseArrayIndex } from "../pointer/arrayIndex.js"; import { mutateContainer, parseSafe, withMutated, } from "./container.js"; import { objectHasOwn } from "./object.js"; -import { numericSegment } from "./path.js"; import type { ErrorCode, JSONPatchOperation } from "./contract.js"; export function applyTrustedValueMutation( @@ -74,7 +74,7 @@ function applyObjectArrayElementTrustedValueMutation( if (arrayKeySlash === -1 || path.indexOf("/", arrayKeySlash + 1) !== -1) return null; const arrayKey = path.slice(1, arrayKeySlash); - const itemIndex = numericSegment(path.slice(arrayKeySlash + 1)); + const itemIndex = parseArrayIndex(path.slice(arrayKeySlash + 1)); if (itemIndex === null) return null; if (state === null || typeof state !== "object" || Array.isArray(state)) return null; @@ -110,7 +110,7 @@ function applyObjectArrayFieldTrustedValueMutation( if (fieldSlash === -1 || path.indexOf("/", fieldSlash + 1) !== -1) return null; const arrayKey = path.slice(1, arrayKeySlash); - const rowIndex = numericSegment(path.slice(arrayKeySlash + 1, fieldSlash)); + const rowIndex = parseArrayIndex(path.slice(arrayKeySlash + 1, fieldSlash)); if (rowIndex === null) return null; const field = path.slice(fieldSlash + 1); if (field === "") return null; diff --git a/packages/json-document/src/foundation/pointer/array.ts b/packages/json-document/src/foundation/pointer/array.ts index 209c320f..3c43da73 100644 --- a/packages/json-document/src/foundation/pointer/array.ts +++ b/packages/json-document/src/foundation/pointer/array.ts @@ -1,23 +1,12 @@ import { buildPointer, tryParsePointer, type Pointer } from "./core.js"; - -export function arrayIndexValue(seg: string): number | null { - if (seg === "0") return 0; - if (seg.length === 0) return null; - const first = seg.charCodeAt(0); - if (first < 49 || first > 57) return null; - for (let index = 1; index < seg.length; index += 1) { - const code = seg.charCodeAt(index); - if (code < 48 || code > 57) return null; - } - return Number(seg); -} +import { parseArrayIndex } from "./arrayIndex.js"; export function arrayElementLocation(path: Pointer): { parent: Pointer; index: number } | null { if (path === "" || path[0] !== "/") return null; if (!path.includes("~")) { const indexSlash = path.lastIndexOf("/"); if (indexSlash < 0) return null; - const index = arrayIndexValue(path.slice(indexSlash + 1)); + const index = parseArrayIndex(path.slice(indexSlash + 1)); return index === null ? null : { parent: path.slice(0, indexSlash), index }; @@ -26,7 +15,7 @@ export function arrayElementLocation(path: Pointer): { parent: Pointer; index: n if (segments === null) return null; const segment = segments[segments.length - 1]; if (segment === undefined) return null; - const index = arrayIndexValue(segment); + const index = parseArrayIndex(segment); if (index === null) return null; return { parent: buildPointer(segments.slice(0, -1)), diff --git a/packages/json-document/src/foundation/pointer/arrayIndex.ts b/packages/json-document/src/foundation/pointer/arrayIndex.ts new file mode 100644 index 00000000..cc90ca0c --- /dev/null +++ b/packages/json-document/src/foundation/pointer/arrayIndex.ts @@ -0,0 +1,14 @@ +export function parseArrayIndex(segment: string): number | null { + if (segment === "0") return 0; + if (segment.length === 0) return null; + + const first = segment.charCodeAt(0); + if (first < 49 || first > 57) return null; + for (let index = 1; index < segment.length; index += 1) { + const code = segment.charCodeAt(index); + if (code < 48 || code > 57) return null; + } + + const value = Number(segment); + return Number.isSafeInteger(value) ? value : null; +} diff --git a/packages/json-document/src/foundation/pointer/core.ts b/packages/json-document/src/foundation/pointer/core.ts index a6076bb7..fe248a99 100644 --- a/packages/json-document/src/foundation/pointer/core.ts +++ b/packages/json-document/src/foundation/pointer/core.ts @@ -1,6 +1,8 @@ // RFC 6901 — JSON Pointer. // 정본: docs/standard/json-document-spec.md §2. 변환은 lossless. +import { parseArrayIndex } from "./arrayIndex.js"; + export type Pointer = string; export function escapeSegment(s: string): string { @@ -119,21 +121,7 @@ export function lastSegment(pointer: Pointer): string | null { /** 마지막 segment 가 array index 면 그 정수, 아니면 `null` (record key 또는 root). */ export function lastSegmentIndex(pointer: Pointer): number | null { const seg = lastSegment(pointer); - if (seg === null) return null; - if (!isArrayIndexSegment(seg)) return null; - return Number(seg); -} - -function isArrayIndexSegment(segment: string): boolean { - if (segment === "") return false; - if (segment === "0") return true; - const first = segment.charCodeAt(0); - if (first < 49 || first > 57) return false; - for (let index = 1; index < segment.length; index += 1) { - const code = segment.charCodeAt(index); - if (code < 48 || code > 57) return false; - } - return true; + return seg === null ? null : parseArrayIndex(seg); } /** Pointer 끝에 segment 추가. `appendSegment("/a", 0)` → `"/a/0"`, escape 자동. */ @@ -166,8 +154,8 @@ export function readAt(state: unknown, segs: ReadonlyArray): { ok: true; if (Array.isArray(cur)) { // RFC 6901 §4: 배열 인덱스는 `0` 또는 `[1-9][0-9]*` 만 허용. write path 와 // 동일한 strict 파서를 써서 1.0/01/+1/"-" 같은 non-canonical 토큰을 거부한다. - if (!isArrayIndexSegment(seg)) return { ok: false }; - const i = Number(seg); + const i = parseArrayIndex(seg); + if (i === null) return { ok: false }; if (i >= cur.length) return { ok: false }; cur = cur[i]; } else { diff --git a/packages/json-document/tests/conformance/v2/foundation-vectors.json b/packages/json-document/tests/conformance/v2/foundation-vectors.json new file mode 100644 index 00000000..a061646e --- /dev/null +++ b/packages/json-document/tests/conformance/v2/foundation-vectors.json @@ -0,0 +1,63 @@ +{ + "formatVersion": 1, + "status": "stable", + "profile": "docs/standard/v2-projection-profile.md", + "arrayIndexes": [ + { "segment": "0", "value": 0 }, + { "segment": "1", "value": 1 }, + { "segment": "42", "value": 42 }, + { "segment": "9007199254740991", "value": 9007199254740991 }, + { "segment": "", "value": null }, + { "segment": "-", "value": null }, + { "segment": "00", "value": null }, + { "segment": "01", "value": null }, + { "segment": "+1", "value": null }, + { "segment": "-1", "value": null }, + { "segment": "1.0", "value": null }, + { "segment": " 1", "value": null }, + { "segment": "1 ", "value": null }, + { "segment": "9007199254740992", "value": null } + ], + "equalities": [ + { + "left": { "title": "same", "nested": { "count": 1 } }, + "right": { "nested": { "count": 1 }, "title": "same" }, + "equal": true + }, + { + "left": ["a", { "done": false }], + "right": ["a", { "done": false }], + "equal": true + }, + { + "left": ["a", "b"], + "right": ["b", "a"], + "equal": false + }, + { + "left": { "value": null }, + "right": {}, + "equal": false + }, + { + "left": { "nested": { "count": 1 } }, + "right": { "nested": { "count": 2 } }, + "equal": false + }, + { + "left": [], + "right": {}, + "equal": false + } + ], + "jsonValues": [ + null, + true, + 0, + "text", + [], + {}, + [1, "two", null, { "nested": [false] }], + { "items": [{ "id": "a" }] } + ] +} diff --git a/packages/json-document/tests/conformance/v2/pointer-vectors.json b/packages/json-document/tests/conformance/v2/pointer-vectors.json index 2ff56f6a..dcceeda2 100644 --- a/packages/json-document/tests/conformance/v2/pointer-vectors.json +++ b/packages/json-document/tests/conformance/v2/pointer-vectors.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "status": "candidate", + "status": "stable", "profile": "docs/standard/v2-projection-profile.md", "requirements": ["JD2-ADDRESS-001", "JD2-PATCH-001"], "parse": [ diff --git a/packages/json-document/tests/conformance/v2/pressure-suite.ts b/packages/json-document/tests/conformance/v2/pressure-suite.ts new file mode 100644 index 00000000..d50e5541 --- /dev/null +++ b/packages/json-document/tests/conformance/v2/pressure-suite.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; + +import type { + JSONPatchOperation, + JSONValue, + ProjectionAcceptance, + ProjectionChange, + ProjectionHarness, + ProjectionMetadata, +} from "./projection-suite.js"; +import rawVectors from "./pressure-vectors.json" with { type: "json" }; + +type ExpectedObject = Readonly>; + +interface PressureStep { + readonly operations: ReadonlyArray; + readonly metadata?: ProjectionMetadata; + readonly expect: { + readonly probe: ExpectedObject; + readonly commit: ExpectedObject; + readonly value: JSONValue; + readonly notificationCount: number; + }; +} + +interface PressureVertical { + readonly id: string; + readonly requirements: ReadonlyArray; + readonly acceptance: ProjectionAcceptance; + readonly initial: JSONValue; + readonly steps: ReadonlyArray; + readonly replay?: boolean; + readonly reads?: ReadonlyArray<{ + readonly pointer: string; + readonly expect: ExpectedObject; + }>; + readonly queries?: ReadonlyArray<{ + readonly jsonPath: string; + readonly expect: ExpectedObject; + }>; +} + +interface PressureManifest { + readonly verticals: ReadonlyArray; +} + +const manifest = rawVectors as unknown as PressureManifest; + +export function runPressureConformance( + harness: ProjectionHarness, +): void { + describe("json-document v2 pressure verticals", () => { + for (const vertical of manifest.verticals) { + test(`[${vertical.id}]`, () => runVertical(harness, vertical)); + } + }); +} + +function runVertical( + harness: ProjectionHarness, + vertical: PressureVertical, +): void { + const initial = cloneJSON(vertical.initial); + const projection = harness.create(vertical.acceptance, initial); + const changes: ProjectionChange[] = []; + projection.subscribe((change) => { + changes.push(cloneJSON(change)); + }); + + for (const step of vertical.steps) { + const operations = cloneJSON(step.operations); + const metadata = step.metadata === undefined + ? undefined + : cloneJSON(step.metadata); + const probe = projection.canPatch(operations); + expect(probe).toMatchObject(step.expect.probe); + const committed = projection.commit( + operations, + metadata === undefined ? undefined : { metadata }, + ); + expect(committed).toMatchObject(step.expect.commit); + expect(projection.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); + } + for (const query of vertical.queries ?? []) { + expect(projection.query(query.jsonPath)).toMatchObject(query.expect); + } + + if (vertical.replay === true) { + const replay = harness.create(vertical.acceptance, cloneJSON(vertical.initial)); + for (const change of changes) { + const result = replay.commit( + cloneJSON(change.applied), + change.metadata === undefined + ? undefined + : { metadata: cloneJSON(change.metadata) }, + ); + expect(result).toMatchObject({ ok: true }); + } + expect(replay.value).toEqual(projection.value); + } +} + +function cloneJSON(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/packages/json-document/tests/conformance/v2/pressure-vectors.json b/packages/json-document/tests/conformance/v2/pressure-vectors.json new file mode 100644 index 00000000..4c374c67 --- /dev/null +++ b/packages/json-document/tests/conformance/v2/pressure-vectors.json @@ -0,0 +1,387 @@ +{ + "formatVersion": 1, + "status": "stable", + "profile": "docs/standard/v2-projection-profile.md", + "verticals": [ + { + "id": "form", + "requirements": [ + "JD2-SCHEMA-001", + "JD2-CONFORMANCE-002" + ], + "acceptance": "task-list", + "initial": { + "title": "Draft", + "items": [ + { "id": "a", "done": false } + ], + "meta": { "owner": "form" } + }, + "steps": [ + { + "operations": [ + { "op": "replace", "path": "/title", "value": "Ready" } + ], + "expect": { + "probe": { "ok": true }, + "commit": { + "ok": true, + "change": { + "applied": [ + { "op": "replace", "path": "/title", "value": "Ready" } + ] + } + }, + "value": { + "title": "Ready", + "items": [ + { "id": "a", "done": false } + ], + "meta": { "owner": "form" } + }, + "notificationCount": 1 + } + }, + { + "operations": [ + { "op": "replace", "path": "/items/0/done", "value": "yes" } + ], + "expect": { + "probe": { "ok": false, "code": "schema_violation" }, + "commit": { "ok": false, "code": "schema_violation" }, + "value": { + "title": "Ready", + "items": [ + { "id": "a", "done": false } + ], + "meta": { "owner": "form" } + }, + "notificationCount": 1 + } + } + ], + "reads": [ + { + "pointer": "/title", + "expect": { "ok": true, "path": "/title", "value": "Ready" } + } + ] + }, + { + "id": "table-data-grid", + "requirements": [ + "JD2-PATCH-001", + "JD2-CONFORMANCE-002" + ], + "acceptance": "json", + "initial": { + "rows": [ + { "id": "a", "cells": { "score": 1 } }, + { "id": "b", "cells": { "score": 2 } } + ] + }, + "steps": [ + { + "operations": [ + { + "op": "add", + "path": "/rows/-", + "value": { "id": "c", "cells": { "score": 0 } } + }, + { + "op": "replace", + "path": "/rows/2/cells/score", + "value": 3 + }, + { + "op": "move", + "from": "/rows/0", + "path": "/rows/2" + } + ], + "metadata": { + "origin": "grid", + "transaction": "paste-sort" + }, + "expect": { + "probe": { "ok": true }, + "commit": { + "ok": true, + "change": { + "applied": [ + { + "op": "add", + "path": "/rows/2", + "value": { "id": "c", "cells": { "score": 0 } } + }, + { + "op": "replace", + "path": "/rows/2/cells/score", + "value": 3 + }, + { + "op": "move", + "from": "/rows/0", + "path": "/rows/2" + } + ], + "metadata": { + "origin": "grid", + "transaction": "paste-sort" + } + } + }, + "value": { + "rows": [ + { "id": "b", "cells": { "score": 2 } }, + { "id": "c", "cells": { "score": 3 } }, + { "id": "a", "cells": { "score": 1 } } + ] + }, + "notificationCount": 1 + } + } + ], + "queries": [ + { + "jsonPath": "$.rows[*].id", + "expect": { + "ok": true, + "query": "$.rows[*].id", + "pointers": ["/rows/0/id", "/rows/1/id", "/rows/2/id"] + } + } + ] + }, + { + "id": "outliner-tree", + "requirements": [ + "JD2-ADDRESS-001", + "JD2-PATCH-001", + "JD2-CONFORMANCE-002" + ], + "acceptance": "json", + "initial": { + "nodes": [ + { + "id": "a", + "title": "A", + "children": [ + { "id": "a1", "title": "A.1", "children": [] } + ] + }, + { + "id": "b", + "title": "B", + "children": [] + } + ] + }, + "steps": [ + { + "operations": [ + { + "op": "move", + "from": "/nodes/0/children/0", + "path": "/nodes/1/children/-" + } + ], + "expect": { + "probe": { "ok": true }, + "commit": { + "ok": true, + "change": { + "applied": [ + { + "op": "move", + "from": "/nodes/0/children/0", + "path": "/nodes/1/children/0" + } + ] + } + }, + "value": { + "nodes": [ + { + "id": "a", + "title": "A", + "children": [] + }, + { + "id": "b", + "title": "B", + "children": [ + { "id": "a1", "title": "A.1", "children": [] } + ] + } + ] + }, + "notificationCount": 1 + } + } + ], + "reads": [ + { + "pointer": "/nodes/1/children/0/id", + "expect": { + "ok": true, + "path": "/nodes/1/children/0/id", + "value": "a1" + } + } + ] + }, + { + "id": "rich-text", + "requirements": [ + "JD2-COMMIT-002", + "JD2-CONFORMANCE-002" + ], + "acceptance": "json", + "initial": { + "doc": { + "blocks": [ + { "type": "paragraph", "text": "가🙂" } + ] + } + }, + "steps": [ + { + "operations": [ + { + "op": "replace", + "path": "/doc/blocks/0/text", + "value": "가나다🙂" + } + ], + "metadata": { + "origin": "input", + "selection": { "anchor": 3, "focus": 3 } + }, + "expect": { + "probe": { "ok": true }, + "commit": { + "ok": true, + "change": { + "applied": [ + { + "op": "replace", + "path": "/doc/blocks/0/text", + "value": "가나다🙂" + } + ], + "metadata": { + "origin": "input", + "selection": { "anchor": 3, "focus": 3 } + } + } + }, + "value": { + "doc": { + "blocks": [ + { "type": "paragraph", "text": "가나다🙂" } + ] + } + }, + "notificationCount": 1 + } + } + ], + "queries": [ + { + "jsonPath": "$.doc.blocks[*].text", + "expect": { + "ok": true, + "query": "$.doc.blocks[*].text", + "pointers": ["/doc/blocks/0/text"] + } + } + ] + }, + { + "id": "storage-collaboration", + "requirements": [ + "JD2-DATA-002", + "JD2-COMMIT-002", + "JD2-PUBLISH-001", + "JD2-CONFORMANCE-002" + ], + "acceptance": "json", + "initial": { + "counter": 0, + "notes": [] + }, + "steps": [ + { + "operations": [ + { "op": "replace", "path": "/counter", "value": 1 } + ], + "metadata": { + "actor": "a", + "sequence": 1 + }, + "expect": { + "probe": { "ok": true }, + "commit": { + "ok": true, + "change": { + "applied": [ + { "op": "replace", "path": "/counter", "value": 1 } + ], + "metadata": { + "actor": "a", + "sequence": 1 + } + } + }, + "value": { + "counter": 1, + "notes": [] + }, + "notificationCount": 1 + } + }, + { + "operations": [ + { "op": "add", "path": "/notes/-", "value": "synced" } + ], + "metadata": { + "actor": "b", + "sequence": 1 + }, + "expect": { + "probe": { "ok": true }, + "commit": { + "ok": true, + "change": { + "applied": [ + { "op": "add", "path": "/notes/0", "value": "synced" } + ], + "metadata": { + "actor": "b", + "sequence": 1 + } + } + }, + "value": { + "counter": 1, + "notes": ["synced"] + }, + "notificationCount": 2 + } + } + ], + "replay": true, + "reads": [ + { + "pointer": "/notes/0", + "expect": { + "ok": true, + "path": "/notes/0", + "value": "synced" + } + } + ] + } + ] +} diff --git a/packages/json-document/tests/conformance/v2/projection-suite.ts b/packages/json-document/tests/conformance/v2/projection-suite.ts index 4c2a60d1..aee2308b 100644 --- a/packages/json-document/tests/conformance/v2/projection-suite.ts +++ b/packages/json-document/tests/conformance/v2/projection-suite.ts @@ -18,7 +18,10 @@ export type JSONPatchOperation = | { readonly op: "copy"; readonly from: string; readonly path: string } | { readonly op: "test"; readonly path: string; readonly value: JSONValue }; -export type ProjectionAcceptance = "json" | "task-list"; +export type ProjectionAcceptance = + | "json" + | "task-list" + | "attempt-transform"; export type ProjectionMetadata = Readonly>; export interface ProjectionChange { diff --git a/packages/json-document/tests/conformance/v2/projection-vectors.json b/packages/json-document/tests/conformance/v2/projection-vectors.json index 5c03656f..d69e06ae 100644 --- a/packages/json-document/tests/conformance/v2/projection-vectors.json +++ b/packages/json-document/tests/conformance/v2/projection-vectors.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "status": "candidate", + "status": "stable", "profile": "docs/standard/v2-projection-profile.md", "projectionMembers": [ "value", @@ -11,9 +11,9 @@ "subscribe" ], "coverage": { - "runtime": 13, + "runtime": 15, "static": 6, - "deferred": 2 + "deferred": 0 }, "initial": { "title": "Draft", @@ -408,6 +408,65 @@ "notifications": [] } }, + { + "id": "acceptance-cannot-transform-the-candidate", + "kind": "commit", + "requirements": [ + "JD2-SCHEMA-002" + ], + "acceptance": "attempt-transform", + "operations": [ + { + "op": "replace", + "path": "/title", + "value": "Explicit" + } + ], + "expect": { + "probe": { + "ok": true + }, + "commit": { + "ok": true, + "change": { + "applied": [ + { + "op": "replace", + "path": "/title", + "value": "Explicit" + } + ] + } + }, + "value": { + "title": "Explicit", + "items": [ + { + "id": "a", + "done": false + }, + { + "id": "b", + "done": false + } + ], + "meta": { + "owner": "core" + } + }, + "notifications": [ + { + "applied": [ + { + "op": "replace", + "path": "/title", + "value": "Explicit" + } + ] + } + ] + } + }, { "id": "same-scalar-replace-does-not-publish", "kind": "commit", @@ -1138,11 +1197,6 @@ "mode": "static", "reason": "The generic runner matches required fields only and never exact result keys." }, - { - "id": "JD2-SCHEMA-002", - "mode": "deferred", - "reason": "Transform identity needs a provider-neutral acceptance harness and a second provider." - }, { "id": "JD2-SESSION-001", "mode": "static", @@ -1158,11 +1212,6 @@ "mode": "static", "reason": "The vectors run through an injected structural harness without private implementation imports." }, - { - "id": "JD2-CONFORMANCE-002", - "mode": "deferred", - "reason": "An independent implementation and all five pressure verticals have not passed this suite yet." - }, { "id": "JD2-BINDING-001", "mode": "static", diff --git a/packages/json-document/tests/conformance/v2/protocol-vectors.json b/packages/json-document/tests/conformance/v2/protocol-vectors.json index ac9eb3a0..14c40657 100644 --- a/packages/json-document/tests/conformance/v2/protocol-vectors.json +++ b/packages/json-document/tests/conformance/v2/protocol-vectors.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "status": "candidate", + "status": "stable", "profile": "docs/standard/v2-projection-profile.md", "function": "applyPatch", "initial": { diff --git a/packages/json-document/tests/foundation/durability-parity.test.ts b/packages/json-document/tests/foundation/durability-parity.test.ts new file mode 100644 index 00000000..587cd3d7 --- /dev/null +++ b/packages/json-document/tests/foundation/durability-parity.test.ts @@ -0,0 +1,126 @@ +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 rawVectors from "../conformance/v2/foundation-vectors.json" with { type: "json" }; + +interface FoundationVectors { + readonly arrayIndexes: ReadonlyArray<{ + readonly segment: string; + readonly value: number | null; + }>; + readonly equalities: ReadonlyArray<{ + readonly left: unknown; + readonly right: unknown; + readonly equal: boolean; + }>; + readonly jsonValues: ReadonlyArray; +} + +const vectors = rawVectors as FoundationVectors; + +describe("v2 durability primitive parity", () => { + test("one Core parser owns canonical array-index classification", () => { + for (const vector of vectors.arrayIndexes) { + expect(parseArrayIndex(vector.segment), vector.segment) + .toBe(vector.value); + } + }); + + test("the canonical JSON equality primitive matches shared vectors", () => { + for (const vector of vectors.equalities) { + expect(jsonEqual(vector.left, vector.right)) + .toBe(vector.equal); + } + }); + + test("validation and owning clone agree at the untrusted JSON boundary", () => { + for (const value of validJsonValues()) { + expect(jsonSerializableError(value)).toBeNull(); + const cloned = cloneJsonSerializable(value); + expect(cloned.ok).toBe(true); + if (!cloned.ok) continue; + expect(jsonEqual(cloned.value, value)).toBe(true); + if (value !== null && typeof value === "object") { + expect(cloned.value).not.toBe(value); + } + } + + for (const createInvalid of invalidJsonValues) { + const value = createInvalid(); + const validationReason = jsonSerializableError(value); + const cloned = cloneJsonSerializable(value); + expect(validationReason).not.toBeNull(); + expect(cloned).toMatchObject({ + ok: false, + reason: validationReason, + }); + } + }); + + test("trusted clone preserves ownership without repeating validation", () => { + for (const value of validJsonValues()) { + const cloned = cloneTrustedPlainJson(value); + expect(jsonEqual(cloned, value)).toBe(true); + if (value !== null && typeof value === "object") { + expect(cloned).not.toBe(value); + } + } + }); +}); + +function validJsonValues(): ReadonlyArray { + return [ + ...vectors.jsonValues, + JSON.parse('{"__proto__":{"owned":true}}') as unknown, + ]; +} + +const invalidJsonValues: ReadonlyArray<() => unknown> = [ + () => undefined, + () => Number.NaN, + () => Number.POSITIVE_INFINITY, + () => 1n, + () => Symbol("invalid"), + () => () => undefined, + () => new Date(0), + () => { + const value: unknown[] = []; + value.length = 1; + return value; + }, + () => { + const value = [1] as unknown[] & { extra?: string }; + value.extra = "invalid"; + return value; + }, + () => { + const value: Record = {}; + Object.defineProperty(value, "hidden", { + value: true, + enumerable: false, + }); + return value; + }, + () => { + const value: Record = {}; + Object.defineProperty(value, "computed", { + get: () => true, + enumerable: true, + }); + return value; + }, + () => { + const value: Record = {}; + value.self = value; + return value; + }, + () => { + const value: Record = {}; + value[Symbol("invalid")] = true; + return value; + }, +]; 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 new file mode 100644 index 00000000..9a792d1d --- /dev/null +++ b/packages/json-document/tests/independent/v2-projection-independent-conformance.test.ts @@ -0,0 +1,15 @@ +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/v2-projection.ts b/packages/json-document/tests/independent/v2-projection.ts new file mode 100644 index 00000000..33ac0a91 --- /dev/null +++ b/packages/json-document/tests/independent/v2-projection.ts @@ -0,0 +1,775 @@ +import type { + JSONPatchOperation, + JSONValue, + Projection, + ProjectionAcceptance, + ProjectionCapabilityResult, + ProjectionChange, + ProjectionCommitOptions, + ProjectionCommitResult, + ProjectionMetadata, + ProjectionQueryResult, + ProjectionReadResult, +} from "../conformance/v2/projection-suite.js"; + +interface PreparedCommit { + readonly value: JSONValue; + readonly applied: ReadonlyArray; +} + +interface OperationResult { + readonly value: JSONValue; + readonly applied: JSONPatchOperation; +} + +export function createIndependentProjection( + acceptance: ProjectionAcceptance, + initial: JSONValue, +): Projection { + let state = freezeJSON(cloneJSON(initial)); + accept(acceptance, state); + + const listeners = new Set<(change: ProjectionChange) => void>(); + const publicationQueue: ProjectionChange[] = []; + let publishing = false; + let evaluatingAcceptance = false; + + const prepare = ( + operations: ReadonlyArray, + ): PreparedCommit | ProjectionFailure => { + try { + let value = cloneJSON(state); + const applied: JSONPatchOperation[] = []; + for (const operation of operations) { + const result = applyOperation(value, operation); + value = result.value; + applied.push( + cloneJSON(result.applied) as unknown as JSONPatchOperation, + ); + } + value = freezeJSON(value); + evaluatingAcceptance = true; + try { + accept(acceptance, value); + } finally { + evaluatingAcceptance = false; + } + return { value, applied }; + } catch (error) { + return failureFrom(error); + } + }; + + const projection: Projection = { + get value() { + return state; + }, + at(pointer: string): ProjectionReadResult { + try { + const value = readAt(state, parsePointer(pointer)); + return Object.freeze({ ok: true, path: pointer, value }); + } catch (error) { + const failed = failureFrom(error); + return failed.code === "invalid_pointer" + ? Object.freeze({ ...failed, pointer }) + : Object.freeze({ ...failed, pointer }); + } + }, + query(jsonPath: string): ProjectionQueryResult { + try { + return Object.freeze({ + ok: true, + query: jsonPath, + pointers: Object.freeze(queryPointers(state, jsonPath)), + }); + } catch (error) { + const failed = failureFrom(error); + return Object.freeze({ + ok: false, + code: failed.code === "invalid_query" + ? failed.code + : "invalid_query", + ...(failed.reason === undefined ? {} : { reason: failed.reason }), + }); + } + }, + canPatch( + operations: ReadonlyArray, + ): ProjectionCapabilityResult { + if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; + const prepared = prepare(operations); + return "ok" in prepared ? prepared : OK; + }, + commit( + operations: ReadonlyArray, + options?: ProjectionCommitOptions, + ): ProjectionCommitResult { + if (evaluatingAcceptance) return ACCEPTANCE_REENTRANCY_FAILURE; + + let metadata: ProjectionMetadata | undefined; + try { + metadata = options?.metadata === undefined + ? undefined + : freezeJSON(cloneJSON(options.metadata)) as ProjectionMetadata; + } catch (error) { + return failureFrom(error); + } + + const prepared = prepare(operations); + if ("ok" in prepared) return prepared; + if (jsonEqual(state, prepared.value)) { + return Object.freeze({ + ok: true, + change: createChange([], metadata), + }); + } + + state = prepared.value; + const change = createChange(prepared.applied, metadata); + publish(change); + return Object.freeze({ ok: true, change }); + }, + subscribe(listener: (change: ProjectionChange) => void): () => void { + listeners.add(listener); + let active = true; + return () => { + if (!active) return; + active = false; + listeners.delete(listener); + }; + }, + }; + + return Object.freeze(projection); + + function publish(change: ProjectionChange): void { + publicationQueue.push(change); + if (publishing) return; + + publishing = true; + try { + while (publicationQueue.length > 0) { + const next = publicationQueue.shift() as ProjectionChange; + 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 + // prevent delivery to the remaining listeners. + } + } + } + } finally { + publishing = false; + } + } +} + +interface ProjectionFailure { + readonly ok: false; + readonly code: string; + readonly reason?: string; + readonly pointer?: string; +} + +class IndependentError extends Error { + constructor( + readonly code: string, + message: string, + readonly pointer?: string, + ) { + super(message); + } +} + +const OK = Object.freeze({ ok: true as const }); +const ACCEPTANCE_REENTRANCY_FAILURE = Object.freeze({ + ok: false as const, + code: "acceptance_reentrancy", + reason: "acceptance callback cannot call canPatch or commit", +}); + +function failureFrom(error: unknown): ProjectionFailure { + if (error instanceof IndependentError) { + return Object.freeze({ + ok: false, + code: error.code, + reason: error.message, + ...(error.pointer === undefined ? {} : { pointer: error.pointer }), + }); + } + return Object.freeze({ + ok: false, + code: "not_serializable", + reason: error instanceof Error ? error.message : "value is not JSON", + }); +} + +function accept( + acceptance: ProjectionAcceptance, + candidate: JSONValue, +): void { + if (acceptance === "json") return; + if (acceptance === "attempt-transform") { + if (isRecord(candidate)) { + Reflect.set(candidate, "title", "Implicit"); + } + return; + } + if ( + isRecord(candidate) + && typeof candidate.title === "string" + && Array.isArray(candidate.items) + && candidate.items.every((item) => ( + isRecord(item) + && typeof item.id === "string" + && typeof item.done === "boolean" + )) + && isRecord(candidate.meta) + && typeof candidate.meta.owner === "string" + ) { + return; + } + throw new IndependentError( + "schema_violation", + "candidate does not satisfy the task-list acceptance rule", + ); +} + +function applyOperation( + state: JSONValue, + operation: JSONPatchOperation, +): OperationResult { + if (operation === null || typeof operation !== "object") { + throw new IndependentError("invalid_pointer", "operation must be an object"); + } + + switch (operation.op) { + case "add": + return addValue(state, operation.path, cloneJSON(operation.value)); + case "remove": + return removeValue(state, operation.path); + case "replace": + return replaceValue(state, operation.path, cloneJSON(operation.value)); + case "copy": { + const copied = cloneJSON(readAt(state, parsePointer(operation.from))); + const added = addValue(state, operation.path, copied); + return { + value: added.value, + applied: { + op: "copy", + from: operation.from, + path: added.applied.path, + }, + }; + } + case "move": { + parsePointer(operation.path); + parsePointer(operation.from); + if (operation.from === operation.path) { + return { + value: state, + applied: { + op: "move", + from: operation.from, + path: operation.path, + }, + }; + } + if (isPointerAncestor(operation.from, operation.path)) { + throw new IndependentError( + "path_not_found", + "move destination cannot be inside its source", + operation.path, + ); + } + const moved = cloneJSON(readAt(state, parsePointer(operation.from))); + const removed = removeValue(state, operation.from); + const added = addValue(removed.value, operation.path, moved); + return { + value: added.value, + applied: { + op: "move", + from: operation.from, + path: added.applied.path, + }, + }; + } + case "test": { + const actual = readAt(state, parsePointer(operation.path)); + if (!jsonEqual(actual, operation.value)) { + throw new IndependentError( + "test_failed", + "value mismatch", + operation.path, + ); + } + return { + value: state, + applied: { + op: "test", + path: operation.path, + value: cloneJSON(operation.value), + }, + }; + } + default: + throw new IndependentError( + "invalid_pointer", + "operation is not recognized", + ); + } +} + +function addValue( + state: JSONValue, + pointer: string, + value: JSONValue, +): OperationResult { + const segments = parsePointer(pointer); + if (segments.length === 0) { + return { + value, + applied: { op: "add", path: pointer, value }, + }; + } + + const { parent, key, parentPointer } = resolveParent(state, segments, pointer); + let path = pointer; + if (Array.isArray(parent)) { + const index = key === "-" ? parent.length : parseArrayIndex(key); + if (index === null || index > parent.length) { + throw new IndependentError( + "path_not_found", + `array insertion index is invalid: ${key}`, + pointer, + ); + } + parent.splice(index, 0, value); + path = appendPointer(parentPointer, String(index)); + } else { + defineJsonProperty(parent, key, value); + } + return { + value: state, + applied: { op: "add", path, value }, + }; +} + +function removeValue( + state: JSONValue, + pointer: string, +): OperationResult { + const segments = parsePointer(pointer); + if (segments.length === 0) { + throw new IndependentError( + "path_not_found", + "a JSON document cannot remove its root", + pointer, + ); + } + + const { parent, key } = resolveParent(state, segments, pointer); + if (Array.isArray(parent)) { + const index = parseArrayIndex(key); + if (index === null || index >= parent.length) { + throw new IndependentError("path_not_found", "array index is absent", pointer); + } + parent.splice(index, 1); + } else { + if (!Object.prototype.hasOwnProperty.call(parent, key)) { + throw new IndependentError("path_not_found", "object member is absent", pointer); + } + delete parent[key]; + } + return { + value: state, + applied: { op: "remove", path: pointer }, + }; +} + +function replaceValue( + state: JSONValue, + pointer: string, + value: JSONValue, +): OperationResult { + const segments = parsePointer(pointer); + if (segments.length === 0) { + return { + value, + applied: { op: "replace", path: pointer, value }, + }; + } + + const { parent, key } = resolveParent(state, segments, pointer); + if (Array.isArray(parent)) { + const index = parseArrayIndex(key); + if (index === null || index >= parent.length) { + throw new IndependentError("path_not_found", "array index is absent", pointer); + } + parent[index] = value; + } else { + if (!Object.prototype.hasOwnProperty.call(parent, key)) { + throw new IndependentError("path_not_found", "object member is absent", pointer); + } + defineJsonProperty(parent, key, value); + } + return { + value: state, + applied: { op: "replace", path: pointer, value }, + }; +} + +function resolveParent( + state: JSONValue, + segments: ReadonlyArray, + pointer: string, +): { + readonly parent: JSONValue[] | Record; + readonly key: string; + readonly parentPointer: string; +} { + const parentSegments = segments.slice(0, -1); + const parent = readAt(state, parentSegments); + if (parent === null || typeof parent !== "object") { + throw new IndependentError( + "path_not_found", + "parent is not a container", + pointer, + ); + } + return { + parent: parent as JSONValue[] | Record, + key: segments[segments.length - 1] as string, + parentPointer: buildPointer(parentSegments), + }; +} + +function readAt( + state: JSONValue, + segments: ReadonlyArray, +): JSONValue { + let current = state; + for (const segment of segments) { + if (current === null || typeof current !== "object") { + throw new IndependentError("path_not_found", "path is absent"); + } + if (Array.isArray(current)) { + const index = parseArrayIndex(segment); + if (index === null || index >= current.length) { + throw new IndependentError("path_not_found", "array index is absent"); + } + current = current[index] as JSONValue; + } else { + if (!Object.prototype.hasOwnProperty.call(current, segment)) { + throw new IndependentError("path_not_found", "object member is absent"); + } + current = ( + current as Readonly> + )[segment] as JSONValue; + } + } + return current; +} + +function parsePointer(pointer: string): string[] { + if (pointer === "" || pointer === "#") return []; + let body = pointer; + if (pointer.startsWith("#")) { + if (!pointer.startsWith("#/")) { + throw new IndependentError("invalid_pointer", "invalid URI fragment", pointer); + } + try { + body = `/${decodeURIComponent(pointer.slice(2))}`; + } catch { + throw new IndependentError("invalid_pointer", "invalid URI encoding", pointer); + } + } + if (!body.startsWith("/")) { + throw new IndependentError( + "invalid_pointer", + "JSON Pointer must start with '/'", + pointer, + ); + } + return body.slice(1).split("/").map((segment) => { + for ( + let index = segment.indexOf("~"); + index !== -1; + index = segment.indexOf("~", index + 2) + ) { + const escaped = segment[index + 1]; + if (escaped !== "0" && escaped !== "1") { + throw new IndependentError( + "invalid_pointer", + "invalid JSON Pointer escape", + pointer, + ); + } + } + return segment.replaceAll("~1", "/").replaceAll("~0", "~"); + }); +} + +function parseArrayIndex(segment: string): number | null { + if (!/^(0|[1-9]\d*)$/.test(segment)) return null; + const index = Number(segment); + return Number.isSafeInteger(index) ? index : null; +} + +function queryPointers(value: JSONValue, query: string): string[] { + const tokens = parseQuery(query); + let matches: Array<{ readonly value: JSONValue; readonly path: string }> = [ + { value, path: "" }, + ]; + for (const token of tokens) { + const next: Array<{ readonly value: JSONValue; readonly path: string }> = []; + for (const match of matches) { + if (token === "*") { + if (Array.isArray(match.value)) { + for (let index = 0; index < match.value.length; index += 1) { + next.push({ + value: match.value[index] as JSONValue, + path: appendPointer(match.path, String(index)), + }); + } + } else if (isRecord(match.value)) { + for (const key of Object.keys(match.value)) { + next.push({ + value: match.value[key] as JSONValue, + path: appendPointer(match.path, key), + }); + } + } + continue; + } + if (Array.isArray(match.value)) { + const index = parseArrayIndex(token); + if (index !== null && index < match.value.length) { + next.push({ + value: match.value[index] as JSONValue, + path: appendPointer(match.path, token), + }); + } + } else if ( + isRecord(match.value) + && Object.prototype.hasOwnProperty.call(match.value, token) + ) { + next.push({ + value: match.value[token] as JSONValue, + path: appendPointer(match.path, token), + }); + } + } + matches = next; + } + return matches.map((match) => match.path); +} + +function parseQuery(query: string): string[] { + if (query === "$") return []; + if (!query.startsWith("$")) { + throw new IndependentError("invalid_query", "JSONPath must start with '$'"); + } + + const tokens: string[] = []; + let index = 1; + while (index < query.length) { + if (query[index] === ".") { + const start = ++index; + while ( + index < query.length + && query[index] !== "." + && query[index] !== "[" + ) { + index += 1; + } + if (index === start) { + throw new IndependentError("invalid_query", "empty member name"); + } + tokens.push(query.slice(start, index)); + continue; + } + if (query[index] === "[") { + const close = query.indexOf("]", index + 1); + if (close === -1) { + throw new IndependentError("invalid_query", "unclosed selector"); + } + const selector = query.slice(index + 1, close); + if (selector === "*") { + tokens.push("*"); + } else if (/^(0|[1-9]\d*)$/.test(selector)) { + tokens.push(selector); + } else { + const quoted = /^(["'])(.*)\1$/.exec(selector); + if (quoted === null) { + throw new IndependentError("invalid_query", "unsupported selector"); + } + tokens.push(quoted[2] as string); + } + index = close + 1; + continue; + } + throw new IndependentError("invalid_query", "unexpected JSONPath token"); + } + return tokens; +} + +function createChange( + applied: ReadonlyArray, + metadata: ProjectionMetadata | undefined, +): ProjectionChange { + const ownedApplied = freezeJSON( + cloneJSON(applied) as unknown as JSONValue, + ) as unknown as ReadonlyArray; + return Object.freeze( + metadata === undefined + ? { applied: ownedApplied } + : { applied: ownedApplied, metadata }, + ); +} + +function cloneJSON(value: unknown, seen = new WeakSet()): JSONValue { + if (value === null) return null; + if (typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + throw new TypeError("non-finite number is not JSON"); + } + if (typeof value !== "object") { + throw new TypeError(`${typeof value} is not JSON`); + } + if (seen.has(value)) throw new TypeError("circular reference is not JSON"); + seen.add(value); + + if (Array.isArray(value)) { + if ( + Object.getOwnPropertySymbols(value).length > 0 + || Object.getOwnPropertyNames(value).length !== value.length + 1 + ) { + throw new TypeError("array has non-JSON properties"); + } + const result: JSONValue[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || "get" in descriptor + || "set" in descriptor + ) { + throw new TypeError("array is sparse or has accessors"); + } + result.push(cloneJSON(descriptor.value, seen)); + } + return result; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("non-plain object is not JSON"); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError("symbol keys are not JSON"); + } + + const result: Record = {}; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || "get" in descriptor + || "set" in descriptor + ) { + throw new TypeError("object property is not JSON data"); + } + defineJsonProperty(result, key, cloneJSON(descriptor.value, seen)); + } + return result; +} + +function freezeJSON(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + for (const child of Object.values(value)) { + freezeJSON(child); + } + return Object.freeze(value); +} + +function jsonEqual(left: JSONValue, right: JSONValue): boolean { + if (left === right) return true; + if ( + left === null + || right === null + || typeof left !== "object" + || typeof right !== "object" + || Array.isArray(left) !== Array.isArray(right) + ) { + return false; + } + if (Array.isArray(left)) { + return left.length === (right as ReadonlyArray).length + && left.every((value, index) => ( + jsonEqual(value, (right as ReadonlyArray)[index] as JSONValue) + )); + } + const leftKeys = Object.keys(left); + const leftObject = left as Readonly>; + const rightObject = right as Readonly>; + return leftKeys.length === Object.keys(rightObject).length + && leftKeys.every((key) => ( + Object.prototype.hasOwnProperty.call(rightObject, key) + && jsonEqual(leftObject[key] as JSONValue, rightObject[key] as JSONValue) + )); +} + +function defineJsonProperty( + target: Record, + key: string, + value: JSONValue, +): void { + if (key === "__proto__") { + Object.defineProperty(target, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } else { + target[key] = value; + } +} + +function buildPointer(segments: ReadonlyArray): string { + return segments.length === 0 + ? "" + : `/${segments.map(escapePointerSegment).join("/")}`; +} + +function appendPointer(pointer: string, segment: string): string { + return `${pointer}/${escapePointerSegment(segment)}`; +} + +function escapePointerSegment(segment: string): string { + return segment.replaceAll("~", "~0").replaceAll("/", "~1"); +} + +function isPointerAncestor(ancestor: string, descendant: string): boolean { + const ancestorSegments = parsePointer(ancestor); + const descendantSegments = parsePointer(descendant); + return ancestorSegments.length < descendantSegments.length + && ancestorSegments.every(( + segment, + index, + ) => segment === descendantSegments[index]); +} + +function isRecord( + value: JSONValue | undefined, +): value is Readonly> { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/json-document/tests/public/v2-projection-standard-conformance.test.ts b/packages/json-document/tests/public/v2-projection-standard-conformance.test.ts index 308a3ec0..b07f29c0 100644 --- a/packages/json-document/tests/public/v2-projection-standard-conformance.test.ts +++ b/packages/json-document/tests/public/v2-projection-standard-conformance.test.ts @@ -12,6 +12,7 @@ import { type ProjectionAcceptance, type ProjectionHarness, } from "../conformance/v2/projection-suite.js"; +import { runPressureConformance } from "../conformance/v2/pressure-suite.js"; function taskListAcceptance(candidate: JSONValue): JSONCapabilityResult { const valid = isRecord(candidate) @@ -37,14 +38,26 @@ function createReferenceProjection( acceptance: ProjectionAcceptance, initial: JSONValue, ): Projection { + const accepts = acceptance === "task-list" + ? taskListAcceptance + : acceptance === "attempt-transform" + ? attemptTransformAcceptance + : undefined; return createJSONDocument( initial, - acceptance === "task-list" - ? { accepts: taskListAcceptance } - : {}, + accepts === undefined ? {} : { accepts }, ); } +function attemptTransformAcceptance( + candidate: JSONValue, +): JSONCapabilityResult { + if (isRecord(candidate)) { + Reflect.set(candidate, "title", "Implicit"); + } + return { ok: true }; +} + function isRecord( value: JSONValue | undefined, ): value is { readonly [key: string]: JSONValue } { @@ -56,6 +69,7 @@ const referenceHarness: ProjectionHarness = { }; runProjectionConformance(referenceHarness); +runPressureConformance(referenceHarness); test("nested commits preserve one causal publication order for every subscriber", () => { const document = createJSONDocument({ value: 0 }); diff --git a/scripts/evaluate-archive-isolation.mjs b/scripts/evaluate-archive-isolation.mjs index 6514df06..2853d5f9 100644 --- a/scripts/evaluate-archive-isolation.mjs +++ b/scripts/evaluate-archive-isolation.mjs @@ -32,7 +32,7 @@ if ( || JSON.stringify(Object.keys(collaborationPackage.exports ?? {})) !== JSON.stringify([".", "./history", "./text"]) || collaborationPackage.peerDependencies?.["@interactive-os/json-document"] - !== "^2.0.0-rc.0" + !== "^2.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-rc.0" + ] !== "^2.0.0" || contenteditableCollaborationPackage.peerDependencies?.[ "@interactive-os/json-document-collaboration" - ] !== "^0.1.0-rc.0" + ] !== "^0.1.0-rc.1" ) { failures.push("the contenteditable collaboration companion surface is invalid"); } diff --git a/scripts/evaluate-docs.mjs b/scripts/evaluate-docs.mjs index 2dd1fe64..de36e3e6 100644 --- a/scripts/evaluate-docs.mjs +++ b/scripts/evaluate-docs.mjs @@ -223,13 +223,13 @@ 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-rc\.0/], + ["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, /contains no transport, presence,\s*storage, DOM, React, or server dependency/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /IME-safe DOM publication lease/], ["contenteditableCollaborationReadme", surfaces.contenteditableCollaborationReadme, /does not activate or depend on the archived 1\.x DOM adapters/], - ["llms", surfaces.llms, /2\.0\.0-rc\.0.*Candidate/], + ["llms", surfaces.llms, /2\.0\.0.*Stable/], ["llms", surfaces.llms, /공개 Root는 정확히 다음 20개 symbol/], ["llms", surfaces.llms, /## Host adapter와 companion/], ["llms", surfaces.llms, /@interactive-os\/json-document-collaboration/], @@ -338,7 +338,7 @@ for (const pattern of [ for (const pattern of [ /Provider-neutral JSON editing/, /six-member document projection/, - /npm install @interactive-os\/json-document@2\.0\.0-rc\.0/, + /npm install @interactive-os\/json-document@2\.0\.0/, /Rich editing belongs to host adapters/, ]) { requirePattern("site home", siteHome, pattern); diff --git a/scripts/evaluate-standardization.mjs b/scripts/evaluate-standardization.mjs index 97c0a001..b6441704 100644 --- a/scripts/evaluate-standardization.mjs +++ b/scripts/evaluate-standardization.mjs @@ -51,6 +51,12 @@ const EXPECTED_CONFORMANCE = { 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", }; function read(path) { @@ -186,8 +192,8 @@ const signatureSource = read( const packageSmoke = read("packages/json-document/tests/smoke/package-smoke.mjs"); const coreBenchmark = read("scripts/benchmark-core.mjs"); -if (manifest.formatVersion !== 1 || manifest.status !== "candidate") { - fail("v2 manifest: expected formatVersion 1 and candidate status."); +if (manifest.formatVersion !== 1 || manifest.status !== "stable") { + fail("v2 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."); @@ -322,15 +328,29 @@ const rfc6902Suite = read(manifest.conformance.rfc6902Suite); const rfc6902Binding = read(manifest.conformance.rfc6902Binding); const jsonPathSuite = read(manifest.conformance.jsonPathSuite); 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 independentProjectionBinding = read( + manifest.conformance.independentProjectionBinding, +); +const collaborationProjectionBinding = read( + manifest.conformance.collaborationProjectionBinding, +); for (const [label, vectors] of [ ["projection", projectionVectors], ["protocol", protocolVectors], ["pointer", pointerVectors], + ["foundation", foundationVectors], + ["pressure", pressureVectors], ]) { if ( vectors.formatVersion !== 1 - || vectors.status !== "candidate" + || vectors.status !== "stable" || vectors.profile !== "docs/standard/v2-projection-profile.md" ) { fail(`${label} vectors: metadata drifted.`); @@ -401,6 +421,42 @@ for (const [suiteName, vectors] of [ } } } +const pressureVerticals = pressureVectors.verticals ?? []; +setEqual( + "pressure verticals", + pressureVerticals.map((vertical) => vertical.id), + [ + "form", + "table-data-grid", + "outliner-tree", + "rich-text", + "storage-collaboration", + ], +); +for (const vertical of pressureVerticals) { + if ( + !Array.isArray(vertical.requirements) + || !vertical.requirements.includes("JD2-CONFORMANCE-002") + ) { + fail(`pressure vectors: ${vertical.id} must trace JD2-CONFORMANCE-002.`); + } + for (const id of vertical.requirements ?? []) { + if (!requirementSet.has(id)) { + fail(`pressure vectors: ${vertical.id} references unknown ${id}.`); + } + runtimeRequirements.add(id); + } +} +if ( + !Array.isArray(foundationVectors.arrayIndexes) + || foundationVectors.arrayIndexes.length === 0 + || !Array.isArray(foundationVectors.equalities) + || foundationVectors.equalities.length === 0 + || !Array.isArray(foundationVectors.jsonValues) + || foundationVectors.jsonValues.length === 0 +) { + fail("foundation vectors: array index, equality, and JSON boundary fixtures are required."); +} const nonRuntimeRequirements = new Map(); const nonRuntimeCounts = { static: 0, deferred: 0 }; @@ -457,6 +513,11 @@ assertPublicBinding( projectionBinding, /createJSONDocument[\s\S]*runProjectionConformance/, ); +requirePattern( + "projection binding", + projectionBinding, + /runPressureConformance\(referenceHarness\)/, +); requirePattern( "projection binding", projectionBinding, @@ -478,6 +539,68 @@ assertPublicBinding( protocolBinding, /applyPatch[\s\S]*runProtocolConformance/, ); +assertGenericSuite( + "pressure suite", + pressureSuite, + /ProjectionHarness[\s\S]*runPressureConformance/, +); +for (const vertical of [ + "form", + "table-data-grid", + "outliner-tree", + "rich-text", + "storage-collaboration", +]) { + requirePattern( + "pressure vectors", + JSON.stringify(pressureVectors), + new RegExp(vertical), + ); +} +if ( + /@interactive-os\/json-document|\/src\//.test( + independentProjectionImplementation, + ) +) { + fail("independent projection: reference package or private source import leaked."); +} +for (const pattern of [ + /get value\(\)/, + /\bat\(/, + /\bquery\(/, + /\bcanPatch\(/, + /\bcommit\(/, + /\bsubscribe\(/, +]) { + requirePattern( + "independent projection", + independentProjectionImplementation, + pattern, + ); +} +if ( + /@interactive-os\/json-document|\/src\//.test(independentProjectionBinding) +) { + fail("independent projection binding must not import the reference implementation."); +} +for (const pattern of [ + /createIndependentProjection/, + /runProjectionConformance\(independentHarness\)/, + /runPressureConformance\(independentHarness\)/, +]) { + requirePattern("independent projection binding", independentProjectionBinding, pattern); +} +for (const pattern of [ + /from "@interactive-os\/json-document-collaboration"/, + /runProjectionConformance\(harness\)/, + /runPressureConformance\(harness\)/, +]) { + requirePattern( + "collaboration projection binding", + collaborationProjectionBinding, + pattern, + ); +} assertGenericSuite( "pointer suite", pointerSuite,