@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
package/core/src/mcp.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMo
|
|
|
10
10
|
import * as linkOps from "./links.js";
|
|
11
11
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
12
12
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
13
|
-
import * as indexedFieldOps from "./indexed-fields.js";
|
|
14
13
|
import {
|
|
15
14
|
SchemaValidationError,
|
|
16
15
|
strictViolations,
|
|
@@ -53,6 +52,22 @@ export interface McpToolDef {
|
|
|
53
52
|
// Helpers
|
|
54
53
|
// ---------------------------------------------------------------------------
|
|
55
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Build a plain `Error` carrying a stable `error_type` (+ optional `field`/
|
|
57
|
+
* `hint`) as duck-typed extra properties — the same pattern `QueryError`
|
|
58
|
+
* uses for its optional structured fields. For validation leaves that don't
|
|
59
|
+
* warrant a dedicated exported class (one caller-facing shape, not a reusable
|
|
60
|
+
* domain error), this is what lets the generic domain-error mapping in
|
|
61
|
+
* `src/mcp-http.ts` surface `data.error_type` instead of falling through to
|
|
62
|
+
* the unstructured `isError: true` text fallback (vault#554).
|
|
63
|
+
*/
|
|
64
|
+
function structuredError(
|
|
65
|
+
message: string,
|
|
66
|
+
fields: { error_type: string; field?: string; hint?: string },
|
|
67
|
+
): Error {
|
|
68
|
+
return Object.assign(new Error(message), fields);
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
/**
|
|
57
72
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
58
73
|
* path match. Works everywhere a note reference is accepted.
|
|
@@ -86,7 +101,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
|
86
101
|
|
|
87
102
|
function requireNote(db: Database, idOrPath: string): Note {
|
|
88
103
|
const note = resolveNote(db, idOrPath);
|
|
89
|
-
if (!note)
|
|
104
|
+
if (!note) {
|
|
105
|
+
throw structuredError(`Note not found: "${idOrPath}"`, { error_type: "not_found", field: "id" });
|
|
106
|
+
}
|
|
90
107
|
return note;
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -173,9 +190,11 @@ export interface GenerateMcpToolsOpts {
|
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
/**
|
|
176
|
-
* Generate the consolidated MCP tools for a vault. Surface (
|
|
193
|
+
* Generate the consolidated MCP tools for a vault. Surface (13):
|
|
177
194
|
* query-notes, create-note, update-note, delete-note, list-tags, update-tag,
|
|
178
|
-
* delete-tag, find-path, vault-info, prune-schema
|
|
195
|
+
* delete-tag, rename-tag, merge-tags, find-path, vault-info, prune-schema
|
|
196
|
+
* (admin), doctor (admin). `manage-token` (admin) is appended by the SERVER
|
|
197
|
+
* layer (src/mcp-tools.ts), not here — see that file's doc comment.
|
|
179
198
|
*/
|
|
180
199
|
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
181
200
|
const db: Database = store.db;
|
|
@@ -423,7 +442,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
423
442
|
// --- Single note by ID/path ---
|
|
424
443
|
if (params.id) {
|
|
425
444
|
const note = resolveNote(db, params.id as string);
|
|
426
|
-
if (!note) return { error: "Note not found", id: params.id };
|
|
445
|
+
if (!note) return { error: "Note not found", error_type: "not_found", id: params.id };
|
|
427
446
|
const includeContent = params.include_content !== false; // default true for single
|
|
428
447
|
// Range params are meaningless on a content-less shape — error
|
|
429
448
|
// rather than silently ignore (same loud-validation policy as
|
|
@@ -471,7 +490,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
471
490
|
if (params.near) {
|
|
472
491
|
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
473
492
|
const anchor = resolveNote(db, near.note_id);
|
|
474
|
-
if (!anchor) return { error: "Anchor note not found", note_id: near.note_id };
|
|
493
|
+
if (!anchor) return { error: "Anchor note not found", error_type: "not_found", note_id: near.note_id };
|
|
475
494
|
const depth = Math.min(near.depth ?? 2, 5);
|
|
476
495
|
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
477
496
|
max_depth: depth,
|
|
@@ -914,7 +933,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
914
933
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
915
934
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
916
935
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
917
|
-
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
936
|
+
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
|
|
918
937
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
919
938
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
|
|
920
939
|
|
|
@@ -1043,7 +1062,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1043
1062
|
},
|
|
1044
1063
|
execute: async (params) => {
|
|
1045
1064
|
const batch = params.notes as any[] | undefined;
|
|
1046
|
-
|
|
1065
|
+
// vault#554: top-level `force` / `if_updated_at` apply as per-item
|
|
1066
|
+
// DEFAULTS in a batch call — item-level values win when both are
|
|
1067
|
+
// present. Before this fix `items = batch ?? [params]` never merged
|
|
1068
|
+
// the top-level fields into batch items at all, so a caller passing
|
|
1069
|
+
// `{force: true, notes: [...]}` had it silently ignored: every item
|
|
1070
|
+
// without its OWN `force`/`if_updated_at` still threw
|
|
1071
|
+
// `PreconditionRequiredError` (a gardener-reported round-trip cost).
|
|
1072
|
+
// The single-item form (`items = [params]`) already behaved this
|
|
1073
|
+
// way implicitly (params IS the item), so only the batch branch
|
|
1074
|
+
// needs the merge.
|
|
1075
|
+
const items = batch
|
|
1076
|
+
? batch.map((item: any) => ({
|
|
1077
|
+
...(params.force !== undefined ? { force: params.force } : {}),
|
|
1078
|
+
...(params.if_updated_at !== undefined ? { if_updated_at: params.if_updated_at } : {}),
|
|
1079
|
+
...item,
|
|
1080
|
+
}))
|
|
1081
|
+
: [params];
|
|
1047
1082
|
|
|
1048
1083
|
if (items.length > MAX_BATCH_SIZE) {
|
|
1049
1084
|
throw new BatchTooLargeError(items.length);
|
|
@@ -1175,7 +1210,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1175
1210
|
// Fallthrough: not-found + no if_missing → existing error
|
|
1176
1211
|
// contract. Match `requireNote`'s message shape so existing
|
|
1177
1212
|
// callers see no behavior change.
|
|
1178
|
-
throw
|
|
1213
|
+
throw structuredError(`Note not found: "${item.id}"`, { error_type: "not_found", field: "id" });
|
|
1179
1214
|
}
|
|
1180
1215
|
const note = resolved;
|
|
1181
1216
|
|
|
@@ -1185,8 +1220,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1185
1220
|
const hasContentEdit = item.content_edit !== undefined;
|
|
1186
1221
|
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
1187
1222
|
if (contentModes > 1) {
|
|
1188
|
-
throw
|
|
1223
|
+
throw structuredError(
|
|
1189
1224
|
`update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
|
|
1225
|
+
{ error_type: "mutually_exclusive", hint: "pass exactly one of `content`, `append`/`prepend`, or `content_edit`" },
|
|
1190
1226
|
);
|
|
1191
1227
|
}
|
|
1192
1228
|
|
|
@@ -1237,20 +1273,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1237
1273
|
if (hasContentEdit) {
|
|
1238
1274
|
const ce = item.content_edit as { old_text: string; new_text: string };
|
|
1239
1275
|
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1240
|
-
throw
|
|
1276
|
+
throw structuredError(
|
|
1241
1277
|
"update-note: `content_edit` requires { old_text: string, new_text: string }.",
|
|
1278
|
+
{ error_type: "invalid_content_edit", field: "content_edit", hint: "pass { old_text: string, new_text: string }" },
|
|
1242
1279
|
);
|
|
1243
1280
|
}
|
|
1244
1281
|
const idx = note.content.indexOf(ce.old_text);
|
|
1245
1282
|
if (idx < 0) {
|
|
1246
|
-
throw
|
|
1283
|
+
throw structuredError(
|
|
1247
1284
|
`update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
|
|
1285
|
+
{ error_type: "content_edit_not_found", field: "content_edit.old_text", hint: "re-read the note's current content and retry with an old_text that occurs exactly once" },
|
|
1248
1286
|
);
|
|
1249
1287
|
}
|
|
1250
1288
|
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1251
1289
|
if (second >= 0) {
|
|
1252
|
-
throw
|
|
1290
|
+
throw structuredError(
|
|
1253
1291
|
`update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
|
|
1292
|
+
{ error_type: "content_edit_ambiguous", field: "content_edit.old_text", hint: "add surrounding context to old_text so it matches exactly once" },
|
|
1254
1293
|
);
|
|
1255
1294
|
}
|
|
1256
1295
|
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
@@ -1315,8 +1354,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1315
1354
|
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1316
1355
|
if (stItem !== undefined) {
|
|
1317
1356
|
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1318
|
-
throw
|
|
1357
|
+
throw structuredError(
|
|
1319
1358
|
`update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
|
|
1359
|
+
{ error_type: "invalid_state_transition", field: "state_transition.field", hint: "pass a non-empty string naming the metadata field to transition" },
|
|
1320
1360
|
);
|
|
1321
1361
|
}
|
|
1322
1362
|
updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
|
|
@@ -1605,34 +1645,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1605
1645
|
|
|
1606
1646
|
// Validate cross-tag consistency on fields being (re)declared in this
|
|
1607
1647
|
// call. `type` and `indexed` are global — all declarers must agree.
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
if (!otherSpec) continue;
|
|
1616
|
-
if (otherSpec.type !== spec.type) {
|
|
1617
|
-
throw new Error(
|
|
1618
|
-
`field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
|
|
1619
|
-
);
|
|
1620
|
-
}
|
|
1621
|
-
if ((otherSpec.indexed === true) !== incomingIndexed) {
|
|
1622
|
-
throw new Error(
|
|
1623
|
-
`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.`,
|
|
1624
|
-
);
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
if (incomingIndexed) {
|
|
1628
|
-
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
1629
|
-
if (!mapped) {
|
|
1630
|
-
throw new Error(
|
|
1631
|
-
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
1632
|
-
);
|
|
1633
|
-
}
|
|
1634
|
-
indexedFieldOps.validateFieldName(fieldName);
|
|
1635
|
-
}
|
|
1648
|
+
// vault#553/#554: collects EVERY violation instead of throwing on the
|
|
1649
|
+
// first — a caller declaring two bad fields sees both in one
|
|
1650
|
+
// response, and the thrown error states explicitly that no changes
|
|
1651
|
+
// were applied (nothing is persisted before this check runs).
|
|
1652
|
+
const fieldViolations = tagSchemaOps.collectTagFieldViolations(db, tag, incomingFields);
|
|
1653
|
+
if (fieldViolations.length > 0) {
|
|
1654
|
+
throw new tagSchemaOps.TagFieldConflictError(tag, fieldViolations);
|
|
1636
1655
|
}
|
|
1637
1656
|
|
|
1638
1657
|
// ---- relationships: replace wholesale when provided. `relationships`
|
|
@@ -1655,7 +1674,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1655
1674
|
parentNamesPatch = null;
|
|
1656
1675
|
} else if (params.parent_names !== undefined) {
|
|
1657
1676
|
if (!Array.isArray(params.parent_names)) {
|
|
1658
|
-
throw
|
|
1677
|
+
throw structuredError("parent_names must be an array of tag names", {
|
|
1678
|
+
error_type: "invalid_parent_names",
|
|
1679
|
+
field: "parent_names",
|
|
1680
|
+
hint: "pass an array of tag name strings, or null to clear",
|
|
1681
|
+
});
|
|
1659
1682
|
}
|
|
1660
1683
|
const cleaned = (params.parent_names as unknown[])
|
|
1661
1684
|
.filter((p): p is string => typeof p === "string" && p.length > 0);
|
|
@@ -1695,11 +1718,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1695
1718
|
// mgmt + future config writes; deletes are write-tier mutations.
|
|
1696
1719
|
// See delete-note rationale.
|
|
1697
1720
|
requiredVerb: "write",
|
|
1698
|
-
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
|
|
1721
|
+
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway.",
|
|
1699
1722
|
inputSchema: {
|
|
1700
1723
|
type: "object",
|
|
1701
1724
|
properties: {
|
|
1702
1725
|
tag: { type: "string", description: "Tag name to delete" },
|
|
1726
|
+
cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
|
|
1727
|
+
detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
|
|
1703
1728
|
},
|
|
1704
1729
|
required: ["tag"],
|
|
1705
1730
|
},
|
|
@@ -1710,8 +1735,107 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1710
1735
|
// Indexed-field release is handled inside store.deleteTag →
|
|
1711
1736
|
// noteOps.deleteTag so every entry point (MCP, REST, import sweep)
|
|
1712
1737
|
// releases consistently with the co-declaration guard. See the
|
|
1713
|
-
// gitcoin orphaned-fields bug report.
|
|
1714
|
-
|
|
1738
|
+
// gitcoin orphaned-fields bug report. The referential-integrity
|
|
1739
|
+
// guard (vault#552) is ALSO inside store.deleteTag, so it returns
|
|
1740
|
+
// (not throws) `{error: "tag_referenced_as_parent", ...}` when
|
|
1741
|
+
// refused — same in-band shape delete-tag has always used for its
|
|
1742
|
+
// token-reference guard (applyTagDependencyGuards, src/mcp-tools.ts).
|
|
1743
|
+
return await store.deleteTag(tag, {
|
|
1744
|
+
cascade: params.cascade === true,
|
|
1745
|
+
detach: params.detach === true,
|
|
1746
|
+
});
|
|
1747
|
+
},
|
|
1748
|
+
},
|
|
1749
|
+
|
|
1750
|
+
// =====================================================================
|
|
1751
|
+
// 7a. rename-tag — atomic cascading rename (vault#552 MCP parity)
|
|
1752
|
+
// =====================================================================
|
|
1753
|
+
{
|
|
1754
|
+
name: "rename-tag",
|
|
1755
|
+
requiredVerb: "write",
|
|
1756
|
+
description:
|
|
1757
|
+
"Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
|
|
1758
|
+
inputSchema: {
|
|
1759
|
+
type: "object",
|
|
1760
|
+
properties: {
|
|
1761
|
+
old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
|
|
1762
|
+
new_name: { type: "string", description: "The new name. Alias: to." },
|
|
1763
|
+
from: { type: "string", description: "Alias for old_name." },
|
|
1764
|
+
to: { type: "string", description: "Alias for new_name." },
|
|
1765
|
+
tag: { type: "string", description: "Alias for old_name." },
|
|
1766
|
+
},
|
|
1767
|
+
},
|
|
1768
|
+
execute: async (params) => {
|
|
1769
|
+
const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
|
|
1770
|
+
const newName = (params.new_name ?? params.to) as string | undefined;
|
|
1771
|
+
if (typeof oldName !== "string" || oldName.length === 0) {
|
|
1772
|
+
throw structuredError("rename-tag: old_name (or from/tag) is required", {
|
|
1773
|
+
error_type: "invalid_request",
|
|
1774
|
+
field: "old_name",
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
if (typeof newName !== "string" || newName.length === 0) {
|
|
1778
|
+
throw structuredError("rename-tag: new_name (or to) is required", {
|
|
1779
|
+
error_type: "invalid_request",
|
|
1780
|
+
field: "new_name",
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
const result = await store.renameTag(oldName, newName);
|
|
1784
|
+
if ("error" in result) {
|
|
1785
|
+
if (result.error === "not_found") {
|
|
1786
|
+
throw structuredError(`rename-tag: tag "${oldName}" not found`, {
|
|
1787
|
+
error_type: "tag_not_found",
|
|
1788
|
+
field: "old_name",
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
throw Object.assign(
|
|
1792
|
+
new Error(
|
|
1793
|
+
`rename-tag: target "${newName}" (or one of its sub-tags) already exists — use merge-tags to combine them instead`,
|
|
1794
|
+
),
|
|
1795
|
+
{
|
|
1796
|
+
error_type: "target_exists" as const,
|
|
1797
|
+
target: newName,
|
|
1798
|
+
conflicting: result.conflicting,
|
|
1799
|
+
hint: "use merge-tags to combine the tags instead",
|
|
1800
|
+
},
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
return result;
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
|
|
1807
|
+
// =====================================================================
|
|
1808
|
+
// 7b. merge-tags — same machinery, N sources → one target
|
|
1809
|
+
// =====================================================================
|
|
1810
|
+
{
|
|
1811
|
+
name: "merge-tags",
|
|
1812
|
+
requiredVerb: "write",
|
|
1813
|
+
description:
|
|
1814
|
+
"Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
|
|
1815
|
+
inputSchema: {
|
|
1816
|
+
type: "object",
|
|
1817
|
+
properties: {
|
|
1818
|
+
sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
|
|
1819
|
+
target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
|
|
1820
|
+
},
|
|
1821
|
+
required: ["sources", "target"],
|
|
1822
|
+
},
|
|
1823
|
+
execute: async (params) => {
|
|
1824
|
+
const sources = params.sources;
|
|
1825
|
+
const target = params.target;
|
|
1826
|
+
if (!Array.isArray(sources) || sources.length === 0 || !sources.every((s) => typeof s === "string" && s.length > 0)) {
|
|
1827
|
+
throw structuredError("merge-tags: sources must be a non-empty array of strings", {
|
|
1828
|
+
error_type: "invalid_request",
|
|
1829
|
+
field: "sources",
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
if (typeof target !== "string" || target.length === 0) {
|
|
1833
|
+
throw structuredError("merge-tags: target must be a non-empty string", {
|
|
1834
|
+
error_type: "invalid_request",
|
|
1835
|
+
field: "target",
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
return await store.mergeTags(sources as string[], target);
|
|
1715
1839
|
},
|
|
1716
1840
|
},
|
|
1717
1841
|
|
|
@@ -1762,7 +1886,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1762
1886
|
execute: () => {
|
|
1763
1887
|
// This is a placeholder — vault-info needs access to vault config,
|
|
1764
1888
|
// which is only available in the server layer (mcp-tools.ts).
|
|
1765
|
-
return { error: "vault-info must be configured by the server layer" };
|
|
1889
|
+
return { error: "vault-info must be configured by the server layer", error_type: "not_configured" };
|
|
1766
1890
|
},
|
|
1767
1891
|
},
|
|
1768
1892
|
|
|
@@ -1801,6 +1925,22 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1801
1925
|
},
|
|
1802
1926
|
},
|
|
1803
1927
|
|
|
1928
|
+
// =====================================================================
|
|
1929
|
+
// 11. doctor — read-only taxonomy/metadata integrity scan (vault#552)
|
|
1930
|
+
// =====================================================================
|
|
1931
|
+
{
|
|
1932
|
+
name: "doctor",
|
|
1933
|
+
// `admin` — same tier as prune-schema: a diagnostic over the WHOLE
|
|
1934
|
+
// vault's taxonomy, not scoped to any one tag's write authority.
|
|
1935
|
+
requiredVerb: "admin",
|
|
1936
|
+
description:
|
|
1937
|
+
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Reports, per finding, {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
1938
|
+
inputSchema: { type: "object", properties: {} },
|
|
1939
|
+
execute: async () => {
|
|
1940
|
+
return await store.doctor();
|
|
1941
|
+
},
|
|
1942
|
+
},
|
|
1943
|
+
|
|
1804
1944
|
];
|
|
1805
1945
|
}
|
|
1806
1946
|
|
|
@@ -1994,6 +2134,9 @@ export class PreconditionRequiredError extends Error {
|
|
|
1994
2134
|
*/
|
|
1995
2135
|
export class BatchTooLargeError extends Error {
|
|
1996
2136
|
code = "BATCH_TOO_LARGE" as const;
|
|
2137
|
+
// Stable error_type (vault#554) — additive; matches the string REST has
|
|
2138
|
+
// hardcoded in its json response since #213.
|
|
2139
|
+
error_type = "batch_too_large" as const;
|
|
1997
2140
|
limit: number;
|
|
1998
2141
|
got: number;
|
|
1999
2142
|
|
package/core/src/notes.ts
CHANGED
|
@@ -284,6 +284,11 @@ export class TransitionConflictError extends Error {
|
|
|
284
284
|
*/
|
|
285
285
|
export class PathConflictError extends Error {
|
|
286
286
|
code = "PATH_CONFLICT" as const;
|
|
287
|
+
// Stable error_type (vault#554) — additive; mirrors the `error_type` REST
|
|
288
|
+
// has hardcoded in its json response since #126. Lets the generic MCP
|
|
289
|
+
// domain-error mapping (src/mcp-http.ts) pick this class up without a
|
|
290
|
+
// bespoke branch, the same way REST's catch already does explicitly.
|
|
291
|
+
error_type = "path_conflict" as const;
|
|
287
292
|
path: string;
|
|
288
293
|
|
|
289
294
|
constructor(path: string) {
|
|
@@ -306,6 +311,8 @@ export class PathConflictError extends Error {
|
|
|
306
311
|
*/
|
|
307
312
|
export class AmbiguousPathError extends Error {
|
|
308
313
|
code = "AMBIGUOUS_PATH" as const;
|
|
314
|
+
// Stable error_type (vault#554) — see PathConflictError's comment.
|
|
315
|
+
error_type = "ambiguous_path" as const;
|
|
309
316
|
path: string;
|
|
310
317
|
candidates: { id: string; extension: string }[];
|
|
311
318
|
|
|
@@ -346,6 +353,8 @@ export const EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/;
|
|
|
346
353
|
|
|
347
354
|
export class ExtensionValidationError extends Error {
|
|
348
355
|
code = "INVALID_EXTENSION" as const;
|
|
356
|
+
// Stable error_type (vault#554) — see PathConflictError's comment.
|
|
357
|
+
error_type = "invalid_extension" as const;
|
|
349
358
|
extension: string;
|
|
350
359
|
reason: string;
|
|
351
360
|
|
|
@@ -1503,40 +1512,114 @@ export function listTags(db: Database): { name: string; count: number; expanded_
|
|
|
1503
1512
|
return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
|
|
1504
1513
|
}
|
|
1505
1514
|
|
|
1506
|
-
|
|
1515
|
+
/** Options accepted by {@link deleteTag} (vault#552). */
|
|
1516
|
+
export interface DeleteTagOpts {
|
|
1517
|
+
/**
|
|
1518
|
+
* Proceed with the delete even though another tag's `parent_names`
|
|
1519
|
+
* references this one — strip the reference from every referencing tag's
|
|
1520
|
+
* `parent_names` array as part of the same transaction. Accepted as a
|
|
1521
|
+
* synonym of `detach` (both name the identical repair: remove the stale
|
|
1522
|
+
* reference, never delete the referencing tag itself); offered as two
|
|
1523
|
+
* flags because operators reach for either word depending on whether
|
|
1524
|
+
* they're thinking "cascade the delete" or "detach the children."
|
|
1525
|
+
*/
|
|
1526
|
+
cascade?: boolean;
|
|
1527
|
+
/** Synonym of `cascade` — see above. */
|
|
1528
|
+
detach?: boolean;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
export type DeleteTagResult =
|
|
1532
|
+
| { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
|
|
1533
|
+
| { error: "tag_referenced_as_parent"; referencing_tags: string[] };
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Delete a tag: drop its identity row, untag every note carrying it, and
|
|
1537
|
+
* release any indexed fields it exclusively declared. Notes themselves are
|
|
1538
|
+
* NEVER deleted — only untagged.
|
|
1539
|
+
*
|
|
1540
|
+
* Referential integrity (vault#552): if another tag's `parent_names` array
|
|
1541
|
+
* names `name`, deleting it would silently orphan that reference — the
|
|
1542
|
+
* child tag's hierarchy edge would point at a name with no identity row
|
|
1543
|
+
* (the exact "renamed-away tag remains a live query surface" class of bug
|
|
1544
|
+
* the gardener found, one hop over from rename). Refuse by default with
|
|
1545
|
+
* `{ error: "tag_referenced_as_parent", referencing_tags }`; pass
|
|
1546
|
+
* `opts.cascade` or `opts.detach` (either — see {@link DeleteTagOpts}) to
|
|
1547
|
+
* proceed, stripping `name` from every referencing tag's `parent_names` in
|
|
1548
|
+
* the same transaction as the delete.
|
|
1549
|
+
*/
|
|
1550
|
+
export function deleteTag(db: Database, name: string, opts?: DeleteTagOpts): DeleteTagResult {
|
|
1507
1551
|
const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
|
|
1508
1552
|
| { fields: string | null }
|
|
1509
1553
|
| null;
|
|
1510
1554
|
if (!row) return { deleted: false, notes_untagged: 0 };
|
|
1511
1555
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
//
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1556
|
+
// Referential-integrity guard: who names `name` as a parent? Reuses the
|
|
1557
|
+
// SAME parent_names parsing loadTagHierarchy uses everywhere else in the
|
|
1558
|
+
// hierarchy (rather than a bespoke LIKE scan) so "who references this
|
|
1559
|
+
// tag" answers identically here as it does for query expansion.
|
|
1560
|
+
const hierarchy = loadTagHierarchy(db);
|
|
1561
|
+
const referencing = Array.from(hierarchy.childrenOf.get(name) ?? []);
|
|
1562
|
+
if (referencing.length > 0 && !opts?.cascade && !opts?.detach) {
|
|
1563
|
+
return { error: "tag_referenced_as_parent", referencing_tags: referencing.sort() };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return transaction(db, (): DeleteTagResult => {
|
|
1567
|
+
// Strip the stale reference from every referencing tag's parent_names
|
|
1568
|
+
// BEFORE dropping this tag's own row — order doesn't matter for
|
|
1569
|
+
// correctness (different rows), but doing the repair first means a
|
|
1570
|
+
// mid-transaction failure never leaves the identity row gone with
|
|
1571
|
+
// dangling references still pointing at it.
|
|
1572
|
+
let parentRefsDetached = 0;
|
|
1573
|
+
if (referencing.length > 0) {
|
|
1574
|
+
const readStmt = db.prepare("SELECT parent_names FROM tags WHERE name = ?");
|
|
1575
|
+
const updateStmt = db.prepare("UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?");
|
|
1576
|
+
const now = new Date().toISOString();
|
|
1577
|
+
for (const refTag of referencing) {
|
|
1578
|
+
const r = readStmt.get(refTag) as { parent_names: string | null } | null;
|
|
1579
|
+
if (!r?.parent_names) continue;
|
|
1580
|
+
let parsed: unknown;
|
|
1581
|
+
try { parsed = JSON.parse(r.parent_names); } catch { continue; }
|
|
1582
|
+
if (!Array.isArray(parsed)) continue;
|
|
1583
|
+
const next = (parsed as unknown[]).filter((p) => p !== name);
|
|
1584
|
+
if (next.length === parsed.length) continue;
|
|
1585
|
+
updateStmt.run(next.length > 0 ? JSON.stringify(next) : null, now, refTag);
|
|
1586
|
+
parentRefsDetached++;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
|
|
1591
|
+
const notesUntagged = countRow.c;
|
|
1592
|
+
|
|
1593
|
+
// Release any indexed fields this tag declared BEFORE the row drops.
|
|
1594
|
+
// `releaseField` only drops the generated column + index when this tag is
|
|
1595
|
+
// the last live declarer (co-declaration guard) — a field co-declared by
|
|
1596
|
+
// another live tag keeps its column and just loses this tag from the set.
|
|
1597
|
+
// This lives in the store-level delete (not the MCP layer) so every caller
|
|
1598
|
+
// — MCP delete-tag, the REST DELETE /tags/:name route, the import
|
|
1599
|
+
// blow-away sweep — releases consistently. See the gitcoin orphaned-fields
|
|
1600
|
+
// bug report.
|
|
1601
|
+
if (row.fields) {
|
|
1602
|
+
try {
|
|
1603
|
+
const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
|
|
1604
|
+
for (const [fieldName, spec] of Object.entries(fields)) {
|
|
1605
|
+
if (spec?.indexed === true) {
|
|
1606
|
+
releaseField(db, fieldName, name);
|
|
1607
|
+
}
|
|
1529
1608
|
}
|
|
1609
|
+
} catch {
|
|
1610
|
+
// Malformed fields JSON — nothing to release; proceed with the delete.
|
|
1530
1611
|
}
|
|
1531
|
-
} catch {
|
|
1532
|
-
// Malformed fields JSON — nothing to release; proceed with the delete.
|
|
1533
1612
|
}
|
|
1534
|
-
}
|
|
1535
1613
|
|
|
1536
|
-
|
|
1537
|
-
|
|
1614
|
+
db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
|
|
1615
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run(name);
|
|
1538
1616
|
|
|
1539
|
-
|
|
1617
|
+
return {
|
|
1618
|
+
deleted: true,
|
|
1619
|
+
notes_untagged: notesUntagged,
|
|
1620
|
+
...(parentRefsDetached > 0 ? { parent_refs_detached: parentRefsDetached } : {}),
|
|
1621
|
+
};
|
|
1622
|
+
});
|
|
1540
1623
|
}
|
|
1541
1624
|
|
|
1542
1625
|
// The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
|