@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5
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/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +92 -3
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +190 -47
- package/core/src/notes.ts +107 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +231 -4
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +137 -8
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +350 -79
- package/src/routing.ts +29 -0
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +109 -0
- package/src/vault.test.ts +25 -8
|
@@ -1441,6 +1441,113 @@ describe("portable-md non-markdown round-trip (vault#328)", async () => {
|
|
|
1441
1441
|
// No sidecar → no DB row.
|
|
1442
1442
|
expect(stats.notes_created).toBe(0);
|
|
1443
1443
|
});
|
|
1444
|
+
|
|
1445
|
+
// -------------------------------------------------------------------------
|
|
1446
|
+
// vault#552 — the write-time parent_names cycle guard must NOT abort an
|
|
1447
|
+
// import of an OLD export that carries a mutual-cycle fixture (accepted
|
|
1448
|
+
// before the guard shipped). The offending parent_names is dropped +
|
|
1449
|
+
// warned, the tags + notes still land. On a blow-away replace this is
|
|
1450
|
+
// load-bearing: the vault was already wiped, so an uncaught throw here
|
|
1451
|
+
// would leave it EMPTY with no rollback.
|
|
1452
|
+
// -------------------------------------------------------------------------
|
|
1453
|
+
|
|
1454
|
+
/** Hand-build a portable-md export whose two tag schemas form an A↔B
|
|
1455
|
+
* parent_names cycle, plus one note tagged with `a`. */
|
|
1456
|
+
function writeCyclicSchemaExport(dir: string): void {
|
|
1457
|
+
mkdirSync(join(dir, SIDECAR_DIR, "schemas"), { recursive: true });
|
|
1458
|
+
writeFileSync(
|
|
1459
|
+
join(dir, SIDECAR_DIR, "vault.yaml"),
|
|
1460
|
+
"export_format_version: 1\nexported_at: '2026-07-09T00:00:00.000Z'\n",
|
|
1461
|
+
);
|
|
1462
|
+
writeFileSync(
|
|
1463
|
+
join(dir, SIDECAR_DIR, "schemas", "a.yaml"),
|
|
1464
|
+
"name: a\ndescription: tag a\nparent_names:\n - b\n",
|
|
1465
|
+
);
|
|
1466
|
+
writeFileSync(
|
|
1467
|
+
join(dir, SIDECAR_DIR, "schemas", "b.yaml"),
|
|
1468
|
+
"name: b\ndescription: tag b\nparent_names:\n - a\n",
|
|
1469
|
+
);
|
|
1470
|
+
writeFileSync(
|
|
1471
|
+
join(dir, "note1.md"),
|
|
1472
|
+
"---\nid: note1\npath: note1\ntags:\n - a\ncreated_at: '2026-07-09T00:00:00.000Z'\nupdated_at: '2026-07-09T00:00:00.000Z'\n---\nhello from note1\n",
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
it("imports an export carrying a parent_names cycle — the cycle is dropped + warned, not fatal (vault#552 MUST-FIX 1)", async () => {
|
|
1477
|
+
const outDir = join(tmpBase, "cyclic-schemas");
|
|
1478
|
+
writeCyclicSchemaExport(outDir);
|
|
1479
|
+
|
|
1480
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1481
|
+
// MUST complete, not throw.
|
|
1482
|
+
const stats = await importPortableVault(restored, { inDir: outDir });
|
|
1483
|
+
|
|
1484
|
+
// Both schemas + the note landed.
|
|
1485
|
+
expect(stats.schemas_restored).toBe(2);
|
|
1486
|
+
expect(stats.notes_created).toBe(1);
|
|
1487
|
+
expect((await restored.getNote("note1"))!.content.trimEnd()).toBe("hello from note1");
|
|
1488
|
+
expect(await restored.getTagRecord("a")).not.toBeNull();
|
|
1489
|
+
expect(await restored.getTagRecord("b")).not.toBeNull();
|
|
1490
|
+
|
|
1491
|
+
// Exactly one of the two had its parent_names dropped (whichever the
|
|
1492
|
+
// filesystem walk restored SECOND closes the cycle) and is recorded.
|
|
1493
|
+
expect(stats.skipped_schema_parents).toHaveLength(1);
|
|
1494
|
+
const skipped = stats.skipped_schema_parents[0]!;
|
|
1495
|
+
expect(["a", "b"]).toContain(skipped.tag);
|
|
1496
|
+
expect(skipped.reason).toMatch(/cycle/i);
|
|
1497
|
+
|
|
1498
|
+
// The surviving hierarchy is acyclic: at most one of a/b still carries
|
|
1499
|
+
// parent_names (the other was dropped). Never both.
|
|
1500
|
+
const aParents = (await restored.getTagRecord("a"))!.parent_names ?? null;
|
|
1501
|
+
const bParents = (await restored.getTagRecord("b"))!.parent_names ?? null;
|
|
1502
|
+
expect(Boolean(aParents) && Boolean(bParents)).toBe(false);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it("blow-away import of a cyclic-schema export leaves the vault populated, NOT empty (vault#552 MUST-FIX 1 — destructive path)", async () => {
|
|
1506
|
+
const outDir = join(tmpBase, "cyclic-blowaway");
|
|
1507
|
+
writeCyclicSchemaExport(outDir);
|
|
1508
|
+
|
|
1509
|
+
// Seed the target with a throwaway note so blow-away has something to
|
|
1510
|
+
// wipe — proving the wipe ran AND that the uncaught-throw regression
|
|
1511
|
+
// (wipe-then-abort → empty vault) is gone.
|
|
1512
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1513
|
+
await restored.createNote("doomed pre-existing note", { id: "old1", path: "old1" });
|
|
1514
|
+
|
|
1515
|
+
const stats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
|
|
1516
|
+
|
|
1517
|
+
expect(stats.notes_wiped).toBe(1); // the pre-existing note was wiped
|
|
1518
|
+
expect(await restored.getNote("old1")).toBeNull(); // gone
|
|
1519
|
+
// Vault is NOT left empty — the fixture note landed despite the cycle.
|
|
1520
|
+
expect((await restored.getNote("note1"))!.content.trimEnd()).toBe("hello from note1");
|
|
1521
|
+
expect(stats.skipped_schema_parents).toHaveLength(1);
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it("blow-away wipes a parent-before-child hierarchy with NO stale tag rows left (vault#552 MUST-FIX 2)", async () => {
|
|
1525
|
+
// The bug: blow-away's tag sweep called store.deleteTag(tag) with no
|
|
1526
|
+
// flag and ignored the return. listTagRecords is ORDER BY name, so the
|
|
1527
|
+
// namespace parent "task" is visited BEFORE its child "task/work" —
|
|
1528
|
+
// while the child's parent_names still references it — and deleteTag now
|
|
1529
|
+
// RETURNS {error:"tag_referenced_as_parent"} instead of throwing, so the
|
|
1530
|
+
// parent's row silently survived the "clean slate." Fixed by passing
|
|
1531
|
+
// {cascade:true} in the sweep.
|
|
1532
|
+
const target = new SqliteStore(new Database(":memory:"));
|
|
1533
|
+
await target.upsertTagRecord("task", { description: "parent" });
|
|
1534
|
+
await target.upsertTagRecord("task/work", { parent_names: ["task"], description: "child" });
|
|
1535
|
+
await target.createNote("stale note", { id: "stale1", path: "stale1", tags: ["task/work"] });
|
|
1536
|
+
|
|
1537
|
+
// Build a minimal UNRELATED export from a fresh store to replay.
|
|
1538
|
+
const sourceStore = new SqliteStore(new Database(":memory:"));
|
|
1539
|
+
await sourceStore.createNote("fresh body", { id: "fresh1", path: "fresh1", tags: ["fresh"] });
|
|
1540
|
+
const outDir = join(tmpBase, "blowaway-hierarchy-source");
|
|
1541
|
+
await exportVaultToDir(sourceStore, { outDir, exportedAt: "2026-07-09T00:00:00.000Z" });
|
|
1542
|
+
|
|
1543
|
+
await importPortableVault(target, { inDir: outDir, blowAway: true });
|
|
1544
|
+
|
|
1545
|
+
// After a full wipe, NEITHER old hierarchy tag row survives.
|
|
1546
|
+
const remaining = (await target.listTagRecords()).map((t) => t.tag);
|
|
1547
|
+
expect(remaining).not.toContain("task");
|
|
1548
|
+
expect(remaining).not.toContain("task/work");
|
|
1549
|
+
expect(await target.getNote("stale1")).toBeNull();
|
|
1550
|
+
});
|
|
1444
1551
|
});
|
|
1445
1552
|
|
|
1446
1553
|
// ---------------------------------------------------------------------------
|
package/core/src/portable-md.ts
CHANGED
|
@@ -76,6 +76,7 @@ import { readdirSync, readFileSync, realpathSync, statSync, mkdirSync, writeFile
|
|
|
76
76
|
import { basename, join, relative, extname, dirname, resolve as resolvePath, sep as pathSep } from "path";
|
|
77
77
|
import type { Store, Note, Link, Attachment } from "./types.js";
|
|
78
78
|
import type { TagRecord } from "./tag-schemas.js";
|
|
79
|
+
import { ParentCycleError } from "./tag-schemas.js";
|
|
79
80
|
|
|
80
81
|
// ---------------------------------------------------------------------------
|
|
81
82
|
// Format constants
|
|
@@ -1631,6 +1632,17 @@ export interface ImportStats {
|
|
|
1631
1632
|
* content file during the walk.
|
|
1632
1633
|
*/
|
|
1633
1634
|
skipped_sidecars: Array<{ sidecar_id: string; expected_path: string | null; expected_extension: string | null; reason: string }>;
|
|
1635
|
+
/**
|
|
1636
|
+
* Tag schemas whose `parent_names` was DROPPED on restore because it
|
|
1637
|
+
* would have created a hierarchy cycle (vault#552 — the write-time cycle
|
|
1638
|
+
* guard in `upsertTagRecord`). The tag itself is still restored (its
|
|
1639
|
+
* description/fields/relationships land); only the offending
|
|
1640
|
+
* `parent_names` is skipped, so the import completes instead of aborting
|
|
1641
|
+
* mid-flight on an uncaught `ParentCycleError`. An OLD export that carried
|
|
1642
|
+
* a mutual-cycle fixture (accepted before the guard shipped) still
|
|
1643
|
+
* imports — the cycle is warned, not fatal. Empty in the common case.
|
|
1644
|
+
*/
|
|
1645
|
+
skipped_schema_parents: Array<{ tag: string; parent_names: string[]; reason: string }>;
|
|
1634
1646
|
/** Set when the caller passed `blowAway: true`; counts notes removed. */
|
|
1635
1647
|
notes_wiped: number;
|
|
1636
1648
|
/**
|
|
@@ -1802,6 +1814,7 @@ export async function importVault(
|
|
|
1802
1814
|
skipped_links: [],
|
|
1803
1815
|
skipped_attachments: [],
|
|
1804
1816
|
skipped_sidecars: [],
|
|
1817
|
+
skipped_schema_parents: [],
|
|
1805
1818
|
notes_wiped: 0,
|
|
1806
1819
|
indexes_declared: 0,
|
|
1807
1820
|
};
|
|
@@ -1821,10 +1834,18 @@ export async function importVault(
|
|
|
1821
1834
|
}
|
|
1822
1835
|
}
|
|
1823
1836
|
// Clear tag rows too — `deleteNote` clears note_tags via FK cascade but
|
|
1824
|
-
// leaves the `tags` table rows in place (orphaned schemas).
|
|
1837
|
+
// leaves the `tags` table rows in place (orphaned schemas). `cascade:
|
|
1838
|
+
// true` is REQUIRED here (vault#552): a blow-away is a full wipe, and
|
|
1839
|
+
// `listTagRecords` returns rows `ORDER BY name`, so a namespace parent
|
|
1840
|
+
// (`task`) sorts before its child (`task/work`) and is visited while the
|
|
1841
|
+
// child's `parent_names` still references it — without `cascade`,
|
|
1842
|
+
// `deleteTag` would RETURN `{error: "tag_referenced_as_parent"}` (it no
|
|
1843
|
+
// longer throws) and silently leave the parent's row behind, defeating
|
|
1844
|
+
// the "clean slate." Since we're deleting every tag anyway, cascading the
|
|
1845
|
+
// parent_names strip is exactly right.
|
|
1825
1846
|
const tagRecords = await store.listTagRecords();
|
|
1826
1847
|
for (const tag of tagRecords) {
|
|
1827
|
-
await store.deleteTag(tag.tag);
|
|
1848
|
+
await store.deleteTag(tag.tag, { cascade: true });
|
|
1828
1849
|
}
|
|
1829
1850
|
}
|
|
1830
1851
|
|
|
@@ -1842,12 +1863,37 @@ export async function importVault(
|
|
|
1842
1863
|
stats.schemas_restored++;
|
|
1843
1864
|
continue;
|
|
1844
1865
|
}
|
|
1845
|
-
|
|
1866
|
+
const parentNames = (frontmatter.parent_names as string[] | null | undefined) ?? null;
|
|
1867
|
+
const patch = {
|
|
1846
1868
|
description: (frontmatter.description as string | null | undefined) ?? null,
|
|
1847
1869
|
fields: (frontmatter.fields as Record<string, unknown> | null | undefined) as any ?? null,
|
|
1848
1870
|
relationships: (frontmatter.relationships as Record<string, unknown> | null | undefined) as any ?? null,
|
|
1849
|
-
parent_names:
|
|
1850
|
-
}
|
|
1871
|
+
parent_names: parentNames,
|
|
1872
|
+
};
|
|
1873
|
+
try {
|
|
1874
|
+
await store.upsertTagRecord(tagName, patch);
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
// vault#552: the write-time cycle guard rejects a `parent_names` that
|
|
1877
|
+
// would close a hierarchy cycle. An OLD export carrying a mutual-cycle
|
|
1878
|
+
// fixture (accepted before the guard shipped) must still import —
|
|
1879
|
+
// dropping the offending `parent_names` and warning, not aborting the
|
|
1880
|
+
// whole import mid-flight (import isn't atomic; on a blow-away replace
|
|
1881
|
+
// the vault was already wiped in step 1, so an uncaught throw here
|
|
1882
|
+
// would leave it EMPTY). Retry WITHOUT parent_names so the tag's
|
|
1883
|
+
// description/fields/relationships still land; record a skip warning
|
|
1884
|
+
// mirroring the skipped_links/skipped_attachments/skipped_sidecars
|
|
1885
|
+
// precedent. Any OTHER error still propagates.
|
|
1886
|
+
if (err instanceof ParentCycleError) {
|
|
1887
|
+
stats.skipped_schema_parents.push({
|
|
1888
|
+
tag: tagName,
|
|
1889
|
+
parent_names: parentNames ?? [],
|
|
1890
|
+
reason: `parent_names would create a hierarchy cycle (${err.cycle.join(" → ")}); dropped on import`,
|
|
1891
|
+
});
|
|
1892
|
+
await store.upsertTagRecord(tagName, { ...patch, parent_names: null });
|
|
1893
|
+
} else {
|
|
1894
|
+
throw err;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1851
1897
|
stats.schemas_restored++;
|
|
1852
1898
|
}
|
|
1853
1899
|
|
package/core/src/store.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type ConformanceReport,
|
|
36
36
|
} from "./conformance.js";
|
|
37
37
|
import type { SearchMode } from "./search-query.js";
|
|
38
|
+
import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -457,10 +458,15 @@ export class BunSqliteStore implements Store {
|
|
|
457
458
|
return noteOps.listTags(this.db);
|
|
458
459
|
}
|
|
459
460
|
|
|
460
|
-
async deleteTag(name: string): Promise<
|
|
461
|
-
const result = noteOps.deleteTag(this.db, name);
|
|
461
|
+
async deleteTag(name: string, opts?: noteOps.DeleteTagOpts): Promise<noteOps.DeleteTagResult> {
|
|
462
|
+
const result = noteOps.deleteTag(this.db, name, opts);
|
|
463
|
+
// Referential-integrity refusal (vault#552) — nothing was written;
|
|
464
|
+
// caches and hooks are untouched.
|
|
465
|
+
if ("error" in result) return result;
|
|
462
466
|
// The deleted tag may have been a parent or child in the hierarchy
|
|
463
|
-
// and may have declared `fields` powering schema validation.
|
|
467
|
+
// and may have declared `fields` powering schema validation. A
|
|
468
|
+
// cascade/detach repair also rewrites OTHER tags' parent_names, so
|
|
469
|
+
// busting the hierarchy cache covers both cases.
|
|
464
470
|
this._tagHierarchy = null;
|
|
465
471
|
this._schemaConfig = null;
|
|
466
472
|
// Fire "deleted" only when SOMETHING happened (the underlying
|
|
@@ -644,6 +650,14 @@ export class BunSqliteStore implements Store {
|
|
|
644
650
|
return count;
|
|
645
651
|
}
|
|
646
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Read-only taxonomy/metadata integrity scan (vault#552). Pure read — no
|
|
655
|
+
* cache invalidation needed since nothing is written.
|
|
656
|
+
*/
|
|
657
|
+
async doctor(opts?: DoctorScanOpts): Promise<DoctorReport> {
|
|
658
|
+
return runDoctorScan(this.db, opts);
|
|
659
|
+
}
|
|
660
|
+
|
|
647
661
|
// ---- Tag Records (post-v14: full identity row) ----
|
|
648
662
|
|
|
649
663
|
async listTagRecords() {
|
|
@@ -388,3 +388,69 @@ export function findHierarchyCycles(h: TagHierarchy): string[] {
|
|
|
388
388
|
}
|
|
389
389
|
return cycles;
|
|
390
390
|
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Cycle guard for a PROSPECTIVE `parent_names` write (vault#552). Setting
|
|
394
|
+
* `tag`'s parents to include `p` adds the edge `childrenOf[p] += tag` (tag
|
|
395
|
+
* becomes a child of `p`). That closes a cycle iff `tag` can ALREADY reach
|
|
396
|
+
* `p` via the existing hierarchy — i.e. `p` is already in `tag`'s descendant
|
|
397
|
+
* set — because the new edge would then let `p` reach back to itself through
|
|
398
|
+
* `tag`. Self-parent (`parentNames` containing `tag` itself) is caught by the
|
|
399
|
+
* same check: `getTagDescendants` always includes `tag` in its own result.
|
|
400
|
+
*
|
|
401
|
+
* Returns the offending cycle path (`[tag, ...existing chain down to p, p,
|
|
402
|
+
* tag]`, closing the loop) for the FIRST conflicting parent found, or `null`
|
|
403
|
+
* when no cycle would result. `h` must reflect the hierarchy BEFORE this
|
|
404
|
+
* write — the check only depends on OTHER tags' parent_names (edges pointing
|
|
405
|
+
* away from `tag`), never `tag`'s own current parent_names, so passing the
|
|
406
|
+
* pre-write hierarchy is always correct regardless of write ordering.
|
|
407
|
+
*/
|
|
408
|
+
export function findParentCycle(
|
|
409
|
+
h: TagHierarchy,
|
|
410
|
+
tag: string,
|
|
411
|
+
parentNames: string[],
|
|
412
|
+
): string[] | null {
|
|
413
|
+
const descendants = getTagDescendants(h, tag);
|
|
414
|
+
for (const p of parentNames) {
|
|
415
|
+
if (descendants.has(p)) {
|
|
416
|
+
return [...findChildPath(h, tag, p), tag];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* BFS shortest path from `from` to `to` following `childrenOf` edges
|
|
424
|
+
* (parent → child). Used only to render a human-legible cycle path for
|
|
425
|
+
* {@link findParentCycle}'s error — the existence of a path is already
|
|
426
|
+
* established by the caller (`to` is a known member of `from`'s descendant
|
|
427
|
+
* set) before this runs, so the fallback return is unreachable in practice
|
|
428
|
+
* and exists only to keep the function total.
|
|
429
|
+
*/
|
|
430
|
+
function findChildPath(h: TagHierarchy, from: string, to: string): string[] {
|
|
431
|
+
if (from === to) return [from];
|
|
432
|
+
const queue: string[] = [from];
|
|
433
|
+
const cameFrom = new Map<string, string>();
|
|
434
|
+
const visited = new Set<string>([from]);
|
|
435
|
+
while (queue.length > 0) {
|
|
436
|
+
const current = queue.shift()!;
|
|
437
|
+
const children = h.childrenOf.get(current);
|
|
438
|
+
if (!children) continue;
|
|
439
|
+
for (const child of children) {
|
|
440
|
+
if (visited.has(child)) continue;
|
|
441
|
+
visited.add(child);
|
|
442
|
+
cameFrom.set(child, current);
|
|
443
|
+
if (child === to) {
|
|
444
|
+
const path = [to];
|
|
445
|
+
let node = to;
|
|
446
|
+
while (cameFrom.has(node)) {
|
|
447
|
+
node = cameFrom.get(node)!;
|
|
448
|
+
path.unshift(node);
|
|
449
|
+
}
|
|
450
|
+
return path;
|
|
451
|
+
}
|
|
452
|
+
queue.push(child);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return [from, to];
|
|
456
|
+
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Database } from "bun:sqlite";
|
|
19
|
+
import { mapFieldType, validateFieldName } from "./indexed-fields.js";
|
|
20
|
+
import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Types
|
|
@@ -131,10 +133,48 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
|
|
|
131
133
|
return row ? rowToRecord(row) : null;
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Thrown by `upsertTagRecord` (and therefore by both `PUT /api/tags/:name`
|
|
138
|
+
* and the MCP `update-tag` tool — this is the single chokepoint both share,
|
|
139
|
+
* see `core/src/store.ts:upsertTagRecord`) when the incoming `parent_names`
|
|
140
|
+
* would create a cycle in the hierarchy (vault#552). Traversal elsewhere
|
|
141
|
+
* (`getTagDescendants`) is already cycle-safe — a visited-set stops it from
|
|
142
|
+
* looping forever — but the WRITE itself was dishonest about creating one:
|
|
143
|
+
* an adversarial or accidental `A→B` then `B→A` (or a bare self-parent)
|
|
144
|
+
* silently succeeded pre-#552. `cycle` is the offending path, e.g.
|
|
145
|
+
* `["A", "B", "A"]` for a direct A↔B cycle, or the longer chain for a
|
|
146
|
+
* transitive one; the SERVER layer scope-scrubs out-of-scope names in it
|
|
147
|
+
* for a tag-scoped caller (`scrubParentCycleError` in src/tag-scope.ts),
|
|
148
|
+
* same posture as `TagFieldConflictError`.
|
|
149
|
+
*/
|
|
150
|
+
export class ParentCycleError extends Error {
|
|
151
|
+
code = "PARENT_CYCLE" as const;
|
|
152
|
+
error_type = "parent_cycle" as const;
|
|
153
|
+
tag: string;
|
|
154
|
+
cycle: string[];
|
|
155
|
+
|
|
156
|
+
constructor(tag: string, cycle: string[]) {
|
|
157
|
+
super(
|
|
158
|
+
`parent_cycle: setting "${tag}"'s parent_names would create a cycle: ${cycle.join(" → ")}`,
|
|
159
|
+
);
|
|
160
|
+
this.name = "ParentCycleError";
|
|
161
|
+
this.tag = tag;
|
|
162
|
+
this.cycle = cycle;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
/**
|
|
135
167
|
* Upsert a tag record — partial update. Any field left `undefined` is
|
|
136
168
|
* preserved. Pass `null` explicitly to clear a column. Always touches
|
|
137
169
|
* `updated_at`; sets `created_at` on first insert.
|
|
170
|
+
*
|
|
171
|
+
* Cycle guard (vault#552): when `patch.parent_names` is a non-empty array,
|
|
172
|
+
* validate it against the CURRENT hierarchy (loaded fresh — this function is
|
|
173
|
+
* the shared chokepoint for both REST and MCP, so it can't rely on a
|
|
174
|
+
* possibly-stale caller cache) before touching any row. A conflicting parent
|
|
175
|
+
* throws {@link ParentCycleError} and nothing is persisted — same
|
|
176
|
+
* fail-closed-before-any-write posture as the cross-tag field-conflict
|
|
177
|
+
* checks in this module.
|
|
138
178
|
*/
|
|
139
179
|
export function upsertTagRecord(
|
|
140
180
|
db: Database,
|
|
@@ -146,6 +186,12 @@ export function upsertTagRecord(
|
|
|
146
186
|
parent_names?: string[] | null;
|
|
147
187
|
},
|
|
148
188
|
): TagRecord {
|
|
189
|
+
if (patch.parent_names != null && patch.parent_names.length > 0) {
|
|
190
|
+
const hierarchy = loadTagHierarchy(db);
|
|
191
|
+
const cycle = findParentCycle(hierarchy, tag, patch.parent_names);
|
|
192
|
+
if (cycle) throw new ParentCycleError(tag, cycle);
|
|
193
|
+
}
|
|
194
|
+
|
|
149
195
|
const now = new Date().toISOString();
|
|
150
196
|
db.prepare(
|
|
151
197
|
"INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
|
|
@@ -280,17 +326,23 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
|
|
|
280
326
|
* time"); dropping the inner-shape gate is consistent with that intent.
|
|
281
327
|
*/
|
|
282
328
|
export function validateRelationships(raw: unknown): Record<string, unknown> {
|
|
329
|
+
// `error_type` (vault#554) is attached directly on the thrown Error
|
|
330
|
+
// (duck-typed extra property, same pattern as QueryError's optional
|
|
331
|
+
// fields) rather than via a dedicated class — this is a validation
|
|
332
|
+
// leaf with one caller-facing shape, not a reusable domain error. Both
|
|
333
|
+
// REST (src/routes.ts, which already hardcodes this same string) and the
|
|
334
|
+
// generic MCP domain-error mapping (src/mcp-http.ts) key off it.
|
|
283
335
|
if (raw === null || raw === undefined) {
|
|
284
|
-
throw
|
|
336
|
+
throw relationshipsError("relationships: expected an object, got null/undefined");
|
|
285
337
|
}
|
|
286
338
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
287
|
-
throw
|
|
339
|
+
throw relationshipsError(
|
|
288
340
|
"relationships: expected an object mapping relationship name → value (got an array or primitive)",
|
|
289
341
|
);
|
|
290
342
|
}
|
|
291
343
|
for (const rel of Object.keys(raw as Record<string, unknown>)) {
|
|
292
344
|
if (!rel) {
|
|
293
|
-
throw
|
|
345
|
+
throw relationshipsError("relationships: keys must be non-empty strings");
|
|
294
346
|
}
|
|
295
347
|
}
|
|
296
348
|
// Round-trip through JSON to (a) confirm the payload is serializable —
|
|
@@ -300,11 +352,15 @@ export function validateRelationships(raw: unknown): Record<string, unknown> {
|
|
|
300
352
|
try {
|
|
301
353
|
serialized = JSON.stringify(raw);
|
|
302
354
|
} catch (err) {
|
|
303
|
-
throw
|
|
355
|
+
throw relationshipsError(`relationships: value must be JSON-serializable (${(err as Error).message})`);
|
|
304
356
|
}
|
|
305
357
|
return JSON.parse(serialized) as Record<string, unknown>;
|
|
306
358
|
}
|
|
307
359
|
|
|
360
|
+
function relationshipsError(message: string): Error {
|
|
361
|
+
return Object.assign(new Error(message), { error_type: "invalid_relationships" as const });
|
|
362
|
+
}
|
|
363
|
+
|
|
308
364
|
// ---------------------------------------------------------------------------
|
|
309
365
|
// Helpers
|
|
310
366
|
// ---------------------------------------------------------------------------
|
|
@@ -334,3 +390,174 @@ function jsonOrNull(value: unknown): string | null {
|
|
|
334
390
|
}
|
|
335
391
|
return JSON.stringify(value);
|
|
336
392
|
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Cross-tag field validation (vault#553 / #554) — update-tag messaging
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A single field-declaration violation surfaced by {@link collectTagFieldViolations}.
|
|
400
|
+
* `reason` is a stable machine-checkable slug; `message` is the existing
|
|
401
|
+
* human-readable prose (unchanged wording, just no longer thrown on the
|
|
402
|
+
* first hit).
|
|
403
|
+
*/
|
|
404
|
+
export interface TagFieldViolation {
|
|
405
|
+
field: string;
|
|
406
|
+
reason: "type_conflict" | "indexed_flag_conflict" | "unsupported_indexed_type" | "invalid_field_name";
|
|
407
|
+
message: string;
|
|
408
|
+
/**
|
|
409
|
+
* The conflicting declarer tag — present on the cross-tag reasons
|
|
410
|
+
* (`type_conflict` / `indexed_flag_conflict`) only; the solo own-field
|
|
411
|
+
* reasons have no other party. Core populates it unconditionally
|
|
412
|
+
* (scope-unaware by architecture); the SERVER layers scrub it — and
|
|
413
|
+
* generalize `message` — when the declarer is outside a tag-scoped
|
|
414
|
+
* caller's allowlist (`scrubTagFieldViolationsByScope` in
|
|
415
|
+
* src/tag-scope.ts), so a scoped session learns THAT a conflict exists
|
|
416
|
+
* (the write is still rejected — integrity is scope-independent) but not
|
|
417
|
+
* WHICH out-of-scope tag it conflicts with or what that tag declares.
|
|
418
|
+
*/
|
|
419
|
+
other_tag?: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Thrown by `update-tag` (MCP tool + REST `PUT /api/tags/:name`) when one or
|
|
424
|
+
* more fields in the incoming `fields` payload conflict with another tag's
|
|
425
|
+
* declaration, or declare an unindexable/invalid indexed field. Carries
|
|
426
|
+
* EVERY violation found in the call (vault#553 settled complaint: the prior
|
|
427
|
+
* behavior threw on the FIRST offending field and silently never reported
|
|
428
|
+
* the rest — two testers independently assumed the other fields had landed).
|
|
429
|
+
* The message states explicitly that no changes were applied — the whole
|
|
430
|
+
* `upsertTagRecord` call is validated BEFORE any write, so a rejection here
|
|
431
|
+
* always leaves the tag record untouched (atomicity unchanged from before;
|
|
432
|
+
* this only improves what the caller is told).
|
|
433
|
+
*/
|
|
434
|
+
export class TagFieldConflictError extends Error {
|
|
435
|
+
code = "TAG_FIELD_CONFLICT" as const;
|
|
436
|
+
error_type = "tag_field_conflict" as const;
|
|
437
|
+
tag: string;
|
|
438
|
+
violations: TagFieldViolation[];
|
|
439
|
+
|
|
440
|
+
constructor(tag: string, violations: TagFieldViolation[]) {
|
|
441
|
+
const summary = violations.map((v) => `${v.field} (${v.reason}): ${v.message}`).join("; ");
|
|
442
|
+
super(
|
|
443
|
+
`tag_field_conflict: ${violations.length} field violation(s) for tag "${tag}" — no changes were applied. ${summary}`,
|
|
444
|
+
);
|
|
445
|
+
this.name = "TagFieldConflictError";
|
|
446
|
+
this.tag = tag;
|
|
447
|
+
this.violations = violations;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Validate the fields a caller is declaring/redeclaring for `tag` against
|
|
453
|
+
* every OTHER tag's schema — `type` and `indexed` must agree across all
|
|
454
|
+
* declarers (both are global per field). Returns every violation found —
|
|
455
|
+
* never throws — so the caller can report the complete set in one
|
|
456
|
+
* structured error (or none, when the array is empty).
|
|
457
|
+
*
|
|
458
|
+
* `incomingFields` is the set of fields THIS call is declaring (MCP:
|
|
459
|
+
* `params.fields`; REST: `body.fields`) — not the full merged field map.
|
|
460
|
+
* Fields the call doesn't touch were already valid (or already accepted)
|
|
461
|
+
* and are not re-validated here, matching the pre-#553 scope of the check.
|
|
462
|
+
*
|
|
463
|
+
* Two deliberate exclusions preserve pre-existing wire contracts
|
|
464
|
+
* (vault#554 — statuses/error_types on previously-working calls must not
|
|
465
|
+
* change):
|
|
466
|
+
*
|
|
467
|
+
* 1. The SEPARATE own-field checks (unsupported type for indexing,
|
|
468
|
+
* invalid identifier) are NOT included — REST's `PUT /api/tags/:name`
|
|
469
|
+
* has an established, tested single-violation `IndexedFieldError` →
|
|
470
|
+
* 400 `invalid_indexed_field` contract for those (vault#478). Use
|
|
471
|
+
* {@link collectTagFieldViolations} (own-field checks included) for a
|
|
472
|
+
* caller — MCP's `update-tag` tool — with no such prior contract.
|
|
473
|
+
* 2. The type-conflict check is SKIPPED for any field whose INCOMING spec
|
|
474
|
+
* is `indexed: true` (wire review, vault#554): a both-indexed cross-tag
|
|
475
|
+
* type conflict already errors on main via `store.upsertTagRecord` →
|
|
476
|
+
* `declareField`'s cross-declarer sqlite-type check (`IndexedFieldError`
|
|
477
|
+
* → 400 `invalid_indexed_field`), and reporting it here would flip that
|
|
478
|
+
* established 400 to a 422. Letting it fall through preserves the 400.
|
|
479
|
+
* No case is silently re-accepted by the skip: an incoming-indexed type
|
|
480
|
+
* conflict against an INDEXED declarer hits declareField's 400; against
|
|
481
|
+
* a NON-indexed declarer the indexed-FLAG conflict below still fires
|
|
482
|
+
* (the flags necessarily differ). What this function newly reports —
|
|
483
|
+
* both silent 200s on main — is (a) type conflicts on NON-indexed
|
|
484
|
+
* incoming fields and (b) indexed-flag conflicts.
|
|
485
|
+
*/
|
|
486
|
+
export function collectCrossTagFieldViolations(
|
|
487
|
+
db: Database,
|
|
488
|
+
tag: string,
|
|
489
|
+
incomingFields: Record<string, TagFieldSchema>,
|
|
490
|
+
): TagFieldViolation[] {
|
|
491
|
+
const violations: TagFieldViolation[] = [];
|
|
492
|
+
const otherSchemas = listTagSchemas(db).filter((s) => s.tag !== tag);
|
|
493
|
+
|
|
494
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
495
|
+
const incomingIndexed = spec.indexed === true;
|
|
496
|
+
for (const other of otherSchemas) {
|
|
497
|
+
const otherSpec = other.fields?.[fieldName];
|
|
498
|
+
if (!otherSpec) continue;
|
|
499
|
+
// See doc comment exclusion 2: incoming-indexed type conflicts keep
|
|
500
|
+
// their pre-existing declareField → 400 invalid_indexed_field path.
|
|
501
|
+
if (!incomingIndexed && otherSpec.type !== spec.type) {
|
|
502
|
+
violations.push({
|
|
503
|
+
field: fieldName,
|
|
504
|
+
reason: "type_conflict",
|
|
505
|
+
message: `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
|
|
506
|
+
other_tag: other.tag,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
if ((otherSpec.indexed === true) !== incomingIndexed) {
|
|
510
|
+
violations.push({
|
|
511
|
+
field: fieldName,
|
|
512
|
+
reason: "indexed_flag_conflict",
|
|
513
|
+
message: `field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
|
|
514
|
+
other_tag: other.tag,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return violations;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Full field-violation collection: {@link collectCrossTagFieldViolations}
|
|
524
|
+
* PLUS the own-field checks (unsupported type for indexing, invalid
|
|
525
|
+
* identifier). Used by the MCP `update-tag` tool, which — unlike REST — had
|
|
526
|
+
* no prior single-violation status-code contract to preserve for those two
|
|
527
|
+
* checks (its old inline loop threw an unstructured `Error` for them, same
|
|
528
|
+
* as everything else pre-#554); collecting everything here is a strict
|
|
529
|
+
* improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
|
|
530
|
+
* why REST calls that narrower function directly instead of this one.
|
|
531
|
+
*/
|
|
532
|
+
export function collectTagFieldViolations(
|
|
533
|
+
db: Database,
|
|
534
|
+
tag: string,
|
|
535
|
+
incomingFields: Record<string, TagFieldSchema>,
|
|
536
|
+
): TagFieldViolation[] {
|
|
537
|
+
const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
|
|
538
|
+
|
|
539
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
540
|
+
if (spec.indexed === true) {
|
|
541
|
+
const mapped = mapFieldType(spec.type);
|
|
542
|
+
if (!mapped) {
|
|
543
|
+
violations.push({
|
|
544
|
+
field: fieldName,
|
|
545
|
+
reason: "unsupported_indexed_type",
|
|
546
|
+
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
547
|
+
});
|
|
548
|
+
} else {
|
|
549
|
+
try {
|
|
550
|
+
validateFieldName(fieldName);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
violations.push({
|
|
553
|
+
field: fieldName,
|
|
554
|
+
reason: "invalid_field_name",
|
|
555
|
+
message: (err as Error).message,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return violations;
|
|
563
|
+
}
|
package/core/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { SearchMode } from "./search-query.js";
|
|
|
6
6
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
7
7
|
import type { ConformanceReport } from "./conformance.js";
|
|
8
8
|
import type { FindPathResult } from "./links.js";
|
|
9
|
+
import type { DoctorReport, DoctorScanOpts } from "./doctor.js";
|
|
9
10
|
|
|
10
11
|
// ---- Re-exports ----
|
|
11
12
|
|
|
@@ -14,6 +15,7 @@ export type { PrunedField } from "./indexed-fields.js";
|
|
|
14
15
|
export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
15
16
|
export type { ConformanceReport } from "./conformance.js";
|
|
16
17
|
export type { FindPathResult } from "./links.js";
|
|
18
|
+
export type { DoctorReport, DoctorFinding, DoctorFindingType, DoctorSeverity, DoctorScanOpts } from "./doctor.js";
|
|
17
19
|
|
|
18
20
|
// ---- Note ----
|
|
19
21
|
|
|
@@ -364,7 +366,20 @@ export interface Store {
|
|
|
364
366
|
* alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
|
|
365
367
|
*/
|
|
366
368
|
listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
|
|
367
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Delete a tag. Refused (vault#552) when another tag's `parent_names`
|
|
371
|
+
* still references it — pass `cascade` or `detach` (synonyms; either
|
|
372
|
+
* strips the stale reference from the referencing tags' `parent_names`
|
|
373
|
+
* in the same transaction) to proceed anyway. Notes are never deleted,
|
|
374
|
+
* only untagged.
|
|
375
|
+
*/
|
|
376
|
+
deleteTag(
|
|
377
|
+
name: string,
|
|
378
|
+
opts?: { cascade?: boolean; detach?: boolean },
|
|
379
|
+
): Promise<
|
|
380
|
+
| { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
|
|
381
|
+
| { error: "tag_referenced_as_parent"; referencing_tags: string[] }
|
|
382
|
+
>;
|
|
368
383
|
renameTag(
|
|
369
384
|
oldName: string,
|
|
370
385
|
newName: string,
|
|
@@ -431,6 +446,13 @@ export interface Store {
|
|
|
431
446
|
*/
|
|
432
447
|
reconcileDeclaredIndexes(): Promise<number>;
|
|
433
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Read-only taxonomy/metadata integrity scan (vault#552). Never mutates —
|
|
451
|
+
* every finding carries a suggested `remedy` the caller applies
|
|
452
|
+
* deliberately. See `core/src/doctor.ts` for the finding-type catalog.
|
|
453
|
+
*/
|
|
454
|
+
doctor(opts?: DoctorScanOpts): Promise<DoctorReport>;
|
|
455
|
+
|
|
434
456
|
// Tag records — full v14 identity row (description + fields + typed
|
|
435
457
|
// relationships + parent_names + timestamps). See
|
|
436
458
|
// docs/contracts/tag-data-model.md.
|