@rubytech/create-maxy 1.0.678 → 1.0.680

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 (52) hide show
  1. package/dist/index.js +23 -0
  2. package/package.json +1 -1
  3. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.d.ts +2 -0
  4. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.d.ts.map +1 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.js +112 -0
  6. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate.test.js.map +1 -0
  7. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.d.ts +2 -0
  8. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.d.ts.map +1 -0
  9. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.js +163 -0
  10. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cache.test.js.map +1 -0
  11. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts +38 -0
  12. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts.map +1 -0
  13. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js +130 -0
  14. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js.map +1 -0
  15. package/payload/platform/lib/graph-mcp/dist/index.js +201 -45
  16. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  17. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +78 -0
  18. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -0
  19. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +194 -0
  20. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -0
  21. package/payload/platform/lib/graph-mcp/src/__tests__/cypher-validate.test.ts +141 -0
  22. package/payload/platform/lib/graph-mcp/src/__tests__/schema-cache.test.ts +169 -0
  23. package/payload/platform/lib/graph-mcp/src/cypher-validate.ts +157 -0
  24. package/payload/platform/lib/graph-mcp/src/index.ts +247 -47
  25. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +212 -0
  26. package/payload/platform/lib/graph-trash/dist/index.d.ts +8 -0
  27. package/payload/platform/lib/graph-trash/dist/index.d.ts.map +1 -1
  28. package/payload/platform/lib/graph-trash/dist/index.js +109 -14
  29. package/payload/platform/lib/graph-trash/dist/index.js.map +1 -1
  30. package/payload/platform/lib/graph-trash/src/index.ts +136 -21
  31. package/payload/platform/plugins/docs/references/memory-guide.md +5 -1
  32. package/payload/platform/plugins/docs/references/platform.md +1 -1
  33. package/payload/platform/plugins/docs/references/troubleshooting.md +18 -0
  34. package/payload/platform/plugins/memory/PLUGIN.md +1 -0
  35. package/payload/platform/plugins/memory/mcp/dist/index.js +54 -6
  36. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  37. package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.d.ts +36 -0
  38. package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.d.ts.map +1 -0
  39. package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.js +86 -0
  40. package/payload/platform/plugins/memory/mcp/dist/lib/filter-token.js.map +1 -0
  41. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts +23 -0
  42. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts.map +1 -1
  43. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js +47 -1
  44. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js.map +1 -1
  45. package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.d.ts +58 -0
  46. package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.d.ts.map +1 -0
  47. package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.js +125 -0
  48. package/payload/platform/plugins/memory/mcp/dist/tools/memory-find-candidates.js.map +1 -0
  49. package/payload/platform/templates/agents/admin/IDENTITY.md +16 -0
  50. package/payload/server/chunk-3RBKKDHC.js +783 -0
  51. package/payload/server/maxy-edge.js +11 -3
  52. package/payload/server/server.js +284 -112
@@ -28,6 +28,8 @@ import { accessSync, appendFileSync, constants, mkdirSync, readFileSync, statSyn
28
28
  import { resolve } from "node:path";
29
29
  import { StringDecoder } from "node:string_decoder";
30
30
  import { initStderrTee } from "../../mcp-stderr-tee/dist/index.js";
31
+ import { validate as validateCypher, type UnknownToken } from "./cypher-validate.js";
32
+ import { SchemaCache, neo4jSchemaFetcher } from "./schema-cache.js";
31
33
 
32
34
  const SERVER_NAME = "graph";
33
35
  const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
@@ -179,6 +181,38 @@ console.error(
179
181
  `namespace=${namespace} readOnly=${readOnly} tokenLimit=${responseTokenLimit}`,
180
182
  );
181
183
 
184
+ // Task 654 — async schema cache. The validator fails OPEN until the first
185
+ // refresh resolves; all cypher calls during that window are forwarded
186
+ // unvalidated and the existing [graph-query] line gains `validated=false`
187
+ // so operators see the bypass. One Neo4j driver is shared across refreshes;
188
+ // loaded lazily so test harnesses can exercise SchemaCache without pulling
189
+ // neo4j-driver into the module graph.
190
+ // neo4jUri is narrowed to `string` by the guard throw above, but the narrow
191
+ // doesn't flow into the closure below. Pin it to a local const whose type
192
+ // is `string` by construction.
193
+ const resolvedNeo4jUri: string = neo4jUri;
194
+ let schemaFetcherReady: Promise<Awaited<ReturnType<typeof neo4jSchemaFetcher>>> | null = null;
195
+ function getSchemaFetcher(): Promise<Awaited<ReturnType<typeof neo4jSchemaFetcher>>> {
196
+ if (!schemaFetcherReady) {
197
+ schemaFetcherReady = neo4jSchemaFetcher(resolvedNeo4jUri, neo4jUser, neo4jPassword);
198
+ }
199
+ return schemaFetcherReady;
200
+ }
201
+ const schemaCache = new SchemaCache({
202
+ async labels() {
203
+ const f = await getSchemaFetcher();
204
+ return f.labels();
205
+ },
206
+ async relationshipTypes() {
207
+ const f = await getSchemaFetcher();
208
+ return f.relationshipTypes();
209
+ },
210
+ });
211
+ void schemaCache.start().catch((err) => {
212
+ const msg = err instanceof Error ? err.message : String(err);
213
+ console.error(`[schema-cache] start failed error="${msg.replace(/"/g, "'")}"`);
214
+ });
215
+
182
216
  const uvx = resolveUvxPath();
183
217
  if (!uvx.path) {
184
218
  syncEmit(
@@ -216,12 +250,18 @@ child.stderr.on("data", (chunk: Buffer) => {
216
250
  process.stderr.write(chunk);
217
251
  });
218
252
 
219
- // --- JSON-RPC call correlation ---
220
- // tools/call is the only method we time; initialize and tools/list are noise.
253
+ // --- JSON-RPC call correlation + validation (Task 654) ---
254
+ // tools/call is the only method we time or validate. For read/write cypher
255
+ // calls, the line is validated against the schema cache before forwarding.
256
+ // Write-path rejection: synthesised MCP tool-error response on stdout, NOT
257
+ // forwarded. Read-path rejection: forwarded, with warnings appendix prepended
258
+ // to response.content[0].text.
221
259
  interface PendingCall {
222
260
  method: string;
223
261
  cypherPrefix: string | null;
224
262
  startMs: number;
263
+ validated: boolean;
264
+ readWarnings: UnknownToken[];
225
265
  }
226
266
  const pending = new Map<string | number, PendingCall>();
227
267
 
@@ -235,18 +275,29 @@ function stripNamespace(toolName: string | undefined): string {
235
275
  return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
236
276
  }
237
277
 
278
+ const READ_CYPHER_TOOL = "read_neo4j_cypher";
279
+ const WRITE_CYPHER_TOOL = "write_neo4j_cypher";
280
+
238
281
  type JsonRpcMessage = {
282
+ jsonrpc?: string;
239
283
  id?: string | number;
240
284
  method?: string;
241
285
  params?: { name?: string; arguments?: Record<string, unknown> };
242
- result?: { content?: Array<{ text?: string; type?: string }> };
286
+ result?: {
287
+ content?: Array<{ text?: string; type?: string }>;
288
+ isError?: boolean;
289
+ };
243
290
  error?: { message?: string; code?: number };
244
291
  };
245
292
 
246
- function extractCypher(args: Record<string, unknown> | undefined): string | null {
293
+ function extractCypherFull(args: Record<string, unknown> | undefined): string | null {
247
294
  if (!args) return null;
248
295
  const q = args["query"] ?? args["cypher"];
249
- return typeof q === "string" ? truncate(q.replace(/\s+/g, " ").trim(), 80) : null;
296
+ return typeof q === "string" ? q : null;
297
+ }
298
+
299
+ function truncateForLog(cypher: string): string {
300
+ return truncate(cypher.replace(/\s+/g, " ").trim(), 80);
250
301
  }
251
302
 
252
303
  function countRows(result: JsonRpcMessage["result"]): string {
@@ -257,72 +308,221 @@ function countRows(result: JsonRpcMessage["result"]): string {
257
308
  return String(result.content.length);
258
309
  }
259
310
 
260
- function onRequestLine(line: string): void {
311
+ /**
312
+ * Render the admin-facing rejection or warnings text. Plain prose so the
313
+ * agent's Tool Failure Discipline reads it the same way it reads any tool
314
+ * error — no special-case structured JSON parser required.
315
+ */
316
+ function renderUnknownTokens(
317
+ unknown: UnknownToken[],
318
+ mode: "rejected" | "warning",
319
+ ): string {
320
+ const heading = mode === "rejected"
321
+ ? "schema-validation rejected — cypher NOT executed"
322
+ : "schema-validation warning — cypher executed but referenced unknown tokens";
323
+ const lines = unknown.map((u) => {
324
+ const nearest = u.nearest.length > 0 ? ` nearest=[${u.nearest.join(", ")}]` : "";
325
+ return ` - ${u.kind} '${u.token}':${nearest} — ${u.hint}`;
326
+ });
327
+ return `${heading}\n${lines.join("\n")}`;
328
+ }
329
+
330
+ function synthesiseRejection(id: string | number, unknown: UnknownToken[]): string {
331
+ const text = `${renderUnknownTokens(unknown, "rejected")}\n\nFix the token names, or if the schema has just changed, invoke ${namespace}_get_neo4j_schema to refresh your view.`;
332
+ const envelope = {
333
+ jsonrpc: "2.0",
334
+ id,
335
+ result: {
336
+ content: [{ type: "text", text }],
337
+ isError: true,
338
+ },
339
+ };
340
+ return JSON.stringify(envelope);
341
+ }
342
+
343
+ function wrapReadWarnings(msg: JsonRpcMessage, warnings: UnknownToken[]): string {
344
+ const warningText = `${renderUnknownTokens(warnings, "warning")}\n\n--- results below (executed despite unknown tokens) ---\n`;
345
+ const original = msg.result?.content ?? [];
346
+ const wrapped: JsonRpcMessage = {
347
+ ...msg,
348
+ result: {
349
+ ...(msg.result ?? {}),
350
+ content: [{ type: "text", text: warningText }, ...original],
351
+ },
352
+ };
353
+ return JSON.stringify(wrapped);
354
+ }
355
+
356
+ type RequestDecision = "forward" | "intercepted";
357
+
358
+ function handleRequestLine(line: string): RequestDecision {
359
+ let msg: JsonRpcMessage;
261
360
  try {
262
- const msg = JSON.parse(line) as JsonRpcMessage;
263
- if (msg.method === "tools/call" && msg.id !== undefined) {
264
- pending.set(msg.id, {
265
- method: stripNamespace(msg.params?.name),
266
- cypherPrefix: extractCypher(msg.params?.arguments),
267
- startMs: Date.now(),
268
- });
269
- }
361
+ msg = JSON.parse(line) as JsonRpcMessage;
270
362
  } catch {
271
- // Non-JSON / partial line — forward untouched, don't log.
363
+ return "forward";
364
+ }
365
+ if (msg.method !== "tools/call" || msg.id === undefined) return "forward";
366
+
367
+ const methodName = stripNamespace(msg.params?.name);
368
+ const cypherFull = extractCypherFull(msg.params?.arguments);
369
+ const cypherPrefix = cypherFull ? truncateForLog(cypherFull) : null;
370
+ const isCypherCall =
371
+ methodName === READ_CYPHER_TOOL || methodName === WRITE_CYPHER_TOOL;
372
+
373
+ const entry: PendingCall = {
374
+ method: methodName,
375
+ cypherPrefix,
376
+ startMs: Date.now(),
377
+ validated: false,
378
+ readWarnings: [],
379
+ };
380
+
381
+ if (!isCypherCall || !cypherFull) {
382
+ pending.set(msg.id, entry);
383
+ return "forward";
272
384
  }
385
+
386
+ const isWrite = methodName === WRITE_CYPHER_TOOL;
387
+ const snapshot = schemaCache.snapshot();
388
+ const cacheReady = schemaCache.ready();
389
+
390
+ if (!cacheReady) {
391
+ console.error(
392
+ `[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
393
+ );
394
+ pending.set(msg.id, entry);
395
+ return "forward";
396
+ }
397
+
398
+ const result = validateCypher(cypherFull, snapshot);
399
+ entry.validated = true;
400
+
401
+ if (result.ok) {
402
+ console.error(
403
+ `[cypher-validate] tool=${isWrite ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
404
+ );
405
+ pending.set(msg.id, entry);
406
+ return "forward";
407
+ }
408
+
409
+ void schemaCache.maybeRebuildOnStaleMiss(result.unknown);
410
+ const tokenSummary = result.unknown
411
+ .map((u) => `${u.kind}:${u.token}`)
412
+ .join(",");
413
+
414
+ if (isWrite) {
415
+ console.error(
416
+ `[cypher-validate] tool=write outcome=rejected unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
417
+ );
418
+ const response = synthesiseRejection(msg.id, result.unknown);
419
+ process.stdout.write(`${response}\n`);
420
+ console.error(
421
+ `[graph-query] op=${methodName} brand=${brand} port=${neo4jPort} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}" rejected=true validated=true ms=${Date.now() - entry.startMs}`,
422
+ );
423
+ return "intercepted";
424
+ }
425
+
426
+ entry.readWarnings = result.unknown;
427
+ console.error(
428
+ `[cypher-validate] tool=read outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
429
+ );
430
+ pending.set(msg.id, entry);
431
+ return "forward";
273
432
  }
274
433
 
275
- function onResponseLine(line: string): void {
434
+ function handleResponseLine(line: string): string | null {
435
+ let msg: JsonRpcMessage;
276
436
  try {
277
- const msg = JSON.parse(line) as JsonRpcMessage;
278
- if (msg.id === undefined || !pending.has(msg.id)) return;
279
- const p = pending.get(msg.id)!;
280
- pending.delete(msg.id);
281
- const elapsed = Date.now() - p.startMs;
282
- const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
283
- if (msg.error) {
284
- const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
285
- console.error(
286
- `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ms=${elapsed}`,
287
- );
288
- } else {
289
- const rows = countRows(msg.result);
437
+ msg = JSON.parse(line) as JsonRpcMessage;
438
+ } catch {
439
+ return null;
440
+ }
441
+ if (msg.id === undefined || !pending.has(msg.id)) return null;
442
+ const p = pending.get(msg.id)!;
443
+ pending.delete(msg.id);
444
+ const elapsed = Date.now() - p.startMs;
445
+ const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
446
+ const validatedField = `validated=${p.validated}`;
447
+ if (msg.error) {
448
+ const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
449
+ console.error(
450
+ `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ${validatedField} ms=${elapsed}`,
451
+ );
452
+ return null;
453
+ }
454
+ const rows = countRows(msg.result);
455
+ const warnedField = p.readWarnings.length > 0 ? ` warned=${p.readWarnings.length}` : "";
456
+ console.error(
457
+ `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ${validatedField}${warnedField} ms=${elapsed}`,
458
+ );
459
+ if (p.readWarnings.length > 0) {
460
+ try {
461
+ return wrapReadWarnings(msg, p.readWarnings);
462
+ } catch (err) {
463
+ const errMsg = err instanceof Error ? err.message : String(err);
290
464
  console.error(
291
- `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ms=${elapsed}`,
465
+ `[cypher-validate] warning-wrap failed op=${p.method} error="${errMsg.replace(/"/g, "'")}" forwarding response unwrapped`,
292
466
  );
467
+ return null;
293
468
  }
294
- } catch {
295
- // Non-JSON — forward untouched.
296
469
  }
470
+ return null;
297
471
  }
298
472
 
299
- function makeLineSplitter(onLine: (line: string) => void): (chunk: Buffer) => void {
473
+ /**
474
+ * Per-stream buffering splitter. Yields complete lines (without trailing \n)
475
+ * as they accumulate; leaves any partial tail in the buffer until more bytes
476
+ * arrive. Unlike the pre-Task-652 splitter, this one lets the caller decide
477
+ * per-line whether to forward or intercept — the original splitter was
478
+ * fire-and-forget and the raw chunk was forwarded unconditionally alongside,
479
+ * which ruled out interception.
480
+ */
481
+ function makeLineBuffer(): { push: (chunk: Buffer) => string[] } {
300
482
  const decoder = new StringDecoder("utf8");
301
483
  let buf = "";
302
- return (chunk: Buffer) => {
303
- buf += decoder.write(chunk);
304
- let idx: number;
305
- while ((idx = buf.indexOf("\n")) !== -1) {
306
- const line = buf.slice(0, idx);
307
- buf = buf.slice(idx + 1);
308
- if (line.length > 0) onLine(line);
309
- }
484
+ return {
485
+ push(chunk: Buffer): string[] {
486
+ buf += decoder.write(chunk);
487
+ const out: string[] = [];
488
+ let idx: number;
489
+ while ((idx = buf.indexOf("\n")) !== -1) {
490
+ out.push(buf.slice(0, idx));
491
+ buf = buf.slice(idx + 1);
492
+ }
493
+ return out;
494
+ },
310
495
  };
311
496
  }
312
497
 
313
- const splitRequest = makeLineSplitter(onRequestLine);
498
+ const requestBuffer = makeLineBuffer();
314
499
  process.stdin.on("data", (chunk: Buffer) => {
315
- splitRequest(chunk);
316
- child.stdin.write(chunk);
500
+ for (const line of requestBuffer.push(chunk)) {
501
+ if (line.length === 0) {
502
+ child.stdin.write("\n");
503
+ continue;
504
+ }
505
+ const decision = handleRequestLine(line);
506
+ if (decision === "forward") {
507
+ child.stdin.write(`${line}\n`);
508
+ }
509
+ // "intercepted" — synthesised response already written to stdout.
510
+ }
317
511
  });
318
512
  process.stdin.on("end", () => {
319
513
  child.stdin.end();
320
514
  });
321
515
 
322
- const splitResponse = makeLineSplitter(onResponseLine);
516
+ const responseBuffer = makeLineBuffer();
323
517
  child.stdout.on("data", (chunk: Buffer) => {
324
- splitResponse(chunk);
325
- process.stdout.write(chunk);
518
+ for (const line of responseBuffer.push(chunk)) {
519
+ if (line.length === 0) {
520
+ process.stdout.write("\n");
521
+ continue;
522
+ }
523
+ const rewritten = handleResponseLine(line);
524
+ process.stdout.write(`${rewritten ?? line}\n`);
525
+ }
326
526
  });
327
527
  child.stdout.on("end", () => {
328
528
  process.stdout.end();
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Per-process schema cache for the graph-mcp proxy (Task 654).
3
+ *
4
+ * Caches { labels, relationshipTypes } from the connected Neo4j instance so
5
+ * the cypher validator can look up tokens without a round-trip per call.
6
+ *
7
+ * Refresh triggers:
8
+ * - boot — async refresh kicked off by start(); validator fails
9
+ * OPEN until the first refresh resolves.
10
+ * - interval — every 60s (configurable) while the process is alive.
11
+ * - stale-miss — an unknown token with a near match (edit distance ≤ 2)
12
+ * triggers a debounced refresh, catching fresh migrations
13
+ * without requiring operators to wait out the interval.
14
+ *
15
+ * Failure posture:
16
+ * - A refresh that throws preserves the last good snapshot and leaves
17
+ * ready() as it was. `[schema-cache] refresh failure ...` is logged
18
+ * loudly so operators see persistent Neo4j unreachability.
19
+ * - A boot refresh that fails leaves ready()=false and the snapshot empty.
20
+ * The validator's own fail-open branch handles this (empty snapshot →
21
+ * ok=true, no rejections), so the graph-mcp proxy keeps working against
22
+ * a still-reachable Neo4j instance that's just slow to respond to the
23
+ * initial `db.labels()` call.
24
+ */
25
+
26
+ import type { SchemaSnapshot, UnknownToken } from "./cypher-validate.js";
27
+
28
+ export interface SchemaFetcher {
29
+ labels(): Promise<string[]>;
30
+ relationshipTypes(): Promise<string[]>;
31
+ }
32
+
33
+ export interface SchemaCacheOptions {
34
+ /** Interval between automatic refreshes. 0 disables the timer (tests). */
35
+ refreshIntervalMs?: number;
36
+ /** Minimum ms between stale-miss-triggered refreshes. Default 5000. */
37
+ staleMissDebounceMs?: number;
38
+ /** Emit a log line. Default: stderr. Tests inject to capture. */
39
+ emit?: (line: string) => void;
40
+ }
41
+
42
+ const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
43
+ const DEFAULT_STALE_MISS_DEBOUNCE_MS = 5_000;
44
+ const STALE_MISS_DISTANCE_CEILING = 2;
45
+
46
+ export class SchemaCache {
47
+ private _labels = new Set<string>();
48
+ private _rels = new Set<string>();
49
+ private _ready = false;
50
+ private _timer: ReturnType<typeof setInterval> | null = null;
51
+ private _refreshInFlight: Promise<boolean> | null = null;
52
+ private _lastStaleMissAt = 0;
53
+
54
+ constructor(
55
+ private readonly fetcher: SchemaFetcher,
56
+ private readonly options: SchemaCacheOptions = {},
57
+ ) {}
58
+
59
+ snapshot(): SchemaSnapshot {
60
+ return { labels: this._labels, relationshipTypes: this._rels };
61
+ }
62
+
63
+ ready(): boolean {
64
+ return this._ready;
65
+ }
66
+
67
+ async start(): Promise<void> {
68
+ await this.refresh("boot");
69
+ const interval = this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
70
+ if (interval > 0) {
71
+ this._timer = setInterval(() => {
72
+ void this.refresh("interval");
73
+ }, interval);
74
+ // Allow the process to exit without waiting for the timer.
75
+ if (typeof this._timer === "object" && "unref" in this._timer) {
76
+ (this._timer as { unref?: () => void }).unref?.();
77
+ }
78
+ }
79
+ }
80
+
81
+ stop(): void {
82
+ if (this._timer) {
83
+ clearInterval(this._timer);
84
+ this._timer = null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Refresh the cache. Single-flight: concurrent calls await the same in-flight
90
+ * promise rather than triggering overlapping Neo4j reads. Returns true on
91
+ * successful update, false on failure (last good snapshot preserved).
92
+ */
93
+ async refresh(reason: string): Promise<boolean> {
94
+ if (this._refreshInFlight) return this._refreshInFlight;
95
+ const run = async (): Promise<boolean> => {
96
+ const started = Date.now();
97
+ try {
98
+ const [labels, rels] = await Promise.all([
99
+ this.fetcher.labels(),
100
+ this.fetcher.relationshipTypes(),
101
+ ]);
102
+ this._labels = new Set(labels);
103
+ this._rels = new Set(rels);
104
+ this._ready = true;
105
+ this.emit(
106
+ `[schema-cache] refresh reason=${reason} labels=${this._labels.size} relationshipTypes=${this._rels.size} ms=${Date.now() - started}`,
107
+ );
108
+ return true;
109
+ } catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ this.emit(
112
+ `[schema-cache] refresh failure reason=${reason} ms=${Date.now() - started} error="${msg.replace(/"/g, "'")}"`,
113
+ );
114
+ return false;
115
+ }
116
+ };
117
+ this._refreshInFlight = run();
118
+ try {
119
+ return await this._refreshInFlight;
120
+ } finally {
121
+ this._refreshInFlight = null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * If the validator returned unknown tokens whose nearest known token is
127
+ * within STALE_MISS_DISTANCE_CEILING edits, trigger an out-of-band refresh
128
+ * (debounced). The heuristic: a near-miss is likely a fresh migration
129
+ * (renamed / newly added label/edge), where a far-miss is almost certainly
130
+ * a typo and a refresh would not change the outcome. Returns true iff a
131
+ * refresh was actually run this call.
132
+ */
133
+ async maybeRebuildOnStaleMiss(unknown: UnknownToken[]): Promise<boolean> {
134
+ if (unknown.length === 0) return false;
135
+ const now = Date.now();
136
+ const debounceMs =
137
+ this.options.staleMissDebounceMs ?? DEFAULT_STALE_MISS_DEBOUNCE_MS;
138
+ if (now - this._lastStaleMissAt < debounceMs) return false;
139
+ const anyNearMiss = unknown.some((u) => {
140
+ if (u.nearest.length === 0) return false;
141
+ return editDistance(u.token, u.nearest[0]) <= STALE_MISS_DISTANCE_CEILING;
142
+ });
143
+ if (!anyNearMiss) return false;
144
+ this._lastStaleMissAt = now;
145
+ await this.refresh("stale-miss");
146
+ return true;
147
+ }
148
+
149
+ private emit(line: string): void {
150
+ if (this.options.emit) {
151
+ this.options.emit(line);
152
+ } else {
153
+ process.stderr.write(`${line}\n`);
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Build a SchemaFetcher over a long-lived neo4j-driver Driver. Each call
160
+ * opens a fresh session (cheap, sub-ms) and closes it in finally so the
161
+ * driver's connection pool stays small. Consumer owns the driver lifecycle.
162
+ *
163
+ * Import is deferred to the factory call so that test files can exercise
164
+ * the SchemaCache class without pulling neo4j-driver into the module graph.
165
+ */
166
+ export async function neo4jSchemaFetcher(
167
+ uri: string,
168
+ user: string,
169
+ password: string,
170
+ ): Promise<SchemaFetcher> {
171
+ const neo4j = await import("neo4j-driver");
172
+ const driver = neo4j.default.driver(uri, neo4j.default.auth.basic(user, password));
173
+ const query = async (cypher: string): Promise<string[]> => {
174
+ const session = driver.session();
175
+ try {
176
+ const result = await session.run(cypher);
177
+ return result.records
178
+ .map((r) => r.get(0))
179
+ .filter((v): v is string => typeof v === "string");
180
+ } finally {
181
+ await session.close();
182
+ }
183
+ };
184
+ return {
185
+ async labels() {
186
+ return query("CALL db.labels() YIELD label RETURN label");
187
+ },
188
+ async relationshipTypes() {
189
+ return query(
190
+ "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType",
191
+ );
192
+ },
193
+ };
194
+ }
195
+
196
+ function editDistance(a: string, b: string): number {
197
+ if (a === b) return 0;
198
+ if (a.length === 0) return b.length;
199
+ if (b.length === 0) return a.length;
200
+ let prev = new Array<number>(b.length + 1);
201
+ let curr = new Array<number>(b.length + 1);
202
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
203
+ for (let i = 1; i <= a.length; i++) {
204
+ curr[0] = i;
205
+ for (let j = 1; j <= b.length; j++) {
206
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
207
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
208
+ }
209
+ [prev, curr] = [curr, prev];
210
+ }
211
+ return prev[b.length];
212
+ }
@@ -13,6 +13,14 @@
13
13
  * are snapshotted into `_trashedKeys` (JSON) and nulled on the node, so
14
14
  * MERGE against the same key won't collide. `restoreNode` writes them back
15
15
  * and fails loudly when an active node already occupies the slot.
16
+ *
17
+ * Label-aware cascade (Task 655): when `trashNode` trashes a `:Conversation`,
18
+ * it also trashes every `(m:Message)-[:PART_OF]->(c)` Message in the same
19
+ * managed transaction. This makes `MATCH (m:Trashed)` a correct audit
20
+ * primitive again — pre-Task-655 a Conversation trash left attached Messages
21
+ * un-labelled, so audits that filtered `m:Trashed` silently under-reported
22
+ * the blast radius of the 2026-04-22 incident by a factor of 10+.
23
+ * `restoreNode` reverses both sides.
16
24
  */
17
25
  import type { Session } from "neo4j-driver";
18
26
  export interface TrashParams {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAuC5C,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAyEzE;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAkE/E;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uFAAuF;IACvF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,qFAAqF;IACrF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gFAAgF;IAChF,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA0EpF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAI9D;AAED,2FAA2F;AAC3F,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAuC5C,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAyIzE;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CA6G/E;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uFAAuF;IACvF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,qFAAqF;IACrF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gFAAgF;IAChF,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA0EpF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAI9D;AAED,2FAA2F;AAC3F,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EAAqB,CAAC"}