@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.
@@ -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 = 23;
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;