@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.7
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/attribution.test.ts +11 -3
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +38 -8
- package/core/src/doctor.ts +43 -13
- package/core/src/mcp.ts +66 -26
- package/core/src/notes.ts +85 -20
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +314 -10
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +18 -0
- package/core/src/tag-schemas.ts +121 -6
- package/core/src/types.ts +20 -0
- package/package.json +1 -1
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +42 -53
- package/src/vault.test.ts +33 -27
package/core/src/schema.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
|
-
import { rebuildIndexes } from "./indexed-fields.js";
|
|
3
|
+
import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
|
|
4
|
+
import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
|
|
4
5
|
import { transaction } from "./txn.js";
|
|
5
6
|
|
|
6
|
-
export const SCHEMA_VERSION =
|
|
7
|
+
export const SCHEMA_VERSION = 25;
|
|
7
8
|
|
|
8
9
|
export const SCHEMA_SQL = `
|
|
9
10
|
-- Notes: the universal record.
|
|
@@ -255,25 +256,43 @@ CREATE TABLE IF NOT EXISTS schema_version (
|
|
|
255
256
|
applied_at TEXT NOT NULL
|
|
256
257
|
);
|
|
257
258
|
|
|
258
|
-
-- 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.
|
|
259
273
|
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
274
|
+
path,
|
|
260
275
|
content,
|
|
261
276
|
content='notes',
|
|
262
|
-
content_rowid='rowid'
|
|
277
|
+
content_rowid='rowid',
|
|
278
|
+
tokenize='porter unicode61'
|
|
263
279
|
);
|
|
264
280
|
|
|
265
|
-
-- 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.
|
|
266
285
|
CREATE TRIGGER IF NOT EXISTS notes_fts_insert AFTER INSERT ON notes BEGIN
|
|
267
|
-
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);
|
|
268
287
|
END;
|
|
269
288
|
|
|
270
289
|
CREATE TRIGGER IF NOT EXISTS notes_fts_delete AFTER DELETE ON notes BEGIN
|
|
271
|
-
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);
|
|
272
291
|
END;
|
|
273
292
|
|
|
274
|
-
CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE OF content ON notes BEGIN
|
|
275
|
-
INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
276
|
-
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);
|
|
277
296
|
END;
|
|
278
297
|
|
|
279
298
|
-- Indexes
|
|
@@ -502,6 +521,14 @@ export function initSchema(db: Database): void {
|
|
|
502
521
|
// vault#298.
|
|
503
522
|
migrateToV23(db);
|
|
504
523
|
|
|
524
|
+
// Migrate v23 → v24: coerce existing typed-index poison where lossless,
|
|
525
|
+
// leave the rest in place for `doctor` to surface. See vault#553.
|
|
526
|
+
migrateToV24(db);
|
|
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
|
+
|
|
505
532
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
506
533
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
507
534
|
rebuildIndexes(db);
|
|
@@ -1187,6 +1214,283 @@ function migrateToV23(db: Database): void {
|
|
|
1187
1214
|
}
|
|
1188
1215
|
}
|
|
1189
1216
|
|
|
1217
|
+
/** Sentinel returned by {@link coerceIndexedValue} when no lossless conversion exists. */
|
|
1218
|
+
const NOT_COERCIBLE = Symbol("not-coercible");
|
|
1219
|
+
|
|
1220
|
+
/** Full JSON-number grammar — used to gate string→number coercion to values that round-trip exactly. */
|
|
1221
|
+
const JSON_NUMBER_RE = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Decide the lossless coercion (if any) for a single poisoned indexed-field
|
|
1225
|
+
* value, given `jt` — the SQLite `json_type()` of its CURRENT value (the
|
|
1226
|
+
* SAME vocabulary `doctor`'s `findMixedTypeIndexedFieldNotes` compares
|
|
1227
|
+
* against: "text", "integer", "real", "true", "false", "array", "object")
|
|
1228
|
+
* — and `targetSqliteType`, the field's declared storage class ("INTEGER"
|
|
1229
|
+
* or "TEXT"). Returns the coerced value, or {@link NOT_COERCIBLE} when no
|
|
1230
|
+
* exact round-trip conversion exists — the caller leaves the value
|
|
1231
|
+
* untouched (never deletes or nulls note data).
|
|
1232
|
+
*
|
|
1233
|
+
* Coercible cases (vault#553 Decision D):
|
|
1234
|
+
* - INTEGER target, TEXT source: a clean numeric string ("5", "5.5",
|
|
1235
|
+
* "-3") → the JSON number, ONLY when `String(Number(str)) === str`
|
|
1236
|
+
* (exact round-trip — rejects "5.50", scientific-notation reformats,
|
|
1237
|
+
* and anything outside safe double precision that wouldn't survive the
|
|
1238
|
+
* trip). "true"/"false" (exact string match) → the JSON boolean.
|
|
1239
|
+
* - TEXT target, INTEGER/REAL/boolean source: a number or boolean → its
|
|
1240
|
+
* JSON string form via `String(value)` — always exact for finite JS
|
|
1241
|
+
* numbers and booleans within IEEE-754 double precision.
|
|
1242
|
+
* Never coercible: array/object/null values in either direction, or a TEXT
|
|
1243
|
+
* value that isn't a clean number/boolean string (e.g. "four", "5,000").
|
|
1244
|
+
*/
|
|
1245
|
+
function coerceIndexedValue(
|
|
1246
|
+
value: unknown,
|
|
1247
|
+
jt: string,
|
|
1248
|
+
targetSqliteType: string,
|
|
1249
|
+
): unknown | typeof NOT_COERCIBLE {
|
|
1250
|
+
if (targetSqliteType === "INTEGER") {
|
|
1251
|
+
if (jt !== "text" || typeof value !== "string") return NOT_COERCIBLE;
|
|
1252
|
+
if (value === "true") return true;
|
|
1253
|
+
if (value === "false") return false;
|
|
1254
|
+
if (JSON_NUMBER_RE.test(value)) {
|
|
1255
|
+
const n = Number(value);
|
|
1256
|
+
if (Number.isFinite(n) && String(n) === value) return n;
|
|
1257
|
+
}
|
|
1258
|
+
return NOT_COERCIBLE;
|
|
1259
|
+
}
|
|
1260
|
+
if (targetSqliteType === "TEXT") {
|
|
1261
|
+
if ((jt === "true" || jt === "false") && typeof value === "boolean") {
|
|
1262
|
+
return jt; // "true" / "false" — the json_type string IS the target text
|
|
1263
|
+
}
|
|
1264
|
+
if ((jt === "integer" || jt === "real") && typeof value === "number") {
|
|
1265
|
+
const s = String(value);
|
|
1266
|
+
if (Number(s) === value) return s;
|
|
1267
|
+
}
|
|
1268
|
+
return NOT_COERCIBLE;
|
|
1269
|
+
}
|
|
1270
|
+
return NOT_COERCIBLE;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Migrate v23 → v24: typed-index poison coercion (vault#553 Decision D).
|
|
1275
|
+
*
|
|
1276
|
+
* For every declared indexed field (`indexed_fields` — the SSOT from
|
|
1277
|
+
* `core/src/indexed-fields.ts`) and every note whose `metadata.<field>` JSON
|
|
1278
|
+
* type disagrees with the field's declared sqlite storage class — reusing
|
|
1279
|
+
* `doctor`'s `findMixedTypeIndexedFieldNotes` (`core/src/doctor.ts`) as the
|
|
1280
|
+
* DETECTOR, the exact same query that powers the `mixed_type_indexed_field`
|
|
1281
|
+
* finding, so migration and diagnostic can never disagree on what counts as
|
|
1282
|
+
* poisoned:
|
|
1283
|
+
*
|
|
1284
|
+
* - COERCE where {@link coerceIndexedValue} finds a lossless conversion —
|
|
1285
|
+
* rewrites `notes.metadata` in place (the generated `meta_<field>`
|
|
1286
|
+
* column re-derives via `json_extract` automatically; nothing else to
|
|
1287
|
+
* touch).
|
|
1288
|
+
* - LEAVE IN PLACE everything else. NEVER DELETE OR NULL NOTE DATA in a
|
|
1289
|
+
* startup migration — a non-coercible value (e.g. `"hello"` in an
|
|
1290
|
+
* integer field) stays exactly as written and remains visible via
|
|
1291
|
+
* `doctor`'s `mixed_type_indexed_field` finding for the operator to
|
|
1292
|
+
* clean up deliberately.
|
|
1293
|
+
*
|
|
1294
|
+
* Rewrites happen at the JS level — `SELECT metadata` → `JSON.parse` →
|
|
1295
|
+
* mutate the one field → `JSON.stringify` → single-row `UPDATE ... WHERE id
|
|
1296
|
+
* = ?` (two bound params) — rather than SQL `json_set`, so the migration
|
|
1297
|
+
* stays portable to a narrower json1 surface (Cloudflare DO SQLite; flagged
|
|
1298
|
+
* for the wire reviewer to confirm). Detection still uses
|
|
1299
|
+
* `json_extract`/`json_type`/`json_valid`, the same functions the indexed
|
|
1300
|
+
* generated columns and `doctor` already depend on everywhere in this
|
|
1301
|
+
* codebase — not a new cross-runtime risk. One row at a time also means this
|
|
1302
|
+
* never approaches SQLite's bound-parameter ceiling regardless of how many
|
|
1303
|
+
* notes are poisoned (no batched `WHERE id IN (...)`).
|
|
1304
|
+
*
|
|
1305
|
+
* Idempotent: every step is naturally re-runnable (a coerced value now
|
|
1306
|
+
* agrees with the declared type and is never re-detected as a mismatch; a
|
|
1307
|
+
* left-in-place value re-detects identically and is re-skipped identically).
|
|
1308
|
+
* Safe on a vault with zero indexed fields (the field-list guard short-
|
|
1309
|
+
* circuits before any table scan) and safe to run on every boot, matching
|
|
1310
|
+
* the unconditional-migration-function idiom the rest of this file uses.
|
|
1311
|
+
*/
|
|
1312
|
+
function migrateToV24(db: Database): void {
|
|
1313
|
+
if (!hasTable(db, "notes") || !hasTable(db, "indexed_fields")) return;
|
|
1314
|
+
const fields = listIndexedFields(db);
|
|
1315
|
+
if (fields.length === 0) return;
|
|
1316
|
+
|
|
1317
|
+
let coerced = 0;
|
|
1318
|
+
let left = 0;
|
|
1319
|
+
|
|
1320
|
+
transaction(db, () => {
|
|
1321
|
+
const readStmt = db.prepare("SELECT metadata FROM notes WHERE id = ?");
|
|
1322
|
+
const updateStmt = db.prepare("UPDATE notes SET metadata = ? WHERE id = ?");
|
|
1323
|
+
|
|
1324
|
+
for (const f of fields) {
|
|
1325
|
+
const mismatches = findMixedTypeIndexedFieldNotes(db, f.field, f.sqliteType);
|
|
1326
|
+
for (const { id, jt } of mismatches) {
|
|
1327
|
+
const row = readStmt.get(id) as { metadata: string | null } | null;
|
|
1328
|
+
if (!row?.metadata) continue;
|
|
1329
|
+
let meta: unknown;
|
|
1330
|
+
try {
|
|
1331
|
+
meta = JSON.parse(row.metadata);
|
|
1332
|
+
} catch {
|
|
1333
|
+
continue; // shouldn't happen — findMixedTypeIndexedFieldNotes already filters to json_valid rows
|
|
1334
|
+
}
|
|
1335
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
|
|
1336
|
+
const obj = meta as Record<string, unknown>;
|
|
1337
|
+
if (!(f.field in obj)) continue;
|
|
1338
|
+
const coercedValue = coerceIndexedValue(obj[f.field], jt, f.sqliteType);
|
|
1339
|
+
if (coercedValue === NOT_COERCIBLE) {
|
|
1340
|
+
left++;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
obj[f.field] = coercedValue;
|
|
1344
|
+
updateStmt.run(JSON.stringify(obj), id);
|
|
1345
|
+
coerced++;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
if (coerced > 0 || left > 0) {
|
|
1351
|
+
console.log(
|
|
1352
|
+
`[vault] migrated to schema v24 (vault#553): typed-index poison scan — coerced ${coerced} value(s) to their declared indexed type; left ${left} non-coercible value(s) in place (see \`doctor\`'s mixed_type_indexed_field finding for cleanup).`,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
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
|
+
|
|
1190
1494
|
function hasTable(db: Database, name: string): boolean {
|
|
1191
1495
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1192
1496
|
return !!row;
|