@openparachute/vault 0.5.3-rc.3 → 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 (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
@@ -6,10 +6,21 @@
6
6
  "port": 1940,
7
7
  "paths": ["/vault/default"],
8
8
  "health": "/vault/default/health",
9
- "managementUrl": "/admin/",
10
- "uiUrl": "/admin/",
9
+ "managementUrl": "admin/",
10
+ "uiUrl": "admin/",
11
+ "configUiUrl": "/vault/admin/",
12
+ "focus": "core",
13
+ "adminCapabilities": ["config", "credentials"],
11
14
  "startCmd": ["parachute-vault", "serve"],
12
15
  "scopes": {
13
16
  "defines": ["vault:read", "vault:write", "vault:admin"]
14
- }
17
+ },
18
+ "events": [
19
+ { "key": "note.created", "title": "A note was created" },
20
+ { "key": "note.updated", "title": "A note was updated" },
21
+ { "key": "note.deleted", "title": "A note was deleted" }
22
+ ],
23
+ "actions": [
24
+ { "key": "note.create", "title": "Create a note", "inputSchema": {} }
25
+ ]
15
26
  }
package/core/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
3
3
  import * as noteOps from "./notes.js";
4
4
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
5
5
  import { QueryError } from "./query-operators.js";
6
+ import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
6
7
  import * as linkOps from "./links.js";
7
8
  import * as tagSchemaOps from "./tag-schemas.js";
8
9
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -165,6 +166,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
165
166
  description: "Filter by tag(s)",
166
167
  },
167
168
  tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
169
+ expand: {
170
+ type: "string",
171
+ enum: ["subtypes", "namespace", "both", "exact"],
172
+ description: "How each `tag` expands. 'subtypes' (DEFAULT): the tag plus its declared parent_names descendants — the semantic is-a axis (e.g. tag:entity also matches person/work). 'namespace': the tag plus everything filed under it by NAME (tag:entity also matches entity/archived) — the lexical filing axis. 'both': union of the two. 'exact': only the literal tag, no expansion. Omit for 'subtypes' (current behavior).",
173
+ },
168
174
  exclude_tags: {
169
175
  oneOf: [
170
176
  { type: "string" },
@@ -361,6 +367,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
361
367
  "INVALID_QUERY",
362
368
  );
363
369
  }
370
+ // Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
371
+ // typo'd value doesn't silently fall back to the default.
372
+ let expand: TagExpandMode | undefined;
373
+ if (params.expand !== undefined && params.expand !== null) {
374
+ if (typeof params.expand !== "string" || !(TAG_EXPAND_MODES as readonly string[]).includes(params.expand)) {
375
+ throw new QueryError(
376
+ `invalid \`expand\` value ${JSON.stringify(params.expand)} — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes").`,
377
+ "INVALID_QUERY",
378
+ );
379
+ }
380
+ expand = params.expand as TagExpandMode;
381
+ }
364
382
 
365
383
  // --- Full-text search ---
366
384
  let results: Note[];
@@ -376,6 +394,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
376
394
  results = await store.searchNotes(params.search as string, {
377
395
  tags,
378
396
  limit: (params.limit as number) ?? 50,
397
+ expand,
379
398
  });
380
399
  } else {
381
400
  // --- Structured query ---
@@ -395,6 +414,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
395
414
  const queryOpts = {
396
415
  tags,
397
416
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
417
+ expand,
398
418
  excludeTags,
399
419
  hasTags: params.has_tags as boolean | undefined,
400
420
  hasLinks: params.has_links as boolean | undefined,
@@ -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.
@@ -186,6 +186,23 @@ CREATE TABLE IF NOT EXISTS mcp_mint_ledger (
186
186
  revoked_at TEXT
187
187
  );
188
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
+
189
206
  -- OAuth: registered clients (Dynamic Client Registration)
190
207
  -- VESTIGIAL after vault 0.4.x workstream E (2026-05-25). The standalone
191
208
  -- OAuth issuer that wrote these rows was retired (hub is the issuer now;
@@ -452,6 +469,11 @@ export function initSchema(db: Database): void {
452
469
  // version bump. See vault#403 (MGT — manage-token mints hub JWTs).
453
470
  migrateToV20(db);
454
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
+
455
477
  // Rebuild any generated columns + indexes declared in indexed_fields.
456
478
  // No-op for a fresh vault; idempotent on existing vaults.
457
479
  rebuildIndexes(db);
@@ -1077,6 +1099,28 @@ function migrateToV20(db: Database): void {
1077
1099
  );
1078
1100
  }
1079
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
+
1080
1124
  function hasTable(db: Database, name: string): boolean {
1081
1125
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1082
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
  }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Tag-expansion axis (`expand`) tests — vault tag `expand` axis
3
+ * (design `design/2026-06-09-tag-expand-axis.md`).
4
+ *
5
+ * Covers the query engine (`store.queryNotes` → `expandQueryTags` →
6
+ * `_tagsExpanded`) and the mode-aware core helpers
7
+ * (`getTagExpansion`/`getTagNamespace`). REST + MCP + SSE parity are exercised
8
+ * in their own suites (`routes`/`mcp` here, `subscribe`/`live-match` in src/).
9
+ *
10
+ * The corpus deliberately separates the two axes so a mode that confuses them
11
+ * fails loudly:
12
+ * - `entity` is the SUBTYPE parent of `person` (declared via parent_names).
13
+ * `person` is NOT name-prefixed `entity/`.
14
+ * - `entity/archived` is NAME-prefixed under `entity` but is NOT a declared
15
+ * subtype (no parent_names link to `entity`).
16
+ * - `entity/person` is BOTH a declared subtype-child of `entity` AND
17
+ * name-prefixed `entity/` — the dedupe case.
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach } from "bun:test";
21
+ import { Database } from "bun:sqlite";
22
+ import { SqliteStore } from "./store.js";
23
+ import {
24
+ loadTagHierarchy,
25
+ getTagExpansion,
26
+ getTagNamespace,
27
+ getTagDescendants,
28
+ } from "./tag-hierarchy.js";
29
+
30
+ let store: SqliteStore;
31
+ let db: Database;
32
+
33
+ /**
34
+ * Seed the two-axis corpus and return the set of note ids by their kind so
35
+ * tests can assert membership without depending on insertion order.
36
+ */
37
+ async function seedTwoAxisCorpus(s: SqliteStore) {
38
+ // SUBTYPE axis: person is-a entity (declared), but NOT filed under entity/.
39
+ await s.upsertTagRecord("entity", { description: "an entity" });
40
+ await s.upsertTagRecord("person", { parent_names: ["entity"] });
41
+ // NAMESPACE axis: entity/archived is filed under entity/ but declares no
42
+ // parent_names (pure filing, no is-a edge).
43
+ await s.upsertTagRecord("entity/archived", {});
44
+ // BOTH axes: entity/person is a declared subtype-child AND name-prefixed.
45
+ await s.upsertTagRecord("entity/person", { parent_names: ["entity"] });
46
+
47
+ const nEntity = await s.createNote("literal entity", { tags: ["entity"] });
48
+ const nPerson = await s.createNote("a person (subtype)", { tags: ["person"] });
49
+ const nArchived = await s.createNote("filed under entity/", { tags: ["entity/archived"] });
50
+ const nBoth = await s.createNote("subtype AND filed", { tags: ["entity/person"] });
51
+ const nUnrelated = await s.createNote("unrelated", { tags: ["work"] });
52
+
53
+ return {
54
+ entity: nEntity.id,
55
+ person: nPerson.id,
56
+ archived: nArchived.id,
57
+ both: nBoth.id,
58
+ unrelated: nUnrelated.id,
59
+ };
60
+ }
61
+
62
+ const idsOf = (notes: { id: string }[]) => new Set(notes.map((n) => n.id));
63
+
64
+ beforeEach(() => {
65
+ db = new Database(":memory:");
66
+ store = new SqliteStore(db);
67
+ });
68
+
69
+ describe("tag expand axis — core helpers", () => {
70
+ it("getTagNamespace returns the tag + lexically prefixed names, no parent_names subtypes", async () => {
71
+ await seedTwoAxisCorpus(store);
72
+ const h = loadTagHierarchy(db);
73
+ const ns = getTagNamespace(h, "entity");
74
+ // tag itself + name-prefixed entity/*
75
+ expect(ns).toContain("entity");
76
+ expect(ns).toContain("entity/archived");
77
+ expect(ns).toContain("entity/person");
78
+ // `person` is a subtype but NOT name-prefixed → must be absent.
79
+ expect(ns.has("person")).toBe(false);
80
+ });
81
+
82
+ it("getTagExpansion: subtypes = descendants, namespace = lexical, both = union, exact = literal", async () => {
83
+ await seedTwoAxisCorpus(store);
84
+ const h = loadTagHierarchy(db);
85
+
86
+ const subtypes = getTagExpansion(h, "entity", "subtypes");
87
+ // descendants via parent_names: entity, person, entity/person — NOT entity/archived
88
+ expect(subtypes).toEqual(getTagDescendants(h, "entity"));
89
+ expect(subtypes).toContain("person");
90
+ expect(subtypes).toContain("entity/person");
91
+ expect(subtypes.has("entity/archived")).toBe(false);
92
+
93
+ const ns = getTagExpansion(h, "entity", "namespace");
94
+ expect(ns).toContain("entity/archived");
95
+ expect(ns).toContain("entity/person");
96
+ expect(ns.has("person")).toBe(false);
97
+
98
+ const both = getTagExpansion(h, "entity", "both");
99
+ // union: subtype-only person + namespace-only entity/archived + shared
100
+ expect(both).toContain("person");
101
+ expect(both).toContain("entity/archived");
102
+ expect(both).toContain("entity/person");
103
+
104
+ const exact = getTagExpansion(h, "entity", "exact");
105
+ expect(exact).toEqual(new Set(["entity"]));
106
+ });
107
+
108
+ it("dedupe: entity/person (subtype AND name-prefixed) appears once under both", async () => {
109
+ await seedTwoAxisCorpus(store);
110
+ const h = loadTagHierarchy(db);
111
+ const both = getTagExpansion(h, "entity", "both");
112
+ const count = Array.from(both).filter((t) => t === "entity/person").length;
113
+ expect(count).toBe(1);
114
+ });
115
+
116
+ it("store.expandTags mirrors the helper modes and unions multi-tag input", async () => {
117
+ await seedTwoAxisCorpus(store);
118
+ expect(await store.expandTags(["entity"], "exact")).toEqual(new Set(["entity"]));
119
+ const ns = await store.expandTags(["entity"], "namespace");
120
+ expect(ns).toContain("entity/archived");
121
+ expect(ns.has("person")).toBe(false);
122
+ // default (no mode) === subtypes === expandTagsWithDescendants
123
+ const def = await store.expandTags(["entity"]);
124
+ expect(def).toEqual(await store.expandTagsWithDescendants(["entity"]));
125
+ });
126
+ });
127
+
128
+ describe("tag expand axis — query engine", () => {
129
+ it("subtypes (default / absent) is a pure regression: descendants only, no namespaced sibling", async () => {
130
+ const ids = await seedTwoAxisCorpus(store);
131
+
132
+ const absent = await store.queryNotes({ tags: ["entity"] });
133
+ const explicit = await store.queryNotes({ tags: ["entity"], expand: "subtypes" });
134
+
135
+ // Absent ≡ explicit "subtypes" — byte-identical result set.
136
+ expect(idsOf(explicit)).toEqual(idsOf(absent));
137
+
138
+ // Returns the literal + declared subtypes…
139
+ expect(idsOf(absent)).toEqual(new Set([ids.entity, ids.person, ids.both]));
140
+ // …and NOT the namespace-only sibling entity/archived.
141
+ expect(idsOf(absent).has(ids.archived)).toBe(false);
142
+ });
143
+
144
+ it("namespace returns tag + tag/* lexical, NOT parent_names-only subtypes", async () => {
145
+ const ids = await seedTwoAxisCorpus(store);
146
+ const res = await store.queryNotes({ tags: ["entity"], expand: "namespace" });
147
+ // entity (literal) + entity/archived + entity/person (name-prefixed)
148
+ expect(idsOf(res)).toEqual(new Set([ids.entity, ids.archived, ids.both]));
149
+ // `person` is a subtype but not name-prefixed → excluded.
150
+ expect(idsOf(res).has(ids.person)).toBe(false);
151
+ });
152
+
153
+ it("both = union of subtypes and namespace", async () => {
154
+ const ids = await seedTwoAxisCorpus(store);
155
+ const res = await store.queryNotes({ tags: ["entity"], expand: "both" });
156
+ expect(idsOf(res)).toEqual(
157
+ new Set([ids.entity, ids.person, ids.archived, ids.both]),
158
+ );
159
+ });
160
+
161
+ it("exact = literal tag only, no descendants", async () => {
162
+ const ids = await seedTwoAxisCorpus(store);
163
+ const res = await store.queryNotes({ tags: ["entity"], expand: "exact" });
164
+ expect(idsOf(res)).toEqual(new Set([ids.entity]));
165
+ });
166
+
167
+ it("dedupe: a note tagged with a both-axis tag is returned once under both", async () => {
168
+ await seedTwoAxisCorpus(store);
169
+ const res = await store.queryNotes({ tags: ["entity"], expand: "both" });
170
+ const bothCount = res.filter((n) => n.content === "subtype AND filed").length;
171
+ expect(bothCount).toBe(1);
172
+ });
173
+
174
+ it("_default magic stays subtypes-only: namespace on _default does NOT collapse to all notes", async () => {
175
+ // _default declared → universal subtype parent. With a namespaced child.
176
+ await store.upsertTagRecord("_default", { description: "universal" });
177
+ await store.upsertTagRecord("_default/scoped", {});
178
+ const nA = await store.createNote("a", { tags: ["alpha"] });
179
+ const nScoped = await store.createNote("scoped", { tags: ["_default/scoped"] });
180
+
181
+ // subtypes: _default expands to ALL tags → every note matches.
182
+ const sub = await store.queryNotes({ tags: ["_default"], expand: "subtypes" });
183
+ expect(idsOf(sub).has(nA.id)).toBe(true);
184
+
185
+ // namespace: _default is treated literally → only _default + _default/* —
186
+ // does NOT collapse to "all notes" (nA, tagged only `alpha`, is excluded).
187
+ const ns = await store.queryNotes({ tags: ["_default"], expand: "namespace" });
188
+ expect(idsOf(ns).has(nA.id)).toBe(false);
189
+ expect(idsOf(ns).has(nScoped.id)).toBe(true);
190
+ });
191
+
192
+ it("both + _default collapses to all-notes via the subtypes axis", async () => {
193
+ // `both` includes the subtypes axis, so the `_default` universal-parent
194
+ // magic must still fire — the union with the namespace axis can only widen
195
+ // the set, and subtypes alone already means "every note." Untagged notes
196
+ // included (the _default collapse drops the tag filter entirely).
197
+ await store.upsertTagRecord("_default", { description: "universal" });
198
+ const nTagged = await store.createNote("tagged", { tags: ["alpha"] });
199
+ const nUntagged = await store.createNote("untagged", {});
200
+
201
+ const res = await store.queryNotes({ tags: ["_default"], expand: "both" });
202
+ const got = idsOf(res);
203
+ expect(got.has(nTagged.id)).toBe(true);
204
+ expect(got.has(nUntagged.id)).toBe(true);
205
+ // Equivalent to the no-filter corpus.
206
+ const all = await store.queryNotes({});
207
+ expect(got).toEqual(idsOf(all));
208
+ });
209
+ });
210
+
211
+ describe("tag expand axis — MCP query-notes schema + handler", () => {
212
+ it("query-notes schema advertises the four expand values", async () => {
213
+ const { generateMcpTools } = await import("./mcp.js");
214
+ const tools = generateMcpTools(store);
215
+ const q = tools.find((t) => t.name === "query-notes")!;
216
+ const props = (q.inputSchema as any).properties;
217
+ expect(props.expand).toBeDefined();
218
+ expect(props.expand.enum).toEqual(["subtypes", "namespace", "both", "exact"]);
219
+ });
220
+
221
+ it("query-notes handler honors expand=namespace", async () => {
222
+ const { generateMcpTools } = await import("./mcp.js");
223
+ const ids = await seedTwoAxisCorpus(store);
224
+ const tools = generateMcpTools(store);
225
+ const q = tools.find((t) => t.name === "query-notes")!;
226
+ // Non-cursor structured query returns a flat array of note-index entries.
227
+ const res: any = await q.execute({ tag: "entity", expand: "namespace" });
228
+ const got = new Set<string>(res.map((n: any) => n.id));
229
+ expect(got).toEqual(new Set([ids.entity, ids.archived, ids.both]));
230
+ expect(got.has(ids.person)).toBe(false);
231
+ });
232
+
233
+ it("query-notes handler rejects an unknown expand value with INVALID_QUERY", async () => {
234
+ const { generateMcpTools } = await import("./mcp.js");
235
+ const tools = generateMcpTools(store);
236
+ const q = tools.find((t) => t.name === "query-notes")!;
237
+ let err: any;
238
+ try {
239
+ await q.execute({ tag: "entity", expand: "bogus" });
240
+ } catch (e) {
241
+ err = e;
242
+ }
243
+ expect(err).toBeDefined();
244
+ expect(err.code).toBe("INVALID_QUERY");
245
+ });
246
+ });
247
+
248
+ describe("tag expand axis — MCP search path honors expand", () => {
249
+ // Corpus shares the FTS term "fox" so search(tag=entity) differs ONLY by the
250
+ // expand axis — proving the search branch threads it into store.searchNotes.
251
+ async function seedSearchCorpus(s: SqliteStore) {
252
+ await s.upsertTagRecord("entity", { description: "entity root" });
253
+ await s.upsertTagRecord("person", { parent_names: ["entity"] }); // subtype, not name-prefixed
254
+ await s.upsertTagRecord("entity/archived", {}); // name-prefixed, not subtype
255
+ await s.createNote("fox literal", { tags: ["entity"] });
256
+ await s.createNote("fox subtype", { tags: ["person"] });
257
+ await s.createNote("fox filed", { tags: ["entity/archived"] });
258
+ await s.createNote("dog unrelated", { tags: ["entity"] }); // no "fox" → FTS excludes
259
+ }
260
+
261
+ it("search + tag, absent expand ≡ subtypes (descendants, no namespaced sibling)", async () => {
262
+ const { generateMcpTools } = await import("./mcp.js");
263
+ await seedSearchCorpus(store);
264
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
265
+ const absent: any = await q.execute({ search: "fox", tag: "entity", include_content: true });
266
+ const sub: any = await q.execute({ search: "fox", tag: "entity", expand: "subtypes", include_content: true });
267
+ const absentSet = new Set(absent.map((n: any) => n.content));
268
+ expect(new Set(sub.map((n: any) => n.content))).toEqual(absentSet);
269
+ expect(absentSet).toEqual(new Set(["fox literal", "fox subtype"]));
270
+ });
271
+
272
+ it("search + tag + expand=namespace returns lexical tag/*, NOT subtype sibling", async () => {
273
+ const { generateMcpTools } = await import("./mcp.js");
274
+ await seedSearchCorpus(store);
275
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
276
+ const res: any = await q.execute({ search: "fox", tag: "entity", expand: "namespace", include_content: true });
277
+ expect(new Set(res.map((n: any) => n.content))).toEqual(new Set(["fox literal", "fox filed"]));
278
+ });
279
+
280
+ it("search + tag + expand=exact returns only the literal-tagged match", async () => {
281
+ const { generateMcpTools } = await import("./mcp.js");
282
+ await seedSearchCorpus(store);
283
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
284
+ const res: any = await q.execute({ search: "fox", tag: "entity", expand: "exact", include_content: true });
285
+ expect(res.map((n: any) => n.content)).toEqual(["fox literal"]);
286
+ });
287
+
288
+ it("search + expand=bogus is rejected with INVALID_QUERY before the search runs", async () => {
289
+ const { generateMcpTools } = await import("./mcp.js");
290
+ await store.createNote("fox here", { tags: ["entity"] });
291
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
292
+ let err: any;
293
+ try {
294
+ await q.execute({ search: "fox", tag: "entity", expand: "bogus" });
295
+ } catch (e) {
296
+ err = e;
297
+ }
298
+ expect(err).toBeDefined();
299
+ expect(err.code).toBe("INVALID_QUERY");
300
+ });
301
+ });