From 9c0a774bf5112a097439bbd8b1b96bf8612f2518 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: Fri, 10 Jul 2026 23:49:28 +0900 Subject: [PATCH] feat(labs): add stable-id rebase tracer --- apps/site/src/generated/repo-catalog.ts | 32 +- docs/generated/extensions-catalog.md | 3 +- docs/generated/repo-catalog.json | 32 +- labs/extensions/stable-id-rebase/README.md | 101 ++++ labs/extensions/stable-id-rebase/package.json | 51 ++ labs/extensions/stable-id-rebase/src/index.ts | 7 + labs/extensions/stable-id-rebase/src/plan.ts | 244 +++++++++ labs/extensions/stable-id-rebase/src/types.ts | 76 +++ .../tests/stable-id-rebase.test.ts | 504 ++++++++++++++++++ .../extensions/stable-id-rebase/tsconfig.json | 22 + .../stable-id-rebase/tsconfig.test.json | 15 + .../stable-id-rebase/vitest.config.ts | 13 + scripts/ci-scope.mjs | 1 + scripts/evaluate-extension-lab.mjs | 33 +- scripts/generate-docs.mjs | 4 + 15 files changed, 1133 insertions(+), 5 deletions(-) create mode 100644 labs/extensions/stable-id-rebase/README.md create mode 100644 labs/extensions/stable-id-rebase/package.json create mode 100644 labs/extensions/stable-id-rebase/src/index.ts create mode 100644 labs/extensions/stable-id-rebase/src/plan.ts create mode 100644 labs/extensions/stable-id-rebase/src/types.ts create mode 100644 labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts create mode 100644 labs/extensions/stable-id-rebase/tsconfig.json create mode 100644 labs/extensions/stable-id-rebase/tsconfig.test.json create mode 100644 labs/extensions/stable-id-rebase/vitest.config.ts diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index bb1feb69..a426454b 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -2913,6 +2913,36 @@ export const repoCatalog = { "text" ] }, + { + "path": "labs/extensions/stable-id-rebase", + "name": "@interactive-os/json-document-stable-id-rebase", + "status": "lab-extension", + "private": true, + "publishable": false, + "version": "0.1.0", + "description": "Lab stable-id anchored delayed change planner for json-document documents.", + "license": "MIT", + "summary": "Lab planner for materializing one delayed field replacement against the current\nPointer of a host-owned stable id.", + "guidance": { + "useFor": "materialize one delayed field replacement against a current stable-id target", + "notFor": "id policy, structural patches, CRDT/OT protocols, transport, or merge UI" + }, + "publicExports": [ + "StableIdRebaseDiagnostic", + "StableIdRebaseResult", + "StableIdReplaceInput", + "StableIdTarget", + "rebaseStableChange" + ], + "publicExportCount": 5, + "keywords": [ + "@interactive-os/json-document", + "conflict", + "headless", + "rebase", + "stable-id" + ] + }, { "path": "labs/extensions/swap-items", "name": "@interactive-os/json-document-swap-items", @@ -3285,7 +3315,7 @@ export const repoCatalog = { "totals": { "packages": 20, "officialExtensions": 19, - "labExtensions": 39, + "labExtensions": 40, "apps": 12 } } as const; diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 8fec03ef..3def2348 100644 --- a/docs/generated/extensions-catalog.md +++ b/docs/generated/extensions-catalog.md @@ -28,7 +28,7 @@ Official extensions: 19 | `@interactive-os/json-document-search-replace` | 23 | find and replace text across document string fields | regex engines, rendered text extraction, or search UI | Official headless search and replace extension for text fields in `@interactive-os/json-document` documents. | | `@interactive-os/json-document-snippets` | 12 | insert reusable JSON payloads with schema-safe paste checks | slash palette UI or snippet storage | Official headless snippet insertion extension for `@interactive-os/json-document` documents. | -Lab extensions: 39 +Lab extensions: 40 Lab extensions are private candidates. They are listed to show product pressure, not as shipped packages. @@ -68,6 +68,7 @@ Lab extensions are private candidates. They are listed to show product pressure, | `@interactive-os/json-document-sort-items` | lab-only | 13 | sort or reverse JSON array items | query views, filters, or server sorting | Lab collection sort extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-sparse-record` | lab-only | 14 | set or remove keyed entries in sparse JSON records with add/replace/remove/no-op planning | 2D grid coordinate expansion, product key normalization, or rendered selection | Lab sparse-record entry editing extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-split-text` | lab-only | 9 | split a string into array items by a delimiter (tag input, paste-as-list) | CSV/TSV quoting, split-to-columns, or clipboard access | Lab split-text extension for `@interactive-os/json-document` documents. | +| `@interactive-os/json-document-stable-id-rebase` | lab-only | 5 | materialize one delayed field replacement against a current stable-id target | id policy, structural patches, CRDT/OT protocols, transport, or merge UI | Lab planner for materializing one delayed field replacement against the current Pointer of a host-owned stable id. | | `@interactive-os/json-document-swap-items` | lab-only | 8 | exchange the positions of two items in the same array | cross-array swaps or moving to an arbitrary index | Lab swap-items extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-toggle-option` | lab-only | 9 | toggle, add, or remove a value's presence in a JSON array (tag/multi-select) | ordered insertion position or deduping an existing array | Lab toggle-option extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-toggle-value` | lab-only | 10 | toggle a boolean or advance an enum/value field (enum options come from the schema) | rendered toggle controls or keyboard policy | Lab toggle-value extension for `@interactive-os/json-document` documents. | diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index ab523820..d9d78eb6 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -2912,6 +2912,36 @@ "text" ] }, + { + "path": "labs/extensions/stable-id-rebase", + "name": "@interactive-os/json-document-stable-id-rebase", + "status": "lab-extension", + "private": true, + "publishable": false, + "version": "0.1.0", + "description": "Lab stable-id anchored delayed change planner for json-document documents.", + "license": "MIT", + "summary": "Lab planner for materializing one delayed field replacement against the current\nPointer of a host-owned stable id.", + "guidance": { + "useFor": "materialize one delayed field replacement against a current stable-id target", + "notFor": "id policy, structural patches, CRDT/OT protocols, transport, or merge UI" + }, + "publicExports": [ + "StableIdRebaseDiagnostic", + "StableIdRebaseResult", + "StableIdReplaceInput", + "StableIdTarget", + "rebaseStableChange" + ], + "publicExportCount": 5, + "keywords": [ + "@interactive-os/json-document", + "conflict", + "headless", + "rebase", + "stable-id" + ] + }, { "path": "labs/extensions/swap-items", "name": "@interactive-os/json-document-swap-items", @@ -3284,7 +3314,7 @@ "totals": { "packages": 20, "officialExtensions": 19, - "labExtensions": 39, + "labExtensions": 40, "apps": 12 } } diff --git a/labs/extensions/stable-id-rebase/README.md b/labs/extensions/stable-id-rebase/README.md new file mode 100644 index 00000000..3da2e920 --- /dev/null +++ b/labs/extensions/stable-id-rebase/README.md @@ -0,0 +1,101 @@ +# @interactive-os/json-document-stable-id-rebase + +Lab planner for materializing one delayed field replacement against the current +Pointer of a host-owned stable id. + +It complements the positional `patch-rebase` lab. Instead of replaying a patch +log, it assumes remote changes are already present in the document and asks the +official id resolver where the authored target lives now. + +```ts +const planned = rebaseStableChange(doc, { + scopes: [{ + scope: "card", + query: "$.columns[*].cards[*]", + readId: value => isCard(value) ? value.id : undefined, + }], + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "Draft", + value: "Reviewed", + relativeSelectionAfter: "/title", +}); + +if (planned.ok) { + const options = planned.selectionAfter === undefined + ? {} + : { selectionAfter: planned.selectionAfter }; + doc.commit(planned.operations, options); +} +``` + +`relativePath` and `relativeSelectionAfter` are RFC 6901 Pointers rooted at the +stable entity. A successful result's `selectionAfter` is instead an absolute +document Pointer. URI-fragment forms are accepted and returned as ordinary +canonical Pointers. `expected` is the field value observed when the delayed +input was authored; a different current value produces `target_changed` rather +than an overwrite. + +## Scope + +- Bind the official id resolver to the supplied document from scope + descriptors, so a resolver for another document cannot be passed by mistake. +- Resolve one delayed non-root field replacement against the current document. +- Preserve a remote move, reorder, or unrelated field edit on the same entity. +- Return the resolver's missing, duplicate, invalid-query, and missing-scope + meanings as structured identity failures. +- Prefix the replacement with an entity snapshot guard and an authored-value + guard for one atomic `doc.commit`. +- Re-run the target scope's `readId` against the preview entity and reject an + `readId`-changing replacement. +- Rebase one Pointer selection relative to the same stable entity, or report + that it was dropped when it will not exist after the change. +- Preflight the complete guarded plan through public `doc.canPatch` semantics. + +## Non-goals + +- No patch-log replay, transport, acknowledgement, offline queue, causal clock, + CRDT, OT, convergence, or replicated undo. +- No id generation, rekeying, repair, tombstone, or universal id field. +- No stable-entity root replacement, structural add/remove/move/copy, or + multiple target batch language. +- No identity inference below `relativePath`; nested array indexes remain + positional. +- No ABA detection when an identical entity disappears and reappears. +- No scope-wide protection against a duplicate id introduced after planning; + products needing that invariant must enforce it in their document schema. +- No text offset, multi-range selection, DOM focus, render, or input policy. +- No mutation during planning and no plugin registration. + +## Friction report + +The existing resolver is sufficient to reconnect a delayed input after a move +without adding identity to core. The planner creates it internally from the +provided scopes, eliminating the unsafe possibility of resolving against a +different document. Scope names must be unique and `readId` must be deterministic +and non-mutating. + +After the structural dry-run, the planner applies the matching scope's `readId` +to the preview entity and verifies that the id is unchanged. It cannot rerun the +scope's JSONPath query against the preview without another schema-backed +document, so a field that changes query membership while preserving `readId` +remains a caller-owned constraint in this tracer. + +The resolver exposes the current entity Pointer but not a cheap identity guard +Pointer or revision token. The planner therefore guards the entire current +entity value. That stops a second move between planning and commit, but copying +and comparing a large entity is expensive and an unrelated post-plan field edit +causes a conservative conflict. This is concrete pressure for an indexed +resolver plus identity/revision token; it is not a reason to add clocks or CRDT +state to `JSONDocument` core. + +The official resolver currently scans its configured query on each `resolve`. +This tracer calls it once, but a broad scope is still linear in its match set. +`doc.canPatch` also pays the document's schema/refinement cost. One additional +structural dry-run always verifies preview identity, and selection checking +reuses that preview when requested. + +The module intentionally handles one replacement. If multiple anchored edits, +identity-based destinations, or structural operations become real callers, +they should first prove a shared operation language rather than widening this +tracer speculatively. diff --git a/labs/extensions/stable-id-rebase/package.json b/labs/extensions/stable-id-rebase/package.json new file mode 100644 index 00000000..6a07368f --- /dev/null +++ b/labs/extensions/stable-id-rebase/package.json @@ -0,0 +1,51 @@ +{ + "name": "@interactive-os/json-document-stable-id-rebase", + "version": "0.1.0", + "description": "Lab stable-id anchored delayed change planner for json-document documents.", + "private": true, + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "labs/extensions/stable-id-rebase" + }, + "keywords": [ + "@interactive-os/json-document", + "stable-id", + "rebase", + "conflict", + "headless" + ], + "files": [ + "README.md", + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -p tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0", + "@interactive-os/json-document-id-resolver": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0", + "@interactive-os/json-document": "*", + "@interactive-os/json-document-id-resolver": "*" + } +} diff --git a/labs/extensions/stable-id-rebase/src/index.ts b/labs/extensions/stable-id-rebase/src/index.ts new file mode 100644 index 00000000..5996fe44 --- /dev/null +++ b/labs/extensions/stable-id-rebase/src/index.ts @@ -0,0 +1,7 @@ +export { rebaseStableChange } from "./plan.js"; +export type { + StableIdReplaceInput, + StableIdRebaseResult, + StableIdRebaseDiagnostic, + StableIdTarget, +} from "./types.js"; diff --git a/labs/extensions/stable-id-rebase/src/plan.ts b/labs/extensions/stable-id-rebase/src/plan.ts new file mode 100644 index 00000000..512ea454 --- /dev/null +++ b/labs/extensions/stable-id-rebase/src/plan.ts @@ -0,0 +1,244 @@ +import { + applyPatch, + buildPointer, + tryParsePointer, + type JSONDocument, + type JSONPatchOperation, + type Pointer, +} from "@interactive-os/json-document"; +import { + createIdResolver, +} from "@interactive-os/json-document-id-resolver"; + +import type { + StableIdReplaceInput, + StableIdRebaseResult, +} from "./types.js"; + +const PERMISSIVE_JSON_SCHEMA = { + safeParse: (input: unknown) => ({ success: true as const, data: input }), +}; + +export function rebaseStableChange( + doc: JSONDocument, + input: StableIdReplaceInput, +): StableIdRebaseResult { + const relativePath = canonicalPointer(input.relativePath); + if (relativePath === null) { + return { + ok: false, + code: "invalid_change", + reason: `invalid relative pointer: ${input.relativePath}`, + pointer: input.relativePath, + }; + } + if (relativePath === "") { + return { + ok: false, + code: "invalid_change", + reason: "stable entity root replacement is not supported", + pointer: relativePath, + }; + } + + const scopeNames = new Set(); + for (const candidate of input.scopes) { + if (scopeNames.has(candidate.scope)) { + return { + ok: false, + code: "invalid_change", + reason: `stable id scope must be registered once: ${candidate.scope}`, + }; + } + scopeNames.add(candidate.scope); + } + const scope = input.scopes.find((candidate) => { + return candidate.scope === input.target.scope; + }); + + const ids = createIdResolver(doc, { scopes: input.scopes }); + const resolved = ids.resolve(input.target.scope, input.target.id); + if (!resolved.ok) { + return { + ok: false, + code: "identity_resolution_failed", + reason: resolved.reason, + identityCode: resolved.code, + scope: input.target.scope, + id: input.target.id, + ...(resolved.pointers === undefined + ? {} + : { pointers: [...resolved.pointers] }), + }; + } + + const targetPointer = canonicalPointer(resolved.pointer); + if (targetPointer === null) { + return { + ok: false, + code: "identity_resolution_failed", + reason: `id resolver returned an invalid pointer: ${resolved.pointer}`, + identityCode: "invalid_pointer", + scope: input.target.scope, + id: input.target.id, + }; + } + const target = doc.at(targetPointer); + if (!target.ok) { + return { + ok: false, + code: "identity_resolution_failed", + reason: target.reason ?? `stable target does not exist: ${targetPointer}`, + identityCode: "target_not_found", + scope: input.target.scope, + id: input.target.id, + }; + } + + const changePointer = joinPointers(targetPointer, relativePath); + let selectionAfter: Pointer | undefined; + if (input.relativeSelectionAfter !== undefined) { + const relativeSelection = canonicalPointer(input.relativeSelectionAfter); + if (relativeSelection === null) { + return { + ok: false, + code: "invalid_change", + reason: `invalid relative selection pointer: ${input.relativeSelectionAfter}`, + pointer: input.relativeSelectionAfter, + }; + } + selectionAfter = joinPointers(targetPointer, relativeSelection); + } + + const operations: JSONPatchOperation[] = [ + { op: "test", path: targetPointer, value: cloneJson(target.value) }, + { op: "test", path: changePointer, value: input.expected }, + { op: "replace", path: changePointer, value: input.value }, + ]; + const capability = doc.canPatch(operations); + if (!capability.ok) { + if (capability.code === "test_failed" || capability.code === "path_not_found") { + return { + ok: false, + code: "target_changed", + reason: capability.reason ?? `stable target changed: ${changePointer}`, + pointer: changePointer, + capability: cloneJson(capability), + }; + } + return { + ok: false, + code: "change_patch_failed", + reason: capability.reason ?? `stable change patch failed: ${changePointer}`, + capability: cloneJson(capability), + }; + } + + const applied = applyPatch( + PERMISSIVE_JSON_SCHEMA as Parameters[0], + doc.value, + operations, + ); + if (!applied.result.ok) { + if ( + applied.result.code === "test_failed" + || applied.result.code === "path_not_found" + ) { + return { + ok: false, + code: "target_changed", + reason: applied.result.reason ?? `stable target changed: ${changePointer}`, + pointer: changePointer, + capability: cloneJson(applied.result), + }; + } + return { + ok: false, + code: "change_patch_failed", + reason: applied.result.reason ?? `stable change patch failed: ${changePointer}`, + capability: cloneJson(applied.result), + }; + } + + const finalTarget = readPointerValue(applied.state, targetPointer); + let finalId: string | null | undefined; + try { + finalId = scope?.readId( + finalTarget.ok ? finalTarget.value : undefined, + targetPointer, + ); + } catch { + finalId = undefined; + } + if (!finalTarget.ok || scope === undefined || finalId !== input.target.id) { + return { + ok: false, + code: "identity_changed", + reason: `stable change would alter identity ${input.target.scope}:${input.target.id}`, + scope: input.target.scope, + id: input.target.id, + pointer: targetPointer, + }; + } + + const diagnostics = []; + if (selectionAfter !== undefined) { + if (!readPointerValue(applied.state, selectionAfter).ok) { + diagnostics.push({ + code: "selection_dropped" as const, + reason: "stable selection target will not exist after the change", + pointer: selectionAfter, + }); + selectionAfter = undefined; + } + } + + return { + ok: true, + operations: operations.map((operation) => cloneJson(operation)), + ...(selectionAfter === undefined ? {} : { selectionAfter }), + diagnostics, + }; +} + +function canonicalPointer(pointer: Pointer): Pointer | null { + const segments = tryParsePointer(pointer); + return segments === null ? null : buildPointer(segments); +} + +function joinPointers(parent: Pointer, relative: Pointer): Pointer { + return buildPointer([ + ...tryParsePointer(parent)!, + ...tryParsePointer(relative)!, + ]); +} + +function cloneJson(value: T): T { + if (value === undefined) return undefined as T; + return JSON.parse(JSON.stringify(value)) as T; +} + +function readPointerValue( + state: unknown, + pointer: Pointer, +): { ok: true; value: unknown } | { ok: false } { + let current = state; + for (const segment of tryParsePointer(pointer) ?? []) { + if (Array.isArray(current)) { + if (!/^(0|[1-9]\d*)$/.test(segment)) return { ok: false }; + const index = Number(segment); + if (index >= current.length) return { ok: false }; + current = current[index]; + continue; + } + if ( + current === null + || typeof current !== "object" + || !Object.prototype.hasOwnProperty.call(current, segment) + ) { + return { ok: false }; + } + current = (current as Record)[segment]; + } + return { ok: true, value: current }; +} diff --git a/labs/extensions/stable-id-rebase/src/types.ts b/labs/extensions/stable-id-rebase/src/types.ts new file mode 100644 index 00000000..583a8f0d --- /dev/null +++ b/labs/extensions/stable-id-rebase/src/types.ts @@ -0,0 +1,76 @@ +import type { + JSONCapabilityResult, + JSONPatchOperation, + Pointer, +} from "@interactive-os/json-document"; +import type { + IdResolverScope, + ResolveIdErrorCode, +} from "@interactive-os/json-document-id-resolver"; + +export interface StableIdTarget { + readonly scope: string; + readonly id: string; +} + +export interface StableIdReplaceInput { + readonly scopes: ReadonlyArray; + readonly target: StableIdTarget; + readonly relativePath: Pointer; + readonly expected: unknown; + readonly value: unknown; + readonly relativeSelectionAfter?: Pointer; +} + +export interface StableIdRebaseDiagnostic { + readonly code: "selection_dropped"; + readonly reason: string; + readonly pointer: Pointer; +} + +export type StableIdRebaseResult = + | { + readonly ok: true; + readonly operations: ReadonlyArray; + readonly selectionAfter?: Pointer; + readonly diagnostics: ReadonlyArray; + } + | { + readonly ok: false; + readonly code: "invalid_change"; + readonly reason: string; + readonly pointer?: Pointer; + } + | { + readonly ok: false; + readonly code: "identity_resolution_failed"; + readonly reason: string; + readonly identityCode: + | ResolveIdErrorCode + | "invalid_pointer" + | "target_not_found"; + readonly scope: string; + readonly id: string; + readonly pointers?: ReadonlyArray; + } + | { + readonly ok: false; + readonly code: "identity_changed"; + readonly reason: string; + readonly scope: string; + readonly id: string; + readonly pointer: Pointer; + } + | { + readonly ok: false; + readonly code: "target_changed"; + readonly reason: string; + readonly pointer: Pointer; + readonly capability: Exclude; + } + | { + readonly ok: false; + readonly code: "change_patch_failed"; + readonly reason: string; + readonly capability: Exclude; + }; diff --git a/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts b/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts new file mode 100644 index 00000000..ea3d2c7b --- /dev/null +++ b/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, test } from "vitest"; +import * as z from "zod"; + +import { + createJSONDocument, +} from "@interactive-os/json-document"; +import { + createIdResolver, +} from "@interactive-os/json-document-id-resolver"; +import { + rebaseStableChange, + type StableIdReplaceInput, +} from "../src/index.js"; + +const Card = z.object({ + id: z.string(), + title: z.string(), + status: z.string().optional(), + details: z.union([ + z.object({ cursor: z.string() }), + z.object({ label: z.string() }), + ]).optional(), +}); +const Board = z.object({ + columns: z.array(z.object({ + id: z.string(), + cards: z.array(Card), + })), +}); + +const CARD_SCOPES = [{ + scope: "card", + query: "$.columns[*].cards[*]", + readId: (value: unknown) => Card.safeParse(value).data?.id, +}]; + +function createBoard() { + return createJSONDocument(Board, { + columns: [{ + id: "todo", + cards: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }, { + id: "done", + cards: [ + { id: "c", title: "C" }, + ], + }], + }, { + history: 10, + selection: { + mode: "single", + initial: ["/columns/0/cards/1/title"], + }, + }); +} + +function createCardIds(doc: ReturnType) { + return createIdResolver(doc, { scopes: CARD_SCOPES }); +} + +function rebaseCardChange( + doc: ReturnType, + input: Omit, +) { + return rebaseStableChange(doc, { + scopes: CARD_SCOPES, + ...input, + }); +} + +describe("@interactive-os/json-document-stable-id-rebase", () => { + test("materializes a delayed field change at the stable target's current pointer", () => { + const doc = createBoard(); + expect(doc.move( + "/columns/0/cards/1", + "/columns/1/cards/-", + )).toMatchObject({ ok: true }); + + const planned = rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + relativeSelectionAfter: "/title", + }); + + expect(planned).toEqual({ + ok: true, + operations: [ + { + op: "test", + path: "/columns/1/cards/1", + value: { id: "b", title: "B" }, + }, + { + op: "test", + path: "/columns/1/cards/1/title", + value: "B", + }, + { + op: "replace", + path: "/columns/1/cards/1/title", + value: "Reviewed", + }, + ], + selectionAfter: "/columns/1/cards/1/title", + diagnostics: [], + }); + + if (!planned.ok || planned.selectionAfter === undefined) { + throw new Error("expected a stable selection plan"); + } + expect(doc.commit(planned.operations, { + selectionAfter: planned.selectionAfter, + })).toEqual({ ok: true }); + expect(doc.value.columns[1]?.cards[1]).toEqual({ + id: "b", + title: "Reviewed", + }); + expect(doc.selection?.primaryPointer).toBe("/columns/1/cards/1/title"); + }); + + test("reports a conflict when the stable field changed while the input was delayed", () => { + const doc = createBoard(); + expect(doc.replace( + "/columns/0/cards/1/title", + "Remote", + )).toMatchObject({ ok: true }); + + const planned = rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + }); + + expect(planned).toMatchObject({ + ok: false, + code: "target_changed", + pointer: "/columns/0/cards/1/title", + capability: { + ok: false, + code: "test_failed", + }, + }); + expect(doc.value.columns[0]?.cards[1]?.title).toBe("Remote"); + expect(doc.history.undoDepth).toBe(1); + }); + + test("reports a removed authored field as a target conflict", () => { + const doc = createBoard(); + expect(doc.patch({ + op: "add", + path: "/columns/0/cards/1/status", + value: "draft", + })).toEqual({ ok: true }); + expect(doc.delete( + "/columns/0/cards/1/status", + )).toMatchObject({ ok: true }); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/status", + expected: "draft", + value: "local", + })).toMatchObject({ + ok: false, + code: "target_changed", + pointer: "/columns/0/cards/1/status", + capability: { + ok: false, + code: "path_not_found", + }, + }); + }); + + test("preserves a concurrent change to another field on the stable entity", () => { + const doc = createBoard(); + expect(doc.patch({ + op: "add", + path: "/columns/0/cards/1/status", + value: "remote", + })).toEqual({ ok: true }); + + const planned = rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + }); + if (!planned.ok) throw new Error(planned.reason); + + expect(planned.operations[0]).toEqual({ + op: "test", + path: "/columns/0/cards/1", + value: { id: "b", title: "B", status: "remote" }, + }); + expect(doc.commit(planned.operations)).toEqual({ ok: true }); + expect(doc.value.columns[0]?.cards[1]).toEqual({ + id: "b", + title: "Local", + status: "remote", + }); + }); + + test("guards a wrong slot after another stable-target reorder", () => { + const doc = createBoard(); + expect(doc.replace( + "/columns/0/cards/0/title", + "B", + )).toMatchObject({ ok: true }); + const planned = rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + relativeSelectionAfter: "/title", + }); + if (!planned.ok || planned.selectionAfter === undefined) { + throw new Error("expected a guarded stable-id plan"); + } + + expect(doc.move( + "/columns/0/cards/1", + "/columns/0/cards/0", + )).toMatchObject({ ok: true }); + const selectionBeforeCommit = doc.selection?.primaryPointer; + const historyBeforeCommit = doc.history.undoDepth; + + expect(doc.commit(planned.operations, { + selectionAfter: planned.selectionAfter, + })).toMatchObject({ + ok: false, + code: "test_failed", + }); + expect(doc.value.columns[0]?.cards).toEqual([ + { id: "b", title: "B" }, + { id: "a", title: "B" }, + ]); + expect(doc.selection?.primaryPointer).toBe(selectionBeforeCommit); + expect(doc.history.undoDepth).toBe(historyBeforeCommit); + }); + + test("reports a schema-validation reentry move as a target conflict", () => { + let doc: ReturnType | undefined; + let moveDuringValidation = false; + const ReentrantBoard = Board.superRefine(() => { + if (!moveDuringValidation || doc === undefined) return; + moveDuringValidation = false; + expect(doc.move( + "/columns/0/cards/1", + "/columns/0/cards/0", + )).toMatchObject({ ok: true }); + }); + doc = createJSONDocument(ReentrantBoard, { + columns: [{ + id: "todo", + cards: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }, { + id: "done", + cards: [ + { id: "c", title: "C" }, + ], + }], + }, { history: 10 }); + moveDuringValidation = true; + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + })).toMatchObject({ + ok: false, + code: "target_changed", + pointer: "/columns/0/cards/1/title", + capability: { + ok: false, + code: "test_failed", + }, + }); + expect(doc.value.columns[0]?.cards).toEqual([ + { id: "b", title: "B" }, + { id: "a", title: "A" }, + ]); + expect(doc.history.undoDepth).toBe(1); + }); + + test("reports a missing stable identity instead of using its former pointer", () => { + const doc = createBoard(); + expect(doc.delete("/columns/0/cards/1")).toMatchObject({ ok: true }); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + })).toEqual({ + ok: false, + code: "identity_resolution_failed", + reason: "id not found in scope card: b", + identityCode: "id_not_found", + scope: "card", + id: "b", + }); + expect(doc.value.columns[0]?.cards).toEqual([ + { id: "a", title: "A" }, + ]); + }); + + test("reports every pointer when a stable identity becomes ambiguous", () => { + const doc = createBoard(); + expect(doc.replace( + "/columns/1/cards/0/id", + "b", + )).toMatchObject({ ok: true }); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + })).toEqual({ + ok: false, + code: "identity_resolution_failed", + reason: "id is duplicated in scope card: b", + identityCode: "ambiguous_id", + scope: "card", + id: "b", + pointers: [ + "/columns/0/cards/1", + "/columns/1/cards/0", + ], + }); + }); + + test("rejects stable entity root replacement outside the field-change scope", () => { + const doc = createBoard(); + const ids = createCardIds(doc); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "", + expected: { id: "b", title: "B" }, + value: { id: "other", title: "Local" }, + })).toEqual({ + ok: false, + code: "invalid_change", + reason: "stable entity root replacement is not supported", + pointer: "", + }); + expect(ids.resolve("card", "b")).toMatchObject({ ok: true }); + }); + + test("rejects a field replacement that changes the resolver-owned identity", () => { + const doc = createBoard(); + const ids = createCardIds(doc); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/id", + expected: "b", + value: "other", + })).toEqual({ + ok: false, + code: "identity_changed", + reason: "stable change would alter identity card:b", + scope: "card", + id: "b", + pointer: "/columns/0/cards/1", + }); + expect(ids.resolve("card", "b")).toMatchObject({ ok: true }); + expect(ids.resolve("card", "other")).toMatchObject({ + ok: false, + code: "id_not_found", + }); + }); + + test("returns the document schema failure without producing a plan", () => { + const doc = createBoard(); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: 42, + })).toMatchObject({ + ok: false, + code: "change_patch_failed", + capability: { + ok: false, + code: "schema_violation", + }, + }); + expect(doc.value.columns[0]?.cards[1]).toEqual({ + id: "b", + title: "B", + }); + }); + + test("returns a structured failure for a non-JSON replacement value", () => { + const doc = createBoard(); + + expect(() => rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: 1n, + })).not.toThrow(); + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: 1n, + })).toMatchObject({ + ok: false, + code: "change_patch_failed", + capability: { + ok: false, + code: "not_serializable", + }, + }); + }); + + test("drops an anchored selection that will not exist after the change", () => { + const doc = createBoard(); + expect(doc.patch({ + op: "add", + path: "/columns/0/cards/1/details", + value: { cursor: "inside" }, + })).toEqual({ ok: true }); + + const planned = rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/details", + expected: { cursor: "inside" }, + value: { label: "replaced" }, + relativeSelectionAfter: "/details/cursor", + }); + + expect(planned).toMatchObject({ + ok: true, + diagnostics: [{ + code: "selection_dropped", + reason: "stable selection target will not exist after the change", + pointer: "/columns/0/cards/1/details/cursor", + }], + }); + expect(planned).not.toHaveProperty("selectionAfter"); + }); + + test("canonicalizes URI-fragment paths before joining them to the stable target", () => { + const doc = createBoard(); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "#/title", + expected: "B", + value: "Local", + relativeSelectionAfter: "#/title", + })).toMatchObject({ + ok: true, + operations: [ + { op: "test", path: "/columns/0/cards/1" }, + { op: "test", path: "/columns/0/cards/1/title" }, + { op: "replace", path: "/columns/0/cards/1/title" }, + ], + selectionAfter: "/columns/0/cards/1/title", + diagnostics: [], + }); + }); + + test("owns returned guard and replacement values independently from input aliases", () => { + const doc = createBoard(); + expect(doc.patch({ + op: "add", + path: "/columns/0/cards/1/details", + value: { cursor: "before" }, + })).toEqual({ ok: true }); + const expected = { cursor: "before" }; + const value = { label: "planned" }; + + const planned = rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/details", + expected, + value, + }); + if (!planned.ok) throw new Error(planned.reason); + expected.cursor = "mutated"; + value.label = "mutated"; + + expect(doc.commit(planned.operations)).toEqual({ ok: true }); + expect(doc.value.columns[0]?.cards[1]?.details).toEqual({ + label: "planned", + }); + }); +}); diff --git a/labs/extensions/stable-id-rebase/tsconfig.json b/labs/extensions/stable-id-rebase/tsconfig.json new file mode 100644 index 00000000..615be15f --- /dev/null +++ b/labs/extensions/stable-id-rebase/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "rootDir": "src", + "outDir": "dist", + "lib": [ + "ES2022", + "DOM" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/labs/extensions/stable-id-rebase/tsconfig.test.json b/labs/extensions/stable-id-rebase/tsconfig.test.json new file mode 100644 index 00000000..49ef16fc --- /dev/null +++ b/labs/extensions/stable-id-rebase/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": [ + "vitest" + ] + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "vitest.config.ts" + ] +} diff --git a/labs/extensions/stable-id-rebase/vitest.config.ts b/labs/extensions/stable-id-rebase/vitest.config.ts new file mode 100644 index 00000000..e52943b2 --- /dev/null +++ b/labs/extensions/stable-id-rebase/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@interactive-os/json-document": new URL("../../../packages/json-document/src/application/document/index.ts", import.meta.url).pathname, + "@interactive-os/json-document-id-resolver": new URL("../../../packages/id-resolver/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 095cee61..a9b0ed0c 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -75,6 +75,7 @@ const labExtensions = matches([ ...rootDependency, ...ciWorkflow, ...coreDependency, + ...officialExtensionRuntime, ...labRuntime, ]); const site = matches([ diff --git a/scripts/evaluate-extension-lab.mjs b/scripts/evaluate-extension-lab.mjs index d44034d6..1810d0b0 100644 --- a/scripts/evaluate-extension-lab.mjs +++ b/scripts/evaluate-extension-lab.mjs @@ -13,6 +13,8 @@ const fullVerificationPathPatterns = [ /^packages\/json-document\/src\//, /^packages\/json-document\/dist\//, /^packages\/json-document\/(?:package\.json|public-contract\.json|tsconfig\.json)$/, + /^packages\/(?!json-document\/)[^/]+\/(?:src|tests)\//, + /^packages\/(?!json-document\/)[^/]+\/(?:package\.json|tsconfig(?:\.test)?\.json|vitest\.config\.ts|eslint\.config\.js)$/, /^scripts\/evaluate-extension-lab\.mjs$/, ]; const retiredLabNames = new Set([ @@ -81,6 +83,21 @@ function buildCorePackage() { } } +function buildOfficialDependencies(targets) { + const dependencies = new Set( + targets.flatMap((target) => target.officialDependencies), + ); + for (const dependency of dependencies) { + const result = spawnSync("npm", ["run", "build", "-w", dependency], { + cwd: root, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`${dependency} build failed before lab verification.`); + } + } +} + function isCoreResolutionFailure(output) { return output.includes("Cannot find module '@interactive-os/json-document'") || (/TS2307/.test(output) && /(?:^|\.\.\/|\/)json-document\/dist\//.test(output)); @@ -267,8 +284,14 @@ for (const dir of labs) { for (const match of source.matchAll(/\bfrom\s+["']([^"']+)["']/g)) { const specifier = match[1]; if (specifier === "@interactive-os/json-document") continue; + if (officialPackageNames.has(specifier)) { + if (pkg.peerDependencies?.[specifier] === undefined) { + fail(`${sourcePath}: official extension imports must be declared as peer dependencies (${specifier}).`); + } + continue; + } if (specifier.startsWith(".")) continue; - fail(`${sourcePath}: lab source must import json-document only through the public package entrypoint (${specifier}).`); + fail(`${sourcePath}: lab source may import only json-document or declared official extensions (${specifier}).`); } if (/src\/application|src\/domain|src\/foundation|\.\.\/json-document\/src/.test(source)) { fail(`${sourcePath}: lab source must not import json-document internals.`); @@ -279,7 +302,12 @@ for (const dir of labs) { } if (verify && selectedVerification.dirs.has(dir)) { - verificationTargets.push({ dir, name: pkg.name }); + verificationTargets.push({ + dir, + name: pkg.name, + officialDependencies: Object.keys(pkg.peerDependencies ?? {}) + .filter((dependency) => officialPackageNames.has(dependency)), + }); } } @@ -287,6 +315,7 @@ if (verify && process.exitCode !== 1) { console.log(`extension lab verify scope: ${verificationTargets.length}/${labs.length} package(s); ${selectedVerification.reason}`); if (verificationTargets.length > 0) { buildCorePackage(); + buildOfficialDependencies(verificationTargets); await verifyPackages(verificationTargets); } } diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index 41f10a30..6258c738 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -102,6 +102,10 @@ const extensionGuidance = { useFor: "plan conservative optimistic rebases over ordered applied JSON Patch batches", notFor: "CRDT/OT protocols, transport, durable sync queues, or merge UI", }, + "@interactive-os/json-document-stable-id-rebase": { + useFor: "materialize one delayed field replacement against a current stable-id target", + notFor: "id policy, structural patches, CRDT/OT protocols, transport, or merge UI", + }, "@interactive-os/json-document-drag-drop": { useFor: "turn drag/drop intent into move or paste operations", notFor: "DOM drag/drop events, hit testing, or hover UI",