@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.
- package/core/src/core.test.ts +413 -0
- package/core/src/mcp.ts +168 -27
- package/core/src/notes.ts +12 -4
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +168 -25
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
|
@@ -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
|
+
});
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
431
|
-
* Called when a note is created or
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
linkOps.createLink(db, row.source_id, noteId,
|
|
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
|
@@ -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
|
+
});
|