336 lines
11 KiB
TypeScript
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;
|
|
}
|