@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.
- package/.parachute/module.json +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- 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
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -33,10 +33,13 @@ export interface TagFieldSchema {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Cardinality vocabulary for typed
|
|
37
|
-
* algebra so AI clients reading `list-tags` can reason
|
|
38
|
-
* directly.
|
|
39
|
-
*
|
|
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?:
|
|
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 |
|
|
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?:
|
|
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 } |
|
|
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 —
|
|
244
|
+
// Validation — relationships (opaque vocabulary map)
|
|
230
245
|
// ---------------------------------------------------------------------------
|
|
231
246
|
|
|
232
247
|
/**
|
|
233
|
-
* Validate a `relationships` payload before persisting.
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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(
|
|
275
|
+
throw new Error(
|
|
276
|
+
"relationships: expected an object mapping relationship name → value (got an array or primitive)",
|
|
277
|
+
);
|
|
250
278
|
}
|
|
251
|
-
const
|
|
252
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
+
});
|