Refactor workspace memo removal and MCP tool helpers

This commit is contained in:
tianma-if committed 2026-10-02 18:58:52 +08:00
1 parent 633aa16784
commit d963ff76e2
7 files changed
+1084 -858

No files matched your search

+431
View File
@@ -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 };
};
+252
View File
@@ -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,
};
};
+15 -660
View File
@@ -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,
+14 -197
View File
@@ -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 };
};
+117 -1
View File
@@ -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", () => {
+47
View File
@@ -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);