@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -794,6 +794,37 @@ describe("importPortableVault", async () => {
794
794
  expect(typed!.metadata).toEqual({ source: "git://x" });
795
795
  });
796
796
 
797
+ it("preserves an opaque relationship-vocabulary map across export → import → re-export (vault#428)", async () => {
798
+ const vocab = {
799
+ "works-on": { from: "person", to: "project" },
800
+ "member-of": { from: "person", to: "organization" },
801
+ "partner-of": { from: "person", to: "person" },
802
+ "based-at": { from: "project", to: "place", note: "freeform" },
803
+ };
804
+ await store.upsertTagRecord("person", {
805
+ description: "a human",
806
+ relationships: vocab,
807
+ });
808
+ const outDir = join(tmpBase, "out");
809
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
810
+
811
+ const target = new SqliteStore(new Database(":memory:"));
812
+ const stats = await importPortableVault(target, { inDir: outDir });
813
+ expect(stats.schemas_restored).toBe(1);
814
+
815
+ // Imported value matches the original verbatim.
816
+ const restored = await target.getTagRecord("person");
817
+ expect(restored?.relationships).toEqual(vocab);
818
+
819
+ // Re-export from the restored store and confirm the schema file is
820
+ // byte-identical to the first export (deep round-trip stability).
821
+ const outDir2 = join(tmpBase, "out2");
822
+ await exportVaultToDir(target, { outDir: outDir2, exportedAt: "2026-05-13T00:00:00.000Z" });
823
+ const schemaA = readFileSync(join(outDir, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
824
+ const schemaB = readFileSync(join(outDir2, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
825
+ expect(schemaB).toBe(schemaA);
826
+ });
827
+
797
828
  it("skips typed links whose target is missing from the import set", async () => {
798
829
  // Source note has a typed link to a target we don't include in
799
830
  // the export (synthetic — write the .md file by hand).
@@ -862,6 +893,15 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
862
893
  description: "A long-running effort",
863
894
  fields: { status: { type: "string", enum: ["active", "done"] } },
864
895
  });
896
+ // Opaque relationship-vocabulary map (vault#428) — exercises that the
897
+ // round-trip preserves an arbitrary app-defined relationships shape,
898
+ // not just the historical { target_tag, cardinality } one.
899
+ await store.upsertTagRecord("project", {
900
+ relationships: {
901
+ "works-on": { from: "person", to: "project" },
902
+ "based-at": { from: "project", to: "place", note: "freeform" },
903
+ },
904
+ });
865
905
  const n1 = await store.createNote("alpha body", {
866
906
  id: "01HX001",
867
907
  path: "Inbox/alpha",
@@ -2091,11 +2091,29 @@ export function parseFrontmatter(raw: string): {
2091
2091
  frontmatter: Record<string, unknown>;
2092
2092
  content: string;
2093
2093
  } {
2094
- if (!raw.startsWith("---")) return { frontmatter: {}, content: raw };
2095
- const endIdx = raw.indexOf("\n---", 3);
2096
- if (endIdx === -1) return { frontmatter: {}, content: raw };
2097
- const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
2098
- const content = raw.slice(endIdx + 4).replace(/^\n/, "");
2094
+ // 1. Strip a leading UTF-8 BOM (U+FEFF) if present. Without this an
2095
+ // Obsidian export saved with a BOM (`---\n…`) fails the open
2096
+ // test and the whole file frontmatter included falls into the
2097
+ // body, silently losing id/tags/timestamps (contract FX-FENCE-BOM).
2098
+ const src = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
2099
+
2100
+ // 2. Line-scan close-fence (CRLF-aware). The opening line must be
2101
+ // EXACTLY `---`; the closing fence is the FIRST subsequent line that
2102
+ // is EXACTLY `---` — not `----`, not `---text`, not ` ---`. An
2103
+ // unclosed block means the whole file is body (never swallow). This
2104
+ // replaces the old `indexOf("\n---")` which wrongly matched `\n----`
2105
+ // and `\n---more` (contract §1.1, FX-FENCE-FOURDASH-OPEN).
2106
+ const lines = src.split(/\r?\n/);
2107
+ if (lines[0] !== "---") return { frontmatter: {}, content: src };
2108
+
2109
+ let closeIdx = -1;
2110
+ for (let i = 1; i < lines.length; i++) {
2111
+ if (lines[i] === "---") { closeIdx = i; break; }
2112
+ }
2113
+ if (closeIdx === -1) return { frontmatter: {}, content: src };
2114
+
2115
+ const yamlBlock = lines.slice(1, closeIdx).join("\n");
2116
+ const content = lines.slice(closeIdx + 1).join("\n");
2099
2117
  return { frontmatter: parseBlock(yamlBlock, 0).value, content };
2100
2118
  }
2101
2119
 
@@ -2119,11 +2137,16 @@ function parseBlock(text: string, baseIndent: number): ParseResult {
2119
2137
  while (i < lines.length) {
2120
2138
  const line = lines[i]!;
2121
2139
  if (line.trim() === "") { i++; continue; }
2140
+ // Skip `#`-comment lines (contract C3 / §1.2). A comment at the
2141
+ // block's base level is not a key line; the parser must step over it.
2142
+ if (line.trimStart().startsWith("#")) { i++; continue; }
2122
2143
  const indent = countLeadingSpaces(line);
2123
2144
  if (indent < baseIndent) break;
2124
2145
  if (indent > baseIndent) { i++; continue; } // shouldn't happen at this level
2125
2146
 
2126
- const kv = line.slice(baseIndent).match(/^([\w][\w-]*):\s*(.*)$/);
2147
+ // Key regex (contract C2 / §1.2): dots allowed in keys, optional
2148
+ // whitespace before the colon (`created.at:` / `key :` both parse).
2149
+ const kv = line.slice(baseIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
2127
2150
  if (!kv) { i++; continue; }
2128
2151
  const key = kv[1]!;
2129
2152
  const valueText = kv[2]!.trim();
@@ -2199,7 +2222,8 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
2199
2222
  // First content after `- `.
2200
2223
  const after = line.slice(indent + 2).trim();
2201
2224
  // Is this a scalar item (`- foo`) or an object item (`- key: value`)?
2202
- const objMatch = after.match(/^([\w][\w-]*):\s*(.*)$/);
2225
+ // Key regex matches parseBlock's (contract C2): dots + optional space.
2226
+ const objMatch = after.match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
2203
2227
  if (!objMatch) {
2204
2228
  result.push(parseScalarOrInline(after));
2205
2229
  i++;
@@ -2251,7 +2275,7 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
2251
2275
  const sibIndent = countLeadingSpaces(sib);
2252
2276
  if (sibIndent !== itemIndent) break;
2253
2277
  if (sib.slice(sibIndent).startsWith("- ")) break;
2254
- const sibKv = sib.slice(sibIndent).match(/^([\w][\w-]*):\s*(.*)$/);
2278
+ const sibKv = sib.slice(sibIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
2255
2279
  if (!sibKv) break;
2256
2280
  const sibKey = sibKv[1]!;
2257
2281
  const sibValue = sibKv[2]!.trim();
@@ -2287,6 +2311,36 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
2287
2311
  return { value: result, consumed: i - start };
2288
2312
  }
2289
2313
 
2314
+ /**
2315
+ * Quote-aware split of an inline-array body on top-level commas
2316
+ * (contract C4 / §1.2). A comma inside a single- or double-quoted string
2317
+ * does not separate items, so `"a, b", c` → `['"a, b"', 'c']`. Quote
2318
+ * chars are preserved in the parts; `parseScalarOrInline` strips them via
2319
+ * `unquote`. Mirrors the web parser's `splitInlineArray`.
2320
+ */
2321
+ function splitInlineArray(inner: string): string[] {
2322
+ const parts: string[] = [];
2323
+ let buf = "";
2324
+ let quote: '"' | "'" | null = null;
2325
+ for (let i = 0; i < inner.length; i++) {
2326
+ const ch = inner[i]!;
2327
+ if (quote) {
2328
+ buf += ch;
2329
+ if (ch === quote) quote = null;
2330
+ } else if (ch === '"' || ch === "'") {
2331
+ quote = ch;
2332
+ buf += ch;
2333
+ } else if (ch === ",") {
2334
+ parts.push(buf);
2335
+ buf = "";
2336
+ } else {
2337
+ buf += ch;
2338
+ }
2339
+ }
2340
+ parts.push(buf);
2341
+ return parts;
2342
+ }
2343
+
2290
2344
  /**
2291
2345
  * Parse a scalar or inline form (`[a, b]`, `{ k: v }`). Used for the
2292
2346
  * value portion of `key: value` lines.
@@ -2295,7 +2349,10 @@ function parseScalarOrInline(s: string): unknown {
2295
2349
  if (s.startsWith("[") && s.endsWith("]")) {
2296
2350
  const inner = s.slice(1, -1).trim();
2297
2351
  if (inner === "") return [];
2298
- return inner.split(",").map((part) => parseScalarOrInline(part.trim()));
2352
+ // Quote-aware split (contract C4 / §1.2): a comma inside a quoted
2353
+ // string is NOT an item separator, so `["a, b", c]` → 2 items, not 3.
2354
+ // Matches the web parser's `splitInlineArray`.
2355
+ return splitInlineArray(inner).map((part) => parseScalarOrInline(part.trim()));
2299
2356
  }
2300
2357
  if (s.startsWith("{") && s.endsWith("}")) {
2301
2358
  const inner = s.slice(1, -1).trim();
@@ -2376,18 +2433,59 @@ function unquote(s: string): unknown {
2376
2433
  // Directory walking — shared with obsidian.ts
2377
2434
  // ---------------------------------------------------------------------------
2378
2435
 
2379
- /** Recursively list all .md files in a directory, excluding hidden dirs
2380
- * (including `.parachute/` and `.obsidian/`). */
2436
+ /**
2437
+ * Markdown-file classification (contract §1.7). Case-insensitive `.md`
2438
+ * OR `.markdown`. `.mdx`, `.txt`, etc. are NOT markdown for the importer.
2439
+ * Identical to the web parser's classifier.
2440
+ */
2441
+ export function isMarkdownExtension(path: string): boolean {
2442
+ return /\.(md|markdown)$/i.test(path);
2443
+ }
2444
+
2445
+ /**
2446
+ * Named intake-excluded directory/entry segments (contract §1.9). The
2447
+ * generic `startsWith(".")` rule below subsumes the dot-prefixed ones;
2448
+ * the named set is kept explicit for readability + to cover
2449
+ * `__MACOSX`/`node_modules` (which do not start with ".").
2450
+ */
2451
+ const EXCLUDED_SEGMENTS = new Set([
2452
+ ".obsidian",
2453
+ ".trash",
2454
+ ".git",
2455
+ ".parachute",
2456
+ "__MACOSX",
2457
+ "node_modules",
2458
+ ]);
2459
+
2460
+ /**
2461
+ * Intake (file-selection) exclusion (contract §1.9). Excludes a source
2462
+ * path if ANY `/`-segment is a named-excluded entry OR starts with "."
2463
+ * (generic dotfile/dotdir). Applied identically by both parsers before
2464
+ * parsing. The generic dot rule means legit dot-prefixed user files
2465
+ * (`.daily-note.md`) ARE excluded — the chosen, consistent behavior.
2466
+ */
2467
+ export function isExcludedPath(sourcePath: string): boolean {
2468
+ for (const segment of sourcePath.split("/")) {
2469
+ if (segment === "") continue;
2470
+ if (EXCLUDED_SEGMENTS.has(segment)) return true;
2471
+ if (segment.startsWith(".")) return true;
2472
+ }
2473
+ return false;
2474
+ }
2475
+
2476
+ /** Recursively list all .md / .markdown files in a directory, excluding
2477
+ * hidden + named-internal dirs per the canonical `isExcludedPath` rule
2478
+ * (`.parachute/`, `.obsidian/`, `node_modules`, …). Legacy import path. */
2381
2479
  export function walkMarkdownFiles(dir: string): string[] {
2382
2480
  const results: string[] = [];
2383
2481
  function walk(current: string) {
2384
2482
  for (const entry of readdirSync(current)) {
2385
- if (entry.startsWith(".")) continue;
2386
- if (entry === "node_modules") continue;
2483
+ // Per-segment exclusion: the named set + generic dotfile rule.
2484
+ if (EXCLUDED_SEGMENTS.has(entry) || entry.startsWith(".")) continue;
2387
2485
  const full = join(current, entry);
2388
2486
  const stat = statSync(full);
2389
2487
  if (stat.isDirectory()) walk(full);
2390
- else if (stat.isFile() && extname(entry).toLowerCase() === ".md") results.push(full);
2488
+ else if (stat.isFile() && isMarkdownExtension(entry)) results.push(full);
2391
2489
  }
2392
2490
  }
2393
2491
  walk(dir);
@@ -2417,15 +2515,43 @@ export function walkContentFiles(dir: string): string[] {
2417
2515
  return results.sort();
2418
2516
  }
2419
2517
 
2518
+ /**
2519
+ * Canonical inline-tag regex (contract §1.3). `#` at line-start or after
2520
+ * whitespace; body chars `[A-Za-z0-9_/-]` (slash for hierarchy); the tag
2521
+ * MUST contain ≥1 non-numeric char (the middle `[A-Za-z_/-]`), so `#2024`
2522
+ * is NOT a tag but `#v2`, `#2024-plan`, `#area/sub` are. Identical to the
2523
+ * web parser's `INLINE_HASHTAG`.
2524
+ */
2525
+ const INLINE_TAG_REGEX = /(?:^|\s)#([A-Za-z0-9_/-]*[A-Za-z_/-][A-Za-z0-9_/-]*)/g;
2526
+
2527
+ /**
2528
+ * Normalize + slug-validate a tag value (contract §1.4). Shared by inline
2529
+ * tag extraction and frontmatter tag extraction (obsidian.ts re-exports
2530
+ * this) so both surfaces validate identically. Returns null when the
2531
+ * value is unusable (non-string non-scalar, empty, or fails slug rules).
2532
+ */
2533
+ export function normalizeTagValue(v: unknown): string | null {
2534
+ if (typeof v === "number" || typeof v === "boolean") {
2535
+ return String(v).toLowerCase();
2536
+ }
2537
+ if (typeof v !== "string") return null;
2538
+ const stripped = v.trim().replace(/^#/, "").toLowerCase();
2539
+ if (stripped === "") return null;
2540
+ // Slug-validate: lowercase alnum, underscore, hyphen, slash (hierarchy).
2541
+ if (!/^[a-z0-9_/-]+$/.test(stripped)) return null;
2542
+ return stripped;
2543
+ }
2544
+
2420
2545
  /** Extract inline #tags from markdown content. Excludes tags in code blocks. */
2421
2546
  export function extractInlineTags(content: string): string[] {
2422
2547
  let stripped = content.replace(/```[\s\S]*?```/g, "");
2423
2548
  stripped = stripped.replace(/`[^`\n]+`/g, "");
2424
2549
  const tags = new Set<string>();
2425
- const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
2550
+ const regex = new RegExp(INLINE_TAG_REGEX.source, INLINE_TAG_REGEX.flags);
2426
2551
  let match: RegExpExecArray | null;
2427
2552
  while ((match = regex.exec(stripped)) !== null) {
2428
- tags.add(match[1]!.toLowerCase());
2553
+ const tag = normalizeTagValue(match[1]!);
2554
+ if (tag) tags.add(tag);
2429
2555
  }
2430
2556
  return [...tags];
2431
2557
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Regression pins for the 2026-06-10 query-perf work:
3
+ *
4
+ * 1. Plain `{field: value}` metadata equality on an INDEXED field now routes
5
+ * through the generated column as an index prefilter, keeping the
6
+ * original json_extract clause as a residual predicate. Results must be
7
+ * IDENTICAL to the JSON-scan path — property-tested here by giving every
8
+ * note twin fields (`*_idx` indexed, `*_raw` not) carrying the SAME
9
+ * value, then asserting every probe returns the same ids through both.
10
+ *
11
+ * 2. Tag membership is a semijoin (no `JOIN note_tags` + `DISTINCT n.*`),
12
+ * so a note matched by SEVERAL tags in one query must still appear once.
13
+ *
14
+ * 3. `getLinksHydratedForNotes` (the batched include_links path) must agree
15
+ * with per-note `getLinksHydrated`, including self-loops and links whose
16
+ * endpoints are both on the page.
17
+ */
18
+ import { describe, it, expect, beforeEach } from "bun:test";
19
+ import { Database } from "bun:sqlite";
20
+ import { SqliteStore } from "./store.js";
21
+ import { queryNotes } from "./notes.js";
22
+ import { getLinksHydrated, getLinksHydratedForNotes } from "./links.js";
23
+
24
+ let db: Database;
25
+ let store: SqliteStore;
26
+
27
+ beforeEach(() => {
28
+ db = new Database(":memory:");
29
+ store = new SqliteStore(db);
30
+ });
31
+
32
+ describe("plain metadata equality: indexed routing is result-identical to the JSON scan", () => {
33
+ /**
34
+ * Edge values exercised through BOTH paths. Some of these never match
35
+ * (today's documented scan behavior — e.g. a JS number binds as its JSON
36
+ * text and SQLite won't equate INTEGER 5 with TEXT '5'); the pin is that
37
+ * indexed routing reproduces the scan's verdict EXACTLY, match or not.
38
+ */
39
+ const STORED_VALUES: unknown[] = [
40
+ "pending",
41
+ "",
42
+ "5", // numeric-looking string
43
+ 5, // JSON number — affinity edge case vs "5"
44
+ 0,
45
+ -1.5,
46
+ true,
47
+ false,
48
+ null,
49
+ "naïve-Ünïcode ✨",
50
+ { nested: { deep: 1 } },
51
+ ["a", "b"],
52
+ undefined, // field absent from metadata
53
+ ];
54
+
55
+ const PROBES: unknown[] = [
56
+ "pending",
57
+ "",
58
+ "5",
59
+ 5,
60
+ 0,
61
+ -1.5,
62
+ true,
63
+ false,
64
+ null,
65
+ "naïve-Ünïcode ✨",
66
+ "missing-everywhere",
67
+ ];
68
+
69
+ async function seedTwinFieldFixture(sqliteType: "string" | "integer") {
70
+ // `status_idx` is indexed (string) via a tag schema; `status_raw` is not.
71
+ await store.upsertTagRecord("thing", {
72
+ fields: { status_idx: { type: sqliteType, indexed: true } },
73
+ });
74
+ for (let i = 0; i < STORED_VALUES.length; i++) {
75
+ const v = STORED_VALUES[i];
76
+ const metadata: Record<string, unknown> = { seq: i };
77
+ if (v !== undefined) {
78
+ metadata.status_idx = v;
79
+ metadata.status_raw = v;
80
+ }
81
+ await store.createNote(`note ${i}`, { tags: ["thing"], metadata });
82
+ }
83
+ }
84
+
85
+ for (const sqliteType of ["string", "integer"] as const) {
86
+ it(`every probe matches the same notes through the indexed and scan paths (${sqliteType} column)`, async () => {
87
+ await seedTwinFieldFixture(sqliteType);
88
+ for (const probe of PROBES) {
89
+ const viaIndexed = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
90
+ const viaScan = queryNotes(db, { metadata: { status_raw: probe } }).map((n) => n.id);
91
+ expect(viaIndexed).toEqual(viaScan);
92
+ }
93
+ });
94
+ }
95
+
96
+ it("string probe on the indexed field actually matches (sanity: not vacuous)", async () => {
97
+ await seedTwinFieldFixture("string");
98
+ const hits = queryNotes(db, { metadata: { status_idx: "pending" } });
99
+ expect(hits.length).toBe(1);
100
+ expect(hits[0]!.metadata?.seq).toBe(0);
101
+ });
102
+
103
+ it("indexed plain equality agrees with the operator form for string values", async () => {
104
+ await seedTwinFieldFixture("string");
105
+ for (const probe of ["pending", "", "missing-everywhere", "naïve-Ünïcode ✨"]) {
106
+ const plain = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
107
+ const operator = queryNotes(db, { metadata: { status_idx: { eq: probe } } }).map((n) => n.id);
108
+ expect(plain).toEqual(operator);
109
+ }
110
+ });
111
+
112
+ it("plain equality on a non-indexed field still works (no FIELD_NOT_INDEXED error)", async () => {
113
+ await store.createNote("a", { metadata: { color: "blue" } });
114
+ await store.createNote("b", { metadata: { color: "red" } });
115
+ const hits = queryNotes(db, { metadata: { color: "blue" } });
116
+ expect(hits.length).toBe(1);
117
+ });
118
+ });
119
+
120
+ describe("tag semijoin: no duplicate rows without DISTINCT", () => {
121
+ it("a note carrying several matching tags appears once under tag_match=any", async () => {
122
+ await store.upsertTagRecord("capture/voice", { parent_names: ["capture"] });
123
+ await store.upsertTagRecord("capture/text", { parent_names: ["capture"] });
124
+ // Tagged with the parent AND two children — the old JOIN produced 3 rows.
125
+ const note = await store.createNote("multi", {
126
+ tags: ["capture", "capture/voice", "capture/text"],
127
+ });
128
+ await store.createNote("single", { tags: ["capture/voice"] });
129
+
130
+ const viaExpansion = await store.queryNotes({ tags: ["capture"] });
131
+ expect(viaExpansion.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
132
+ expect(viaExpansion.length).toBe(2);
133
+
134
+ const viaAny = await store.queryNotes({
135
+ tags: ["capture", "capture/voice", "capture/text"],
136
+ tagMatch: "any",
137
+ });
138
+ expect(viaAny.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
139
+ });
140
+
141
+ it("tag_match=all still requires every input tag", async () => {
142
+ const both = await store.createNote("both", { tags: ["a", "b"] });
143
+ await store.createNote("only-a", { tags: ["a"] });
144
+ const hits = await store.queryNotes({ tags: ["a", "b"], tagMatch: "all" });
145
+ expect(hits.map((n) => n.id)).toEqual([both.id]);
146
+ });
147
+ });
148
+
149
+ describe("getLinksHydratedForNotes agrees with per-note getLinksHydrated", () => {
150
+ it("matches per-note hydration across self-loops, shared links, and isolated notes", async () => {
151
+ const a = await store.createNote("a", { tags: ["x"] });
152
+ const b = await store.createNote("b", { tags: ["y"], metadata: { k: 1 } });
153
+ const c = await store.createNote("c", { path: "Notes/c" });
154
+ const lonely = await store.createNote("lonely");
155
+
156
+ await store.createLink(a.id, b.id, "mentions"); // both endpoints on page
157
+ await store.createLink(b.id, a.id, "replies"); // reverse direction
158
+ await store.createLink(a.id, a.id, "self"); // self-loop
159
+ await store.createLink(c.id, a.id, "references"); // inbound from page note
160
+ await store.createLink(b.id, c.id, "cites", { via: "test" });
161
+
162
+ const pageIds = [a.id, b.id, c.id, lonely.id];
163
+ const batch = getLinksHydratedForNotes(db, pageIds);
164
+
165
+ for (const id of pageIds) {
166
+ const single = getLinksHydrated(db, id);
167
+ const batched = batch.get(id)!;
168
+ const key = (l: { sourceId: string; targetId: string; relationship: string }) =>
169
+ `${l.sourceId}→${l.targetId}:${l.relationship}`;
170
+ // Same link set per note…
171
+ expect(batched.map(key).sort()).toEqual(single.map(key).sort());
172
+ // …ordered newest-first like the single-note SQL.
173
+ const stamps = batched.map((l) => l.createdAt);
174
+ expect([...stamps].sort().reverse()).toEqual(stamps);
175
+ // …with identical hydrated summaries (path, tags, metadata).
176
+ const summaries = (ls: typeof single) =>
177
+ new Map(ls.map((l) => [key(l), JSON.stringify([l.sourceNote, l.targetNote])]));
178
+ expect(summaries(batched)).toEqual(summaries(single));
179
+ }
180
+
181
+ // The self-loop appears once in a's list (not duplicated by the
182
+ // source/target double-walk), matching the single-note query.
183
+ const selfLoops = batch.get(a.id)!.filter((l) => l.sourceId === a.id && l.targetId === a.id);
184
+ expect(selfLoops.length).toBe(1);
185
+
186
+ expect(batch.get(lonely.id)).toEqual([]);
187
+ });
188
+ });
189
+
190
+ describe("v22 keyset index", () => {
191
+ it("exists after initSchema and covers (updated_at, id)", () => {
192
+ const row = db
193
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_notes_updated'")
194
+ .get();
195
+ expect(row).toBeTruthy();
196
+ const plan = db
197
+ .prepare(
198
+ `EXPLAIN QUERY PLAN
199
+ SELECT n.id FROM notes n
200
+ WHERE (n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))
201
+ ORDER BY n.updated_at ASC, n.id ASC LIMIT 10`,
202
+ )
203
+ .all("2025-01-01", "2025-01-01", "x") as { detail: string }[];
204
+ const details = plan.map((r) => r.detail).join(" | ");
205
+ expect(details).toContain("idx_notes_updated");
206
+ expect(details).not.toContain("TEMP B-TREE");
207
+ });
208
+ });
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 20;
5
+ export const SCHEMA_VERSION = 22;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record.
@@ -30,10 +30,13 @@ CREATE TABLE IF NOT EXISTS notes (
30
30
  -- description — human-readable blurb (markdown).
31
31
  -- fields — JSON: indexed metadata field declarations per
32
32
  -- query-operators.md. Replaces v6-era tag_schemas.fields.
33
- -- relationships — JSON: typed-link declarations
34
- -- ({ "rel": { target_tag, cardinality, description? } }).
35
- -- Cardinality vocabulary: one | optional | many | many-required.
36
- -- Phase 1 informational declared but not enforced at write.
33
+ -- relationships — JSON: opaque relationship-vocabulary map. Keys are
34
+ -- relationship names; values are arbitrary JSON the declaring
35
+ -- app interprets (e.g. the Weaver's { "works-on": { from, to } }).
36
+ -- Stored + returned verbatim; only the top level is validated
37
+ -- (must be a JSON object/map). The historical typed shape
38
+ -- { "rel": { target_tag, cardinality, description? } } remains a
39
+ -- valid value. Not enforced at write time. See vault#428.
37
40
  -- parent_names — JSON array of parent tag names. Replaces the v6-era
38
41
  -- _tags/NAME config-note hierarchy.
39
42
  CREATE TABLE IF NOT EXISTS tags (
@@ -96,7 +99,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
96
99
 
97
100
  -- Tokens: API authentication with OAuth-standard scopes.
98
101
  --
99
- -- VESTIGIAL as of 0.6.0 (vault#282 Stage 2). Vault is a pure hub
102
+ -- VESTIGIAL as of 0.5.0 (vault#282 Stage 2). Vault is a pure hub
100
103
  -- resource-server: it no longer mints (pvt_*) or validates rows in this
101
104
  -- table — auth runs through hub-issued JWTs + VAULT_AUTH_TOKEN + legacy YAML
102
105
  -- api_keys only. The table is KEPT (not dropped) because migrateVaultKeys
@@ -105,7 +108,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
105
108
  -- leftover rows. A future cosmetic migration may drop it alongside
106
109
  -- oauth_clients/oauth_codes. 'tokens list' / 'tokens revoke' (CLI) still
107
110
  -- read/delete here for cleanup of leftover rows. See the field docs below for
108
- -- the historical (pre-0.6.0) semantics.
111
+ -- the historical (pre-0.5.0) semantics.
109
112
  --
110
113
  -- scopes is a whitespace-separated list of granted scopes (OAuth 2.0 §3.3)
111
114
  -- — e.g. "vault:read vault:write". Introduced in v12 alongside enforcement;
@@ -140,7 +143,7 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
140
143
  -- session. Session-pinned list+revoke in manage-token filters on this.
141
144
  --
142
145
  -- revoked_at (v19) marked soft-revocation of vault-DB tokens. Vestigial
143
- -- post-0.6.0 (vault#282 Stage 2) — the validation path that read it
146
+ -- post-0.5.0 (vault#282 Stage 2) — the validation path that read it
144
147
  -- (resolveToken) was removed alongside the pvt_* mint.
145
148
  CREATE TABLE IF NOT EXISTS tokens (
146
149
  token_hash TEXT PRIMARY KEY,
@@ -183,6 +186,23 @@ CREATE TABLE IF NOT EXISTS mcp_mint_ledger (
183
186
  revoked_at TEXT
184
187
  );
185
188
 
189
+ -- Triggers (v21, vault frictionless-channel-setup PR 1): runtime, persisted,
190
+ -- per-vault webhook triggers. Complements the static config.yaml trigger
191
+ -- system — config.yaml triggers stay global; rows here are scoped to THIS
192
+ -- vault's DB and fire only for events on this vault. The structured columns
193
+ -- (events/when/action) are JSON-encoded; the action column carries the webhook
194
+ -- URL, send mode, timeout, and an optional auth { bearer } for the JWT webhook
195
+ -- path. Managed at runtime via the admin-scoped /api/triggers REST surface
196
+ -- and re-registered on the live hook registry at boot. See src/triggers-api.ts.
197
+ CREATE TABLE IF NOT EXISTS triggers (
198
+ name TEXT PRIMARY KEY,
199
+ events TEXT NOT NULL DEFAULT '[]',
200
+ "when" TEXT NOT NULL DEFAULT '{}',
201
+ action TEXT NOT NULL DEFAULT '{}',
202
+ created_at TEXT NOT NULL,
203
+ updated_at TEXT NOT NULL
204
+ );
205
+
186
206
  -- OAuth: registered clients (Dynamic Client Registration)
187
207
  -- VESTIGIAL after vault 0.4.x workstream E (2026-05-25). The standalone
188
208
  -- OAuth issuer that wrote these rows was retired (hub is the issuer now;
@@ -420,7 +440,7 @@ export function initSchema(db: Database): void {
420
440
  // Migrate v15 → v16: add `vault_name` column to tokens. Existing rows
421
441
  // backfilled to NULL ("server-wide / legacy" semantic) — at the time auth
422
442
  // accepted NULL for any vault so pre-v16 pvt_* tokens kept working. (pvt_*
423
- // validation was dropped at 0.6.0 / vault#282 Stage 2; the column is now
443
+ // validation was dropped at 0.5.0 / vault#282 Stage 2; the column is now
424
444
  // vestigial.) See vault#257.
425
445
  migrateToV16(db);
426
446
 
@@ -449,6 +469,16 @@ export function initSchema(db: Database): void {
449
469
  // version bump. See vault#403 (MGT — manage-token mints hub JWTs).
450
470
  migrateToV20(db);
451
471
 
472
+ // Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
473
+ // webhook triggers). Created by SCHEMA_SQL's CREATE TABLE IF NOT EXISTS
474
+ // above, so this is a defensive confirmation hook for upgrading vaults.
475
+ migrateToV21(db);
476
+
477
+ // Migrate v21 → v22: composite index notes(updated_at, id) backing cursor
478
+ // keyset pagination and date_filter on updated_at — both were full table
479
+ // scans. See the 2026-06-10 query-perf measurements.
480
+ migrateToV22(db);
481
+
452
482
  // Rebuild any generated columns + indexes declared in indexed_fields.
453
483
  // No-op for a fresh vault; idempotent on existing vaults.
454
484
  rebuildIndexes(db);
@@ -794,7 +824,7 @@ function migrateToV15(db: Database): void {
794
824
  // 2. Copy `_schema_defaults` note → schema_mappings.
795
825
  const mappingNote = db.prepare(
796
826
  "SELECT metadata FROM notes WHERE path = '_schema_defaults'",
797
- ).get() as { metadata: string | null } | undefined;
827
+ ).get() as { metadata: string | null } | null;
798
828
  if (mappingNote?.metadata) {
799
829
  const insertMapping = db.prepare(
800
830
  "INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
@@ -852,7 +882,7 @@ function migrateToV15(db: Database): void {
852
882
  * "server-wide / legacy" semantic. At the time, `authenticateVaultRequest`
853
883
  * accepted NULL for any vault so pre-v16 pvt_* tokens kept working. (pvt_*
854
884
  * validation + the `/vault/<name>/tokens` mint route were both removed at
855
- * 0.6.0 / vault#282 Stage 2 — the column + index are now vestigial; the
885
+ * 0.5.0 / vault#282 Stage 2 — the column + index are now vestigial; the
856
886
  * index still speeds the per-vault `listTokens` cleanup listing.)
857
887
  *
858
888
  * Wrapped in BEGIN IMMEDIATE / COMMIT (with try/catch ROLLBACK) per the
@@ -1074,6 +1104,52 @@ function migrateToV20(db: Database): void {
1074
1104
  );
1075
1105
  }
1076
1106
 
1107
+ /**
1108
+ * Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
1109
+ * webhook triggers — vault frictionless-channel-setup PR 1). SCHEMA_SQL's
1110
+ * `CREATE TABLE IF NOT EXISTS` already covers fresh AND upgrading vaults
1111
+ * (it runs unconditionally before the migration steps), so this is a
1112
+ * defensive no-op confirmation for vaults created before v21. The reserved
1113
+ * keyword `when` is quoted in the column definition. Idempotent.
1114
+ */
1115
+ function migrateToV21(db: Database): void {
1116
+ if (hasTable(db, "triggers")) return;
1117
+ db.exec(`
1118
+ CREATE TABLE IF NOT EXISTS triggers (
1119
+ name TEXT PRIMARY KEY,
1120
+ events TEXT NOT NULL DEFAULT '[]',
1121
+ "when" TEXT NOT NULL DEFAULT '{}',
1122
+ action TEXT NOT NULL DEFAULT '{}',
1123
+ created_at TEXT NOT NULL,
1124
+ updated_at TEXT NOT NULL
1125
+ )
1126
+ `);
1127
+ }
1128
+
1129
+ /**
1130
+ * Migrate v21 → v22: composite B-tree on notes(updated_at, id).
1131
+ *
1132
+ * Two query shapes ride it (2026-06-10 perf measurements — both were full
1133
+ * table scans + temp-B-tree sorts before):
1134
+ *
1135
+ * 1. Cursor keyset pagination (vault#313): the predicate
1136
+ * `updated_at > ? OR (updated_at = ? AND id > ?)` with
1137
+ * `ORDER BY updated_at ASC, id ASC` matches the composite key exactly,
1138
+ * so a "since last checked" poll seeks straight to the watermark and
1139
+ * streams in order — no scan, no sort.
1140
+ * 2. `date_filter: { field: "updated_at" }` range queries (incremental
1141
+ * rebuild flows, vault#285 1.5).
1142
+ *
1143
+ * Lives here (not SCHEMA_SQL) following the idx_tokens_vault_name precedent:
1144
+ * migrations run after every column-shape change, so the index statement
1145
+ * never races an older table shape. Fresh vaults reach this through the
1146
+ * same initSchema path — CREATE INDEX IF NOT EXISTS is idempotent.
1147
+ */
1148
+ function migrateToV22(db: Database): void {
1149
+ if (!hasTable(db, "notes")) return;
1150
+ db.exec("CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at, id)");
1151
+ }
1152
+
1077
1153
  function hasTable(db: Database, name: string): boolean {
1078
1154
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1079
1155
  return !!row;