@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.8

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.
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
- import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks } from "./wikilinks.js";
4
+ import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks, listUnresolvedWikilinks } from "./wikilinks.js";
5
5
 
6
6
  let store: SqliteStore;
7
7
  let db: Database;
@@ -275,3 +275,115 @@ describe("path-based resolution", async () => {
275
275
  expect(links[0].targetId).toBe(target.id);
276
276
  });
277
277
  });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // unresolved_wikilinks relationship-column migration — atomicity (vault#555
281
+ // wire+generalist must-fix; W7's migrateToV25-interruption lesson applied).
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe("ensureRelationshipColumn — crash-safe rebuild", () => {
285
+ /**
286
+ * Build a legacy (pre-vault#555) 2-column `unresolved_wikilinks` table with
287
+ * pending rows, on a store whose other tables already exist. Returns the
288
+ * source note's id (a real row so the FK is satisfiable when
289
+ * foreign_keys is on).
290
+ */
291
+ async function seedLegacyTable(): Promise<string> {
292
+ // A plain note (no wikilinks) doesn't create the v555 table.
293
+ const src = await store.createNote("plain body, no wikilinks", { path: "src-note" });
294
+ const tgt = await store.createNote("plain target", { path: "Target A" });
295
+ // Hand-build the pre-v555 shape (2-column PK, no `relationship`).
296
+ db.exec(`
297
+ CREATE TABLE unresolved_wikilinks (
298
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
299
+ target_path TEXT NOT NULL COLLATE NOCASE,
300
+ PRIMARY KEY (source_id, target_path)
301
+ )
302
+ `);
303
+ db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)")
304
+ .run(src.id, "Target B");
305
+ db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)")
306
+ .run(src.id, "Target A"); // this one resolves to tgt after the migration
307
+ return src.id;
308
+ }
309
+
310
+ function hasRelationshipColumn(): boolean {
311
+ const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
312
+ return cols.some((c) => c.name === "relationship");
313
+ }
314
+
315
+ function tableExists(name: string): boolean {
316
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name);
317
+ return row !== null;
318
+ }
319
+
320
+ it("a crash mid-rebuild (between RENAME and CREATE) rolls back — original table + pending rows intact, no orphan _pre_v555", async () => {
321
+ await seedLegacyTable();
322
+ expect(hasRelationshipColumn()).toBe(false); // legacy shape confirmed
323
+ const rowsBefore = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
324
+ expect(rowsBefore).toBe(2);
325
+
326
+ // Monkey-patch db.exec to throw on the migration's CREATE — i.e. AFTER
327
+ // the RENAME has moved the table to _pre_v555 but BEFORE the new table
328
+ // exists. This is the exact interruption window the transaction wrapper
329
+ // must survive. BEGIN/RENAME/ROLLBACK all pass through untouched.
330
+ const origExec = db.exec.bind(db);
331
+ let crashed = false;
332
+ (db as unknown as { exec: (sql: string) => unknown }).exec = (sql: string) => {
333
+ if (!crashed && /CREATE TABLE unresolved_wikilinks \(/.test(sql)) {
334
+ crashed = true;
335
+ throw new Error("simulated crash between RENAME and CREATE");
336
+ }
337
+ return origExec(sql);
338
+ };
339
+
340
+ let thrown: unknown;
341
+ try {
342
+ // resolveUnresolvedWikilinks calls ensureRelationshipColumn first —
343
+ // the REAL heal path, not a hand-rolled copy.
344
+ resolveUnresolvedWikilinks(db, "Target A", "irrelevant-id");
345
+ } catch (e) {
346
+ thrown = e;
347
+ } finally {
348
+ (db as unknown as { exec: (sql: string) => unknown }).exec = origExec;
349
+ }
350
+
351
+ // The crash propagated (not swallowed).
352
+ expect(crashed).toBe(true);
353
+ expect((thrown as Error)?.message).toContain("simulated crash");
354
+
355
+ // ROLLBACK restored the ORIGINAL table exactly:
356
+ expect(tableExists("unresolved_wikilinks")).toBe(true); // NOT renamed away
357
+ expect(tableExists("unresolved_wikilinks_pre_v555")).toBe(false); // no orphan
358
+ expect(hasRelationshipColumn()).toBe(false); // still the legacy 2-col shape
359
+ const rowsAfter = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
360
+ expect(rowsAfter).toBe(2); // pending rows NOT lost
361
+
362
+ // And a clean retry (no crash) fully recovers: migration runs, column
363
+ // added, rows preserved and backfilled as "wikilink".
364
+ resolveUnresolvedWikilinks(db, "nothing-matches-here", "irrelevant-id-2");
365
+ expect(hasRelationshipColumn()).toBe(true);
366
+ expect(tableExists("unresolved_wikilinks_pre_v555")).toBe(false);
367
+ const migratedRows = db.prepare("SELECT relationship FROM unresolved_wikilinks").all() as { relationship: string }[];
368
+ expect(migratedRows).toHaveLength(2);
369
+ expect(migratedRows.every((r) => r.relationship === "wikilink")).toBe(true);
370
+ });
371
+
372
+ it("a successful (uninterrupted) rebuild migrates + backfills as wikilink, and a pending forward-ref then resolves", async () => {
373
+ const srcId = await seedLegacyTable();
374
+
375
+ // Drive the heal through the real read path.
376
+ const before = listUnresolvedWikilinks(db);
377
+ expect(before.count).toBe(2);
378
+ expect(hasRelationshipColumn()).toBe(true); // listUnresolvedWikilinks healed it
379
+ expect(before.unresolved.every((u) => u.relationship === "wikilink")).toBe(true);
380
+
381
+ // "Target A" already exists (seedLegacyTable created it) — resolving now
382
+ // backfills the edge from the migrated pending row.
383
+ const targetA = await store.getNoteByPath("Target A");
384
+ const resolved = resolveUnresolvedWikilinks(db, "Target A", targetA!.id);
385
+ expect(resolved).toBe(1);
386
+ const links = await store.getLinks(srcId, { direction: "outbound" });
387
+ expect(links.some((l) => l.targetId === targetA!.id && l.relationship === "wikilink")).toBe(true);
388
+ });
389
+ });
@@ -1,6 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import type { Note } from "./types.js";
2
3
  import * as linkOps from "./links.js";
4
+ import { getNote } from "./notes.js";
3
5
  import { chunkForInClause } from "./sql-in.js";
6
+ import { transaction } from "./txn.js";
4
7
 
5
8
  // ---------------------------------------------------------------------------
6
9
  // Parser — extract [[wikilinks]] from markdown content
@@ -251,19 +254,28 @@ export interface UnresolvedWikilink {
251
254
  source_id: string;
252
255
  source_path?: string;
253
256
  target_path: string;
257
+ /**
258
+ * Link relationship this pending entry will materialize as once
259
+ * resolved. `"wikilink"` for content-parsed `[[targets]]`; the
260
+ * caller's own relationship string for structured `links` forward-refs
261
+ * (vault#555). Always present post-migration; defaults to `"wikilink"`
262
+ * for rows written before the column existed.
263
+ */
264
+ relationship: string;
254
265
  }
255
266
 
256
267
  /**
257
268
  * List unresolved wikilinks across the vault.
258
269
  */
259
270
  export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved: UnresolvedWikilink[]; count: number } {
271
+ ensureRelationshipColumn(db);
260
272
  let total: number;
261
- let rows: { source_id: string; target_path: string }[];
273
+ let rows: { source_id: string; target_path: string; relationship: string }[];
262
274
  try {
263
275
  total = (db.prepare("SELECT COUNT(*) as c FROM unresolved_wikilinks").get() as { c: number }).c;
264
276
  rows = db.prepare(
265
- "SELECT source_id, target_path FROM unresolved_wikilinks ORDER BY source_id LIMIT ?",
266
- ).all(limit) as { source_id: string; target_path: string }[];
277
+ "SELECT source_id, target_path, relationship FROM unresolved_wikilinks ORDER BY source_id LIMIT ?",
278
+ ).all(limit) as { source_id: string; target_path: string; relationship: string }[];
267
279
  } catch {
268
280
  // Table doesn't exist yet
269
281
  return { unresolved: [], count: 0 };
@@ -288,6 +300,7 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
288
300
  source_id: r.source_id,
289
301
  source_path: pathMap.get(r.source_id) ?? undefined,
290
302
  target_path: r.target_path,
303
+ relationship: r.relationship || WIKILINK_REL,
291
304
  }));
292
305
 
293
306
  return { unresolved, count: total };
@@ -382,6 +395,73 @@ export function syncWikilinks(
382
395
  // Unresolved wikilinks — pending resolution when target notes are created
383
396
  // ---------------------------------------------------------------------------
384
397
 
398
+ /**
399
+ * Self-heal the `relationship` column onto a pre-vault#555
400
+ * `unresolved_wikilinks` table. An `ALTER TABLE ADD COLUMN` alone can't
401
+ * widen the PRIMARY KEY, which would let a structured link (non-"wikilink"
402
+ * relationship) queued against the same (source, target_path) as an
403
+ * existing pending wikilink silently collide and get dropped by `INSERT OR
404
+ * IGNORE`. So on a table that predates the column, this rebuilds it with
405
+ * the 3-column PK (source_id, target_path, relationship) and backfills
406
+ * every existing row as `relationship = 'wikilink'` (the only kind that
407
+ * could have been queued before this fix). No-op on a fresh table (created
408
+ * directly with the new schema by {@link ensureUnresolvedTable}) or one
409
+ * already migrated. `PRAGMA table_info` on a nonexistent table returns zero
410
+ * rows rather than throwing, so this is safe to call unconditionally,
411
+ * including from read paths that don't want to create the table lazily.
412
+ */
413
+ function ensureRelationshipColumn(db: Database): void {
414
+ const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
415
+ if (cols.length === 0) return; // table doesn't exist — nothing to heal
416
+ if (cols.some((c) => c.name === "relationship")) return; // already migrated
417
+
418
+ // The rebuild is a 4-statement DDL sequence (RENAME → CREATE → INSERT…
419
+ // SELECT → DROP) that MUST be atomic (wire + generalist review, vault#555
420
+ // — same class as W7's migrateToV25 must-fix). A crash between RENAME and
421
+ // CREATE would leave NO `unresolved_wikilinks` table at all (breaking every
422
+ // subsequent wikilink/structured-link write); a crash between CREATE and
423
+ // INSERT would strand every pending forward-ref row in the orphaned
424
+ // `_pre_v555` table permanently — silent data loss — because the guard
425
+ // above (`relationship` column present) flips true the moment CREATE
426
+ // commits, so a retry would skip the backfill.
427
+ const migrate = (): void => {
428
+ db.exec("ALTER TABLE unresolved_wikilinks RENAME TO unresolved_wikilinks_pre_v555");
429
+ db.exec(`
430
+ CREATE TABLE unresolved_wikilinks (
431
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
432
+ target_path TEXT NOT NULL COLLATE NOCASE,
433
+ relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
434
+ PRIMARY KEY (source_id, target_path, relationship)
435
+ )
436
+ `);
437
+ db.exec(`
438
+ INSERT INTO unresolved_wikilinks (source_id, target_path, relationship)
439
+ SELECT source_id, target_path, '${WIKILINK_REL}' FROM unresolved_wikilinks_pre_v555
440
+ `);
441
+ db.exec("DROP TABLE unresolved_wikilinks_pre_v555");
442
+ };
443
+
444
+ // Nesting guard: this heal runs from `ensureUnresolvedTable` /
445
+ // `resolveUnresolvedWikilinks`, which are themselves reached from
446
+ // `store.createNote`/`updateNote` — and a BATCH create/update wraps those
447
+ // in `transactionAsync` (an already-open transaction). The txn seam is
448
+ // single-level by design (see core/src/txn.ts — a nested `BEGIN IMMEDIATE`
449
+ // throws "cannot start a transaction within a transaction"), so when a
450
+ // transaction is already active the DDL is ALREADY covered by that
451
+ // transaction's atomicity and we run it directly; only when idle do we open
452
+ // our own. `db.inTransaction` is bun:sqlite's active-transaction flag; a
453
+ // backend that doesn't expose it (a DO-SQLite Store) reads `undefined`
454
+ // (falsy) and takes the `transaction()` path — which for that backend
455
+ // delegates to its native `transactionSync`. (Immaterial in practice: a
456
+ // fresh DO deployment is created with the v555 schema directly, so this
457
+ // legacy heal never fires there.)
458
+ if (db.inTransaction) {
459
+ migrate();
460
+ } else {
461
+ transaction(db, migrate);
462
+ }
463
+ }
464
+
385
465
  /**
386
466
  * Ensure the unresolved_wikilinks table exists.
387
467
  * Called lazily — only when we actually have unresolved links.
@@ -391,13 +471,18 @@ export function ensureUnresolvedTable(db: Database): void {
391
471
  CREATE TABLE IF NOT EXISTS unresolved_wikilinks (
392
472
  source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
393
473
  target_path TEXT NOT NULL COLLATE NOCASE,
394
- PRIMARY KEY (source_id, target_path)
474
+ relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
475
+ PRIMARY KEY (source_id, target_path, relationship)
395
476
  )
396
477
  `);
478
+ ensureRelationshipColumn(db);
397
479
  }
398
480
 
399
481
  /**
400
- * Update unresolved wikilinks for a note.
482
+ * Update unresolved wikilinks for a note. Scoped to `relationship =
483
+ * "wikilink"` rows only (vault#555) — a content re-parse must not clobber
484
+ * pending STRUCTURED-link forward-refs queued for this note via
485
+ * {@link queueUnresolvedLink} (a different relationship, same table).
401
486
  */
402
487
  function syncUnresolvedWikilinks(
403
488
  db: Database,
@@ -405,9 +490,9 @@ function syncUnresolvedWikilinks(
405
490
  unresolvedPaths: string[],
406
491
  ): void {
407
492
  if (unresolvedPaths.length === 0) {
408
- // Clean up any old unresolved entries for this note
493
+ // Clean up any old wikilink-kind unresolved entries for this note
409
494
  try {
410
- db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ?").run(noteId);
495
+ db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
411
496
  } catch {
412
497
  // Table may not exist yet — that's fine
413
498
  }
@@ -416,19 +501,22 @@ function syncUnresolvedWikilinks(
416
501
 
417
502
  ensureUnresolvedTable(db);
418
503
 
419
- // Replace all unresolved entries for this note
420
- db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ?").run(noteId);
504
+ // Replace wikilink-kind unresolved entries for this note only
505
+ db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
421
506
  const insert = db.prepare(
422
- "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)",
507
+ "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path, relationship) VALUES (?, ?, ?)",
423
508
  );
424
509
  for (const path of unresolvedPaths) {
425
- insert.run(noteId, path);
510
+ insert.run(noteId, path, WIKILINK_REL);
426
511
  }
427
512
  }
428
513
 
429
514
  /**
430
- * Try to resolve pending wikilinks that point to a given path.
431
- * Called when a note is created or its path changes.
515
+ * Try to resolve pending wikilinks AND pending structured-link forward-refs
516
+ * (vault#555) that point to a given path. Called when a note is created or
517
+ * its path changes. Each pending row materializes with ITS OWN relationship
518
+ * (a structured link queued via {@link queueUnresolvedLink} backfills with
519
+ * the caller's original relationship, not "wikilink").
432
520
  *
433
521
  * Returns the number of links resolved.
434
522
  */
@@ -437,13 +525,14 @@ export function resolveUnresolvedWikilinks(
437
525
  notePath: string,
438
526
  noteId: string,
439
527
  ): number {
440
- let rows: { source_id: string }[];
528
+ ensureRelationshipColumn(db);
529
+ let rows: { source_id: string; relationship: string }[];
441
530
  try {
442
531
  rows = db.prepare(`
443
- SELECT source_id FROM unresolved_wikilinks
532
+ SELECT source_id, relationship FROM unresolved_wikilinks
444
533
  WHERE target_path = ? COLLATE NOCASE
445
534
  OR ? LIKE '%/' || target_path
446
- `).all(notePath, notePath) as { source_id: string }[];
535
+ `).all(notePath, notePath) as { source_id: string; relationship: string }[];
447
536
  } catch {
448
537
  return 0; // Table doesn't exist
449
538
  }
@@ -454,15 +543,91 @@ export function resolveUnresolvedWikilinks(
454
543
  for (const row of rows) {
455
544
  if (row.source_id === noteId) continue; // Skip self-links
456
545
 
457
- // Create the wikilink
458
- linkOps.createLink(db, row.source_id, noteId, WIKILINK_REL);
546
+ const relationship = row.relationship || WIKILINK_REL;
547
+ linkOps.createLink(db, row.source_id, noteId, relationship);
459
548
  resolved++;
460
549
 
461
- // Remove the unresolved entry
550
+ // Remove the unresolved entry (this exact relationship only — a
551
+ // source may have BOTH a wikilink and a structured-link forward-ref
552
+ // pending against the same target_path).
462
553
  db.prepare(
463
- "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
464
- ).run(row.source_id, notePath, notePath);
554
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
555
+ ).run(row.source_id, relationship, notePath, notePath);
465
556
  }
466
557
 
467
558
  return resolved;
468
559
  }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Structured links — same resolution + lazy forward-ref semantics as
563
+ // [[wikilinks]] (vault#555). A structured `links: [{target, relationship}]`
564
+ // entry (create-note/update-note) used to resolve by exact path only, with
565
+ // no basename fallback and no forward-ref queueing — silently dropping the
566
+ // edge whenever the equivalent `[[wikilink]]` would have resolved. These
567
+ // helpers give both the MCP tool layer (core/src/mcp.ts) and the REST
568
+ // handler layer (src/routes.ts) one shared implementation so they can't
569
+ // drift.
570
+ // ---------------------------------------------------------------------------
571
+
572
+ /**
573
+ * Resolve a structured-link `target` — an ID or a path/title — to a note
574
+ * ID. ID lookup first (structured links accept "note ID or path" per the
575
+ * tool docs; wikilinks never carry a raw ID), then the SAME path/basename
576
+ * resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
577
+ * extension, exact path, basename).
578
+ */
579
+ export function resolveLinkTarget(db: Database, idOrPath: string): string | null {
580
+ const byId = db.prepare("SELECT id FROM notes WHERE id = ?").get(idOrPath) as { id: string } | null;
581
+ if (byId) return byId.id;
582
+ return resolveWikilink(db, idOrPath);
583
+ }
584
+
585
+ /**
586
+ * Resolve a structured-link `target` (create-note/update-note `links`) to
587
+ * its full {@link Note} — same ID-or-path/title semantics as `[[wikilinks]]`
588
+ * via {@link resolveLinkTarget}. Used for `links.remove`, where the
589
+ * bracket-cleanup step needs the target's `path`, not just its ID.
590
+ * `links.add` resolution goes through {@link resolveOrQueueLink} instead
591
+ * (it also queues a forward-ref on a miss). Single home for both the MCP
592
+ * tool layer (core/src/mcp.ts) and the REST handler layer (src/routes.ts) —
593
+ * vault#555 generalist review, was duplicated verbatim in both.
594
+ */
595
+ export function resolveStructuredLinkNote(db: Database, target: string): Note | null {
596
+ const id = resolveLinkTarget(db, target);
597
+ return id ? getNote(db, id) : null;
598
+ }
599
+
600
+ /** Queue a structured-link forward-ref for lazy resolution. */
601
+ export function queueUnresolvedLink(
602
+ db: Database,
603
+ sourceId: string,
604
+ targetPath: string,
605
+ relationship: string,
606
+ ): void {
607
+ ensureUnresolvedTable(db);
608
+ db.prepare(
609
+ "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path, relationship) VALUES (?, ?, ?)",
610
+ ).run(sourceId, targetPath, relationship);
611
+ }
612
+
613
+ /**
614
+ * Resolve a structured link NOW, or queue it for lazy resolution when the
615
+ * target doesn't exist yet — mirroring the wikilink forward-ref contract
616
+ * (a target created later, in this same batch or a future call, backfills
617
+ * the edge automatically via {@link resolveUnresolvedWikilinks}). Returns
618
+ * the resolved note ID, or `null` when queued. Callers MUST surface an
619
+ * `unresolved_link` warning naming the target when this returns `null` —
620
+ * the write itself never fails, but silence is never the fallback
621
+ * (vault#555 — "the API should never silently do the wrong thing").
622
+ */
623
+ export function resolveOrQueueLink(
624
+ db: Database,
625
+ sourceId: string,
626
+ target: string,
627
+ relationship: string,
628
+ ): string | null {
629
+ const resolvedId = resolveLinkTarget(db, target);
630
+ if (resolvedId) return resolvedId;
631
+ queueUnresolvedLink(db, sourceId, target, relationship);
632
+ return null;
633
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.7",
3
+ "version": "0.7.0-rc.8",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -261,4 +261,77 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
261
261
  const bRecord = await store.getTagRecord("b");
262
262
  expect(bRecord?.fields ?? null).toBeFalsy();
263
263
  });
264
+
265
+ // vault#555 fix 4 — a non-indexed field's `type` was NEVER validated
266
+ // anywhere: `mapFieldType` (the only prior type check) ran solely on
267
+ // `indexed: true` fields. `update-tag{fields:{weird:{type:"frobnicator"}}}`
268
+ // used to be silently accepted and persisted verbatim.
269
+ it("REST PUT /api/tags/:name rejects an unrecognized field type with 422 tag_field_conflict / invalid_type", async () => {
270
+ const req = new Request("http://localhost/api/tags/widget", {
271
+ method: "PUT",
272
+ body: JSON.stringify({ fields: { weird: { type: "frobnicator" } } }),
273
+ });
274
+ const res = await handleTags(req, store, "/widget");
275
+ expect(res.status).toBe(422);
276
+ const body: any = await res.json();
277
+ expect(body.error_type).toBe("tag_field_conflict");
278
+ expect(body.violations).toHaveLength(1);
279
+ expect(body.violations[0].field).toBe("weird");
280
+ expect(body.violations[0].reason).toBe("invalid_type");
281
+ expect(body.violations[0].message).toContain("frobnicator");
282
+ expect(body.violations[0].message).toContain("string");
283
+
284
+ // Nothing persisted.
285
+ const record = await store.getTagRecord("widget");
286
+ expect(record?.fields ?? null).toBeFalsy();
287
+ });
288
+
289
+ // vault#555 fix 5 — invalid_field_default used to be FAIL-FAST on REST
290
+ // (a single-violation 400, silently dropping every OTHER bad field in the
291
+ // same call). Now bundled with invalid_type (and any cross-tag
292
+ // violations) into ONE 422 report — every invalid field at once.
293
+ it("REST PUT /api/tags/:name reports a bad default AND an unrecognized type together, not first-only", async () => {
294
+ const req = new Request("http://localhost/api/tags/widget", {
295
+ method: "PUT",
296
+ body: JSON.stringify({
297
+ fields: {
298
+ weird: { type: "frobnicator" },
299
+ bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
300
+ },
301
+ }),
302
+ });
303
+ const res = await handleTags(req, store, "/widget");
304
+ expect(res.status).toBe(422);
305
+ const body: any = await res.json();
306
+ expect(body.error_type).toBe("tag_field_conflict");
307
+ expect(body.violations).toHaveLength(2);
308
+ const byField = new Map(body.violations.map((v: any) => [v.field, v.reason]));
309
+ expect(byField.get("weird")).toBe("invalid_type");
310
+ expect(byField.get("bad_default")).toBe("invalid_default");
311
+ expect(body.message).toContain("no changes were applied");
312
+
313
+ // Nothing persisted.
314
+ const record = await store.getTagRecord("widget");
315
+ expect(record?.fields ?? null).toBeFalsy();
316
+ });
317
+
318
+ // Positive control — every one of the six recognized types (indexable or
319
+ // not) is accepted without complaint.
320
+ it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
321
+ const req = new Request("http://localhost/api/tags/widget", {
322
+ method: "PUT",
323
+ body: JSON.stringify({
324
+ fields: {
325
+ a: { type: "string" },
326
+ b: { type: "number" },
327
+ c: { type: "integer" },
328
+ d: { type: "boolean" },
329
+ e: { type: "array" },
330
+ f: { type: "object" },
331
+ },
332
+ }),
333
+ });
334
+ const res = await handleTags(req, store, "/widget");
335
+ expect(res.status).toBe(200);
336
+ });
264
337
  });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * MCP JSON-RPC error mapping — no double-wrap (vault#555 fix 6).
3
+ *
4
+ * Verified at code: the catch block in `handleMcp` read `err.message` and
5
+ * fed it into a fresh `new McpError(...)`. The MCP SDK's `McpError`
6
+ * constructor bakes `"MCP error <code>: "` into `.message` ITSELF (not just
7
+ * `.toString()`) — confirmed directly against the SDK below. So an already-
8
+ * formed `McpError` reaching that catch block (or a plain error whose
9
+ * `.message` happens to already carry that prefix — e.g. forwarded verbatim
10
+ * from another MCP-speaking service) got double-prefixed:
11
+ * `"MCP error -32602: MCP error -32602: ..."`. The structured
12
+ * `data.error_type` was always correct — only the human-readable `message`
13
+ * string could double.
14
+ *
15
+ * `handleMcp` (the underlying dispatcher `handleScopedMcp` wraps) is
16
+ * exported specifically so a test can inject a synthetic failing tool and
17
+ * exercise the exact catch-block path without needing a real domain error
18
+ * class that happens to throw an `McpError` naturally (none of core's tools
19
+ * do — `McpError` is only ever constructed inside `mcp-http.ts` itself).
20
+ */
21
+ import { describe, test, expect } from "bun:test";
22
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
23
+ import { handleMcp, mcpDomainError } from "./mcp-http.ts";
24
+ import type { McpToolDef } from "../core/src/mcp.ts";
25
+ import type { AuthResult } from "./auth.ts";
26
+
27
+ function toolsWith(execute: (params: Record<string, unknown>) => unknown): () => McpToolDef[] {
28
+ return () => [
29
+ {
30
+ name: "boom",
31
+ description: "throws whatever `execute` throws",
32
+ inputSchema: { type: "object", properties: {} },
33
+ requiredVerb: "read",
34
+ execute,
35
+ },
36
+ ];
37
+ }
38
+
39
+ function fullAuth(): AuthResult {
40
+ return {
41
+ permission: "full",
42
+ scopes: ["vault:v:read", "vault:v:write", "vault:v:admin"],
43
+ legacyDerived: false,
44
+ scoped_tags: null,
45
+ vault_name: null,
46
+ caller_jti: null,
47
+ actor: "test-user",
48
+ via: "api",
49
+ };
50
+ }
51
+
52
+ async function callBoom(execute: (params: Record<string, unknown>) => unknown): Promise<any> {
53
+ const req = new Request("http://localhost:1940/vault/v/mcp", {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
56
+ body: JSON.stringify({
57
+ jsonrpc: "2.0",
58
+ id: 1,
59
+ method: "tools/call",
60
+ params: { name: "boom", arguments: {} },
61
+ }),
62
+ });
63
+ const res = await handleMcp(req, toolsWith(execute), "test-server", "v", fullAuth(), "");
64
+ return res.json();
65
+ }
66
+
67
+ describe("MCP SDK McpError — confirms the mechanics this fix depends on", () => {
68
+ test("the SDK bakes the JSON-RPC prefix into .message itself, not just .toString()", () => {
69
+ const err = new McpError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
70
+ expect(err.message).toBe("MCP error -32602: bad stuff happened");
71
+ // Naive re-wrap (the pre-fix behavior) doubles it — this is the exact
72
+ // bug, reproduced directly against the SDK class.
73
+ const naiveRewrap = new McpError(ErrorCode.InvalidParams, err.message, { error_type: "invalid_query" });
74
+ expect(naiveRewrap.message).toBe("MCP error -32602: MCP error -32602: bad stuff happened");
75
+ });
76
+ });
77
+
78
+ describe("mcpDomainError — single source of truth for the JSON-RPC mapping (vault#555 fix 6)", () => {
79
+ test("a plain message gets the error_type token prepended (optional polish)", () => {
80
+ const err = mcpDomainError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
81
+ expect(err.message).toBe("MCP error -32602: [invalid_query] bad stuff happened");
82
+ expect(err.data).toEqual({ error_type: "invalid_query" });
83
+ });
84
+
85
+ test("a message that ALREADY carries the JSON-RPC prefix is not doubled", () => {
86
+ const err = mcpDomainError(ErrorCode.InvalidParams, "MCP error -32602: bad stuff happened", {
87
+ error_type: "invalid_query",
88
+ });
89
+ expect(err.message).toBe("MCP error -32602: [invalid_query] bad stuff happened");
90
+ expect(err.message).not.toContain("MCP error -32602: MCP error");
91
+ });
92
+
93
+ test("no error_type in data — message passes through without a bracketed token", () => {
94
+ const err = mcpDomainError(ErrorCode.InternalError, "something broke", {});
95
+ expect(err.message).toBe("MCP error -32603: something broke");
96
+ });
97
+ });
98
+
99
+ describe("handleMcp JSON-RPC error mapping — end to end (vault#555 fix 6)", () => {
100
+ test("a tool throwing an ALREADY-formed McpError passes through unchanged — no double prefix, data preserved verbatim", async () => {
101
+ const inner = new McpError(ErrorCode.InvalidRequest, "conflict: note was modified", {
102
+ error_type: "conflict",
103
+ note_id: "abc123",
104
+ hint: "re-read and retry",
105
+ });
106
+ const body = await callBoom(() => {
107
+ throw inner;
108
+ });
109
+ expect(body.error.code).toBe(ErrorCode.InvalidRequest);
110
+ // Single prefix — NOT "MCP error -32600: MCP error -32600: ...".
111
+ expect(body.error.message).toBe("MCP error -32600: conflict: note was modified");
112
+ expect((body.error.message.match(/MCP error/g) ?? []).length).toBe(1);
113
+ // data.error_type fidelity preserved exactly — nothing re-derived.
114
+ expect(body.error.data).toEqual({
115
+ error_type: "conflict",
116
+ note_id: "abc123",
117
+ hint: "re-read and retry",
118
+ });
119
+ });
120
+
121
+ test("a plain domain error (never wrapped) is mapped once, with the error_type token in the human message", async () => {
122
+ const body = await callBoom(() => {
123
+ throw Object.assign(new Error("something went wrong"), { error_type: "invalid_query", field: "limit" });
124
+ });
125
+ expect(body.error.message).toBe("MCP error -32602: [invalid_query] something went wrong");
126
+ expect((body.error.message.match(/MCP error/g) ?? []).length).toBe(1);
127
+ expect(body.error.data.error_type).toBe("invalid_query");
128
+ expect(body.error.data.field).toBe("limit");
129
+ });
130
+
131
+ test("a truly unknown error (no error_type anywhere) falls through to the unstructured isError text, not a thrown McpError", async () => {
132
+ const body = await callBoom(() => {
133
+ throw new Error("plain unstructured failure");
134
+ });
135
+ // No JSON-RPC `error` — the tool result carries isError: true instead.
136
+ expect(body.error).toBeUndefined();
137
+ expect(body.result.isError).toBe(true);
138
+ expect(body.result.content[0].text).toContain("plain unstructured failure");
139
+ });
140
+ });