@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
|
@@ -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
|
+
}
|
package/core/src/schema.ts
CHANGED
|
@@ -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 =
|
|
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;
|