@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.
@@ -217,3 +217,113 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
217
217
  param,
218
218
  };
219
219
  }
220
+
221
+ /**
222
+ * Cheap zero-result search suggestion (vault#551 WS2B — mirrors the tag
223
+ * `did_you_mean` above via the SAME `suggestSimilarTag` scorer, just over a
224
+ * different candidate pool). Callers MUST only invoke this AFTER a search
225
+ * already returned zero rows — it is never on the hot non-empty path, so
226
+ * the extra vocabulary scan costs nothing in the overwhelmingly common
227
+ * case where the search actually found something.
228
+ *
229
+ * Candidates are drawn from two cheap-to-reach sources, unioned:
230
+ *
231
+ * - the FTS5 vocabulary — a `notes_fts_vocab` `fts5vocab('row')` table
232
+ * created here LAZILY and BEST-EFFORT (not part of the schema/
233
+ * migration — see the try/catch below) — the terms actually indexed,
234
+ * POST-tokenization/stemming (porter). A suggestion therefore
235
+ * sometimes reads as a stemmed form ("propoli" rather than "propolis")
236
+ * rather than the original dictionary word — an accepted tradeoff
237
+ * (documented in docs/HTTP_API.md) rather than maintaining a second,
238
+ * unstemmed index just for spelling suggestions.
239
+ * - tag names, when the caller passes `tagNames` (already cached by the
240
+ * caller's tag hierarchy — free to include).
241
+ *
242
+ * `fts5vocab` is registered by the same FTS5 extension init as `notes_fts`
243
+ * itself (not a separately-enabled module) so it's expected to be
244
+ * available anywhere search already works — but this is NOT exercised
245
+ * against Cloudflare DO SQLite (flagged for the wire reviewer). The WHOLE
246
+ * function is wrapped in try/catch and degrades to "no suggestion" on ANY
247
+ * failure — including a runtime where `fts5vocab` is unavailable — rather
248
+ * than ever throwing; a spelling hint is a nicety, never worth risking the
249
+ * search response itself.
250
+ *
251
+ * Per-token length filtering (`length(term) BETWEEN len-2 AND len+2`) keeps
252
+ * the vocabulary scan bounded on a large vault without a hardcoded row cap
253
+ * that could silently exclude the very term a caller needs (a `LIMIT`
254
+ * ORDER-BY-frequency would bias toward common words, which is backwards
255
+ * for a name/typo lookup — the word you're trying to find is often rare).
256
+ */
257
+ export function computeSearchDidYouMean(
258
+ db: Database,
259
+ rawQuery: string,
260
+ tagNames?: Iterable<string>,
261
+ ): string | undefined {
262
+ try {
263
+ const tokens = rawQuery.trim().split(/\s+/).filter(Boolean);
264
+ if (tokens.length === 0) return undefined;
265
+
266
+ db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts_vocab USING fts5vocab(notes_fts, 'row')");
267
+ const exactStmt = db.prepare("SELECT 1 FROM notes_fts_vocab WHERE term = ? LIMIT 1");
268
+ const rangeStmt = db.prepare("SELECT term FROM notes_fts_vocab WHERE length(term) BETWEEN ? AND ?");
269
+
270
+ let changed = false;
271
+ const corrected = tokens.map((tok) => {
272
+ // Strip leading/trailing punctuation for the lookup (the FTS5
273
+ // vocabulary never contains punctuation) but leave short tokens
274
+ // (numbers, "a", "of", ...) alone — too little signal to safely
275
+ // "correct" without a high false-positive rate.
276
+ const clean = tok.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
277
+ if (clean.length < 3) return tok;
278
+ const lower = clean.toLowerCase();
279
+
280
+ // Already indexed verbatim (post-stemming) — no typo signal.
281
+ if (exactStmt.get(lower)) return tok;
282
+
283
+ const lo = Math.max(1, clean.length - 2);
284
+ const hi = clean.length + 2;
285
+ const rows = rangeStmt.all(lo, hi) as { term: string }[];
286
+ const candidates = new Set<string>(rows.map((r) => r.term));
287
+ if (tagNames) for (const t of tagNames) candidates.add(t);
288
+
289
+ const suggestion = suggestSimilarTag(candidates, lower);
290
+ if (suggestion && suggestion.toLowerCase() !== lower) {
291
+ changed = true;
292
+ return suggestion;
293
+ }
294
+ return tok;
295
+ });
296
+
297
+ if (!changed) return undefined;
298
+ return corrected.join(" ");
299
+ } catch {
300
+ return undefined;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * `search_did_you_mean` warning (vault#551 WS2B) — wraps
306
+ * {@link computeSearchDidYouMean}'s suggestion in the standard warning
307
+ * shape, mirroring `unknown_tag`'s `did_you_mean` field. Returns
308
+ * `undefined` (nothing to push) when no suggestion clears the bar — a
309
+ * zero-result search is a perfectly ordinary outcome on its own and does
310
+ * NOT warrant a warning by itself; only an actual spelling suggestion does.
311
+ *
312
+ * Scope-unaware by construction, exactly like `collectUnknownTagWarnings`
313
+ * (see this file's top-of-file doc comment) — the FTS5 vocabulary spans
314
+ * the WHOLE vault regardless of any caller's tag scope. Callers on a
315
+ * tag-scoped session MUST NOT surface this warning directly:
316
+ * `src/mcp-tools.ts`'s `query-notes` wrapper already strips the entire
317
+ * `warnings` array for a scoped caller (so MCP is safe with no extra
318
+ * work); `src/routes.ts` must gate the call itself behind
319
+ * `tagScope.allowed === null`, same as it already does for
320
+ * `collectUnknownTagWarnings`.
321
+ */
322
+ export function searchDidYouMeanWarning(rawQuery: string, suggestion: string): QueryWarning {
323
+ return {
324
+ code: "search_did_you_mean",
325
+ message: `no results for "${rawQuery}" — did you mean "${suggestion}"?`,
326
+ query: rawQuery,
327
+ did_you_mean: suggestion,
328
+ };
329
+ }
@@ -4,7 +4,7 @@ import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
4
4
  import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
5
5
  import { transaction } from "./txn.js";
6
6
 
7
- export const SCHEMA_VERSION = 24;
7
+ export const SCHEMA_VERSION = 25;
8
8
 
9
9
  export const SCHEMA_SQL = `
10
10
  -- Notes: the universal record.
@@ -256,25 +256,43 @@ CREATE TABLE IF NOT EXISTS schema_version (
256
256
  applied_at TEXT NOT NULL
257
257
  );
258
258
 
259
- -- Full-text search on note content
259
+ -- Full-text search on note title (path) AND content (v25, vault#551 WS2B/C).
260
+ -- Two columns, declared in this order — bm25(notes_fts, w_path, w_content)
261
+ -- calls in core/src/notes.ts positionally match column 0 = path, column 1 =
262
+ -- content, weighted so a TITLE match outranks a passing body mention (see
263
+ -- SEARCH_WEIGHT_PATH/SEARCH_WEIGHT_CONTENT in core/src/search-query.ts).
264
+ -- Pre-v25, only content was indexed — a note's title/path was completely
265
+ -- unsearchable, which both defeated any title-biased ranking (nothing to
266
+ -- bias) and was a plain recall gap. tokenize='porter unicode61' adds
267
+ -- Porter stemming on top of the v3-era default unicode61 tokenizer so
268
+ -- regular-affix variants (firefighter/firefighters, microbe/microbes) match
269
+ -- each other; irregular plurals with a consonant change (wolf/wolves) are a
270
+ -- known Porter limitation, not fixed by this — see docs/HTTP_API.md.
271
+ -- Existing vaults upgrade via migrateToV25 (rebuild + repopulate); this
272
+ -- CREATE only reaches its new shape directly on a fresh vault.
260
273
  CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
274
+ path,
261
275
  content,
262
276
  content='notes',
263
- content_rowid='rowid'
277
+ content_rowid='rowid',
278
+ tokenize='porter unicode61'
264
279
  );
265
280
 
266
- -- FTS triggers
281
+ -- FTS triggers. UPDATE OF content, path (not content alone, pre-v25) —
282
+ -- either column changing must resync the index now that path is indexed too;
283
+ -- a path-only rename (content untouched) used to be silently invisible to
284
+ -- notes_fts because the trigger's column list didn't include it.
267
285
  CREATE TRIGGER IF NOT EXISTS notes_fts_insert AFTER INSERT ON notes BEGIN
268
- INSERT INTO notes_fts(rowid, content) VALUES (new.rowid, new.content);
286
+ INSERT INTO notes_fts(rowid, path, content) VALUES (new.rowid, COALESCE(new.path, ''), new.content);
269
287
  END;
270
288
 
271
289
  CREATE TRIGGER IF NOT EXISTS notes_fts_delete AFTER DELETE ON notes BEGIN
272
- INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
290
+ INSERT INTO notes_fts(notes_fts, rowid, path, content) VALUES('delete', old.rowid, COALESCE(old.path, ''), old.content);
273
291
  END;
274
292
 
275
- CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE OF content ON notes BEGIN
276
- INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
277
- INSERT INTO notes_fts(rowid, content) VALUES (new.rowid, new.content);
293
+ CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE OF content, path ON notes BEGIN
294
+ INSERT INTO notes_fts(notes_fts, rowid, path, content) VALUES('delete', old.rowid, COALESCE(old.path, ''), old.content);
295
+ INSERT INTO notes_fts(rowid, path, content) VALUES (new.rowid, COALESCE(new.path, ''), new.content);
278
296
  END;
279
297
 
280
298
  -- Indexes
@@ -507,6 +525,10 @@ export function initSchema(db: Database): void {
507
525
  // leave the rest in place for `doctor` to surface. See vault#553.
508
526
  migrateToV24(db);
509
527
 
528
+ // Migrate v24 → v25: rebuild notes_fts with path+content columns +
529
+ // porter stemming, repopulate from every existing note. See vault#551.
530
+ migrateToV25(db);
531
+
510
532
  // Rebuild any generated columns + indexes declared in indexed_fields.
511
533
  // No-op for a fresh vault; idempotent on existing vaults.
512
534
  rebuildIndexes(db);
@@ -1332,6 +1354,143 @@ function migrateToV24(db: Database): void {
1332
1354
  }
1333
1355
  }
1334
1356
 
1357
+ /**
1358
+ * Migrate v24 → v25: rebuild `notes_fts` to index BOTH `path` (title) and
1359
+ * `content` as separate weighted FTS5 columns, with Porter stemming added
1360
+ * to the tokenizer (vault#551 WS2B/C — the Reliability & Usability
1361
+ * Program's interim-harness finding: a note's title/path was completely
1362
+ * unsearchable pre-v25, which both defeated title-biased ranking and was a
1363
+ * plain recall gap on its own).
1364
+ *
1365
+ * `notes_fts` is an EXTERNAL CONTENT table (`content='notes'`) — the FTS5
1366
+ * index is a side structure over `notes`, not a copy of the data itself, so
1367
+ * changing its column shape means DROP + CREATE, not `ALTER`. FTS5 virtual
1368
+ * tables don't support `ALTER TABLE ... ADD COLUMN` the way ordinary tables
1369
+ * do. The three sync triggers are defined ON `notes` (`AFTER INSERT/UPDATE/
1370
+ * DELETE ON notes`), not on `notes_fts` itself, so dropping `notes_fts`
1371
+ * does NOT drop them automatically — they're dropped explicitly FIRST
1372
+ * (referencing the old single-column shape, they'd fail on the very next
1373
+ * note write if left in place against the new table) and recreated
1374
+ * immediately after in the new two-column form. SCHEMA_SQL carries the
1375
+ * identical CREATE VIRTUAL TABLE + trigger definitions for fresh vaults;
1376
+ * this function is the upgrade path for a vault that already has the old
1377
+ * shape.
1378
+ *
1379
+ * External-content tables start EMPTY after a bare CREATE — unlike a normal
1380
+ * table, there's no data to inherit from the old dropped table (FTS5 stores
1381
+ * its own inverted-index structures, not a row-for-row copy). The
1382
+ * repopulation pass below re-derives every row from `notes` directly, so
1383
+ * this migration is self-contained (it does NOT read from the old
1384
+ * `notes_fts` — that data is gone the moment DROP TABLE runs).
1385
+ *
1386
+ * ALL-OR-NOTHING (generalist review, #565): the entire DROP + CREATE +
1387
+ * trigger-recreate + repopulation sequence runs inside a SINGLE
1388
+ * `transaction`, not just the repopulation loop. A crash partway through
1389
+ * with the DDL committed but the index empty would otherwise be
1390
+ * unrecoverable — the recreated table already has the `path` column, so the
1391
+ * `hasColumn(db, "notes_fts", "path")` guard would report "done" and never
1392
+ * retry, leaving search permanently empty. Wrapping the whole sequence means a
1393
+ * rollback restores the pre-v25 single-column shape (no `path`), so the
1394
+ * guard correctly re-detects "not migrated" and the next boot re-runs it —
1395
+ * correct-by-construction rather than dependent on the DDL fully
1396
+ * completing.
1397
+ *
1398
+ * Idempotent via `hasColumn(db, "notes_fts", "path")` (checked first — a
1399
+ * vault already on the v25 shape, including every fresh vault, no-ops
1400
+ * immediately; `PRAGMA table_info` returns `[]` for a nonexistent table and
1401
+ * the declared columns for an FTS5 virtual table, so the shared helper is
1402
+ * correct here without a separate `hasTable` pre-check).
1403
+ * Cross-runtime: DROP/CREATE VIRTUAL TABLE, triggers, and the repopulation
1404
+ * SELECT/INSERT are all standard FTS5 + SQL surface — no bun-only
1405
+ * functions — but `tokenize='porter unicode61'` and the two-column
1406
+ * external-content shape are new usage for this codebase; flagged for the
1407
+ * wire reviewer to confirm against Cloudflare DO SQLite's FTS5 build (the
1408
+ * hosted door's async Store backend is not shipped yet — see `store.ts`'s
1409
+ * `BunSqliteStore` doc comment — so this is unverified-until-that-lands,
1410
+ * not a regression against a working path). Porter is part of the same
1411
+ * FTS5 extension registration as unicode61 (not a separately-enabled
1412
+ * module) and `PRAGMA table_info` on a virtual table is standard SQLite,
1413
+ * so the expected risk surface is narrow, but it hasn't been exercised
1414
+ * against DO SQLite directly.
1415
+ */
1416
+ function migrateToV25(db: Database): void {
1417
+ if (!hasTable(db, "notes")) return;
1418
+ if (hasColumn(db, "notes_fts", "path")) return;
1419
+
1420
+ console.log(
1421
+ "[vault] migrating to schema v25 (vault#551): rebuilding notes_fts with path+content columns and porter stemming...",
1422
+ );
1423
+
1424
+ // The ENTIRE sequence — DROP triggers + table, CREATE the new virtual
1425
+ // table, CREATE the three sync triggers, AND repopulate — runs inside ONE
1426
+ // transaction so it's strictly all-or-nothing (generalist review, #565).
1427
+ //
1428
+ // Why this matters: if the DDL ran outside the transaction (only the
1429
+ // repopulation transacted, the pre-review shape), a crash between
1430
+ // "CREATE VIRTUAL TABLE" and the end of repopulation would leave a
1431
+ // recreated-but-EMPTY notes_fts — and the idempotency guard
1432
+ // (`hasColumn(db, "notes_fts", "path")`) would see the new `path` column
1433
+ // and report the migration "done" on the next boot, so search stays
1434
+ // PERMANENTLY empty with no retry. Worse, a crash before the CREATE TRIGGERs would leave
1435
+ // future writes unindexed too. Wrapping the whole thing means a rollback
1436
+ // restores the pre-v25 shape (single `content` column, no `path`), so the
1437
+ // guard correctly reports "not migrated" and the next boot re-runs it
1438
+ // cleanly — the guard becomes correct-by-construction rather than relying
1439
+ // on the DDL having fully completed. The reviewer verified CREATE VIRTUAL
1440
+ // TABLE + CREATE TRIGGER execute fine inside bun's `BEGIN IMMEDIATE …
1441
+ // COMMIT`; a DO backend routes the same block through `transactionSync`
1442
+ // (see core/src/txn.ts) with the identical commit-on-return /
1443
+ // rollback-on-throw contract.
1444
+ let repopulated = 0;
1445
+ transaction(db, () => {
1446
+ db.exec("DROP TRIGGER IF EXISTS notes_fts_insert");
1447
+ db.exec("DROP TRIGGER IF EXISTS notes_fts_delete");
1448
+ db.exec("DROP TRIGGER IF EXISTS notes_fts_update");
1449
+ db.exec("DROP TABLE IF EXISTS notes_fts");
1450
+
1451
+ db.exec(`
1452
+ CREATE VIRTUAL TABLE notes_fts USING fts5(
1453
+ path,
1454
+ content,
1455
+ content='notes',
1456
+ content_rowid='rowid',
1457
+ tokenize='porter unicode61'
1458
+ )
1459
+ `);
1460
+ db.exec(`
1461
+ CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
1462
+ INSERT INTO notes_fts(rowid, path, content) VALUES (new.rowid, COALESCE(new.path, ''), new.content);
1463
+ END
1464
+ `);
1465
+ db.exec(`
1466
+ CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
1467
+ INSERT INTO notes_fts(notes_fts, rowid, path, content) VALUES('delete', old.rowid, COALESCE(old.path, ''), old.content);
1468
+ END
1469
+ `);
1470
+ db.exec(`
1471
+ CREATE TRIGGER notes_fts_update AFTER UPDATE OF content, path ON notes BEGIN
1472
+ INSERT INTO notes_fts(notes_fts, rowid, path, content) VALUES('delete', old.rowid, COALESCE(old.path, ''), old.content);
1473
+ INSERT INTO notes_fts(rowid, path, content) VALUES (new.rowid, COALESCE(new.path, ''), new.content);
1474
+ END
1475
+ `);
1476
+
1477
+ const rows = db.prepare("SELECT rowid, path, content FROM notes").all() as {
1478
+ rowid: number;
1479
+ path: string | null;
1480
+ content: string | null;
1481
+ }[];
1482
+ const insert = db.prepare("INSERT INTO notes_fts(rowid, path, content) VALUES (?, ?, ?)");
1483
+ for (const row of rows) {
1484
+ insert.run(row.rowid, row.path ?? "", row.content ?? "");
1485
+ repopulated++;
1486
+ }
1487
+ });
1488
+
1489
+ console.log(
1490
+ `[vault] migrated to schema v25 (vault#551): notes_fts rebuilt with path+content columns + porter stemming; repopulated ${repopulated} note(s).`,
1491
+ );
1492
+ }
1493
+
1335
1494
  function hasTable(db: Database, name: string): boolean {
1336
1495
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1337
1496
  return !!row;