diff --git a/apps/api/src/mcp-diagram-tools.ts b/apps/api/src/mcp-diagram-tools.ts new file mode 100644 index 00000000..5fbdfea3 --- /dev/null +++ b/apps/api/src/mcp-diagram-tools.ts @@ -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([ + "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 => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const assertAllowedKeys = (value: Record, allowed: Set, 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[number]; +}; + +export const parseDiagramMemoIr = (args: Record): 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(); + 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["edges"][number]; + +export const applyDiagramOperations = async ( + args: Record, + 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(); + const repositionedNodeIds = new Set(); + const affectedBoundaryIds = new Set(); + 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 = { ...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 = { ...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 }; +}; diff --git a/apps/api/src/mcp-table-tools.ts b/apps/api/src/mcp-table-tools.ts new file mode 100644 index 00000000..faaf00ea --- /dev/null +++ b/apps/api/src/mcp-table-tools.ts @@ -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; + const normalized: Record = {}; + 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 => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AppError("invalid_params", `${label} must be an object`, 400); + } + return value as Record; +}; + +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> => { + 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, + }; +}; diff --git a/apps/api/src/mcp-tool-service.ts b/apps/api/src/mcp-tool-service.ts index 124e70aa..02cfd421 100644 --- a/apps/api/src/mcp-tool-service.ts +++ b/apps/api/src/mcp-tool-service.ts @@ -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([ - "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 => - Boolean(value) && typeof value === "object" && !Array.isArray(value); - -const assertAllowedKeys = (value: Record, allowed: Set, 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[number]; -}; - -const parseDiagramMemoIr = (args: Record): 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(); - 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; - const normalized: Record = {}; - 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 => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new AppError("invalid_params", `${label} must be an object`, 400); - } - return value as Record; -}; - -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> => { - 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["edges"][number]; - -const applyDiagramOperations = async ( - args: Record, - 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(); - const repositionedNodeIds = new Set(); - const affectedBoundaryIds = new Set(); - 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 = { ...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 = { ...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, diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 97d030e4..8aca7455 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -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 }) => ( ); -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 => { - 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({ queryKey: ["memos"] }); - const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); - const previousNotebooks = queryClient.getQueryData(["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 => { - 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({ queryKey: ["memos"] }); - const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo", variables.memoId] }); - const previousNotebooks = queryClient.getQueryData(["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 => { - 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({ 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({ diff --git a/apps/web/src/hooks/useWorkspaceMemoRemoval.ts b/apps/web/src/hooks/useWorkspaceMemoRemoval.ts new file mode 100644 index 00000000..5bee3bfe --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceMemoRemoval.ts @@ -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; + selectedMemoId: string | null; + setActivePane: Dispatch>; + setAppNoticeDialog: Dispatch>; + setEmptyTrashConfirmationOpen: Dispatch>; + setSelectedMemoId: Dispatch>; + 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 => { + 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 => { + 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 => { + 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 }; +}; diff --git a/apps/web/src/lib/workspace-memo-cache.test.mjs b/apps/web/src/lib/workspace-memo-cache.test.mjs index 5a4b2fdb..0f7d8c63 100644 --- a/apps/web/src/lib/workspace-memo-cache.test.mjs +++ b/apps/web/src/lib/workspace-memo-cache.test.mjs @@ -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", () => { diff --git a/apps/web/src/lib/workspace-memo-cache.ts b/apps/web/src/lib/workspace-memo-cache.ts index c8074f57..876b9810 100644 --- a/apps/web/src/lib/workspace-memo-cache.ts +++ b/apps/web/src/lib/workspace-memo-cache.ts @@ -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; + 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({ 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({ queryKey: ["memos"] }), + previousMemoDetails: queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: detailQueryKey }), + previousNotebooks: queryClient.getQueryData(["notebooks"]), +}); + +export const applyMemoRemovalToCache = (queryClient: QueryClient, memoIds: Set) => { + 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, anchorMemoId: string) => { const anchorIndex = memos.findIndex((memo) => memo.id === anchorMemoId);