import { DIFF_CODES, DiffError } from "../errors/diff-error.js"; import { formatPath, type Path } from "../path/path.js"; import { deepClone, deepEqual, deleteValueAtPathMut, getValueAtPath, insertValueAtPathMut, type JsonValue, setValueAtPathMut, } from "../utils/utils.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type AddOperation = { op: "add"; path: Path; value: JsonValue }; export type RemoveOperation = { op: "remove"; path: Path }; export type ReplaceOperation = { op: "replace"; path: Path; value: JsonValue }; export type MoveOperation = { op: "move"; from: Path; path: Path }; export type CopyOperation = { op: "copy"; from: Path; path: Path }; export type TestOperation = { op: "test"; path: Path; value: JsonValue }; export type Operation = | AddOperation | RemoveOperation | ReplaceOperation | MoveOperation | CopyOperation | TestOperation; export type Patch = Operation[]; // --------------------------------------------------------------------------- // Factories // --------------------------------------------------------------------------- /** Create an RFC 6902 `add` operation. */ export function createAdd(path: Path, value: JsonValue): AddOperation { return { op: "add", path, value }; } /** Create an RFC 6902 `remove` operation. */ export function createRemove(path: Path): RemoveOperation { return { op: "remove", path }; } /** Create an RFC 6902 `replace` operation. */ export function createReplace(path: Path, value: JsonValue): ReplaceOperation { return { op: "replace", path, value }; } /** Create an RFC 6902 `move` operation. */ export function createMove(from: Path, path: Path): MoveOperation { return { op: "move", from, path }; } /** Create an RFC 6902 `copy` operation. */ export function createCopy(from: Path, path: Path): CopyOperation { return { op: "copy", from, path }; } /** Create an RFC 6902 `test` operation. */ export function createTest(path: Path, value: JsonValue): TestOperation { return { op: "test", path, value }; } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- const VALID_OPS = new Set(["add", "remove", "replace", "move", "copy", "test"]); /** * Validate that `op` is a well-formed RFC 6902 operation object. * @throws {DiffError} DIFF.INVALID_PATCH on any structural violation. */ export function validateOperation(op: unknown): Operation { if (op === null || typeof op !== "object" || Array.isArray(op)) { throw new DiffError( "Operation must be a non-null object", DIFF_CODES.INVALID_PATCH, { reason: "expected object", } ); } const obj = op as Record; const opName = obj["op"]; if (typeof opName !== "string" || !VALID_OPS.has(opName)) { throw new DiffError( `Invalid op: "${String(opName)}"`, DIFF_CODES.INVALID_PATCH, { operation: String(opName), reason: "op must be one of: add, remove, replace, move, copy, test", } ); } if (!Array.isArray(obj["path"])) { throw new DiffError( "Operation must have a path array", DIFF_CODES.INVALID_PATCH, { operation: opName, reason: "path must be an array", } ); } if (opName === "move" || opName === "copy") { if (!Array.isArray(obj["from"])) { throw new DiffError( `${opName} operation must have a from array`, DIFF_CODES.INVALID_PATCH, { operation: opName, reason: "from must be an array", } ); } } if (opName === "add" || opName === "replace" || opName === "test") { if (!("value" in obj)) { throw new DiffError( `${opName} operation must have a value`, DIFF_CODES.INVALID_PATCH, { operation: opName, reason: "value is required", } ); } } return op as Operation; } /** * Validate that `patch` is an array of well-formed operations. * @throws {DiffError} DIFF.INVALID_PATCH on any structural violation. */ export function validatePatch(patch: unknown): Patch { if (!Array.isArray(patch)) { throw new DiffError( "Patch must be an array", DIFF_CODES.INVALID_PATCH, { reason: "expected array of operations", } ); } return patch.map((op, i) => { try { return validateOperation(op); } catch (e) { const msg = (e as Error).message; throw new DiffError( `Invalid operation at index ${i}: ${msg}`, DIFF_CODES.INVALID_PATCH, { reason: `index ${i}: ${msg}`, } ); } }); } // --------------------------------------------------------------------------- // Internal minimal operation applier (no circular dependency with patch.ts) // --------------------------------------------------------------------------- function applyOpToDoc(doc: JsonValue, op: Operation): JsonValue { const d = deepClone(doc); switch (op.op) { case "test": { const actual = getValueAtPath(d, op.path); if (!deepEqual(actual, op.value)) { throw new DiffError( `Test failed at ${formatPath(op.path)}`, DIFF_CODES.TEST_FAILED, { path: op.path, operation: "test" } ); } return d; } case "add": insertValueAtPathMut(d, op.path, op.value); return d; case "remove": deleteValueAtPathMut(d, op.path); return d; case "replace": setValueAtPathMut(d, op.path, op.value); return d; case "move": { const val = getValueAtPath(d, op.from); if (val === undefined) { throw new DiffError( `move: no value at ${formatPath(op.from)}`, DIFF_CODES.APPLY_FAILED, { path: op.from, operation: "move" } ); } deleteValueAtPathMut(d, op.from); insertValueAtPathMut(d, op.path, val); return d; } case "copy": { const val = getValueAtPath(d, op.from); if (val === undefined) { throw new DiffError( `copy: no value at ${formatPath(op.from)}`, DIFF_CODES.APPLY_FAILED, { path: op.from, operation: "copy" } ); } insertValueAtPathMut(d, op.path, deepClone(val)); return d; } } } // --------------------------------------------------------------------------- // Inversion // --------------------------------------------------------------------------- /** * Compute the inverse of a single operation given the doc state *before* * the operation was applied. * * - `add` → `remove` at same path * - `remove` → `add` the original value back * - `replace` → `replace` with the original value * - `move` → `move` back (`from` ↔ `path` swapped) * - `copy` → `remove` at target path * - `test` → identity (`test` is its own inverse) * * @throws {DiffError} DIFF.INVERT_FAILED if the doc does not contain * the value needed to produce the inverse. */ export function invertOperation(doc: JsonValue, op: Operation): Operation { switch (op.op) { case "add": return createRemove(op.path); case "remove": { const val = getValueAtPath(doc, op.path); if (val === undefined) { throw new DiffError( `Cannot invert remove: no value at ${formatPath(op.path)}`, DIFF_CODES.INVERT_FAILED, { path: op.path, operation: "remove" } ); } return createAdd(op.path, val); } case "replace": { const val = getValueAtPath(doc, op.path); if (val === undefined) { throw new DiffError( `Cannot invert replace: no value at ${formatPath(op.path)}`, DIFF_CODES.INVERT_FAILED, { path: op.path, operation: "replace" } ); } return createReplace(op.path, val); } case "move": return createMove(op.path, op.from); case "copy": return createRemove(op.path); case "test": return createTest(op.path, op.value); } } /** * Compute the inverse patch for `patch` given the doc state *before* * the patch was applied. Returns operations in reverse order so the inverse * patch can be applied directly to undo the original. */ export function invertPatch(doc: JsonValue, patch: Patch): Patch { const inverses: Operation[] = []; let current = doc; for (const op of patch) { inverses.push(invertOperation(current, op)); try { current = applyOpToDoc(current, op); } catch { // Stop collecting if we cannot advance the state break; } } return inverses.reverse(); } // --------------------------------------------------------------------------- // Conflict detection // --------------------------------------------------------------------------- /** * Returns `true` if operations `a` and `b` conflict — i.e., they touch the * same path or one path is a prefix of the other. */ export function operationsConflict(a: Operation, b: Operation): boolean { const pathsA = getOperationPaths(a); const pathsB = getOperationPaths(b); for (const pa of pathsA) { for (const pb of pathsB) { if (pathsOverlap(pa, pb)) return true; } } return false; } function getOperationPaths(op: Operation): Path[] { switch (op.op) { case "move": case "copy": return [op.from, op.path]; default: return [op.path]; } } function pathsOverlap(a: Path, b: Path): boolean { const len = Math.min(a.length, b.length); for (let i = 0; i < len; i++) { if (a[i] !== b[i]) return false; } return true; }