@openparachute/vault 0.6.4-rc.10 → 0.6.4-rc.11
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/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/store.ts +19 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/routes.ts +86 -7
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance check (vault#283) — counting existing notes that would violate
|
|
3
|
+
* a proposed tightening of a tag's field spec. Backs the Schema editor's
|
|
4
|
+
* "N existing notes violate this" warning.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { SqliteStore } from "./store.js";
|
|
9
|
+
import { countConformanceViolations } from "./conformance.js";
|
|
10
|
+
|
|
11
|
+
let db: Database;
|
|
12
|
+
let store: SqliteStore;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
db = new Database(":memory:");
|
|
16
|
+
store = new SqliteStore(db);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("countConformanceViolations", () => {
|
|
20
|
+
it("returns zero violations when proposed fields is empty", async () => {
|
|
21
|
+
await store.createNote("n", { tags: ["task"], metadata: { status: "open" } });
|
|
22
|
+
const r = countConformanceViolations(db, "task", {});
|
|
23
|
+
expect(r.total_notes).toBe(1);
|
|
24
|
+
expect(r.violating_notes).toBe(0);
|
|
25
|
+
expect(r.checked_fields).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("counts notes missing a newly-required field", async () => {
|
|
29
|
+
await store.createNote("has", { tags: ["task"], metadata: { due: "2026-01-01" } });
|
|
30
|
+
await store.createNote("missing-1", { tags: ["task"], metadata: {} });
|
|
31
|
+
await store.createNote("missing-2", { tags: ["task"] });
|
|
32
|
+
|
|
33
|
+
const r = countConformanceViolations(db, "task", {
|
|
34
|
+
due: { type: "string", required: true },
|
|
35
|
+
});
|
|
36
|
+
expect(r.total_notes).toBe(3);
|
|
37
|
+
expect(r.violating_notes).toBe(2);
|
|
38
|
+
expect(r.checked_fields).toEqual(["due"]);
|
|
39
|
+
// missing_required reasons surface in the sample.
|
|
40
|
+
expect(r.sample.every((s) => s.fields.some((f) => f.reason === "missing_required"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts notes whose value falls outside a narrowed enum", async () => {
|
|
44
|
+
await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
|
|
45
|
+
await store.createNote("bad", { tags: ["task"], metadata: { status: "archived" } });
|
|
46
|
+
|
|
47
|
+
const r = countConformanceViolations(db, "task", {
|
|
48
|
+
status: { type: "string", enum: ["open", "done"] },
|
|
49
|
+
});
|
|
50
|
+
expect(r.violating_notes).toBe(1);
|
|
51
|
+
expect(r.sample[0]?.fields[0]?.reason).toBe("enum_mismatch");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("counts notes whose value contradicts a changed type", async () => {
|
|
55
|
+
await store.createNote("num", { tags: ["task"], metadata: { count: 3 } });
|
|
56
|
+
await store.createNote("str", { tags: ["task"], metadata: { count: "three" } });
|
|
57
|
+
|
|
58
|
+
const r = countConformanceViolations(db, "task", {
|
|
59
|
+
count: { type: "integer" },
|
|
60
|
+
});
|
|
61
|
+
expect(r.violating_notes).toBe(1);
|
|
62
|
+
expect(r.sample[0]?.fields[0]?.reason).toBe("type_mismatch");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes descendant-tag notes via inheritance", async () => {
|
|
66
|
+
// dev/log declares dev as a parent → a strict field on dev applies to
|
|
67
|
+
// dev/log notes too.
|
|
68
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
69
|
+
await store.createNote("parent-note", { tags: ["dev"], metadata: {} });
|
|
70
|
+
await store.createNote("child-note", { tags: ["dev/log"], metadata: {} });
|
|
71
|
+
|
|
72
|
+
const r = countConformanceViolations(db, "dev", {
|
|
73
|
+
kind: { type: "string", required: true },
|
|
74
|
+
});
|
|
75
|
+
expect(r.total_notes).toBe(2);
|
|
76
|
+
expect(r.violating_notes).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores fields the proposal does not touch", async () => {
|
|
80
|
+
await store.createNote("n", { tags: ["task"], metadata: { other: 123 } });
|
|
81
|
+
// We only propose `status`; `other` being weird is irrelevant.
|
|
82
|
+
const r = countConformanceViolations(db, "task", {
|
|
83
|
+
status: { type: "string" },
|
|
84
|
+
});
|
|
85
|
+
// `status` absent + not required → no violation.
|
|
86
|
+
expect(r.violating_notes).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps the sample at sampleLimit but counts all violations", async () => {
|
|
90
|
+
for (let i = 0; i < 5; i++) {
|
|
91
|
+
await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
|
|
92
|
+
}
|
|
93
|
+
const r = countConformanceViolations(
|
|
94
|
+
db,
|
|
95
|
+
"task",
|
|
96
|
+
{ req: { type: "string", required: true } },
|
|
97
|
+
{ sampleLimit: 2 },
|
|
98
|
+
);
|
|
99
|
+
expect(r.violating_notes).toBe(5);
|
|
100
|
+
expect(r.sample.length).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("store.countTagConformance", () => {
|
|
105
|
+
it("proxies to the core helper", async () => {
|
|
106
|
+
await store.createNote("n", { tags: ["task"], metadata: {} });
|
|
107
|
+
const r = await store.countTagConformance("task", {
|
|
108
|
+
due: { type: "string", required: true },
|
|
109
|
+
});
|
|
110
|
+
expect(r.violating_notes).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema conformance check — count how many EXISTING notes would violate a
|
|
3
|
+
* PROPOSED field spec for a tag, before the operator commits a tightening
|
|
4
|
+
* (vault#283, the MW2/#314 tie-in).
|
|
5
|
+
*
|
|
6
|
+
* Motivation: marking a field `strict`/`required`, narrowing an `enum`, or
|
|
7
|
+
* changing a `type` is a backwards-incompatible move — notes written under
|
|
8
|
+
* the old contract may now reject on their next write
|
|
9
|
+
* (`enforceStrictSchema` → `SchemaValidationError`). The Schema editor in
|
|
10
|
+
* the admin SPA calls this BEFORE save so the operator sees "N existing
|
|
11
|
+
* notes violate this — they'll reject on next write" and is pointed at the
|
|
12
|
+
* `parachute-vault schema migrate-field` CLI (#314) rather than silently
|
|
13
|
+
* shipping a contract their own data breaks.
|
|
14
|
+
*
|
|
15
|
+
* What "violate" means here: we overlay the PROPOSED `fields` for `tag` on
|
|
16
|
+
* top of the live schema config, treating every proposed field as if it
|
|
17
|
+
* were `strict:true` for the purpose of the count (so the count is "notes
|
|
18
|
+
* that would hard-reject IF this field were strict" — the worst case the
|
|
19
|
+
* operator is being warned about). We then walk every note carrying `tag`
|
|
20
|
+
* (descendants included, per the hierarchy) and run the SAME `validateNote`
|
|
21
|
+
* resolver the write path uses, counting notes with ≥1 violation on a field
|
|
22
|
+
* the proposal touches.
|
|
23
|
+
*
|
|
24
|
+
* This is a READ — no mutation. It's deliberately scoped to the proposed
|
|
25
|
+
* tag's own fields (the columns the operator is editing); inherited fields
|
|
26
|
+
* from ancestors are already enforced and aren't what the operator is
|
|
27
|
+
* tightening in this edit.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Database } from "bun:sqlite";
|
|
31
|
+
import {
|
|
32
|
+
loadSchemaConfig,
|
|
33
|
+
validateNote,
|
|
34
|
+
type SchemaField,
|
|
35
|
+
type ResolvedSchemas,
|
|
36
|
+
} from "./schema-defaults.ts";
|
|
37
|
+
import type { TagFieldSchema } from "./tag-schemas.ts";
|
|
38
|
+
import { getTagDescendants, loadTagHierarchy } from "./tag-hierarchy.ts";
|
|
39
|
+
import { queryNotes, getNoteTagsForNotes } from "./notes.ts";
|
|
40
|
+
|
|
41
|
+
export interface ConformanceViolation {
|
|
42
|
+
/** Note id of a non-conforming note. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Note path, when set (helps the operator find it). */
|
|
45
|
+
path?: string | null;
|
|
46
|
+
/** The proposed-field violations on this note (reasons + messages). */
|
|
47
|
+
fields: { field: string; reason: string; message: string }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConformanceReport {
|
|
51
|
+
tag: string;
|
|
52
|
+
/** Total notes carrying the tag (descendants included). */
|
|
53
|
+
total_notes: number;
|
|
54
|
+
/** Count of notes that would violate the proposed spec. */
|
|
55
|
+
violating_notes: number;
|
|
56
|
+
/** The field names the proposal touches (the ones checked). */
|
|
57
|
+
checked_fields: string[];
|
|
58
|
+
/**
|
|
59
|
+
* A bounded sample of violating notes (id + path + which fields), so the
|
|
60
|
+
* SPA can show examples without paging the whole vault. Capped at
|
|
61
|
+
* `sampleLimit` (default 20).
|
|
62
|
+
*/
|
|
63
|
+
sample: ConformanceViolation[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert the on-disk `TagFieldSchema` shape to the resolver's `SchemaField`
|
|
68
|
+
* shape, FORCING `strict:true` so `validateNote` flags every constraint as a
|
|
69
|
+
* (strict) violation we can count. `indexed` is dropped — it's not a
|
|
70
|
+
* note-data constraint. `type` is narrowed to the resolver's union; an
|
|
71
|
+
* unknown type string is dropped (no type check) rather than throwing.
|
|
72
|
+
*/
|
|
73
|
+
function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
|
|
74
|
+
const out: SchemaField = { strict: true };
|
|
75
|
+
if (
|
|
76
|
+
spec.type === "string" ||
|
|
77
|
+
spec.type === "number" ||
|
|
78
|
+
spec.type === "integer" ||
|
|
79
|
+
spec.type === "boolean" ||
|
|
80
|
+
spec.type === "array" ||
|
|
81
|
+
spec.type === "object"
|
|
82
|
+
) {
|
|
83
|
+
out.type = spec.type;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(spec.enum)) out.enum = spec.enum;
|
|
86
|
+
if (spec.required === true) out.required = true;
|
|
87
|
+
if (spec.cardinality === "one" || spec.cardinality === "many") {
|
|
88
|
+
out.cardinality = spec.cardinality;
|
|
89
|
+
}
|
|
90
|
+
if (typeof spec.description === "string") out.description = spec.description;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a `ResolvedSchemas` snapshot with the proposed fields overlaid on
|
|
96
|
+
* `tag`'s OWN field map. We start from the live config (so inheritance +
|
|
97
|
+
* other tags are intact) and replace just `tag`'s field declarations with
|
|
98
|
+
* the strict-forced proposed set.
|
|
99
|
+
*/
|
|
100
|
+
function overlayProposedSchema(
|
|
101
|
+
base: ResolvedSchemas,
|
|
102
|
+
tag: string,
|
|
103
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
104
|
+
): ResolvedSchemas {
|
|
105
|
+
const tagToFields = new Map(base.tagToFields);
|
|
106
|
+
const forced: Record<string, SchemaField> = {};
|
|
107
|
+
for (const [name, spec] of Object.entries(proposedFields)) {
|
|
108
|
+
forced[name] = toStrictSchemaField(spec);
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(forced).length > 0) {
|
|
111
|
+
tagToFields.set(tag, forced);
|
|
112
|
+
} else {
|
|
113
|
+
tagToFields.delete(tag);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
allTags: new Set(base.allTags).add(tag),
|
|
117
|
+
tagToFields,
|
|
118
|
+
tagToParents: base.tagToParents,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Count existing notes that would violate the PROPOSED field spec for `tag`.
|
|
124
|
+
*
|
|
125
|
+
* `proposedFields` is the full merged field map the operator intends to save
|
|
126
|
+
* (the same object the PUT body carries). Only the fields in this map are
|
|
127
|
+
* checked — the violation count answers "if I tighten THESE fields, how many
|
|
128
|
+
* notes break?" The check runs against every note carrying `tag` or any of
|
|
129
|
+
* its descendants.
|
|
130
|
+
*
|
|
131
|
+
* Pure read; no mutation. Returns 0 violating notes when `proposedFields` is
|
|
132
|
+
* empty (nothing to enforce).
|
|
133
|
+
*/
|
|
134
|
+
export function countConformanceViolations(
|
|
135
|
+
db: Database,
|
|
136
|
+
tag: string,
|
|
137
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
138
|
+
opts: { sampleLimit?: number } = {},
|
|
139
|
+
): ConformanceReport {
|
|
140
|
+
const sampleLimit = opts.sampleLimit ?? 20;
|
|
141
|
+
const checkedFields = Object.keys(proposedFields);
|
|
142
|
+
|
|
143
|
+
const empty: ConformanceReport = {
|
|
144
|
+
tag,
|
|
145
|
+
total_notes: 0,
|
|
146
|
+
violating_notes: 0,
|
|
147
|
+
checked_fields: checkedFields,
|
|
148
|
+
sample: [],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Resolve the tag's descendant set so we count notes on subtype tags too
|
|
152
|
+
// (a strict field on `#dev` applies to `#dev/log` notes via inheritance).
|
|
153
|
+
const hierarchy = loadTagHierarchy(db);
|
|
154
|
+
const tagSet = Array.from(getTagDescendants(hierarchy, tag));
|
|
155
|
+
if (tagSet.length === 0) return empty;
|
|
156
|
+
|
|
157
|
+
// All notes carrying the tag (or a descendant). `tagMatch: "any"` over the
|
|
158
|
+
// expanded set — a note on ANY of these tags is in scope. We hydrate tags
|
|
159
|
+
// ourselves (batched) so the resolver sees the full ancestor set per note.
|
|
160
|
+
const notes = queryNotes(db, { tags: tagSet, tagMatch: "any" });
|
|
161
|
+
if (notes.length === 0) return empty;
|
|
162
|
+
|
|
163
|
+
if (checkedFields.length === 0) {
|
|
164
|
+
return { ...empty, total_notes: notes.length };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const base = loadSchemaConfig(db);
|
|
168
|
+
const resolved = overlayProposedSchema(base, tag, proposedFields);
|
|
169
|
+
const checkedSet = new Set(checkedFields);
|
|
170
|
+
|
|
171
|
+
const tagsById = getNoteTagsForNotes(
|
|
172
|
+
db,
|
|
173
|
+
notes.map((n) => n.id),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
let violating = 0;
|
|
177
|
+
const sample: ConformanceViolation[] = [];
|
|
178
|
+
|
|
179
|
+
for (const note of notes) {
|
|
180
|
+
const tags = tagsById.get(note.id) ?? note.tags ?? [];
|
|
181
|
+
const status = validateNote(resolved, {
|
|
182
|
+
path: note.path ?? null,
|
|
183
|
+
tags,
|
|
184
|
+
metadata: note.metadata ?? {},
|
|
185
|
+
});
|
|
186
|
+
if (!status) continue;
|
|
187
|
+
// Keep only violations on a field the PROPOSAL touches (ignore conflicts
|
|
188
|
+
// + violations on inherited/other fields the operator isn't editing).
|
|
189
|
+
const relevant = status.warnings.filter(
|
|
190
|
+
(w) => w.reason !== "schema_conflict" && checkedSet.has(w.field),
|
|
191
|
+
);
|
|
192
|
+
if (relevant.length === 0) continue;
|
|
193
|
+
violating++;
|
|
194
|
+
if (sample.length < sampleLimit) {
|
|
195
|
+
sample.push({
|
|
196
|
+
id: note.id,
|
|
197
|
+
path: note.path ?? null,
|
|
198
|
+
fields: relevant.map((w) => ({
|
|
199
|
+
field: w.field,
|
|
200
|
+
reason: w.reason,
|
|
201
|
+
message: w.message,
|
|
202
|
+
})),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
tag,
|
|
209
|
+
total_notes: notes.length,
|
|
210
|
+
violating_notes: violating,
|
|
211
|
+
checked_fields: checkedFields,
|
|
212
|
+
sample,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/core/src/store.ts
CHANGED
|
@@ -28,6 +28,10 @@ import {
|
|
|
28
28
|
type ResolvedSchemas,
|
|
29
29
|
type ValidationStatus,
|
|
30
30
|
} from "./schema-defaults.js";
|
|
31
|
+
import {
|
|
32
|
+
countConformanceViolations,
|
|
33
|
+
type ConformanceReport,
|
|
34
|
+
} from "./conformance.js";
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -716,6 +720,21 @@ export class BunSqliteStore implements Store {
|
|
|
716
720
|
return result;
|
|
717
721
|
}
|
|
718
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Conformance check (vault#283) — count how many EXISTING notes carrying
|
|
725
|
+
* `tag` (descendants included) would violate the PROPOSED field spec, so a
|
|
726
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
727
|
+
* warn the operator BEFORE save. Pure read — no mutation. See
|
|
728
|
+
* core/src/conformance.ts.
|
|
729
|
+
*/
|
|
730
|
+
async countTagConformance(
|
|
731
|
+
tag: string,
|
|
732
|
+
proposedFields: Record<string, tagSchemaOps.TagFieldSchema>,
|
|
733
|
+
opts?: { sampleLimit?: number },
|
|
734
|
+
): Promise<ConformanceReport> {
|
|
735
|
+
return countConformanceViolations(this.db, tag, proposedFields, opts);
|
|
736
|
+
}
|
|
737
|
+
|
|
719
738
|
// ---- Batch Wikilink Sync ----
|
|
720
739
|
|
|
721
740
|
/**
|
package/core/src/types.ts
CHANGED
|
@@ -3,12 +3,14 @@ import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } f
|
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
4
|
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
5
5
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
|
+
import type { ConformanceReport } from "./conformance.js";
|
|
6
7
|
|
|
7
8
|
// ---- Re-exports ----
|
|
8
9
|
|
|
9
10
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
10
11
|
export type { PrunedField } from "./indexed-fields.js";
|
|
11
12
|
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
13
|
+
export type { ConformanceReport } from "./conformance.js";
|
|
12
14
|
|
|
13
15
|
// ---- Note ----
|
|
14
16
|
|
|
@@ -413,6 +415,19 @@ export interface Store {
|
|
|
413
415
|
},
|
|
414
416
|
): Promise<TagRecord>;
|
|
415
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Conformance check (vault#283) — count existing notes carrying `tag`
|
|
420
|
+
* (descendants included) that would violate the PROPOSED field spec, so a
|
|
421
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
422
|
+
* warn before save. Pure read. `proposedFields` is the full merged field
|
|
423
|
+
* map the operator intends to save; only those fields are checked.
|
|
424
|
+
*/
|
|
425
|
+
countTagConformance(
|
|
426
|
+
tag: string,
|
|
427
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
428
|
+
opts?: { sampleLimit?: number },
|
|
429
|
+
): Promise<ConformanceReport>;
|
|
430
|
+
|
|
416
431
|
// Schema validation (post-v17: backed by `tags.fields` only — the
|
|
417
432
|
// standalone note_schemas + schema_mappings subsystem retired in v17, see
|
|
418
433
|
// vault#267). Post vault#270 the resolver walks `parent_names` so a note's
|
package/package.json
CHANGED
package/src/routes.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { logStrictBypass } from "./scopes.ts";
|
|
|
27
27
|
import * as linkOps from "../core/src/links.ts";
|
|
28
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
29
29
|
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
30
|
+
import { buildVaultProjection, resolveTagInheritance } from "../core/src/vault-projection.ts";
|
|
31
|
+
import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
|
|
30
32
|
import {
|
|
31
33
|
buildExpandVisibility,
|
|
32
34
|
filterHydratedLinksByTagScope,
|
|
@@ -1997,6 +1999,64 @@ export async function handleTags(
|
|
|
1997
1999
|
return json(result);
|
|
1998
2000
|
}
|
|
1999
2001
|
|
|
2002
|
+
// POST /tags/:name/conformance — count existing notes that would VIOLATE a
|
|
2003
|
+
// proposed field spec for the tag (vault#283 tightening warning). Read-only
|
|
2004
|
+
// (POST because it carries a proposed `fields` body). Must precede the
|
|
2005
|
+
// /:name matcher so "conformance" isn't read as a tag name.
|
|
2006
|
+
const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
|
|
2007
|
+
if (conformanceMatch) {
|
|
2008
|
+
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2009
|
+
const cTag = decodeURIComponent(conformanceMatch[1]!);
|
|
2010
|
+
if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
|
|
2011
|
+
return json({ error: "Tag not found", tag: cTag }, 404);
|
|
2012
|
+
}
|
|
2013
|
+
const body = (await req.json().catch(() => null)) as
|
|
2014
|
+
| { fields?: Record<string, unknown> | null }
|
|
2015
|
+
| null;
|
|
2016
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2017
|
+
// The proposed fields the operator intends to save. Sanitized through the
|
|
2018
|
+
// same parse the resolver uses (drop non-object specs). Empty/absent →
|
|
2019
|
+
// nothing to enforce → zero violations.
|
|
2020
|
+
const proposed: Record<string, tagSchemaOps.TagFieldSchema> = {};
|
|
2021
|
+
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2022
|
+
for (const [k, v] of Object.entries(body.fields)) {
|
|
2023
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
2024
|
+
proposed[k] = v as tagSchemaOps.TagFieldSchema;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
const report = await store.countTagConformance(cTag, proposed);
|
|
2029
|
+
return json(report);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// GET /tags/:name/effective — the tag's effective (own ∪ inherited) fields +
|
|
2033
|
+
// direct/effective parents + schema-conflict info, drawn from the same
|
|
2034
|
+
// projection vault-info exposes. Read-only inheritance preview for the
|
|
2035
|
+
// Schema editor (vault#283). Must precede the /:name matcher.
|
|
2036
|
+
const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
|
|
2037
|
+
if (effectiveMatch) {
|
|
2038
|
+
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2039
|
+
const eTag = decodeURIComponent(effectiveMatch[1]!);
|
|
2040
|
+
if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
|
|
2041
|
+
return json({ error: "Tag not found", tag: eTag }, 404);
|
|
2042
|
+
}
|
|
2043
|
+
const projection = buildVaultProjection(store.db);
|
|
2044
|
+
const record = await store.getTagRecord(eTag);
|
|
2045
|
+
// Resolve inheritance directly (not via projection.tags, which omits
|
|
2046
|
+
// hierarchy-only tags carrying no own schema) so the editor's preview
|
|
2047
|
+
// works even for a tag the operator is just starting to give fields.
|
|
2048
|
+
const resolved = loadSchemaConfig(store.db);
|
|
2049
|
+
const { effective_parents, effective_fields } = resolveTagInheritance(resolved, eTag);
|
|
2050
|
+
return json({
|
|
2051
|
+
name: eTag,
|
|
2052
|
+
parents: record?.parent_names ?? [],
|
|
2053
|
+
effective_parents,
|
|
2054
|
+
fields: record?.fields ?? null,
|
|
2055
|
+
effective_fields,
|
|
2056
|
+
indexed_fields: projection.indexed_fields,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2000
2060
|
// Routes with tag name
|
|
2001
2061
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
2002
2062
|
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
@@ -2034,7 +2094,18 @@ export async function handleTags(
|
|
|
2034
2094
|
fields?: Record<string, unknown> | null;
|
|
2035
2095
|
relationships?: Record<string, unknown> | null;
|
|
2036
2096
|
parent_names?: unknown;
|
|
2097
|
+
/**
|
|
2098
|
+
* When true, `fields` is treated as the FULL intended field map for the
|
|
2099
|
+
* tag — fields absent from the payload are DROPPED (a replace, not a
|
|
2100
|
+
* merge). Default false preserves the historical partial-update merge
|
|
2101
|
+
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2102
|
+
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2103
|
+
* so removing a field row actually deletes the field. See
|
|
2104
|
+
* patterns/tag-data-model.md.
|
|
2105
|
+
*/
|
|
2106
|
+
replace_fields?: unknown;
|
|
2037
2107
|
};
|
|
2108
|
+
const replaceFields = body.replace_fields === true;
|
|
2038
2109
|
|
|
2039
2110
|
// Validate the relationships payload up front so a bad payload returns
|
|
2040
2111
|
// 400, not a thrown 500. `relationships` is an opaque vocabulary map
|
|
@@ -2071,7 +2142,10 @@ export async function handleTags(
|
|
|
2071
2142
|
}
|
|
2072
2143
|
|
|
2073
2144
|
// Field merge mirrors MCP update-tag — preserves prior keys when the
|
|
2074
|
-
// payload only declares new ones.
|
|
2145
|
+
// payload only declares new ones. UNLESS `replace_fields: true`, in which
|
|
2146
|
+
// case `fields` is the full intended map and absent keys are dropped (the
|
|
2147
|
+
// Schema editor's full-replacement save — vault#283; without this a
|
|
2148
|
+
// removed field row is silently resurrected by the merge).
|
|
2075
2149
|
let fieldsPatch:
|
|
2076
2150
|
| Record<string, tagSchemaOps.TagFieldSchema>
|
|
2077
2151
|
| null
|
|
@@ -2079,12 +2153,17 @@ export async function handleTags(
|
|
|
2079
2153
|
if (body.fields === null) {
|
|
2080
2154
|
fieldsPatch = null;
|
|
2081
2155
|
} else if (body.fields !== undefined) {
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2156
|
+
if (replaceFields) {
|
|
2157
|
+
const full = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
|
|
2158
|
+
fieldsPatch = Object.keys(full).length > 0 ? full : null;
|
|
2159
|
+
} else {
|
|
2160
|
+
const existing = await store.getTagSchema(tagName);
|
|
2161
|
+
const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
|
|
2162
|
+
...(existing?.fields ?? {}),
|
|
2163
|
+
...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
|
|
2164
|
+
};
|
|
2165
|
+
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
2166
|
+
}
|
|
2088
2167
|
}
|
|
2089
2168
|
|
|
2090
2169
|
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
package/src/routing.test.ts
CHANGED
|
@@ -1956,6 +1956,131 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1956
1956
|
expect(res.status).toBe(200);
|
|
1957
1957
|
});
|
|
1958
1958
|
|
|
1959
|
+
// ----- vault#283: Schema editor read endpoints -------------------------
|
|
1960
|
+
|
|
1961
|
+
test("POST /api/tags/:name/conformance → counts notes violating a proposed spec", async () => {
|
|
1962
|
+
createVault("journal");
|
|
1963
|
+
const store = getVaultStore("journal");
|
|
1964
|
+
await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
|
|
1965
|
+
await store.createNote("missing", { tags: ["task"], metadata: {} });
|
|
1966
|
+
const admin = await createAdminToken("journal");
|
|
1967
|
+
|
|
1968
|
+
const path = "/vault/journal/api/tags/task/conformance";
|
|
1969
|
+
const res = await route(
|
|
1970
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1971
|
+
method: "POST",
|
|
1972
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
1973
|
+
body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
|
|
1974
|
+
}),
|
|
1975
|
+
path,
|
|
1976
|
+
);
|
|
1977
|
+
expect(res.status).toBe(200);
|
|
1978
|
+
const body = (await res.json()) as {
|
|
1979
|
+
tag: string;
|
|
1980
|
+
total_notes: number;
|
|
1981
|
+
violating_notes: number;
|
|
1982
|
+
checked_fields: string[];
|
|
1983
|
+
};
|
|
1984
|
+
expect(body.tag).toBe("task");
|
|
1985
|
+
expect(body.total_notes).toBe(2);
|
|
1986
|
+
expect(body.violating_notes).toBe(1);
|
|
1987
|
+
expect(body.checked_fields).toEqual(["status"]);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
test("POST /api/tags/:name/conformance is read-only — a read token may run it", async () => {
|
|
1991
|
+
createVault("journal");
|
|
1992
|
+
const store = getVaultStore("journal");
|
|
1993
|
+
await store.createNote("missing", { tags: ["task"], metadata: {} });
|
|
1994
|
+
const readToken = await mintToken("journal", {
|
|
1995
|
+
permission: "read",
|
|
1996
|
+
scopes: ["vault:read"],
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
const path = "/vault/journal/api/tags/task/conformance";
|
|
2000
|
+
const res = await route(
|
|
2001
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2002
|
+
method: "POST",
|
|
2003
|
+
headers: { authorization: `Bearer ${readToken}`, "content-type": "application/json" },
|
|
2004
|
+
body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
|
|
2005
|
+
}),
|
|
2006
|
+
path,
|
|
2007
|
+
);
|
|
2008
|
+
expect(res.status).toBe(200);
|
|
2009
|
+
const body = (await res.json()) as { violating_notes: number };
|
|
2010
|
+
expect(body.violating_notes).toBe(1);
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
test("PUT /api/tags/:name with replace_fields drops omitted fields (no merge resurrection)", async () => {
|
|
2014
|
+
createVault("journal");
|
|
2015
|
+
const store = getVaultStore("journal");
|
|
2016
|
+
await store.upsertTagRecord("task", {
|
|
2017
|
+
fields: { status: { type: "string" }, due: { type: "string" } },
|
|
2018
|
+
});
|
|
2019
|
+
const admin = await createAdminToken("journal");
|
|
2020
|
+
|
|
2021
|
+
const path = "/vault/journal/api/tags/task";
|
|
2022
|
+
// Send only `status` WITH replace_fields → `due` must be dropped.
|
|
2023
|
+
const res = await route(
|
|
2024
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2025
|
+
method: "PUT",
|
|
2026
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
2027
|
+
body: JSON.stringify({ fields: { status: { type: "string" } }, replace_fields: true }),
|
|
2028
|
+
}),
|
|
2029
|
+
path,
|
|
2030
|
+
);
|
|
2031
|
+
expect(res.status).toBe(200);
|
|
2032
|
+
const rec = await store.getTagRecord("task");
|
|
2033
|
+
expect(Object.keys(rec?.fields ?? {})).toEqual(["status"]);
|
|
2034
|
+
expect(rec?.fields?.due).toBeUndefined();
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
test("PUT /api/tags/:name WITHOUT replace_fields preserves omitted fields (merge — MCP contract)", async () => {
|
|
2038
|
+
createVault("journal");
|
|
2039
|
+
const store = getVaultStore("journal");
|
|
2040
|
+
await store.upsertTagRecord("task", {
|
|
2041
|
+
fields: { status: { type: "string" }, due: { type: "string" } },
|
|
2042
|
+
});
|
|
2043
|
+
const admin = await createAdminToken("journal");
|
|
2044
|
+
|
|
2045
|
+
const path = "/vault/journal/api/tags/task";
|
|
2046
|
+
// Send only `status` WITHOUT the flag → `due` preserved (partial update).
|
|
2047
|
+
const res = await route(
|
|
2048
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2049
|
+
method: "PUT",
|
|
2050
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
2051
|
+
body: JSON.stringify({ fields: { status: { type: "string", enum: ["a"] } } }),
|
|
2052
|
+
}),
|
|
2053
|
+
path,
|
|
2054
|
+
);
|
|
2055
|
+
expect(res.status).toBe(200);
|
|
2056
|
+
const rec = await store.getTagRecord("task");
|
|
2057
|
+
expect(Object.keys(rec?.fields ?? {}).sort()).toEqual(["due", "status"]);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
test("GET /api/tags/:name/effective → surfaces inherited fields + parents", async () => {
|
|
2061
|
+
createVault("journal");
|
|
2062
|
+
const store = getVaultStore("journal");
|
|
2063
|
+
// dev declares a field; dev/log inherits it via parent_names.
|
|
2064
|
+
await store.upsertTagRecord("dev", {
|
|
2065
|
+
fields: { area: { type: "string" } },
|
|
2066
|
+
});
|
|
2067
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
2068
|
+
const admin = await createAdminToken("journal");
|
|
2069
|
+
|
|
2070
|
+
const path = "/vault/journal/api/tags/dev%2Flog/effective";
|
|
2071
|
+
const res = await route(authed(admin, "GET", path), path);
|
|
2072
|
+
expect(res.status).toBe(200);
|
|
2073
|
+
const body = (await res.json()) as {
|
|
2074
|
+
name: string;
|
|
2075
|
+
parents: string[];
|
|
2076
|
+
effective_parents: string[];
|
|
2077
|
+
effective_fields: Record<string, { type?: string }>;
|
|
2078
|
+
};
|
|
2079
|
+
expect(body.parents).toEqual(["dev"]);
|
|
2080
|
+
expect(body.effective_parents).toContain("dev");
|
|
2081
|
+
expect(body.effective_fields.area?.type).toBe("string");
|
|
2082
|
+
});
|
|
2083
|
+
|
|
1959
2084
|
test("POST /api/tags/:name/rename → 200 cascades token allowlists (vault#240)", async () => {
|
|
1960
2085
|
createVault("journal");
|
|
1961
2086
|
const store = getVaultStore("journal");
|