@chainlesschain/personal-data-hub 0.2.3 → 0.3.0

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.
Files changed (56) hide show
  1. package/__tests__/adapters/browser-history-chrome.test.js +377 -0
  2. package/__tests__/adapters/browser-history-edge.test.js +159 -0
  3. package/__tests__/adapters/git-activity.test.js +216 -0
  4. package/__tests__/adapters/local-files.test.js +264 -0
  5. package/__tests__/adapters/shell-history.test.js +180 -0
  6. package/__tests__/adapters/system-data-android.test.js +104 -3
  7. package/__tests__/adapters/vscode.test.js +299 -0
  8. package/__tests__/adapters/win-recent.test.js +192 -0
  9. package/__tests__/analysis.test.js +841 -2
  10. package/__tests__/categories.test.js +92 -0
  11. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
  12. package/__tests__/entity-resolver-vault.test.js +5 -2
  13. package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
  14. package/__tests__/longtail-adapters.test.js +7 -2
  15. package/__tests__/query-parser.test.js +66 -0
  16. package/__tests__/registry.test.js +114 -0
  17. package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
  18. package/__tests__/sidecar-supervisor.test.js +9 -1
  19. package/__tests__/social-kuaishou-snapshot.test.js +55 -2
  20. package/__tests__/social-toutiao-snapshot.test.js +54 -2
  21. package/__tests__/vault-search-helpers.test.js +104 -0
  22. package/__tests__/vault-search.test.js +423 -0
  23. package/__tests__/vault.test.js +77 -3
  24. package/lib/adapters/browser-history-chrome/adapter.js +247 -0
  25. package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
  26. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
  27. package/lib/adapters/browser-history-chrome/index.js +23 -0
  28. package/lib/adapters/browser-history-edge/adapter.js +34 -0
  29. package/lib/adapters/browser-history-edge/index.js +13 -0
  30. package/lib/adapters/git-activity/adapter.js +155 -0
  31. package/lib/adapters/git-activity/git-reader.js +125 -0
  32. package/lib/adapters/git-activity/index.js +17 -0
  33. package/lib/adapters/local-files/adapter.js +149 -0
  34. package/lib/adapters/local-files/file-walker.js +125 -0
  35. package/lib/adapters/local-files/index.js +18 -0
  36. package/lib/adapters/shell-history/adapter.js +137 -0
  37. package/lib/adapters/shell-history/index.js +17 -0
  38. package/lib/adapters/shell-history/shell-reader.js +100 -0
  39. package/lib/adapters/social-kuaishou/index.js +57 -1
  40. package/lib/adapters/social-toutiao/index.js +59 -1
  41. package/lib/adapters/system-data-android/adapter.js +220 -3
  42. package/lib/adapters/vscode/adapter.js +285 -0
  43. package/lib/adapters/vscode/index.js +18 -0
  44. package/lib/adapters/vscode/vscode-reader.js +191 -0
  45. package/lib/adapters/win-recent/adapter.js +150 -0
  46. package/lib/adapters/win-recent/index.js +16 -0
  47. package/lib/adapters/win-recent/win-recent-reader.js +72 -0
  48. package/lib/analysis.js +227 -9
  49. package/lib/categories.js +101 -0
  50. package/lib/index.js +61 -0
  51. package/lib/migrations.js +146 -0
  52. package/lib/query-parser.js +74 -0
  53. package/lib/registry.js +162 -0
  54. package/lib/vault.js +363 -2
  55. package/package.json +2 -1
  56. package/scripts/run-native-tests-sandbox.sh +53 -0
package/lib/vault.js CHANGED
@@ -27,8 +27,79 @@ const fs = require("node:fs");
27
27
  const path = require("node:path");
28
28
 
29
29
  const { validate } = require("./schemas");
30
- const { applyMigrations, getSchemaVersion } = require("./migrations");
30
+ const { applyMigrations, getSchemaVersion, getFtsMode } = require("./migrations");
31
31
  const { isValidKeyHex } = require("./key-providers");
32
+ const { getCategory, PREFIX_RULES } = require("./categories");
33
+
34
+ // FTS5 trigram tokenizer requires queries of >= 3 chars to produce any
35
+ // trigrams at all (single 2-char input gives zero index keys → empty result).
36
+ // Surface this to the caller so the UI can show a hint instead of a confusing
37
+ // "no results" state.
38
+ const FTS5_MIN_QUERY_LEN = 3;
39
+
40
+ /**
41
+ * Translate a user-typed FTS5 query into a safe-to-bind string. FTS5 has its
42
+ * own query syntax — bare operators like `OR`, `AND`, `NOT`, `(`, `:`, `*`,
43
+ * double-quotes have meaning. For the browser keyword box we want literal
44
+ * substring search, so wrap the whole input in double quotes (FTS5 phrase
45
+ * mode) after escaping any embedded double quotes.
46
+ */
47
+ function _quoteFtsQuery(q) {
48
+ return '"' + String(q).replace(/"/g, '""') + '"';
49
+ }
50
+
51
+ /**
52
+ * Build a (sql, params) fragment matching events for the given category.
53
+ * Uses categories.js PREFIX_RULES as the single source of truth so a new
54
+ * adapter prefix only needs to be added in one place.
55
+ *
56
+ * Returns `{ sql: "(...)", params: { catN: ... } }` or
57
+ * `{ sql: "0=1", params: {} }` when no rule matches (unknown category).
58
+ */
59
+ function _categoryToWhere(category, paramPrefix = "cat") {
60
+ if (typeof category !== "string" || category.length === 0) {
61
+ return { sql: null, params: {} };
62
+ }
63
+ const matched = PREFIX_RULES.filter((rule) => rule[1] === category);
64
+ // "other" is a synthetic bucket — nothing in PREFIX_RULES maps to it; an
65
+ // event's category is "other" iff its adapter matches no prefix. Translate
66
+ // that to a NOT-IN-any-prefix condition.
67
+ if (category === "other") {
68
+ const exclude = [];
69
+ const params = {};
70
+ let i = 0;
71
+ for (const [rule] of PREFIX_RULES) {
72
+ const key = `${paramPrefix}${i}`;
73
+ if (rule.endsWith("*")) {
74
+ params[key] = rule.slice(0, -1) + "%";
75
+ exclude.push(`source_adapter NOT LIKE @${key}`);
76
+ } else {
77
+ params[key] = rule;
78
+ exclude.push(`source_adapter != @${key}`);
79
+ }
80
+ i++;
81
+ }
82
+ return { sql: "(" + exclude.join(" AND ") + ")", params };
83
+ }
84
+ if (matched.length === 0) {
85
+ return { sql: "0=1", params: {} };
86
+ }
87
+ const conds = [];
88
+ const params = {};
89
+ let i = 0;
90
+ for (const [rule] of matched) {
91
+ const key = `${paramPrefix}${i}`;
92
+ if (rule.endsWith("*")) {
93
+ params[key] = rule.slice(0, -1) + "%";
94
+ conds.push(`source_adapter LIKE @${key}`);
95
+ } else {
96
+ params[key] = rule;
97
+ conds.push(`source_adapter = @${key}`);
98
+ }
99
+ i++;
100
+ }
101
+ return { sql: "(" + conds.join(" OR ") + ")", params };
102
+ }
32
103
 
33
104
  // Default SQLCipher cipher (matches WCDB / mainstream SQLCipher v4).
34
105
  const DEFAULT_CIPHER = "sqlcipher";
@@ -277,6 +348,22 @@ class LocalVault {
277
348
  source = excluded.source,
278
349
  extra = excluded.extra,
279
350
  ingested_at = excluded.ingested_at,
351
+ confidence = excluded.confidence
352
+ ON CONFLICT(source_adapter, source_original_id)
353
+ WHERE source_original_id IS NOT NULL
354
+ DO UPDATE SET
355
+ subtype = excluded.subtype,
356
+ occurred_at = excluded.occurred_at,
357
+ duration_ms = excluded.duration_ms,
358
+ actor = excluded.actor,
359
+ participants = excluded.participants,
360
+ place = excluded.place,
361
+ items = excluded.items,
362
+ topics = excluded.topics,
363
+ content = excluded.content,
364
+ source = excluded.source,
365
+ extra = excluded.extra,
366
+ ingested_at = excluded.ingested_at,
280
367
  confidence = excluded.confidence`
281
368
  )
282
369
  .run({
@@ -325,6 +412,18 @@ class LocalVault {
325
412
  source = excluded.source,
326
413
  extra = excluded.extra,
327
414
  ingested_at = excluded.ingested_at,
415
+ confidence = excluded.confidence
416
+ ON CONFLICT(source_adapter, source_original_id)
417
+ WHERE source_original_id IS NOT NULL
418
+ DO UPDATE SET
419
+ subtype = excluded.subtype,
420
+ names = excluded.names,
421
+ identifiers = excluded.identifiers,
422
+ relation = excluded.relation,
423
+ notes = excluded.notes,
424
+ source = excluded.source,
425
+ extra = excluded.extra,
426
+ ingested_at = excluded.ingested_at,
328
427
  confidence = excluded.confidence`
329
428
  )
330
429
  .run({
@@ -370,6 +469,19 @@ class LocalVault {
370
469
  source = excluded.source,
371
470
  extra = excluded.extra,
372
471
  ingested_at = excluded.ingested_at,
472
+ confidence = excluded.confidence
473
+ ON CONFLICT(source_adapter, source_original_id)
474
+ WHERE source_original_id IS NOT NULL
475
+ DO UPDATE SET
476
+ name = excluded.name,
477
+ coordinates_lat = excluded.coordinates_lat,
478
+ coordinates_lng = excluded.coordinates_lng,
479
+ address = excluded.address,
480
+ category = excluded.category,
481
+ aliases = excluded.aliases,
482
+ source = excluded.source,
483
+ extra = excluded.extra,
484
+ ingested_at = excluded.ingested_at,
373
485
  confidence = excluded.confidence`
374
486
  )
375
487
  .run({
@@ -420,6 +532,21 @@ class LocalVault {
420
532
  source = excluded.source,
421
533
  extra = excluded.extra,
422
534
  ingested_at = excluded.ingested_at,
535
+ confidence = excluded.confidence
536
+ ON CONFLICT(source_adapter, source_original_id)
537
+ WHERE source_original_id IS NOT NULL
538
+ DO UPDATE SET
539
+ subtype = excluded.subtype,
540
+ name = excluded.name,
541
+ category = excluded.category,
542
+ price_value = excluded.price_value,
543
+ price_currency = excluded.price_currency,
544
+ merchant = excluded.merchant,
545
+ external_url = excluded.external_url,
546
+ thumbnail_local_path = excluded.thumbnail_local_path,
547
+ source = excluded.source,
548
+ extra = excluded.extra,
549
+ ingested_at = excluded.ingested_at,
423
550
  confidence = excluded.confidence`
424
551
  )
425
552
  .run({
@@ -546,6 +673,50 @@ class LocalVault {
546
673
  .run(adapter, originalId, capturedAt, json);
547
674
  }
548
675
 
676
+ /**
677
+ * 2026-05-24 — iterate raw_events sequentially for re-derive flow.
678
+ * Returns rows shaped like the original raw payload object the adapter
679
+ * yielded ({ originalId, capturedAt, payload }) so the caller can feed
680
+ * directly into adapter.normalize().
681
+ *
682
+ * @param {object} [opts]
683
+ * @param {string} [opts.adapter] Filter by adapter name; default = all
684
+ * @param {number} [opts.limit] Max rows; default = unlimited
685
+ * @param {number} [opts.offset=0] Skip first N rows
686
+ * @returns {Array<{adapter: string, originalId: string, capturedAt: number, payload: object}>}
687
+ */
688
+ queryRawEvents({ adapter, limit, offset = 0 } = {}) {
689
+ const db = this._requireOpen();
690
+ let sql =
691
+ "SELECT adapter, original_id, captured_at, payload FROM raw_events";
692
+ const args = [];
693
+ if (adapter) {
694
+ sql += " WHERE adapter = ?";
695
+ args.push(adapter);
696
+ }
697
+ sql += " ORDER BY adapter, captured_at, original_id";
698
+ if (Number.isInteger(limit) && limit > 0) {
699
+ sql += " LIMIT ? OFFSET ?";
700
+ args.push(limit, Number.isInteger(offset) ? offset : 0);
701
+ } else if (Number.isInteger(offset) && offset > 0) {
702
+ sql += " LIMIT -1 OFFSET ?";
703
+ args.push(offset);
704
+ }
705
+ const rows = db.prepare(sql).all(...args);
706
+ return rows.map((r) => ({
707
+ adapter: r.adapter,
708
+ originalId: r.original_id,
709
+ capturedAt: r.captured_at,
710
+ payload: (() => {
711
+ try {
712
+ return JSON.parse(r.payload);
713
+ } catch (_e) {
714
+ return r.payload; // raw string if not JSON
715
+ }
716
+ })(),
717
+ }));
718
+ }
719
+
549
720
  // ─── Entity reads ──────────────────────────────────────────────────────
550
721
 
551
722
  getEvent(id) {
@@ -734,6 +905,190 @@ class LocalVault {
734
905
  .map((row) => this._rowToItem(row));
735
906
  }
736
907
 
908
+ /**
909
+ * Mode (`'fts5'` or `'like'`) recorded by migration 3. Determines whether
910
+ * searchEvents uses the FTS5 virtual table or falls back to LIKE scans.
911
+ * Cached on first read.
912
+ */
913
+ ftsMode() {
914
+ if (!this._ftsMode) this._ftsMode = getFtsMode(this._requireOpen());
915
+ return this._ftsMode;
916
+ }
917
+
918
+ /**
919
+ * Full-text + faceted search over events for the Vault Browser UI.
920
+ *
921
+ * Filters (all optional, ANDed):
922
+ * q — keyword string; FTS5 phrase match if length >= 3 (or LIKE
923
+ * substring match on subtype/content/actor/place/extra in
924
+ * fallback mode). Shorter queries are ignored as if absent.
925
+ * adapter — exact source_adapter
926
+ * category — one of categories.CATEGORIES; expands to adapter prefix list
927
+ * subtype — exact subtype match
928
+ * since — occurred_at >= since (ms epoch)
929
+ * until — occurred_at <= until
930
+ * cursor — { occurredAt, id } from previous page's last row
931
+ * limit — default 50, max 500
932
+ *
933
+ * Pagination is cursor-based on (occurred_at DESC, id DESC) — stable under
934
+ * concurrent inserts (newer events appear only on re-fetch of page 1).
935
+ *
936
+ * Returns `{ rows: Event[], nextCursor: {occurredAt, id} | null, mode: 'fts5'|'like', shortQuery: boolean }`.
937
+ * - shortQuery=true means the q was non-empty but below FTS5_MIN_QUERY_LEN
938
+ * and was dropped — UI should hint "请输入至少 3 个字".
939
+ */
940
+ searchEvents(q = {}) {
941
+ const db = this._requireOpen();
942
+ const mode = this.ftsMode();
943
+ const limit = Number.isInteger(q.limit) && q.limit > 0 ? Math.min(q.limit, 500) : 50;
944
+
945
+ const where = [];
946
+ const params = { limit: limit + 1 }; // +1 to detect "is there a next page?"
947
+
948
+ let shortQuery = false;
949
+ const rawQ = typeof q.q === "string" ? q.q.trim() : "";
950
+
951
+ // Keyword filter — FTS5 path uses MATCH on events_fts; LIKE path does
952
+ // OR across the 5 indexed columns. Sub-min-length q in FTS5 mode is
953
+ // dropped silently (and reported back as shortQuery=true).
954
+ let joinFts = false;
955
+ if (rawQ.length > 0) {
956
+ if (mode === "fts5") {
957
+ if (rawQ.length >= FTS5_MIN_QUERY_LEN) {
958
+ joinFts = true;
959
+ params.q = _quoteFtsQuery(rawQ);
960
+ where.push("events_fts MATCH @q");
961
+ } else {
962
+ shortQuery = true;
963
+ }
964
+ } else {
965
+ params.qLike = "%" + rawQ + "%";
966
+ where.push(
967
+ "(subtype LIKE @qLike OR content LIKE @qLike OR actor LIKE @qLike OR place LIKE @qLike OR extra LIKE @qLike)"
968
+ );
969
+ }
970
+ }
971
+
972
+ if (q.adapter) {
973
+ where.push("source_adapter = @adapter");
974
+ params.adapter = q.adapter;
975
+ }
976
+ if (q.category) {
977
+ const { sql, params: catParams } = _categoryToWhere(q.category);
978
+ if (sql) {
979
+ where.push(sql);
980
+ Object.assign(params, catParams);
981
+ }
982
+ }
983
+ if (q.subtype) {
984
+ where.push("e.subtype = @subtype");
985
+ params.subtype = q.subtype;
986
+ }
987
+ if (Number.isFinite(q.since)) {
988
+ where.push("occurred_at >= @since");
989
+ params.since = q.since;
990
+ }
991
+ if (Number.isFinite(q.until)) {
992
+ where.push("occurred_at <= @until");
993
+ params.until = q.until;
994
+ }
995
+ // Cursor: rows strictly older than the cursor's (occurred_at, id) tuple.
996
+ // SQLite tuple comparison handles this natively.
997
+ if (q.cursor && Number.isFinite(q.cursor.occurredAt) && typeof q.cursor.id === "string") {
998
+ where.push("(occurred_at, e.id) < (@cursorAt, @cursorId)");
999
+ params.cursorAt = q.cursor.occurredAt;
1000
+ params.cursorId = q.cursor.id;
1001
+ }
1002
+
1003
+ const sql =
1004
+ "SELECT e.* FROM events e" +
1005
+ (joinFts ? " JOIN events_fts f ON e.rowid = f.rowid" : "") +
1006
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
1007
+ " ORDER BY occurred_at DESC, e.id DESC LIMIT @limit";
1008
+
1009
+ const rowsPlusOne = db.prepare(sql).all(params);
1010
+ const hasMore = rowsPlusOne.length > limit;
1011
+ const rows = hasMore ? rowsPlusOne.slice(0, limit) : rowsPlusOne;
1012
+ const events = rows.map((r) => this._rowToEvent(r));
1013
+ const last = rows[rows.length - 1];
1014
+ return {
1015
+ rows: events,
1016
+ nextCursor: hasMore && last ? { occurredAt: last.occurred_at, id: last.id } : null,
1017
+ mode,
1018
+ shortQuery,
1019
+ };
1020
+ }
1021
+
1022
+ /**
1023
+ * Counts events grouped by category / adapter / subtype, honoring the
1024
+ * same q/since/until filters as searchEvents. Powers the sidebar badges
1025
+ * + adapter chips ("社交聊天 (123)" / "social-bilibili (45)").
1026
+ *
1027
+ * Returns `{ byCategory, byAdapter, bySubtype, total, mode, shortQuery }`.
1028
+ * Each map is `{ key: count }`. Empty keys are omitted.
1029
+ */
1030
+ facetCounts(q = {}) {
1031
+ const db = this._requireOpen();
1032
+ const mode = this.ftsMode();
1033
+ const params = {};
1034
+ const where = [];
1035
+
1036
+ let shortQuery = false;
1037
+ const rawQ = typeof q.q === "string" ? q.q.trim() : "";
1038
+ let joinFts = false;
1039
+ if (rawQ.length > 0) {
1040
+ if (mode === "fts5") {
1041
+ if (rawQ.length >= FTS5_MIN_QUERY_LEN) {
1042
+ joinFts = true;
1043
+ params.q = _quoteFtsQuery(rawQ);
1044
+ where.push("events_fts MATCH @q");
1045
+ } else {
1046
+ shortQuery = true;
1047
+ }
1048
+ } else {
1049
+ params.qLike = "%" + rawQ + "%";
1050
+ where.push(
1051
+ "(subtype LIKE @qLike OR content LIKE @qLike OR actor LIKE @qLike OR place LIKE @qLike OR extra LIKE @qLike)"
1052
+ );
1053
+ }
1054
+ }
1055
+ if (Number.isFinite(q.since)) {
1056
+ where.push("occurred_at >= @since");
1057
+ params.since = q.since;
1058
+ }
1059
+ if (Number.isFinite(q.until)) {
1060
+ where.push("occurred_at <= @until");
1061
+ params.until = q.until;
1062
+ }
1063
+
1064
+ const baseFrom =
1065
+ "FROM events e" + (joinFts ? " JOIN events_fts f ON e.rowid = f.rowid" : "");
1066
+ const whereSql = where.length ? " WHERE " + where.join(" AND ") : "";
1067
+
1068
+ const adapterRows = db
1069
+ .prepare(
1070
+ `SELECT source_adapter AS k, COUNT(*) AS n ${baseFrom}${whereSql} GROUP BY source_adapter`
1071
+ )
1072
+ .all(params);
1073
+ const subtypeRows = db
1074
+ .prepare(`SELECT e.subtype AS k, COUNT(*) AS n ${baseFrom}${whereSql} GROUP BY e.subtype`)
1075
+ .all(params);
1076
+
1077
+ const byAdapter = {};
1078
+ const byCategory = {};
1079
+ let total = 0;
1080
+ for (const r of adapterRows) {
1081
+ byAdapter[r.k] = r.n;
1082
+ const cat = getCategory(r.k);
1083
+ byCategory[cat] = (byCategory[cat] || 0) + r.n;
1084
+ total += r.n;
1085
+ }
1086
+ const bySubtype = {};
1087
+ for (const r of subtypeRows) bySubtype[r.k] = r.n;
1088
+
1089
+ return { byCategory, byAdapter, bySubtype, total, mode, shortQuery };
1090
+ }
1091
+
737
1092
  countEvents(q = {}) {
738
1093
  const where = [];
739
1094
  const params = {};
@@ -1275,4 +1630,10 @@ class LocalVault {
1275
1630
  }
1276
1631
  }
1277
1632
 
1278
- module.exports = { LocalVault, _internal: { loadDriver, formatDriverLoadError } };
1633
+ module.exports = {
1634
+ LocalVault,
1635
+ _internal: { loadDriver, formatDriverLoadError },
1636
+ // Pure-JS helpers exported for unit testing without the native bs3mc
1637
+ // binding (search SQL builders, category WHERE translator, FTS5 escape).
1638
+ _searchHelpers: { _categoryToWhere, _quoteFtsQuery, FTS5_MIN_QUERY_LEN },
1639
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
5
5
  "type": "commonjs",
6
6
  "main": "lib/index.js",
@@ -14,6 +14,7 @@
14
14
  "./migrations": "./lib/migrations.js",
15
15
  "./key-providers": "./lib/key-providers.js",
16
16
  "./adapter-spec": "./lib/adapter-spec.js",
17
+ "./categories": "./lib/categories.js",
17
18
  "./kg-derive": "./lib/kg-derive.js",
18
19
  "./rag-derive": "./lib/rag-derive.js",
19
20
  "./registry": "./lib/registry.js",
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # Run vault-search.test.js (FTS5 native integration) in an isolated sandbox.
3
+ #
4
+ # Background: the root node_modules bs3mc binding is built for Electron 39
5
+ # (NODE_MODULE_VERSION 140) which doesn't match ANY Node.js version
6
+ # (Node 24 = ABI 137, Node 25 = ABI 141). Plain `npm test` fails locally with
7
+ # ABI mismatch. CI is unaffected because its node_modules is built fresh.
8
+ #
9
+ # This script:
10
+ # 1. Copies lib/ + the target test into a temp sandbox
11
+ # 2. Installs a separate bs3mc compiled for the CURRENT host Node ABI
12
+ # 3. Runs vitest against it
13
+ #
14
+ # Idempotent; rerun any time. Sandbox lives under $TMPDIR/pdh-fts5-sandbox
15
+ # so it survives between runs (faster re-install).
16
+ #
17
+ # Usage: bash packages/personal-data-hub/scripts/run-native-tests-sandbox.sh
18
+
19
+ set -euo pipefail
20
+
21
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
22
+ SANDBOX="${TMPDIR:-/tmp}/pdh-fts5-sandbox"
23
+
24
+ echo "==> Sandbox: $SANDBOX"
25
+ mkdir -p "$SANDBOX/lib" "$SANDBOX/__tests__"
26
+
27
+ # Sync sources every run (lib/ may have evolved since last sandbox build)
28
+ cp -r "$ROOT/lib/." "$SANDBOX/lib/"
29
+ cp "$ROOT/__tests__/vault-search.test.js" "$SANDBOX/__tests__/"
30
+
31
+ # Minimal package.json — only the deps the target test needs.
32
+ cat > "$SANDBOX/package.json" <<'EOF'
33
+ {
34
+ "name": "pdh-fts5-sandbox",
35
+ "version": "0.0.0",
36
+ "private": true,
37
+ "type": "commonjs",
38
+ "scripts": { "test": "vitest run" },
39
+ "dependencies": { "better-sqlite3-multiple-ciphers": "^12.5.0" },
40
+ "devDependencies": { "vitest": "^4.1.5" }
41
+ }
42
+ EOF
43
+
44
+ if [ ! -d "$SANDBOX/node_modules/better-sqlite3-multiple-ciphers/build" ]; then
45
+ echo "==> Installing deps (one-time, ~30-60s)"
46
+ (cd "$SANDBOX" && npm install --no-audit --no-fund --loglevel=warn)
47
+ else
48
+ echo "==> Deps already installed (skipping npm install)"
49
+ fi
50
+
51
+ echo "==> Running tests"
52
+ cd "$SANDBOX"
53
+ exec node ./node_modules/vitest/vitest.mjs run