@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.
- package/core/src/core.test.ts +413 -0
- package/core/src/mcp.ts +194 -30
- package/core/src/notes.ts +97 -24
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema.ts +168 -9
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +20 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +192 -26
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
|
@@ -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
|
+
});
|
package/core/src/search-query.ts
CHANGED
|
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
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// regardless of queryability. This is
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
// pre-
|
|
755
|
-
//
|
|
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
|
|
758
|
-
if (
|
|
759
|
-
throw new tagSchemaOps.
|
|
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
|
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -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
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* `
|
|
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. */
|