@rubytech/create-maxy 1.0.744 → 1.0.745

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts +37 -0
  3. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js +333 -0
  5. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js.map +1 -0
  6. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts +71 -0
  7. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts.map +1 -0
  8. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js +168 -0
  9. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js.map +1 -0
  10. package/payload/platform/lib/graph-mcp/dist/index.js +146 -3
  11. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +3 -6
  13. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
  14. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +30 -7
  15. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
  16. package/payload/platform/lib/graph-mcp/src/cypher-rewrite-stamp.ts +349 -0
  17. package/payload/platform/lib/graph-mcp/src/cypher-shim-write.ts +240 -0
  18. package/payload/platform/lib/graph-mcp/src/index.ts +216 -4
  19. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +37 -7
  20. package/payload/platform/plugins/docs/references/deployment.md +2 -1
  21. package/payload/platform/templates/specialists/agents/database-operator.md +3 -22
  22. package/payload/server/chunk-SPTD7L7Z.js +9474 -0
  23. package/payload/server/maxy-edge.js +1 -1
  24. package/payload/server/public/assets/{graph-DhNy70eS.js → graph-BoSJpLG3.js} +1 -1
  25. package/payload/server/public/graph.html +1 -1
  26. package/payload/server/server.js +1 -1
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Cypher rewrite pass that injects provenance stamps into operator-supplied
3
+ * write_neo4j_cypher (Task 797). Sibling to cypher-validate.ts: validate first,
4
+ * rewrite second, run third.
5
+ *
6
+ * Doctrine context: Task 673 wrapped writers stamp every CREATE node
7
+ * structurally via writeNodeWithEdges; raw cypher (Task 796) relied on
8
+ * prompt-internalised discipline. This module closes the asymmetry — the
9
+ * rewriter appends `SET <alias>.createdAt = $__autoStartTimestamp,
10
+ * <alias>.createdByAgent = $__autoAgent, <alias>.createdByTool =
11
+ * 'graph-cypher-write', <alias>.createdBySession = $__autoSession` to every
12
+ * CREATE/MERGE clause that introduces a new labeled node. Caller supplies
13
+ * the three `$__auto*` params at tx.run time.
14
+ *
15
+ * MERGE handling preserves the create-vs-match distinction. CREATE always
16
+ * stamps (every CREATE introduces a new node by definition). MERGE injects
17
+ * `ON CREATE SET` so an existing-node match path keeps the original writer's
18
+ * provenance intact — overwriting it would destroy forensic attribution.
19
+ *
20
+ * Node-only stamping. Edge aliases (`[r:R]`) are not auto-stamped — matches
21
+ * the wrapped writer's writeNodeWithEdges, which stamps the node only.
22
+ *
23
+ * Idempotent: re-applying the rewriter to its own output is a no-op. The
24
+ * marker that proves "already stamped" is `<alias>.createdBySession =
25
+ * $__autoSession` — the unique combination of property name + auto param.
26
+ *
27
+ * String literals and comments are redacted (chars replaced with spaces,
28
+ * offsets preserved) so a `CREATE (fake:Foo)` inside a comment or quoted
29
+ * string cannot mistakenly trip the rewriter.
30
+ */
31
+
32
+ export interface RewriteResult {
33
+ cypher: string;
34
+ /** Number of node aliases that received a fresh stamp injection. */
35
+ stampsAppended: number;
36
+ }
37
+
38
+ const AUTO_PARAM_AGENT = "$__autoAgent";
39
+ const AUTO_PARAM_SESSION = "$__autoSession";
40
+ const AUTO_PARAM_TIMESTAMP = "$__autoStartTimestamp";
41
+ const AUTO_TOOL_LITERAL = "'graph-cypher-write'";
42
+
43
+ const KEYWORD_TERMINATORS = new Set([
44
+ "SET",
45
+ "ON",
46
+ "MATCH",
47
+ "MERGE",
48
+ "CREATE",
49
+ "RETURN",
50
+ "WITH",
51
+ "WHERE",
52
+ "REMOVE",
53
+ "DELETE",
54
+ "DETACH",
55
+ "UNION",
56
+ "ORDER",
57
+ "LIMIT",
58
+ "SKIP",
59
+ "FOREACH",
60
+ "UNWIND",
61
+ "CALL",
62
+ "USE",
63
+ "LOAD",
64
+ "YIELD",
65
+ ]);
66
+
67
+ function buildRedacted(cypher: string): string {
68
+ let out = "";
69
+ let i = 0;
70
+ while (i < cypher.length) {
71
+ const c = cypher[i];
72
+ if (c === "/" && cypher[i + 1] === "/") {
73
+ while (i < cypher.length && cypher[i] !== "\n") {
74
+ out += " ";
75
+ i++;
76
+ }
77
+ continue;
78
+ }
79
+ if (c === "/" && cypher[i + 1] === "*") {
80
+ out += " ";
81
+ i += 2;
82
+ while (i < cypher.length && !(cypher[i] === "*" && cypher[i + 1] === "/")) {
83
+ out += " ";
84
+ i++;
85
+ }
86
+ if (i < cypher.length) {
87
+ out += " ";
88
+ i += 2;
89
+ }
90
+ continue;
91
+ }
92
+ if (c === "'" || c === '"') {
93
+ const quote = c;
94
+ out += c;
95
+ i++;
96
+ while (i < cypher.length && cypher[i] !== quote) {
97
+ if (cypher[i] === "\\" && i + 1 < cypher.length) {
98
+ out += " ";
99
+ i += 2;
100
+ continue;
101
+ }
102
+ out += " ";
103
+ i++;
104
+ }
105
+ if (i < cypher.length) {
106
+ out += cypher[i];
107
+ i++;
108
+ }
109
+ continue;
110
+ }
111
+ out += c;
112
+ i++;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function findKeywordHits(
118
+ redacted: string,
119
+ ): Array<{ kw: "CREATE" | "MERGE"; afterKw: number }> {
120
+ const re = /\b(CREATE|MERGE)\b/gi;
121
+ const results: Array<{ kw: "CREATE" | "MERGE"; afterKw: number }> = [];
122
+ for (const m of redacted.matchAll(re)) {
123
+ if (m.index === undefined) continue;
124
+ const upper = m[1].toUpperCase();
125
+ // Skip `CREATE` that's part of `ON CREATE SET` (a MERGE sub-clause, not
126
+ // an introducing CREATE). Without this, the auto-stamp's own injected
127
+ // `ON CREATE SET` would register as a separate CREATE hit on the next
128
+ // rewriter pass, breaking idempotency by truncating the stamp window.
129
+ if (upper === "CREATE") {
130
+ const prefix = redacted.slice(Math.max(0, m.index - 8), m.index);
131
+ if (/\bON\s+$/i.test(prefix)) continue;
132
+ }
133
+ results.push({
134
+ kw: upper as "CREATE" | "MERGE",
135
+ afterKw: m.index + m[0].length,
136
+ });
137
+ }
138
+ return results;
139
+ }
140
+
141
+ const TERMINATOR_PROBE = /^(\s*)(?:;|([A-Za-z]+))/;
142
+
143
+ /**
144
+ * Returns true iff position `i` in `redacted` is at a word boundary — i.e.,
145
+ * the immediately preceding character is not part of an identifier (letters,
146
+ * digits, underscore). Without this guard, TERMINATOR_PROBE matches the
147
+ * trailing letters of a multi-char identifier as a keyword (e.g. `Session`
148
+ * ending in `on` would match the `ON` keyword and truncate the scan
149
+ * mid-identifier).
150
+ */
151
+ function atWordBoundary(redacted: string, i: number): boolean {
152
+ if (i === 0) return true;
153
+ return !/[A-Za-z0-9_]/.test(redacted[i - 1]);
154
+ }
155
+
156
+ /**
157
+ * Returns the index just past the closing `)` of the CREATE/MERGE pattern,
158
+ * or null if no node pattern follows the keyword (e.g. CREATE INDEX, CREATE
159
+ * CONSTRAINT — neither would have reached the rewriter, but defensive).
160
+ */
161
+ function findPatternEnd(redacted: string, afterKw: number): number | null {
162
+ let i = afterKw;
163
+ while (i < redacted.length && /\s/.test(redacted[i])) i++;
164
+ if (i >= redacted.length || redacted[i] !== "(") return null;
165
+ let parenDepth = 0;
166
+ let bracketDepth = 0;
167
+ while (i < redacted.length) {
168
+ if (parenDepth === 0 && bracketDepth === 0 && i > afterKw) {
169
+ const tmatch = redacted.slice(i).match(TERMINATOR_PROBE);
170
+ if (tmatch) {
171
+ const wsLen = tmatch[1].length;
172
+ const ch = redacted[i + wsLen];
173
+ if (ch === ";") return i + wsLen;
174
+ const word = tmatch[2];
175
+ if (
176
+ word &&
177
+ KEYWORD_TERMINATORS.has(word.toUpperCase()) &&
178
+ atWordBoundary(redacted, i + wsLen)
179
+ ) {
180
+ return i + wsLen;
181
+ }
182
+ }
183
+ }
184
+ const c = redacted[i];
185
+ if (c === "(") parenDepth++;
186
+ else if (c === ")") parenDepth--;
187
+ else if (c === "[") bracketDepth++;
188
+ else if (c === "]") bracketDepth--;
189
+ i++;
190
+ }
191
+ return i;
192
+ }
193
+
194
+ function extractLabeledAliases(redactedSlice: string): string[] {
195
+ const out: string[] = [];
196
+ const seen = new Set<string>();
197
+ const re = /\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[A-Z]/g;
198
+ for (const m of redactedSlice.matchAll(re)) {
199
+ const alias = m[1];
200
+ if (!seen.has(alias)) {
201
+ seen.add(alias);
202
+ out.push(alias);
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function aliasAlreadyStamped(slice: string, alias: string): boolean {
209
+ // Per-pattern scope (review F1). The `slice` covers this CREATE/MERGE
210
+ // pattern's possible SET / ON CREATE SET clauses up to the next CREATE/
211
+ // MERGE boundary — scoping the marker check globally would mis-classify
212
+ // a re-introduction of the same alias name across two distinct patterns
213
+ // as "already stamped" (the first pattern's stamp would shadow the second).
214
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
+ const marker = new RegExp(
216
+ `\\b${escaped}\\.createdBySession\\s*=\\s*\\$__autoSession\\b`,
217
+ );
218
+ return marker.test(slice);
219
+ }
220
+
221
+ function buildAssignments(aliases: string[]): string {
222
+ return aliases
223
+ .flatMap((a) => [
224
+ `${a}.createdAt = ${AUTO_PARAM_TIMESTAMP}`,
225
+ `${a}.createdByAgent = ${AUTO_PARAM_AGENT}`,
226
+ `${a}.createdByTool = ${AUTO_TOOL_LITERAL}`,
227
+ `${a}.createdBySession = ${AUTO_PARAM_SESSION}`,
228
+ ])
229
+ .join(", ");
230
+ }
231
+
232
+ const ON_CREATE_SET_PROBE = /^ON\s+CREATE\s+SET\s+/i;
233
+ const ON_MATCH_SET_PROBE = /^ON\s+MATCH\s+SET\b/i;
234
+
235
+ function findOnCreateSetEnd(
236
+ redacted: string,
237
+ fromIdx: number,
238
+ ): { end: number } | null {
239
+ let i = fromIdx;
240
+ while (i < redacted.length && /\s/.test(redacted[i])) i++;
241
+ const m = redacted.slice(i).match(ON_CREATE_SET_PROBE);
242
+ if (!m) return null;
243
+ let j = i + m[0].length;
244
+ let parenDepth = 0;
245
+ let bracketDepth = 0;
246
+ while (j < redacted.length) {
247
+ if (parenDepth === 0 && bracketDepth === 0) {
248
+ const tmatch = redacted.slice(j).match(TERMINATOR_PROBE);
249
+ if (tmatch) {
250
+ const wsLen = tmatch[1].length;
251
+ const ch = redacted[j + wsLen];
252
+ if (ch === ";") return { end: j + wsLen };
253
+ const word = tmatch[2];
254
+ if (
255
+ word &&
256
+ KEYWORD_TERMINATORS.has(word.toUpperCase()) &&
257
+ atWordBoundary(redacted, j + wsLen)
258
+ ) {
259
+ return { end: j + wsLen };
260
+ }
261
+ }
262
+ }
263
+ const c = redacted[j];
264
+ if (c === "(") parenDepth++;
265
+ else if (c === ")") parenDepth--;
266
+ else if (c === "[") bracketDepth++;
267
+ else if (c === "]") bracketDepth--;
268
+ j++;
269
+ }
270
+ return { end: j };
271
+ }
272
+
273
+ function findOnMatchSetStart(redacted: string, fromIdx: number): number | null {
274
+ let i = fromIdx;
275
+ while (i < redacted.length && /\s/.test(redacted[i])) i++;
276
+ if (ON_MATCH_SET_PROBE.test(redacted.slice(i))) {
277
+ return i;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ export function rewriteWithProvenanceStamps(cypher: string): RewriteResult {
283
+ const redacted = buildRedacted(cypher);
284
+ const hits = findKeywordHits(redacted);
285
+
286
+ type Edit = { at: number; insert: string };
287
+ const edits: Edit[] = [];
288
+ let stampsAppended = 0;
289
+
290
+ for (let h = 0; h < hits.length; h++) {
291
+ const { kw, afterKw } = hits[h];
292
+ const patternEnd = findPatternEnd(redacted, afterKw);
293
+ if (patternEnd === null) continue;
294
+
295
+ let pStart = afterKw;
296
+ while (pStart < redacted.length && /\s/.test(redacted[pStart])) pStart++;
297
+
298
+ const patternSlice = redacted.slice(pStart, patternEnd);
299
+ const labeled = extractLabeledAliases(patternSlice);
300
+ // Per-pattern stamp window: from this pattern's start through the next
301
+ // CREATE/MERGE boundary (or end of cypher). Scoping the "already stamped"
302
+ // check globally would mis-classify a re-introduction of the same alias
303
+ // name across two distinct patterns (review F1).
304
+ const nextHitAfterKw =
305
+ h + 1 < hits.length ? hits[h + 1].afterKw : redacted.length;
306
+ const stampWindow = redacted.slice(pStart, nextHitAfterKw);
307
+ const aliases = labeled.filter((a) => !aliasAlreadyStamped(stampWindow, a));
308
+ if (aliases.length === 0) continue;
309
+
310
+ if (kw === "CREATE") {
311
+ // Trailing space matters: when patternEnd lands at a clause keyword
312
+ // (e.g. ` WITH` immediately after `(n:Person)`), our `findPatternEnd`
313
+ // returns the keyword's position. Without a trailing space, the
314
+ // injected `$__autoSession` runs into the keyword and Cypher parses
315
+ // `$__autoSessionWITH` as one parameter name.
316
+ edits.push({
317
+ at: patternEnd,
318
+ insert: ` SET ${buildAssignments(aliases)} `,
319
+ });
320
+ stampsAppended += aliases.length;
321
+ continue;
322
+ }
323
+
324
+ const existingOnCreate = findOnCreateSetEnd(redacted, patternEnd);
325
+ if (existingOnCreate) {
326
+ edits.push({
327
+ at: existingOnCreate.end,
328
+ insert: `, ${buildAssignments(aliases)} `,
329
+ });
330
+ stampsAppended += aliases.length;
331
+ continue;
332
+ }
333
+ const onMatchStart = findOnMatchSetStart(redacted, patternEnd);
334
+ const insertAt = onMatchStart ?? patternEnd;
335
+ edits.push({
336
+ at: insertAt,
337
+ insert: ` ON CREATE SET ${buildAssignments(aliases)} `,
338
+ });
339
+ stampsAppended += aliases.length;
340
+ }
341
+
342
+ edits.sort((a, b) => b.at - a.at);
343
+ let result = cypher;
344
+ for (const edit of edits) {
345
+ result = result.slice(0, edit.at) + edit.insert + result.slice(edit.at);
346
+ }
347
+
348
+ return { cypher: result, stampsAppended };
349
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Pure helpers for the shim-owned write_neo4j_cypher path (Task 797).
3
+ * Extracted from index.ts so the executeWrite tx body, response synthesis,
4
+ * and value serialization can be unit-tested without the shim's top-level
5
+ * process bootstrap (which spawns uvx and opens stdin/stdout pipes).
6
+ *
7
+ * The shim's index.ts wires these up: rewriter → shared driver → session.
8
+ * executeWrite(tx => runWriteTxBody(...)) → response synth → stdout.
9
+ */
10
+
11
+ export const ORPHAN_CHECK_CYPHER =
12
+ "MATCH (n) WHERE n.createdBySession = $__autoSession " +
13
+ "AND n.createdAt >= $__autoStartTimestamp " +
14
+ "AND NOT (n)--() " +
15
+ "RETURN collect(elementId(n)) AS orphanIds, " +
16
+ "collect(distinct labels(n)) AS labelSets";
17
+
18
+ export interface GraphTx {
19
+ run(
20
+ cypher: string,
21
+ params?: Record<string, unknown>,
22
+ ): Promise<{
23
+ records: Array<{
24
+ keys: readonly string[];
25
+ get: (k: string | number) => unknown;
26
+ }>;
27
+ summary: {
28
+ counters: {
29
+ updates(): {
30
+ nodesCreated: number;
31
+ relationshipsCreated: number;
32
+ propertiesSet: number;
33
+ };
34
+ };
35
+ };
36
+ }>;
37
+ }
38
+
39
+ export interface GraphSession {
40
+ executeWrite<T>(work: (tx: GraphTx) => Promise<T>): Promise<T>;
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ export interface GraphDriver {
45
+ session(): GraphSession;
46
+ }
47
+
48
+ export class OrphanRollbackError extends Error {
49
+ constructor(
50
+ public readonly orphanIds: string[],
51
+ public readonly sampleLabels: string[],
52
+ ) {
53
+ super(`orphan rollback: ${orphanIds.length} unattached node(s)`);
54
+ }
55
+ }
56
+
57
+ export interface AutoContext {
58
+ agent: string;
59
+ session: string;
60
+ }
61
+
62
+ export interface WriteTxOutcome {
63
+ nodesCreated: number;
64
+ relsCreated: number;
65
+ propertiesSet: number;
66
+ serializedRecords: Array<Record<string, unknown>>;
67
+ }
68
+
69
+ export function serializeValue(v: unknown): unknown {
70
+ if (v === null || v === undefined) return v;
71
+ if (typeof v === "string" || typeof v === "boolean" || typeof v === "number") {
72
+ return v;
73
+ }
74
+ if (Array.isArray(v)) return v.map(serializeValue);
75
+ if (typeof v === "object") {
76
+ const obj = v as Record<string, unknown>;
77
+ if (
78
+ "low" in obj &&
79
+ "high" in obj &&
80
+ typeof obj.low === "number" &&
81
+ typeof obj.high === "number" &&
82
+ Object.keys(obj).length === 2
83
+ ) {
84
+ if (obj.high === 0 || obj.high === -1) return obj.low;
85
+ return String(obj);
86
+ }
87
+ if ("properties" in obj && "labels" in obj) {
88
+ return {
89
+ labels: obj.labels,
90
+ elementId: obj.elementId,
91
+ properties: serializeValue(obj.properties),
92
+ };
93
+ }
94
+ if ("properties" in obj && "type" in obj && "elementId" in obj) {
95
+ return {
96
+ type: obj.type,
97
+ elementId: obj.elementId,
98
+ properties: serializeValue(obj.properties),
99
+ };
100
+ }
101
+ if (
102
+ obj.constructor &&
103
+ obj.constructor.name &&
104
+ obj.constructor.name !== "Object" &&
105
+ obj.constructor.name !== "Array"
106
+ ) {
107
+ return String(obj);
108
+ }
109
+ const out: Record<string, unknown> = {};
110
+ for (const [k, val] of Object.entries(obj)) {
111
+ out[k] = serializeValue(val);
112
+ }
113
+ return out;
114
+ }
115
+ return v;
116
+ }
117
+
118
+ /**
119
+ * Body of the executeWrite tx. Runs the rewritten cypher, captures counters,
120
+ * runs the orphan check, throws OrphanRollbackError if any orphans remain
121
+ * (executeWrite catches the throw and rolls back the entire tx).
122
+ *
123
+ * Caller is responsible for: (a) opening the session, (b) wrapping this in
124
+ * `session.executeWrite(tx => runWriteTxBody(tx, ...))`, (c) calling close()
125
+ * in finally, (d) translating the outcome / OrphanRollbackError into the
126
+ * synthesised MCP response and audit log lines.
127
+ */
128
+ export async function runWriteTxBody(
129
+ tx: GraphTx,
130
+ rewrittenCypher: string,
131
+ operatorParams: Record<string, unknown>,
132
+ autoCtx: AutoContext,
133
+ ): Promise<WriteTxOutcome> {
134
+ const tsRes = await tx.run("RETURN datetime() AS now");
135
+ const startTs = tsRes.records[0].get("now");
136
+
137
+ const merged: Record<string, unknown> = {
138
+ ...operatorParams,
139
+ __autoStartTimestamp: startTs,
140
+ __autoAgent: autoCtx.agent,
141
+ __autoSession: autoCtx.session,
142
+ };
143
+
144
+ const writeRes = await tx.run(rewrittenCypher, merged);
145
+ const counters = writeRes.summary.counters.updates();
146
+ const nodesCreated = counters.nodesCreated;
147
+ const relsCreated = counters.relationshipsCreated;
148
+ const propertiesSet = counters.propertiesSet;
149
+ const keys =
150
+ writeRes.records.length > 0 ? writeRes.records[0].keys.map(String) : [];
151
+ const serializedRecords = writeRes.records.map((rec) => {
152
+ const out: Record<string, unknown> = {};
153
+ for (const k of keys) {
154
+ out[k] = serializeValue(rec.get(k));
155
+ }
156
+ return out;
157
+ });
158
+
159
+ const orphanRes = await tx.run(ORPHAN_CHECK_CYPHER, merged);
160
+ const orphanIds = (orphanRes.records[0].get("orphanIds") as string[]) ?? [];
161
+ const labelSets =
162
+ (orphanRes.records[0].get("labelSets") as string[][]) ?? [];
163
+ if (orphanIds.length > 0) {
164
+ const flatLabels = Array.from(new Set(labelSets.flat()));
165
+ throw new OrphanRollbackError(orphanIds, flatLabels);
166
+ }
167
+
168
+ return { nodesCreated, relsCreated, propertiesSet, serializedRecords };
169
+ }
170
+
171
+ export interface WriteSynthInput {
172
+ nodesCreated: number;
173
+ relsCreated: number;
174
+ propertiesSet: number;
175
+ records: Array<Record<string, unknown>>;
176
+ }
177
+
178
+ export function synthesiseWriteResponse(
179
+ id: string | number,
180
+ w: WriteSynthInput,
181
+ ): string {
182
+ const parts: string[] = [];
183
+ if (w.records.length > 0) {
184
+ parts.push(
185
+ `${w.records.length} record${w.records.length === 1 ? "" : "s"} returned`,
186
+ );
187
+ }
188
+ parts.push(`${w.nodesCreated} nodes created`);
189
+ parts.push(`${w.relsCreated} relationships created`);
190
+ if (w.propertiesSet > 0) parts.push(`${w.propertiesSet} properties set`);
191
+ if (w.records.length > 0) {
192
+ parts.push("");
193
+ parts.push(JSON.stringify(w.records, null, 2));
194
+ }
195
+ return JSON.stringify({
196
+ jsonrpc: "2.0",
197
+ id,
198
+ result: {
199
+ content: [{ type: "text", text: parts.join("\n") }],
200
+ },
201
+ });
202
+ }
203
+
204
+ export function synthesiseOrphanRollback(
205
+ id: string | number,
206
+ orphanCount: number,
207
+ sampleLabels: string[],
208
+ ): string {
209
+ const labelLine =
210
+ sampleLabels.length > 0
211
+ ? sampleLabels.slice(0, 5).join(", ")
212
+ : "(unknown — orphans had no labels)";
213
+ const text =
214
+ `orphan rollback — ${orphanCount} node(s) created without an edge in the same transaction.\n` +
215
+ `Sample labels: ${labelLine}.\n\n` +
216
+ `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).`;
217
+ return JSON.stringify({
218
+ jsonrpc: "2.0",
219
+ id,
220
+ result: {
221
+ content: [{ type: "text", text }],
222
+ isError: true,
223
+ },
224
+ });
225
+ }
226
+
227
+ export function synthesiseShimError(
228
+ id: string | number,
229
+ prefix: string,
230
+ errMsg: string,
231
+ ): string {
232
+ return JSON.stringify({
233
+ jsonrpc: "2.0",
234
+ id,
235
+ result: {
236
+ content: [{ type: "text", text: `${prefix}: ${errMsg}` }],
237
+ isError: true,
238
+ },
239
+ });
240
+ }