diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index 78498d45..bb1feb69 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -2650,6 +2650,39 @@ export const repoCatalog = { "paste" ] }, + { + "path": "labs/extensions/patch-rebase", + "name": "@interactive-os/json-document-patch-rebase", + "status": "lab-extension", + "private": true, + "publishable": false, + "version": "0.1.0", + "description": "Lab optimistic JSON Patch rebase planner for json-document documents.", + "license": "MIT", + "summary": "Lab optimistic JSON Patch rebase planner for `@interactive-os/json-document`\ndocuments.", + "guidance": { + "useFor": "plan conservative optimistic rebases over ordered applied JSON Patch batches", + "notFor": "CRDT/OT protocols, transport, durable sync queues, or merge UI" + }, + "publicExports": [ + "RebaseChangeInput", + "RebaseChangeResult", + "RebaseConflict", + "RebaseConflictCode", + "RebaseDiagnostic", + "RebaseDiagnosticCode", + "RebaseSchema", + "rebaseChange" + ], + "publicExportCount": 8, + "keywords": [ + "@interactive-os/json-document", + "conflict", + "headless", + "patch", + "rebase" + ] + }, { "path": "labs/extensions/references", "name": "@interactive-os/json-document-references", @@ -3252,7 +3285,7 @@ export const repoCatalog = { "totals": { "packages": 20, "officialExtensions": 19, - "labExtensions": 38, + "labExtensions": 39, "apps": 12 } } as const; diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 83e7303f..8fec03ef 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: 38 +Lab extensions: 39 Lab extensions are private candidates. They are listed to show product pressure, not as shipped packages. @@ -61,6 +61,7 @@ Lab extensions are private candidates. They are listed to show product pressure, | `@interactive-os/json-document-pad-text` | lab-only | 9 | pad a string field to a minimum length (zero-padded codes/IDs) | number formatting or display-time alignment | Lab pad-text extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-paste-cells` | lab-only | 9 | paste a 2D value matrix onto a rectangular array-of-records region | TSV/CSV parsing, clipboard I/O, or auto-growing the array | Lab paste-cells extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-paste-special` | lab-only | 17 | adapt external payloads before schema-safe paste | browser clipboard I/O or autocomplete dropdowns | Lab extension for paste special. | +| `@interactive-os/json-document-patch-rebase` | lab-only | 8 | plan conservative optimistic rebases over ordered applied JSON Patch batches | CRDT/OT protocols, transport, durable sync queues, or merge UI | Lab optimistic JSON Patch rebase planner for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-references` | lab-only | 21 | track stable references and backlinks over JSON documents | route state or rendered links | Lab extension for stable references and backlinks over JSON documents. | | `@interactive-os/json-document-renumber-items` | lab-only | 9 | sync an order/position field to each item array position after a reorder | reordering the array itself, or fractional/gap indexing | Lab renumber-items extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-round` | lab-only | 10 | round a number to a precision or nearest step (round/floor/ceil/trunc) | currency/locale formatting, or increment/clamp (see increment-number) | Lab round extension for `@interactive-os/json-document` documents. | diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index b6d3f9c0..ab523820 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -2649,6 +2649,39 @@ "paste" ] }, + { + "path": "labs/extensions/patch-rebase", + "name": "@interactive-os/json-document-patch-rebase", + "status": "lab-extension", + "private": true, + "publishable": false, + "version": "0.1.0", + "description": "Lab optimistic JSON Patch rebase planner for json-document documents.", + "license": "MIT", + "summary": "Lab optimistic JSON Patch rebase planner for `@interactive-os/json-document`\ndocuments.", + "guidance": { + "useFor": "plan conservative optimistic rebases over ordered applied JSON Patch batches", + "notFor": "CRDT/OT protocols, transport, durable sync queues, or merge UI" + }, + "publicExports": [ + "RebaseChangeInput", + "RebaseChangeResult", + "RebaseConflict", + "RebaseConflictCode", + "RebaseDiagnostic", + "RebaseDiagnosticCode", + "RebaseSchema", + "rebaseChange" + ], + "publicExportCount": 8, + "keywords": [ + "@interactive-os/json-document", + "conflict", + "headless", + "patch", + "rebase" + ] + }, { "path": "labs/extensions/references", "name": "@interactive-os/json-document-references", @@ -3251,7 +3284,7 @@ "totals": { "packages": 20, "officialExtensions": 19, - "labExtensions": 38, + "labExtensions": 39, "apps": 12 } } diff --git a/labs/extensions/patch-rebase/README.md b/labs/extensions/patch-rebase/README.md new file mode 100644 index 00000000..e4419e0c --- /dev/null +++ b/labs/extensions/patch-rebase/README.md @@ -0,0 +1,90 @@ +# @interactive-os/json-document-patch-rebase + +Lab optimistic JSON Patch rebase planner for `@interactive-os/json-document` +documents. + +It tests how far delayed local edits can be rebased over an ordered applied-patch +stream before a collaboration protocol or CRDT is required. + +```ts +const planned = rebaseChange(DocumentSchema, { + base, + concurrentBatches, + operations: [ + { op: "replace", path: "/items/1/title", value: "Reviewed" }, + ], + selectionAfter: "/items/1/title", +}); + +if (planned.ok) { + doc.commit(planned.operations, { + selectionAfter: planned.selectionAfter, + }); +} +``` + +`base` is the immutable document snapshot from which the local operations and +the ordered, gap-free `concurrentBatches` diverged. In-process callers can retain +the structurally shared `doc.value` reference instead of cloning the whole +document. Persisted workflows must own their snapshot and patch-log format. + +Pass the same Zod-compatible schema that owns the document. The lab types only +the `safeParse` slice of that schema to avoid installing a second Zod runtime; +failed parses must expose an `error.issues` collection, as core `applyPatch` +expects. The returned selection is authoritative even when its Pointer shifts; +diagnostics describe shifted operation targets and dropped selections. URI +fragment Pointers are accepted and canonicalized to ordinary RFC 6901 Pointers +in the returned plan. + +## Scope + +- Replay ordered concurrent batches from a shared base through public + `applyPatch` semantics. +- Validate the local batch against that same base. +- Rebase local non-root `replace` and `test` targets over concurrent `test`, + `replace`, and object/array `add` or `remove` operations. +- Shift local targets and one headless Pointer selection after concrete array + index changes. +- Treat numeric object keys as object members rather than array indexes. +- Report same-path and ancestor/descendant writes as structured conflicts. +- Revalidate the combined result so cross-field schema refinements can report a + semantic conflict. +- Return leading RFC 6902 `test` guards followed by rebased local operations for + one atomic `doc.commit`. +- Guard an independent `selectionAfter` target during the gap between planning + and commit. + +## Non-goals + +- No transport, server version, acknowledgement, offline queue, persistence, or + synchronization session. +- No CRDT, OT, actor identity, causal clock, tombstone, or convergence claim. +- No stable-id matching, array item identity inference, or ABA detection. +- No automatic rebase of concurrent `move`, `copy`, or root mutations. +- No automatic rebase of structural local operations while concurrent changes + exist; uncertain cases return `unsupported_operation` conflict. +- No `SelectionSnap`, multi-range selection, text-offset transform, DOM focus, + rendering, keyboard, or UI policy. +- No plugin registration and no `@interactive-os/json-document` internal + imports. + +## Friction report + +The public `applyPatch`, Pointer helpers, and `trackPointer` exports are enough +for a conservative pure planner. Applied operations must be used instead of raw +inputs so append paths such as `/-` have concrete coordinates. + +Pointer tracking alone is not sufficient: numeric object keys look like array +indexes syntactically. The planner must inspect the replayed JSON state before +shifting array siblings. Likewise, selection freshness needs a guard even when +the selection target is independent from the mutation target. + +The remaining hard cases are identity and causality. A positional Pointer +cannot prove that an item removed and reinserted at the same index is the same +logical node. A later stable-id rebase experiment should compose the official +id resolver before this lab grows a protocol or adapter interface. + +Freshness guards copy the guarded JSON values. A selection outside every local +mutation therefore adds its own guard and can be expensive when it points at a +large subtree. This is deliberate for the tracer; a production protocol needs +stable identity or a cheaper revision/identity token before relaxing it. diff --git a/labs/extensions/patch-rebase/package.json b/labs/extensions/patch-rebase/package.json new file mode 100644 index 00000000..a683fd17 --- /dev/null +++ b/labs/extensions/patch-rebase/package.json @@ -0,0 +1,49 @@ +{ + "name": "@interactive-os/json-document-patch-rebase", + "version": "0.1.0", + "description": "Lab optimistic JSON Patch rebase 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/patch-rebase" + }, + "keywords": [ + "@interactive-os/json-document", + "rebase", + "conflict", + "patch", + "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" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0", + "@interactive-os/json-document": "*" + } +} diff --git a/labs/extensions/patch-rebase/src/copy.ts b/labs/extensions/patch-rebase/src/copy.ts new file mode 100644 index 00000000..a9bf8ec1 --- /dev/null +++ b/labs/extensions/patch-rebase/src/copy.ts @@ -0,0 +1,14 @@ +import type { + JSONPatchOperation, +} from "@interactive-os/json-document"; + +export function copyOperations( + operations: ReadonlyArray, +): JSONPatchOperation[] { + return operations.map((operation) => cloneJson(operation)); +} + +export function cloneJson(value: T): T { + if (value === undefined) return undefined as T; + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/labs/extensions/patch-rebase/src/guards.ts b/labs/extensions/patch-rebase/src/guards.ts new file mode 100644 index 00000000..228e75de --- /dev/null +++ b/labs/extensions/patch-rebase/src/guards.ts @@ -0,0 +1,58 @@ +import { + parentPointer, + type JSONPatchOperation, + type Pointer, +} from "@interactive-os/json-document"; + +import { + cloneJson, +} from "./copy.js"; +import { + pointerRelation, + readPointerValue, +} from "./pointer.js"; + +export function createGuards( + state: unknown, + operations: ReadonlyArray, + selectionAfter: Pointer | undefined, +): { ok: true; operations: JSONPatchOperation[] } | { ok: false; pointer: Pointer } { + const paths: Pointer[] = []; + for (const operation of operations) { + for (const path of guardPaths(operation)) addGuardPath(paths, path); + } + if ( + selectionAfter !== undefined + && !paths.some((path) => { + const relation = pointerRelation(path, selectionAfter); + return relation === "same" || relation === "ancestor"; + }) + ) { + addGuardPath(paths, selectionAfter); + } + + const guards: JSONPatchOperation[] = []; + for (const path of paths) { + const value = readPointerValue(state, path); + if (!value.ok) return { ok: false, pointer: path }; + guards.push({ op: "test", path, value: cloneJson(value.value) }); + } + return { ok: true, operations: guards }; +} + +function addGuardPath(paths: Pointer[], candidate: Pointer): void { + for (let index = paths.length - 1; index >= 0; index -= 1) { + const relation = pointerRelation(paths[index]!, candidate); + if (relation === "same" || relation === "ancestor") return; + if (relation === "descendant") paths.splice(index, 1); + } + paths.push(candidate); +} + +function guardPaths(operation: JSONPatchOperation): Pointer[] { + if (operation.op === "add") return [parentPointer(operation.path) ?? ""]; + if (operation.op === "move" || operation.op === "copy") { + return [operation.from, parentPointer(operation.path) ?? ""]; + } + return [operation.path]; +} diff --git a/labs/extensions/patch-rebase/src/index.ts b/labs/extensions/patch-rebase/src/index.ts new file mode 100644 index 00000000..8c60981c --- /dev/null +++ b/labs/extensions/patch-rebase/src/index.ts @@ -0,0 +1,10 @@ +export { rebaseChange } from "./plan.js"; +export type { + RebaseChangeInput, + RebaseChangeResult, + RebaseConflict, + RebaseConflictCode, + RebaseDiagnostic, + RebaseDiagnosticCode, + RebaseSchema, +} from "./types.js"; diff --git a/labs/extensions/patch-rebase/src/plan.ts b/labs/extensions/patch-rebase/src/plan.ts new file mode 100644 index 00000000..7e6212d0 --- /dev/null +++ b/labs/extensions/patch-rebase/src/plan.ts @@ -0,0 +1,260 @@ +import { + applyPatch, + type JSONPatchOperation, + type Pointer, +} from "@interactive-os/json-document"; + +import { + cloneJson, + copyOperations, +} from "./copy.js"; +import { + createGuards, +} from "./guards.js"; +import { + canonicalPointer, + readPointerValue, +} from "./pointer.js"; +import { + transformLocalOperations, + transformSelection, + type ConcurrentStep, + unsupportedConcurrentChange, +} from "./transform.js"; +import type { + RebaseChangeInput, + RebaseChangeResult, + RebaseSchema, +} from "./types.js"; + +const PERMISSIVE_JSON_SCHEMA: RebaseSchema = { + safeParse: (input) => ({ success: true, data: input }), +}; + +export function rebaseChange( + schema: RebaseSchema, + input: RebaseChangeInput, +): RebaseChangeResult { + let selectionAfter: Pointer | undefined; + if (input.selectionAfter !== undefined) { + const canonicalSelection = canonicalPointer(input.selectionAfter); + if (canonicalSelection === null) { + const reason = `invalid selection pointer: ${input.selectionAfter}`; + return { + ok: false, + code: "conflict", + reason, + conflicts: [{ + code: "invalid_selection", + reason, + pointer: input.selectionAfter, + }], + }; + } + selectionAfter = canonicalSelection; + } + + const local = applyPatchWithSchema( + schema, + input.base, + canonicalizeOperations(input.operations), + ); + if (!local.result.ok) { + return { + ok: false, + code: "change_patch_failed", + reason: local.result.reason ?? "local change patch failed", + result: cloneJson(local.result), + }; + } + + const concurrent = replayConcurrentChanges(schema, input); + if (!concurrent.ok) return concurrent.error; + + const unsupportedConcurrent = unsupportedConcurrentChange(concurrent.steps); + if (unsupportedConcurrent !== null) { + return { + ok: false, + code: "conflict", + reason: unsupportedConcurrent.reason, + conflicts: [unsupportedConcurrent.conflict], + }; + } + + const transformed = transformLocalOperations(local.applied, concurrent.steps); + if (!transformed.ok) { + return { + ok: false, + code: "conflict", + reason: transformed.reason, + conflicts: [transformed.conflict], + }; + } + + const selection = selectionAfter === undefined + ? { selectionAfter: undefined, diagnostics: [] } + : transformSelection(selectionAfter, concurrent.steps); + const guards = createGuards( + concurrent.state, + transformed.operations, + selection.selectionAfter, + ); + if (!guards.ok) { + return { + ok: false, + code: "conflict", + reason: "local change target no longer exists", + conflicts: [{ + code: "target_removed", + reason: `local change target no longer exists: ${guards.pointer}`, + pointer: guards.pointer, + }], + }; + } + + const operations = [ + ...guards.operations, + ...copyOperations(transformed.operations), + ]; + const validated = applyPatchWithSchema(schema, concurrent.state, operations); + if (!validated.result.ok) { + return { + ok: false, + code: "conflict", + reason: "rebased change is not valid on the concurrent state", + conflicts: [{ + code: "schema_conflict", + reason: validated.result.reason ?? "rebased change failed", + ...(validated.result.pointer === undefined ? {} : { pointer: validated.result.pointer }), + }], + }; + } + + const finalSelection = selection.selectionAfter !== undefined + && !readPointerValue(validated.state, selection.selectionAfter).ok + ? { + selectionAfter: undefined, + diagnostics: [ + ...selection.diagnostics, + { + code: "selection_dropped" as const, + reason: "selection target does not exist after the rebased change", + pointer: selection.selectionAfter, + }, + ], + } + : selection; + + return { + ok: true, + operations, + ...(finalSelection.selectionAfter === undefined + ? {} + : { selectionAfter: finalSelection.selectionAfter }), + diagnostics: [ + ...transformed.diagnostics, + ...finalSelection.diagnostics, + ], + }; +} + +function replayConcurrentChanges( + schema: RebaseSchema, + input: RebaseChangeInput, +): + | { ok: true; state: unknown; steps: ConcurrentStep[] } + | { + ok: false; + error: Extract; + } { + let state: unknown = input.base; + const steps: ConcurrentStep[] = []; + for (let batchIndex = 0; batchIndex < input.concurrentBatches.length; batchIndex += 1) { + const before = state; + const replayed = applyPatchWithSchema( + schema, + state, + canonicalizeOperations(input.concurrentBatches[batchIndex]!), + ); + if (!replayed.result.ok) { + return { + ok: false, + error: { + ok: false, + code: "concurrent_patch_failed", + reason: replayed.result.reason ?? `concurrent patch failed at batch ${batchIndex}`, + batchIndex, + result: cloneJson(replayed.result), + }, + }; + } + let operationState = before; + for (let operationIndex = 0; operationIndex < replayed.applied.length; operationIndex += 1) { + const operation = replayed.applied[operationIndex]!; + const appliedStep = applyPatchWithSchema( + PERMISSIVE_JSON_SCHEMA, + operationState, + [operation], + ); + if (!appliedStep.result.ok) { + return { + ok: false, + error: { + ok: false, + code: "concurrent_patch_failed", + reason: appliedStep.result.reason ?? `concurrent operation failed at batch ${batchIndex}`, + batchIndex, + result: cloneJson(appliedStep.result), + }, + }; + } + steps.push({ + operation, + batchIndex, + operationIndex, + before: operationState, + after: appliedStep.state, + }); + operationState = appliedStep.state; + } + state = replayed.state; + } + return { ok: true, state, steps }; +} + +function applyPatchWithSchema( + schema: RebaseSchema, + state: unknown, + operations: ReadonlyArray, +) { + return applyPatch( + schema as Parameters[0], + state, + operations, + ); +} + +function canonicalizeOperations( + operations: ReadonlyArray, +): ReadonlyArray { + if (!Array.isArray(operations)) return operations; + return operations.map((operation) => { + if ( + operation === null + || typeof operation !== "object" + || typeof (operation as { path?: unknown }).path !== "string" + ) { + return operation; + } + const path = canonicalPointer(operation.path) ?? operation.path; + if (operation.op === "move" || operation.op === "copy") { + if (typeof operation.from !== "string") return { ...operation, path }; + return { + ...operation, + path, + from: canonicalPointer(operation.from) ?? operation.from, + }; + } + return { ...operation, path }; + }); +} diff --git a/labs/extensions/patch-rebase/src/pointer.ts b/labs/extensions/patch-rebase/src/pointer.ts new file mode 100644 index 00000000..329b58b6 --- /dev/null +++ b/labs/extensions/patch-rebase/src/pointer.ts @@ -0,0 +1,63 @@ +import { + buildPointer, + parsePointer, + tryParsePointer, + type Pointer, +} from "@interactive-os/json-document"; + +export type PointerRelation = "same" | "ancestor" | "descendant" | "disjoint"; + +export function canonicalPointer(pointer: Pointer): Pointer | null { + const segments = tryParsePointer(pointer); + return segments === null ? null : buildPointer(segments); +} + +export function pointerRelation( + left: Pointer, + right: Pointer, +): PointerRelation { + const leftSegments = parsePointer(left); + const rightSegments = parsePointer(right); + const shared = Math.min(leftSegments.length, rightSegments.length); + for (let index = 0; index < shared; index += 1) { + if (leftSegments[index] !== rightSegments[index]) return "disjoint"; + } + if (leftSegments.length === rightSegments.length) return "same"; + return leftSegments.length < rightSegments.length ? "ancestor" : "descendant"; +} + +export function readPointerValue( + state: unknown, + pointer: Pointer, +): { ok: true; value: unknown } | { ok: false } { + let current = state; + for (const segment of parsePointer(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 }; +} + +export function isArrayAtEither( + before: unknown, + after: unknown, + pointer: Pointer, +): boolean { + const beforeValue = readPointerValue(before, pointer); + if (beforeValue.ok && Array.isArray(beforeValue.value)) return true; + const afterValue = readPointerValue(after, pointer); + return afterValue.ok && Array.isArray(afterValue.value); +} diff --git a/labs/extensions/patch-rebase/src/transform.ts b/labs/extensions/patch-rebase/src/transform.ts new file mode 100644 index 00000000..0a7aee90 --- /dev/null +++ b/labs/extensions/patch-rebase/src/transform.ts @@ -0,0 +1,211 @@ +import { + parentPointer, + trackPointer, + type JSONPatchOperation, + type Pointer, +} from "@interactive-os/json-document"; + +import { + isArrayAtEither, + pointerRelation, +} from "./pointer.js"; +import type { + RebaseConflict, + RebaseDiagnostic, +} from "./types.js"; + +export interface ConcurrentStep { + readonly operation: JSONPatchOperation; + readonly batchIndex: number; + readonly operationIndex: number; + readonly before: unknown; + readonly after: unknown; +} + +export type TransformOperationsResult = + | { + ok: true; + operations: JSONPatchOperation[]; + diagnostics: RebaseDiagnostic[]; + } + | { + ok: false; + reason: string; + conflict: RebaseConflict; + }; + +export function transformLocalOperations( + operations: ReadonlyArray, + concurrentSteps: ReadonlyArray, +): TransformOperationsResult { + const transformed: JSONPatchOperation[] = []; + const diagnostics: RebaseDiagnostic[] = []; + + for (let operationIndex = 0; operationIndex < operations.length; operationIndex += 1) { + const operation = operations[operationIndex]!; + if ( + concurrentSteps.length > 0 + && (operation.op !== "replace" && operation.op !== "test" || operation.path === "") + ) { + const reason = `cannot conservatively rebase local ${operation.op} operation`; + return { + ok: false, + reason, + conflict: { + code: "unsupported_operation", + reason, + pointer: operation.path, + operationIndex, + }, + }; + } + + let path = operation.path; + for (const step of concurrentSteps) { + const overlap = overlappingConcurrentChange(path, step); + if (overlap !== null) { + const reason = `local target overlaps a concurrent change: ${path}`; + return { + ok: false, + reason, + conflict: { + code: overlap, + reason, + pointer: path, + operationIndex, + concurrentBatchIndex: step.batchIndex, + concurrentOperationIndex: step.operationIndex, + }, + }; + } + + const next = trackPointerForConcurrentStep(path, step); + if (next === null) { + const reason = `local target was removed by a concurrent change: ${path}`; + return { + ok: false, + reason, + conflict: { + code: "target_removed", + reason, + pointer: path, + operationIndex, + concurrentBatchIndex: step.batchIndex, + concurrentOperationIndex: step.operationIndex, + }, + }; + } + path = next; + } + + if (path !== operation.path) { + diagnostics.push({ + code: "pointer_shifted", + reason: "local pointer shifted by concurrent array edits", + pointer: operation.path, + rebasedPointer: path, + operationIndex, + }); + } + transformed.push({ ...operation, path }); + } + + return { ok: true, operations: transformed, diagnostics }; +} + +export function unsupportedConcurrentChange( + concurrentSteps: ReadonlyArray, +): Extract | null { + for (const step of concurrentSteps) { + if ( + step.operation.op !== "move" + && step.operation.op !== "copy" + && (step.operation.op === "test" || step.operation.path !== "") + ) { + continue; + } + const reason = `cannot conservatively rebase across concurrent ${step.operation.op} operation`; + return { + ok: false, + reason, + conflict: { + code: "unsupported_operation", + reason, + pointer: step.operation.path, + concurrentBatchIndex: step.batchIndex, + concurrentOperationIndex: step.operationIndex, + }, + }; + } + return null; +} + +export function transformSelection( + selectionAfter: Pointer, + concurrentSteps: ReadonlyArray, +): { + selectionAfter: Pointer | undefined; + diagnostics: RebaseDiagnostic[]; +} { + let pointer: Pointer | null = selectionAfter; + for (const step of concurrentSteps) { + if (pointer === null) break; + pointer = trackPointerForConcurrentStep(pointer, step); + } + return pointer === null + ? { + selectionAfter: undefined, + diagnostics: [{ + code: "selection_dropped", + reason: "selection target was removed by concurrent edits", + pointer: selectionAfter, + }], + } + : { selectionAfter: pointer, diagnostics: [] }; +} + +function overlappingConcurrentChange( + localPointer: Pointer, + step: ConcurrentStep, +): "target_changed" | "target_removed" | "ancestor_replaced" | null { + const operation = step.operation; + if (operation.op === "test") return null; + + const relation = pointerRelation(localPointer, operation.path); + if (relation === "disjoint") return null; + + if (operation.op === "replace") { + return relation === "descendant" ? "ancestor_replaced" : "target_changed"; + } + + if (operation.op === "add" || operation.op === "remove") { + const parent = parentPointer(operation.path); + const arrayEdit = parent !== null && isArrayAtEither(step.before, step.after, parent); + if (arrayEdit) { + return relation === "ancestor" ? "target_changed" : null; + } + if (operation.op === "remove" && (relation === "same" || relation === "descendant")) { + return "target_removed"; + } + return "target_changed"; + } + + return "target_changed"; +} + +function trackPointerForConcurrentStep( + pointer: Pointer, + step: ConcurrentStep, +): Pointer | null { + const operation = step.operation; + if (operation.op === "test") return pointer; + if (operation.op === "add" || operation.op === "remove") { + const parent = parentPointer(operation.path); + if (parent === null || !isArrayAtEither(step.before, step.after, parent)) { + if (operation.op === "add") return pointer; + const relation = pointerRelation(pointer, operation.path); + return relation === "same" || relation === "descendant" ? null : pointer; + } + } + return trackPointer(pointer, [operation]); +} diff --git a/labs/extensions/patch-rebase/src/types.ts b/labs/extensions/patch-rebase/src/types.ts new file mode 100644 index 00000000..b443c7a9 --- /dev/null +++ b/labs/extensions/patch-rebase/src/types.ts @@ -0,0 +1,81 @@ +import type { + JSONPatchOperation, + JSONResult, + Pointer, +} from "@interactive-os/json-document"; + +export interface RebaseSchema { + /** + * The Zod-compatible schema used by the owning JSON document. + * + * This lab accepts only the `safeParse` surface so it does not acquire a + * second runtime Zod dependency. Failed parses must expose an `issues` + * collection, matching the contract consumed by core `applyPatch`. + */ + safeParse(input: unknown): + | { success: true; data: TDocument } + | { success: false; error: { issues: ReadonlyArray } }; +} + +export interface RebaseChangeInput { + readonly base: TDocument; + readonly concurrentBatches: ReadonlyArray>; + readonly operations: ReadonlyArray; + readonly selectionAfter?: Pointer; +} + +export type RebaseDiagnosticCode = + | "pointer_shifted" + | "selection_dropped"; + +export interface RebaseDiagnostic { + readonly code: RebaseDiagnosticCode; + readonly reason: string; + readonly pointer?: Pointer; + readonly rebasedPointer?: Pointer; + readonly operationIndex?: number; +} + +export type RebaseConflictCode = + | "target_changed" + | "target_removed" + | "ancestor_replaced" + | "schema_conflict" + | "invalid_selection" + | "unsupported_operation"; + +export interface RebaseConflict { + readonly code: RebaseConflictCode; + readonly reason: string; + readonly pointer?: Pointer; + readonly operationIndex?: number; + readonly concurrentBatchIndex?: number; + readonly concurrentOperationIndex?: number; +} + +export type RebaseChangeResult = + | { + readonly ok: true; + readonly operations: ReadonlyArray; + readonly selectionAfter?: Pointer; + readonly diagnostics: ReadonlyArray; + } + | { + readonly ok: false; + readonly code: "concurrent_patch_failed"; + readonly reason: string; + readonly batchIndex: number; + readonly result: Extract; + } + | { + readonly ok: false; + readonly code: "change_patch_failed"; + readonly reason: string; + readonly result: Extract; + } + | { + readonly ok: false; + readonly code: "conflict"; + readonly reason: string; + readonly conflicts: ReadonlyArray; + }; diff --git a/labs/extensions/patch-rebase/tests/patch-rebase.test.ts b/labs/extensions/patch-rebase/tests/patch-rebase.test.ts new file mode 100644 index 00000000..e3ac458c --- /dev/null +++ b/labs/extensions/patch-rebase/tests/patch-rebase.test.ts @@ -0,0 +1,647 @@ +import { describe, expect, test, vi } from "vitest"; +import * as z from "zod"; + +import { + createJSONDocument, + type JSONPatchOperation, +} from "@interactive-os/json-document"; +import { + rebaseChange, +} from "../src/index.js"; + +const Schema = z.object({ + title: z.string(), + status: z.enum(["draft", "review"]), +}); + +const CollectionSchema = z.object({ + items: z.array(z.object({ + id: z.string(), + title: z.string(), + })), +}); + +const OptionalPanelSchema = z.object({ + title: z.string(), + panel: z.object({ focus: z.string() }).optional(), +}); + +const NumericKeySchema = z.object({ + records: z.record(z.string(), z.object({ title: z.string() })), +}); + +const ExclusiveFlagsSchema = z.object({ + local: z.boolean(), + remote: z.boolean(), +}).refine((value) => !(value.local && value.remote)); + +const SlotSchema = z.object({ title: z.string() }); +const MixedGroupsSchema = z.object({ + groups: z.array(z.object({ + slots: z.union([ + z.array(SlotSchema), + z.record(z.string(), SlotSchema), + ]), + })), +}); + +const ReplaceAncestorSchema = z.object({ + a: z.object({ x: z.number() }).or(z.object({ y: z.number() })), + b: z.number(), +}); + +const NullableAncestorSchema = z.object({ + a: z.object({ x: z.number() }).nullable(), + b: z.number(), +}); + +describe("@interactive-os/json-document-patch-rebase", () => { + test("preserves disjoint concurrent and local changes", () => { + const base = { + title: "Draft", + status: "draft" as const, + }; + const concurrent: JSONPatchOperation[] = [ + { op: "replace", path: "/status", value: "review" }, + ]; + const local: JSONPatchOperation[] = [ + { op: "replace", path: "/title", value: "Reviewed" }, + ]; + + const planned = rebaseChange(Schema, { + base, + concurrentBatches: [concurrent], + operations: local, + }); + + expect(planned).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/title", value: "Draft" }, + { op: "replace", path: "/title", value: "Reviewed" }, + ], + diagnostics: [], + }); + + if (!planned.ok) throw new Error(planned.reason); + const doc = createJSONDocument(Schema, base, { history: 10 }); + expect(doc.patch(concurrent)).toEqual({ ok: true }); + expect(doc.commit(planned.operations)).toEqual({ ok: true }); + expect(doc.value).toEqual({ + title: "Reviewed", + status: "review", + }); + }); + + test("shifts a local target and selection after a concurrent array insertion", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }; + const concurrent: JSONPatchOperation[] = [{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }]; + + const planned = rebaseChange(CollectionSchema, { + base, + concurrentBatches: [concurrent], + operations: [{ op: "replace", path: "/items/1/title", value: "B2" }], + selectionAfter: "/items/1/title", + }); + + expect(planned).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/items/2/title", value: "B" }, + { op: "replace", path: "/items/2/title", value: "B2" }, + ], + selectionAfter: "/items/2/title", + diagnostics: [{ + code: "pointer_shifted", + reason: "local pointer shifted by concurrent array edits", + pointer: "/items/1/title", + rebasedPointer: "/items/2/title", + operationIndex: 0, + }], + }); + + if (!planned.ok || planned.selectionAfter === undefined) { + throw new Error("expected a rebased selection"); + } + const doc = createJSONDocument(CollectionSchema, base, { + history: 10, + selection: { mode: "single", initial: ["/items/1/title"] }, + }); + expect(doc.patch(concurrent)).toEqual({ ok: true }); + expect(doc.commit(planned.operations, { + selectionAfter: planned.selectionAfter, + })).toEqual({ ok: true }); + expect(doc.value.items).toEqual([ + { id: "x", title: "X" }, + { id: "a", title: "A" }, + { id: "b", title: "B2" }, + ]); + expect(doc.selection?.primaryPointer).toBe("/items/2/title"); + }); + + test("shifts a local target and selection after an earlier array removal", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + { id: "c", title: "C" }, + ], + }; + + expect(rebaseChange(CollectionSchema, { + base, + concurrentBatches: [[ + { op: "remove", path: "/items/0" }, + ]], + operations: [ + { op: "replace", path: "/items/2/title", value: "C2" }, + ], + selectionAfter: "/items/2/title", + })).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/items/1/title", value: "C" }, + { op: "replace", path: "/items/1/title", value: "C2" }, + ], + selectionAfter: "/items/1/title", + diagnostics: [{ + code: "pointer_shifted", + reason: "local pointer shifted by concurrent array edits", + pointer: "/items/2/title", + rebasedPointer: "/items/1/title", + operationIndex: 0, + }], + }); + }); + + test("normalizes encoded URI-fragment pointers before array insertion transforms", () => { + const planned = rebaseChange(CollectionSchema, { + base: { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }, + concurrentBatches: [[{ + op: "add", + path: "#/items%2F0", + value: { id: "x", title: "X" }, + }]], + operations: [{ + op: "replace", + path: "#/items%2F1%2Ftitle", + value: "B2", + }], + selectionAfter: "#/items%2F1%2Ftitle", + }); + + expect(planned).toMatchObject({ + ok: true, + operations: [ + { op: "test", path: "/items/2/title", value: "B" }, + { op: "replace", path: "/items/2/title", value: "B2" }, + ], + selectionAfter: "/items/2/title", + }); + }); + + test("normalizes encoded URI-fragment pointers before array removal transforms", () => { + const planned = rebaseChange(CollectionSchema, { + base: { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + { id: "c", title: "C" }, + ], + }, + concurrentBatches: [[{ + op: "remove", + path: "#/items%2F0", + }]], + operations: [{ + op: "replace", + path: "#/items%2F2%2Ftitle", + value: "C2", + }], + selectionAfter: "#/items%2F2%2Ftitle", + }); + + expect(planned).toMatchObject({ + ok: true, + operations: [ + { op: "test", path: "/items/1/title", value: "C" }, + { op: "replace", path: "/items/1/title", value: "C2" }, + ], + selectionAfter: "/items/1/title", + }); + }); + + test("reports a conflict instead of overwriting a concurrent target change", () => { + const base = { + title: "Draft", + status: "draft" as const, + }; + + expect(rebaseChange(Schema, { + base, + concurrentBatches: [[ + { op: "replace", path: "/title", value: "Remote" }, + ]], + operations: [ + { op: "replace", path: "/title", value: "Local" }, + ], + })).toEqual({ + ok: false, + code: "conflict", + reason: "local target overlaps a concurrent change: /title", + conflicts: [{ + code: "target_changed", + reason: "local target overlaps a concurrent change: /title", + pointer: "/title", + operationIndex: 0, + concurrentBatchIndex: 0, + concurrentOperationIndex: 0, + }], + }); + }); + + test("guards the gap between planning and commit without moving selection", () => { + const base = { + title: "Draft", + status: "draft" as const, + }; + const planned = rebaseChange(Schema, { + base, + concurrentBatches: [], + operations: [ + { op: "replace", path: "/title", value: "Local" }, + ], + selectionAfter: "/title", + }); + if (!planned.ok || planned.selectionAfter === undefined) { + throw new Error("expected a guarded rebase plan"); + } + const doc = createJSONDocument(Schema, base, { + history: 10, + selection: { mode: "single", initial: ["/status"] }, + }); + expect(doc.replace("/title", "Remote")).toMatchObject({ ok: true }); + const listener = vi.fn(); + doc.subscribe(listener); + + expect(doc.commit(planned.operations, { + selectionAfter: planned.selectionAfter, + })).toMatchObject({ + ok: false, + code: "test_failed", + pointer: "/title", + }); + expect(doc.value.title).toBe("Remote"); + expect(doc.selection?.primaryPointer).toBe("/status"); + expect(doc.history.undoDepth).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); + + test("drops a selection target removed by a disjoint concurrent change", () => { + const planned = rebaseChange(OptionalPanelSchema, { + base: { + title: "Draft", + panel: { focus: "details" }, + }, + concurrentBatches: [[ + { op: "remove", path: "/panel" }, + ]], + operations: [ + { op: "replace", path: "/title", value: "Local" }, + ], + selectionAfter: "/panel/focus", + }); + + expect(planned).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/title", value: "Draft" }, + { op: "replace", path: "/title", value: "Local" }, + ], + diagnostics: [{ + code: "selection_dropped", + reason: "selection target was removed by concurrent edits", + pointer: "/panel/focus", + }], + }); + }); + + test("does not shift numeric object keys as if they were array indexes", () => { + expect(rebaseChange(NumericKeySchema, { + base: { + records: { + "0": { title: "A" }, + "1": { title: "B" }, + }, + }, + concurrentBatches: [[{ + op: "add", + path: "/records/0", + value: { title: "Remote" }, + }]], + operations: [{ + op: "replace", + path: "/records/1/title", + value: "Local", + }], + })).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/records/1/title", value: "B" }, + { op: "replace", path: "/records/1/title", value: "Local" }, + ], + diagnostics: [], + }); + }); + + test("guards an independent selection target during the planning gap", () => { + const base = { + title: "Draft", + panel: { focus: "details" }, + }; + const planned = rebaseChange(OptionalPanelSchema, { + base, + concurrentBatches: [], + operations: [ + { op: "replace", path: "/title", value: "Local" }, + ], + selectionAfter: "/panel/focus", + }); + + expect(planned).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/title", value: "Draft" }, + { op: "test", path: "/panel/focus", value: "details" }, + { op: "replace", path: "/title", value: "Local" }, + ], + selectionAfter: "/panel/focus", + diagnostics: [], + }); + + if (!planned.ok || planned.selectionAfter === undefined) { + throw new Error("expected a guarded selection"); + } + const doc = createJSONDocument(OptionalPanelSchema, base, { + history: 10, + selection: { mode: "single", initial: ["/title"] }, + }); + expect(doc.delete("/panel")).toMatchObject({ ok: true }); + + expect(doc.commit(planned.operations, { + selectionAfter: planned.selectionAfter, + })).toMatchObject({ + ok: false, + code: "path_not_found", + pointer: "/panel/focus", + }); + expect(doc.value.title).toBe("Draft"); + expect(doc.selection?.primaryPointer).toBe("/title"); + expect(doc.history.undoDepth).toBe(1); + }); + + test("reports a schema conflict created by otherwise disjoint changes", () => { + expect(rebaseChange(ExclusiveFlagsSchema, { + base: { local: false, remote: false }, + concurrentBatches: [[ + { op: "replace", path: "/remote", value: true }, + ]], + operations: [ + { op: "replace", path: "/local", value: true }, + ], + })).toMatchObject({ + ok: false, + code: "conflict", + conflicts: [{ code: "schema_conflict" }], + }); + }); + + test("uses each concurrent operation's actual container kind", () => { + const base = { + groups: [{ + slots: [ + { title: "A0" }, + { title: "A1" }, + ], + }, { + slots: { + "0": { title: "B0" }, + "1": { title: "B1" }, + "2": { title: "B2" }, + }, + }], + }; + const concurrent: JSONPatchOperation[] = [ + { op: "remove", path: "/groups/0" }, + { + op: "add", + path: "/groups/0/slots/0", + value: { title: "Remote" }, + }, + ]; + const planned = rebaseChange(MixedGroupsSchema, { + base, + concurrentBatches: [concurrent], + operations: [{ + op: "replace", + path: "/groups/1/slots/1/title", + value: "Local", + }], + }); + + expect(planned).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/groups/0/slots/1/title", value: "B1" }, + { op: "replace", path: "/groups/0/slots/1/title", value: "Local" }, + ], + diagnostics: [{ + code: "pointer_shifted", + reason: "local pointer shifted by concurrent array edits", + pointer: "/groups/1/slots/1/title", + rebasedPointer: "/groups/0/slots/1/title", + operationIndex: 0, + }], + }); + + if (!planned.ok) throw new Error(planned.reason); + const doc = createJSONDocument(MixedGroupsSchema, base); + expect(doc.patch(concurrent)).toEqual({ ok: true }); + expect(doc.commit(planned.operations)).toEqual({ ok: true }); + expect(doc.value.groups[0]?.slots).toEqual({ + "0": { title: "Remote" }, + "1": { title: "Local" }, + "2": { title: "B2" }, + }); + }); + + test("rejects unsupported concurrent operations for selection-only plans", () => { + expect(rebaseChange(NumericKeySchema, { + base: { + records: { + "0": { title: "A" }, + "1": { title: "B" }, + "2": { title: "C" }, + }, + }, + concurrentBatches: [[{ + op: "copy", + from: "/records/0", + path: "/records/0", + }]], + operations: [], + selectionAfter: "/records/1/title", + })).toEqual({ + ok: false, + code: "conflict", + reason: "cannot conservatively rebase across concurrent copy operation", + conflicts: [{ + code: "unsupported_operation", + reason: "cannot conservatively rebase across concurrent copy operation", + pointer: "/records/0", + concurrentBatchIndex: 0, + concurrentOperationIndex: 0, + }], + }); + }); + + test("treats the URI-fragment root as an unsupported root mutation", () => { + const base = { + title: "Draft", + status: "draft" as const, + }; + + expect(rebaseChange(Schema, { + base, + concurrentBatches: [[ + { op: "replace", path: "/status", value: "review" }, + ]], + operations: [{ + op: "replace", + path: "#", + value: { title: "Local", status: "draft" }, + }], + })).toMatchObject({ + ok: false, + code: "conflict", + conflicts: [{ + code: "unsupported_operation", + pointer: "", + operationIndex: 0, + }], + }); + + expect(rebaseChange(Schema, { + base, + concurrentBatches: [[{ + op: "replace", + path: "#", + value: { title: "Remote", status: "draft" }, + }]], + operations: [], + selectionAfter: "#/title", + })).toMatchObject({ + ok: false, + code: "conflict", + conflicts: [{ + code: "unsupported_operation", + pointer: "", + concurrentBatchIndex: 0, + concurrentOperationIndex: 0, + }], + }); + }); + + test("drops a selection removed by the rebased local change", () => { + expect(rebaseChange(ReplaceAncestorSchema, { + base: { a: { x: 1 }, b: 0 }, + concurrentBatches: [], + operations: [ + { op: "replace", path: "/a", value: { y: 2 } }, + ], + selectionAfter: "/a/x", + })).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/a", value: { x: 1 } }, + { op: "replace", path: "/a", value: { y: 2 } }, + ], + diagnostics: [{ + code: "selection_dropped", + reason: "selection target does not exist after the rebased change", + pointer: "/a/x", + }], + }); + }); + + test("returns a structured conflict for an invalid selection pointer", () => { + expect(rebaseChange(Schema, { + base: { title: "Draft", status: "draft" }, + concurrentBatches: [], + operations: [ + { op: "replace", path: "/title", value: "Local" }, + ], + selectionAfter: "title", + })).toEqual({ + ok: false, + code: "conflict", + reason: "invalid selection pointer: title", + conflicts: [{ + code: "invalid_selection", + reason: "invalid selection pointer: title", + pointer: "title", + }], + }); + }); + + test("uses an ancestor guard for a sequentially created local target", () => { + expect(rebaseChange(NullableAncestorSchema, { + base: { a: null, b: 0 }, + concurrentBatches: [[ + { op: "replace", path: "/b", value: 1 }, + ]], + operations: [ + { op: "replace", path: "/a", value: { x: 1 } }, + { op: "replace", path: "/a/x", value: 2 }, + ], + })).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/a", value: null }, + { op: "replace", path: "/a", value: { x: 1 } }, + { op: "replace", path: "/a/x", value: 2 }, + ], + diagnostics: [], + }); + }); + + test("reports an invalid local change before replaying concurrent batches", () => { + expect(rebaseChange(Schema, { + base: { title: "Draft", status: "draft" }, + concurrentBatches: [[ + { op: "replace", path: "/status", value: "invalid" }, + ]], + operations: [ + { op: "replace", path: "/title", value: 42 }, + ], + })).toMatchObject({ + ok: false, + code: "change_patch_failed", + result: { code: "schema_violation" }, + }); + }); +}); diff --git a/labs/extensions/patch-rebase/tsconfig.json b/labs/extensions/patch-rebase/tsconfig.json new file mode 100644 index 00000000..615be15f --- /dev/null +++ b/labs/extensions/patch-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/patch-rebase/tsconfig.test.json b/labs/extensions/patch-rebase/tsconfig.test.json new file mode 100644 index 00000000..49ef16fc --- /dev/null +++ b/labs/extensions/patch-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/patch-rebase/vitest.config.ts b/labs/extensions/patch-rebase/vitest.config.ts new file mode 100644 index 00000000..b470175d --- /dev/null +++ b/labs/extensions/patch-rebase/vitest.config.ts @@ -0,0 +1,12 @@ +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, + }, + }, + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index fc8293b0..41f10a30 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -98,6 +98,10 @@ const extensionGuidance = { useFor: "produce and apply patch changes toward a target document", notFor: "visual diff UI or merge conflict resolution", }, + "@interactive-os/json-document-patch-rebase": { + 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-drag-drop": { useFor: "turn drag/drop intent into move or paste operations", notFor: "DOM drag/drop events, hit testing, or hover UI",