@openparachute/vault 0.5.3-rc.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +14 -3
- package/core/src/mcp.ts +20 -0
- package/core/src/schema.ts +45 -1
- package/core/src/store.ts +66 -19
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +27 -1
- package/package.json +1 -1
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/cli.ts +45 -18
- package/src/config.test.ts +27 -0
- package/src/config.ts +87 -0
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/routes.ts +192 -78
- package/src/routing.test.ts +64 -0
- package/src/routing.ts +48 -1
- package/src/server.ts +49 -3
- 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/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/vault-create.test.ts +35 -1
- 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 +194 -0
- 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-DJL6Az--.js +0 -60
package/.parachute/module.json
CHANGED
|
@@ -6,10 +6,21 @@
|
|
|
6
6
|
"port": 1940,
|
|
7
7
|
"paths": ["/vault/default"],
|
|
8
8
|
"health": "/vault/default/health",
|
|
9
|
-
"managementUrl": "
|
|
10
|
-
"uiUrl": "
|
|
9
|
+
"managementUrl": "admin/",
|
|
10
|
+
"uiUrl": "admin/",
|
|
11
|
+
"configUiUrl": "/vault/admin/",
|
|
12
|
+
"focus": "core",
|
|
13
|
+
"adminCapabilities": ["config", "credentials"],
|
|
11
14
|
"startCmd": ["parachute-vault", "serve"],
|
|
12
15
|
"scopes": {
|
|
13
16
|
"defines": ["vault:read", "vault:write", "vault:admin"]
|
|
14
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"events": [
|
|
19
|
+
{ "key": "note.created", "title": "A note was created" },
|
|
20
|
+
{ "key": "note.updated", "title": "A note was updated" },
|
|
21
|
+
{ "key": "note.deleted", "title": "A note was deleted" }
|
|
22
|
+
],
|
|
23
|
+
"actions": [
|
|
24
|
+
{ "key": "note.create", "title": "Create a note", "inputSchema": {} }
|
|
25
|
+
]
|
|
15
26
|
}
|
package/core/src/mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
|
|
|
3
3
|
import * as noteOps from "./notes.js";
|
|
4
4
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
5
|
import { QueryError } from "./query-operators.js";
|
|
6
|
+
import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
|
|
6
7
|
import * as linkOps from "./links.js";
|
|
7
8
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
8
9
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -165,6 +166,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
165
166
|
description: "Filter by tag(s)",
|
|
166
167
|
},
|
|
167
168
|
tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
|
|
169
|
+
expand: {
|
|
170
|
+
type: "string",
|
|
171
|
+
enum: ["subtypes", "namespace", "both", "exact"],
|
|
172
|
+
description: "How each `tag` expands. 'subtypes' (DEFAULT): the tag plus its declared parent_names descendants — the semantic is-a axis (e.g. tag:entity also matches person/work). 'namespace': the tag plus everything filed under it by NAME (tag:entity also matches entity/archived) — the lexical filing axis. 'both': union of the two. 'exact': only the literal tag, no expansion. Omit for 'subtypes' (current behavior).",
|
|
173
|
+
},
|
|
168
174
|
exclude_tags: {
|
|
169
175
|
oneOf: [
|
|
170
176
|
{ type: "string" },
|
|
@@ -361,6 +367,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
361
367
|
"INVALID_QUERY",
|
|
362
368
|
);
|
|
363
369
|
}
|
|
370
|
+
// Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
|
|
371
|
+
// typo'd value doesn't silently fall back to the default.
|
|
372
|
+
let expand: TagExpandMode | undefined;
|
|
373
|
+
if (params.expand !== undefined && params.expand !== null) {
|
|
374
|
+
if (typeof params.expand !== "string" || !(TAG_EXPAND_MODES as readonly string[]).includes(params.expand)) {
|
|
375
|
+
throw new QueryError(
|
|
376
|
+
`invalid \`expand\` value ${JSON.stringify(params.expand)} — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes").`,
|
|
377
|
+
"INVALID_QUERY",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
expand = params.expand as TagExpandMode;
|
|
381
|
+
}
|
|
364
382
|
|
|
365
383
|
// --- Full-text search ---
|
|
366
384
|
let results: Note[];
|
|
@@ -376,6 +394,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
376
394
|
results = await store.searchNotes(params.search as string, {
|
|
377
395
|
tags,
|
|
378
396
|
limit: (params.limit as number) ?? 50,
|
|
397
|
+
expand,
|
|
379
398
|
});
|
|
380
399
|
} else {
|
|
381
400
|
// --- Structured query ---
|
|
@@ -395,6 +414,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
395
414
|
const queryOpts = {
|
|
396
415
|
tags,
|
|
397
416
|
tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
417
|
+
expand,
|
|
398
418
|
excludeTags,
|
|
399
419
|
hasTags: params.has_tags as boolean | undefined,
|
|
400
420
|
hasLinks: params.has_links as boolean | undefined,
|
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 21;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record.
|
|
@@ -186,6 +186,23 @@ CREATE TABLE IF NOT EXISTS mcp_mint_ledger (
|
|
|
186
186
|
revoked_at TEXT
|
|
187
187
|
);
|
|
188
188
|
|
|
189
|
+
-- Triggers (v21, vault frictionless-channel-setup PR 1): runtime, persisted,
|
|
190
|
+
-- per-vault webhook triggers. Complements the static config.yaml trigger
|
|
191
|
+
-- system — config.yaml triggers stay global; rows here are scoped to THIS
|
|
192
|
+
-- vault's DB and fire only for events on this vault. The structured columns
|
|
193
|
+
-- (events/when/action) are JSON-encoded; the action column carries the webhook
|
|
194
|
+
-- URL, send mode, timeout, and an optional auth { bearer } for the JWT webhook
|
|
195
|
+
-- path. Managed at runtime via the admin-scoped /api/triggers REST surface
|
|
196
|
+
-- and re-registered on the live hook registry at boot. See src/triggers-api.ts.
|
|
197
|
+
CREATE TABLE IF NOT EXISTS triggers (
|
|
198
|
+
name TEXT PRIMARY KEY,
|
|
199
|
+
events TEXT NOT NULL DEFAULT '[]',
|
|
200
|
+
"when" TEXT NOT NULL DEFAULT '{}',
|
|
201
|
+
action TEXT NOT NULL DEFAULT '{}',
|
|
202
|
+
created_at TEXT NOT NULL,
|
|
203
|
+
updated_at TEXT NOT NULL
|
|
204
|
+
);
|
|
205
|
+
|
|
189
206
|
-- OAuth: registered clients (Dynamic Client Registration)
|
|
190
207
|
-- VESTIGIAL after vault 0.4.x workstream E (2026-05-25). The standalone
|
|
191
208
|
-- OAuth issuer that wrote these rows was retired (hub is the issuer now;
|
|
@@ -452,6 +469,11 @@ export function initSchema(db: Database): void {
|
|
|
452
469
|
// version bump. See vault#403 (MGT — manage-token mints hub JWTs).
|
|
453
470
|
migrateToV20(db);
|
|
454
471
|
|
|
472
|
+
// Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
|
|
473
|
+
// webhook triggers). Created by SCHEMA_SQL's CREATE TABLE IF NOT EXISTS
|
|
474
|
+
// above, so this is a defensive confirmation hook for upgrading vaults.
|
|
475
|
+
migrateToV21(db);
|
|
476
|
+
|
|
455
477
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
456
478
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
457
479
|
rebuildIndexes(db);
|
|
@@ -1077,6 +1099,28 @@ function migrateToV20(db: Database): void {
|
|
|
1077
1099
|
);
|
|
1078
1100
|
}
|
|
1079
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Migrate v20 → v21: ensure the `triggers` table exists (runtime per-vault
|
|
1104
|
+
* webhook triggers — vault frictionless-channel-setup PR 1). SCHEMA_SQL's
|
|
1105
|
+
* `CREATE TABLE IF NOT EXISTS` already covers fresh AND upgrading vaults
|
|
1106
|
+
* (it runs unconditionally before the migration steps), so this is a
|
|
1107
|
+
* defensive no-op confirmation for vaults created before v21. The reserved
|
|
1108
|
+
* keyword `when` is quoted in the column definition. Idempotent.
|
|
1109
|
+
*/
|
|
1110
|
+
function migrateToV21(db: Database): void {
|
|
1111
|
+
if (hasTable(db, "triggers")) return;
|
|
1112
|
+
db.exec(`
|
|
1113
|
+
CREATE TABLE IF NOT EXISTS triggers (
|
|
1114
|
+
name TEXT PRIMARY KEY,
|
|
1115
|
+
events TEXT NOT NULL DEFAULT '[]',
|
|
1116
|
+
"when" TEXT NOT NULL DEFAULT '{}',
|
|
1117
|
+
action TEXT NOT NULL DEFAULT '{}',
|
|
1118
|
+
created_at TEXT NOT NULL,
|
|
1119
|
+
updated_at TEXT NOT NULL
|
|
1120
|
+
)
|
|
1121
|
+
`);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1080
1124
|
function hasTable(db: Database, name: string): boolean {
|
|
1081
1125
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1082
1126
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -15,10 +15,12 @@ import { pathTitle } from "./paths.js";
|
|
|
15
15
|
import { HookRegistry } from "./hooks.js";
|
|
16
16
|
import {
|
|
17
17
|
loadTagHierarchy,
|
|
18
|
-
|
|
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
|
}
|
|
@@ -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
|
+
});
|