@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
@@ -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
+ });
@@ -143,6 +143,86 @@ export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
143
143
  return result;
144
144
  }
145
145
 
146
+ /**
147
+ * Tag-expansion axis (vault tag `expand` axis — design
148
+ * `design/2026-06-09-tag-expand-axis.md`). A tag relates to others along two
149
+ * orthogonal axes; this selects which one (or both, or neither) a query expands
150
+ * along:
151
+ *
152
+ * - `"subtypes"` (DEFAULT): tag ∪ `parent_names` descendants. The semantic
153
+ * is-a axis — today's always-on behavior, unchanged. `_default` universal
154
+ * parent magic fires here.
155
+ * - `"namespace"`: tag ∪ {names lexically prefixed `tag/`}. The organizational
156
+ * filing axis — purely lexical over the known tag set, no `parent_names`, no
157
+ * `_default` magic.
158
+ * - `"both"`: union of subtypes and namespace.
159
+ * - `"exact"`: the literal tag only, no expansion.
160
+ */
161
+ export type TagExpandMode = "subtypes" | "namespace" | "both" | "exact";
162
+
163
+ export const TAG_EXPAND_MODES: readonly TagExpandMode[] = [
164
+ "subtypes",
165
+ "namespace",
166
+ "both",
167
+ "exact",
168
+ ] as const;
169
+
170
+ export const DEFAULT_TAG_EXPAND_MODE: TagExpandMode = "subtypes";
171
+
172
+ /**
173
+ * Lexical namespace expansion for a single tag: the tag itself plus every
174
+ * known tag name filed under it (`name === tag` OR `name` starts with
175
+ * `tag + "/"`). Purely a string-prefix match over `h.allTags` — namespacing is
176
+ * free-form (the slash in the tag *name*), declared nowhere, which is the whole
177
+ * point: subtyping is declared via `parent_names`, filing is not. No `_default`
178
+ * magic here — that's a subtypes-axis concept.
179
+ */
180
+ export function getTagNamespace(h: TagHierarchy, tag: string): Set<string> {
181
+ // Pre-seeded with `tag` itself, so the loop only needs the strict `tag/*`
182
+ // prefix test (the `name === tag` case is already covered by the seed).
183
+ const result = new Set<string>([tag]);
184
+ const prefix = tag + "/";
185
+ for (const name of h.allTags) {
186
+ if (name.startsWith(prefix)) result.add(name);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ /**
192
+ * Mode-aware single-tag expansion (vault tag `expand` axis). Returns the set of
193
+ * tag names a query for `tag` should match under `mode`. Always includes `tag`
194
+ * itself. Set semantics: a tag that is BOTH a declared subtype-child AND
195
+ * name-prefixed appears once.
196
+ *
197
+ * - `"subtypes"` → `getTagDescendants` (parent_names + `_default` magic).
198
+ * - `"namespace"` → `getTagNamespace` (lexical `tag/*`).
199
+ * - `"both"` → union of the two.
200
+ * - `"exact"` → `{tag}`.
201
+ */
202
+ export function getTagExpansion(
203
+ h: TagHierarchy,
204
+ tag: string,
205
+ mode: TagExpandMode,
206
+ ): Set<string> {
207
+ switch (mode) {
208
+ case "exact":
209
+ return new Set<string>([tag]);
210
+ case "namespace":
211
+ return getTagNamespace(h, tag);
212
+ case "both": {
213
+ const union = new Set<string>(getTagDescendants(h, tag));
214
+ for (const name of getTagNamespace(h, tag)) union.add(name);
215
+ return union;
216
+ }
217
+ case "subtypes":
218
+ default:
219
+ // `default` is a defensive fallback — `TagExpandMode` already constrains
220
+ // the value to the four cases above; it can only be reached if an
221
+ // untyped caller passes a bad string.
222
+ return getTagDescendants(h, tag);
223
+ }
224
+ }
225
+
146
226
  /**
147
227
  * Detect cycles in the declared hierarchy. Returns the list of tags
148
228
  * reachable from themselves via parent declarations. Used by
@@ -33,10 +33,13 @@ export interface TagFieldSchema {
33
33
  }
34
34
 
35
35
  /**
36
- * Cardinality vocabulary for typed relationships. Names rather than
37
- * algebra so AI clients reading `list-tags` can reason about intent
38
- * directly. Phase 1 is informational declarations are not enforced
39
- * at write time. See patterns/tag-data-model.md §Typed relationships.
36
+ * Cardinality vocabulary for the historical typed-relationship shape.
37
+ * Names rather than algebra so AI clients reading `list-tags` can reason
38
+ * about intent directly. Retained for callers that still want the typed
39
+ * `{ target_tag, cardinality }` declaration but `relationships` is now an
40
+ * opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
41
+ * so this is one valid value shape among many, not a required one.
42
+ * See patterns/tag-data-model.md §Relationships.
40
43
  */
41
44
  export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
42
45
 
@@ -47,12 +50,24 @@ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
47
50
  "many-required",
48
51
  ] as const;
49
52
 
53
+ /**
54
+ * The historical typed-relationship declaration. Still a valid opaque-map
55
+ * value — vault no longer enforces it. New apps (the Weaver / structural-link
56
+ * picker) declare their own freeform vocabulary instead.
57
+ */
50
58
  export interface TagRelationship {
51
59
  target_tag: string;
52
60
  cardinality: TagRelCardinality;
53
61
  description?: string;
54
62
  }
55
63
 
64
+ /**
65
+ * `relationships` is an opaque vocabulary map: relationship-name → arbitrary
66
+ * JSON value the declaring app interprets. Vault stores and returns the values
67
+ * verbatim and enforces only that the top-level value is a JSON object (a map).
68
+ */
69
+ export type TagRelationshipMap = Record<string, unknown>;
70
+
56
71
  /**
57
72
  * Schema-only view of a tag — the historical shape. Backwards-compatible
58
73
  * with v13-and-earlier callers.
@@ -67,7 +82,7 @@ export interface TagSchema {
67
82
  * Full tag record — schema + typed relationships + hierarchy parents.
68
83
  */
69
84
  export interface TagRecord extends TagSchema {
70
- relationships?: Record<string, TagRelationship>;
85
+ relationships?: TagRelationshipMap;
71
86
  parent_names?: string[];
72
87
  created_at?: string;
73
88
  updated_at?: string;
@@ -100,7 +115,7 @@ export function listTagRecords(db: Database): TagRecord[] {
100
115
  export function getTagRecord(db: Database, tag: string): TagRecord | null {
101
116
  const row = db.prepare(
102
117
  "SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags WHERE name = ?",
103
- ).get(tag) as TagRow | undefined;
118
+ ).get(tag) as TagRow | null;
104
119
  return row ? rowToRecord(row) : null;
105
120
  }
106
121
 
@@ -115,7 +130,7 @@ export function upsertTagRecord(
115
130
  patch: {
116
131
  description?: string | null;
117
132
  fields?: Record<string, TagFieldSchema> | null;
118
- relationships?: Record<string, TagRelationship> | null;
133
+ relationships?: TagRelationshipMap | null;
119
134
  parent_names?: string[] | null;
120
135
  },
121
136
  ): TagRecord {
@@ -174,7 +189,7 @@ export function listTagSchemas(db: Database): TagSchema[] {
174
189
  export function getTagSchema(db: Database, tag: string): TagSchema | null {
175
190
  const row = db.prepare(
176
191
  "SELECT name, description, fields FROM tags WHERE name = ?",
177
- ).get(tag) as { name: string; description: string | null; fields: string | null } | undefined;
192
+ ).get(tag) as { name: string; description: string | null; fields: string | null } | null;
178
193
  if (!row) return null;
179
194
  if (row.description === null && row.fields === null) return null;
180
195
  return {
@@ -226,56 +241,56 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
226
241
  }
227
242
 
228
243
  // ---------------------------------------------------------------------------
229
- // Validation — typed relationships
244
+ // Validation — relationships (opaque vocabulary map)
230
245
  // ---------------------------------------------------------------------------
231
246
 
232
247
  /**
233
- * Validate a `relationships` payload before persisting. Returns the
234
- * canonicalized object on success; throws Error with a user-readable
235
- * message on the first violation. Rules:
248
+ * Validate a `relationships` payload before persisting. `relationships` is
249
+ * an **opaque vocabulary map**: a JSON object whose keys are relationship
250
+ * names and whose values are arbitrary JSON the declaring app interprets
251
+ * (e.g. the Weaver / structural-link picker's `{ "works-on": { from, to } }`
252
+ * shape). Vault does not enforce any inner structure — it stores and returns
253
+ * the values verbatim.
254
+ *
255
+ * Rules (the only ones):
256
+ * - The top-level value must be a plain JSON object (a map). A top-level
257
+ * array or primitive is rejected — relationships is a map, not a list.
258
+ * - The payload must be JSON-serializable (no circular refs / functions /
259
+ * bigints), since it's persisted as a JSON column.
236
260
  *
237
- * - Each value must declare `target_tag` (non-empty string) and
238
- * `cardinality` from the named vocabulary.
239
- * - `description` is optional, must be a string when present.
240
- * - Relationship keys must be non-empty strings.
261
+ * Returns the value verbatim (round-trips through JSON.parse(JSON.stringify)
262
+ * to both prove serializability and strip anything non-serializable). The
263
+ * historical typed shape `{ target_tag, cardinality }` is a valid opaque map,
264
+ * so this is a backwards-compatible superset — existing typed declarations
265
+ * and callers keep working unchanged.
266
+ *
267
+ * Phase 1 was already informational ("declarations are not enforced at write
268
+ * time"); dropping the inner-shape gate is consistent with that intent.
241
269
  */
242
- export function validateRelationships(
243
- raw: unknown,
244
- ): Record<string, TagRelationship> {
270
+ export function validateRelationships(raw: unknown): Record<string, unknown> {
245
271
  if (raw === null || raw === undefined) {
246
272
  throw new Error("relationships: expected an object, got null/undefined");
247
273
  }
248
274
  if (typeof raw !== "object" || Array.isArray(raw)) {
249
- throw new Error("relationships: expected an object mapping rel name → declaration");
275
+ throw new Error(
276
+ "relationships: expected an object mapping relationship name → value (got an array or primitive)",
277
+ );
250
278
  }
251
- const out: Record<string, TagRelationship> = {};
252
- for (const [rel, decl] of Object.entries(raw as Record<string, unknown>)) {
253
- if (!rel || typeof rel !== "string") {
279
+ for (const rel of Object.keys(raw as Record<string, unknown>)) {
280
+ if (!rel) {
254
281
  throw new Error("relationships: keys must be non-empty strings");
255
282
  }
256
- if (!decl || typeof decl !== "object" || Array.isArray(decl)) {
257
- throw new Error(`relationships["${rel}"]: declaration must be an object`);
258
- }
259
- const d = decl as Record<string, unknown>;
260
- if (typeof d.target_tag !== "string" || d.target_tag.length === 0) {
261
- throw new Error(`relationships["${rel}"]: target_tag must be a non-empty string`);
262
- }
263
- const card = d.cardinality;
264
- if (typeof card !== "string" || !TAG_REL_CARDINALITIES.includes(card as TagRelCardinality)) {
265
- throw new Error(
266
- `relationships["${rel}"]: cardinality must be one of ${TAG_REL_CARDINALITIES.join(" | ")}; got ${JSON.stringify(card)}`,
267
- );
268
- }
269
- if (d.description !== undefined && typeof d.description !== "string") {
270
- throw new Error(`relationships["${rel}"]: description must be a string when set`);
271
- }
272
- out[rel] = {
273
- target_tag: d.target_tag,
274
- cardinality: card as TagRelCardinality,
275
- ...(d.description !== undefined ? { description: d.description as string } : {}),
276
- };
277
283
  }
278
- return out;
284
+ // Round-trip through JSON to (a) confirm the payload is serializable —
285
+ // the column is stored as JSON — and (b) return a clean, owned copy with
286
+ // no non-JSON values lingering. Throws on circular refs / bigint / etc.
287
+ let serialized: string;
288
+ try {
289
+ serialized = JSON.stringify(raw);
290
+ } catch (err) {
291
+ throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
292
+ }
293
+ return JSON.parse(serialized) as Record<string, unknown>;
279
294
  }
280
295
 
281
296
  // ---------------------------------------------------------------------------
@@ -287,7 +302,7 @@ function rowToRecord(row: TagRow): TagRecord {
287
302
  tag: row.name,
288
303
  description: row.description ?? undefined,
289
304
  fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
290
- relationships: parseJson<Record<string, TagRelationship>>(row.relationships),
305
+ relationships: parseJson<TagRelationshipMap>(row.relationships),
291
306
  parent_names: parseJson<string[]>(row.parent_names),
292
307
  created_at: row.created_at ?? undefined,
293
308
  updated_at: row.updated_at ?? undefined,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Unit tests for the persisted-triggers store (core/src/triggers-store.ts) —
3
+ * JSON encode/decode round-trip + upsert/list/get/delete semantics over an
4
+ * in-memory SQLite DB (schema v21).
5
+ */
6
+
7
+ import { describe, test, expect, beforeEach } from "bun:test";
8
+ import { Database } from "bun:sqlite";
9
+ import { initSchema } from "./schema.js";
10
+ import {
11
+ upsertTrigger,
12
+ listTriggers,
13
+ getTrigger,
14
+ deleteTrigger,
15
+ loadAllTriggers,
16
+ } from "./triggers-store.js";
17
+
18
+ let db: Database;
19
+
20
+ beforeEach(() => {
21
+ db = new Database(":memory:");
22
+ initSchema(db);
23
+ });
24
+
25
+ function sample(name: string) {
26
+ return {
27
+ name,
28
+ events: ["created", "updated"] as Array<"created" | "updated">,
29
+ when: { tags: ["channel-message"], has_content: true },
30
+ action: {
31
+ webhook: "https://example.test/hook",
32
+ send: "json" as const,
33
+ timeout: 30000,
34
+ auth: { bearer: "jwt-token" },
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("triggers-store", () => {
40
+ test("the triggers table exists after initSchema (v21)", () => {
41
+ const row = db
42
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='triggers'")
43
+ .get();
44
+ expect(row).toBeTruthy();
45
+ });
46
+
47
+ test("upsert → get round-trips the structured JSON columns", () => {
48
+ upsertTrigger(db, sample("inbound"));
49
+ const got = getTrigger(db, "inbound");
50
+ expect(got).not.toBeNull();
51
+ expect(got!.when).toEqual({ tags: ["channel-message"], has_content: true });
52
+ expect(got!.action.webhook).toBe("https://example.test/hook");
53
+ expect(got!.action.auth).toEqual({ bearer: "jwt-token" });
54
+ expect(got!.events).toEqual(["created", "updated"]);
55
+ expect(got!.created_at).toBeTruthy();
56
+ expect(got!.updated_at).toBeTruthy();
57
+ });
58
+
59
+ test("upsert by name replaces + preserves created_at, bumps updated_at", async () => {
60
+ const first = upsertTrigger(db, sample("inbound"));
61
+ // Ensure clock advances so updated_at differs.
62
+ await new Promise((r) => setTimeout(r, 5));
63
+ const second = upsertTrigger(db, {
64
+ ...sample("inbound"),
65
+ action: { webhook: "https://example.test/v2" },
66
+ });
67
+
68
+ expect(listTriggers(db)).toHaveLength(1);
69
+ expect(second.created_at).toBe(first.created_at);
70
+ expect(second.updated_at >= first.updated_at).toBe(true);
71
+ expect(getTrigger(db, "inbound")!.action.webhook).toBe("https://example.test/v2");
72
+ });
73
+
74
+ test("events defaults to [created, updated] when omitted", () => {
75
+ upsertTrigger(db, {
76
+ name: "no-events",
77
+ when: { tags: ["x"] },
78
+ action: { webhook: "https://example.test/hook" },
79
+ });
80
+ expect(getTrigger(db, "no-events")!.events).toEqual(["created", "updated"]);
81
+ });
82
+
83
+ test("list / loadAllTriggers return all rows ordered by name", () => {
84
+ upsertTrigger(db, sample("zebra"));
85
+ upsertTrigger(db, sample("alpha"));
86
+ expect(listTriggers(db).map((t) => t.name)).toEqual(["alpha", "zebra"]);
87
+ expect(loadAllTriggers(db).map((t) => t.name)).toEqual(["alpha", "zebra"]);
88
+ });
89
+
90
+ test("delete removes the row; returns false on a missing name", () => {
91
+ upsertTrigger(db, sample("inbound"));
92
+ expect(deleteTrigger(db, "inbound")).toBe(true);
93
+ expect(getTrigger(db, "inbound")).toBeNull();
94
+ expect(deleteTrigger(db, "inbound")).toBe(false);
95
+ });
96
+
97
+ test("getTrigger returns null for an unknown name", () => {
98
+ expect(getTrigger(db, "nope")).toBeNull();
99
+ });
100
+ });