@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
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vault doctor` — read-only taxonomy/metadata integrity scan (vault#552).
|
|
3
|
+
*
|
|
4
|
+
* NOT to be confused with `parachute-vault doctor` (the CLI's install/daemon
|
|
5
|
+
* diagnostic in `src/cli.ts` — port reachability, config sanity, launchd/
|
|
6
|
+
* systemd registration). This module answers a different question: "did my
|
|
7
|
+
* tag reorg (rename/merge/delete) leak?" It never auto-fixes — every finding
|
|
8
|
+
* carries a suggested `remedy` the caller applies deliberately (usually via
|
|
9
|
+
* `rename-tag`/`merge-tags`/`prune-schema`/another `update-tag` call).
|
|
10
|
+
*
|
|
11
|
+
* Scans:
|
|
12
|
+
* - `dangling_parent_name` — a `parent_names` entry naming a tag with no
|
|
13
|
+
* identity row.
|
|
14
|
+
* - `parent_names_cycle` — a tag that reaches itself through its
|
|
15
|
+
* declared ancestor chain (traversal is cycle-safe; this just SURFACES
|
|
16
|
+
* data that predates the vault#552 write-time cycle guard, or was
|
|
17
|
+
* written by a path that bypasses it).
|
|
18
|
+
* - `mixed_type_indexed_field` — a note whose `metadata.<field>` JSON type
|
|
19
|
+
* disagrees with the field's declared indexed sqlite type. This is the
|
|
20
|
+
* WS4 typed-index "poison precursor": inconsistent column affinity
|
|
21
|
+
* coercion silently breaks ordering/comparison across rows. The finding
|
|
22
|
+
* shape here is intentionally reusable as WS4's migration pre-flight.
|
|
23
|
+
* - `orphaned_indexed_field_declarer` — an `indexed_fields` row naming a
|
|
24
|
+
* declarer tag with no `tags` row (overlaps `prune-schema`, which is the
|
|
25
|
+
* suggested remedy).
|
|
26
|
+
* - `dead_tag_metadata_reference` — HEURISTIC, always labeled `heuristic:
|
|
27
|
+
* true`. A metadata key is used elsewhere in the vault with values that
|
|
28
|
+
* ARE live tag names (establishing "this key holds tag-shaped values");
|
|
29
|
+
* a value under the same key that does NOT match any live tag is
|
|
30
|
+
* flagged as a possible stale reference to a renamed/merged/deleted tag
|
|
31
|
+
* — the PM's `metadata.epic` drift class from the #552 write-up. Vault
|
|
32
|
+
* has no history of past tag names, so this can never be certain —
|
|
33
|
+
* hence heuristic.
|
|
34
|
+
*
|
|
35
|
+
* Tag-scope (vault#552): pass `allowedTags` (the caller's EXPANDED
|
|
36
|
+
* allowlist, e.g. from `expandTokenTagScope`) to restrict every finding to
|
|
37
|
+
* in-scope tags/fields/notes. `null` (or omitted) — the default — scans the
|
|
38
|
+
* whole vault. The scan is re-run with the allowlist rather than filtering
|
|
39
|
+
* an unscoped result afterward, so the `summary` counts never leak
|
|
40
|
+
* out-of-scope activity.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Database } from "bun:sqlite";
|
|
44
|
+
import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
|
|
45
|
+
import { listIndexedFields } from "./indexed-fields.js";
|
|
46
|
+
import { pruneOrphanedIndexedFields } from "./indexed-fields.js";
|
|
47
|
+
|
|
48
|
+
export type DoctorFindingType =
|
|
49
|
+
| "dangling_parent_name"
|
|
50
|
+
| "parent_names_cycle"
|
|
51
|
+
| "mixed_type_indexed_field"
|
|
52
|
+
| "orphaned_indexed_field_declarer"
|
|
53
|
+
| "dead_tag_metadata_reference";
|
|
54
|
+
|
|
55
|
+
export type DoctorSeverity = "error" | "warning" | "info";
|
|
56
|
+
|
|
57
|
+
export interface DoctorFinding {
|
|
58
|
+
type: DoctorFindingType;
|
|
59
|
+
severity: DoctorSeverity;
|
|
60
|
+
/** The tag / field / metadata-key this finding is about. */
|
|
61
|
+
subject: string;
|
|
62
|
+
detail: string;
|
|
63
|
+
remedy: string;
|
|
64
|
+
/**
|
|
65
|
+
* True ONLY for `dead_tag_metadata_reference` — flags the finding as a
|
|
66
|
+
* best-effort inference (no tag-rename history exists to confirm it),
|
|
67
|
+
* never present (not even `false`) on the other, structurally-certain
|
|
68
|
+
* finding types.
|
|
69
|
+
*/
|
|
70
|
+
heuristic?: true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DoctorReport {
|
|
74
|
+
findings: DoctorFinding[];
|
|
75
|
+
summary: string;
|
|
76
|
+
scanned_at: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DoctorScanOpts {
|
|
80
|
+
/**
|
|
81
|
+
* The caller's expanded tag allowlist (root ∪ descendants). `null`
|
|
82
|
+
* (default) scans the whole vault. See the module doc comment for how
|
|
83
|
+
* each finding type is scoped.
|
|
84
|
+
*/
|
|
85
|
+
allowedTags?: Set<string> | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cap on exemplar note IDs embedded in a finding's `detail` (vault#552 — keep report bodies bounded). */
|
|
89
|
+
const MAX_EXEMPLARS = 5;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a per-note in-scope predicate for a tag-scoped doctor run (vault#552
|
|
93
|
+
* auth fold). A note is in-scope iff at least one of its tags is in
|
|
94
|
+
* `allowedTags`; an unscoped run (`allowedTags === null`) treats every note
|
|
95
|
+
* as in-scope. Caches each note's tag lookup so repeated checks across scans
|
|
96
|
+
* cost one query per note. Shared by the mixed-type-indexed-field and
|
|
97
|
+
* dead-tag-metadata scans, both of which query notes vault-wide and must NOT
|
|
98
|
+
* surface an out-of-scope note id (or count, or exemplar) to a scoped caller.
|
|
99
|
+
*/
|
|
100
|
+
function makeNoteInScope(db: Database, allowedTags: Set<string> | null): (id: string) => boolean {
|
|
101
|
+
if (!allowedTags) return () => true;
|
|
102
|
+
const cache = new Map<string, boolean>();
|
|
103
|
+
return (id: string): boolean => {
|
|
104
|
+
const cached = cache.get(id);
|
|
105
|
+
if (cached !== undefined) return cached;
|
|
106
|
+
const tags = (db.prepare("SELECT tag_name FROM note_tags WHERE note_id = ?").all(id) as { tag_name: string }[]).map((r) => r.tag_name);
|
|
107
|
+
const inScope = tags.some((t) => allowedTags.has(t));
|
|
108
|
+
cache.set(id, inScope);
|
|
109
|
+
return inScope;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport {
|
|
114
|
+
const allowedTags = opts?.allowedTags ?? null;
|
|
115
|
+
const findings: DoctorFinding[] = [
|
|
116
|
+
...scanDanglingParentNames(db, allowedTags),
|
|
117
|
+
...scanParentNamesCycles(db, allowedTags),
|
|
118
|
+
...scanMixedTypeIndexedFields(db, allowedTags),
|
|
119
|
+
...scanOrphanedIndexedFieldDeclarers(db, allowedTags),
|
|
120
|
+
...scanDeadTagMetadataReferences(db, allowedTags),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
124
|
+
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
125
|
+
const infos = findings.filter((f) => f.severity === "info").length;
|
|
126
|
+
const summary =
|
|
127
|
+
findings.length === 0
|
|
128
|
+
? "clean — no integrity findings"
|
|
129
|
+
: `${findings.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`;
|
|
130
|
+
|
|
131
|
+
return { findings, summary, scanned_at: new Date().toISOString() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// A. dangling parent_names
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function scanDanglingParentNames(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
139
|
+
const rows = db
|
|
140
|
+
.prepare("SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL")
|
|
141
|
+
.all() as { name: string; parent_names: string }[];
|
|
142
|
+
const allTags = new Set(
|
|
143
|
+
(db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const findings: DoctorFinding[] = [];
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
if (allowedTags && !allowedTags.has(row.name)) continue;
|
|
149
|
+
let parsed: unknown;
|
|
150
|
+
try { parsed = JSON.parse(row.parent_names); } catch { continue; }
|
|
151
|
+
if (!Array.isArray(parsed)) continue;
|
|
152
|
+
for (const parent of parsed) {
|
|
153
|
+
if (typeof parent !== "string" || parent.length === 0) continue;
|
|
154
|
+
if (allTags.has(parent)) continue;
|
|
155
|
+
findings.push({
|
|
156
|
+
type: "dangling_parent_name",
|
|
157
|
+
severity: "warning",
|
|
158
|
+
subject: row.name,
|
|
159
|
+
detail: `tag "${row.name}" declares parent "${parent}", which has no identity row — a typo, a tag that was never created, or a rename/delete that didn't update this reference.`,
|
|
160
|
+
remedy: `remove "${parent}" from "${row.name}"'s parent_names (update-tag), or create the missing "${parent}" tag if it's meant to exist`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return findings;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// B. parent_names cycles (pre-existing data — the write-time guard blocks
|
|
169
|
+
// NEW ones; this surfaces anything already on disk)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function scanParentNamesCycles(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
173
|
+
const hierarchy = loadTagHierarchy(db);
|
|
174
|
+
const cycles = findHierarchyCycles(hierarchy);
|
|
175
|
+
const findings: DoctorFinding[] = [];
|
|
176
|
+
for (const tag of cycles) {
|
|
177
|
+
if (allowedTags && !allowedTags.has(tag)) continue;
|
|
178
|
+
findings.push({
|
|
179
|
+
type: "parent_names_cycle",
|
|
180
|
+
severity: "error",
|
|
181
|
+
subject: tag,
|
|
182
|
+
detail: `tag "${tag}" reaches itself through its declared parent_names ancestor chain. Query expansion tolerates this (a visited-set stops it looping), but it's confusing hierarchy state.`,
|
|
183
|
+
remedy: `clear or correct parent_names on one of the tags in "${tag}"'s ancestor chain (update-tag) to break the cycle`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// C. mixed-type values in an indexed column (WS4 poison precursor)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** `json_type()` results consistent with each declared indexed sqlite storage class. */
|
|
194
|
+
const EXPECTED_JSON_TYPES: Record<string, Set<string>> = {
|
|
195
|
+
INTEGER: new Set(["integer", "real", "true", "false"]),
|
|
196
|
+
TEXT: new Set(["text"]),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function scanMixedTypeIndexedFields(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
200
|
+
const findings: DoctorFinding[] = [];
|
|
201
|
+
const noteInScope = makeNoteInScope(db, allowedTags);
|
|
202
|
+
for (const f of listIndexedFields(db)) {
|
|
203
|
+
if (allowedTags && !f.declarerTags.some((t) => allowedTags.has(t))) continue;
|
|
204
|
+
const expected = EXPECTED_JSON_TYPES[f.sqliteType];
|
|
205
|
+
if (!expected) continue; // unknown sqlite_type — nothing to compare against
|
|
206
|
+
|
|
207
|
+
// `json_type(metadata, ?)` returns NULL when the key is absent — those
|
|
208
|
+
// notes simply don't declare the field and aren't a mismatch. Only rows
|
|
209
|
+
// where the key IS present get compared.
|
|
210
|
+
const path = `$."${f.field}"`;
|
|
211
|
+
const rows = db
|
|
212
|
+
.prepare(
|
|
213
|
+
`SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
|
|
214
|
+
)
|
|
215
|
+
.all(path) as { id: string; jt: string | null }[];
|
|
216
|
+
// Tag-scope (vault#552 auth fold): the note query above is vault-wide —
|
|
217
|
+
// filter mismatches to IN-SCOPE notes so a scoped caller never sees an
|
|
218
|
+
// out-of-scope note id (nor a count/exemplar reflecting one). If every
|
|
219
|
+
// mismatch is on an out-of-scope note, the finding is dropped entirely.
|
|
220
|
+
const mismatches = rows.filter((r) => r.jt !== null && !expected.has(r.jt) && noteInScope(r.id));
|
|
221
|
+
if (mismatches.length === 0) continue;
|
|
222
|
+
|
|
223
|
+
// Also generalize the "Declared by" list to in-scope declarers only —
|
|
224
|
+
// the field can be co-declared by an out-of-scope tag whose name must
|
|
225
|
+
// not leak (the caller sees this field only via its in-scope declarer).
|
|
226
|
+
const visibleDeclarers = allowedTags
|
|
227
|
+
? f.declarerTags.filter((t) => allowedTags.has(t))
|
|
228
|
+
: f.declarerTags;
|
|
229
|
+
const exemplars = mismatches.slice(0, MAX_EXEMPLARS).map((r) => `${r.id} (${r.jt})`);
|
|
230
|
+
findings.push({
|
|
231
|
+
type: "mixed_type_indexed_field",
|
|
232
|
+
severity: "error",
|
|
233
|
+
subject: f.field,
|
|
234
|
+
detail: `field "${f.field}" is indexed as ${f.sqliteType} but ${mismatches.length} note(s) carry a metadata."${f.field}" value of a disagreeing JSON type — the generated column's affinity coercion makes ordering/filtering across these rows inconsistent. Declared by: ${visibleDeclarers.join(", ") || "(no live declarer)"}. Example note(s): ${exemplars.join(", ")}${mismatches.length > exemplars.length ? ", …" : ""}`,
|
|
235
|
+
remedy: `backfill each listed note's metadata."${f.field}" to a value matching the declared type, or relax the field's declared type (update-tag) if the mixed values are intentional`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return findings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// D. orphaned indexed_fields declarers (overlaps prune-schema)
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function scanOrphanedIndexedFieldDeclarers(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
246
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: true });
|
|
247
|
+
const findings: DoctorFinding[] = [];
|
|
248
|
+
for (const p of plan) {
|
|
249
|
+
if (allowedTags && !p.deadDeclarers.some((t) => allowedTags.has(t))) {
|
|
250
|
+
// Dead declarer names never survive `allowedTags` (it's derived from
|
|
251
|
+
// LIVE tags only) — this branch exists for completeness; in practice
|
|
252
|
+
// a scoped caller only sees this finding when the report is unscoped.
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
findings.push({
|
|
256
|
+
type: "orphaned_indexed_field_declarer",
|
|
257
|
+
severity: p.dropped ? "warning" : "info",
|
|
258
|
+
subject: p.field,
|
|
259
|
+
detail: p.dropped
|
|
260
|
+
? `field "${p.field}" has NO surviving live declarer (dead: ${p.deadDeclarers.join(", ")}) — the generated column + index are stale and will be dropped on next prune (metadata values are never lost, only the index).`
|
|
261
|
+
: `field "${p.field}" has dead declarer tag(s) [${p.deadDeclarers.join(", ")}] still listed alongside live ones — cosmetic, no functional impact.`,
|
|
262
|
+
remedy: `run the prune-schema tool (apply: true) to drop the dead declarer(s)${p.dropped ? " and the now-unreferenced column/index" : ""}`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// E. metadata values equal to a tag name that no longer exists (heuristic)
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function scanDeadTagMetadataReferences(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
273
|
+
const liveTags = new Set(
|
|
274
|
+
(db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const rows = db
|
|
278
|
+
.prepare("SELECT id, metadata FROM notes WHERE metadata IS NOT NULL AND metadata != ''")
|
|
279
|
+
.all() as { id: string; metadata: string }[];
|
|
280
|
+
|
|
281
|
+
// key -> value -> note ids carrying that (key, value) pair.
|
|
282
|
+
const byKey = new Map<string, Map<string, string[]>>();
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
let parsed: unknown;
|
|
285
|
+
try { parsed = JSON.parse(row.metadata); } catch { continue; }
|
|
286
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
287
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
288
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
289
|
+
let valueMap = byKey.get(key);
|
|
290
|
+
if (!valueMap) { valueMap = new Map(); byKey.set(key, valueMap); }
|
|
291
|
+
let ids = valueMap.get(value);
|
|
292
|
+
if (!ids) { ids = []; valueMap.set(value, ids); }
|
|
293
|
+
ids.push(row.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Tag-scope (vault#552 auth fold): a note is in-scope iff at least one of
|
|
298
|
+
// its tags is in `allowedTags` (unscoped → every note in-scope).
|
|
299
|
+
const noteInScope = makeNoteInScope(db, allowedTags);
|
|
300
|
+
|
|
301
|
+
const findings: DoctorFinding[] = [];
|
|
302
|
+
for (const [key, valueMap] of byKey) {
|
|
303
|
+
// Signal: does this metadata key hold tag-shaped values on IN-SCOPE
|
|
304
|
+
// notes? Computed over in-scope notes ONLY (not vault-wide) so the
|
|
305
|
+
// example live value quoted in `detail` can never leak an out-of-scope
|
|
306
|
+
// tag name — a scoped caller must not learn a tag exists just because a
|
|
307
|
+
// note it can't see uses it as a metadata value. Only flag mismatches
|
|
308
|
+
// under a key that demonstrably holds tag-shaped values in-scope.
|
|
309
|
+
const liveValuesInScope = [...valueMap.entries()]
|
|
310
|
+
.filter(([v, ids]) => liveTags.has(v) && ids.some(noteInScope))
|
|
311
|
+
.map(([v]) => v);
|
|
312
|
+
if (liveValuesInScope.length === 0) continue;
|
|
313
|
+
|
|
314
|
+
for (const [value, noteIds] of valueMap) {
|
|
315
|
+
if (liveTags.has(value)) continue;
|
|
316
|
+
const inScopeIds = noteIds.filter(noteInScope);
|
|
317
|
+
if (inScopeIds.length === 0) continue;
|
|
318
|
+
const exemplars = inScopeIds.slice(0, MAX_EXEMPLARS);
|
|
319
|
+
findings.push({
|
|
320
|
+
type: "dead_tag_metadata_reference",
|
|
321
|
+
severity: "info",
|
|
322
|
+
subject: `metadata.${key}`,
|
|
323
|
+
detail: `${inScopeIds.length} note(s) carry metadata.${key} = "${value}", which matches no current tag — other notes' metadata.${key} values ARE live tags (e.g. "${liveValuesInScope[0]}"), suggesting "${value}" may be a stale reference to a tag that was renamed, merged, or deleted. Example note(s): ${exemplars.join(", ")}${inScopeIds.length > exemplars.length ? ", …" : ""}`,
|
|
324
|
+
remedy: `if "${value}" was renamed/merged/deleted, update these notes' metadata.${key} to the current tag name; if "${value}" was never a tag, ignore this finding`,
|
|
325
|
+
heuristic: true,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return findings;
|
|
330
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
validateFieldName,
|
|
15
15
|
} from "./indexed-fields.js";
|
|
16
16
|
import { buildVaultProjection } from "./vault-projection.js";
|
|
17
|
+
import { TagFieldConflictError } from "./tag-schemas.js";
|
|
17
18
|
|
|
18
19
|
let db: Database;
|
|
19
20
|
let store: SqliteStore;
|
|
@@ -186,9 +187,17 @@ describe("update-tag: indexed flag", () => {
|
|
|
186
187
|
it("type conflict across declarers throws and names the other tag", async () => {
|
|
187
188
|
const t = findTool("update-tag");
|
|
188
189
|
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
190
|
+
// vault#554 wire review: a BOTH-indexed cross-tag type conflict is
|
|
191
|
+
// deliberately excluded from the update-tag pre-check (see
|
|
192
|
+
// `collectCrossTagFieldViolations` doc-comment exclusion 2) so it keeps
|
|
193
|
+
// its pre-existing declareField → IndexedFieldError path — REST maps
|
|
194
|
+
// that to the established 400 invalid_indexed_field. The declarer is
|
|
195
|
+
// still named (declareField's message shape), which is this test's
|
|
196
|
+
// intent; the pre-#554 inline-loop wording (`tag "project" declares
|
|
197
|
+
// "string"`) is gone along with the loop.
|
|
189
198
|
expect(() =>
|
|
190
199
|
t.execute({ tag: "ticket", fields: { status: { type: "integer", indexed: true } } }),
|
|
191
|
-
).toThrow(/tag
|
|
200
|
+
).toThrow(/declared by tag\(s\) \[project\]/);
|
|
192
201
|
});
|
|
193
202
|
|
|
194
203
|
it("indexed-flag conflict across declarers throws", async () => {
|
|
@@ -209,12 +218,28 @@ describe("update-tag: indexed flag", () => {
|
|
|
209
218
|
});
|
|
210
219
|
|
|
211
220
|
it("invalid field name for indexing throws", async () => {
|
|
212
|
-
|
|
213
|
-
|
|
221
|
+
// vault#553/#554: update-tag's cross-tag field validation now collects
|
|
222
|
+
// EVERY violation into one `TagFieldConflictError` (carrying a
|
|
223
|
+
// `violations` array) instead of throwing the first `IndexedFieldError`
|
|
224
|
+
// it hits — see core/src/tag-schemas.ts `collectTagFieldViolations`.
|
|
225
|
+
// The underlying reason ("invalid_field_name") and message text are
|
|
226
|
+
// unchanged; only the thrown class + the "collect everything" framing
|
|
227
|
+
// are new.
|
|
228
|
+
let caught: unknown;
|
|
229
|
+
try {
|
|
230
|
+
await findTool("update-tag").execute({
|
|
214
231
|
tag: "project",
|
|
215
232
|
fields: { "bad-name": { type: "string", indexed: true } },
|
|
216
|
-
})
|
|
217
|
-
|
|
233
|
+
});
|
|
234
|
+
} catch (e) {
|
|
235
|
+
caught = e;
|
|
236
|
+
}
|
|
237
|
+
expect(caught).toBeInstanceOf(TagFieldConflictError);
|
|
238
|
+
const err = caught as TagFieldConflictError;
|
|
239
|
+
expect(err.violations).toHaveLength(1);
|
|
240
|
+
expect(err.violations[0]!.field).toBe("bad-name");
|
|
241
|
+
expect(err.violations[0]!.reason).toBe("invalid_field_name");
|
|
242
|
+
expect(err.message).toContain("no changes were applied");
|
|
218
243
|
});
|
|
219
244
|
|
|
220
245
|
it("rejects non-atomic indexed-flag change while other declarers hold it true", async () => {
|
|
@@ -45,6 +45,22 @@ interface IndexedFieldRow {
|
|
|
45
45
|
|
|
46
46
|
export class IndexedFieldError extends Error {
|
|
47
47
|
override name = "IndexedFieldError";
|
|
48
|
+
// Stable error_type (vault#554) — additive; matches the string REST has
|
|
49
|
+
// hardcoded in its json response since vault#478. Lets the generic MCP
|
|
50
|
+
// domain-error mapping (src/mcp-http.ts) pick this class up.
|
|
51
|
+
error_type = "invalid_indexed_field" as const;
|
|
52
|
+
/**
|
|
53
|
+
* Structured context for the CROSS-DECLARER sqlite-type conflict thrown
|
|
54
|
+
* by `declareField` (vault#554 auth-and-scope fold): the message names
|
|
55
|
+
* the other declarer tag(s), which a tag-scoped caller must not learn
|
|
56
|
+
* when they're outside its allowlist. `declarer_tags` lets the server
|
|
57
|
+
* layer (`scrubIndexedFieldConflictError` in src/tag-scope.ts) detect
|
|
58
|
+
* that case and generalize the message. Absent on the solo own-field
|
|
59
|
+
* throws (invalid name, unsupported type), whose messages name no other
|
|
60
|
+
* tag.
|
|
61
|
+
*/
|
|
62
|
+
field?: string;
|
|
63
|
+
declarer_tags?: string[];
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
// Restrict field names to safe SQL identifiers. This also bounds the
|
|
@@ -172,8 +188,14 @@ export function declareField(
|
|
|
172
188
|
if (existing.sqliteType !== sqliteType) {
|
|
173
189
|
const others = existing.declarerTags.filter((t) => t !== tag);
|
|
174
190
|
if (others.length > 0) {
|
|
175
|
-
|
|
176
|
-
|
|
191
|
+
// `field` + `declarer_tags` stamped so the server's tag-scope layer
|
|
192
|
+
// can generalize this message when a declarer is outside a scoped
|
|
193
|
+
// caller's allowlist (see IndexedFieldError's doc comment).
|
|
194
|
+
throw Object.assign(
|
|
195
|
+
new IndexedFieldError(
|
|
196
|
+
`field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
|
|
197
|
+
),
|
|
198
|
+
{ field, declarer_tags: others },
|
|
177
199
|
);
|
|
178
200
|
}
|
|
179
201
|
dropColumnAndIndex(db, field);
|