@openparachute/vault 0.4.0 → 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/README.md +133 -0
- package/core/src/core.test.ts +1171 -518
- package/core/src/mcp.ts +37 -426
- package/core/src/notes.ts +405 -32
- package/core/src/schema-defaults.ts +214 -170
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +90 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +37 -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/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +313 -206
- 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 +875 -297
- package/core/src/note-schemas.ts +0 -232
|
@@ -1,34 +1,47 @@
|
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -39,35 +52,44 @@ export interface SchemaField {
|
|
|
39
52
|
description?: string;
|
|
40
53
|
}
|
|
41
54
|
|
|
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
|
-
|
|
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
|
+
*/
|
|
56
60
|
export interface ResolvedSchemas {
|
|
57
|
-
|
|
58
|
-
|
|
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[]>;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
export interface ValidationWarning {
|
|
62
70
|
field: string;
|
|
71
|
+
/** Tag whose schema declared the violated field (or won the conflict). */
|
|
63
72
|
schema: string;
|
|
64
|
-
/**
|
|
65
|
-
|
|
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";
|
|
66
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;
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
export interface ValidationStatus {
|
|
70
|
-
/**
|
|
92
|
+
/** Tag names whose schemas contributed at least one field to the merged map. */
|
|
71
93
|
schemas: string[];
|
|
72
94
|
/** Empty when all checks pass. */
|
|
73
95
|
warnings: ValidationWarning[];
|
|
@@ -99,55 +121,34 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
|
|
|
99
121
|
return fields;
|
|
100
122
|
}
|
|
101
123
|
|
|
102
|
-
function
|
|
124
|
+
function parseParentsJson(raw: string | null): string[] {
|
|
103
125
|
if (!raw) return [];
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
} catch {
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
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);
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
/**
|
|
114
|
-
* Build
|
|
115
|
-
*
|
|
116
|
-
* tables are empty (empty maps).
|
|
133
|
+
* Build a resolution map from the `tags` table. Returns a well-formed
|
|
134
|
+
* `ResolvedSchemas` even when no tag declares fields (empty `tagToFields`).
|
|
117
135
|
*/
|
|
118
136
|
export function loadSchemaConfig(db: Database): ResolvedSchemas {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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) {
|
|
124
144
|
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
|
-
}
|
|
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);
|
|
146
150
|
}
|
|
147
|
-
|
|
148
|
-
defaults.pathPrefixes.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
149
|
-
|
|
150
|
-
return { defaults, definitions };
|
|
151
|
+
return { allTags, tagToFields, tagToParents };
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
// ---------------------------------------------------------------------------
|
|
@@ -155,41 +156,111 @@ export function loadSchemaConfig(db: Database): ResolvedSchemas {
|
|
|
155
156
|
// ---------------------------------------------------------------------------
|
|
156
157
|
|
|
157
158
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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.
|
|
162
168
|
*/
|
|
163
|
-
export function
|
|
169
|
+
export function walkAncestors(
|
|
170
|
+
startTag: string,
|
|
164
171
|
resolved: ResolvedSchemas,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
184
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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);
|
|
180
220
|
}
|
|
181
221
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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;
|
|
188
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
|
+
});
|
|
189
242
|
}
|
|
190
243
|
}
|
|
191
244
|
|
|
192
|
-
|
|
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;
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
@@ -209,79 +280,52 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
209
280
|
}
|
|
210
281
|
|
|
211
282
|
/**
|
|
212
|
-
* Validate a note's metadata against
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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.
|
|
215
287
|
*
|
|
216
|
-
* Rules per field:
|
|
217
|
-
* - In `required` and absent → `missing_required`
|
|
288
|
+
* Rules per merged field:
|
|
218
289
|
* - Present and `type` declared and value's type doesn't match → `type_mismatch`
|
|
219
290
|
* - Present and `enum` declared and value not in enum → `enum_mismatch`
|
|
220
291
|
*
|
|
221
|
-
* Fields not declared
|
|
222
|
-
* "strict" validator — it's a guide).
|
|
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.
|
|
223
295
|
*/
|
|
224
|
-
export function
|
|
296
|
+
export function validateNote(
|
|
225
297
|
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;
|
|
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;
|
|
235
302
|
|
|
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
|
-
}
|
|
303
|
+
const m = note.metadata ?? {};
|
|
304
|
+
const warnings: ValidationWarning[] = [...resolution.conflicts];
|
|
246
305
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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;
|
|
251
310
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
}
|
|
260
319
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
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
|
+
});
|
|
269
327
|
}
|
|
270
328
|
}
|
|
271
329
|
|
|
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);
|
|
330
|
+
return { schemas: resolution.effectiveTags, warnings };
|
|
287
331
|
}
|
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 17;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record
|
|
@@ -69,37 +69,12 @@ CREATE TABLE IF NOT EXISTS links (
|
|
|
69
69
|
-- tags row directly. The CREATE TABLE was removed from SCHEMA_SQL after the
|
|
70
70
|
-- v14 data migration drops the table; existing v6+ vaults pick up the
|
|
71
71
|
-- migration on next boot. See migrateToV14.
|
|
72
|
-
|
|
73
|
-
-- Note schemas (v15): schema definitions used to validate notes by path
|
|
74
|
-
-- prefix or tag. Replaces the v6-era _schemas/NAME notes-as-config
|
|
75
|
-
-- convention. Validation is non-blocking — schemas surface warnings on
|
|
76
|
-
-- create/update responses, never reject the write. See
|
|
77
|
-
-- core/src/schema-defaults.ts and patterns/tag-data-model.md §Note schemas.
|
|
78
72
|
--
|
|
79
|
-
--
|
|
80
|
-
--
|
|
81
|
-
-- fields
|
|
82
|
-
--
|
|
83
|
-
|
|
84
|
-
name TEXT PRIMARY KEY,
|
|
85
|
-
description TEXT,
|
|
86
|
-
fields TEXT,
|
|
87
|
-
required TEXT,
|
|
88
|
-
created_at TEXT,
|
|
89
|
-
updated_at TEXT
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
-- Schema mappings (v15): replaces the singleton _schema_defaults note. One
|
|
93
|
-
-- row per match rule; the resolver walks the table at note-write time.
|
|
94
|
-
-- match_kind is constrained to 'path_prefix' or 'tag'. Composite PK so
|
|
95
|
-
-- (schema, kind, value) is naturally unique without an extra surrogate id.
|
|
96
|
-
-- ON DELETE CASCADE: dropping a schema cleans up its mappings.
|
|
97
|
-
CREATE TABLE IF NOT EXISTS schema_mappings (
|
|
98
|
-
schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
|
|
99
|
-
match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
|
|
100
|
-
match_value TEXT NOT NULL,
|
|
101
|
-
PRIMARY KEY (schema_name, match_kind, match_value)
|
|
102
|
-
);
|
|
73
|
+
-- note_schemas + schema_mappings (v15) were retired in v17 (vault#267).
|
|
74
|
+
-- The two-table validation subsystem turned out to be a parallel path to
|
|
75
|
+
-- the per-tag fields column with zero operator usage; v17 drops both
|
|
76
|
+
-- tables and the six MCP tools that managed them. Validation now reads
|
|
77
|
+
-- tags.fields exclusively — see core/src/schema-defaults.ts.
|
|
103
78
|
|
|
104
79
|
-- Indexed fields: SSOT for generated columns and indexes on notes derived
|
|
105
80
|
-- from tag-declared fields with indexed=true. One row per indexed metadata
|
|
@@ -209,7 +184,6 @@ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
|
|
|
209
184
|
CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
|
|
210
185
|
CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
|
|
211
186
|
CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
|
|
212
|
-
CREATE INDEX IF NOT EXISTS idx_schema_mappings_match ON schema_mappings(match_kind, match_value);
|
|
213
187
|
-- idx_tokens_vault_name is created in migrateToV16, not here. SCHEMA_SQL
|
|
214
188
|
-- runs BEFORE migrations; an upgrading v15 vault doesn't yet have the
|
|
215
189
|
-- vault_name column when this section evaluates, so the index has to
|
|
@@ -285,6 +259,13 @@ export function initSchema(db: Database): void {
|
|
|
285
259
|
// New mints via per-vault routes write the column explicitly. See vault#257.
|
|
286
260
|
migrateToV16(db);
|
|
287
261
|
|
|
262
|
+
// Migrate v16 → v17: rip the standalone `note_schemas` + `schema_mappings`
|
|
263
|
+
// subsystem. Validation now reads `tags.fields` exclusively. The two
|
|
264
|
+
// tables are dropped wholesale; if a vault carried rows we log a warning
|
|
265
|
+
// naming the dropped schemas/mappings so the operator can recreate them
|
|
266
|
+
// as `tags.fields` if needed. See vault#267.
|
|
267
|
+
migrateToV17(db);
|
|
268
|
+
|
|
288
269
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
289
270
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
290
271
|
rebuildIndexes(db);
|
|
@@ -572,6 +553,10 @@ function migrateToV14(db: Database): void {
|
|
|
572
553
|
* leaves the DB in either pre-v15 or post-v15 state, never partial.
|
|
573
554
|
*/
|
|
574
555
|
function migrateToV15(db: Database): void {
|
|
556
|
+
// note_schemas was dropped in v17; on any v17+ vault (or a v14→v17 skip),
|
|
557
|
+
// this guard returns immediately and the function is effectively dead code.
|
|
558
|
+
// Left in place rather than deleted because removing it would change initSchema's
|
|
559
|
+
// migration call ordering. Safe to delete in a future cleanup.
|
|
575
560
|
if (!hasTable(db, "note_schemas") || !hasTable(db, "notes")) return;
|
|
576
561
|
|
|
577
562
|
// Short-circuit: if either destination table already has data, the
|
|
@@ -721,6 +706,93 @@ function migrateToV16(db: Database): void {
|
|
|
721
706
|
db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
|
|
722
707
|
}
|
|
723
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Migrate v16 → v17: rip `note_schemas` + `schema_mappings` (vault#267).
|
|
711
|
+
*
|
|
712
|
+
* The two-table validation subsystem from v15 turned out to be a parallel
|
|
713
|
+
* path to `tags.fields` with zero operator usage. v17 drops both tables
|
|
714
|
+
* outright. Fresh vaults never see them; upgrading vaults lose them.
|
|
715
|
+
*
|
|
716
|
+
* If an upgrading vault DID carry rows (which Aaron's didn't, but a future
|
|
717
|
+
* operator's might), the migration logs the dropped names + mapping rules
|
|
718
|
+
* so the operator can re-create them as `tags.fields` declarations on the
|
|
719
|
+
* relevant tag rows. We don't try to auto-migrate path_prefix mappings —
|
|
720
|
+
* the new validation surface is tag-axis only, and a path_prefix → tag
|
|
721
|
+
* translation has no faithful one-to-one shape.
|
|
722
|
+
*
|
|
723
|
+
* Wrapped in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14/v15/v16
|
|
724
|
+
* pattern from vault#251 — DROP TABLE statements are individually atomic,
|
|
725
|
+
* but the wrap means a crash mid-migration leaves either pre-v17 or
|
|
726
|
+
* post-v17 state, never partial.
|
|
727
|
+
*/
|
|
728
|
+
function migrateToV17(db: Database): void {
|
|
729
|
+
const hasNoteSchemas = hasTable(db, "note_schemas");
|
|
730
|
+
const hasSchemaMappings = hasTable(db, "schema_mappings");
|
|
731
|
+
if (!hasNoteSchemas && !hasSchemaMappings) return;
|
|
732
|
+
|
|
733
|
+
// Snapshot any data so the operator can recreate as `tags.fields` if
|
|
734
|
+
// needed. Read BEFORE the transaction so we don't lose the warning if
|
|
735
|
+
// the DROP fails (the COMMIT below atomically swaps state).
|
|
736
|
+
let droppedSchemas: { name: string; description: string | null }[] = [];
|
|
737
|
+
let droppedMappings: { schema_name: string; match_kind: string; match_value: string }[] = [];
|
|
738
|
+
if (hasNoteSchemas) {
|
|
739
|
+
droppedSchemas = db.prepare(
|
|
740
|
+
"SELECT name, description FROM note_schemas",
|
|
741
|
+
).all() as { name: string; description: string | null }[];
|
|
742
|
+
}
|
|
743
|
+
if (hasSchemaMappings) {
|
|
744
|
+
droppedMappings = db.prepare(
|
|
745
|
+
"SELECT schema_name, match_kind, match_value FROM schema_mappings",
|
|
746
|
+
).all() as { schema_name: string; match_kind: string; match_value: string }[];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
db.exec("BEGIN IMMEDIATE");
|
|
750
|
+
try {
|
|
751
|
+
// Drop the index first — the index references the table; SQLite would
|
|
752
|
+
// tear it down on DROP TABLE but the explicit DROP keeps the order
|
|
753
|
+
// obvious if a future migration reads from sqlite_master mid-flight.
|
|
754
|
+
db.exec("DROP INDEX IF EXISTS idx_schema_mappings_match");
|
|
755
|
+
// schema_mappings has an FK to note_schemas — drop it first.
|
|
756
|
+
if (hasSchemaMappings) {
|
|
757
|
+
db.exec("DROP TABLE schema_mappings");
|
|
758
|
+
}
|
|
759
|
+
if (hasNoteSchemas) {
|
|
760
|
+
db.exec("DROP TABLE note_schemas");
|
|
761
|
+
}
|
|
762
|
+
db.exec("COMMIT");
|
|
763
|
+
} catch (err) {
|
|
764
|
+
db.exec("ROLLBACK");
|
|
765
|
+
throw err;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (droppedSchemas.length > 0 || droppedMappings.length > 0) {
|
|
769
|
+
const schemaNames = droppedSchemas.map((s) => s.name).join(", ");
|
|
770
|
+
const tagMappings = droppedMappings.filter((m) => m.match_kind === "tag");
|
|
771
|
+
const pathMappings = droppedMappings.filter((m) => m.match_kind === "path_prefix");
|
|
772
|
+
const lines: string[] = [
|
|
773
|
+
`[vault] migrated to schema v17 (vault#267): note_schemas + schema_mappings retired.`,
|
|
774
|
+
];
|
|
775
|
+
if (droppedSchemas.length > 0) {
|
|
776
|
+
lines.push(` dropped schemas (${droppedSchemas.length}): ${schemaNames}`);
|
|
777
|
+
}
|
|
778
|
+
if (tagMappings.length > 0) {
|
|
779
|
+
const list = tagMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
|
|
780
|
+
lines.push(
|
|
781
|
+
` dropped tag mappings (${tagMappings.length}): ${list}`,
|
|
782
|
+
` recreate as \`tags.fields\` declarations on the relevant tag rows.`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
if (pathMappings.length > 0) {
|
|
786
|
+
const list = pathMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
|
|
787
|
+
lines.push(
|
|
788
|
+
` dropped path_prefix mappings (${pathMappings.length}): ${list}`,
|
|
789
|
+
` no path-prefix-driven validation in v17 — file vault#267 if you need this.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
console.log(lines.join("\n"));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
724
796
|
function hasTable(db: Database, name: string): boolean {
|
|
725
797
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
726
798
|
return !!row;
|