@openparachute/vault 0.6.0-rc.1 → 0.6.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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. 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
  }
@@ -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 = 21;
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,11 @@ 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
+
452
477
  // Rebuild any generated columns + indexes declared in indexed_fields.
453
478
  // No-op for a fresh vault; idempotent on existing vaults.
454
479
  rebuildIndexes(db);
@@ -794,7 +819,7 @@ function migrateToV15(db: Database): void {
794
819
  // 2. Copy `_schema_defaults` note → schema_mappings.
795
820
  const mappingNote = db.prepare(
796
821
  "SELECT metadata FROM notes WHERE path = '_schema_defaults'",
797
- ).get() as { metadata: string | null } | undefined;
822
+ ).get() as { metadata: string | null } | null;
798
823
  if (mappingNote?.metadata) {
799
824
  const insertMapping = db.prepare(
800
825
  "INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
@@ -852,7 +877,7 @@ function migrateToV15(db: Database): void {
852
877
  * "server-wide / legacy" semantic. At the time, `authenticateVaultRequest`
853
878
  * accepted NULL for any vault so pre-v16 pvt_* tokens kept working. (pvt_*
854
879
  * 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
880
+ * 0.5.0 / vault#282 Stage 2 — the column + index are now vestigial; the
856
881
  * index still speeds the per-vault `listTokens` cleanup listing.)
857
882
  *
858
883
  * Wrapped in BEGIN IMMEDIATE / COMMIT (with try/catch ROLLBACK) per the
@@ -1074,6 +1099,28 @@ function migrateToV20(db: Database): void {
1074
1099
  );
1075
1100
  }
1076
1101
 
1102
+ /**
1103
+ * Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
1104
+ * webhook triggers — vault frictionless-channel-setup PR 1). SCHEMA_SQL's
1105
+ * `CREATE TABLE IF NOT EXISTS` already covers fresh AND upgrading vaults
1106
+ * (it runs unconditionally before the migration steps), so this is a
1107
+ * defensive no-op confirmation for vaults created before v21. The reserved
1108
+ * keyword `when` is quoted in the column definition. Idempotent.
1109
+ */
1110
+ function migrateToV21(db: Database): void {
1111
+ if (hasTable(db, "triggers")) return;
1112
+ db.exec(`
1113
+ CREATE TABLE IF NOT EXISTS triggers (
1114
+ name TEXT PRIMARY KEY,
1115
+ events TEXT NOT NULL DEFAULT '[]',
1116
+ "when" TEXT NOT NULL DEFAULT '{}',
1117
+ action TEXT NOT NULL DEFAULT '{}',
1118
+ created_at TEXT NOT NULL,
1119
+ updated_at TEXT NOT NULL
1120
+ )
1121
+ `);
1122
+ }
1123
+
1077
1124
  function hasTable(db: Database, name: string): boolean {
1078
1125
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1079
1126
  return !!row;
package/core/src/store.ts CHANGED
@@ -15,10 +15,12 @@ import { pathTitle } from "./paths.js";
15
15
  import { HookRegistry } from "./hooks.js";
16
16
  import {
17
17
  loadTagHierarchy,
18
- getTagDescendants,
18
+ getTagExpansion,
19
19
  TAG_CONFIG_PREFIX,
20
20
  DEFAULT_TAG_NAME,
21
+ DEFAULT_TAG_EXPAND_MODE,
21
22
  type TagHierarchy,
23
+ type TagExpandMode,
22
24
  } from "./tag-hierarchy.js";
23
25
  import {
24
26
  loadSchemaConfig,
@@ -278,13 +280,25 @@ export class BunSqliteStore implements Store {
278
280
  * the other tags' notes — wrong).
279
281
  *
280
282
  * Other filters (path, metadata, dates) still apply in both cases.
283
+ *
284
+ * `expand` axis (vault tag `expand` axis): `opts.expand` selects WHICH axis
285
+ * each tag expands along — `"subtypes"` (default, the parent_names path
286
+ * documented above, with the `_default` magic), `"namespace"` (lexical
287
+ * `tag/*`), `"both"` (union), or `"exact"` (no expansion). The `_default`
288
+ * universal-parent magic is a SUBTYPES-axis concept, so it fires only when
289
+ * the resolved mode includes subtypes (`"subtypes"`/`"both"`); under
290
+ * `"namespace"`/`"exact"` a literal `_default` tag is treated like any other.
281
291
  */
282
292
  private expandQueryTags(opts: QueryOpts): QueryOpts {
283
293
  if (!opts.tags || opts.tags.length === 0) return opts;
284
294
  const hierarchy = this.getTagHierarchy();
295
+ const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
296
+ const subtypeAxis = mode === "subtypes" || mode === "both";
285
297
 
286
298
  let tags = opts.tags;
287
- if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
299
+ // `_default` collapse only applies on the subtypes axis — it's the
300
+ // universal *parent* (an is-a relationship), not a namespace prefix.
301
+ if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
288
302
  const match = opts.tagMatch ?? "all";
289
303
  if (match === "any") {
290
304
  const { tags: _drop, ..._rest } = opts;
@@ -298,34 +312,61 @@ export class BunSqliteStore implements Store {
298
312
  opts = { ...opts, tags };
299
313
  }
300
314
 
301
- if (hierarchy.childrenOf.size === 0) return opts;
302
- const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
315
+ // Subtypes fast-path: with no declared hierarchy there are no descendants,
316
+ // so the engine's `[tag]` fallback already produces the literal-tag join —
317
+ // skip attaching `_tagsExpanded` to stay byte-identical to pre-axis
318
+ // behavior. `exact` likewise needs no expansion. Namespace/both must still
319
+ // run (lexical expansion is independent of `parent_names`).
320
+ if (mode === "exact") return opts;
321
+ if (mode === "subtypes" && hierarchy.childrenOf.size === 0) return opts;
322
+
323
+ const expanded = tags.map((t) => Array.from(getTagExpansion(hierarchy, t, mode)));
303
324
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
304
325
  }
305
326
 
306
- async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
307
- // Same hierarchy-expansion treatment as queryNotes searching `#manual`
308
- // should match notes tagged with any descendant tag. The underlying
309
- // FTS path already uses `IN (...)` for tags, so we flatten the
310
- // per-input expansions into a single union (search semantics are
311
- // "any tag matches"). When `_default` is among the requested tags
312
- // (and a `_default` row exists), the OR collapses to "every note" —
313
- // drop the tag filter entirely so the search hits the full corpus
314
- // and untagged notes are reachable.
327
+ async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
328
+ // Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
329
+ // (vault tag `expand` axis) searching `#manual` should match notes
330
+ // tagged with any descendant under "subtypes", any `manual/*` under
331
+ // "namespace", etc. The underlying FTS path already uses `IN (...)` for
332
+ // tags, so we flatten the per-input expansions into a single union (search
333
+ // semantics are "any tag matches").
334
+ //
335
+ // `_default` collapse is a SUBTYPES-axis concept (the universal *parent*):
336
+ // when `_default` is among the requested tags and a `_default` row exists,
337
+ // the OR collapses to "every note" — drop the tag filter entirely so the
338
+ // search hits the full corpus and untagged notes are reachable. It fires
339
+ // only on the subtypes/both axes (mirrors `expandQueryTags`).
315
340
  if (opts?.tags && opts.tags.length > 0) {
341
+ const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
342
+ const subtypeAxis = mode === "subtypes" || mode === "both";
316
343
  const hierarchy = this.getTagHierarchy();
317
- if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
318
- const { tags: _drop, ..._rest } = opts;
344
+ if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
345
+ const { tags: _drop, expand: _e, ..._rest } = opts;
319
346
  return noteOps.searchNotes(this.db, query, _rest);
320
347
  }
321
- if (hierarchy.childrenOf.size > 0) {
348
+ // Subtypes fast-path: with no declared hierarchy there are no
349
+ // descendants, so the tags pass through unchanged (byte-identical to
350
+ // pre-axis behavior). `exact` likewise needs no expansion.
351
+ // Namespace/both must still run (lexical expansion is independent of
352
+ // `parent_names`).
353
+ const skipExpansion =
354
+ mode === "exact" || (mode === "subtypes" && hierarchy.childrenOf.size === 0);
355
+ if (!skipExpansion) {
322
356
  const expanded = new Set<string>();
323
357
  for (const t of opts.tags) {
324
- for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
358
+ for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
325
359
  }
326
- return noteOps.searchNotes(this.db, query, { ...opts, tags: Array.from(expanded) });
360
+ const { expand: _e, ..._rest } = opts;
361
+ return noteOps.searchNotes(this.db, query, { ..._rest, tags: Array.from(expanded) });
327
362
  }
328
363
  }
364
+ // Strip the internal `expand` before passing to noteOps (it has no field
365
+ // for it; harmless but keep the boundary clean).
366
+ if (opts && "expand" in opts) {
367
+ const { expand: _e, ..._rest } = opts;
368
+ return noteOps.searchNotes(this.db, query, _rest);
369
+ }
329
370
  return noteOps.searchNotes(this.db, query, opts);
330
371
  }
331
372
 
@@ -340,11 +381,17 @@ export class BunSqliteStore implements Store {
340
381
  }
341
382
 
342
383
  async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
384
+ // Thin `mode:"subtypes"` shim over the mode-aware `expandTags`, so existing
385
+ // callers (tag-scope auth, search) keep the exact descendant semantics.
386
+ return this.expandTags(tags, "subtypes");
387
+ }
388
+
389
+ async expandTags(tags: string[], mode: TagExpandMode = DEFAULT_TAG_EXPAND_MODE): Promise<Set<string>> {
343
390
  const expanded = new Set<string>();
344
391
  if (tags.length === 0) return expanded;
345
392
  const hierarchy = this.getTagHierarchy();
346
393
  for (const t of tags) {
347
- for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
394
+ for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
348
395
  }
349
396
  return expanded;
350
397
  }
@@ -572,7 +619,7 @@ export class BunSqliteStore implements Store {
572
619
  patch: {
573
620
  description?: string | null;
574
621
  fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
575
- relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
622
+ relationships?: tagSchemaOps.TagRelationshipMap | null;
576
623
  parent_names?: string[] | null;
577
624
  },
578
625
  ) {
@@ -730,7 +777,7 @@ export class BunSqliteStore implements Store {
730
777
  // Scope by noteId so a token authorized for note A can't delete note B's attachments.
731
778
  const row = this.db.prepare(
732
779
  "SELECT path FROM attachments WHERE id = ? AND note_id = ?",
733
- ).get(attachmentId, noteId) as { path: string } | undefined;
780
+ ).get(attachmentId, noteId) as { path: string } | null;
734
781
  if (!row) return { deleted: false, path: null, orphaned: false };
735
782
 
736
783
  this.db.prepare("DELETE FROM attachments WHERE id = ? AND note_id = ?").run(attachmentId, noteId);
@@ -756,7 +803,7 @@ export class BunSqliteStore implements Store {
756
803
  async getAttachment(attachmentId: string): Promise<Attachment | null> {
757
804
  const row = this.db.prepare(
758
805
  "SELECT * FROM attachments WHERE id = ?",
759
- ).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | undefined;
806
+ ).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | null;
760
807
  if (!row) return null;
761
808
  let metadata: Record<string, unknown> | undefined;
762
809
  if (row.metadata && row.metadata !== "{}") {