/** * Pure helpers for the shim-owned write_neo4j_cypher path. * Extracted from index.ts so the executeWrite tx body, response synthesis, * and value serialization can be unit-tested without the shim's top-level * process bootstrap (which spawns uvx and opens stdin/stdout pipes). * * The shim's index.ts wires these up: rewriter → shared driver → session. * executeWrite(tx => runWriteTxBody(...)) → response synth → stdout. */ export const ORPHAN_CHECK_CYPHER = "MATCH (n) WHERE n.createdBySession = $__autoSession " + "AND n.createdAt >= $__autoStartTimestamp " + "AND NOT (n)--() " + "RETURN collect(elementId(n)) AS orphanIds, " + "collect(distinct labels(n)) AS labelSets"; export interface GraphTx { run( cypher: string, params?: Record, ): Promise<{ records: Array<{ keys: readonly string[]; get: (k: string | number) => unknown; }>; summary: { counters: { updates(): { nodesCreated: number; relationshipsCreated: number; propertiesSet: number; }; }; }; }>; } export interface GraphSession { executeWrite(work: (tx: GraphTx) => Promise): Promise; close(): Promise; } export interface GraphDriver { session(): GraphSession; } export class OrphanRollbackError extends Error { constructor( public readonly orphanIds: string[], public readonly sampleLabels: string[], ) { super(`orphan rollback: ${orphanIds.length} unattached node(s)`); } } export interface AutoContext { agent: string; session: string; } export interface WriteTxOutcome { nodesCreated: number; relsCreated: number; propertiesSet: number; serializedRecords: Array>; } export function serializeValue(v: unknown): unknown { if (v === null || v === undefined) return v; if (typeof v === "string" || typeof v === "boolean" || typeof v === "number") { return v; } if (Array.isArray(v)) return v.map(serializeValue); if (typeof v === "object") { const obj = v as Record; if ( "low" in obj && "high" in obj && typeof obj.low === "number" && typeof obj.high === "number" && Object.keys(obj).length === 2 ) { if (obj.high === 0 || obj.high === -1) return obj.low; return String(obj); } if ("properties" in obj && "labels" in obj) { return { labels: obj.labels, elementId: obj.elementId, properties: serializeValue(obj.properties), }; } if ("properties" in obj && "type" in obj && "elementId" in obj) { return { type: obj.type, elementId: obj.elementId, properties: serializeValue(obj.properties), }; } if ( obj.constructor && obj.constructor.name && obj.constructor.name !== "Object" && obj.constructor.name !== "Array" ) { return String(obj); } const out: Record = {}; for (const [k, val] of Object.entries(obj)) { out[k] = serializeValue(val); } return out; } return v; } /** * Body of the executeWrite tx. Runs the rewritten cypher, captures counters, * runs the orphan check, throws OrphanRollbackError if any orphans remain * (executeWrite catches the throw and rolls back the entire tx). * * Caller is responsible for: (a) opening the session, (b) wrapping this in * `session.executeWrite(tx => runWriteTxBody(tx, ...))`, (c) calling close() * in finally, (d) translating the outcome / OrphanRollbackError into the * synthesised MCP response and audit log lines. */ export async function runWriteTxBody( tx: GraphTx, rewrittenCypher: string, operatorParams: Record, autoCtx: AutoContext, ): Promise { const tsRes = await tx.run("RETURN datetime() AS now"); const startTs = tsRes.records[0].get("now"); const merged: Record = { ...operatorParams, __autoStartTimestamp: startTs, __autoAgent: autoCtx.agent, __autoSession: autoCtx.session, }; const writeRes = await tx.run(rewrittenCypher, merged); const counters = writeRes.summary.counters.updates(); const nodesCreated = counters.nodesCreated; const relsCreated = counters.relationshipsCreated; const propertiesSet = counters.propertiesSet; const keys = writeRes.records.length > 0 ? writeRes.records[0].keys.map(String) : []; const serializedRecords = writeRes.records.map((rec) => { const out: Record = {}; for (const k of keys) { out[k] = serializeValue(rec.get(k)); } return out; }); const orphanRes = await tx.run(ORPHAN_CHECK_CYPHER, merged); const orphanIds = (orphanRes.records[0].get("orphanIds") as string[]) ?? []; const labelSets = (orphanRes.records[0].get("labelSets") as string[][]) ?? []; if (orphanIds.length > 0) { const flatLabels = Array.from(new Set(labelSets.flat())); throw new OrphanRollbackError(orphanIds, flatLabels); } return { nodesCreated, relsCreated, propertiesSet, serializedRecords }; } export interface WriteSynthInput { nodesCreated: number; relsCreated: number; propertiesSet: number; records: Array>; } export function synthesiseWriteResponse( id: string | number, w: WriteSynthInput, ): string { const parts: string[] = []; if (w.records.length > 0) { parts.push( `${w.records.length} record${w.records.length === 1 ? "" : "s"} returned`, ); } parts.push(`${w.nodesCreated} nodes created`); parts.push(`${w.relsCreated} relationships created`); if (w.propertiesSet > 0) parts.push(`${w.propertiesSet} properties set`); if (w.records.length > 0) { parts.push(""); parts.push(JSON.stringify(w.records, null, 2)); } return JSON.stringify({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: parts.join("\n") }], }, }); } export function synthesiseOrphanRollback( id: string | number, orphanCount: number, sampleLabels: string[], ): string { const labelLine = sampleLabels.length > 0 ? sampleLabels.slice(0, 5).join(", ") : "(unknown — orphans had no labels)"; const text = `orphan rollback — ${orphanCount} node(s) created without an edge in the same transaction.\n` + `Sample labels: ${labelLine}.\n\n` + `The transaction was aborted; nothing was persisted. Anchor each new node to its semantic parent in the same statement (Graph Stewardship Doctrine Rule 1 in database-operator.md).`; return JSON.stringify({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], isError: true, }, }); } export function synthesiseShimError( id: string | number, prefix: string, errMsg: string, ): string { return JSON.stringify({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: `${prefix}: ${errMsg}` }], isError: true, }, }); }