@openparachute/vault 0.3.3 → 0.4.3
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/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/obsidian.ts
CHANGED
|
@@ -82,8 +82,8 @@ export function parseFrontmatter(raw: string): {
|
|
|
82
82
|
// Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
|
|
83
83
|
const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
|
|
84
84
|
if (kvMatch) {
|
|
85
|
-
const key = kvMatch[1]
|
|
86
|
-
const value = kvMatch[2]
|
|
85
|
+
const key = kvMatch[1]!;
|
|
86
|
+
const value = kvMatch[2]!.trim();
|
|
87
87
|
|
|
88
88
|
if (value === "[]") {
|
|
89
89
|
frontmatter[key] = [];
|
|
@@ -143,7 +143,7 @@ export function extractInlineTags(content: string): string[] {
|
|
|
143
143
|
const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
|
|
144
144
|
let match: RegExpExecArray | null;
|
|
145
145
|
while ((match = regex.exec(stripped)) !== null) {
|
|
146
|
-
tags.add(match[1]
|
|
146
|
+
tags.add(match[1]!.toLowerCase());
|
|
147
147
|
}
|
|
148
148
|
return [...tags];
|
|
149
149
|
}
|
package/core/src/paths.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function normalizePath(path: string | null | undefined): string | null {
|
|
|
39
39
|
*/
|
|
40
40
|
export function pathTitle(path: string): string {
|
|
41
41
|
const segments = path.split("/");
|
|
42
|
-
return segments[segments.length - 1]
|
|
42
|
+
return segments[segments.length - 1]!;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* See `Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas`.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { Database } from "bun:sqlite";
|
|
18
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
19
19
|
import { getIndexedField, type IndexedField } from "./indexed-fields.js";
|
|
20
20
|
|
|
21
21
|
export const SUPPORTED_OPS = [
|
|
@@ -68,6 +68,22 @@ function validateOperatorObject(field: string, obj: Record<string, unknown>): vo
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function toBinding(field: string, op: string, value: unknown): SQLQueryBindings {
|
|
72
|
+
if (
|
|
73
|
+
value === null ||
|
|
74
|
+
typeof value === "string" ||
|
|
75
|
+
typeof value === "number" ||
|
|
76
|
+
typeof value === "boolean" ||
|
|
77
|
+
typeof value === "bigint"
|
|
78
|
+
) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
throw new QueryError(
|
|
82
|
+
`operator "${op}" on metadata field "${field}" expects a primitive value (string, number, boolean, bigint, or null), got ${typeof value}`,
|
|
83
|
+
"INVALID_OPERATOR_VALUE",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
/**
|
|
72
88
|
* Look up `field` in `indexed_fields` or throw a loud error suggesting the
|
|
73
89
|
* caller declare it via `update-tag` with `indexed: true`.
|
|
@@ -91,14 +107,14 @@ export function requireIndexedField(db: Database, field: string): IndexedField {
|
|
|
91
107
|
export function buildOperatorClause(
|
|
92
108
|
field: string,
|
|
93
109
|
opObj: Record<string, unknown>,
|
|
94
|
-
): { sql: string; params:
|
|
110
|
+
): { sql: string; params: SQLQueryBindings[] } {
|
|
95
111
|
validateOperatorObject(field, opObj);
|
|
96
112
|
// `field` came from indexed_fields (which validated it via FIELD_NAME_RE
|
|
97
113
|
// when the declaration was recorded), so interpolating it into the column
|
|
98
114
|
// name is safe.
|
|
99
115
|
const col = `"meta_${field}"`;
|
|
100
116
|
const parts: string[] = [];
|
|
101
|
-
const params:
|
|
117
|
+
const params: SQLQueryBindings[] = [];
|
|
102
118
|
|
|
103
119
|
for (const [op, value] of Object.entries(opObj)) {
|
|
104
120
|
switch (op as QueryOp) {
|
|
@@ -107,7 +123,7 @@ export function buildOperatorClause(
|
|
|
107
123
|
parts.push(`${col} IS NULL`);
|
|
108
124
|
} else {
|
|
109
125
|
parts.push(`${col} = ?`);
|
|
110
|
-
params.push(value);
|
|
126
|
+
params.push(toBinding(field, op, value));
|
|
111
127
|
}
|
|
112
128
|
break;
|
|
113
129
|
case "ne":
|
|
@@ -119,7 +135,7 @@ export function buildOperatorClause(
|
|
|
119
135
|
// that has no value for the field would be silently excluded. Be
|
|
120
136
|
// explicit: either the column is null, or the values differ.
|
|
121
137
|
parts.push(`(${col} IS NULL OR ${col} <> ?)`);
|
|
122
|
-
params.push(value);
|
|
138
|
+
params.push(toBinding(field, op, value));
|
|
123
139
|
}
|
|
124
140
|
break;
|
|
125
141
|
case "gt":
|
|
@@ -128,7 +144,7 @@ export function buildOperatorClause(
|
|
|
128
144
|
case "lte": {
|
|
129
145
|
const sym = op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=";
|
|
130
146
|
parts.push(`${col} ${sym} ?`);
|
|
131
|
-
params.push(value);
|
|
147
|
+
params.push(toBinding(field, op, value));
|
|
132
148
|
break;
|
|
133
149
|
}
|
|
134
150
|
case "in":
|
|
@@ -152,7 +168,7 @@ export function buildOperatorClause(
|
|
|
152
168
|
} else {
|
|
153
169
|
parts.push(`(${col} IS NULL OR ${col} NOT IN (${placeholders}))`);
|
|
154
170
|
}
|
|
155
|
-
for (const v of value) params.push(v);
|
|
171
|
+
for (const v of value) params.push(toBinding(field, op, v));
|
|
156
172
|
break;
|
|
157
173
|
}
|
|
158
174
|
case "exists":
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
9
|
+
*
|
|
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.
|
|
35
|
+
*
|
|
36
|
+
* Resolution model:
|
|
37
|
+
* - Lazy: rebuilt on first access, cached on the store.
|
|
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).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Database } from "bun:sqlite";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Types
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface SchemaField {
|
|
50
|
+
type?: "string" | "number" | "boolean" | "array" | "object";
|
|
51
|
+
enum?: string[];
|
|
52
|
+
description?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tag-record snapshot used by the resolver. Loaded from the `tags` table once
|
|
57
|
+
* and cached on the store. `allTags` carries every known name (so `_default`
|
|
58
|
+
* existence checks and query expansion can be answered without a re-read).
|
|
59
|
+
*/
|
|
60
|
+
export interface ResolvedSchemas {
|
|
61
|
+
/** Set of all known tag names (for `_default` magic + presence checks). */
|
|
62
|
+
allTags: Set<string>;
|
|
63
|
+
/** Per-tag own fields (only entries with at least one declaration). */
|
|
64
|
+
tagToFields: Map<string, Record<string, SchemaField>>;
|
|
65
|
+
/** Per-tag `parent_names` (only entries with at least one parent declared). */
|
|
66
|
+
tagToParents: Map<string, string[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ValidationWarning {
|
|
70
|
+
field: string;
|
|
71
|
+
/** Tag whose schema declared the violated field (or won the conflict). */
|
|
72
|
+
schema: string;
|
|
73
|
+
/**
|
|
74
|
+
* `type_mismatch` — value's type contradicts the declared `type`.
|
|
75
|
+
* `enum_mismatch` — string value not in the declared `enum`.
|
|
76
|
+
* `schema_conflict` — two ancestors declared the same field with
|
|
77
|
+
* different specs; first-in-walk wins, the loser surfaces here so the
|
|
78
|
+
* operator can resolve the disagreement.
|
|
79
|
+
*/
|
|
80
|
+
reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
|
|
81
|
+
message: string;
|
|
82
|
+
/**
|
|
83
|
+
* `schema_conflict` only — the tag whose declaration was overridden. Set
|
|
84
|
+
* when `reason === "schema_conflict"`; absent on type/enum mismatches.
|
|
85
|
+
* Surfaces structurally so agents don't have to regex `message` to find
|
|
86
|
+
* the loser.
|
|
87
|
+
*/
|
|
88
|
+
loser_schema?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ValidationStatus {
|
|
92
|
+
/** Tag names whose schemas contributed at least one field to the merged map. */
|
|
93
|
+
schemas: string[];
|
|
94
|
+
/** Empty when all checks pass. */
|
|
95
|
+
warnings: ValidationWarning[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Loading
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
|
|
103
|
+
if (!raw) return {};
|
|
104
|
+
let parsed: unknown;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
111
|
+
const fields: Record<string, SchemaField> = {};
|
|
112
|
+
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
113
|
+
if (!v || typeof v !== "object") continue;
|
|
114
|
+
const f = v as Record<string, unknown>;
|
|
115
|
+
const field: SchemaField = {};
|
|
116
|
+
if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
|
|
117
|
+
if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
|
|
118
|
+
if (typeof f.description === "string") field.description = f.description;
|
|
119
|
+
fields[k] = field;
|
|
120
|
+
}
|
|
121
|
+
return fields;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseParentsJson(raw: string | null): string[] {
|
|
125
|
+
if (!raw) return [];
|
|
126
|
+
let parsed: unknown;
|
|
127
|
+
try { parsed = JSON.parse(raw); } catch { return []; }
|
|
128
|
+
if (!Array.isArray(parsed)) return [];
|
|
129
|
+
return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build a resolution map from the `tags` table. Returns a well-formed
|
|
134
|
+
* `ResolvedSchemas` even when no tag declares fields (empty `tagToFields`).
|
|
135
|
+
*/
|
|
136
|
+
export function loadSchemaConfig(db: Database): ResolvedSchemas {
|
|
137
|
+
const allTags = new Set<string>();
|
|
138
|
+
const tagToFields = new Map<string, Record<string, SchemaField>>();
|
|
139
|
+
const tagToParents = new Map<string, string[]>();
|
|
140
|
+
const rows = db.prepare(
|
|
141
|
+
`SELECT name, fields, parent_names FROM tags`,
|
|
142
|
+
).all() as { name: string; fields: string | null; parent_names: string | null }[];
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
if (!row.name) continue;
|
|
145
|
+
allTags.add(row.name);
|
|
146
|
+
const fields = parseFieldsJson(row.fields);
|
|
147
|
+
if (Object.keys(fields).length > 0) tagToFields.set(row.name, fields);
|
|
148
|
+
const parents = parseParentsJson(row.parent_names);
|
|
149
|
+
if (parents.length > 0) tagToParents.set(row.name, parents);
|
|
150
|
+
}
|
|
151
|
+
return { allTags, tagToFields, tagToParents };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Resolution + validation
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Walk-order accumulator for a single note's effective ancestor set. DFS
|
|
160
|
+
* through `parent_names` in declaration order, cycle-protected via a visited
|
|
161
|
+
* Set. The output array preserves first-encounter order so the field-merge
|
|
162
|
+
* pass can apply first-wins precedence.
|
|
163
|
+
*
|
|
164
|
+
* Exported so other consumers (vault projection, future hierarchy
|
|
165
|
+
* inspectors) can reuse the exact walk semantics rather than carrying
|
|
166
|
+
* their own copy. Mutating walks (push to `out`, add to `visited`) keep
|
|
167
|
+
* the implementation cheap; callers pass fresh accumulators.
|
|
168
|
+
*/
|
|
169
|
+
export function walkAncestors(
|
|
170
|
+
startTag: string,
|
|
171
|
+
resolved: ResolvedSchemas,
|
|
172
|
+
visited: Set<string>,
|
|
173
|
+
out: string[],
|
|
174
|
+
): void {
|
|
175
|
+
if (visited.has(startTag)) return;
|
|
176
|
+
visited.add(startTag);
|
|
177
|
+
out.push(startTag);
|
|
178
|
+
const parents = resolved.tagToParents.get(startTag);
|
|
179
|
+
if (!parents) return;
|
|
180
|
+
for (const p of parents) {
|
|
181
|
+
walkAncestors(p, resolved, visited, out);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface MergedField {
|
|
186
|
+
spec: SchemaField;
|
|
187
|
+
sourceTag: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface NoteResolution {
|
|
191
|
+
/** Walk-order tag list whose `fields` contributed at least one entry. */
|
|
192
|
+
effectiveTags: string[];
|
|
193
|
+
/** Field name → winning spec + source tag. First-in-walk wins. */
|
|
194
|
+
mergedFields: Map<string, MergedField>;
|
|
195
|
+
/** Conflict warnings — same field declared by ≥2 ancestors with diverging specs. */
|
|
196
|
+
conflicts: ValidationWarning[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve the effective schema for a note. Walks each note tag through its
|
|
201
|
+
* `parent_names` chain (cycle-safe), implicitly appends `_default` when the
|
|
202
|
+
* tag exists, and merges all encountered `fields` declarations with
|
|
203
|
+
* first-in-walk precedence. A note with no tags still picks up `_default`'s
|
|
204
|
+
* schema when one is declared.
|
|
205
|
+
*
|
|
206
|
+
* Internal — exported for tests. The public entry point is `validateNote`.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveNoteSchemas(
|
|
209
|
+
resolved: ResolvedSchemas,
|
|
210
|
+
note: { tags?: string[] },
|
|
211
|
+
): NoteResolution {
|
|
212
|
+
const visited = new Set<string>();
|
|
213
|
+
const order: string[] = [];
|
|
214
|
+
|
|
215
|
+
for (const tag of note.tags ?? []) {
|
|
216
|
+
walkAncestors(tag, resolved, visited, order);
|
|
217
|
+
}
|
|
218
|
+
if (resolved.allTags.has("_default")) {
|
|
219
|
+
walkAncestors("_default", resolved, visited, order);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const mergedFields = new Map<string, MergedField>();
|
|
223
|
+
const conflicts: ValidationWarning[] = [];
|
|
224
|
+
|
|
225
|
+
for (const tagName of order) {
|
|
226
|
+
const fields = resolved.tagToFields.get(tagName);
|
|
227
|
+
if (!fields) continue;
|
|
228
|
+
for (const [fieldName, spec] of Object.entries(fields)) {
|
|
229
|
+
const existing = mergedFields.get(fieldName);
|
|
230
|
+
if (!existing) {
|
|
231
|
+
mergedFields.set(fieldName, { spec, sourceTag: tagName });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (fieldSpecsEqual(existing.spec, spec)) continue;
|
|
235
|
+
conflicts.push({
|
|
236
|
+
field: fieldName,
|
|
237
|
+
schema: existing.sourceTag,
|
|
238
|
+
loser_schema: tagName,
|
|
239
|
+
reason: "schema_conflict",
|
|
240
|
+
message: `field '${fieldName}' has conflicting specs in ancestor tags '${existing.sourceTag}' (kept) and '${tagName}' (ignored)`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const contributing = new Set<string>();
|
|
246
|
+
for (const { sourceTag } of mergedFields.values()) contributing.add(sourceTag);
|
|
247
|
+
const effectiveTags = order.filter((t) => contributing.has(t));
|
|
248
|
+
|
|
249
|
+
return { effectiveTags, mergedFields, conflicts };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
|
|
253
|
+
if (a.type !== b.type) return false;
|
|
254
|
+
if (!stringArraysEqual(a.enum, b.enum)) return false;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): boolean {
|
|
259
|
+
if (a === b) return true;
|
|
260
|
+
if (!a || !b) return false;
|
|
261
|
+
if (a.length !== b.length) return false;
|
|
262
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
267
|
+
if (type === undefined) return true;
|
|
268
|
+
switch (type) {
|
|
269
|
+
case "string":
|
|
270
|
+
return typeof value === "string";
|
|
271
|
+
case "number":
|
|
272
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
273
|
+
case "boolean":
|
|
274
|
+
return typeof value === "boolean";
|
|
275
|
+
case "array":
|
|
276
|
+
return Array.isArray(value);
|
|
277
|
+
case "object":
|
|
278
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Validate a note's metadata against the merged schema. Returns null when no
|
|
284
|
+
* ancestor declares any fields (so the caller can omit `validation_status`
|
|
285
|
+
* entirely). Otherwise returns the status with conflict warnings prepended,
|
|
286
|
+
* followed by per-field type/enum mismatches.
|
|
287
|
+
*
|
|
288
|
+
* Rules per merged field:
|
|
289
|
+
* - Present and `type` declared and value's type doesn't match → `type_mismatch`
|
|
290
|
+
* - Present and `enum` declared and value not in enum → `enum_mismatch`
|
|
291
|
+
*
|
|
292
|
+
* Fields not declared by any ancestor's schema are ignored entirely (this
|
|
293
|
+
* isn't a "strict" validator — it's a guide). There is no `required` concept
|
|
294
|
+
* (post-v17); declarations are advisory only.
|
|
295
|
+
*/
|
|
296
|
+
export function validateNote(
|
|
297
|
+
resolved: ResolvedSchemas,
|
|
298
|
+
note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
299
|
+
): ValidationStatus | null {
|
|
300
|
+
const resolution = resolveNoteSchemas(resolved, note);
|
|
301
|
+
if (resolution.mergedFields.size === 0) return null;
|
|
302
|
+
|
|
303
|
+
const m = note.metadata ?? {};
|
|
304
|
+
const warnings: ValidationWarning[] = [...resolution.conflicts];
|
|
305
|
+
|
|
306
|
+
for (const [fieldName, { spec, sourceTag }] of resolution.mergedFields) {
|
|
307
|
+
if (!(fieldName in m)) continue;
|
|
308
|
+
const value = m[fieldName];
|
|
309
|
+
if (value === undefined || value === null) continue;
|
|
310
|
+
|
|
311
|
+
if (spec.type && !valueMatchesType(value, spec.type)) {
|
|
312
|
+
warnings.push({
|
|
313
|
+
field: fieldName,
|
|
314
|
+
schema: sourceTag,
|
|
315
|
+
reason: "type_mismatch",
|
|
316
|
+
message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
|
|
321
|
+
warnings.push({
|
|
322
|
+
field: fieldName,
|
|
323
|
+
schema: sourceTag,
|
|
324
|
+
reason: "enum_mismatch",
|
|
325
|
+
message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { schemas: resolution.effectiveTags, warnings };
|
|
331
|
+
}
|