@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.
@@ -1,34 +1,47 @@
1
1
  /**
2
- * Schema validation: resolves which schemas apply to a note (by path prefix
3
- * or tag), then validates the note's metadata against each. Writes are
4
- * never blocked schemas are guidance. The MCP/REST layer surfaces a
5
- * `validation_status` block on create/update responses with any warnings
6
- * the agent can act on (missing required, type mismatch, enum mismatch).
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 (post-v15): schemas live in the `note_schemas` table; mapping
9
- * rules live in `schema_mappings`. Authoring is via `update-note-schema`
10
- * + `set-schema-mapping` (MCP/REST). The legacy `_schemas/<name>` and
11
- * `_schema_defaults` notes are retired but left in place — inert after
12
- * v15 (no resolver reads them).
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 `note_schemas` or `schema_mappings` are mutated
17
- * (table writes, not note writes).
18
- * - When no mappings exist and nothing else matches, validation is a
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
- export interface SchemaDefinition {
43
- name: string;
44
- description?: string;
45
- fields: Record<string, SchemaField>;
46
- required: string[];
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
- defaults: SchemaDefaults;
58
- definitions: Map<string, SchemaDefinition>;
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
- /** "missing_required" | "type_mismatch" | "enum_mismatch" */
65
- reason: "missing_required" | "type_mismatch" | "enum_mismatch";
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
- /** Schema names that matched the note (for transparency). */
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 parseRequiredJson(raw: string | null): string[] {
124
+ function parseParentsJson(raw: string | null): string[] {
103
125
  if (!raw) return [];
104
- try {
105
- const parsed = JSON.parse(raw);
106
- if (!Array.isArray(parsed)) return [];
107
- return parsed.filter((x): x is string => typeof x === "string");
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 the full resolution map from the `note_schemas` and `schema_mappings`
115
- * tables. Always returns a well-formed `ResolvedSchemas` even when the
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 definitions = new Map<string, SchemaDefinition>();
120
- const defRows = db.prepare(
121
- `SELECT name, description, fields, required FROM note_schemas`,
122
- ).all() as { name: string; description: string | null; fields: string | null; required: string | null }[];
123
- for (const row of defRows) {
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
- definitions.set(row.name, {
126
- name: row.name,
127
- description: row.description ?? undefined,
128
- fields: parseFieldsJson(row.fields),
129
- required: parseRequiredJson(row.required),
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
- // Longest prefix wins sort once at load so resolve is O(n) without re-sorts.
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
- * Find the schemas that apply to a note based on its path and tags. Returns
159
- * schema *names* in the order they were resolved (path-prefix first, then
160
- * each matching tag in declaration order). Names that don't have a row in
161
- * `note_schemas` are dropped.
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 resolveApplicableSchemas(
169
+ export function walkAncestors(
170
+ startTag: string,
164
171
  resolved: ResolvedSchemas,
165
- note: { path?: string | null; tags?: string[] },
166
- ): string[] {
167
- const names: string[] = [];
168
- const seen = new Set<string>();
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
- if (note.path) {
171
- for (const { prefix, schema } of resolved.defaults.pathPrefixes) {
172
- if (note.path.startsWith(prefix)) {
173
- if (!seen.has(schema) && resolved.definitions.has(schema)) {
174
- names.push(schema);
175
- seen.add(schema);
176
- }
177
- break; // longest match wins (sorted at load)
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
- if (note.tags) {
183
- for (const tag of note.tags) {
184
- const schema = resolved.defaults.tagToSchema.get(tag);
185
- if (schema && !seen.has(schema) && resolved.definitions.has(schema)) {
186
- names.push(schema);
187
- seen.add(schema);
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
- return names;
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 each applicable schema and collect
213
- * warnings. Validation is non-blocking the caller decides what to do with
214
- * the warnings (currently: surface them on the create/update response).
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 in the schema are ignored entirely (this isn't a
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 validateMetadata(
296
+ export function validateNote(
225
297
  resolved: ResolvedSchemas,
226
- schemaNames: string[],
227
- metadata: Record<string, unknown> | undefined,
228
- ): ValidationStatus {
229
- const warnings: ValidationWarning[] = [];
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
- for (const requiredField of def.required) {
237
- if (!(requiredField in m) || m[requiredField] === undefined || m[requiredField] === null) {
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
- for (const [fieldName, field] of Object.entries(def.fields)) {
248
- if (!(fieldName in m)) continue;
249
- const value = m[fieldName];
250
- if (value === undefined || value === null) continue;
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
- if (field.type && !valueMatchesType(value, field.type)) {
253
- warnings.push({
254
- field: fieldName,
255
- schema: name,
256
- reason: "type_mismatch",
257
- message: `'${fieldName}' should be ${field.type} (schema '${name}')`,
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
- if (field.enum && field.enum.length > 0 && typeof value === "string" && !field.enum.includes(value)) {
262
- warnings.push({
263
- field: fieldName,
264
- schema: name,
265
- reason: "enum_mismatch",
266
- message: `'${fieldName}' must be one of [${field.enum.join(", ")}] (schema '${name}')`,
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: schemaNames, warnings };
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
  }
@@ -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 = 16;
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
- -- name — primary key; the schema identifier referenced by mappings.
80
- -- description — human-readable blurb (markdown).
81
- -- fields JSON: { fieldName: { type?, enum?, description? } }.
82
- -- required — JSON: string[] of required field names.
83
- CREATE TABLE IF NOT EXISTS note_schemas (
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;