Initial Commit
This commit is contained in:
335
packages/foundation/data/diff/src/operations/operations.ts
Normal file
335
packages/foundation/data/diff/src/operations/operations.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user