@openparachute/vault 0.4.0 → 0.4.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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
|
@@ -1,73 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schema validation:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Schema validation: walk the tags carried by a note (plus their ancestors via
|
|
3
|
+
* `parent_names`), look up each ancestor's `fields` declaration, merge them
|
|
4
|
+
* (first-in-walk wins on conflict), and validate the note's metadata against
|
|
5
|
+
* the merged field map. Writes are never blocked — schemas are guidance. The
|
|
6
|
+
* MCP/REST layer surfaces a `validation_status` block on create/update
|
|
7
|
+
* responses with any warnings the agent can act on (type mismatch, enum
|
|
8
|
+
* mismatch, schema conflict).
|
|
7
9
|
*
|
|
8
|
-
* Storage
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Storage: schemas live as `fields` columns on the `tags` table — same row
|
|
11
|
+
* that already carries description, relationships, and parent_names. Authoring
|
|
12
|
+
* is via `update-tag` (MCP) or PATCH /api/tags/:name (REST).
|
|
13
|
+
*
|
|
14
|
+
* This was a two-table subsystem (`note_schemas` + `schema_mappings`) prior
|
|
15
|
+
* to v17 — see vault#267. Removed in v17 because zero operator vaults used
|
|
16
|
+
* the path-prefix mapping kind, and tag-mapped schemas were fully redundant
|
|
17
|
+
* with `tags.fields`. The single-axis tag-driven validation lives here.
|
|
18
|
+
*
|
|
19
|
+
* Inheritance (vault#270, 2026-05-09):
|
|
20
|
+
* - A note's effective ancestor set = union of {tag ∪ ancestors(tag)} for each
|
|
21
|
+
* tag on the note, walking `parent_names` recursively (cycle-safe).
|
|
22
|
+
* - `_default` is an implicit universal parent: if a tag named `_default`
|
|
23
|
+
* exists in the tags table, it's appended to every note's effective ancestor
|
|
24
|
+
* set (including untagged notes). The `tags.parent_names` column is never
|
|
25
|
+
* auto-mutated — the magic lives at resolve time only.
|
|
26
|
+
* - Conflict resolution: first-in-walk wins. The walk visits each note tag
|
|
27
|
+
* in order, then DFS through its `parent_names` array in declaration order,
|
|
28
|
+
* so "first-in-`parent_names`-array wins" is the operator-controlled
|
|
29
|
+
* precedence. Conflicts surface as advisory `schema_conflict` warnings; no
|
|
30
|
+
* write blocking.
|
|
31
|
+
* - `_default` can technically carry its own `parent_names` and the resolver
|
|
32
|
+
* handles it (cycle guard + visited Set), but the resulting interaction is
|
|
33
|
+
* non-obvious — `_default` is usually appended last, so its ancestors
|
|
34
|
+
* become low-precedence. Treat `_default` as a root tag in normal use.
|
|
13
35
|
*
|
|
14
36
|
* Resolution model:
|
|
15
37
|
* - Lazy: rebuilt on first access, cached on the store.
|
|
16
|
-
* - Invalidated when `
|
|
17
|
-
*
|
|
18
|
-
* - When no
|
|
19
|
-
* no-op (status omitted).
|
|
38
|
+
* - Invalidated when `tags.fields` or `tags.parent_names` is mutated, when a
|
|
39
|
+
* tag is deleted, renamed, or merged.
|
|
40
|
+
* - When no ancestor declares fields, validation is a no-op (status omitted).
|
|
20
41
|
*/
|
|
21
42
|
|
|
22
43
|
import { Database } from "bun:sqlite";
|
|
23
44
|
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
// Legacy path prefixes — kept exported for any historical caller that still
|
|
26
|
-
// references them. No resolver code reads notes-as-config post-v15.
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
export const SCHEMA_CONFIG_PREFIX = "_schemas/";
|
|
30
|
-
export const SCHEMA_DEFAULTS_PATH = "_schema_defaults";
|
|
31
|
-
|
|
32
45
|
// ---------------------------------------------------------------------------
|
|
33
46
|
// Types
|
|
34
47
|
// ---------------------------------------------------------------------------
|
|
35
48
|
|
|
36
49
|
export interface SchemaField {
|
|
37
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Declared type for the field's metadata value. `"integer"` is distinct
|
|
52
|
+
* from `"number"` only at validation time — JSON has no separate integer
|
|
53
|
+
* type, so a JSON number with zero fractional part (`5`, `5.0`,
|
|
54
|
+
* `Number.isInteger(n) === true`) is accepted as integer and a non-zero
|
|
55
|
+
* fractional value (`5.5`) is rejected. This matches the indexed-fields
|
|
56
|
+
* `"integer"` storage type (TYPE_MAP) and removes the false-positive
|
|
57
|
+
* `type_mismatch` warning that previously fired on every integer-shaped
|
|
58
|
+
* field because the validator had no `"integer"` case. See vault#310.
|
|
59
|
+
*/
|
|
60
|
+
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
|
38
61
|
enum?: string[];
|
|
39
62
|
description?: string;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface SchemaDefaults {
|
|
50
|
-
/** Path prefix → schema name. Longest prefix wins on tie. */
|
|
51
|
-
pathPrefixes: Array<{ prefix: string; schema: string }>;
|
|
52
|
-
/** Tag → schema name. */
|
|
53
|
-
tagToSchema: Map<string, string>;
|
|
54
|
-
}
|
|
55
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Tag-record snapshot used by the resolver. Loaded from the `tags` table once
|
|
67
|
+
* and cached on the store. `allTags` carries every known name (so `_default`
|
|
68
|
+
* existence checks and query expansion can be answered without a re-read).
|
|
69
|
+
*/
|
|
56
70
|
export interface ResolvedSchemas {
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
/** Set of all known tag names (for `_default` magic + presence checks). */
|
|
72
|
+
allTags: Set<string>;
|
|
73
|
+
/** Per-tag own fields (only entries with at least one declaration). */
|
|
74
|
+
tagToFields: Map<string, Record<string, SchemaField>>;
|
|
75
|
+
/** Per-tag `parent_names` (only entries with at least one parent declared). */
|
|
76
|
+
tagToParents: Map<string, string[]>;
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
export interface ValidationWarning {
|
|
62
80
|
field: string;
|
|
81
|
+
/** Tag whose schema declared the violated field (or won the conflict). */
|
|
63
82
|
schema: string;
|
|
64
|
-
/**
|
|
65
|
-
|
|
83
|
+
/**
|
|
84
|
+
* `type_mismatch` — value's type contradicts the declared `type`.
|
|
85
|
+
* `enum_mismatch` — string value not in the declared `enum`.
|
|
86
|
+
* `schema_conflict` — two ancestors declared the same field with
|
|
87
|
+
* different specs; first-in-walk wins, the loser surfaces here so the
|
|
88
|
+
* operator can resolve the disagreement.
|
|
89
|
+
*/
|
|
90
|
+
reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
|
|
66
91
|
message: string;
|
|
92
|
+
/**
|
|
93
|
+
* `schema_conflict` only — the tag whose declaration was overridden. Set
|
|
94
|
+
* when `reason === "schema_conflict"`; absent on type/enum mismatches.
|
|
95
|
+
* Surfaces structurally so agents don't have to regex `message` to find
|
|
96
|
+
* the loser.
|
|
97
|
+
*/
|
|
98
|
+
loser_schema?: string;
|
|
67
99
|
}
|
|
68
100
|
|
|
69
101
|
export interface ValidationStatus {
|
|
70
|
-
/**
|
|
102
|
+
/** Tag names whose schemas contributed at least one field to the merged map. */
|
|
71
103
|
schemas: string[];
|
|
72
104
|
/** Empty when all checks pass. */
|
|
73
105
|
warnings: ValidationWarning[];
|
|
@@ -99,55 +131,34 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
|
|
|
99
131
|
return fields;
|
|
100
132
|
}
|
|
101
133
|
|
|
102
|
-
function
|
|
134
|
+
function parseParentsJson(raw: string | null): string[] {
|
|
103
135
|
if (!raw) return [];
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
} catch {
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
136
|
+
let parsed: unknown;
|
|
137
|
+
try { parsed = JSON.parse(raw); } catch { return []; }
|
|
138
|
+
if (!Array.isArray(parsed)) return [];
|
|
139
|
+
return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
/**
|
|
114
|
-
* Build
|
|
115
|
-
*
|
|
116
|
-
* tables are empty (empty maps).
|
|
143
|
+
* Build a resolution map from the `tags` table. Returns a well-formed
|
|
144
|
+
* `ResolvedSchemas` even when no tag declares fields (empty `tagToFields`).
|
|
117
145
|
*/
|
|
118
146
|
export function loadSchemaConfig(db: Database): ResolvedSchemas {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
147
|
+
const allTags = new Set<string>();
|
|
148
|
+
const tagToFields = new Map<string, Record<string, SchemaField>>();
|
|
149
|
+
const tagToParents = new Map<string, string[]>();
|
|
150
|
+
const rows = db.prepare(
|
|
151
|
+
`SELECT name, fields, parent_names FROM tags`,
|
|
152
|
+
).all() as { name: string; fields: string | null; parent_names: string | null }[];
|
|
153
|
+
for (const row of rows) {
|
|
124
154
|
if (!row.name) continue;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const defaults: SchemaDefaults = {
|
|
134
|
-
pathPrefixes: [],
|
|
135
|
-
tagToSchema: new Map(),
|
|
136
|
-
};
|
|
137
|
-
const mappingRows = db.prepare(
|
|
138
|
-
`SELECT schema_name, match_kind, match_value FROM schema_mappings`,
|
|
139
|
-
).all() as { schema_name: string; match_kind: string; match_value: string }[];
|
|
140
|
-
for (const row of mappingRows) {
|
|
141
|
-
if (row.match_kind === "path_prefix") {
|
|
142
|
-
defaults.pathPrefixes.push({ prefix: row.match_value, schema: row.schema_name });
|
|
143
|
-
} else if (row.match_kind === "tag") {
|
|
144
|
-
defaults.tagToSchema.set(row.match_value, row.schema_name);
|
|
145
|
-
}
|
|
155
|
+
allTags.add(row.name);
|
|
156
|
+
const fields = parseFieldsJson(row.fields);
|
|
157
|
+
if (Object.keys(fields).length > 0) tagToFields.set(row.name, fields);
|
|
158
|
+
const parents = parseParentsJson(row.parent_names);
|
|
159
|
+
if (parents.length > 0) tagToParents.set(row.name, parents);
|
|
146
160
|
}
|
|
147
|
-
|
|
148
|
-
defaults.pathPrefixes.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
149
|
-
|
|
150
|
-
return { defaults, definitions };
|
|
161
|
+
return { allTags, tagToFields, tagToParents };
|
|
151
162
|
}
|
|
152
163
|
|
|
153
164
|
// ---------------------------------------------------------------------------
|
|
@@ -155,41 +166,111 @@ export function loadSchemaConfig(db: Database): ResolvedSchemas {
|
|
|
155
166
|
// ---------------------------------------------------------------------------
|
|
156
167
|
|
|
157
168
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
169
|
+
* Walk-order accumulator for a single note's effective ancestor set. DFS
|
|
170
|
+
* through `parent_names` in declaration order, cycle-protected via a visited
|
|
171
|
+
* Set. The output array preserves first-encounter order so the field-merge
|
|
172
|
+
* pass can apply first-wins precedence.
|
|
173
|
+
*
|
|
174
|
+
* Exported so other consumers (vault projection, future hierarchy
|
|
175
|
+
* inspectors) can reuse the exact walk semantics rather than carrying
|
|
176
|
+
* their own copy. Mutating walks (push to `out`, add to `visited`) keep
|
|
177
|
+
* the implementation cheap; callers pass fresh accumulators.
|
|
162
178
|
*/
|
|
163
|
-
export function
|
|
179
|
+
export function walkAncestors(
|
|
180
|
+
startTag: string,
|
|
164
181
|
resolved: ResolvedSchemas,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
182
|
+
visited: Set<string>,
|
|
183
|
+
out: string[],
|
|
184
|
+
): void {
|
|
185
|
+
if (visited.has(startTag)) return;
|
|
186
|
+
visited.add(startTag);
|
|
187
|
+
out.push(startTag);
|
|
188
|
+
const parents = resolved.tagToParents.get(startTag);
|
|
189
|
+
if (!parents) return;
|
|
190
|
+
for (const p of parents) {
|
|
191
|
+
walkAncestors(p, resolved, visited, out);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
169
194
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
interface MergedField {
|
|
196
|
+
spec: SchemaField;
|
|
197
|
+
sourceTag: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface NoteResolution {
|
|
201
|
+
/** Walk-order tag list whose `fields` contributed at least one entry. */
|
|
202
|
+
effectiveTags: string[];
|
|
203
|
+
/** Field name → winning spec + source tag. First-in-walk wins. */
|
|
204
|
+
mergedFields: Map<string, MergedField>;
|
|
205
|
+
/** Conflict warnings — same field declared by ≥2 ancestors with diverging specs. */
|
|
206
|
+
conflicts: ValidationWarning[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve the effective schema for a note. Walks each note tag through its
|
|
211
|
+
* `parent_names` chain (cycle-safe), implicitly appends `_default` when the
|
|
212
|
+
* tag exists, and merges all encountered `fields` declarations with
|
|
213
|
+
* first-in-walk precedence. A note with no tags still picks up `_default`'s
|
|
214
|
+
* schema when one is declared.
|
|
215
|
+
*
|
|
216
|
+
* Internal — exported for tests. The public entry point is `validateNote`.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveNoteSchemas(
|
|
219
|
+
resolved: ResolvedSchemas,
|
|
220
|
+
note: { tags?: string[] },
|
|
221
|
+
): NoteResolution {
|
|
222
|
+
const visited = new Set<string>();
|
|
223
|
+
const order: string[] = [];
|
|
224
|
+
|
|
225
|
+
for (const tag of note.tags ?? []) {
|
|
226
|
+
walkAncestors(tag, resolved, visited, order);
|
|
227
|
+
}
|
|
228
|
+
if (resolved.allTags.has("_default")) {
|
|
229
|
+
walkAncestors("_default", resolved, visited, order);
|
|
180
230
|
}
|
|
181
231
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
232
|
+
const mergedFields = new Map<string, MergedField>();
|
|
233
|
+
const conflicts: ValidationWarning[] = [];
|
|
234
|
+
|
|
235
|
+
for (const tagName of order) {
|
|
236
|
+
const fields = resolved.tagToFields.get(tagName);
|
|
237
|
+
if (!fields) continue;
|
|
238
|
+
for (const [fieldName, spec] of Object.entries(fields)) {
|
|
239
|
+
const existing = mergedFields.get(fieldName);
|
|
240
|
+
if (!existing) {
|
|
241
|
+
mergedFields.set(fieldName, { spec, sourceTag: tagName });
|
|
242
|
+
continue;
|
|
188
243
|
}
|
|
244
|
+
if (fieldSpecsEqual(existing.spec, spec)) continue;
|
|
245
|
+
conflicts.push({
|
|
246
|
+
field: fieldName,
|
|
247
|
+
schema: existing.sourceTag,
|
|
248
|
+
loser_schema: tagName,
|
|
249
|
+
reason: "schema_conflict",
|
|
250
|
+
message: `field '${fieldName}' has conflicting specs in ancestor tags '${existing.sourceTag}' (kept) and '${tagName}' (ignored)`,
|
|
251
|
+
});
|
|
189
252
|
}
|
|
190
253
|
}
|
|
191
254
|
|
|
192
|
-
|
|
255
|
+
const contributing = new Set<string>();
|
|
256
|
+
for (const { sourceTag } of mergedFields.values()) contributing.add(sourceTag);
|
|
257
|
+
const effectiveTags = order.filter((t) => contributing.has(t));
|
|
258
|
+
|
|
259
|
+
return { effectiveTags, mergedFields, conflicts };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
|
|
263
|
+
if (a.type !== b.type) return false;
|
|
264
|
+
if (!stringArraysEqual(a.enum, b.enum)) return false;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): boolean {
|
|
269
|
+
if (a === b) return true;
|
|
270
|
+
if (!a || !b) return false;
|
|
271
|
+
if (a.length !== b.length) return false;
|
|
272
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
273
|
+
return true;
|
|
193
274
|
}
|
|
194
275
|
|
|
195
276
|
function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
@@ -199,6 +280,14 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
199
280
|
return typeof value === "string";
|
|
200
281
|
case "number":
|
|
201
282
|
return typeof value === "number" && Number.isFinite(value);
|
|
283
|
+
case "integer":
|
|
284
|
+
// JSON has no separate integer type — `5.0` and `5` decode to the
|
|
285
|
+
// same JS Number. Accept any finite Number whose fractional part is
|
|
286
|
+
// zero; reject `5.5`, `NaN`, `Infinity`, and non-Number types.
|
|
287
|
+
// vault#310 (Gitcoin Brain drift detector emits JSON for diffs;
|
|
288
|
+
// without this case, every integer-typed field warned
|
|
289
|
+
// `type_mismatch` and buried the real warnings).
|
|
290
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
202
291
|
case "boolean":
|
|
203
292
|
return typeof value === "boolean";
|
|
204
293
|
case "array":
|
|
@@ -209,79 +298,52 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
209
298
|
}
|
|
210
299
|
|
|
211
300
|
/**
|
|
212
|
-
* Validate a note's metadata against
|
|
213
|
-
*
|
|
214
|
-
*
|
|
301
|
+
* Validate a note's metadata against the merged schema. Returns null when no
|
|
302
|
+
* ancestor declares any fields (so the caller can omit `validation_status`
|
|
303
|
+
* entirely). Otherwise returns the status with conflict warnings prepended,
|
|
304
|
+
* followed by per-field type/enum mismatches.
|
|
215
305
|
*
|
|
216
|
-
* Rules per field:
|
|
217
|
-
* - In `required` and absent → `missing_required`
|
|
306
|
+
* Rules per merged field:
|
|
218
307
|
* - Present and `type` declared and value's type doesn't match → `type_mismatch`
|
|
219
308
|
* - Present and `enum` declared and value not in enum → `enum_mismatch`
|
|
220
309
|
*
|
|
221
|
-
* Fields not declared
|
|
222
|
-
* "strict" validator — it's a guide).
|
|
310
|
+
* Fields not declared by any ancestor's schema are ignored entirely (this
|
|
311
|
+
* isn't a "strict" validator — it's a guide). There is no `required` concept
|
|
312
|
+
* (post-v17); declarations are advisory only.
|
|
223
313
|
*/
|
|
224
|
-
export function
|
|
314
|
+
export function validateNote(
|
|
225
315
|
resolved: ResolvedSchemas,
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const m = metadata ?? {};
|
|
231
|
-
|
|
232
|
-
for (const name of schemaNames) {
|
|
233
|
-
const def = resolved.definitions.get(name);
|
|
234
|
-
if (!def) continue;
|
|
316
|
+
note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
317
|
+
): ValidationStatus | null {
|
|
318
|
+
const resolution = resolveNoteSchemas(resolved, note);
|
|
319
|
+
if (resolution.mergedFields.size === 0) return null;
|
|
235
320
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
warnings.push({
|
|
239
|
-
field: requiredField,
|
|
240
|
-
schema: name,
|
|
241
|
-
reason: "missing_required",
|
|
242
|
-
message: `'${requiredField}' is required by schema '${name}'`,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
321
|
+
const m = note.metadata ?? {};
|
|
322
|
+
const warnings: ValidationWarning[] = [...resolution.conflicts];
|
|
246
323
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
324
|
+
for (const [fieldName, { spec, sourceTag }] of resolution.mergedFields) {
|
|
325
|
+
if (!(fieldName in m)) continue;
|
|
326
|
+
const value = m[fieldName];
|
|
327
|
+
if (value === undefined || value === null) continue;
|
|
251
328
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
329
|
+
if (spec.type && !valueMatchesType(value, spec.type)) {
|
|
330
|
+
warnings.push({
|
|
331
|
+
field: fieldName,
|
|
332
|
+
schema: sourceTag,
|
|
333
|
+
reason: "type_mismatch",
|
|
334
|
+
message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
260
337
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
338
|
+
if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
|
|
339
|
+
warnings.push({
|
|
340
|
+
field: fieldName,
|
|
341
|
+
schema: sourceTag,
|
|
342
|
+
reason: "enum_mismatch",
|
|
343
|
+
message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
|
|
344
|
+
});
|
|
269
345
|
}
|
|
270
346
|
}
|
|
271
347
|
|
|
272
|
-
return { schemas:
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Convenience: combine resolve + validate for a note. Returns null when no
|
|
277
|
-
* schemas apply (so the caller can decide whether to omit the field on the
|
|
278
|
-
* response or surface an empty status).
|
|
279
|
-
*/
|
|
280
|
-
export function validateNote(
|
|
281
|
-
resolved: ResolvedSchemas,
|
|
282
|
-
note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
283
|
-
): ValidationStatus | null {
|
|
284
|
-
const names = resolveApplicableSchemas(resolved, note);
|
|
285
|
-
if (names.length === 0) return null;
|
|
286
|
-
return validateMetadata(resolved, names, note.metadata);
|
|
348
|
+
return { schemas: resolution.effectiveTags, warnings };
|
|
287
349
|
}
|