@openparachute/vault 0.7.0-rc.6 → 0.7.0-rc.8

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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Search recall + ranking — schema v25 FTS rebuild (Wave 7 of the
3
+ * Reliability & Usability Program, WS2B/C, vault#551). Covers the
4
+ * MIGRATION-bearing risk this PR carries:
5
+ *
6
+ * - a v24 vault (single-column `content`-only notes_fts) upgrades to the
7
+ * v25 shape (path + content, porter stemming) via `migrateToV25`,
8
+ * idempotently, without touching note data;
9
+ * - a fresh vault gets the v25 shape directly from SCHEMA_SQL;
10
+ * - post-migration, a note's TITLE (path) is searchable — impossible
11
+ * pre-v25;
12
+ * - existing body-text search keeps working (no regression);
13
+ * - porter stemming matches regular-affix variants;
14
+ * - bm25 ranking is weighted so a title match outranks a passing body
15
+ * mention, and every result carries a legible `score`;
16
+ * - the sync triggers keep the index correct across insert/update
17
+ * (content-only, path-only, both)/delete, including notes with no path.
18
+ *
19
+ * See `src/contract-search.test.ts` for the REST/MCP-surface coverage
20
+ * (did_you_mean, the wrapped advanced-mode column-filter hint, sort
21
+ * override parity).
22
+ */
23
+ import { describe, it, expect, beforeEach } from "bun:test";
24
+ import { Database } from "bun:sqlite";
25
+ import { SqliteStore } from "./store.js";
26
+ import { initSchema, SCHEMA_VERSION } from "./schema.js";
27
+ import * as noteOps from "./notes.js";
28
+
29
+ let store: SqliteStore;
30
+ let db: Database;
31
+
32
+ beforeEach(() => {
33
+ db = new Database(":memory:");
34
+ store = new SqliteStore(db);
35
+ });
36
+
37
+ describe("search — schema v25 FTS rebuild", () => {
38
+ it("bumped SCHEMA_VERSION to at least 25 (notes_fts path+content landed)", () => {
39
+ expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(25);
40
+ });
41
+
42
+ it("a fresh vault gets the v25 notes_fts shape directly (path + content columns)", () => {
43
+ const cols = (db.prepare("PRAGMA table_info(notes_fts)").all() as { name: string }[]).map(
44
+ (r) => r.name,
45
+ );
46
+ expect(cols).toContain("path");
47
+ expect(cols).toContain("content");
48
+ });
49
+
50
+ it("a fresh vault: a note's title (path) is searchable even with unrelated body content", async () => {
51
+ await store.createNote("nothing relevant in the body here", {
52
+ path: "quarterly-budget-review",
53
+ });
54
+ const hits = await store.searchNotes("budget");
55
+ expect(hits.map((n) => n.path)).toContain("quarterly-budget-review");
56
+ });
57
+
58
+ it("a fresh vault: existing body search still works (no regression)", async () => {
59
+ await store.createNote("the widget shipped on time", { path: "unrelated-title" });
60
+ const hits = await store.searchNotes("widget");
61
+ expect(hits.map((n) => n.path)).toContain("unrelated-title");
62
+ });
63
+ });
64
+
65
+ describe("search — v24 → v25 migration (legacy vault upgrade)", () => {
66
+ /**
67
+ * Build a v24-shaped vault by hand: the OLD single-column notes_fts
68
+ * (content only), the OLD triggers (UPDATE OF content only — no path
69
+ * sync), and a handful of notes seeded BEFORE the migration runs —
70
+ * mirroring the real upgrade scenario (existing data, not a fresh DB).
71
+ * `initSchema` then drives the whole migration chain up through v25.
72
+ */
73
+ function buildLegacyV24Vault(): Database {
74
+ const legacy = new Database(":memory:");
75
+ legacy.exec(`
76
+ CREATE TABLE notes (
77
+ id TEXT PRIMARY KEY,
78
+ content TEXT DEFAULT '',
79
+ path TEXT,
80
+ metadata TEXT DEFAULT '{}',
81
+ created_at TEXT NOT NULL,
82
+ updated_at TEXT,
83
+ extension TEXT NOT NULL DEFAULT 'md',
84
+ created_by TEXT,
85
+ created_via TEXT,
86
+ last_updated_by TEXT,
87
+ last_updated_via TEXT
88
+ );
89
+ CREATE TABLE tags (
90
+ name TEXT PRIMARY KEY,
91
+ description TEXT,
92
+ fields TEXT,
93
+ relationships TEXT,
94
+ parent_names TEXT,
95
+ created_at TEXT,
96
+ updated_at TEXT
97
+ );
98
+ CREATE TABLE note_tags (
99
+ note_id TEXT NOT NULL,
100
+ tag_name TEXT NOT NULL,
101
+ PRIMARY KEY (note_id, tag_name)
102
+ );
103
+ CREATE TABLE indexed_fields (
104
+ field TEXT PRIMARY KEY,
105
+ sqlite_type TEXT NOT NULL,
106
+ declarer_tags TEXT NOT NULL DEFAULT '[]'
107
+ );
108
+ CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT);
109
+ INSERT INTO schema_version (version, applied_at) VALUES (24, '2026-01-01T00:00:00.000Z');
110
+
111
+ CREATE VIRTUAL TABLE notes_fts USING fts5(
112
+ content,
113
+ content='notes',
114
+ content_rowid='rowid'
115
+ );
116
+ CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
117
+ INSERT INTO notes_fts(rowid, content) VALUES (new.rowid, new.content);
118
+ END;
119
+ CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
120
+ INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
121
+ END;
122
+ CREATE TRIGGER notes_fts_update AFTER UPDATE OF content ON notes BEGIN
123
+ INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
124
+ INSERT INTO notes_fts(rowid, content) VALUES (new.rowid, new.content);
125
+ END;
126
+ `);
127
+
128
+ // Seed BEFORE migration — real-data-like: some notes with a path, one
129
+ // without (path IS NULL — the coalesce-to-'' path must not throw).
130
+ const insert = legacy.prepare(
131
+ "INSERT INTO notes (id, content, path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
132
+ );
133
+ insert.run(
134
+ "legacy-1",
135
+ "a dedicated writeup mentioning propolis only once in passing text",
136
+ "beekeeping-notes",
137
+ "2026-01-01T00:00:00.000Z",
138
+ "2026-01-01T00:00:00.000Z",
139
+ );
140
+ insert.run(
141
+ "legacy-2",
142
+ "nothing about the topic here at all",
143
+ "propolis",
144
+ "2026-01-02T00:00:00.000Z",
145
+ "2026-01-02T00:00:00.000Z",
146
+ );
147
+ insert.run(
148
+ "legacy-3",
149
+ "the firefighters responded quickly to the call",
150
+ null,
151
+ "2026-01-03T00:00:00.000Z",
152
+ "2026-01-03T00:00:00.000Z",
153
+ );
154
+ // Manually populate the OLD single-column FTS index (mirrors what the
155
+ // old triggers would have done on real inserts).
156
+ legacy.exec(`INSERT INTO notes_fts(rowid, content) SELECT rowid, content FROM notes`);
157
+
158
+ return legacy;
159
+ }
160
+
161
+ it("migrates a v24 vault to the current SCHEMA_VERSION without throwing", () => {
162
+ const legacy = buildLegacyV24Vault();
163
+ expect(() => initSchema(legacy)).not.toThrow();
164
+ const ver = (legacy.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v;
165
+ expect(ver).toBe(SCHEMA_VERSION);
166
+ });
167
+
168
+ it("post-migration: notes_fts carries path + content columns", () => {
169
+ const legacy = buildLegacyV24Vault();
170
+ initSchema(legacy);
171
+ const cols = (legacy.prepare("PRAGMA table_info(notes_fts)").all() as { name: string }[]).map(
172
+ (r) => r.name,
173
+ );
174
+ expect(cols).toContain("path");
175
+ expect(cols).toContain("content");
176
+ });
177
+
178
+ it("post-migration: a note's TITLE is searchable (impossible pre-v25)", () => {
179
+ const legacy = buildLegacyV24Vault();
180
+ initSchema(legacy);
181
+ // "beekeeping-notes" (legacy-1's path) never appears in any note's body.
182
+ const hits = noteOps.searchNotes(legacy, "beekeeping");
183
+ expect(hits.map((n) => n.id)).toContain("legacy-1");
184
+ });
185
+
186
+ it("post-migration: existing body search still works (no regression from the rebuild)", () => {
187
+ const legacy = buildLegacyV24Vault();
188
+ initSchema(legacy);
189
+ const hits = noteOps.searchNotes(legacy, "firefighters");
190
+ expect(hits.map((n) => n.id)).toContain("legacy-3");
191
+ });
192
+
193
+ it("post-migration: porter stemming matches a regular-affix variant not present verbatim", () => {
194
+ const legacy = buildLegacyV24Vault();
195
+ initSchema(legacy);
196
+ // legacy-3's body has "firefighters" (plural) — singular query must match.
197
+ const hits = noteOps.searchNotes(legacy, "firefighter");
198
+ expect(hits.map((n) => n.id)).toContain("legacy-3");
199
+ });
200
+
201
+ it("post-migration: a title match ranks ABOVE a passing body-only mention, and both carry a score", () => {
202
+ const legacy = buildLegacyV24Vault();
203
+ initSchema(legacy);
204
+ // legacy-2's path IS "propolis" (title match); legacy-1's body mentions
205
+ // "propolis" once in passing (body-only match). Weighted bm25 must rank
206
+ // the title match first.
207
+ const hits = noteOps.searchNotes(legacy, "propolis");
208
+ const ids = hits.map((n) => n.id);
209
+ expect(ids.indexOf("legacy-2")).toBeLessThan(ids.indexOf("legacy-1"));
210
+ for (const n of hits) {
211
+ expect(typeof n.score).toBe("number");
212
+ }
213
+ const byId = new Map(hits.map((n) => [n.id, n.score!]));
214
+ expect(byId.get("legacy-2")!).toBeGreaterThan(byId.get("legacy-1")!);
215
+ });
216
+
217
+ it("post-migration: a note that had NO path (path IS NULL) survived the rebuild and stays body-searchable", () => {
218
+ const legacy = buildLegacyV24Vault();
219
+ initSchema(legacy);
220
+ const hits = noteOps.searchNotes(legacy, "firefighters");
221
+ expect(hits.some((n) => n.id === "legacy-3")).toBe(true);
222
+ });
223
+
224
+ it("post-migration: the FTS index isn't duplicated or corrupted — an integrity-check passes", () => {
225
+ const legacy = buildLegacyV24Vault();
226
+ initSchema(legacy);
227
+ // FTS5's built-in consistency check: throws if the shadow tables and
228
+ // the external-content table have drifted apart (e.g. double-inserted
229
+ // rows from a repopulation bug).
230
+ expect(() => legacy.exec(`INSERT INTO notes_fts(notes_fts) VALUES('integrity-check')`)).not.toThrow();
231
+ });
232
+
233
+ it("migration is idempotent — running initSchema twice does not duplicate rows or throw", () => {
234
+ const legacy = buildLegacyV24Vault();
235
+ initSchema(legacy);
236
+ expect(() => initSchema(legacy)).not.toThrow();
237
+ const hits = noteOps.searchNotes(legacy, "firefighter");
238
+ // Exactly one match, not duplicated by a second repopulation pass.
239
+ expect(hits.filter((n) => n.id === "legacy-3").length).toBe(1);
240
+ expect(() => legacy.exec(`INSERT INTO notes_fts(notes_fts) VALUES('integrity-check')`)).not.toThrow();
241
+ });
242
+
243
+ it("migration is idempotent on a vault that was ALREADY on v25 (e.g. a fresh vault) — no rebuild, no data loss", async () => {
244
+ // `store`/`db` from the outer beforeEach is already a fresh v25 vault.
245
+ await store.createNote("hello world", { path: "greeting" });
246
+ expect(() => initSchema(db)).not.toThrow();
247
+ const hits = await store.searchNotes("hello");
248
+ expect(hits.length).toBe(1);
249
+ });
250
+
251
+ /**
252
+ * MUST-FIX (generalist review, #565) — LOAD-BEARING regression guard: the
253
+ * whole DDL + repopulation runs inside ONE `transaction`, so a crash partway
254
+ * through can NEVER leave a recreated-but-EMPTY notes_fts (which the
255
+ * path-column idempotency guard would then treat as "done," leaving search
256
+ * permanently empty).
257
+ *
258
+ * This drives the REAL `migrateToV25` (via `initSchema`) and injects the
259
+ * crash by monkey-patching `db.prepare` to throw on the repopulation
260
+ * `INSERT INTO notes_fts(rowid, path, content)` statement — a faithful
261
+ * mid-migration interruption AFTER the rebuild DDL (which runs via
262
+ * `db.exec`, untouched by the patch) but during repopulation. It does NOT
263
+ * hand-roll its own copy of the DDL: an earlier draft did, which made it a
264
+ * generic "transaction() rolls back DDL" test that stayed green even against
265
+ * the pre-fix unwrapped-DDL code. Driving `initSchema` means the test
266
+ * actually guards `migrateToV25`'s OWN transaction wrapping — verified by
267
+ * reverting the fix locally (DDL moved back outside `transaction`) and
268
+ * confirming this test goes RED (the post-crash `ftsCols()` reads
269
+ * `["path","content"]`, the guard skips on restart, and recovery search is
270
+ * empty).
271
+ *
272
+ * The load-bearing assertion is (a): post-crash `ftsCols() === ["content"]`
273
+ * — the rollback restored the pre-v25 shape. Under the unwrapped-DDL
274
+ * regression the recreated table would still carry `path` here.
275
+ */
276
+ it("a REAL interrupted migrateToV25 (crash during repopulation) rolls back to the v24 shape — the next initSchema fully recovers, never silent-empty", () => {
277
+ const legacy = buildLegacyV24Vault();
278
+
279
+ const ftsCols = () =>
280
+ (legacy.prepare("PRAGMA table_info(notes_fts)").all() as { name: string }[]).map((c) => c.name);
281
+ const ftsShadowTables = () =>
282
+ (
283
+ legacy
284
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'notes_fts%' ORDER BY name")
285
+ .all() as { name: string }[]
286
+ ).map((r) => r.name);
287
+
288
+ // Sanity: starts on the OLD single-column shape.
289
+ expect(ftsCols()).toEqual(["content"]);
290
+ const shadowsBefore = ftsShadowTables();
291
+
292
+ // Monkey-patch prepare so migrateToV25's repopulation INSERT throws —
293
+ // everything else (earlier migrations, the guard's PRAGMA, the
294
+ // repopulation SELECT, the DDL via db.exec) delegates to the real prepare.
295
+ const origPrepare = legacy.prepare.bind(legacy);
296
+ let injected = false;
297
+ (legacy as any).prepare = (sql: string) => {
298
+ if (!injected && /INSERT INTO notes_fts\(rowid, path, content\)/.test(sql)) {
299
+ injected = true;
300
+ throw new Error("simulated crash during notes_fts repopulation");
301
+ }
302
+ return origPrepare(sql);
303
+ };
304
+
305
+ // initSchema runs the whole migration chain; migrateToV25 throws mid-repopulation.
306
+ expect(() => initSchema(legacy)).toThrow("simulated crash during notes_fts repopulation");
307
+ // Positive control: the injection actually fired (not a vacuous pass).
308
+ expect(injected).toBe(true);
309
+
310
+ // Restore the real prepare for the assertions + recovery run.
311
+ (legacy as any).prepare = origPrepare;
312
+
313
+ // (a) LOAD-BEARING: rollback restored the v24 single-column shape. Under
314
+ // the pre-fix shape (DDL OUTSIDE the transaction) this reads
315
+ // ["path","content"], the hasColumn(notes_fts,"path") guard skips on
316
+ // restart, and search stays silently empty forever.
317
+ expect(ftsCols()).toEqual(["content"]);
318
+ // schema_version never advanced past the fixture's 24.
319
+ expect(
320
+ (legacy.prepare("SELECT MAX(version) AS v FROM schema_version").get() as { v: number }).v,
321
+ ).toBe(24);
322
+ // No orphaned/duplicated FTS shadow tables — the set is exactly what it was.
323
+ expect(ftsShadowTables()).toEqual(shadowsBefore);
324
+ // The restored v24 index is intact + consistent (integrity-check passes).
325
+ expect(() => legacy.exec(`INSERT INTO notes_fts(notes_fts) VALUES('integrity-check')`)).not.toThrow();
326
+
327
+ // (b) A clean restart re-runs the REAL migration and fully populates
328
+ // search — both a title-only term and a body term are findable.
329
+ initSchema(legacy);
330
+ expect(ftsCols()).toContain("path");
331
+ expect(noteOps.searchNotes(legacy, "beekeeping").map((n) => n.id)).toContain("legacy-1"); // title-only
332
+ expect(noteOps.searchNotes(legacy, "firefighters").map((n) => n.id)).toContain("legacy-3"); // body
333
+ expect(() => legacy.exec(`INSERT INTO notes_fts(notes_fts) VALUES('integrity-check')`)).not.toThrow();
334
+ });
335
+ });
336
+
337
+ describe("search — FTS sync triggers keep path + content current (schema v25)", () => {
338
+ it("a note created with no path is indexed fine (empty path, not an error)", async () => {
339
+ const note = await store.createNote("body text only, no title");
340
+ const hits = await store.searchNotes("body");
341
+ expect(hits.map((n) => n.id)).toContain(note.id);
342
+ });
343
+
344
+ it("a path-ONLY update (content untouched) is now synced to the index — pre-v25 this was invisible to notes_fts", async () => {
345
+ const note = await store.createNote("unrelated body text", { path: "oldtitle" });
346
+ expect((await store.searchNotes("oldtitle")).map((n) => n.id)).toContain(note.id);
347
+ await store.updateNote(note.id, { path: "newtitle" });
348
+ expect((await store.searchNotes("newtitle")).map((n) => n.id)).toContain(note.id);
349
+ expect((await store.searchNotes("oldtitle")).map((n) => n.id)).not.toContain(note.id);
350
+ });
351
+
352
+ it("deleting a note removes it from both the path and content index", async () => {
353
+ const note = await store.createNote("nothing in the body matches this term", {
354
+ path: "uniquetitleterm",
355
+ });
356
+ expect((await store.searchNotes("uniquetitleterm")).map((n) => n.id)).toContain(note.id);
357
+ expect((await store.searchNotes("nothing")).map((n) => n.id)).toContain(note.id);
358
+ await store.deleteNote(note.id);
359
+ expect((await store.searchNotes("uniquetitleterm")).map((n) => n.id)).not.toContain(note.id);
360
+ expect((await store.searchNotes("nothing")).map((n) => n.id)).not.toContain(note.id);
361
+ });
362
+ });
Binary file
package/core/src/store.ts CHANGED
@@ -741,22 +741,29 @@ export class BunSqliteStore implements Store {
741
741
  // Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
742
742
  indexedFieldOps.validateFieldName(fieldName);
743
743
  }
744
- // Default-conformance pre-validate (vault#553 Decision B) — mirrors the
745
- // indexed-type/name checks above: fail BEFORE any persistence so a bad
746
- // `default` never gets written. Runs over EVERY field in the full
747
- // (already-merged) `nextFields` map, not just indexed ones a default
748
- // that doesn't match its own field's type/enum is a tag-schema error
749
- // regardless of queryability. This is the REST parity path: MCP's
750
- // `update-tag` tool ALSO calls `collectTagFieldViolations` before
751
- // reaching here (bundling every violation into one `TagFieldConflictError`
752
- // see that function's doc comment), so a conforming MCP call never
753
- // trips this; a non-conforming one throws there first. REST has no such
754
- // pre-check, so this is REST's only gate same asymmetry as the
755
- // indexed-type/name checks it sits beside.
744
+ // Default-conformance + type-vocabulary pre-validate (vault#553
745
+ // Decision B; vault#555 fix 4/5) mirrors the indexed-type/name
746
+ // checks above: fail BEFORE any persistence so a bad `default` or an
747
+ // unrecognized `type` never gets written. Runs over EVERY field in the
748
+ // full (already-merged) `nextFields` map, not just indexed ones both
749
+ // are tag-schema errors regardless of queryability. This is a
750
+ // DEFENSE-IN-DEPTH backstop, not the primary user-facing gate: both
751
+ // REST's `PUT /api/tags/:name` (`collectCrossTagFieldViolations` +
752
+ // `collectOwnFieldDefaultAndTypeViolations`, bundled 422) and MCP's
753
+ // `update-tag` (`collectTagFieldViolations`, same bundle) now
754
+ // pre-validate every field and report ALL violations together BEFORE
755
+ // ever reaching this chokepoint — a conforming call never trips this
756
+ // fail-fast loop. It stays here so any OTHER caller of
757
+ // `store.upsertTagRecord` (imports, migrations, scripts) still fails
758
+ // closed rather than persisting a lying schema.
756
759
  for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
757
- const violation = tagSchemaOps.validateFieldDefault(fieldName, spec);
758
- if (violation) {
759
- throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, violation.message);
760
+ const typeViolation = tagSchemaOps.validateFieldType(fieldName, spec);
761
+ if (typeViolation) {
762
+ throw new tagSchemaOps.InvalidFieldTypeError(fieldName, spec.type);
763
+ }
764
+ const defaultViolation = tagSchemaOps.validateFieldDefault(fieldName, spec);
765
+ if (defaultViolation) {
766
+ throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, defaultViolation.message);
760
767
  }
761
768
  }
762
769
  }
@@ -190,6 +190,33 @@ export class InvalidFieldDefaultError extends Error {
190
190
  }
191
191
  }
192
192
 
193
+ /**
194
+ * Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
195
+ * field's declared `type` isn't one of the six recognized values (vault#555
196
+ * — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
197
+ * and persisted verbatim, no error, for any NON-indexed field: the only
198
+ * existing type check, `mapFieldType`, ran solely on `indexed: true` fields
199
+ * — see {@link validateFieldType}'s doc comment). Own-field validation leaf,
200
+ * same posture as `InvalidFieldDefaultError`.
201
+ */
202
+ export class InvalidFieldTypeError extends Error {
203
+ code = "INVALID_FIELD_TYPE" as const;
204
+ error_type = "invalid_field_type" as const;
205
+ field: string;
206
+ type: string;
207
+ valid_types: readonly string[];
208
+
209
+ constructor(field: string, type: string) {
210
+ super(
211
+ `field "${field}" declares unknown type "${type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
212
+ );
213
+ this.name = "InvalidFieldTypeError";
214
+ this.field = field;
215
+ this.type = type;
216
+ this.valid_types = VALID_FIELD_TYPES;
217
+ }
218
+ }
219
+
193
220
  /**
194
221
  * Validate that a field's declared `default` (vault#553 Decision B) conforms
195
222
  * to its own `type` and (when declared) `enum`. Returns `null` when the
@@ -226,6 +253,73 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
226
253
  return null;
227
254
  }
228
255
 
256
+ /**
257
+ * The full recognized vocabulary for `TagFieldSchema.type` — storage/
258
+ * advisory validation accepts all six; only `string`/`integer`/`boolean`
259
+ * are INDEXABLE (that narrower subset is `indexed-fields.ts`'s `TYPE_MAP`,
260
+ * enforced separately via `mapFieldType` for `indexed: true` fields).
261
+ * Matches `defaultMatchesType`'s switch and `schema-defaults.ts`'s
262
+ * `SchemaField.type` union — kept in lockstep by hand across the two
263
+ * deliberately-decoupled modules (see `validateFieldDefault`'s doc comment
264
+ * for why they don't cross-import).
265
+ */
266
+ export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object"] as const;
267
+
268
+ /**
269
+ * Validate that a field's declared `type` is one of the six recognized
270
+ * values (vault#555). Returns `null` when `type` is unset (own-field checks
271
+ * elsewhere already treat an unset type as "nothing to check against") or
272
+ * recognized; otherwise a {@link TagFieldViolation} with `reason:
273
+ * "invalid_type"`.
274
+ *
275
+ * Before this, a non-indexed field's `type` was NEVER validated anywhere —
276
+ * `mapFieldType` (the only existing type check) runs solely on fields
277
+ * declaring `indexed: true`. `update-tag{fields:{weird:{type:"frobnicator"}}}`
278
+ * on a plain (non-indexed) field silently persisted the bogus type
279
+ * verbatim; every later read of that field just skipped validation
280
+ * entirely (`valueMatchesType` in schema-defaults.ts has no vocabulary
281
+ * entry for it either). This is now the SINGLE gate — indexed fields still
282
+ * get their own, narrower `unsupported_indexed_type` check (which also
283
+ * covers a RECOGNIZED-but-unindexable type like `"array"`, a different
284
+ * case from a genuinely unknown token).
285
+ *
286
+ * Pure — never throws; callers ({@link collectTagFieldViolations}'s bundled
287
+ * MCP/REST report, and the store chokepoint's defense-in-depth pre-validate)
288
+ * each decide how to surface it.
289
+ */
290
+ export function validateFieldType(field: string, spec: TagFieldSchema): TagFieldViolation | null {
291
+ if (!spec.type) return null;
292
+ if ((VALID_FIELD_TYPES as readonly string[]).includes(spec.type)) return null;
293
+ return {
294
+ field,
295
+ reason: "invalid_type",
296
+ message: `field "${field}" declares unknown type "${spec.type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Combined own-field `default` + `type` violation collection — no cross-tag
302
+ * lookups needed, so no `db` parameter (unlike
303
+ * {@link collectCrossTagFieldViolations}). Used by REST's `PUT
304
+ * /api/tags/:name` (vault#555 fix — folds these into the SAME bundled
305
+ * `tag_field_conflict` 422 report as the cross-tag checks, replacing the
306
+ * old fail-fast single-violation `invalid_field_default` 400) and reused by
307
+ * {@link collectTagFieldViolations} (MCP) so the two surfaces can't drift on
308
+ * what counts as an own-field violation.
309
+ */
310
+ export function collectOwnFieldDefaultAndTypeViolations(
311
+ incomingFields: Record<string, TagFieldSchema>,
312
+ ): TagFieldViolation[] {
313
+ const violations: TagFieldViolation[] = [];
314
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
315
+ const typeViolation = validateFieldType(fieldName, spec);
316
+ if (typeViolation) violations.push(typeViolation);
317
+ const defaultViolation = validateFieldDefault(fieldName, spec);
318
+ if (defaultViolation) violations.push(defaultViolation);
319
+ }
320
+ return violations;
321
+ }
322
+
229
323
  /** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
230
324
  function defaultMatchesType(value: unknown, type: string): boolean {
231
325
  switch (type) {
@@ -507,7 +601,8 @@ export interface TagFieldViolation {
507
601
  | "indexed_flag_conflict"
508
602
  | "unsupported_indexed_type"
509
603
  | "invalid_field_name"
510
- | "invalid_default";
604
+ | "invalid_default"
605
+ | "invalid_type";
511
606
  message: string;
512
607
  /**
513
608
  * The conflicting declarer tag — present on the cross-tag reasons
@@ -625,18 +720,24 @@ export function collectCrossTagFieldViolations(
625
720
 
626
721
  /**
627
722
  * Full field-violation collection: {@link collectCrossTagFieldViolations}
628
- * PLUS the own-field checks (unsupported type for indexing, invalid
629
- * identifier, and vault#553 Decision B a non-conforming `default`).
630
- * Used by the MCP `update-tag` tool, which unlike REST — had no prior
631
- * single-violation status-code contract to preserve for those checks (its
632
- * old inline loop threw an unstructured `Error` for them, same as
633
- * everything else pre-#554); collecting everything here is a strict
634
- * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
635
- * why REST calls that narrower function directly instead of this one — REST
636
- * gets the SAME `invalid_default` coverage via `store.upsertTagRecord`'s
637
- * fail-fast pre-validate (mirrors its indexed-type/name checks), just as a
638
- * single-violation `InvalidFieldDefaultError` 400 rather than a bundled
639
- * `TagFieldConflictError` 422.
723
+ * PLUS EVERY own-field check {@link collectOwnFieldDefaultAndTypeViolations}
724
+ * (a non-conforming `default`, vault#553 Decision B; an unrecognized `type`,
725
+ * vault#555) AND the indexed-only checks (unsupported type for indexing,
726
+ * invalid identifier). Used by the MCP `update-tag` tool, which — unlike
727
+ * REST's indexed-type/name path had no prior single-violation
728
+ * status-code contract to preserve for those two (its old inline loop threw
729
+ * an unstructured `Error` for them, same as everything else pre-#554);
730
+ * collecting everything here is a strict improvement. See
731
+ * {@link collectCrossTagFieldViolations}'s doc comment for why REST calls
732
+ * that narrower function directly for the CROSS-tag checks, then ALSO calls
733
+ * {@link collectOwnFieldDefaultAndTypeViolations} itself (vault#555 fix 5
734
+ * REST used to get `invalid_default` coverage only via
735
+ * `store.upsertTagRecord`'s fail-fast pre-validate, a single-violation
736
+ * `InvalidFieldDefaultError` → 400 that silently dropped every OTHER bad
737
+ * field in the same call; both surfaces now report every own-field default/
738
+ * type violation together, bundled with the cross-tag ones) — the
739
+ * indexed-type/name pair stays REST's own single-violation `400
740
+ * invalid_indexed_field` path (unchanged wire contract, vault#478).
640
741
  */
641
742
  export function collectTagFieldViolations(
642
743
  db: Database,
@@ -644,14 +745,9 @@ export function collectTagFieldViolations(
644
745
  incomingFields: Record<string, TagFieldSchema>,
645
746
  ): TagFieldViolation[] {
646
747
  const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
748
+ violations.push(...collectOwnFieldDefaultAndTypeViolations(incomingFields));
647
749
 
648
750
  for (const [fieldName, spec] of Object.entries(incomingFields)) {
649
- // Own-field default-conformance check (vault#553 Decision B) — applies
650
- // to EVERY field (not just indexed ones); a bad default is a tag-schema
651
- // error regardless of whether the field is queryable.
652
- const defaultViolation = validateFieldDefault(fieldName, spec);
653
- if (defaultViolation) violations.push(defaultViolation);
654
-
655
751
  if (spec.indexed === true) {
656
752
  const mapped = mapFieldType(spec.type);
657
753
  if (!mapped) {
package/core/src/types.ts CHANGED
@@ -57,6 +57,21 @@ export interface Note {
57
57
  * for the exact degree semantics (self-loop = 2 under `both`).
58
58
  */
59
59
  linkCount?: number;
60
+ /**
61
+ * Full-text search relevance score (vault#551 WS2C — ranking legibility).
62
+ * ONLY present on results from `search=`/`query-notes{search}` — every
63
+ * other read path (structured `queryNotes`, `getNoteById`, ...) leaves
64
+ * this `undefined`. Higher is more relevant — the sign-flipped weighted
65
+ * `bm25(notes_fts, SEARCH_WEIGHT_PATH, SEARCH_WEIGHT_CONTENT)` value (raw
66
+ * SQLite bm25 is negative-is-better; flipped here so external callers get
67
+ * the more intuitive "bigger number wins" convention). Meaningful only
68
+ * for RELATIVE comparison within one result set — the absolute magnitude
69
+ * has no fixed scale and isn't comparable across different queries. When
70
+ * an explicit `sort: "asc"|"desc"` overrides relevance ordering, `score`
71
+ * is still computed and returned (for legibility) even though it no
72
+ * longer determines the result order.
73
+ */
74
+ score?: number;
60
75
  }
61
76
 
62
77
  // ---- Link ----
@@ -248,6 +263,11 @@ export interface NoteIndex {
248
263
  preview: string;
249
264
  /** Opt-in link degree (see `Note.linkCount`). */
250
265
  linkCount?: number;
266
+ /** Full-text search relevance score (see `Note.score`). Carried onto the
267
+ * lean shape too — search's default response IS the lean `NoteIndex[]`
268
+ * (`include_content` is opt-in), so `score` would be invisible in the
269
+ * common case if it only lived on the full `Note` shape. */
270
+ score?: number;
251
271
  }
252
272
 
253
273
  /** Link with hydrated note summaries. */