@openparachute/vault 0.6.0-rc.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- 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 +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- 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/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -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/content-range-routes.test.ts +178 -0
- 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/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- 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-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- 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 +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -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-BPgyIjR7.js +61 -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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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
|
-
|
|
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
|
|
358
|
+
for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
|
|
325
359
|
}
|
|
326
|
-
|
|
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
|
|
394
|
+
for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
|
|
348
395
|
}
|
|
349
396
|
return expanded;
|
|
350
397
|
}
|
|
@@ -572,7 +619,7 @@ export class BunSqliteStore implements Store {
|
|
|
572
619
|
patch: {
|
|
573
620
|
description?: string | null;
|
|
574
621
|
fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
|
|
575
|
-
relationships?:
|
|
622
|
+
relationships?: tagSchemaOps.TagRelationshipMap | null;
|
|
576
623
|
parent_names?: string[] | null;
|
|
577
624
|
},
|
|
578
625
|
) {
|
|
@@ -730,7 +777,7 @@ export class BunSqliteStore implements Store {
|
|
|
730
777
|
// Scope by noteId so a token authorized for note A can't delete note B's attachments.
|
|
731
778
|
const row = this.db.prepare(
|
|
732
779
|
"SELECT path FROM attachments WHERE id = ? AND note_id = ?",
|
|
733
|
-
).get(attachmentId, noteId) as { path: string } |
|
|
780
|
+
).get(attachmentId, noteId) as { path: string } | null;
|
|
734
781
|
if (!row) return { deleted: false, path: null, orphaned: false };
|
|
735
782
|
|
|
736
783
|
this.db.prepare("DELETE FROM attachments WHERE id = ? AND note_id = ?").run(attachmentId, noteId);
|
|
@@ -756,7 +803,7 @@ export class BunSqliteStore implements Store {
|
|
|
756
803
|
async getAttachment(attachmentId: string): Promise<Attachment | null> {
|
|
757
804
|
const row = this.db.prepare(
|
|
758
805
|
"SELECT * FROM attachments WHERE id = ?",
|
|
759
|
-
).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } |
|
|
806
|
+
).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | null;
|
|
760
807
|
if (!row) return null;
|
|
761
808
|
let metadata: Record<string, unknown> | undefined;
|
|
762
809
|
if (row.metadata && row.metadata !== "{}") {
|
|
@@ -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
|