Files
suaveplan/packages/foundation/data/diff/src/operations/operations.ts
2026-05-27 23:47:53 +01:00

336 lines
11 KiB
TypeScript

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<string, unknown>;
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;
}