Refactor workspace memo removal and MCP tool helpers
This commit is contained in:
1 parent
633aa16784
commit
d963ff76e2
7 files changed
+1084
-858
No files matched your search
@@ -0,0 +1,431 @@
|
||||
import {
|
||||
ARCHITECTURE_RESOURCE_ICONS,
|
||||
docToText,
|
||||
markdownToDoc,
|
||||
parseDiagramDocument,
|
||||
serializeDiagramDocument,
|
||||
stripDiagramDocumentMarker,
|
||||
type DiagramDocument,
|
||||
type DiagramNodeShape,
|
||||
type MemoDetail,
|
||||
} from "@edgeever/shared";
|
||||
import type { DiagramIr, DiagramIrNodeType } from "@edgeever/shared/diagram-layout";
|
||||
import { AppError } from "./app-error";
|
||||
import { createId } from "./entity-utils";
|
||||
import { getRequiredString } from "./mcp-json-rpc";
|
||||
|
||||
const DIAGRAM_IR_NODE_TYPES = new Set<DiagramIrNodeType>([
|
||||
"topic", "process", "decision", "start", "end", "terminator", "client", "frontend", "service", "database", "storage",
|
||||
"queue", "security", "external", "boundary",
|
||||
]);
|
||||
const DIAGRAM_EDGE_KINDS = new Set(["dependency", "request", "async", "data"]);
|
||||
const DIAGRAM_MEMO_KEYS = new Set(["notebookId", "title", "kind", "theme", "structure", "layout", "tags", "nodes", "edges"]);
|
||||
const DIAGRAM_NODE_KEYS = new Set(["id", "label", "type", "parentId", "resourceIcon"]);
|
||||
const DIAGRAM_EDGE_KEYS = new Set(["source", "target", "label", "type", "bidirectional"]);
|
||||
|
||||
const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const assertAllowedKeys = (value: Record<string, unknown>, allowed: Set<string>, path: string) => {
|
||||
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
||||
if (unexpected) throw new AppError("invalid_params", `${path}.${unexpected} is not supported`, 400);
|
||||
};
|
||||
|
||||
const parseDiagramNode = (value: unknown, index: number) => {
|
||||
const path = `nodes.${index}`;
|
||||
if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400);
|
||||
assertAllowedKeys(value, DIAGRAM_NODE_KEYS, path);
|
||||
const id = getRequiredString(value.id, `${path}.id`);
|
||||
if (id.length > 100) throw new AppError("invalid_params", `${path}.id must be at most 100 characters`, 400);
|
||||
if (typeof value.label !== "string" || value.label.length > 500) {
|
||||
throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400);
|
||||
}
|
||||
if (value.type !== undefined && (
|
||||
typeof value.type !== "string" || !DIAGRAM_IR_NODE_TYPES.has(value.type as DiagramIrNodeType)
|
||||
)) {
|
||||
throw new AppError("invalid_params", `${path}.type is not supported`, 400);
|
||||
}
|
||||
if (value.parentId !== undefined && (typeof value.parentId !== "string" || !value.parentId.trim() || value.parentId.length > 100)) {
|
||||
throw new AppError("invalid_params", `${path}.parentId must be a non-empty string with at most 100 characters`, 400);
|
||||
}
|
||||
if (value.resourceIcon !== undefined && (
|
||||
typeof value.resourceIcon !== "string"
|
||||
|| !ARCHITECTURE_RESOURCE_ICONS.includes(value.resourceIcon as typeof ARCHITECTURE_RESOURCE_ICONS[number])
|
||||
)) {
|
||||
throw new AppError("invalid_params", `${path}.resourceIcon is not supported`, 400);
|
||||
}
|
||||
return { ...value, id } as DiagramIr["nodes"][number];
|
||||
};
|
||||
|
||||
const parseDiagramEdge = (value: unknown, index: number) => {
|
||||
const path = `edges.${index}`;
|
||||
if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400);
|
||||
assertAllowedKeys(value, DIAGRAM_EDGE_KEYS, path);
|
||||
const source = getRequiredString(value.source, `${path}.source`);
|
||||
const target = getRequiredString(value.target, `${path}.target`);
|
||||
if ([source, target].some((item) => item.length > 100)) {
|
||||
throw new AppError("invalid_params", `${path} node IDs must be at most 100 characters`, 400);
|
||||
}
|
||||
if (value.label !== undefined && (typeof value.label !== "string" || value.label.length > 500)) {
|
||||
throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400);
|
||||
}
|
||||
if (value.type !== undefined && (typeof value.type !== "string" || !DIAGRAM_EDGE_KINDS.has(value.type))) {
|
||||
throw new AppError("invalid_params", `${path}.type is not supported`, 400);
|
||||
}
|
||||
if (value.bidirectional !== undefined && typeof value.bidirectional !== "boolean") {
|
||||
throw new AppError("invalid_params", `${path}.bidirectional must be a boolean`, 400);
|
||||
}
|
||||
return { ...value, source, target } as NonNullable<DiagramIr["edges"]>[number];
|
||||
};
|
||||
|
||||
export const parseDiagramMemoIr = (args: Record<string, unknown>): DiagramIr => {
|
||||
assertAllowedKeys(args, DIAGRAM_MEMO_KEYS, "arguments");
|
||||
const kind = getRequiredString(args.kind, "kind");
|
||||
if (!(["mind-map", "flowchart", "architecture"] as const).includes(kind as DiagramIr["kind"])) {
|
||||
throw new AppError("invalid_params", "kind must be mind-map, flowchart, or architecture", 400);
|
||||
}
|
||||
if (!Array.isArray(args.nodes) || args.nodes.length < 1 || args.nodes.length > 200) {
|
||||
throw new AppError("invalid_params", "nodes must include between 1 and 200 items", 400);
|
||||
}
|
||||
if (args.edges !== undefined && (!Array.isArray(args.edges) || args.edges.length > 400)) {
|
||||
throw new AppError("invalid_params", "edges must be an array with at most 400 items", 400);
|
||||
}
|
||||
if (args.theme !== undefined && !["plain", "brand", "sun", "wa", "island", "rose", "mint", "cosmos", "tea", "naive", "macaron", "ocean", "ink", "classic", "paper"].includes(String(args.theme))) {
|
||||
throw new AppError("invalid_params", "theme is not a supported diagram color scheme", 400);
|
||||
}
|
||||
if (args.structure !== undefined && !["map", "line", "capsule", "box", "circle", "ellipse", "hexagon", "logic", "tree", "brace", "org", "timeline", "fishbone"].includes(String(args.structure))) {
|
||||
throw new AppError("invalid_params", "structure is not a supported mind-map shape", 400);
|
||||
}
|
||||
if (args.title !== undefined && (typeof args.title !== "string" || args.title.length > 160)) {
|
||||
throw new AppError("invalid_params", "title must be a string with at most 160 characters", 400);
|
||||
}
|
||||
if (args.tags !== undefined && (
|
||||
!Array.isArray(args.tags) || args.tags.length > 100 || args.tags.some((tag) => typeof tag !== "string")
|
||||
)) {
|
||||
throw new AppError("invalid_params", "tags must be an array of at most 100 strings", 400);
|
||||
}
|
||||
if (args.layout !== undefined && (
|
||||
!isPlainRecord(args.layout)
|
||||
|| Object.keys(args.layout).some((key) => key !== "direction")
|
||||
|| (args.layout.direction !== undefined && !["left-to-right", "top-to-bottom"].includes(String(args.layout.direction)))
|
||||
)) {
|
||||
throw new AppError("invalid_params", "layout may only specify direction as left-to-right or top-to-bottom", 400);
|
||||
}
|
||||
|
||||
const ir: DiagramIr = {
|
||||
kind: kind as DiagramIr["kind"],
|
||||
...(args.theme === undefined ? {} : { theme: args.theme as DiagramIr["theme"] }),
|
||||
...(args.structure === undefined ? {} : { structure: args.structure as DiagramIr["structure"] }),
|
||||
...(args.layout === undefined ? {} : { layout: args.layout as DiagramIr["layout"] }),
|
||||
nodes: args.nodes.map(parseDiagramNode),
|
||||
edges: (args.edges as unknown[] | undefined)?.map(parseDiagramEdge),
|
||||
};
|
||||
const nodeIds = new Set(ir.nodes.map((node) => node.id));
|
||||
if (nodeIds.size !== ir.nodes.length) throw new AppError("invalid_params", "node IDs must be unique", 400);
|
||||
for (const node of ir.nodes) {
|
||||
const allowedTypes = ir.kind === "mind-map"
|
||||
? new Set([undefined, "topic"])
|
||||
: ir.kind === "flowchart"
|
||||
? new Set([undefined, "process", "decision", "start", "end", "terminator"])
|
||||
: new Set([undefined, "client", "frontend", "service", "database", "storage", "queue", "security", "external", "boundary"]);
|
||||
if (!allowedTypes.has(node.type)) {
|
||||
throw new AppError("invalid_params", `${node.type} is not a valid node type for ${ir.kind}`, 400);
|
||||
}
|
||||
if (ir.kind === "flowchart" && node.parentId) {
|
||||
throw new AppError("invalid_params", "flowchart nodes cannot use parentId", 400);
|
||||
}
|
||||
if (ir.kind !== "architecture" && node.resourceIcon) {
|
||||
throw new AppError("invalid_params", "resourceIcon is only available for architecture nodes", 400);
|
||||
}
|
||||
if (node.parentId && !nodeIds.has(node.parentId)) {
|
||||
throw new AppError("invalid_params", `${node.id}.parentId must reference an existing node`, 400);
|
||||
}
|
||||
if (ir.kind === "architecture" && node.parentId && ir.nodes.find((candidate) => candidate.id === node.parentId)?.type !== "boundary") {
|
||||
throw new AppError("invalid_params", `${node.id}.parentId must reference an architecture boundary`, 400);
|
||||
}
|
||||
}
|
||||
for (const edge of ir.edges ?? []) {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
throw new AppError("invalid_params", "every edge endpoint must reference an existing node", 400);
|
||||
}
|
||||
if (ir.kind === "architecture" && ir.nodes.some((node) => (node.id === edge.source || node.id === edge.target) && node.type === "boundary")) {
|
||||
throw new AppError("invalid_params", "architecture boundaries cannot be edge endpoints", 400);
|
||||
}
|
||||
}
|
||||
const parentByNodeId = new Map(ir.nodes.flatMap((node) => node.parentId ? [[node.id, node.parentId]] : []));
|
||||
for (const node of ir.nodes) {
|
||||
const ancestors = new Set<string>();
|
||||
let currentId: string | undefined = node.id;
|
||||
while (currentId) {
|
||||
if (ancestors.has(currentId)) {
|
||||
throw new AppError("invalid_params", "diagram parent relationships must not contain cycles", 400);
|
||||
}
|
||||
ancestors.add(currentId);
|
||||
currentId = parentByNodeId.get(currentId);
|
||||
}
|
||||
}
|
||||
return ir;
|
||||
};
|
||||
|
||||
const diagramNodeType = (shape: DiagramNodeShape): DiagramIrNodeType => shape;
|
||||
|
||||
export const diagramSemanticGraph = (document: DiagramDocument, includeLayout = false) => ({
|
||||
kind: document.kind,
|
||||
...(document.theme ? { theme: document.theme } : {}),
|
||||
...(document.structure ? { structure: document.structure } : {}),
|
||||
nodes: document.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: diagramNodeType(node.shape),
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
|
||||
...(includeLayout ? { layout: { x: node.x, y: node.y, width: node.width, height: node.height } } : {}),
|
||||
})),
|
||||
edges: document.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(edge.label ? { label: edge.label } : {}),
|
||||
...(edge.kind ? { type: edge.kind } : {}),
|
||||
...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}),
|
||||
})),
|
||||
});
|
||||
|
||||
export const memoWithoutDiagramPayload = (memo: MemoDetail) => {
|
||||
const contentMarkdown = stripDiagramDocumentMarker(memo.contentMarkdown);
|
||||
const contentJson = markdownToDoc(contentMarkdown);
|
||||
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
|
||||
};
|
||||
|
||||
type MutableDiagramEdge = ReturnType<typeof diagramSemanticGraph>["edges"][number];
|
||||
|
||||
export const applyDiagramOperations = async (
|
||||
args: Record<string, unknown>,
|
||||
current: DiagramDocument,
|
||||
) => {
|
||||
assertAllowedKeys(args, new Set(["memoId", "expectedRevision", "operations", "dryRun", "reflow"]), "arguments");
|
||||
if (!Array.isArray(args.operations) || args.operations.length < 1 || args.operations.length > 100) {
|
||||
throw new AppError("invalid_params", "operations must include between 1 and 100 items", 400);
|
||||
}
|
||||
if (args.reflow !== undefined && args.reflow !== "preserve" && args.reflow !== "all") {
|
||||
throw new AppError("invalid_params", "reflow must be preserve or all", 400);
|
||||
}
|
||||
|
||||
let nodes: DiagramIr["nodes"] = current.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: diagramNodeType(node.shape),
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
|
||||
}));
|
||||
let edges: MutableDiagramEdge[] = diagramSemanticGraph(current).edges;
|
||||
const resizedNodeIds = new Set<string>();
|
||||
const repositionedNodeIds = new Set<string>();
|
||||
const affectedBoundaryIds = new Set<string>();
|
||||
const counts = { addedNodes: 0, updatedNodes: 0, removedNodes: 0, addedEdges: 0, updatedEdges: 0, removedEdges: 0 };
|
||||
|
||||
const requireOperation = (value: unknown, index: number) => {
|
||||
if (!isPlainRecord(value) || typeof value.op !== "string") {
|
||||
throw new AppError("invalid_params", `operations.${index} must be an object with an op`, 400);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const findNodeIndex = (nodeId: string) => {
|
||||
const index = nodes.findIndex((node) => node.id === nodeId);
|
||||
if (index < 0) throw new AppError("invalid_params", `diagram node ${nodeId} does not exist`, 400);
|
||||
return index;
|
||||
};
|
||||
const findEdgeIndex = (edgeId: string) => {
|
||||
const index = edges.findIndex((edge) => edge.id === edgeId);
|
||||
if (index < 0) throw new AppError("invalid_params", `diagram edge ${edgeId} does not exist`, 400);
|
||||
return index;
|
||||
};
|
||||
|
||||
for (let operationIndex = 0; operationIndex < args.operations.length; operationIndex += 1) {
|
||||
const operation = requireOperation(args.operations[operationIndex], operationIndex);
|
||||
const path = `operations.${operationIndex}`;
|
||||
if (operation.op === "add_node") {
|
||||
assertAllowedKeys(operation, new Set(["op", "node"]), path);
|
||||
const node = parseDiagramNode(operation.node, operationIndex);
|
||||
if (nodes.some((candidate) => candidate.id === node.id)) {
|
||||
throw new AppError("invalid_params", `diagram node ${node.id} already exists`, 400);
|
||||
}
|
||||
nodes.push(node);
|
||||
if (node.parentId) affectedBoundaryIds.add(node.parentId);
|
||||
counts.addedNodes += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "update_node") {
|
||||
assertAllowedKeys(operation, new Set(["op", "nodeId", "changes"]), path);
|
||||
const nodeId = getRequiredString(operation.nodeId, `${path}.nodeId`);
|
||||
if (!isPlainRecord(operation.changes) || Object.keys(operation.changes).length === 0) {
|
||||
throw new AppError("invalid_params", `${path}.changes must be a non-empty object`, 400);
|
||||
}
|
||||
assertAllowedKeys(operation.changes, new Set(["label", "type", "parentId", "resourceIcon"]), `${path}.changes`);
|
||||
const index = findNodeIndex(nodeId);
|
||||
const previous = nodes[index];
|
||||
const candidate: Record<string, unknown> = { ...previous, ...operation.changes, id: nodeId };
|
||||
if (operation.changes.parentId === null || operation.changes.parentId === "") delete candidate.parentId;
|
||||
if (operation.changes.resourceIcon === null || operation.changes.resourceIcon === "") delete candidate.resourceIcon;
|
||||
const next = parseDiagramNode(candidate, operationIndex);
|
||||
nodes[index] = next;
|
||||
if (operation.changes.label !== undefined || operation.changes.type !== undefined) resizedNodeIds.add(nodeId);
|
||||
if (operation.changes.parentId !== undefined && previous.parentId !== next.parentId) {
|
||||
repositionedNodeIds.add(nodeId);
|
||||
if (previous.parentId) affectedBoundaryIds.add(previous.parentId);
|
||||
if (next.parentId) affectedBoundaryIds.add(next.parentId);
|
||||
}
|
||||
counts.updatedNodes += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "remove_node") {
|
||||
assertAllowedKeys(operation, new Set(["op", "nodeId", "cascade"]), path);
|
||||
const nodeId = getRequiredString(operation.nodeId, `${path}.nodeId`);
|
||||
findNodeIndex(nodeId);
|
||||
const removalIds = new Set([nodeId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const node of nodes) {
|
||||
if (node.parentId && removalIds.has(node.parentId) && !removalIds.has(node.id)) {
|
||||
if (operation.cascade !== true) {
|
||||
throw new AppError("invalid_params", `${nodeId} contains child nodes; set cascade to true to remove them`, 400);
|
||||
}
|
||||
removalIds.add(node.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of nodes) if (removalIds.has(node.id) && node.parentId) affectedBoundaryIds.add(node.parentId);
|
||||
const priorEdgeCount = edges.length;
|
||||
nodes = nodes.filter((node) => !removalIds.has(node.id));
|
||||
edges = edges.filter((edge) => !removalIds.has(edge.source) && !removalIds.has(edge.target));
|
||||
counts.removedNodes += removalIds.size;
|
||||
counts.removedEdges += priorEdgeCount - edges.length;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "add_edge") {
|
||||
assertAllowedKeys(operation, new Set(["op", "edge"]), path);
|
||||
if (!isPlainRecord(operation.edge)) throw new AppError("invalid_params", `${path}.edge must be an object`, 400);
|
||||
assertAllowedKeys(operation.edge, new Set([...DIAGRAM_EDGE_KEYS, "id"]), `${path}.edge`);
|
||||
const { id: requestedId, ...edgeInput } = operation.edge;
|
||||
const parsed = parseDiagramEdge(edgeInput, operationIndex);
|
||||
const id = requestedId === undefined ? createId("edge") : getRequiredString(requestedId, `${path}.edge.id`);
|
||||
if (id.length > 100 || edges.some((edge) => edge.id === id)) {
|
||||
throw new AppError("invalid_params", `diagram edge ID ${id} is invalid or already exists`, 400);
|
||||
}
|
||||
edges.push({ id, ...parsed });
|
||||
counts.addedEdges += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "update_edge") {
|
||||
assertAllowedKeys(operation, new Set(["op", "edgeId", "changes"]), path);
|
||||
const edgeId = getRequiredString(operation.edgeId, `${path}.edgeId`);
|
||||
if (!isPlainRecord(operation.changes) || Object.keys(operation.changes).length === 0) {
|
||||
throw new AppError("invalid_params", `${path}.changes must be a non-empty object`, 400);
|
||||
}
|
||||
assertAllowedKeys(operation.changes, DIAGRAM_EDGE_KEYS, `${path}.changes`);
|
||||
const index = findEdgeIndex(edgeId);
|
||||
const candidate: Record<string, unknown> = { ...edges[index], ...operation.changes };
|
||||
delete candidate.id;
|
||||
if (operation.changes.label === null || operation.changes.label === "") delete candidate.label;
|
||||
if (operation.changes.type === null || operation.changes.type === "") delete candidate.type;
|
||||
if (operation.changes.bidirectional === null) delete candidate.bidirectional;
|
||||
edges[index] = { id: edgeId, ...parseDiagramEdge(candidate, operationIndex) };
|
||||
counts.updatedEdges += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "remove_edge") {
|
||||
assertAllowedKeys(operation, new Set(["op", "edgeId"]), path);
|
||||
const edgeId = getRequiredString(operation.edgeId, `${path}.edgeId`);
|
||||
edges.splice(findEdgeIndex(edgeId), 1);
|
||||
counts.removedEdges += 1;
|
||||
continue;
|
||||
}
|
||||
throw new AppError("invalid_params", `${path}.op is not supported`, 400);
|
||||
}
|
||||
|
||||
if (nodes.length < 1) throw new AppError("invalid_params", "a diagram must contain at least one node", 400);
|
||||
if (current.kind === "mind-map") {
|
||||
const priorEdgeCount = edges.length;
|
||||
edges = edges.filter((edge) => !repositionedNodeIds.has(edge.target)
|
||||
|| nodes.find((node) => node.id === edge.target)?.parentId === edge.source);
|
||||
counts.removedEdges += priorEdgeCount - edges.length;
|
||||
for (const node of nodes) {
|
||||
if (!node.parentId || edges.some((edge) => edge.source === node.parentId && edge.target === node.id)) continue;
|
||||
edges.push({ id: createId("edge"), source: node.parentId, target: node.id });
|
||||
counts.addedEdges += 1;
|
||||
}
|
||||
}
|
||||
const validatedIr = parseDiagramMemoIr({
|
||||
kind: current.kind,
|
||||
...(current.theme ? { theme: current.theme } : {}),
|
||||
...(current.structure ? { structure: current.structure } : {}),
|
||||
nodes,
|
||||
edges: edges.map(({ id: _id, ...edge }) => edge),
|
||||
});
|
||||
const { compileDiagramIr } = await import("@edgeever/shared/diagram-layout");
|
||||
const next = compileDiagramIr(validatedIr);
|
||||
next.edges = edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(edge.label ? { label: edge.label } : {}),
|
||||
...(edge.type ? { kind: edge.type } : {}),
|
||||
...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}),
|
||||
}));
|
||||
|
||||
if (args.reflow !== "all") {
|
||||
const currentById = new Map(current.nodes.map((node) => [node.id, node]));
|
||||
for (const node of next.nodes) {
|
||||
const previous = currentById.get(node.id);
|
||||
if (!previous) continue;
|
||||
const boundaryNeedsLayout = node.shape === "boundary" && affectedBoundaryIds.has(node.id);
|
||||
if (repositionedNodeIds.has(node.id) || boundaryNeedsLayout) continue;
|
||||
node.x = previous.x;
|
||||
node.y = previous.y;
|
||||
if (!resizedNodeIds.has(node.id)) {
|
||||
node.width = previous.width;
|
||||
node.height = previous.height;
|
||||
}
|
||||
}
|
||||
if (current.kind === "architecture" && affectedBoundaryIds.size > 0) {
|
||||
const nodeById = new Map(next.nodes.map((node) => [node.id, node]));
|
||||
for (const boundaryId of [...affectedBoundaryIds]) {
|
||||
let parentId = nodeById.get(boundaryId)?.parentId;
|
||||
while (parentId) {
|
||||
affectedBoundaryIds.add(parentId);
|
||||
parentId = nodeById.get(parentId)?.parentId;
|
||||
}
|
||||
}
|
||||
const depth = (nodeId: string) => {
|
||||
let result = 0;
|
||||
let node = nodeById.get(nodeId);
|
||||
while (node?.parentId) {
|
||||
result += 1;
|
||||
node = nodeById.get(node.parentId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const boundaryId of [...affectedBoundaryIds].sort((left, right) => depth(right) - depth(left))) {
|
||||
const boundary = nodeById.get(boundaryId);
|
||||
const children = next.nodes.filter((node) => node.parentId === boundaryId);
|
||||
if (!boundary || boundary.shape !== "boundary" || children.length === 0) continue;
|
||||
const left = Math.min(...children.map((node) => node.x)) - 36;
|
||||
const top = Math.min(...children.map((node) => node.y)) - 56;
|
||||
const right = Math.max(...children.map((node) => node.x + node.width)) + 36;
|
||||
const bottom = Math.max(...children.map((node) => node.y + node.height)) + 36;
|
||||
boundary.x = left;
|
||||
boundary.y = top;
|
||||
boundary.width = Math.max(260, right - left);
|
||||
boundary.height = Math.max(180, bottom - top);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentMarkdown = serializeDiagramDocument(next);
|
||||
if (!parseDiagramDocument(contentMarkdown)) {
|
||||
throw new AppError("invalid_params", "The diagram operations produced an invalid document", 400);
|
||||
}
|
||||
return { document: next, contentMarkdown, changes: counts };
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
addTableField,
|
||||
createTableId,
|
||||
docToText,
|
||||
listTableAttachmentResourceIds,
|
||||
markdownToDoc,
|
||||
parseTableDocument,
|
||||
removeTableField,
|
||||
serializeTableDocument,
|
||||
stripTableDocumentMarker,
|
||||
TABLE_ATTACHMENT_LIMIT,
|
||||
TABLE_FIELD_LIMIT,
|
||||
TABLE_FIELD_TYPES,
|
||||
updateTableField,
|
||||
type MemoDetail,
|
||||
type TableDocument,
|
||||
type TableField,
|
||||
type TableFieldType,
|
||||
} from "@edgeever/shared";
|
||||
import { AppError } from "./app-error";
|
||||
import { getRequiredString } from "./mcp-json-rpc";
|
||||
import type { DatabaseAdapter } from "./storage-contract";
|
||||
|
||||
export const memoWithoutTablePayload = (memo: MemoDetail) => {
|
||||
const contentMarkdown = stripTableDocumentMarker(memo.contentMarkdown);
|
||||
const contentJson = markdownToDoc(contentMarkdown);
|
||||
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
|
||||
};
|
||||
|
||||
export const requiredTableRevision = (value: unknown) => {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new AppError("invalid_params", "expectedRevision must be a non-negative integer", 400);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
export const tableCellPatch = async (
|
||||
db: DatabaseAdapter,
|
||||
memoId: string,
|
||||
document: TableDocument,
|
||||
value: unknown,
|
||||
allowEmpty: boolean,
|
||||
) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AppError("invalid_params", "cells must be an object keyed by field ID", 400);
|
||||
}
|
||||
const cells = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
if (!allowEmpty && Object.keys(cells).length === 0) {
|
||||
throw new AppError("invalid_params", "cells must include at least one field", 400);
|
||||
}
|
||||
const fields = new Map(document.fields.map((field) => [field.id, field]));
|
||||
for (const [fieldId, cell] of Object.entries(cells)) {
|
||||
const field = fields.get(fieldId);
|
||||
if (!field) throw new AppError("invalid_params", `Unknown table field: ${fieldId}`, 400);
|
||||
if (field.type === "attachment") {
|
||||
if (!Array.isArray(cell) || cell.length > TABLE_ATTACHMENT_LIMIT || cell.some((id) => typeof id !== "string")) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an array of up to ${TABLE_ATTACHMENT_LIMIT} resource IDs`, 400);
|
||||
}
|
||||
if (new Set(cell).size !== cell.length) {
|
||||
throw new AppError("invalid_params", `${fieldId} contains duplicate resource IDs`, 400);
|
||||
}
|
||||
const attachments = [];
|
||||
for (const resourceId of cell) {
|
||||
const resource = await db.prepare(
|
||||
`SELECT id, filename, mime_type, byte_size FROM resources
|
||||
WHERE id = ? AND memo_id = ? AND kind = 'attachment' AND is_deleted = 0`,
|
||||
).bind(resourceId, memoId).first<{ id: string; filename: string | null; mime_type: string | null; byte_size: number }>();
|
||||
if (!resource) throw new AppError("invalid_params", `Attachment resource not found in this memo: ${resourceId}`, 400);
|
||||
attachments.push({
|
||||
resourceId: resource.id,
|
||||
filename: resource.filename ?? "attachment",
|
||||
mimeType: resource.mime_type ?? "application/octet-stream",
|
||||
byteSize: resource.byte_size,
|
||||
});
|
||||
}
|
||||
normalized[fieldId] = attachments;
|
||||
continue;
|
||||
}
|
||||
if (field.type === "checkbox") {
|
||||
if (typeof cell !== "boolean") throw new AppError("invalid_params", `${fieldId} requires a boolean`, 400);
|
||||
} else if (field.type === "number") {
|
||||
if (cell !== null && (typeof cell !== "number" || !Number.isFinite(cell))) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires a finite number or null`, 400);
|
||||
}
|
||||
} else if (cell !== null && (typeof cell !== "string" || cell.length > 2000)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires a string of at most 2000 characters or null`, 400);
|
||||
}
|
||||
if (field.type === "select" && cell && !field.options?.includes(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an existing option`, 400);
|
||||
}
|
||||
if (field.type === "date" && cell && !/^\d{4}-\d{2}-\d{2}$/.test(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires YYYY-MM-DD`, 400);
|
||||
}
|
||||
if (field.type === "url" && cell && !/^https?:\/\//i.test(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an http:// or https:// URL`, 400);
|
||||
}
|
||||
normalized[fieldId] = cell;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const tableObject = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AppError("invalid_params", `${label} must be an object`, 400);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tableFieldName = (value: unknown) => {
|
||||
const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
||||
if (!name || name.length > 80) {
|
||||
throw new AppError("invalid_params", "Field name must have 1–80 characters", 400);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const tableFieldType = (value: unknown): TableFieldType => {
|
||||
if (typeof value !== "string" || !TABLE_FIELD_TYPES.includes(value as TableFieldType)) {
|
||||
throw new AppError("invalid_params", "Unsupported table field type", 400);
|
||||
}
|
||||
return value as TableFieldType;
|
||||
};
|
||||
|
||||
const tableFieldOptions = (value: unknown) => {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 40) {
|
||||
throw new AppError("invalid_params", "Select fields require 1–40 options", 400);
|
||||
}
|
||||
const options = value.map((option) => tableFieldName(option));
|
||||
if (new Set(options.map((option) => option.toLocaleLowerCase())).size !== options.length) {
|
||||
throw new AppError("invalid_params", "Select options must be unique", 400);
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export const tableFieldInput = (value: unknown): TableField => {
|
||||
const input = tableObject(value, "field");
|
||||
if (Object.keys(input).some((key) => !["name", "type", "options"].includes(key))) {
|
||||
throw new AppError("invalid_params", "field must include only name, type, or options", 400);
|
||||
}
|
||||
const type = tableFieldType(input.type);
|
||||
if (type !== "select" && input.options !== undefined) {
|
||||
throw new AppError("invalid_params", "Only select fields can have options", 400);
|
||||
}
|
||||
return {
|
||||
id: createTableId("fld"),
|
||||
name: tableFieldName(input.name),
|
||||
type,
|
||||
...(type === "select" ? { options: tableFieldOptions(input.options) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const ensureUniqueTableFieldName = (document: TableDocument, name: string, exceptId?: string) => {
|
||||
if (document.fields.some((field) => field.id !== exceptId && field.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
||||
throw new AppError("invalid_params", `Duplicate table field name: ${name}`, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const tableFieldPatch = (current: TableField, value: unknown): Partial<Pick<TableField, "name" | "type" | "options">> => {
|
||||
const changes = tableObject(value, "changes");
|
||||
if (!Object.keys(changes).length || Object.keys(changes).some((key) => !["name", "type", "options"].includes(key))) {
|
||||
throw new AppError("invalid_params", "changes must include only name, type, or options", 400);
|
||||
}
|
||||
const type = changes.type === undefined ? current.type : tableFieldType(changes.type);
|
||||
if (type !== "select" && changes.options !== undefined) {
|
||||
throw new AppError("invalid_params", "Only select fields can have options", 400);
|
||||
}
|
||||
if (type === "select" && current.type !== "select" && changes.options === undefined) {
|
||||
throw new AppError("invalid_params", "Changing to select requires options", 400);
|
||||
}
|
||||
return {
|
||||
...(changes.name === undefined ? {} : { name: tableFieldName(changes.name) }),
|
||||
...(changes.type === undefined ? {} : { type }),
|
||||
...(type === "select" && changes.options !== undefined ? { options: tableFieldOptions(changes.options) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const validTableValueForField = (field: TableField, value: unknown) => {
|
||||
if (value === null || value === "") return true;
|
||||
if (field.type === "select") return typeof value === "string" && Boolean(field.options?.includes(value));
|
||||
if (field.type === "date") return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
if (field.type === "url") return typeof value === "string" && /^https?:\/\//i.test(value);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const applyTableSchemaOperations = (document: TableDocument, value: unknown) => {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 40) {
|
||||
throw new AppError("invalid_params", "operations must include 1–40 changes", 400);
|
||||
}
|
||||
let next = document;
|
||||
for (const valueItem of value) {
|
||||
const item = tableObject(valueItem, "operation");
|
||||
if (item.op === "add_field") {
|
||||
if (next.fields.length >= TABLE_FIELD_LIMIT) throw new AppError("table_field_limit", "Table field limit reached", 409);
|
||||
const field = tableFieldInput(item.field);
|
||||
ensureUniqueTableFieldName(next, field.name);
|
||||
next = addTableField(next, field);
|
||||
} else if (item.op === "update_field") {
|
||||
const fieldId = getRequiredString(item.fieldId, "fieldId");
|
||||
const current = next.fields.find((field) => field.id === fieldId);
|
||||
if (!current) throw new AppError("not_found", `Table field not found: ${fieldId}`, 404);
|
||||
const patch = tableFieldPatch(current, item.changes);
|
||||
if (patch.name !== undefined) ensureUniqueTableFieldName(next, patch.name, fieldId);
|
||||
next = updateTableField(next, fieldId, patch);
|
||||
const updated = next.fields.find((field) => field.id === fieldId)!;
|
||||
if (updated.type !== "select" && updated.options !== undefined) {
|
||||
next = { ...next, fields: next.fields.map((field) => field.id === fieldId ? { id: field.id, name: field.name, type: field.type } : field) };
|
||||
}
|
||||
const finalField = next.fields.find((field) => field.id === fieldId)!;
|
||||
if ((patch.type !== undefined || patch.options !== undefined) && ["select", "date", "url"].includes(finalField.type)) {
|
||||
next = {
|
||||
...next,
|
||||
records: next.records.map((record) => validTableValueForField(finalField, record.cells[fieldId])
|
||||
? record
|
||||
: { ...record, cells: { ...record.cells, [fieldId]: "" } }),
|
||||
};
|
||||
}
|
||||
} else if (item.op === "remove_field") {
|
||||
const fieldId = getRequiredString(item.fieldId, "fieldId");
|
||||
if (!next.fields.some((field) => field.id === fieldId)) {
|
||||
throw new AppError("not_found", `Table field not found: ${fieldId}`, 404);
|
||||
}
|
||||
if (next.fields.length === 1) throw new AppError("invalid_params", "A table must keep at least one field", 400);
|
||||
next = removeTableField(next, fieldId);
|
||||
} else {
|
||||
throw new AppError("invalid_params", "Unknown table schema operation", 400);
|
||||
}
|
||||
}
|
||||
if (!parseTableDocument(serializeTableDocument(next))) {
|
||||
throw new AppError("invalid_params", "Schema changes produced an invalid table", 400);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const tableSchemaImpact = (before: TableDocument, after: TableDocument) => {
|
||||
const afterRecords = new Map(after.records.map((record) => [record.id, record]));
|
||||
let changedCellCount = 0;
|
||||
for (const record of before.records) {
|
||||
const nextRecord = afterRecords.get(record.id);
|
||||
for (const field of before.fields) {
|
||||
const oldValue = record.cells[field.id];
|
||||
const newValue = nextRecord?.cells[field.id];
|
||||
const hasValue = Array.isArray(oldValue) ? oldValue.length > 0 : oldValue !== null && oldValue !== "";
|
||||
if (hasValue && JSON.stringify(oldValue) !== JSON.stringify(newValue)) changedCellCount += 1;
|
||||
}
|
||||
}
|
||||
const retainedResources = listTableAttachmentResourceIds(after);
|
||||
return {
|
||||
changedCellCount,
|
||||
removedAttachmentCount: [...listTableAttachmentResourceIds(before)].filter((id) => !retainedResources.has(id)).length,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
AiPromptTemplateCreateSchema,
|
||||
AiPromptTemplateUpdateSchema,
|
||||
ARCHITECTURE_RESOURCE_ICONS,
|
||||
TemplateCreateSchema,
|
||||
TemplateUpdateSchema,
|
||||
compileInfographicNote,
|
||||
@@ -11,41 +10,28 @@ import {
|
||||
infographicFallbackMarkdown,
|
||||
infographicSyntaxTemplate,
|
||||
markdownToDoc,
|
||||
addTableField,
|
||||
addTableRecord,
|
||||
createTableId,
|
||||
getTableSummary,
|
||||
TABLE_ATTACHMENT_LIMIT,
|
||||
TABLE_FIELD_LIMIT,
|
||||
TABLE_FIELD_TYPES,
|
||||
TABLE_RECORD_LIMIT,
|
||||
TABLE_SCHEMA_VERSION,
|
||||
hasTableDocumentMarker,
|
||||
parseDiagramDocument,
|
||||
parseInfographicDocument,
|
||||
parseTableDocument,
|
||||
listTableAttachmentResourceIds,
|
||||
removeTableField,
|
||||
removeTableRecord,
|
||||
serializeDiagramDocument,
|
||||
serializeInfographicDocument,
|
||||
serializeTableDocument,
|
||||
stripDiagramDocumentMarker,
|
||||
stripInfographicDocumentMarker,
|
||||
stripTableDocumentMarker,
|
||||
tableFallbackMarkdown,
|
||||
updateTableField,
|
||||
updateTableCell,
|
||||
type DiagramDocument,
|
||||
type DiagramNodeShape,
|
||||
type MemoDetail,
|
||||
type MemoSummary,
|
||||
type MemoUpdateInput,
|
||||
type TableDocument,
|
||||
type TableField,
|
||||
type TableFieldType,
|
||||
} from "@edgeever/shared";
|
||||
import type { DiagramIr, DiagramIrNodeType } from "@edgeever/shared/diagram-layout";
|
||||
import { audit, auditStatement } from "./audit";
|
||||
import type { AppContext, AuditActor, AuthContext, Bindings } from "./api-context";
|
||||
import { AppError } from "./app-error";
|
||||
@@ -60,6 +46,21 @@ import {
|
||||
getRequiredString,
|
||||
getRequiredStringArray,
|
||||
} from "./mcp-json-rpc";
|
||||
import {
|
||||
applyTableSchemaOperations,
|
||||
ensureUniqueTableFieldName,
|
||||
memoWithoutTablePayload,
|
||||
requiredTableRevision,
|
||||
tableCellPatch,
|
||||
tableFieldInput,
|
||||
tableSchemaImpact,
|
||||
} from "./mcp-table-tools";
|
||||
import {
|
||||
applyDiagramOperations,
|
||||
diagramSemanticGraph,
|
||||
memoWithoutDiagramPayload,
|
||||
parseDiagramMemoIr,
|
||||
} from "./mcp-diagram-tools";
|
||||
import {
|
||||
getMemoRevisionRow as getMemoRevisionRowService,
|
||||
listMemoRevisions as listMemoRevisionsService,
|
||||
@@ -230,658 +231,12 @@ const assertMcpMutationAllowed = (environment: Bindings) => {
|
||||
}
|
||||
};
|
||||
|
||||
const DIAGRAM_IR_NODE_TYPES = new Set<DiagramIrNodeType>([
|
||||
"topic", "process", "decision", "start", "end", "terminator", "client", "frontend", "service", "database", "storage",
|
||||
"queue", "security", "external", "boundary",
|
||||
]);
|
||||
const DIAGRAM_EDGE_KINDS = new Set(["dependency", "request", "async", "data"]);
|
||||
const DIAGRAM_MEMO_KEYS = new Set(["notebookId", "title", "kind", "theme", "structure", "layout", "tags", "nodes", "edges"]);
|
||||
const DIAGRAM_NODE_KEYS = new Set(["id", "label", "type", "parentId", "resourceIcon"]);
|
||||
const DIAGRAM_EDGE_KEYS = new Set(["source", "target", "label", "type", "bidirectional"]);
|
||||
|
||||
const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const assertAllowedKeys = (value: Record<string, unknown>, allowed: Set<string>, path: string) => {
|
||||
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
||||
if (unexpected) throw new AppError("invalid_params", `${path}.${unexpected} is not supported`, 400);
|
||||
};
|
||||
|
||||
const parseDiagramNode = (value: unknown, index: number) => {
|
||||
const path = `nodes.${index}`;
|
||||
if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400);
|
||||
assertAllowedKeys(value, DIAGRAM_NODE_KEYS, path);
|
||||
const id = getRequiredString(value.id, `${path}.id`);
|
||||
if (id.length > 100) throw new AppError("invalid_params", `${path}.id must be at most 100 characters`, 400);
|
||||
if (typeof value.label !== "string" || value.label.length > 500) {
|
||||
throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400);
|
||||
}
|
||||
if (value.type !== undefined && (
|
||||
typeof value.type !== "string" || !DIAGRAM_IR_NODE_TYPES.has(value.type as DiagramIrNodeType)
|
||||
)) {
|
||||
throw new AppError("invalid_params", `${path}.type is not supported`, 400);
|
||||
}
|
||||
if (value.parentId !== undefined && (typeof value.parentId !== "string" || !value.parentId.trim() || value.parentId.length > 100)) {
|
||||
throw new AppError("invalid_params", `${path}.parentId must be a non-empty string with at most 100 characters`, 400);
|
||||
}
|
||||
if (value.resourceIcon !== undefined && (
|
||||
typeof value.resourceIcon !== "string"
|
||||
|| !ARCHITECTURE_RESOURCE_ICONS.includes(value.resourceIcon as typeof ARCHITECTURE_RESOURCE_ICONS[number])
|
||||
)) {
|
||||
throw new AppError("invalid_params", `${path}.resourceIcon is not supported`, 400);
|
||||
}
|
||||
return { ...value, id } as DiagramIr["nodes"][number];
|
||||
};
|
||||
|
||||
const parseDiagramEdge = (value: unknown, index: number) => {
|
||||
const path = `edges.${index}`;
|
||||
if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400);
|
||||
assertAllowedKeys(value, DIAGRAM_EDGE_KEYS, path);
|
||||
const source = getRequiredString(value.source, `${path}.source`);
|
||||
const target = getRequiredString(value.target, `${path}.target`);
|
||||
if ([source, target].some((item) => item.length > 100)) {
|
||||
throw new AppError("invalid_params", `${path} node IDs must be at most 100 characters`, 400);
|
||||
}
|
||||
if (value.label !== undefined && (typeof value.label !== "string" || value.label.length > 500)) {
|
||||
throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400);
|
||||
}
|
||||
if (value.type !== undefined && (typeof value.type !== "string" || !DIAGRAM_EDGE_KINDS.has(value.type))) {
|
||||
throw new AppError("invalid_params", `${path}.type is not supported`, 400);
|
||||
}
|
||||
if (value.bidirectional !== undefined && typeof value.bidirectional !== "boolean") {
|
||||
throw new AppError("invalid_params", `${path}.bidirectional must be a boolean`, 400);
|
||||
}
|
||||
return { ...value, source, target } as NonNullable<DiagramIr["edges"]>[number];
|
||||
};
|
||||
|
||||
const parseDiagramMemoIr = (args: Record<string, unknown>): DiagramIr => {
|
||||
assertAllowedKeys(args, DIAGRAM_MEMO_KEYS, "arguments");
|
||||
const kind = getRequiredString(args.kind, "kind");
|
||||
if (!(["mind-map", "flowchart", "architecture"] as const).includes(kind as DiagramIr["kind"])) {
|
||||
throw new AppError("invalid_params", "kind must be mind-map, flowchart, or architecture", 400);
|
||||
}
|
||||
if (!Array.isArray(args.nodes) || args.nodes.length < 1 || args.nodes.length > 200) {
|
||||
throw new AppError("invalid_params", "nodes must include between 1 and 200 items", 400);
|
||||
}
|
||||
if (args.edges !== undefined && (!Array.isArray(args.edges) || args.edges.length > 400)) {
|
||||
throw new AppError("invalid_params", "edges must be an array with at most 400 items", 400);
|
||||
}
|
||||
if (args.theme !== undefined && !["plain", "brand", "sun", "wa", "island", "rose", "mint", "cosmos", "tea", "naive", "macaron", "ocean", "ink", "classic", "paper"].includes(String(args.theme))) {
|
||||
throw new AppError("invalid_params", "theme is not a supported diagram color scheme", 400);
|
||||
}
|
||||
if (args.structure !== undefined && !["map", "line", "capsule", "box", "circle", "ellipse", "hexagon", "logic", "tree", "brace", "org", "timeline", "fishbone"].includes(String(args.structure))) {
|
||||
throw new AppError("invalid_params", "structure is not a supported mind-map shape", 400);
|
||||
}
|
||||
if (args.title !== undefined && (typeof args.title !== "string" || args.title.length > 160)) {
|
||||
throw new AppError("invalid_params", "title must be a string with at most 160 characters", 400);
|
||||
}
|
||||
if (args.tags !== undefined && (
|
||||
!Array.isArray(args.tags) || args.tags.length > 100 || args.tags.some((tag) => typeof tag !== "string")
|
||||
)) {
|
||||
throw new AppError("invalid_params", "tags must be an array of at most 100 strings", 400);
|
||||
}
|
||||
if (args.layout !== undefined && (
|
||||
!isPlainRecord(args.layout)
|
||||
|| Object.keys(args.layout).some((key) => key !== "direction")
|
||||
|| (args.layout.direction !== undefined && !["left-to-right", "top-to-bottom"].includes(String(args.layout.direction)))
|
||||
)) {
|
||||
throw new AppError("invalid_params", "layout may only specify direction as left-to-right or top-to-bottom", 400);
|
||||
}
|
||||
|
||||
const ir: DiagramIr = {
|
||||
kind: kind as DiagramIr["kind"],
|
||||
...(args.theme === undefined ? {} : { theme: args.theme as DiagramIr["theme"] }),
|
||||
...(args.structure === undefined ? {} : { structure: args.structure as DiagramIr["structure"] }),
|
||||
...(args.layout === undefined ? {} : { layout: args.layout as DiagramIr["layout"] }),
|
||||
nodes: args.nodes.map(parseDiagramNode),
|
||||
edges: (args.edges as unknown[] | undefined)?.map(parseDiagramEdge),
|
||||
};
|
||||
const nodeIds = new Set(ir.nodes.map((node) => node.id));
|
||||
if (nodeIds.size !== ir.nodes.length) throw new AppError("invalid_params", "node IDs must be unique", 400);
|
||||
for (const node of ir.nodes) {
|
||||
const allowedTypes = ir.kind === "mind-map"
|
||||
? new Set([undefined, "topic"])
|
||||
: ir.kind === "flowchart"
|
||||
? new Set([undefined, "process", "decision", "start", "end", "terminator"])
|
||||
: new Set([undefined, "client", "frontend", "service", "database", "storage", "queue", "security", "external", "boundary"]);
|
||||
if (!allowedTypes.has(node.type)) {
|
||||
throw new AppError("invalid_params", `${node.type} is not a valid node type for ${ir.kind}`, 400);
|
||||
}
|
||||
if (ir.kind === "flowchart" && node.parentId) {
|
||||
throw new AppError("invalid_params", "flowchart nodes cannot use parentId", 400);
|
||||
}
|
||||
if (ir.kind !== "architecture" && node.resourceIcon) {
|
||||
throw new AppError("invalid_params", "resourceIcon is only available for architecture nodes", 400);
|
||||
}
|
||||
if (node.parentId && !nodeIds.has(node.parentId)) {
|
||||
throw new AppError("invalid_params", `${node.id}.parentId must reference an existing node`, 400);
|
||||
}
|
||||
if (ir.kind === "architecture" && node.parentId && ir.nodes.find((candidate) => candidate.id === node.parentId)?.type !== "boundary") {
|
||||
throw new AppError("invalid_params", `${node.id}.parentId must reference an architecture boundary`, 400);
|
||||
}
|
||||
}
|
||||
for (const edge of ir.edges ?? []) {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
throw new AppError("invalid_params", "every edge endpoint must reference an existing node", 400);
|
||||
}
|
||||
if (ir.kind === "architecture" && ir.nodes.some((node) => (node.id === edge.source || node.id === edge.target) && node.type === "boundary")) {
|
||||
throw new AppError("invalid_params", "architecture boundaries cannot be edge endpoints", 400);
|
||||
}
|
||||
}
|
||||
const parentByNodeId = new Map(ir.nodes.flatMap((node) => node.parentId ? [[node.id, node.parentId]] : []));
|
||||
for (const node of ir.nodes) {
|
||||
const ancestors = new Set<string>();
|
||||
let currentId: string | undefined = node.id;
|
||||
while (currentId) {
|
||||
if (ancestors.has(currentId)) {
|
||||
throw new AppError("invalid_params", "diagram parent relationships must not contain cycles", 400);
|
||||
}
|
||||
ancestors.add(currentId);
|
||||
currentId = parentByNodeId.get(currentId);
|
||||
}
|
||||
}
|
||||
return ir;
|
||||
};
|
||||
|
||||
const diagramNodeType = (shape: DiagramNodeShape): DiagramIrNodeType => shape;
|
||||
|
||||
const diagramSemanticGraph = (document: DiagramDocument, includeLayout = false) => ({
|
||||
kind: document.kind,
|
||||
...(document.theme ? { theme: document.theme } : {}),
|
||||
...(document.structure ? { structure: document.structure } : {}),
|
||||
nodes: document.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: diagramNodeType(node.shape),
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
|
||||
...(includeLayout ? { layout: { x: node.x, y: node.y, width: node.width, height: node.height } } : {}),
|
||||
})),
|
||||
edges: document.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(edge.label ? { label: edge.label } : {}),
|
||||
...(edge.kind ? { type: edge.kind } : {}),
|
||||
...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}),
|
||||
})),
|
||||
});
|
||||
|
||||
const memoWithoutDiagramPayload = (memo: MemoDetail) => {
|
||||
const contentMarkdown = stripDiagramDocumentMarker(memo.contentMarkdown);
|
||||
const contentJson = markdownToDoc(contentMarkdown);
|
||||
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
|
||||
};
|
||||
|
||||
const memoWithoutTablePayload = (memo: MemoDetail) => {
|
||||
const contentMarkdown = stripTableDocumentMarker(memo.contentMarkdown);
|
||||
const contentJson = markdownToDoc(contentMarkdown);
|
||||
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
|
||||
};
|
||||
|
||||
const memoWithoutInfographicPayload = (memo: MemoDetail) => {
|
||||
const contentMarkdown = stripInfographicDocumentMarker(memo.contentMarkdown);
|
||||
const contentJson = markdownToDoc(contentMarkdown);
|
||||
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
|
||||
};
|
||||
|
||||
const requiredTableRevision = (value: unknown) => {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new AppError("invalid_params", "expectedRevision must be a non-negative integer", 400);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
const tableCellPatch = async (
|
||||
db: DatabaseAdapter,
|
||||
memoId: string,
|
||||
document: TableDocument,
|
||||
value: unknown,
|
||||
allowEmpty: boolean,
|
||||
) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AppError("invalid_params", "cells must be an object keyed by field ID", 400);
|
||||
}
|
||||
const cells = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
if (!allowEmpty && Object.keys(cells).length === 0) {
|
||||
throw new AppError("invalid_params", "cells must include at least one field", 400);
|
||||
}
|
||||
const fields = new Map(document.fields.map((field) => [field.id, field]));
|
||||
for (const [fieldId, cell] of Object.entries(cells)) {
|
||||
const field = fields.get(fieldId);
|
||||
if (!field) throw new AppError("invalid_params", `Unknown table field: ${fieldId}`, 400);
|
||||
if (field.type === "attachment") {
|
||||
if (!Array.isArray(cell) || cell.length > TABLE_ATTACHMENT_LIMIT || cell.some((id) => typeof id !== "string")) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an array of up to ${TABLE_ATTACHMENT_LIMIT} resource IDs`, 400);
|
||||
}
|
||||
if (new Set(cell).size !== cell.length) {
|
||||
throw new AppError("invalid_params", `${fieldId} contains duplicate resource IDs`, 400);
|
||||
}
|
||||
const attachments = [];
|
||||
for (const resourceId of cell) {
|
||||
const resource = await db.prepare(
|
||||
`SELECT id, filename, mime_type, byte_size FROM resources
|
||||
WHERE id = ? AND memo_id = ? AND kind = 'attachment' AND is_deleted = 0`,
|
||||
).bind(resourceId, memoId).first<{ id: string; filename: string | null; mime_type: string | null; byte_size: number }>();
|
||||
if (!resource) throw new AppError("invalid_params", `Attachment resource not found in this memo: ${resourceId}`, 400);
|
||||
attachments.push({
|
||||
resourceId: resource.id,
|
||||
filename: resource.filename ?? "attachment",
|
||||
mimeType: resource.mime_type ?? "application/octet-stream",
|
||||
byteSize: resource.byte_size,
|
||||
});
|
||||
}
|
||||
normalized[fieldId] = attachments;
|
||||
continue;
|
||||
}
|
||||
if (field.type === "checkbox") {
|
||||
if (typeof cell !== "boolean") throw new AppError("invalid_params", `${fieldId} requires a boolean`, 400);
|
||||
} else if (field.type === "number") {
|
||||
if (cell !== null && (typeof cell !== "number" || !Number.isFinite(cell))) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires a finite number or null`, 400);
|
||||
}
|
||||
} else if (cell !== null && (typeof cell !== "string" || cell.length > 2000)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires a string of at most 2000 characters or null`, 400);
|
||||
}
|
||||
if (field.type === "select" && cell && !field.options?.includes(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an existing option`, 400);
|
||||
}
|
||||
if (field.type === "date" && cell && !/^\d{4}-\d{2}-\d{2}$/.test(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires YYYY-MM-DD`, 400);
|
||||
}
|
||||
if (field.type === "url" && cell && !/^https?:\/\//i.test(cell as string)) {
|
||||
throw new AppError("invalid_params", `${fieldId} requires an http:// or https:// URL`, 400);
|
||||
}
|
||||
normalized[fieldId] = cell;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const tableObject = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AppError("invalid_params", `${label} must be an object`, 400);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tableFieldName = (value: unknown) => {
|
||||
const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
||||
if (!name || name.length > 80) {
|
||||
throw new AppError("invalid_params", "Field name must have 1–80 characters", 400);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const tableFieldType = (value: unknown): TableFieldType => {
|
||||
if (typeof value !== "string" || !TABLE_FIELD_TYPES.includes(value as TableFieldType)) {
|
||||
throw new AppError("invalid_params", "Unsupported table field type", 400);
|
||||
}
|
||||
return value as TableFieldType;
|
||||
};
|
||||
|
||||
const tableFieldOptions = (value: unknown) => {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 40) {
|
||||
throw new AppError("invalid_params", "Select fields require 1–40 options", 400);
|
||||
}
|
||||
const options = value.map((option) => tableFieldName(option));
|
||||
if (new Set(options.map((option) => option.toLocaleLowerCase())).size !== options.length) {
|
||||
throw new AppError("invalid_params", "Select options must be unique", 400);
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const tableFieldInput = (value: unknown): TableField => {
|
||||
const input = tableObject(value, "field");
|
||||
if (Object.keys(input).some((key) => !["name", "type", "options"].includes(key))) {
|
||||
throw new AppError("invalid_params", "field must include only name, type, or options", 400);
|
||||
}
|
||||
const type = tableFieldType(input.type);
|
||||
if (type !== "select" && input.options !== undefined) {
|
||||
throw new AppError("invalid_params", "Only select fields can have options", 400);
|
||||
}
|
||||
return {
|
||||
id: createTableId("fld"),
|
||||
name: tableFieldName(input.name),
|
||||
type,
|
||||
...(type === "select" ? { options: tableFieldOptions(input.options) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const ensureUniqueTableFieldName = (document: TableDocument, name: string, exceptId?: string) => {
|
||||
if (document.fields.some((field) => field.id !== exceptId && field.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
||||
throw new AppError("invalid_params", `Duplicate table field name: ${name}`, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const tableFieldPatch = (current: TableField, value: unknown): Partial<Pick<TableField, "name" | "type" | "options">> => {
|
||||
const changes = tableObject(value, "changes");
|
||||
if (!Object.keys(changes).length || Object.keys(changes).some((key) => !["name", "type", "options"].includes(key))) {
|
||||
throw new AppError("invalid_params", "changes must include only name, type, or options", 400);
|
||||
}
|
||||
const type = changes.type === undefined ? current.type : tableFieldType(changes.type);
|
||||
if (type !== "select" && changes.options !== undefined) {
|
||||
throw new AppError("invalid_params", "Only select fields can have options", 400);
|
||||
}
|
||||
if (type === "select" && current.type !== "select" && changes.options === undefined) {
|
||||
throw new AppError("invalid_params", "Changing to select requires options", 400);
|
||||
}
|
||||
return {
|
||||
...(changes.name === undefined ? {} : { name: tableFieldName(changes.name) }),
|
||||
...(changes.type === undefined ? {} : { type }),
|
||||
...(type === "select" && changes.options !== undefined ? { options: tableFieldOptions(changes.options) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const validTableValueForField = (field: TableField, value: unknown) => {
|
||||
if (value === null || value === "") return true;
|
||||
if (field.type === "select") return typeof value === "string" && Boolean(field.options?.includes(value));
|
||||
if (field.type === "date") return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
if (field.type === "url") return typeof value === "string" && /^https?:\/\//i.test(value);
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyTableSchemaOperations = (document: TableDocument, value: unknown) => {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 40) {
|
||||
throw new AppError("invalid_params", "operations must include 1–40 changes", 400);
|
||||
}
|
||||
let next = document;
|
||||
for (const valueItem of value) {
|
||||
const item = tableObject(valueItem, "operation");
|
||||
if (item.op === "add_field") {
|
||||
if (next.fields.length >= TABLE_FIELD_LIMIT) throw new AppError("table_field_limit", "Table field limit reached", 409);
|
||||
const field = tableFieldInput(item.field);
|
||||
ensureUniqueTableFieldName(next, field.name);
|
||||
next = addTableField(next, field);
|
||||
} else if (item.op === "update_field") {
|
||||
const fieldId = getRequiredString(item.fieldId, "fieldId");
|
||||
const current = next.fields.find((field) => field.id === fieldId);
|
||||
if (!current) throw new AppError("not_found", `Table field not found: ${fieldId}`, 404);
|
||||
const patch = tableFieldPatch(current, item.changes);
|
||||
if (patch.name !== undefined) ensureUniqueTableFieldName(next, patch.name, fieldId);
|
||||
next = updateTableField(next, fieldId, patch);
|
||||
const updated = next.fields.find((field) => field.id === fieldId)!;
|
||||
if (updated.type !== "select" && updated.options !== undefined) {
|
||||
next = { ...next, fields: next.fields.map((field) => field.id === fieldId ? { id: field.id, name: field.name, type: field.type } : field) };
|
||||
}
|
||||
const finalField = next.fields.find((field) => field.id === fieldId)!;
|
||||
if ((patch.type !== undefined || patch.options !== undefined) && ["select", "date", "url"].includes(finalField.type)) {
|
||||
next = {
|
||||
...next,
|
||||
records: next.records.map((record) => validTableValueForField(finalField, record.cells[fieldId])
|
||||
? record
|
||||
: { ...record, cells: { ...record.cells, [fieldId]: "" } }),
|
||||
};
|
||||
}
|
||||
} else if (item.op === "remove_field") {
|
||||
const fieldId = getRequiredString(item.fieldId, "fieldId");
|
||||
if (!next.fields.some((field) => field.id === fieldId)) {
|
||||
throw new AppError("not_found", `Table field not found: ${fieldId}`, 404);
|
||||
}
|
||||
if (next.fields.length === 1) throw new AppError("invalid_params", "A table must keep at least one field", 400);
|
||||
next = removeTableField(next, fieldId);
|
||||
} else {
|
||||
throw new AppError("invalid_params", "Unknown table schema operation", 400);
|
||||
}
|
||||
}
|
||||
if (!parseTableDocument(serializeTableDocument(next))) {
|
||||
throw new AppError("invalid_params", "Schema changes produced an invalid table", 400);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const tableSchemaImpact = (before: TableDocument, after: TableDocument) => {
|
||||
const afterRecords = new Map(after.records.map((record) => [record.id, record]));
|
||||
let changedCellCount = 0;
|
||||
for (const record of before.records) {
|
||||
const nextRecord = afterRecords.get(record.id);
|
||||
for (const field of before.fields) {
|
||||
const oldValue = record.cells[field.id];
|
||||
const newValue = nextRecord?.cells[field.id];
|
||||
const hasValue = Array.isArray(oldValue) ? oldValue.length > 0 : oldValue !== null && oldValue !== "";
|
||||
if (hasValue && JSON.stringify(oldValue) !== JSON.stringify(newValue)) changedCellCount += 1;
|
||||
}
|
||||
}
|
||||
const retainedResources = listTableAttachmentResourceIds(after);
|
||||
return {
|
||||
changedCellCount,
|
||||
removedAttachmentCount: [...listTableAttachmentResourceIds(before)].filter((id) => !retainedResources.has(id)).length,
|
||||
};
|
||||
};
|
||||
|
||||
type MutableDiagramEdge = ReturnType<typeof diagramSemanticGraph>["edges"][number];
|
||||
|
||||
const applyDiagramOperations = async (
|
||||
args: Record<string, unknown>,
|
||||
current: DiagramDocument,
|
||||
) => {
|
||||
assertAllowedKeys(args, new Set(["memoId", "expectedRevision", "operations", "dryRun", "reflow"]), "arguments");
|
||||
if (!Array.isArray(args.operations) || args.operations.length < 1 || args.operations.length > 100) {
|
||||
throw new AppError("invalid_params", "operations must include between 1 and 100 items", 400);
|
||||
}
|
||||
if (args.reflow !== undefined && args.reflow !== "preserve" && args.reflow !== "all") {
|
||||
throw new AppError("invalid_params", "reflow must be preserve or all", 400);
|
||||
}
|
||||
|
||||
let nodes: DiagramIr["nodes"] = current.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: diagramNodeType(node.shape),
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
|
||||
}));
|
||||
let edges: MutableDiagramEdge[] = diagramSemanticGraph(current).edges;
|
||||
const resizedNodeIds = new Set<string>();
|
||||
const repositionedNodeIds = new Set<string>();
|
||||
const affectedBoundaryIds = new Set<string>();
|
||||
const counts = { addedNodes: 0, updatedNodes: 0, removedNodes: 0, addedEdges: 0, updatedEdges: 0, removedEdges: 0 };
|
||||
|
||||
const requireOperation = (value: unknown, index: number) => {
|
||||
if (!isPlainRecord(value) || typeof value.op !== "string") {
|
||||
throw new AppError("invalid_params", `operations.${index} must be an object with an op`, 400);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const findNodeIndex = (nodeId: string) => {
|
||||
const index = nodes.findIndex((node) => node.id === nodeId);
|
||||
if (index < 0) throw new AppError("invalid_params", `diagram node ${nodeId} does not exist`, 400);
|
||||
return index;
|
||||
};
|
||||
const findEdgeIndex = (edgeId: string) => {
|
||||
const index = edges.findIndex((edge) => edge.id === edgeId);
|
||||
if (index < 0) throw new AppError("invalid_params", `diagram edge ${edgeId} does not exist`, 400);
|
||||
return index;
|
||||
};
|
||||
|
||||
for (let operationIndex = 0; operationIndex < args.operations.length; operationIndex += 1) {
|
||||
const operation = requireOperation(args.operations[operationIndex], operationIndex);
|
||||
const path = `operations.${operationIndex}`;
|
||||
if (operation.op === "add_node") {
|
||||
assertAllowedKeys(operation, new Set(["op", "node"]), path);
|
||||
const node = parseDiagramNode(operation.node, operationIndex);
|
||||
if (nodes.some((candidate) => candidate.id === node.id)) {
|
||||
throw new AppError("invalid_params", `diagram node ${node.id} already exists`, 400);
|
||||
}
|
||||
nodes.push(node);
|
||||
if (node.parentId) affectedBoundaryIds.add(node.parentId);
|
||||
counts.addedNodes += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "update_node") {
|
||||
assertAllowedKeys(operation, new Set(["op", "nodeId", "changes"]), path);
|
||||
const nodeId = getRequiredString(operation.nodeId, `${path}.nodeId`);
|
||||
if (!isPlainRecord(operation.changes) || Object.keys(operation.changes).length === 0) {
|
||||
throw new AppError("invalid_params", `${path}.changes must be a non-empty object`, 400);
|
||||
}
|
||||
assertAllowedKeys(operation.changes, new Set(["label", "type", "parentId", "resourceIcon"]), `${path}.changes`);
|
||||
const index = findNodeIndex(nodeId);
|
||||
const previous = nodes[index];
|
||||
const candidate: Record<string, unknown> = { ...previous, ...operation.changes, id: nodeId };
|
||||
if (operation.changes.parentId === null || operation.changes.parentId === "") delete candidate.parentId;
|
||||
if (operation.changes.resourceIcon === null || operation.changes.resourceIcon === "") delete candidate.resourceIcon;
|
||||
const next = parseDiagramNode(candidate, operationIndex);
|
||||
nodes[index] = next;
|
||||
if (operation.changes.label !== undefined || operation.changes.type !== undefined) resizedNodeIds.add(nodeId);
|
||||
if (operation.changes.parentId !== undefined && previous.parentId !== next.parentId) {
|
||||
repositionedNodeIds.add(nodeId);
|
||||
if (previous.parentId) affectedBoundaryIds.add(previous.parentId);
|
||||
if (next.parentId) affectedBoundaryIds.add(next.parentId);
|
||||
}
|
||||
counts.updatedNodes += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "remove_node") {
|
||||
assertAllowedKeys(operation, new Set(["op", "nodeId", "cascade"]), path);
|
||||
const nodeId = getRequiredString(operation.nodeId, `${path}.nodeId`);
|
||||
findNodeIndex(nodeId);
|
||||
const removalIds = new Set([nodeId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const node of nodes) {
|
||||
if (node.parentId && removalIds.has(node.parentId) && !removalIds.has(node.id)) {
|
||||
if (operation.cascade !== true) {
|
||||
throw new AppError("invalid_params", `${nodeId} contains child nodes; set cascade to true to remove them`, 400);
|
||||
}
|
||||
removalIds.add(node.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of nodes) if (removalIds.has(node.id) && node.parentId) affectedBoundaryIds.add(node.parentId);
|
||||
const priorEdgeCount = edges.length;
|
||||
nodes = nodes.filter((node) => !removalIds.has(node.id));
|
||||
edges = edges.filter((edge) => !removalIds.has(edge.source) && !removalIds.has(edge.target));
|
||||
counts.removedNodes += removalIds.size;
|
||||
counts.removedEdges += priorEdgeCount - edges.length;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "add_edge") {
|
||||
assertAllowedKeys(operation, new Set(["op", "edge"]), path);
|
||||
if (!isPlainRecord(operation.edge)) throw new AppError("invalid_params", `${path}.edge must be an object`, 400);
|
||||
assertAllowedKeys(operation.edge, new Set([...DIAGRAM_EDGE_KEYS, "id"]), `${path}.edge`);
|
||||
const { id: requestedId, ...edgeInput } = operation.edge;
|
||||
const parsed = parseDiagramEdge(edgeInput, operationIndex);
|
||||
const id = requestedId === undefined ? createId("edge") : getRequiredString(requestedId, `${path}.edge.id`);
|
||||
if (id.length > 100 || edges.some((edge) => edge.id === id)) {
|
||||
throw new AppError("invalid_params", `diagram edge ID ${id} is invalid or already exists`, 400);
|
||||
}
|
||||
edges.push({ id, ...parsed });
|
||||
counts.addedEdges += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "update_edge") {
|
||||
assertAllowedKeys(operation, new Set(["op", "edgeId", "changes"]), path);
|
||||
const edgeId = getRequiredString(operation.edgeId, `${path}.edgeId`);
|
||||
if (!isPlainRecord(operation.changes) || Object.keys(operation.changes).length === 0) {
|
||||
throw new AppError("invalid_params", `${path}.changes must be a non-empty object`, 400);
|
||||
}
|
||||
assertAllowedKeys(operation.changes, DIAGRAM_EDGE_KEYS, `${path}.changes`);
|
||||
const index = findEdgeIndex(edgeId);
|
||||
const candidate: Record<string, unknown> = { ...edges[index], ...operation.changes };
|
||||
delete candidate.id;
|
||||
if (operation.changes.label === null || operation.changes.label === "") delete candidate.label;
|
||||
if (operation.changes.type === null || operation.changes.type === "") delete candidate.type;
|
||||
if (operation.changes.bidirectional === null) delete candidate.bidirectional;
|
||||
edges[index] = { id: edgeId, ...parseDiagramEdge(candidate, operationIndex) };
|
||||
counts.updatedEdges += 1;
|
||||
continue;
|
||||
}
|
||||
if (operation.op === "remove_edge") {
|
||||
assertAllowedKeys(operation, new Set(["op", "edgeId"]), path);
|
||||
const edgeId = getRequiredString(operation.edgeId, `${path}.edgeId`);
|
||||
edges.splice(findEdgeIndex(edgeId), 1);
|
||||
counts.removedEdges += 1;
|
||||
continue;
|
||||
}
|
||||
throw new AppError("invalid_params", `${path}.op is not supported`, 400);
|
||||
}
|
||||
|
||||
if (nodes.length < 1) throw new AppError("invalid_params", "a diagram must contain at least one node", 400);
|
||||
if (current.kind === "mind-map") {
|
||||
const priorEdgeCount = edges.length;
|
||||
edges = edges.filter((edge) => !repositionedNodeIds.has(edge.target)
|
||||
|| nodes.find((node) => node.id === edge.target)?.parentId === edge.source);
|
||||
counts.removedEdges += priorEdgeCount - edges.length;
|
||||
for (const node of nodes) {
|
||||
if (!node.parentId || edges.some((edge) => edge.source === node.parentId && edge.target === node.id)) continue;
|
||||
edges.push({ id: createId("edge"), source: node.parentId, target: node.id });
|
||||
counts.addedEdges += 1;
|
||||
}
|
||||
}
|
||||
const validatedIr = parseDiagramMemoIr({
|
||||
kind: current.kind,
|
||||
...(current.theme ? { theme: current.theme } : {}),
|
||||
...(current.structure ? { structure: current.structure } : {}),
|
||||
nodes,
|
||||
edges: edges.map(({ id: _id, ...edge }) => edge),
|
||||
});
|
||||
const { compileDiagramIr } = await import("@edgeever/shared/diagram-layout");
|
||||
const next = compileDiagramIr(validatedIr);
|
||||
next.edges = edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(edge.label ? { label: edge.label } : {}),
|
||||
...(edge.type ? { kind: edge.type } : {}),
|
||||
...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}),
|
||||
}));
|
||||
|
||||
if (args.reflow !== "all") {
|
||||
const currentById = new Map(current.nodes.map((node) => [node.id, node]));
|
||||
for (const node of next.nodes) {
|
||||
const previous = currentById.get(node.id);
|
||||
if (!previous) continue;
|
||||
const boundaryNeedsLayout = node.shape === "boundary" && affectedBoundaryIds.has(node.id);
|
||||
if (repositionedNodeIds.has(node.id) || boundaryNeedsLayout) continue;
|
||||
node.x = previous.x;
|
||||
node.y = previous.y;
|
||||
if (!resizedNodeIds.has(node.id)) {
|
||||
node.width = previous.width;
|
||||
node.height = previous.height;
|
||||
}
|
||||
}
|
||||
if (current.kind === "architecture" && affectedBoundaryIds.size > 0) {
|
||||
const nodeById = new Map(next.nodes.map((node) => [node.id, node]));
|
||||
for (const boundaryId of [...affectedBoundaryIds]) {
|
||||
let parentId = nodeById.get(boundaryId)?.parentId;
|
||||
while (parentId) {
|
||||
affectedBoundaryIds.add(parentId);
|
||||
parentId = nodeById.get(parentId)?.parentId;
|
||||
}
|
||||
}
|
||||
const depth = (nodeId: string) => {
|
||||
let result = 0;
|
||||
let node = nodeById.get(nodeId);
|
||||
while (node?.parentId) {
|
||||
result += 1;
|
||||
node = nodeById.get(node.parentId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const boundaryId of [...affectedBoundaryIds].sort((left, right) => depth(right) - depth(left))) {
|
||||
const boundary = nodeById.get(boundaryId);
|
||||
const children = next.nodes.filter((node) => node.parentId === boundaryId);
|
||||
if (!boundary || boundary.shape !== "boundary" || children.length === 0) continue;
|
||||
const left = Math.min(...children.map((node) => node.x)) - 36;
|
||||
const top = Math.min(...children.map((node) => node.y)) - 56;
|
||||
const right = Math.max(...children.map((node) => node.x + node.width)) + 36;
|
||||
const bottom = Math.max(...children.map((node) => node.y + node.height)) + 36;
|
||||
boundary.x = left;
|
||||
boundary.y = top;
|
||||
boundary.width = Math.max(260, right - left);
|
||||
boundary.height = Math.max(180, bottom - top);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentMarkdown = serializeDiagramDocument(next);
|
||||
if (!parseDiagramDocument(contentMarkdown)) {
|
||||
throw new AppError("invalid_params", "The diagram operations produced an invalid document", 400);
|
||||
}
|
||||
return { document: next, contentMarkdown, changes: counts };
|
||||
};
|
||||
|
||||
export const callMcpTool = async (
|
||||
c: AppContext,
|
||||
auth: AuthContext,
|
||||
|
||||
@@ -77,18 +77,12 @@ import {
|
||||
resolveSelectionMoveTargetNotebookId,
|
||||
} from "@/lib/app-helpers";
|
||||
import { useBrowserBackLayer } from "@/lib/app-hooks";
|
||||
import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache";
|
||||
import { updateMemoSummaryInLists } from "@/lib/memo-list-cache";
|
||||
import {
|
||||
cacheMemoDetail,
|
||||
evictIdleMemoDetails,
|
||||
clearTrashMemoLists,
|
||||
collectMemoSummariesFromCache,
|
||||
decrementNotebookMemoCounts,
|
||||
getAdjacentMemoIdAfterRemoval,
|
||||
memoDetailQueryKey,
|
||||
memoToSummary,
|
||||
removeMemoSummariesFromLists,
|
||||
type ListNotebooksQueryData,
|
||||
} from "@/lib/workspace-memo-cache";
|
||||
import {
|
||||
getVerticalScrollContainer,
|
||||
@@ -122,6 +116,7 @@ import { useNoteProse } from "@/hooks/useNoteProse";
|
||||
import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
|
||||
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
|
||||
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
|
||||
import { useWorkspaceMemoRemoval } from "@/hooks/useWorkspaceMemoRemoval";
|
||||
import { EdgeEverPluginHost, type RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
|
||||
import { loadResolvedPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
|
||||
import { updateOfficialMarketplacePlugins } from "@/lib/plugins/plugin-updates";
|
||||
@@ -166,21 +161,6 @@ const PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
type MemoDeleteOptimisticContext = {
|
||||
previousMemoLists: Array<[readonly unknown[], MemoListQueryData | undefined]>;
|
||||
previousMemoDetails: Array<[readonly unknown[], { memo: MemoDetail } | undefined]>;
|
||||
previousNotebooks: ListNotebooksQueryData | undefined;
|
||||
previousActivePane: Pane;
|
||||
previousSelectedMemoId: string | null;
|
||||
};
|
||||
|
||||
type EmptyTrashOptimisticContext = {
|
||||
previousMemoLists: Array<[readonly unknown[], MemoListQueryData | undefined]>;
|
||||
previousMemoDetails: Array<[readonly unknown[], { memo: MemoDetail } | undefined]>;
|
||||
previousActivePane: Pane;
|
||||
previousSelectedMemoId: string | null;
|
||||
};
|
||||
|
||||
export const WorkspaceApp = ({
|
||||
authRequired,
|
||||
demoMode,
|
||||
@@ -1437,181 +1417,18 @@ export const WorkspaceApp = ({
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMemosMutation = useMutation({
|
||||
mutationFn: repository.deleteMemos,
|
||||
onMutate: async (variables): Promise<MemoDeleteOptimisticContext> => {
|
||||
const deletedMemoIds = new Set(variables.memoIds);
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: ["memos"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["memo"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["notebooks"] }),
|
||||
]);
|
||||
|
||||
const previousMemoLists = queryClient.getQueriesData<MemoListQueryData>({ queryKey: ["memos"] });
|
||||
const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] });
|
||||
const previousNotebooks = queryClient.getQueryData<ListNotebooksQueryData>(["notebooks"]);
|
||||
const removedMemos = collectMemoSummariesFromCache(queryClient, deletedMemoIds);
|
||||
|
||||
clearMemoSelection();
|
||||
|
||||
if (selectedMemoId && deletedMemoIds.has(selectedMemoId)) {
|
||||
setSelectedMemoId(getAdjacentMemoIdAfterRemoval(memos, deletedMemoIds, selectedMemoId));
|
||||
setActivePane("memos");
|
||||
}
|
||||
|
||||
removeMemoSummariesFromLists(queryClient, deletedMemoIds);
|
||||
decrementNotebookMemoCounts(queryClient, removedMemos);
|
||||
|
||||
return { previousMemoLists, previousMemoDetails, previousNotebooks, previousActivePane: activePane, previousSelectedMemoId: selectedMemoId };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
context?.previousMemoLists.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
context?.previousMemoDetails.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
queryClient.setQueryData(["notebooks"], context?.previousNotebooks);
|
||||
setSelectedMemoId(context?.previousSelectedMemoId ?? null);
|
||||
setActivePane(context?.previousActivePane ?? "memos");
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const refetchType = _error ? "active" : "inactive";
|
||||
const deletedMemoIds = new Set(variables?.memoIds ?? []);
|
||||
|
||||
if (!_error) {
|
||||
for (const memoId of deletedMemoIds) {
|
||||
queryClient.removeQueries({ queryKey: ["memo", memoId] });
|
||||
}
|
||||
}
|
||||
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["memo"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: _error ? "active" : "all" }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMemoMutation = useMutation({
|
||||
mutationFn: async ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => {
|
||||
return repository.deleteMemo(memoId, Boolean(permanent));
|
||||
},
|
||||
onMutate: async (variables): Promise<MemoDeleteOptimisticContext> => {
|
||||
const deletedMemoIds = new Set([variables.memoId]);
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: ["memos"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["memo", variables.memoId] }),
|
||||
queryClient.cancelQueries({ queryKey: ["notebooks"] }),
|
||||
]);
|
||||
|
||||
const previousMemoLists = queryClient.getQueriesData<MemoListQueryData>({ queryKey: ["memos"] });
|
||||
const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo", variables.memoId] });
|
||||
const previousNotebooks = queryClient.getQueryData<ListNotebooksQueryData>(["notebooks"]);
|
||||
const removedMemos = collectMemoSummariesFromCache(queryClient, deletedMemoIds);
|
||||
|
||||
if (selectedMemoId === variables.memoId) {
|
||||
setSelectedMemoId(getAdjacentMemoIdAfterRemoval(memos, deletedMemoIds, variables.memoId));
|
||||
setActivePane("memos");
|
||||
}
|
||||
|
||||
removeMemoSummariesFromLists(queryClient, deletedMemoIds);
|
||||
decrementNotebookMemoCounts(queryClient, removedMemos);
|
||||
|
||||
return { previousMemoLists, previousMemoDetails, previousNotebooks, previousActivePane: activePane, previousSelectedMemoId: selectedMemoId };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
context?.previousMemoLists.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
context?.previousMemoDetails.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
queryClient.setQueryData(["notebooks"], context?.previousNotebooks);
|
||||
setSelectedMemoId(context?.previousSelectedMemoId ?? null);
|
||||
setActivePane(context?.previousActivePane ?? "memos");
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const refetchType = _error ? "active" : "inactive";
|
||||
|
||||
if (!_error) {
|
||||
queryClient.removeQueries({ queryKey: ["memo", variables?.memoId] });
|
||||
}
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: _error ? "active" : "all" }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const emptyTrashMutation = useMutation({
|
||||
mutationFn: repository.emptyTrash,
|
||||
onMutate: async (): Promise<EmptyTrashOptimisticContext> => {
|
||||
const previousActivePane = activePane;
|
||||
const previousSelectedMemoId = selectedMemoId;
|
||||
|
||||
// Leave the editor before cancelling/refetching its detail query. On a
|
||||
// desktop layout the editor remains mounted even when the memo pane is
|
||||
// visible, so keeping a trashed memo selected while its query is being
|
||||
// invalidated can render a deleted detail and repeatedly re-select it.
|
||||
setEmptyTrashConfirmationOpen(false);
|
||||
clearMemoSelection();
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: ["memos"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["memo"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["resources"] }),
|
||||
]);
|
||||
|
||||
const previousMemoLists = queryClient.getQueriesData<MemoListQueryData>({ queryKey: ["memos"] });
|
||||
const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] });
|
||||
|
||||
// Keep the optimistic update limited to list data. Removing the active
|
||||
// memo detail query here can make the editor render with a missing memo
|
||||
// during the same React update and blank the whole workspace.
|
||||
clearTrashMemoLists(queryClient);
|
||||
|
||||
return { previousMemoLists, previousMemoDetails, previousActivePane, previousSelectedMemoId };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
context?.previousMemoLists.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
context?.previousMemoDetails.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
setSelectedMemoId(context?.previousSelectedMemoId ?? null);
|
||||
setActivePane(context?.previousActivePane ?? "memos");
|
||||
setAppNoticeDialog({
|
||||
title: t("workspaceDialogs.emptyTrashFailedTitle"),
|
||||
description: t("workspaceDialogs.emptyTrashFailedDescription"),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// The trash detail queries are no longer valid after a successful
|
||||
// permanent delete. Remove them before invalidating the remaining
|
||||
// active queries so an editor cannot briefly observe a 404 detail.
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["memo"],
|
||||
predicate: (query) => {
|
||||
const data = query.state.data as { memo?: { isDeleted?: boolean } } | undefined;
|
||||
return data?.memo?.isDeleted === true;
|
||||
},
|
||||
});
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
},
|
||||
onSettled: () => {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "active" }),
|
||||
queryClient.invalidateQueries({ queryKey: ["memo"], refetchType: "active" }),
|
||||
queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: "active" }),
|
||||
]);
|
||||
},
|
||||
const { deleteMemosMutation, deleteMemoMutation, emptyTrashMutation } = useWorkspaceMemoRemoval({
|
||||
activePane,
|
||||
clearMemoSelection,
|
||||
memos,
|
||||
queryClient,
|
||||
repository,
|
||||
selectedMemoId,
|
||||
setActivePane,
|
||||
setAppNoticeDialog,
|
||||
setEmptyTrashConfirmationOpen,
|
||||
setSelectedMemoId,
|
||||
t,
|
||||
});
|
||||
|
||||
const restoreMemoMutation = useMutation({
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMutation, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { MemoSummary } from "@edgeever/shared";
|
||||
import type { AppNoticeDialogState, Pane } from "@/lib/app-helpers";
|
||||
import type { EdgeEverRepository } from "@/lib/repository";
|
||||
import {
|
||||
applyMemoRemovalToCache,
|
||||
clearTrashMemoLists,
|
||||
getAdjacentMemoIdAfterRemoval,
|
||||
restoreMemoRemovalCache,
|
||||
restoreTrashClearCache,
|
||||
snapshotMemoRemovalCache,
|
||||
snapshotTrashClearCache,
|
||||
type MemoRemovalCacheSnapshot,
|
||||
type TrashClearCacheSnapshot,
|
||||
} from "@/lib/workspace-memo-cache";
|
||||
|
||||
type MemoDeleteOptimisticContext = MemoRemovalCacheSnapshot & {
|
||||
previousActivePane: Pane;
|
||||
previousSelectedMemoId: string | null;
|
||||
};
|
||||
|
||||
type EmptyTrashOptimisticContext = TrashClearCacheSnapshot & {
|
||||
previousActivePane: Pane;
|
||||
previousSelectedMemoId: string | null;
|
||||
};
|
||||
|
||||
type UseWorkspaceMemoRemovalOptions = {
|
||||
activePane: Pane;
|
||||
clearMemoSelection: () => void;
|
||||
memos: MemoSummary[];
|
||||
queryClient: QueryClient;
|
||||
repository: Pick<EdgeEverRepository, "deleteMemos" | "deleteMemo" | "emptyTrash">;
|
||||
selectedMemoId: string | null;
|
||||
setActivePane: Dispatch<SetStateAction<Pane>>;
|
||||
setAppNoticeDialog: Dispatch<SetStateAction<AppNoticeDialogState | null>>;
|
||||
setEmptyTrashConfirmationOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setSelectedMemoId: Dispatch<SetStateAction<string | null>>;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
export const useWorkspaceMemoRemoval = ({
|
||||
activePane,
|
||||
clearMemoSelection,
|
||||
memos,
|
||||
queryClient,
|
||||
repository,
|
||||
selectedMemoId,
|
||||
setActivePane,
|
||||
setAppNoticeDialog,
|
||||
setEmptyTrashConfirmationOpen,
|
||||
setSelectedMemoId,
|
||||
t,
|
||||
}: UseWorkspaceMemoRemovalOptions) => {
|
||||
const deleteMemosMutation = useMutation({
|
||||
mutationFn: repository.deleteMemos,
|
||||
onMutate: async (variables): Promise<MemoDeleteOptimisticContext> => {
|
||||
const deletedMemoIds = new Set(variables.memoIds);
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: ["memos"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["memo"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["notebooks"] }),
|
||||
]);
|
||||
|
||||
const cacheSnapshot = snapshotMemoRemovalCache(queryClient);
|
||||
|
||||
clearMemoSelection();
|
||||
|
||||
if (selectedMemoId && deletedMemoIds.has(selectedMemoId)) {
|
||||
setSelectedMemoId(getAdjacentMemoIdAfterRemoval(memos, deletedMemoIds, selectedMemoId));
|
||||
setActivePane("memos");
|
||||
}
|
||||
|
||||
applyMemoRemovalToCache(queryClient, deletedMemoIds);
|
||||
|
||||
return { ...cacheSnapshot, previousActivePane: activePane, previousSelectedMemoId: selectedMemoId };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context) restoreMemoRemovalCache(queryClient, context);
|
||||
setSelectedMemoId(context?.previousSelectedMemoId ?? null);
|
||||
setActivePane(context?.previousActivePane ?? "memos");
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const refetchType = _error ? "active" : "inactive";
|
||||
const deletedMemoIds = new Set(variables?.memoIds ?? []);
|
||||
|
||||
if (!_error) {
|
||||
for (const memoId of deletedMemoIds) {
|
||||
queryClient.removeQueries({ queryKey: ["memo", memoId] });
|
||||
}
|
||||
}
|
||||
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["memo"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: _error ? "active" : "all" }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMemoMutation = useMutation({
|
||||
mutationFn: async ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => {
|
||||
return repository.deleteMemo(memoId, Boolean(permanent));
|
||||
},
|
||||
onMutate: async (variables): Promise<MemoDeleteOptimisticContext> => {
|
||||
const deletedMemoIds = new Set([variables.memoId]);
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: ["memos"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["memo", variables.memoId] }),
|
||||
queryClient.cancelQueries({ queryKey: ["notebooks"] }),
|
||||
]);
|
||||
|
||||
const cacheSnapshot = snapshotMemoRemovalCache(queryClient, ["memo", variables.memoId]);
|
||||
|
||||
if (selectedMemoId === variables.memoId) {
|
||||
setSelectedMemoId(getAdjacentMemoIdAfterRemoval(memos, deletedMemoIds, variables.memoId));
|
||||
setActivePane("memos");
|
||||
}
|
||||
|
||||
applyMemoRemovalToCache(queryClient, deletedMemoIds);
|
||||
|
||||
return { ...cacheSnapshot, previousActivePane: activePane, previousSelectedMemoId: selectedMemoId };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context) restoreMemoRemovalCache(queryClient, context);
|
||||
setSelectedMemoId(context?.previousSelectedMemoId ?? null);
|
||||
setActivePane(context?.previousActivePane ?? "memos");
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const refetchType = _error ? "active" : "inactive";
|
||||
|
||||
if (!_error) {
|
||||
queryClient.removeQueries({ queryKey: ["memo", variables?.memoId] });
|
||||
}
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType }),
|
||||
queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: _error ? "active" : "all" }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const emptyTrashMutation = useMutation({
|
||||
mutationFn: repository.emptyTrash,
|
||||
onMutate: async (): Promise<EmptyTrashOptimisticContext> => {
|
||||
const previousActivePane = activePane;
|
||||
const previousSelectedMemoId = selectedMemoId;
|
||||
|
||||
// Leave the editor before cancelling/refetching its detail query. On a
|
||||
// desktop layout the editor remains mounted even when the memo pane is
|
||||
// visible, so keeping a trashed memo selected while its query is being
|
||||
// invalidated can render a deleted detail and repeatedly re-select it.
|
||||
setEmptyTrashConfirmationOpen(false);
|
||||
clearMemoSelection();
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: ["memos"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["memo"] }),
|
||||
queryClient.cancelQueries({ queryKey: ["resources"] }),
|
||||
]);
|
||||
|
||||
const cacheSnapshot = snapshotTrashClearCache(queryClient);
|
||||
|
||||
// Keep the optimistic update limited to list data. Removing the active
|
||||
// memo detail query here can make the editor render with a missing memo
|
||||
// during the same React update and blank the whole workspace.
|
||||
clearTrashMemoLists(queryClient);
|
||||
|
||||
return { ...cacheSnapshot, previousActivePane, previousSelectedMemoId };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context) restoreTrashClearCache(queryClient, context);
|
||||
setSelectedMemoId(context?.previousSelectedMemoId ?? null);
|
||||
setActivePane(context?.previousActivePane ?? "memos");
|
||||
setAppNoticeDialog({
|
||||
title: t("workspaceDialogs.emptyTrashFailedTitle"),
|
||||
description: t("workspaceDialogs.emptyTrashFailedDescription"),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// The trash detail queries are no longer valid after a successful
|
||||
// permanent delete. Remove them before invalidating the remaining
|
||||
// active queries so an editor cannot briefly observe a 404 detail.
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["memo"],
|
||||
predicate: (query) => {
|
||||
const data = query.state.data as { memo?: { isDeleted?: boolean } } | undefined;
|
||||
return data?.memo?.isDeleted === true;
|
||||
},
|
||||
});
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
},
|
||||
onSettled: () => {
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "active" }),
|
||||
queryClient.invalidateQueries({ queryKey: ["memo"], refetchType: "active" }),
|
||||
queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: "active" }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
return { deleteMemosMutation, deleteMemoMutation, emptyTrashMutation };
|
||||
};
|
||||
@@ -1,5 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { evictIdleMemoDetails } from "./workspace-memo-cache.ts";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
applyMemoRemovalToCache,
|
||||
clearTrashMemoLists,
|
||||
evictIdleMemoDetails,
|
||||
restoreMemoRemovalCache,
|
||||
restoreTrashClearCache,
|
||||
snapshotMemoRemovalCache,
|
||||
snapshotTrashClearCache,
|
||||
} from "./workspace-memo-cache.ts";
|
||||
|
||||
const memo = (id, notebookId, isDeleted = false) => ({
|
||||
id,
|
||||
notebookId,
|
||||
title: id,
|
||||
excerpt: "",
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
isDeleted,
|
||||
revision: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
deletedAt: isDeleted ? "2026-01-02T00:00:00.000Z" : null,
|
||||
});
|
||||
|
||||
describe("optimistic memo removal cache", () => {
|
||||
test("removes list entries and notebook counts, then restores the exact snapshot on failure", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const listKey = ["memos", "notebook", "nb-1"];
|
||||
const detailKey = ["memo", "memo-1", "notebook"];
|
||||
const list = {
|
||||
pages: [{ memos: [memo("memo-1", "nb-1"), memo("memo-2", "nb-1")], totalCount: 2, nextCursor: null }],
|
||||
pageParams: [null],
|
||||
};
|
||||
const detail = { memo: memo("memo-1", "nb-1") };
|
||||
const notebooks = { notebooks: [{ id: "nb-1", memoCount: 2 }] };
|
||||
queryClient.setQueryData(listKey, list);
|
||||
queryClient.setQueryData(detailKey, detail);
|
||||
queryClient.setQueryData(["notebooks"], notebooks);
|
||||
|
||||
const snapshot = snapshotMemoRemovalCache(queryClient, ["memo", "memo-1"]);
|
||||
applyMemoRemovalToCache(queryClient, new Set(["memo-1"]));
|
||||
|
||||
expect(queryClient.getQueryData(listKey).pages[0].memos.map((item) => item.id)).toEqual(["memo-2"]);
|
||||
expect(queryClient.getQueryData(["notebooks"]).notebooks[0].memoCount).toBe(1);
|
||||
expect(queryClient.getQueryData(detailKey)).toEqual(detail);
|
||||
|
||||
restoreMemoRemovalCache(queryClient, snapshot);
|
||||
expect(queryClient.getQueryData(listKey)).toEqual(list);
|
||||
expect(queryClient.getQueryData(detailKey)).toEqual(detail);
|
||||
expect(queryClient.getQueryData(["notebooks"])).toEqual(notebooks);
|
||||
});
|
||||
|
||||
test("does not decrement notebook counts for a memo already in trash", () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(["memos", "trash"], {
|
||||
pages: [{ memos: [memo("memo-1", "nb-1", true)], totalCount: 1, nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
queryClient.setQueryData(["notebooks"], { notebooks: [{ id: "nb-1", memoCount: 3 }] });
|
||||
|
||||
applyMemoRemovalToCache(queryClient, new Set(["memo-1"]));
|
||||
|
||||
expect(queryClient.getQueryData(["memos", "trash"]).pages[0].memos).toEqual([]);
|
||||
expect(queryClient.getQueryData(["notebooks"]).notebooks[0].memoCount).toBe(3);
|
||||
});
|
||||
|
||||
test("single-note rollback restores only that note's detail cache", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const firstKey = ["memo", "memo-1", "notebook"];
|
||||
const secondKey = ["memo", "memo-2", "notebook"];
|
||||
queryClient.setQueryData(firstKey, { memo: memo("memo-1", "nb-1") });
|
||||
queryClient.setQueryData(secondKey, { memo: memo("memo-2", "nb-1") });
|
||||
|
||||
const snapshot = snapshotMemoRemovalCache(queryClient, ["memo", "memo-1"]);
|
||||
queryClient.setQueryData(firstKey, { memo: { ...memo("memo-1", "nb-1"), title: "changed" } });
|
||||
queryClient.setQueryData(secondKey, { memo: { ...memo("memo-2", "nb-1"), title: "newer" } });
|
||||
restoreMemoRemovalCache(queryClient, snapshot);
|
||||
|
||||
expect(queryClient.getQueryData(firstKey).memo.title).toBe("memo-1");
|
||||
expect(queryClient.getQueryData(secondKey).memo.title).toBe("newer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimistic Trash clearing cache", () => {
|
||||
test("clears only Trash lists, retains the open detail, and restores both on failure", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const trashKey = ["memos", "trash"];
|
||||
const activeKey = ["memos", "notebook"];
|
||||
const detailKey = ["memo", "memo-1", "trash"];
|
||||
const trash = {
|
||||
pages: [{ memos: [memo("memo-1", "nb-1", true)], totalCount: 1, nextCursor: "cursor" }],
|
||||
pageParams: [null],
|
||||
};
|
||||
const active = {
|
||||
pages: [{ memos: [memo("memo-2", "nb-1")], totalCount: 1, nextCursor: null }],
|
||||
pageParams: [null],
|
||||
};
|
||||
const detail = { memo: memo("memo-1", "nb-1", true) };
|
||||
queryClient.setQueryData(trashKey, trash);
|
||||
queryClient.setQueryData(activeKey, active);
|
||||
queryClient.setQueryData(detailKey, detail);
|
||||
|
||||
const snapshot = snapshotTrashClearCache(queryClient);
|
||||
clearTrashMemoLists(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(trashKey).pages[0]).toMatchObject({ memos: [], totalCount: 0, nextCursor: null });
|
||||
expect(queryClient.getQueryData(activeKey)).toEqual(active);
|
||||
expect(queryClient.getQueryData(detailKey)).toEqual(detail);
|
||||
|
||||
restoreTrashClearCache(queryClient, snapshot);
|
||||
expect(queryClient.getQueryData(trashKey)).toEqual(trash);
|
||||
expect(queryClient.getQueryData(activeKey)).toEqual(active);
|
||||
expect(queryClient.getQueryData(detailKey)).toEqual(detail);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idle memo detail eviction", () => {
|
||||
test("drops cached memo bodies except the open note", () => {
|
||||
|
||||
@@ -17,6 +17,14 @@ export type ListNotebooksQueryData = {
|
||||
notebooks: Notebook[];
|
||||
};
|
||||
|
||||
export type MemoRemovalCacheSnapshot = {
|
||||
previousMemoLists: Array<[readonly unknown[], MemoListQueryData | undefined]>;
|
||||
previousMemoDetails: Array<[readonly unknown[], { memo: MemoDetail } | undefined]>;
|
||||
previousNotebooks: ListNotebooksQueryData | undefined;
|
||||
};
|
||||
|
||||
export type TrashClearCacheSnapshot = Pick<MemoRemovalCacheSnapshot, "previousMemoLists" | "previousMemoDetails">;
|
||||
|
||||
export const memoDetailQueryKey = (memoId: string, view: MemoView) => ["memo", memoId, view] as const;
|
||||
|
||||
export const memoToSummary = (memo: MemoDetail): MemoSummary => ({
|
||||
@@ -111,6 +119,20 @@ export const clearTrashMemoLists = (queryClient: QueryClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const snapshotTrashClearCache = (queryClient: QueryClient): TrashClearCacheSnapshot => ({
|
||||
previousMemoLists: queryClient.getQueriesData<MemoListQueryData>({ queryKey: ["memos"] }),
|
||||
previousMemoDetails: queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }),
|
||||
});
|
||||
|
||||
export const restoreTrashClearCache = (queryClient: QueryClient, snapshot: TrashClearCacheSnapshot) => {
|
||||
for (const [queryKey, data] of snapshot.previousMemoLists) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
for (const [queryKey, data] of snapshot.previousMemoDetails) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
};
|
||||
|
||||
export const decrementNotebookMemoCounts = (queryClient: QueryClient, removedMemos: MemoSummary[]) => {
|
||||
if (removedMemos.length === 0) {
|
||||
return;
|
||||
@@ -142,6 +164,31 @@ export const decrementNotebookMemoCounts = (queryClient: QueryClient, removedMem
|
||||
);
|
||||
};
|
||||
|
||||
export const snapshotMemoRemovalCache = (
|
||||
queryClient: QueryClient,
|
||||
detailQueryKey: readonly unknown[] = ["memo"],
|
||||
): MemoRemovalCacheSnapshot => ({
|
||||
previousMemoLists: queryClient.getQueriesData<MemoListQueryData>({ queryKey: ["memos"] }),
|
||||
previousMemoDetails: queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: detailQueryKey }),
|
||||
previousNotebooks: queryClient.getQueryData<ListNotebooksQueryData>(["notebooks"]),
|
||||
});
|
||||
|
||||
export const applyMemoRemovalToCache = (queryClient: QueryClient, memoIds: Set<string>) => {
|
||||
const removedMemos = collectMemoSummariesFromCache(queryClient, memoIds);
|
||||
removeMemoSummariesFromLists(queryClient, memoIds);
|
||||
decrementNotebookMemoCounts(queryClient, removedMemos);
|
||||
};
|
||||
|
||||
export const restoreMemoRemovalCache = (queryClient: QueryClient, snapshot: MemoRemovalCacheSnapshot) => {
|
||||
for (const [queryKey, data] of snapshot.previousMemoLists) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
for (const [queryKey, data] of snapshot.previousMemoDetails) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
queryClient.setQueryData(["notebooks"], snapshot.previousNotebooks);
|
||||
};
|
||||
|
||||
export const getAdjacentMemoIdAfterRemoval = (memos: MemoSummary[], removedMemoIds: Set<string>, anchorMemoId: string) => {
|
||||
const anchorIndex = memos.findIndex((memo) => memo.id === anchorMemoId);
|
||||
|
||||
|
||||
Reference in new issue
Block a user