From 399dae0ae6a8c100e48fb329a661fb5a4e8694ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Wed, 29 Jul 2026 13:31:54 +0900 Subject: [PATCH 1/2] test(collaboration): add provider and convergence evidence --- .github/workflows/collaboration-soak.yml | 23 + package.json | 1 + .../json-document-collaboration/README.md | 27 + .../json-document-collaboration/package.json | 1 + .../tests/convergence-soak.test.ts | 479 ++++++++++++++++++ .../tests/provider-substitution.test.ts | 109 ++++ .../tests/support/task-board-host.ts | 241 +++++++++ 7 files changed, 881 insertions(+) create mode 100644 .github/workflows/collaboration-soak.yml create mode 100644 packages/json-document-collaboration/tests/convergence-soak.test.ts create mode 100644 packages/json-document-collaboration/tests/provider-substitution.test.ts create mode 100644 packages/json-document-collaboration/tests/support/task-board-host.ts diff --git a/.github/workflows/collaboration-soak.yml b/.github/workflows/collaboration-soak.yml new file mode 100644 index 00000000..dfc1f21f --- /dev/null +++ b/.github/workflows/collaboration-soak.yml @@ -0,0 +1,23 @@ +name: Collaboration Soak + +on: + workflow_dispatch: + schedule: + - cron: "0 19 * * 1" + +permissions: + contents: read + +jobs: + collaboration-soak: + name: Deterministic causal convergence + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Install dependencies + run: npm ci --no-audit --no-fund + - name: Run the extended collaboration profile + run: npm run test:collaboration:soak diff --git a/package.json b/package.json index adca1e67..180c0787 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "npm run dev -w @interactive-os/json-document-site", "build": "npm run build -w @interactive-os/json-document", "test": "npm test -w @interactive-os/json-document", + "test:collaboration:soak": "npm run test:soak -w @interactive-os/json-document-collaboration", "typecheck": "npm run typecheck -w @interactive-os/json-document", "smoke:package": "npm run smoke:package -w @interactive-os/json-document", "perf:core": "npm run build -w @interactive-os/json-document && node scripts/benchmark-core.mjs", diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index 4a1d8839..f8cc1cf8 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -162,6 +162,33 @@ DOM/native-input publication leasing is likewise separate in ingesting model Changes during composition and gates only rendering for the leased surface. +## Release evidence + +The ordinary package suite includes a provider-neutral task-board host that +depends only on the six-member `JSONDocument`. The same host commands run +unchanged against the local and collaboration providers, including a +concurrent card edit that follows a structural move by stable identity. + +It also runs a small deterministic convergence soak on every verification. +Three replicas author randomized task-board commands and selective +undo/redo while bundles are partially, out of order, and repeatedly +delivered. Each case must converge before and after checkpoint restore, +continue the restored actor lineage without a fork, export one canonical +checkpoint, and survive new-epoch compaction. + +Run the deeper reproducible profile with: + +```sh +npm run test:collaboration:soak +``` + +The soak reports its seed in every failure so the exact schedule can be +replayed with `JSON_DOCUMENT_COLLABORATION_SOAK_SEED` and a case count of one. +This evidence protects the architecture but does not turn an RC into +production proof by itself. Stable promotion still requires sustained +external use, large-document performance evidence, and successful +offline/checkpoint/epoch operations under a real transport and storage host. + ## Protocol v3 profile The implemented profile includes: diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index fbe92a29..684eb524 100644 --- a/packages/json-document-collaboration/package.json +++ b/packages/json-document-collaboration/package.json @@ -42,6 +42,7 @@ "build": "npm run clean && tsc -p tsconfig.json", "prepack": "npm run build", "test": "vitest run --config vitest.config.ts", + "test:soak": "JSON_DOCUMENT_COLLABORATION_SOAK_CASES=24 JSON_DOCUMENT_COLLABORATION_SOAK_STEPS=96 vitest run --config vitest.config.ts tests/convergence-soak.test.ts", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, diff --git a/packages/json-document-collaboration/tests/convergence-soak.test.ts b/packages/json-document-collaboration/tests/convergence-soak.test.ts new file mode 100644 index 00000000..4f3c40ca --- /dev/null +++ b/packages/json-document-collaboration/tests/convergence-soak.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, test } from "vitest"; + +import { + compactCollaborationCheckpoint, + createCollaborationHistoryRuntime, + restoreCollaborationHistoryRuntime, + type CollaborationBundle, + type CollaborationChange, + type CollaborationHistoryRuntime, + type CollaborationRulesetIdentity, +} from "../src/history-index.js"; +import { + createTaskBoardHost, + taskBoardInitialValue, + type TaskBoardHost, +} from "./support/task-board-host.js"; + +const caseCount = positiveInteger( + process.env.JSON_DOCUMENT_COLLABORATION_SOAK_CASES, + 8, +); +const stepCount = positiveInteger( + process.env.JSON_DOCUMENT_COLLABORATION_SOAK_STEPS, + 48, +); +const firstSeed = nonNegativeInteger( + process.env.JSON_DOCUMENT_COLLABORATION_SOAK_SEED, + 0x6d2b79f5, +); + +describe("deterministic collaboration convergence soak", () => { + test("converges randomized task-board histories across delivery schedules", () => { + for (let caseIndex = 0; caseIndex < caseCount; caseIndex += 1) { + runCase((firstSeed + caseIndex) >>> 0); + } + }, Math.max(120_000, caseCount * stepCount * 100)); +}); + +function runCase(seed: number): void { + const random = pseudoRandom(seed); + const actorIds = ["actor-a", "actor-b", "actor-c"] as const; + const ruleset: CollaborationRulesetIdentity = { + id: "test/task-board-soak", + digest: "test/task-board-soak/v1", + }; + const epochId = `task-board-soak/${seed}/v1`; + const runtimes: CollaborationHistoryRuntime[] = actorIds.map((actorId) => ( + createCollaborationHistoryRuntime(taskBoardInitialValue(), { + actorId, + epochId, + ruleset, + }) + )); + const hosts: TaskBoardHost[] = runtimes.map( + (runtime) => createTaskBoardHost(runtime.document), + ); + + for (let step = 0; step < stepCount; step += 1) { + const actorIndex = integer(random, runtimes.length); + authorRandomCommand( + runtimes[actorIndex] as CollaborationHistoryRuntime, + hosts[actorIndex] as TaskBoardHost, + actorIds[actorIndex] as string, + step, + random, + seed, + ); + if (random() < 0.55) { + deliverRandomSubset(runtimes, random, seed, step); + } + } + + converge(runtimes, random, seed, "before-restore"); + assertConverged(runtimes, seed, "before-restore"); + + const crashCheckpoint = runtimes[0]?.collaboration.exportCheckpoint(); + if (crashCheckpoint === undefined) { + throw new Error(`seed ${seed}: missing crash checkpoint`); + } + const restored = restoreCollaborationHistoryRuntime(crashCheckpoint, { + actorId: actorIds[0], + ruleset, + }); + if (!restored.ok) { + throw new Error( + `seed ${seed}: restore failed: ${restored.code}: ${restored.reason}`, + ); + } + runtimes[0] = restored.runtime; + hosts[0] = createTaskBoardHost(restored.runtime.document); + requireSuccess( + hosts[0]?.renameBoard(`Restored board ${seed}`), + seed, + "resume-authoring", + ); + + converge(runtimes, random, seed, "after-restore"); + assertConverged(runtimes, seed, "after-restore"); + + const checkpoints = runtimes.map( + (runtime) => runtime.collaboration.exportCheckpoint(), + ); + for (const checkpoint of checkpoints.slice(1)) { + expect( + checkpoint, + `seed ${seed}: converged replicas must export one canonical checkpoint`, + ).toEqual(checkpoints[0]); + } + + for (let index = 0; index < runtimes.length; index += 1) { + const checkpoint = checkpoints[index]; + if (checkpoint === undefined) { + throw new Error(`seed ${seed}: checkpoint ${index} is missing`); + } + const roundTrip = restoreCollaborationHistoryRuntime(checkpoint, { + actorId: actorIds[index] as string, + ruleset, + }); + if (!roundTrip.ok) { + throw new Error( + `seed ${seed}: round-trip ${index} failed: ` + + `${roundTrip.code}: ${roundTrip.reason}`, + ); + } + expect( + roundTrip.runtime.document.value, + `seed ${seed}: checkpoint projection ${index}`, + ).toEqual(runtimes[0]?.document.value); + expect( + roundTrip.runtime.collaboration.current(), + `seed ${seed}: checkpoint causal snapshot ${index}`, + ).toEqual(runtimes[0]?.collaboration.current()); + expect( + stableHistory(roundTrip.runtime), + `seed ${seed}: checkpoint history ${index}`, + ).toEqual(stableHistory(runtimes[index] as CollaborationHistoryRuntime)); + } + + const checkpoint = checkpoints[0]; + if (checkpoint === undefined) { + throw new Error(`seed ${seed}: canonical checkpoint is missing`); + } + const nextRuleset: CollaborationRulesetIdentity = { + id: ruleset.id, + digest: `${ruleset.digest}/compacted`, + }; + const compacted = compactCollaborationCheckpoint(checkpoint, { + mode: "new-epoch", + nextEpochId: `task-board-soak/${seed}/v2`, + nextRuleset, + }); + if (!compacted.ok) { + throw new Error( + `seed ${seed}: compaction failed: ` + + `${compacted.code}: ${compacted.reason}`, + ); + } + expect(compacted.report.discardedChanges).toBeGreaterThan(0); + expect(compacted.checkpoint.payload.changes).toEqual([]); + + const compactedRestore = restoreCollaborationHistoryRuntime( + compacted.checkpoint, + { + actorId: "post-compaction", + ruleset: nextRuleset, + }, + ); + if (!compactedRestore.ok) { + throw new Error( + `seed ${seed}: compacted restore failed: ` + + `${compactedRestore.code}: ${compactedRestore.reason}`, + ); + } + expect( + compactedRestore.runtime.document.value, + `seed ${seed}: compacted projection`, + ).toEqual(runtimes[0]?.document.value); + expect(compactedRestore.runtime.collaboration.current().pending).toEqual([]); +} + +function authorRandomCommand( + runtime: CollaborationHistoryRuntime, + host: TaskBoardHost, + actorId: string, + step: number, + random: () => number, + seed: number, +): void { + const laneIds = host.laneIds(); + const cardIds = host.cardIds(); + const command = integer(random, 8); + + if (command === 0) { + requireSuccess( + host.renameBoard(`${actorId} board ${step}`), + seed, + `${actorId}:${step}:rename`, + ); + return; + } + + if (command === 1 || cardIds.length === 0) { + const laneId = choose(laneIds, random); + requireSuccess( + host.addCard(laneId, { + id: `${actorId}-${step}`, + text: `Card ${seed}:${step}`, + done: false, + }), + seed, + `${actorId}:${step}:add`, + ); + return; + } + + const cardId = choose(cardIds, random); + if (command === 2) { + requireSuccess( + host.updateCardText(cardId, `${actorId} text ${step}`), + seed, + `${actorId}:${step}:text`, + ); + return; + } + if (command === 3) { + requireSuccess( + host.toggleCard(cardId), + seed, + `${actorId}:${step}:toggle`, + ); + return; + } + if (command === 4) { + requireSuccess( + host.moveCard(cardId, choose(laneIds, random)), + seed, + `${actorId}:${step}:move`, + ); + return; + } + if (command === 5) { + requireSuccess( + host.removeCard(cardId), + seed, + `${actorId}:${step}:remove`, + ); + return; + } + if (command === 6) { + const result = runtime.history.undo(); + if (!result.ok && result.code !== "nothing_to_undo") { + throw new Error( + `seed ${seed}: ${actorId}:${step}:undo failed: ${result.code}`, + ); + } + return; + } + + const result = runtime.history.redo(); + if (!result.ok && result.code !== "nothing_to_redo") { + throw new Error( + `seed ${seed}: ${actorId}:${step}:redo failed: ${result.code}`, + ); + } +} + +function deliverRandomSubset( + runtimes: ReadonlyArray, + random: () => number, + seed: number, + step: number, +): void { + const sourceIndex = integer(random, runtimes.length); + const targetIndex = ( + sourceIndex + 1 + integer(random, runtimes.length - 1) + ) % runtimes.length; + const source = runtimes[sourceIndex] as CollaborationHistoryRuntime; + const target = runtimes[targetIndex] as CollaborationHistoryRuntime; + const exported = source.collaboration.exportBundle(); + if (exported.changes.length === 0) return; + + let selected = exported.changes.filter(() => random() < 0.45); + if (selected.length === 0) { + selected = [ + exported.changes[ + integer(random, exported.changes.length) + ] as CollaborationChange, + ]; + } + const bundle: CollaborationBundle = { + epoch: exported.epoch, + changes: shuffle(selected, random), + }; + requireIngest( + target.collaboration.ingest(bundle), + seed, + `step ${step}: ${sourceIndex}->${targetIndex}`, + ); + if (random() < 0.25) { + requireIngest( + target.collaboration.ingest(bundle), + seed, + `step ${step}: duplicate ${sourceIndex}->${targetIndex}`, + ); + } +} + +function converge( + runtimes: ReadonlyArray, + random: () => number, + seed: number, + phase: string, +): void { + const union = unionChanges(runtimes, seed); + for (let index = 0; index < runtimes.length; index += 1) { + const runtime = runtimes[index] as CollaborationHistoryRuntime; + const result = runtime.collaboration.ingest({ + epoch: runtime.collaboration.epoch, + changes: shuffle(union, random), + }); + requireIngest(result, seed, `${phase}: converge replica ${index}`); + if (result.ok && result.pending.length > 0) { + throw new Error( + `seed ${seed}: ${phase}: replica ${index} retained pending changes`, + ); + } + } +} + +function unionChanges( + runtimes: ReadonlyArray, + seed: number, +): ReadonlyArray { + const changes = new Map(); + for (const runtime of runtimes) { + for (const change of runtime.collaboration.exportBundle().changes) { + const key = `${change.changeId.actorId}:${change.changeId.counter}`; + const existing = changes.get(key); + if ( + existing !== undefined + && JSON.stringify(existing) !== JSON.stringify(change) + ) { + throw new Error(`seed ${seed}: mismatched duplicate ${key}`); + } + changes.set(key, change); + } + } + return [...changes.values()]; +} + +function assertConverged( + runtimes: ReadonlyArray, + seed: number, + phase: string, +): void { + const expected = runtimes[0]; + if (expected === undefined) { + throw new Error(`seed ${seed}: no replicas`); + } + for (let index = 1; index < runtimes.length; index += 1) { + const runtime = runtimes[index] as CollaborationHistoryRuntime; + expect( + runtime.document.value, + `seed ${seed}: ${phase}: projection ${index}`, + ).toEqual(expected.document.value); + expect( + runtime.collaboration.current(), + `seed ${seed}: ${phase}: causal snapshot ${index}`, + ).toEqual(expected.collaboration.current()); + } +} + +function stableHistory(runtime: CollaborationHistoryRuntime): { + readonly undoTarget: unknown; + readonly redoTarget: unknown; + readonly undoDepth: number; + readonly redoDepth: number; +} { + const history = runtime.history.current(); + return { + undoTarget: history.undoTarget, + redoTarget: history.redoTarget, + undoDepth: history.undoDepth, + redoDepth: history.redoDepth, + }; +} + +function requireSuccess( + result: { readonly ok: boolean; readonly code?: string } | undefined, + seed: number, + context: string, +): void { + if (result?.ok === true) return; + throw new Error( + `seed ${seed}: ${context} failed: ${result?.code ?? "missing_result"}`, + ); +} + +function requireIngest( + result: { + readonly ok: boolean; + readonly code?: string; + readonly reason?: string; + }, + seed: number, + context: string, +): void { + if (result.ok) return; + throw new Error( + `seed ${seed}: ${context} ingest failed: ` + + `${result.code ?? "unknown"}: ${result.reason ?? ""}`, + ); +} + +function positiveInteger(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError("soak configuration must be a positive integer"); + } + return parsed; +} + +function nonNegativeInteger( + value: string | undefined, + fallback: number, +): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if ( + !Number.isSafeInteger(parsed) + || parsed < 0 + || parsed > 0xffff_ffff + ) { + throw new TypeError("soak seed must be an unsigned 32-bit integer"); + } + return parsed; +} + +function integer(random: () => number, upperBound: number): number { + if (upperBound <= 0) { + throw new RangeError("random integer upper bound must be positive"); + } + return Math.floor(random() * upperBound); +} + +function choose( + values: ReadonlyArray, + random: () => number, +): T { + const value = values[integer(random, values.length)]; + if (value === undefined) throw new RangeError("cannot choose an empty value"); + return value; +} + +function shuffle( + values: ReadonlyArray, + random: () => number, +): T[] { + const shuffled = [...values]; + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const other = integer(random, index + 1); + const current = shuffled[index] as T; + shuffled[index] = shuffled[other] as T; + shuffled[other] = current; + } + return shuffled; +} + +function pseudoRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; +} diff --git a/packages/json-document-collaboration/tests/provider-substitution.test.ts b/packages/json-document-collaboration/tests/provider-substitution.test.ts new file mode 100644 index 00000000..a0f9173a --- /dev/null +++ b/packages/json-document-collaboration/tests/provider-substitution.test.ts @@ -0,0 +1,109 @@ +import { + createJSONDocument, + type JSONAppliedChange, + type JSONDocument, + type JSONValue, +} from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; + +import { createCollaborationRuntime } from "../src/index.js"; +import { + createTaskBoardHost, + taskBoardInitialValue, +} from "./support/task-board-host.js"; + +const ruleset = { + id: "test/task-board-host", + digest: "test/task-board-host/v1", +} as const; + +describe("provider-neutral task-board host", () => { + test("runs the same product commands against local and collaborative documents", () => { + const local = exerciseSingleProvider( + createJSONDocument(taskBoardInitialValue()), + ); + const collaborative = exerciseSingleProvider( + createCollaborationRuntime(taskBoardInitialValue(), { + actorId: "single-collaborative", + epochId: "task-board-provider-substitution/v1", + ruleset, + }).document, + ); + + expect(collaborative).toEqual(local); + }); + + test("preserves a card edit across a concurrent structural move", () => { + const leftRuntime = createCollaborationRuntime(taskBoardInitialValue(), { + actorId: "left", + epochId: "task-board-multi/v1", + ruleset, + }); + const rightRuntime = createCollaborationRuntime(taskBoardInitialValue(), { + actorId: "right", + epochId: "task-board-multi/v1", + ruleset, + }); + const left = createTaskBoardHost(leftRuntime.document); + const right = createTaskBoardHost(rightRuntime.document); + + expect(left.moveCard("base", "doing")).toMatchObject({ ok: true }); + expect(right.updateCardText("base", "Edited on the other replica")) + .toMatchObject({ ok: true }); + const leftBundle = leftRuntime.collaboration.exportBundle(); + const rightBundle = rightRuntime.collaboration.exportBundle(); + expect(leftRuntime.collaboration.ingest(rightBundle)) + .toMatchObject({ ok: true, pending: [] }); + expect(rightRuntime.collaboration.ingest(leftBundle)) + .toMatchObject({ ok: true, pending: [] }); + + expect(rightRuntime.document.value).toEqual(leftRuntime.document.value); + expect(rightRuntime.collaboration.current()).toEqual( + leftRuntime.collaboration.current(), + ); + expect(left.card("base")).toEqual({ + id: "base", + text: "Edited on the other replica", + done: false, + }); + expect( + left.snapshot().lanes.find((lane) => lane.id === "doing")?.cards, + ).toContainEqual(left.card("base")); + }); +}); + +function exerciseSingleProvider(document: JSONDocument): { + readonly value: JSONValue; + readonly changes: ReadonlyArray; +} { + const host = createTaskBoardHost(document); + const changes: JSONAppliedChange[] = []; + const unsubscribe = host.subscribe((change) => { + changes.push(change); + }); + + expect(host.renameBoard("Stable board")).toMatchObject({ ok: true }); + expect(host.addCard("todo", { + id: "single-1", + text: "Exercise the host", + done: false, + })).toMatchObject({ ok: true }); + expect(host.updateCardText("single-1", "Exercise both providers")) + .toMatchObject({ ok: true }); + expect(host.toggleCard("single-1")).toMatchObject({ ok: true }); + expect(host.moveCard("single-1", "done")).toMatchObject({ ok: true }); + unsubscribe(); + + expect(host.laneIds()).toEqual(["todo", "doing", "done"]); + expect(host.cardIds()).toEqual(["base", "single-1"]); + expect(host.card("single-1")).toEqual({ + id: "single-1", + text: "Exercise both providers", + done: true, + }); + + return { + value: document.value, + changes, + }; +} diff --git a/packages/json-document-collaboration/tests/support/task-board-host.ts b/packages/json-document-collaboration/tests/support/task-board-host.ts new file mode 100644 index 00000000..a2dc2e6b --- /dev/null +++ b/packages/json-document-collaboration/tests/support/task-board-host.ts @@ -0,0 +1,241 @@ +import type { + JSONAppliedChange, + JSONDocument, + JSONDocumentCommitResult, + JSONPatchOperation, + JSONValue, +} from "@interactive-os/json-document"; + +interface TaskBoardCard { + readonly id: string; + readonly text: string; + readonly done: boolean; +} + +interface TaskBoardLane { + readonly id: string; + readonly title: string; + readonly cards: ReadonlyArray; +} + +interface TaskBoardSnapshot { + readonly title: string; + readonly lanes: ReadonlyArray; +} + +export interface TaskBoardHost { + snapshot(): TaskBoardSnapshot; + laneIds(): ReadonlyArray; + cardIds(): ReadonlyArray; + card(id: string): TaskBoardCard | null; + renameBoard(title: string): JSONDocumentCommitResult; + addCard(laneId: string, card: TaskBoardCard): JSONDocumentCommitResult; + updateCardText(cardId: string, text: string): JSONDocumentCommitResult; + toggleCard(cardId: string): JSONDocumentCommitResult; + moveCard(cardId: string, laneId: string): JSONDocumentCommitResult; + removeCard(cardId: string): JSONDocumentCommitResult; + subscribe(listener: (change: JSONAppliedChange) => void): () => void; +} + +export function taskBoardInitialValue(): JSONValue { + return { + title: "Release board", + lanes: [ + { + id: "todo", + title: "Todo", + cards: [ + { id: "base", text: "Prove the boundary", done: false }, + ], + }, + { + id: "doing", + title: "Doing", + cards: [], + }, + { + id: "done", + title: "Done", + cards: [], + }, + ], + }; +} + +/** + * A product-shaped consumer which knows only the six-member JSONDocument + * contract. Provider construction, collaboration, transport, and history stay + * outside this module. + */ +export function createTaskBoardHost(document: JSONDocument): TaskBoardHost { + const commit = ( + command: string, + operations: ReadonlyArray, + ): JSONDocumentCommitResult => { + const capability = document.canPatch(operations); + if (!capability.ok) return capability; + return document.commit(operations, { + metadata: { + host: "task-board", + command, + }, + }); + }; + + const lanePointer = (laneId: string): string | null => ( + parentPointerForValue(document, "$.lanes[*].id", laneId) + ); + const cardPointer = (cardId: string): string | null => ( + parentPointerForValue(document, "$.lanes[*].cards[*].id", cardId) + ); + + return Object.freeze({ + snapshot(): TaskBoardSnapshot { + return taskBoardSnapshot(document.value); + }, + laneIds(): ReadonlyArray { + return taskBoardSnapshot(document.value).lanes.map((lane) => lane.id); + }, + cardIds(): ReadonlyArray { + return taskBoardSnapshot(document.value).lanes.flatMap( + (lane) => lane.cards.map((card) => card.id), + ); + }, + card(id: string): TaskBoardCard | null { + const pointer = cardPointer(id); + if (pointer === null) return null; + const result = document.at(pointer); + return result.ok ? taskBoardCard(result.value) : null; + }, + renameBoard(title: string): JSONDocumentCommitResult { + return commit("rename-board", [{ + op: "replace", + path: "/title", + value: title, + }]); + }, + addCard(laneId: string, card: TaskBoardCard): JSONDocumentCommitResult { + const lane = lanePointer(laneId); + if (lane === null) return missing("lane", laneId); + return commit("add-card", [{ + op: "add", + path: `${lane}/cards/-`, + value: card as unknown as JSONValue, + }]); + }, + updateCardText(cardId: string, text: string): JSONDocumentCommitResult { + const card = cardPointer(cardId); + if (card === null) return missing("card", cardId); + return commit("update-card-text", [{ + op: "replace", + path: `${card}/text`, + value: text, + }]); + }, + toggleCard(cardId: string): JSONDocumentCommitResult { + const card = cardPointer(cardId); + if (card === null) return missing("card", cardId); + const current = document.at(`${card}/done`); + if (!current.ok || typeof current.value !== "boolean") { + return missing("card", cardId); + } + return commit("toggle-card", [{ + op: "replace", + path: `${card}/done`, + value: !current.value, + }]); + }, + moveCard(cardId: string, laneId: string): JSONDocumentCommitResult { + const card = cardPointer(cardId); + if (card === null) return missing("card", cardId); + const lane = lanePointer(laneId); + if (lane === null) return missing("lane", laneId); + return commit("move-card", [{ + op: "move", + from: card, + path: `${lane}/cards/-`, + }]); + }, + removeCard(cardId: string): JSONDocumentCommitResult { + const card = cardPointer(cardId); + if (card === null) return missing("card", cardId); + return commit("remove-card", [{ + op: "remove", + path: card, + }]); + }, + subscribe(listener: (change: JSONAppliedChange) => void): () => void { + return document.subscribe(listener); + }, + }); +} + +function parentPointerForValue( + document: JSONDocument, + jsonPath: string, + expected: string, +): string | null { + const query = document.query(jsonPath); + if (!query.ok) return null; + for (const pointer of query.pointers) { + const result = document.at(pointer); + if (result.ok && result.value === expected) { + return pointer.slice(0, pointer.lastIndexOf("/")); + } + } + return null; +} + +function taskBoardSnapshot(value: JSONValue): TaskBoardSnapshot { + if ( + !isRecord(value) + || typeof value.title !== "string" + || !Array.isArray(value.lanes) + ) { + throw new TypeError("task-board document has an invalid root"); + } + for (const lane of value.lanes) taskBoardLane(lane); + return value as unknown as TaskBoardSnapshot; +} + +function taskBoardLane(value: JSONValue): TaskBoardLane { + if ( + !isRecord(value) + || typeof value.id !== "string" + || typeof value.title !== "string" + || !Array.isArray(value.cards) + ) { + throw new TypeError("task-board document has an invalid lane"); + } + for (const card of value.cards) taskBoardCard(card); + return value as unknown as TaskBoardLane; +} + +function taskBoardCard(value: JSONValue): TaskBoardCard { + if ( + !isRecord(value) + || typeof value.id !== "string" + || typeof value.text !== "string" + || typeof value.done !== "boolean" + ) { + throw new TypeError("task-board document has an invalid card"); + } + return value as unknown as TaskBoardCard; +} + +function isRecord( + value: JSONValue, +): value is { readonly [key: string]: JSONValue } { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function missing( + kind: "card" | "lane", + id: string, +): Extract { + return { + ok: false, + code: `${kind}_not_found`, + reason: `${kind} ${id} does not exist`, + }; +} From 803d4412442980db2c16d5e7b0b7e5499622dc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Wed, 29 Jul 2026 13:32:02 +0900 Subject: [PATCH 2/2] refactor: remove dead v2 implementation surfaces --- .github/workflows/ci.yml | 2 +- apps/site/src/components/MarkdownViewer.tsx | 2 +- .../src/checkpoint.ts | 46 ++++++++++++++ .../src/compact.ts | 55 ++--------------- .../json-document-collaboration/src/create.ts | 1 - .../src/materialize.ts | 2 +- .../src/restore.ts | 45 ++------------ .../src/text-core.ts | 8 +-- .../src/translate.ts | 4 +- .../json-document-collaboration/src/tree.ts | 22 ++----- packages/json-document/eslint.config.js | 26 -------- .../src/domain/projection/create.ts | 4 +- .../src/domain/projection/index.ts | 4 -- .../src/foundation/json/index.ts | 9 +-- .../src/foundation/jsonpath/ast.ts | 2 +- .../src/foundation/jsonpath/index.ts | 17 +----- .../src/foundation/jsonpath/tokenize.ts | 2 +- .../src/foundation/patch/apply.ts | 29 +-------- .../src/foundation/patch/container.ts | 12 ++-- .../src/foundation/patch/fast/apply.ts | 14 ----- .../src/foundation/patch/path.ts | 2 +- .../src/foundation/patch/sequentialReplace.ts | 61 +++---------------- .../src/foundation/patch/track.ts | 46 +++----------- .../src/foundation/pointer/array.ts | 39 ------------ .../src/foundation/pointer/core.ts | 32 ++-------- .../src/foundation/protocol/index.ts | 4 -- scripts/ci-scope.mjs | 2 +- scripts/site-route-checks.mjs | 3 +- tsconfig.json-document-paths.json | 8 --- 29 files changed, 110 insertions(+), 393 deletions(-) delete mode 100644 packages/json-document/eslint.config.js delete mode 100644 packages/json-document/src/foundation/pointer/array.ts delete mode 100644 tsconfig.json-document-paths.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc56747d..a29956d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: run: npm ci --no-audit --no-fund - name: Verify active v2 packages if: steps.scope.outputs.package_full == 'true' - run: npm run archive:check && npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate + run: npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate - name: Verify package documentation if: steps.scope.outputs.package_full != 'true' && (steps.scope.outputs.package_docs == 'true' || steps.scope.outputs.package_smoke == 'true') run: | diff --git a/apps/site/src/components/MarkdownViewer.tsx b/apps/site/src/components/MarkdownViewer.tsx index d3b1f10d..deaa7367 100644 --- a/apps/site/src/components/MarkdownViewer.tsx +++ b/apps/site/src/components/MarkdownViewer.tsx @@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown"; import rehypeSlug from "rehype-slug"; import remarkGfm from "remark-gfm"; -export type MarkdownHeading = { id: string; level: number; text: string }; +type MarkdownHeading = { id: string; level: number; text: string }; export function MarkdownViewer({ source, hideTitle = false }: { source: string; hideTitle?: boolean }) { return ( diff --git a/packages/json-document-collaboration/src/checkpoint.ts b/packages/json-document-collaboration/src/checkpoint.ts index 720d7e20..1f1bab07 100644 --- a/packages/json-document-collaboration/src/checkpoint.ts +++ b/packages/json-document-collaboration/src/checkpoint.ts @@ -1,5 +1,6 @@ import { applyPatch, + type JSONCapabilityResult, type JSONValue, } from "@interactive-os/json-document"; @@ -21,6 +22,16 @@ type PreparedCheckpoint = | { readonly ok: true; readonly checkpoint: CollaborationCheckpoint } | { readonly ok: false; readonly reason: string }; +type CheckpointVerifier = ( + checkpoint: CollaborationCheckpoint, +) => JSONCapabilityResult; + +type CheckpointVerificationFailure = { + readonly ok: false; + readonly code: string; + readonly reason: string; +}; + export function createCheckpoint( base: JSONValue, membership: CollaborationMembership | null, @@ -162,6 +173,34 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { }; } +export function verifyCheckpointProof( + checkpoint: CollaborationCheckpoint, + verify: CheckpointVerifier | undefined, +): CheckpointVerificationFailure | null { + if (verify === undefined) return null; + try { + const result = verify(checkpoint); + if (result?.ok === true) return null; + if (result?.ok === false && typeof result.code === "string") { + return verificationFailure( + result.code, + result.reason ?? "checkpoint proof verification failed", + ); + } + return verificationFailure( + "checkpoint_verification_failed", + "checkpoint verifier must return a capability result", + ); + } catch (error) { + return verificationFailure( + "checkpoint_verification_failed", + error instanceof Error + ? error.message + : "checkpoint proof verification failed", + ); + } +} + function prepareMembership( input: unknown, ): @@ -206,6 +245,13 @@ function invalid(reason: string): { readonly ok: false; readonly reason: string return { ok: false, reason }; } +function verificationFailure( + code: string, + reason: string, +): CheckpointVerificationFailure { + return Object.freeze({ ok: false, code, reason }); +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/packages/json-document-collaboration/src/compact.ts b/packages/json-document-collaboration/src/compact.ts index 953848d0..c48da364 100644 --- a/packages/json-document-collaboration/src/compact.ts +++ b/packages/json-document-collaboration/src/compact.ts @@ -1,8 +1,3 @@ -import type { - JSONCapabilityResult, - JSONValue, -} from "@interactive-os/json-document"; - import { canonicalMembership, changeIdKey, @@ -16,6 +11,7 @@ import { import { createCheckpoint, prepareCheckpoint, + verifyCheckpointProof, } from "./checkpoint.js"; import { acceptCandidate, @@ -67,7 +63,7 @@ export function compactCollaborationCheckpoint( const invalidOptions = validateOptions(checkpoint, options); if (invalidOptions !== null) return invalidOptions; - const verification = verifyCheckpoint(checkpoint, options); + const verification = verifyCheckpointProof(checkpoint, options.verify); if (verification !== null) return verification; const changes = new Map(); @@ -132,10 +128,7 @@ export function compactCollaborationCheckpoint( initialProjection.reason ?? "checkpoint base is not materializable", ); } - const initialAcceptance = safeAccept( - options.accepts, - initialProjection.value, - ); + const initialAcceptance = acceptCandidate(options.accepts, initialProjection.value); if (!initialAcceptance.ok) { return failure( initialAcceptance.code, @@ -149,7 +142,7 @@ export function compactCollaborationCheckpoint( materialized = materializeChanges( initialTree, graph.ordered, - (candidate) => safeAccept(options.accepts, candidate), + (candidate) => acceptCandidate(options.accepts, candidate), ); } catch (error) { return failure( @@ -160,10 +153,7 @@ export function compactCollaborationCheckpoint( ); } const nextAccepts = effectiveNextAcceptance(checkpoint, options); - const nextAcceptance = safeAccept( - nextAccepts, - materialized.value, - ); + const nextAcceptance = acceptCandidate(nextAccepts, materialized.value); if (!nextAcceptance.ok) { return failure( nextAcceptance.code, @@ -308,41 +298,6 @@ function effectiveNextAcceptance( : undefined; } -function verifyCheckpoint( - checkpoint: CollaborationCheckpoint, - options: CollaborationCompactionOptions, -): Extract | null { - if (options.verify === undefined) return null; - try { - const result = options.verify(checkpoint); - if (result?.ok === true) return null; - if (result?.ok === false && typeof result.code === "string") { - return failure( - result.code, - result.reason ?? "checkpoint proof verification failed", - ); - } - return failure( - "checkpoint_verification_failed", - "checkpoint verifier must return a capability result", - ); - } catch (error) { - return failure( - "checkpoint_verification_failed", - error instanceof Error - ? error.message - : "checkpoint proof verification failed", - ); - } -} - -function safeAccept( - accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined, - candidate: JSONValue, -): JSONCapabilityResult { - return acceptCandidate(accepts, candidate); -} - function unauthorizedReference( change: CollaborationChange, membership: CollaborationMembership | null, diff --git a/packages/json-document-collaboration/src/create.ts b/packages/json-document-collaboration/src/create.ts index b216539f..c56b0c67 100644 --- a/packages/json-document-collaboration/src/create.ts +++ b/packages/json-document-collaboration/src/create.ts @@ -44,7 +44,6 @@ import { projectTree, resolveTextMemberSnapshot, resolveTextSnapshot, - type TreeState, } from "./tree.js"; import { authoredTextAtomId, diff --git a/packages/json-document-collaboration/src/materialize.ts b/packages/json-document-collaboration/src/materialize.ts index a92514ab..153994d0 100644 --- a/packages/json-document-collaboration/src/materialize.ts +++ b/packages/json-document-collaboration/src/materialize.ts @@ -26,7 +26,7 @@ type HistoryOperation = Extract< { readonly kind: "undo-change" | "redo-change" } >; -export interface MaterializedHistoryState { +interface MaterializedHistoryState { readonly disabledByTarget: ReadonlyMap; readonly appliedKeys: ReadonlySet; readonly appliedUndoTargets: ReadonlyMap; diff --git a/packages/json-document-collaboration/src/restore.ts b/packages/json-document-collaboration/src/restore.ts index 38c28701..d6d92e77 100644 --- a/packages/json-document-collaboration/src/restore.ts +++ b/packages/json-document-collaboration/src/restore.ts @@ -1,14 +1,16 @@ import { canonicalStringify, } from "./change.js"; -import { prepareCheckpoint } from "./checkpoint.js"; +import { + prepareCheckpoint, + verifyCheckpointProof, +} from "./checkpoint.js"; import { createRestoredRuntime, createRestoredTextRuntime, } from "./create.js"; import type { JSONValue } from "@interactive-os/json-document"; import type { - CollaborationCheckpoint, CollaborationHistoryRestoreResult, CollaborationHistoryRuntime, CollaborationRestoreOptions, @@ -30,7 +32,7 @@ export function restoreCollaborationRuntime( input: unknown, options: CollaborationRestoreOptions, ): CollaborationRestoreResult { - const restored = restoreHistoryRuntime(input, options); + const restored = restoreCollaborationHistoryRuntime(input, options); if (!restored.ok) return restored; const runtime: CollaborationRuntime = Object.freeze({ document: restored.runtime.document, @@ -63,13 +65,6 @@ export function restoreCollaborationTextRuntime( }); } -function restoreHistoryRuntime( - input: unknown, - options: CollaborationRestoreOptions, -): CollaborationHistoryRestoreResult { - return restoreCollaborationHistoryRuntime(input, options); -} - function restoreProfileRuntime( input: unknown, options: CollaborationRestoreOptions, @@ -109,7 +104,7 @@ function restoreProfileRuntime( "restore ruleset does not match the checkpoint epoch", ); } - const verification = verifyCheckpoint(checkpoint, options); + const verification = verifyCheckpointProof(checkpoint, options.verify); if (verification !== null) return verification; try { @@ -152,34 +147,6 @@ function restoreProfileRuntime( } } -function verifyCheckpoint( - checkpoint: CollaborationCheckpoint, - options: CollaborationRestoreOptions, -): Extract | null { - if (options.verify === undefined) return null; - try { - const result = options.verify(checkpoint); - if (result?.ok === true) return null; - if (result?.ok === false && typeof result.code === "string") { - return failure( - result.code, - result.reason ?? "checkpoint proof verification failed", - ); - } - return failure( - "checkpoint_verification_failed", - "checkpoint verifier must return a capability result", - ); - } catch (error) { - return failure( - "checkpoint_verification_failed", - error instanceof Error - ? error.message - : "checkpoint proof verification failed", - ); - } -} - function failure( code: string, reason: string, diff --git a/packages/json-document-collaboration/src/text-core.ts b/packages/json-document-collaboration/src/text-core.ts index 840ec329..44206c88 100644 --- a/packages/json-document-collaboration/src/text-core.ts +++ b/packages/json-document-collaboration/src/text-core.ts @@ -6,7 +6,7 @@ import type { TextSpliceOperation, } from "./types.js"; -export interface TextAtom { +interface TextAtom { readonly id: TextAtomId; readonly value: string; deleted: boolean; @@ -19,7 +19,7 @@ export interface TextState { atoms?: Map; } -export interface TextCoreFailure { +interface TextCoreFailure { readonly ok: false; readonly code: string; readonly reason: string; @@ -30,13 +30,13 @@ export interface TextAtomSnapshot { readonly value: string; } -export interface TextSnapshot { +interface TextSnapshot { readonly textNode: TextNodeId; readonly value: string; readonly atoms: ReadonlyArray; } -export interface MinimalTextSplice { +interface MinimalTextSplice { readonly left: TextAtomId | null; readonly right: TextAtomId | null; readonly removed: ReadonlyArray; diff --git a/packages/json-document-collaboration/src/translate.ts b/packages/json-document-collaboration/src/translate.ts index 17c6e679..0320a4e3 100644 --- a/packages/json-document-collaboration/src/translate.ts +++ b/packages/json-document-collaboration/src/translate.ts @@ -28,12 +28,12 @@ import type { TextSpliceOperation, } from "./types.js"; -export interface CompiledOperations { +interface CompiledOperations { readonly ops: ReadonlyArray; readonly tree: TreeState; } -export interface CompilePatchOperationsOptions { +interface CompilePatchOperationsOptions { readonly collaborativeText?: boolean; } diff --git a/packages/json-document-collaboration/src/tree.ts b/packages/json-document-collaboration/src/tree.ts index 0143818e..10716aca 100644 --- a/packages/json-document-collaboration/src/tree.ts +++ b/packages/json-document-collaboration/src/tree.ts @@ -23,7 +23,6 @@ import type { ContainerNodeId, MemberId, MemberPlacement, - ObjectPlacement, PositionId, SemanticOperation, TextNodeId, @@ -93,23 +92,23 @@ export interface TreeFailure { readonly reason: string; } -export interface ProjectedTree { +interface ProjectedTree { readonly ok: true; readonly value: JSONValue; readonly conflicts: ReadonlyArray; } -export interface ResolvedMember { +interface ResolvedMember { readonly memberId: MemberId; readonly member: TreeMember; } -export interface ResolvedParent { +interface ResolvedParent { readonly container: TreeContainer; readonly segment: string; } -export interface ResolvedTextSnapshot { +interface ResolvedTextSnapshot { readonly target: MemberId; readonly textNode: TextNodeId; readonly value: string; @@ -413,14 +412,6 @@ export function projectMemberValue( : projected; } -export function objectMemberAt( - tree: TreeState, - container: TreeContainer, - key: string, -): TreeMember | null { - return objectMembersAt(tree, container, key).at(-1) ?? null; -} - export function objectMembersAt( tree: TreeState, container: TreeContainer, @@ -1240,7 +1231,4 @@ function failure(code: string, reason: string): TreeFailure { return { ok: false, code, reason }; } -export type { - TreeContainer, - TreeMember, -}; +export type { TreeContainer }; diff --git a/packages/json-document/eslint.config.js b/packages/json-document/eslint.config.js deleted file mode 100644 index 043814f3..00000000 --- a/packages/json-document/eslint.config.js +++ /dev/null @@ -1,26 +0,0 @@ -// ESLint config — domain verb 모듈끼리 import 금지 (ADR-0002 / SPEC §0.5 layer 규약). -// verb pillar(clipboard·editing) 는 서로/자기 sibling 을 import 하지 않는다. -// 합성은 application/document facade 에서만. type-only import 는 허용. - -const verbPillarGlobs = ["src/domain/clipboard/**/*.ts", "src/domain/editing/**/*.ts"]; - -export default [ - { - files: verbPillarGlobs, - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["./*", "../clipboard/*", "../editing/*"], - allowTypeImports: true, - message: - "verb 모듈끼리 import 금지. 합성은 application/document facade 에서만 (ADR-0002 / SPEC §0.5). type-only import 는 허용 — `import type` 사용.", - }, - ], - }, - ], - }, - }, -]; diff --git a/packages/json-document/src/domain/projection/create.ts b/packages/json-document/src/domain/projection/create.ts index 222262fe..0093ac33 100644 --- a/packages/json-document/src/domain/projection/create.ts +++ b/packages/json-document/src/domain/projection/create.ts @@ -19,11 +19,11 @@ import { type ReadResult, } from "../../foundation/protocol/index.js"; -export interface ProjectionOptions { +interface ProjectionOptions { readonly accepts?: (candidate: JSONValue) => JSONCapabilityResult; } -export interface ProjectionDocument { +interface ProjectionDocument { readonly value: JSONValue; at(pointer: string): ReadResult; query(jsonPath: string): QueryResult; diff --git a/packages/json-document/src/domain/projection/index.ts b/packages/json-document/src/domain/projection/index.ts index aae70754..b10295dc 100644 --- a/packages/json-document/src/domain/projection/index.ts +++ b/packages/json-document/src/domain/projection/index.ts @@ -1,10 +1,6 @@ export { createProjection, } from "./create.js"; -export type { - ProjectionDocument, - ProjectionOptions, -} from "./create.js"; export { appendSegment, diff --git a/packages/json-document/src/foundation/json/index.ts b/packages/json-document/src/foundation/json/index.ts index 7c9b1316..dd2e9920 100644 --- a/packages/json-document/src/foundation/json/index.ts +++ b/packages/json-document/src/foundation/json/index.ts @@ -1,10 +1,3 @@ -export { - cloneJson, - cloneJsonSerializable, -} from "./clone.js"; +export { cloneJsonSerializable } from "./clone.js"; export { jsonEqual } from "./equal.js"; -export { - jsonSerializableError, - jsonSerializableErrorFast, -} from "./serializable.js"; export { cloneTrustedPlainJson } from "./trustedClone.js"; diff --git a/packages/json-document/src/foundation/jsonpath/ast.ts b/packages/json-document/src/foundation/jsonpath/ast.ts index 28c1364a..56581622 100644 --- a/packages/json-document/src/foundation/jsonpath/ast.ts +++ b/packages/json-document/src/foundation/jsonpath/ast.ts @@ -48,7 +48,7 @@ export interface FunctionExpr { args: FunctionArgument[]; } -export type FunctionArgument = Comparable; +type FunctionArgument = Comparable; /** evaluate 결과 — query 가 매칭한 location 들의 Pointer + value. */ export interface Match { diff --git a/packages/json-document/src/foundation/jsonpath/index.ts b/packages/json-document/src/foundation/jsonpath/index.ts index 6c406b18..981a319f 100644 --- a/packages/json-document/src/foundation/jsonpath/index.ts +++ b/packages/json-document/src/foundation/jsonpath/index.ts @@ -5,20 +5,15 @@ // ✓ filter [?] — comparisons (==/!=//>=) + logical (&&/||/!) + exists // ✓ RFC 9535 function extensions — length/count/match/search/value // -// API: -// query(query, root) → Pointer[] // shorthand -// queryMatches(query, root) → Match[] // pointer + value 쌍 -// -// SPEC §0.3 (2) 표준 Path: RFC 6901 + RFC 9535. JSONPath query → Pointer[] 환원. +// API: query(query, root) → Pointer[] +// RFC 9535 JSONPath 결과를 RFC 6901 Pointer 배열로 환원한다. import { parse as parseJsonPath } from "./parse.js"; import { evaluate, matchPointers } from "./evaluate.js"; import { matchPointersForSimpleQuery } from "./simple.js"; import type { Pointer } from "../pointer/core.js"; -import type { Match, Query } from "./ast.js"; -export { parse } from "./parse.js"; +import type { Query } from "./ast.js"; export { JSONPathSyntaxError } from "./tokenize.js"; -export type { Match, Query } from "./ast.js"; const QUERY_CACHE_LIMIT = 128; const queryCache = new Map(); @@ -33,12 +28,6 @@ export function query(jsonpath: string, root: unknown): Pointer[] { return matchPointers(evaluate(ast, root)); } -/** shorthand with values: query string + root → Match[]. */ -export function queryMatches(jsonpath: string, root: unknown): Match[] { - const ast = cachedParse(jsonpath); - return evaluate(ast, root); -} - function cachedParse(jsonpath: string): Query { if (jsonpath === lastQueryText && lastQueryAst !== undefined) return lastQueryAst; diff --git a/packages/json-document/src/foundation/jsonpath/tokenize.ts b/packages/json-document/src/foundation/jsonpath/tokenize.ts index 2d97d1c9..112bb0dc 100644 --- a/packages/json-document/src/foundation/jsonpath/tokenize.ts +++ b/packages/json-document/src/foundation/jsonpath/tokenize.ts @@ -1,7 +1,7 @@ // foundation/jsonpath/tokenizer — RFC 9535 §2 JSONPath tokenizer. // 출력: Token[]. parser 가 소비한다. -export type TokenKind = +type TokenKind = | "$" | "@" | "." | ".." | "*" | "[" | "]" | "," diff --git a/packages/json-document/src/foundation/patch/apply.ts b/packages/json-document/src/foundation/patch/apply.ts index f4774ab2..eabf217c 100644 --- a/packages/json-document/src/foundation/patch/apply.ts +++ b/packages/json-document/src/foundation/patch/apply.ts @@ -1,4 +1,4 @@ -// applyOpRaw — RFC 6902 6 op 의 raw 적용 (schema 검증 없음). public 노출은 patch.ts. +// applyOpRaw — RFC 6902 6 op 의 raw 적용. 공개 경계는 protocol layer가 소유한다. import { isPrefix, parsePointer, type Pointer } from "../pointer/core.js"; import { cloneJson } from "../json/clone.js"; @@ -13,7 +13,7 @@ import { withMutated, } from "./container.js"; -export type RawResult = { state: unknown } | { error: ErrorCode; reason?: string; pointer?: Pointer }; +type RawResult = { state: unknown } | { error: ErrorCode; reason?: string; pointer?: Pointer }; export function validateOperationShape(op: JSONPatchOperation): { error: ErrorCode; reason: string } | null { if (!op || typeof op !== "object") return { error: "invalid_pointer", reason: "op must be object" }; @@ -43,7 +43,6 @@ export function validateOperationShape(op: JSONPatchOperation): { error: ErrorCo } type PatchPointerError = { error: "invalid_pointer"; reason: string; pointer: Pointer }; -type PatchValidationError = { error: ErrorCode; reason: string; pointer?: Pointer }; export function validateOperationPointers(op: JSONPatchOperation): PatchPointerError | null { const pathError = validatePatchPointer(op.path, "path"); @@ -52,30 +51,6 @@ export function validateOperationPointers(op: JSONPatchOperation): PatchPointerE return validatePatchPointer(op.from, "from"); } -export function validatePatchOperations( - operations: ReadonlyArray, -): PatchValidationError | null { - for (let index = 0; index < operations.length; index += 1) { - if (!(index in operations)) { - return { error: "invalid_pointer", reason: `op[${index}]: op must be object` }; - } - const operation = operations[index]!; - const shape = validateOperationShape(operation); - if (shape !== null) { - return { error: shape.error, reason: `op[${index}]: ${shape.reason}` }; - } - const pointerError = validateOperationPointers(operation); - if (pointerError !== null) { - return { - error: pointerError.error, - reason: `op[${index}]: ${pointerError.reason}`, - pointer: pointerError.pointer, - }; - } - } - return null; -} - function validatePatchPointer(pointer: Pointer, field: "path" | "from"): PatchPointerError | null { if (pointer[0] === "#") { return { diff --git a/packages/json-document/src/foundation/patch/container.ts b/packages/json-document/src/foundation/patch/container.ts index 7ee68016..8b632590 100644 --- a/packages/json-document/src/foundation/patch/container.ts +++ b/packages/json-document/src/foundation/patch/container.ts @@ -1,4 +1,4 @@ -// patch.ts 내부 헬퍼 — public API 아님. docs/standard/json-document-spec.md §3 의 RFC 6902 구현 디테일. +// RFC 6902 patch 적용을 위한 내부 container helper. import { parsePointer, readAt, type Pointer, PointerSyntaxError } from "../pointer/core.js"; import { parseArrayIndex } from "../pointer/arrayIndex.js"; @@ -6,7 +6,7 @@ import type { ErrorCode, JSONPatchOperation } from "./contract.js"; // RFC 6902 §4.1: `/-` 는 array append marker. 적용 시점의 array 길이로 concrete index 정규화. // 비-array 부모거나 path 가 `/-` 가 아니면 원본 path 유지. -export function resolveAppendPath(path: Pointer, before: unknown): Pointer { +function resolveAppendPath(path: Pointer, before: unknown): Pointer { if (!path.endsWith("/-")) return path; const parent = path.slice(0, -2); const segs = parent === "" ? [] : parsePointer(parent); @@ -18,7 +18,7 @@ export function resolveAppendPath(path: Pointer, before: unknown): Pointer { // move to `/-` is resolved after the source has been removed. The applied // record must point at the concrete inserted element so selection/history can // track it. -export function resolveAppliedAppendPath(path: Pointer, after: unknown): Pointer { +function resolveAppliedAppendPath(path: Pointer, after: unknown): Pointer { if (!path.endsWith("/-")) return path; const parent = path.slice(0, -2); const segs = parent === "" ? [] : parsePointer(parent); @@ -45,8 +45,8 @@ export function normalizeAppliedOp(op: JSONPatchOperation, after: unknown): JSON return path === op.path ? op : { op: "move", from: op.from, path }; } -export type ContainerError = { error: ErrorCode; reason?: string }; -export type ParseSafeResult = { ok: true; segs: string[] } | { error: ErrorCode; reason: string; pointer: Pointer }; +type ContainerError = { error: ErrorCode; reason?: string }; +type ParseSafeResult = { ok: true; segs: string[] } | { error: ErrorCode; reason: string; pointer: Pointer }; export function attachPointer(e: ContainerError, pointer: Pointer): ContainerError & { pointer: Pointer } { return { ...e, pointer }; @@ -118,7 +118,7 @@ export function withMutated( return { state: next }; } -export type Verb = "set" | "replace" | "remove"; +type Verb = "set" | "replace" | "remove"; // container mutation 정본. set/replace/remove 의 array vs object 분기 통합. export function mutateContainer(parent: unknown, key: string, verb: Verb, value?: unknown): { value: unknown } | ContainerError { diff --git a/packages/json-document/src/foundation/patch/fast/apply.ts b/packages/json-document/src/foundation/patch/fast/apply.ts index 65048b52..eddc206a 100644 --- a/packages/json-document/src/foundation/patch/fast/apply.ts +++ b/packages/json-document/src/foundation/patch/fast/apply.ts @@ -34,20 +34,6 @@ const rootObjectReplaceWhenValuesTrusted: FastPatchStrategy = (state, ops, value ? applyRootObjectReplacePatch(state, ops, true) : { handled: false }; -export const publicTrustedStateStrategies: readonly FastPatchStrategy[] = [ - applyAppendOnlyAddPatch, - applyTailRemovePatch, - applyRootObjectRemovePatch, - applyRootObjectAddPatch, - applyRootObjectReplacePatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, - applySameArrayElementReplacePatch, - applyIndependentReplacePatch, - applySequentialReplacePatch, - applySameArrayStructuralPatch, -]; - export const trustedStrategies: readonly FastPatchStrategy[] = [ applyAppendOnlyAddPatch, applyTailRemovePatch, diff --git a/packages/json-document/src/foundation/patch/path.ts b/packages/json-document/src/foundation/patch/path.ts index 96041b0e..0c721c48 100644 --- a/packages/json-document/src/foundation/patch/path.ts +++ b/packages/json-document/src/foundation/patch/path.ts @@ -8,7 +8,7 @@ export interface ArrayFieldPath { key: string; } -export interface ArrayNestedPath { +interface ArrayNestedPath { arrayPath: Pointer; arraySegments: string[]; index: number; diff --git a/packages/json-document/src/foundation/patch/sequentialReplace.ts b/packages/json-document/src/foundation/patch/sequentialReplace.ts index 1f085eab..37636e7d 100644 --- a/packages/json-document/src/foundation/patch/sequentialReplace.ts +++ b/packages/json-document/src/foundation/patch/sequentialReplace.ts @@ -1,6 +1,6 @@ import { jsonSerializableError } from "../json/serializable.js"; import { parseArrayIndex } from "../pointer/arrayIndex.js"; -import { applyOpRaw, validateOperationShape } from "./apply.js"; +import { validateOperationShape } from "./apply.js"; import { parseSafe } from "./container.js"; import type { FastPatchResult, JSONPatchOperation } from "./contract.js"; import { objectHasOwn } from "./object.js"; @@ -14,8 +14,7 @@ interface PreparedSequentialReplace { interface SequentialReplaceRun { state: unknown; - applied: ReplaceOperation[] | null; - inverses: JSONPatchOperation[] | null; + applied: ReplaceOperation[]; } /** @@ -28,53 +27,21 @@ export function applySequentialReplacePatch( operations: ReadonlyArray, valuesTrusted = false, ): FastPatchResult { - const run = runSequentialReplaceBatch(state, operations, valuesTrusted, false); - return run === null || run.applied === null + const run = runSequentialReplaceBatch(state, operations, valuesTrusted); + return run === null ? { handled: false } : { handled: true, state: run.state, applied: run.applied }; } -/** - * Captures the value immediately before each sequential write and returns - * inverse operations in undo order. A null result delegates to the generic - * inverse path, preserving its existing success and failure semantics. - */ -export function computeSequentialReplaceInverses( - state: unknown, - operations: ReadonlyArray, -): JSONPatchOperation[] | null { - let firstReplace = 0; - while ( - firstReplace < operations.length - && firstReplace in operations - && operations[firstReplace]!.op === "test" - ) { - const asserted = applyOpRaw(state, operations[firstReplace]!); - if ("error" in asserted) return null; - state = asserted.state; - firstReplace += 1; - } - if (firstReplace === operations.length) return null; - - const replaceOperations = firstReplace === 0 - ? operations - : operations.slice(firstReplace); - const run = runSequentialReplaceBatch(state, replaceOperations, false, true); - return run?.inverses ?? null; -} - function runSequentialReplaceBatch( state: unknown, operations: ReadonlyArray, valuesTrusted: boolean, - captureInverses: boolean, ): SequentialReplaceRun | null { if (operations.length < 2) return null; const prepared = new Array(operations.length); - const applied = captureInverses - ? null - : new Array(operations.length); + const applied = new Array(operations.length); for (let index = 0; index < operations.length; index += 1) { if (!(index in operations)) return null; const operation = operations[index]!; @@ -89,12 +56,9 @@ function runSequentialReplaceBatch( const parsed = parseSafe(operation.path); if (!("ok" in parsed)) return null; prepared[index] = { operation, segments: parsed.segs }; - if (applied !== null) applied[index] = operation; + applied[index] = operation; } - const inverses = captureInverses - ? new Array(operations.length) - : null; const draftContainers = new WeakSet(); let draft = state; for (let index = 0; index < prepared.length; index += 1) { @@ -104,14 +68,12 @@ function runSequentialReplaceBatch( item.segments, item.operation, draftContainers, - inverses, - operations.length - index - 1, ); if (replaced === null) return null; draft = replaced; } - return { state: draft, applied, inverses }; + return { state: draft, applied }; } function replaceDraftValue( @@ -119,8 +81,6 @@ function replaceDraftValue( segments: ReadonlyArray, operation: ReplaceOperation, draftContainers: WeakSet, - inverses: JSONPatchOperation[] | null, - inverseIndex: number, ): unknown | null { if (segments.length === 0) return null; const root = ensureDraftContainer(state, draftContainers); @@ -139,13 +99,6 @@ function replaceDraftValue( const target = readDraftChild(current, segments[segments.length - 1]!); if (!target.ok) return null; - if (inverses !== null) { - inverses[inverseIndex] = { - op: "replace", - path: operation.path, - value: target.value, - }; - } writeDraftChild(current, target.key, operation.value); return root; } diff --git a/packages/json-document/src/foundation/patch/track.ts b/packages/json-document/src/foundation/patch/track.ts index 8113d0cc..eaba38b3 100644 --- a/packages/json-document/src/foundation/patch/track.ts +++ b/packages/json-document/src/foundation/patch/track.ts @@ -1,46 +1,16 @@ -// SPEC §0.2 — RFC 6902 op 적용 시 Pointer 좌표를 자동 추적한다. +// RFC 6902 op 적용 시 Pointer 좌표를 자동 추적한다. // 입력: 적용된 op + 기존 Pointer // 출력: 새 Pointer (또는 null = cascading drop) -import { tryParsePointer, buildPointer, isPrefix, parentPointer, lastSegmentIndex, withLastSegment, readAt, type Pointer } from "../pointer/core.js"; +import { + buildPointer, + isPrefix, + tryParsePointer, + type Pointer, +} from "../pointer/core.js"; import { parseArrayIndex } from "../pointer/arrayIndex.js"; import type { JSONPatchOperation } from "./contract.js"; -export function exists(state: unknown, pointer: Pointer): boolean { - const segments = tryParsePointer(pointer); - return segments !== null && readAt(state, segments).ok; -} - -// SPEC §5.7 rule 2 / §5.8 rule 2 — lost pointer 복구: nextSibling → prevSibling → 가장 가까운 존재 ancestor. -// array container 는 좌표 의미가 없는 "항목 컨테이너" 라 fallback 시 건너뛰고 한 단계 더 climb. -export function recoverLostPointer( - lost: Pointer, - applied: ReadonlyArray, - after: unknown, -): Pointer | null { - const idx = lastSegmentIndex(lost); - const parent = parentPointer(lost); - if (idx === null || parent === null) return null; - const trackedParent = trackPointer(parent, applied); - if (trackedParent === null) return null; - const nextCandidate = withLastSegment(`${trackedParent}/${idx}`, idx); - if (nextCandidate !== null && exists(after, nextCandidate)) return nextCandidate; - if (idx > 0) { - const prevCandidate = `${trackedParent}/${idx - 1}`; - if (exists(after, prevCandidate)) return prevCandidate; - } - // array container 를 건너뛰며 가장 가까운 non-array ancestor 로 fallback. - let cur: Pointer | null = trackedParent; - while (cur !== null && cur !== "") { - const segments = tryParsePointer(cur); - if (segments === null) return null; - const r = readAt(after, segments); - if (r.ok && !Array.isArray(r.value)) return cur; - cur = parentPointer(cur); - } - return null; -} - function isArrayIndex(seg: string): boolean { return parseArrayIndex(seg) !== null; } @@ -141,7 +111,7 @@ export function trackPointer( return trackPointerFrom(pointer, applied, 0); } -export function trackPointerFrom( +function trackPointerFrom( pointer: Pointer, applied: ReadonlyArray, startIndex: number, diff --git a/packages/json-document/src/foundation/pointer/array.ts b/packages/json-document/src/foundation/pointer/array.ts deleted file mode 100644 index 3c43da73..00000000 --- a/packages/json-document/src/foundation/pointer/array.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { buildPointer, tryParsePointer, type Pointer } from "./core.js"; -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 = parseArrayIndex(path.slice(indexSlash + 1)); - return index === null - ? null - : { parent: path.slice(0, indexSlash), index }; - } - const segments = tryParsePointer(path); - if (segments === null) return null; - const segment = segments[segments.length - 1]; - if (segment === undefined) return null; - const index = parseArrayIndex(segment); - if (index === null) return null; - return { - parent: buildPointer(segments.slice(0, -1)), - index, - }; -} - -export function appendArrayIndexes(parent: Pointer, indexes: ReadonlyArray): Pointer[] { - const targets = new Array(indexes.length); - if (parent === "") { - for (let index = 0; index < indexes.length; index += 1) { - targets[index] = `/${indexes[index]!}`; - } - return targets; - } - - for (let index = 0; index < indexes.length; index += 1) { - targets[index] = `${parent}/${indexes[index]!}`; - } - return targets; -} diff --git a/packages/json-document/src/foundation/pointer/core.ts b/packages/json-document/src/foundation/pointer/core.ts index fe248a99..46d70ea5 100644 --- a/packages/json-document/src/foundation/pointer/core.ts +++ b/packages/json-document/src/foundation/pointer/core.ts @@ -1,21 +1,21 @@ // RFC 6901 — JSON Pointer. -// 정본: docs/standard/json-document-spec.md §2. 변환은 lossless. +// 문자열과 segment 배열 사이의 변환은 lossless다. import { parseArrayIndex } from "./arrayIndex.js"; export type Pointer = string; -export function escapeSegment(s: string): string { +function escapeSegment(s: string): string { if (!s.includes("~") && !s.includes("/")) return s; return s.replace(/~/g, "~0").replace(/\//g, "~1"); } -export function unescapeSegment(s: string): string { +function unescapeSegment(s: string): string { if (!s.includes("~")) return s; return s.replace(/~1/g, "/").replace(/~0/g, "~"); } -export interface BuildPointerOptions { +interface BuildPointerOptions { /** RFC 6901 §6 — URI fragment 표현 (`#` prefix + percent-encoding). default false. */ uriFragment?: boolean; } @@ -101,7 +101,7 @@ export class PointerSyntaxError extends Error { } // ── Path arithmetic (state-free, schema-free) ─────────────────────────────── -// SPEC §5.6. RFC 6901 위에서 순수 path 조작. state·schema 모름. 모든 editor 가 공유. +// RFC 6901 위의 순수 path 조작으로 state와 schema를 알지 못한다. /** Parent pointer. `""` (root) 는 `null`. `"/a"` → `""`, `"/a/b"` → `"/a"`. */ export function parentPointer(pointer: Pointer): Pointer | null { @@ -110,33 +110,11 @@ export function parentPointer(pointer: Pointer): Pointer | null { return i <= 0 ? "" : pointer.slice(0, i); } -/** 마지막 segment (이스케이프 디코드된). `""` 또는 `null`. */ -export function lastSegment(pointer: Pointer): string | null { - if (pointer === "") return null; - const i = pointer.lastIndexOf("/"); - if (i < 0) return null; - return unescapeSegment(pointer.slice(i + 1)); -} - -/** 마지막 segment 가 array index 면 그 정수, 아니면 `null` (record key 또는 root). */ -export function lastSegmentIndex(pointer: Pointer): number | null { - const seg = lastSegment(pointer); - return seg === null ? null : parseArrayIndex(seg); -} - /** Pointer 끝에 segment 추가. `appendSegment("/a", 0)` → `"/a/0"`, escape 자동. */ export function appendSegment(pointer: Pointer, seg: string | number): Pointer { return pointer + "/" + escapeSegment(String(seg)); } -/** Pointer 의 마지막 segment 를 교체. root 면 `null`. */ -export function withLastSegment(pointer: Pointer, seg: string | number): Pointer | null { - if (pointer === "") return null; - const i = pointer.lastIndexOf("/"); - if (i < 0) return null; - return pointer.slice(0, i + 1) + escapeSegment(String(seg)); -} - // ── Internal helpers (not in public index) ────────────────────────────────── /** segs prefix check. */ diff --git a/packages/json-document/src/foundation/protocol/index.ts b/packages/json-document/src/foundation/protocol/index.ts index d8f096cd..ff007673 100644 --- a/packages/json-document/src/foundation/protocol/index.ts +++ b/packages/json-document/src/foundation/protocol/index.ts @@ -12,7 +12,6 @@ export type { JSONChangeMetadata, JSONDocumentCommitOptions, JSONDocumentCommitResult, - JSONPatchFailure, JSONPatchOperation, JSONPatchResult, JSONValue, @@ -22,7 +21,6 @@ export type { export { cloneJsonSerializable, - cloneTrustedPlainJson, jsonEqual, } from "../json/index.js"; export { @@ -30,7 +28,6 @@ export { query as queryJSONPath, } from "../jsonpath/index.js"; export { - PointerSyntaxError, appendSegment, buildPointer, parentPointer, @@ -38,7 +35,6 @@ export { readAt, tryParsePointer, } from "../pointer/core.js"; -export type { Pointer } from "../pointer/core.js"; export function trackPointer( pointer: Pointer, diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index be3f7cae..39b27344 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -15,7 +15,7 @@ const coreRuntime = [ /^packages\/json-document\/src\//, /^packages\/json-document\/dist\//, /^packages\/json-document\/tests\//, - /^packages\/json-document\/(?:package\.json|public-contract\.json|tsconfig(?:\.test)?\.json|vitest\.config\.ts|eslint\.config\.js)$/, + /^packages\/json-document\/(?:package\.json|public-contract\.json|tsconfig(?:\.test)?\.json|vitest\.config\.ts)$/, ]; const companionRuntime = [ /^packages\/json-document-collaboration\//, diff --git a/scripts/site-route-checks.mjs b/scripts/site-route-checks.mjs index 8791625f..54002fe5 100644 --- a/scripts/site-route-checks.mjs +++ b/scripts/site-route-checks.mjs @@ -1,5 +1,4 @@ const routePathPattern = /^\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/?)*$/; -const validGroups = new Set(["Start", "Demos"]); export function validateSiteRoutes(routes, fail) { if (!Array.isArray(routes) || routes.length === 0) { @@ -34,7 +33,7 @@ export function validateSiteRoutes(routes, fail) { if (typeof route.description !== "string" || route.description.trim() === "") { fail(`site route ${route.path} is missing a description.`); } - if (!validGroups.has(route.group)) { + if (route.group !== "Start") { fail(`site route ${route.path} has an invalid group.`); } diff --git a/tsconfig.json-document-paths.json b/tsconfig.json-document-paths.json deleted file mode 100644 index 32379ced..00000000 --- a/tsconfig.json-document-paths.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@interactive-os/json-document": ["packages/json-document/src/application/document/index.ts"] - } - } -}