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