@openparachute/vault 0.5.3-rc.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
@@ -118,4 +118,55 @@ describe("shouldAutoTranscribe", () => {
118
118
  enabledOverride: false,
119
119
  })).toBe(false);
120
120
  });
121
+
122
+ describe("per-vault precedence (per-vault → global → true)", () => {
123
+ test("per-vault true wins even when global is false", () => {
124
+ expect(shouldAutoTranscribe("audio/wav", {
125
+ readGlobalConfigImpl: readGlobalConfig(false),
126
+ getCachedScribeUrlImpl: scribePresent,
127
+ perVaultEnabled: true,
128
+ })).toBe(true);
129
+ });
130
+
131
+ test("per-vault false wins even when global is true", () => {
132
+ // The whole point: linking scribe to vault X (perVault true) elsewhere
133
+ // must not force-on a vault that set its own false.
134
+ expect(shouldAutoTranscribe("audio/wav", {
135
+ readGlobalConfigImpl: readGlobalConfig(true),
136
+ getCachedScribeUrlImpl: scribePresent,
137
+ perVaultEnabled: false,
138
+ })).toBe(false);
139
+ });
140
+
141
+ test("per-vault unset falls back to global", () => {
142
+ expect(shouldAutoTranscribe("audio/wav", {
143
+ readGlobalConfigImpl: readGlobalConfig(true),
144
+ getCachedScribeUrlImpl: scribePresent,
145
+ perVaultEnabled: undefined,
146
+ })).toBe(true);
147
+ expect(shouldAutoTranscribe("audio/wav", {
148
+ readGlobalConfigImpl: readGlobalConfig(false),
149
+ getCachedScribeUrlImpl: scribePresent,
150
+ perVaultEnabled: undefined,
151
+ })).toBe(false);
152
+ });
153
+
154
+ test("both per-vault and global unset falls back to true (no regression)", () => {
155
+ expect(shouldAutoTranscribe("audio/wav", {
156
+ readGlobalConfigImpl: readGlobalConfig(undefined),
157
+ getCachedScribeUrlImpl: scribePresent,
158
+ perVaultEnabled: undefined,
159
+ })).toBe(true);
160
+ });
161
+
162
+ test("enabledOverride still hard-overrides the per-vault value", () => {
163
+ // The explicit caller-opt-in path beats everything.
164
+ expect(shouldAutoTranscribe("audio/wav", {
165
+ readGlobalConfigImpl: readGlobalConfig(true),
166
+ getCachedScribeUrlImpl: scribePresent,
167
+ perVaultEnabled: false,
168
+ enabledOverride: true,
169
+ })).toBe(true);
170
+ });
171
+ });
121
172
  });
@@ -19,11 +19,18 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
19
19
  *
20
20
  * Returns `true` only when ALL three conditions hold:
21
21
  * 1. mime-type starts with `audio/` (case-insensitive).
22
- * 2. `globalConfig.auto_transcribe?.enabled` is not explicitly false.
23
- * Default behavior (when unset) is ON — once an operator has scribe
24
- * reachable, audio attachments transcribe automatically without a
25
- * separate config step. Operators who want it OFF set
26
- * `auto_transcribe.enabled: false` explicitly.
22
+ * 2. The resolved auto-transcribe toggle is not `false`. Resolution is
23
+ * **per-vault global true**:
24
+ * - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
25
+ * wins when set this is what makes scribe's "link to vault X" affect
26
+ * only X, not the whole server.
27
+ * - else the server-wide `globalConfig.auto_transcribe?.enabled`.
28
+ * - else `true` (default ON — once scribe is reachable, audio
29
+ * transcribes without a separate config step). Operators who want it
30
+ * OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
31
+ * globally).
32
+ * `enabledOverride`, when present, hard-overrides the whole chain (used
33
+ * by the explicit caller-opt-in path).
27
34
  * 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
28
35
  *
29
36
  * The three conditions are independent guards: a single `false` is sufficient
@@ -35,7 +42,17 @@ export function shouldAutoTranscribe(
35
42
  /** Injection seam for tests — defaults to live globals. */
36
43
  readGlobalConfigImpl?: typeof readGlobalConfig;
37
44
  getCachedScribeUrlImpl?: () => string | undefined;
38
- /** Allow per-call enabled override — used by the explicit-opt-in path. */
45
+ /**
46
+ * The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
47
+ * Takes precedence over the global toggle when set, so enabling/disabling
48
+ * one vault doesn't move the rest. `undefined` (the vault left it unset)
49
+ * falls through to the global toggle.
50
+ */
51
+ perVaultEnabled?: boolean;
52
+ /**
53
+ * Hard override of the entire per-vault→global→true chain. Used by the
54
+ * explicit caller-opt-in path; not part of the normal precedence ladder.
55
+ */
39
56
  enabledOverride?: boolean;
40
57
  } = {},
41
58
  ): boolean {
@@ -43,6 +60,7 @@ export function shouldAutoTranscribe(
43
60
  return false;
44
61
  }
45
62
  const enabled = opts.enabledOverride
63
+ ?? opts.perVaultEnabled
46
64
  ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
47
65
  ?? true;
48
66
  if (!enabled) return false;
package/src/cli.ts CHANGED
@@ -933,21 +933,16 @@ async function cmdCreate(args: string[]) {
933
933
  process.exit(1);
934
934
  }
935
935
 
936
- // Lowercase-only (security review multi-user hardening). An uppercase
937
- // vault name flips the audience case (`vault.<Name>` vs `vault.<name>`)
938
- // and drifts from hub-side / init-path lowercasing, breaking JWT
939
- // audience matching. `init` already enforces lowercase via
940
- // `validateVaultName`; mirror that rule here so uppercase can't enter
941
- // through `create` either.
942
- if (!/^[a-z0-9_-]+$/.test(name)) {
943
- console.error("Vault name must be lowercase alphanumeric with hyphens or underscores (no uppercase).");
944
- process.exit(1);
945
- }
946
- if (name === "list") {
947
- // Reserved — keeps the "list" vault name out of play even though per-vault
948
- // routes now live under /vault/<name>/ and no longer collide with the
949
- // /vaults/list discovery endpoint.
950
- console.error(`"list" is a reserved vault name.`);
936
+ // One validator for every name-minting edge (2026-06-09 hub-module-boundary
937
+ // migration B2). cmdCreate used to carry its own inline charset check plus a
938
+ // hardcoded `"list"` reservation that had drifted from `validateVaultName`'s
939
+ // set a vault named `admin`/`new`/`assets` could enter through `create`
940
+ // and capture a reserved route (`/vault/admin` is the daemon-level admin
941
+ // mount as of B3). Consuming the shared validator also picks up its 2–32
942
+ // length rule, aligning `create` with `init`, the env var, and hub's wizard.
943
+ const nameValidation = validateVaultName(name);
944
+ if (!nameValidation.ok) {
945
+ console.error(nameValidation.error);
951
946
  process.exit(1);
952
947
  }
953
948
 
@@ -1575,20 +1570,52 @@ function cmdRemove(args: string[]) {
1575
1570
  // Keep default_vault in sync. If the removed vault was the default, either
1576
1571
  // promote the remaining vault (if exactly one) or clear the setting.
1577
1572
  const globalConfig = readGlobalConfig();
1573
+ const remaining = listVaults();
1574
+ let configDirty = false;
1578
1575
  if (globalConfig.default_vault === name) {
1579
- const remaining = listVaults();
1580
1576
  if (remaining.length === 1) {
1581
1577
  globalConfig.default_vault = remaining[0];
1582
- writeGlobalConfig(globalConfig);
1583
1578
  console.log(` Default vault is now "${remaining[0]}".`);
1584
1579
  } else {
1585
1580
  delete globalConfig.default_vault;
1586
- writeGlobalConfig(globalConfig);
1587
1581
  if (remaining.length > 1) {
1588
1582
  console.log(` Cleared default_vault — set one with: editor ${CONFIG_DIR}/config.yaml`);
1589
1583
  }
1590
1584
  }
1585
+ configDirty = true;
1586
+ }
1587
+
1588
+ // Last-vault marker (2026-06-09 hub-module-boundary migration, B1's
1589
+ // CLI-side improvement). Server boot auto-creates `default` when zero
1590
+ // vaults exist — without the marker, an operator who explicitly emptied
1591
+ // the server would find a freshly-credentialed `default` resurrected on
1592
+ // the next restart. Fresh installs never carry the marker (no config.yaml
1593
+ // at all), so Docker / hub-install first-run auto-create is preserved.
1594
+ if (remaining.length === 0 && globalConfig.auto_create !== false) {
1595
+ globalConfig.auto_create = false;
1596
+ configDirty = true;
1597
+ console.log(
1598
+ ` Last vault removed — wrote auto_create: false to ${GLOBAL_CONFIG_PATH} so the` +
1599
+ ` server won't auto-recreate "default" on next boot. Create a vault with:` +
1600
+ ` parachute-vault create <name>`,
1601
+ );
1591
1602
  }
1603
+ if (configDirty) writeGlobalConfig(globalConfig);
1604
+
1605
+ // Refresh services.json so the removed vault's /vault/<name> path drops
1606
+ // out of the parachute-vault row immediately — the same selfRegister
1607
+ // refresh cmdCreate does (#208). Without this, the hub's well-known
1608
+ // fan-out kept advertising the deleted vault until the next server boot.
1609
+ // Note: with zero vaults remaining, selfRegister falls back to the
1610
+ // manifest's canonical paths (`/vault/default`) — the same row a
1611
+ // subsequent boot would write — so CLI-remove and boot agree on the
1612
+ // zero-vault registration shape. Warnings go to stderr; status lines stay
1613
+ // ours.
1614
+ selfRegister({
1615
+ version: pkg.version,
1616
+ warn: (msg) => console.error(`Warning: ${msg}`),
1617
+ log: () => {},
1618
+ });
1592
1619
  }
1593
1620
 
1594
1621
  async function cmdConfig(args: string[]) {
@@ -169,6 +169,33 @@ describe("config", () => {
169
169
  expect(loaded!.transcription).toBeUndefined();
170
170
  });
171
171
 
172
+ test("round-trips per-vault auto_transcribe.enabled (true + false)", () => {
173
+ // The PATCH /api/vault handler persists the per-vault toggle via
174
+ // writeVaultConfig; confirm it survives a read-back — this is the exact
175
+ // field shouldAutoTranscribe reads per-vault (per-vault → global → true).
176
+ const base: VaultConfig = {
177
+ name: "testvault",
178
+ api_keys: [],
179
+ created_at: "2026-01-01T00:00:00.000Z",
180
+ };
181
+
182
+ writeVaultConfig({ ...base, auto_transcribe: { enabled: true } });
183
+ expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(true);
184
+
185
+ writeVaultConfig({ ...base, auto_transcribe: { enabled: false } });
186
+ expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(false);
187
+ });
188
+
189
+ test("vault config without auto_transcribe loads as undefined (falls back to global)", () => {
190
+ const config: VaultConfig = {
191
+ name: "testvault",
192
+ api_keys: [],
193
+ created_at: "2026-01-01T00:00:00.000Z",
194
+ };
195
+ writeVaultConfig(config);
196
+ expect(readVaultConfig("testvault")!.auto_transcribe).toBeUndefined();
197
+ });
198
+
172
199
  test("round-trips discovery: enabled|disabled", () => {
173
200
  // Default: absent means enabled (endpoint serves names).
174
201
  writeGlobalConfig({ port: 1940 });
package/src/config.ts CHANGED
@@ -183,6 +183,23 @@ export interface VaultConfig {
183
183
  transcription?: {
184
184
  context?: TriggerIncludeContext[];
185
185
  };
186
+ /**
187
+ * Per-vault auto-transcribe override (vault#353 follow-up). When set, this
188
+ * vault's value takes precedence over the server-wide
189
+ * `GlobalConfig.auto_transcribe.enabled`. Resolution at the decision point
190
+ * (`shouldAutoTranscribe`) is **per-vault → global → true**: a vault that
191
+ * sets `enabled` here uses it; a vault that leaves it unset falls back to
192
+ * the global toggle, which itself defaults ON.
193
+ *
194
+ * This is what makes scribe's "link to vault X" genuinely per-vault —
195
+ * `PATCH /vault/X/api/vault {auto_transcribe:{enabled:true}}` flips only
196
+ * vault X, never the whole server. URL + bearer are still resolved per-
197
+ * process (services.json / SCRIBE_AUTH_TOKEN); only the on/off toggle is
198
+ * per-vault.
199
+ */
200
+ auto_transcribe?: {
201
+ enabled?: boolean;
202
+ };
186
203
  }
187
204
 
188
205
  // ---------------------------------------------------------------------------
@@ -240,6 +257,20 @@ export interface TriggerAction {
240
257
  * top-level `context` field (send=json). send=content ignores this.
241
258
  */
242
259
  include_context?: TriggerIncludeContext[];
260
+ /**
261
+ * Optional webhook auth. When `auth.bearer` is set, the trigger sends
262
+ * `Authorization: Bearer <bearer>` on the webhook POST — the JWT path that
263
+ * retires the shared `?secret=` query param. Back-compat: a webhook URL
264
+ * carrying its own `?secret=` still works; `auth` is purely additive.
265
+ * Runtime triggers (registered via the /api/triggers REST surface) are the
266
+ * primary users; config.yaml triggers may also carry it.
267
+ */
268
+ auth?: TriggerAuth;
269
+ }
270
+
271
+ export interface TriggerAuth {
272
+ /** Bearer token (typically a hub-issued JWT) for the webhook Authorization header. */
273
+ bearer?: string;
243
274
  }
244
275
 
245
276
  export interface TriggerConfig {
@@ -281,6 +312,19 @@ export interface GlobalConfig {
281
312
  * point their own supervisor at it.
282
313
  */
283
314
  autostart?: boolean;
315
+ /**
316
+ * Boot auto-create marker (2026-06-09 hub-module-boundary migration, the
317
+ * vault wave's `cmdRemove` improvement). Server boot auto-creates a
318
+ * `default` vault when `listVaults()` is empty — the Docker / hub-install
319
+ * first-run path. When the operator EXPLICITLY deletes their last vault
320
+ * via `parachute-vault remove`, that auto-create would silently resurrect
321
+ * a fresh `default` (with fresh credentials) on the next boot. `cmdRemove`
322
+ * writes `auto_create: false` when it removes the last vault; boot skips
323
+ * the auto-create while the marker is present. Fresh installs (no
324
+ * config.yaml at all) never carry the marker, so the Docker first-run
325
+ * behavior is preserved. See `bootAutoCreateAllowed`.
326
+ */
327
+ auto_create?: boolean;
284
328
  /** Backup configuration: schedule, retention, destinations. */
285
329
  backup?: BackupConfig;
286
330
  /**
@@ -430,6 +474,14 @@ function serializeVaultConfig(config: VaultConfig): string {
430
474
  lines.push(`audio_retention: ${config.audio_retention}`);
431
475
  }
432
476
 
477
+ // Per-vault auto-transcribe override. Serialized as a nested block so future
478
+ // fields can grow under it (mirrors the GlobalConfig shape). Only emitted
479
+ // when `enabled` is explicitly set — an unset vault falls back to global.
480
+ if (config.auto_transcribe?.enabled !== undefined) {
481
+ lines.push("auto_transcribe:");
482
+ lines.push(` enabled: ${config.auto_transcribe.enabled}`);
483
+ }
484
+
433
485
  if (config.transcription?.context?.length) {
434
486
  lines.push("transcription:");
435
487
  lines.push(" context:");
@@ -566,6 +618,22 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
566
618
  config.transcription = { context: transcriptionContext };
567
619
  }
568
620
 
621
+ // Parse the per-vault auto_transcribe block — currently single boolean
622
+ // `enabled`. Nested 2-space-indent block (mirrors the GlobalConfig parser)
623
+ // so future fields can grow under it without breaking the regex.
624
+ const autoTranscribeStart = yaml.match(/^auto_transcribe:\s*$/m);
625
+ if (autoTranscribeStart) {
626
+ const after = yaml.slice((autoTranscribeStart.index ?? 0) + autoTranscribeStart[0].length);
627
+ for (const line of after.split("\n")) {
628
+ if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
629
+ const m = line.match(/^\s+enabled:\s*(true|false)/);
630
+ if (m) {
631
+ config.auto_transcribe = { enabled: m[1]! === "true" };
632
+ break;
633
+ }
634
+ }
635
+ }
636
+
569
637
  return config;
570
638
  }
571
639
 
@@ -1203,6 +1271,20 @@ export function migrateVaultInternalLayout(): void {
1203
1271
  // Global config
1204
1272
  // ---------------------------------------------------------------------------
1205
1273
 
1274
+ /**
1275
+ * Whether server boot may auto-create the first vault when none exist.
1276
+ *
1277
+ * Only the explicit `auto_create: false` marker (written by `cmdRemove`
1278
+ * when it deletes the LAST vault) blocks the auto-create. A fresh install
1279
+ * has no config.yaml — `readGlobalConfig()` returns defaults with
1280
+ * `auto_create` unset — so Docker / hub-install first-run still
1281
+ * auto-creates `default`. Pure + exported so the boot gate is testable
1282
+ * without booting a server.
1283
+ */
1284
+ export function bootAutoCreateAllowed(config: Pick<GlobalConfig, "auto_create">): boolean {
1285
+ return config.auto_create !== false;
1286
+ }
1287
+
1206
1288
  export function readGlobalConfig(): GlobalConfig {
1207
1289
  try {
1208
1290
  const gcPath = globalConfigPath();
@@ -1214,6 +1296,7 @@ export function readGlobalConfig(): GlobalConfig {
1214
1296
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1215
1297
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1216
1298
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1299
+ const autoCreateMatch = yaml.match(/^auto_create:\s*(true|false)/m);
1217
1300
  const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
1218
1301
  // auto_transcribe block — currently single boolean `enabled` (vault#353).
1219
1302
  // Parsed as a nested 2-space-indent block so future fields can grow under
@@ -1243,6 +1326,9 @@ export function readGlobalConfig(): GlobalConfig {
1243
1326
  if (autostartMatch) {
1244
1327
  config.autostart = autostartMatch[1]! === "true";
1245
1328
  }
1329
+ if (autoCreateMatch) {
1330
+ config.auto_create = autoCreateMatch[1]! === "true";
1331
+ }
1246
1332
  if (defaultMirrorMatch) {
1247
1333
  config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
1248
1334
  }
@@ -1318,6 +1404,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1318
1404
  if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
1319
1405
  if (config.discovery) lines.push(`discovery: ${config.discovery}`);
1320
1406
  if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
1407
+ if (config.auto_create !== undefined) lines.push(`auto_create: ${config.auto_create}`);
1321
1408
  if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
1322
1409
  if (config.owner_password_hash) {
1323
1410
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Predicate-parity tests for the live matcher (live-query SSE).
3
+ *
4
+ * The load-bearing invariant: for any supported query, the set of notes the
5
+ * snapshot SQL (`store.queryNotes`) returns MUST equal the set the in-process
6
+ * `buildLiveMatcher` accepts over the same corpus. Each `it` seeds a corpus,
7
+ * runs both evaluators for a query shape, and asserts the id-sets are equal.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
11
+ import { Database } from "bun:sqlite";
12
+ import { SqliteStore } from "../core/src/store.ts";
13
+ import { generateMcpTools } from "../core/src/mcp.ts";
14
+ import type { QueryOpts } from "../core/src/types.ts";
15
+ import { buildLiveMatcher } from "./live-match.ts";
16
+
17
+ /**
18
+ * Declare metadata fields as `indexed: true` via the update-tag MCP tool —
19
+ * the only path that populates the `indexed_fields` table + reconciles the
20
+ * generated `meta_<field>` columns the snapshot operator queries need.
21
+ * `store.upsertTagSchema` alone records the schema but NOT the index.
22
+ */
23
+ async function declareIndexed(tag: string, fields: Record<string, { type: string; indexed: boolean }>) {
24
+ const tools = generateMcpTools(store);
25
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
26
+ await updateTag.execute({ tag, fields });
27
+ }
28
+
29
+ let db: Database;
30
+ let store: SqliteStore;
31
+
32
+ beforeEach(() => {
33
+ db = new Database(":memory:");
34
+ store = new SqliteStore(db);
35
+ });
36
+
37
+ afterEach(() => {
38
+ db.close();
39
+ });
40
+
41
+ /** Build the parity assertion: snapshot id-set === live-matcher id-set. */
42
+ async function assertParity(opts: QueryOpts): Promise<Set<string>> {
43
+ const snapshot = await store.queryNotes(opts);
44
+ const snapshotIds = new Set(snapshot.map((n) => n.id));
45
+
46
+ const all = await store.queryNotes({ limit: 100000 });
47
+ const matcher = await buildLiveMatcher(store, opts);
48
+ const liveIds = new Set(all.filter((n) => matcher.match(n)).map((n) => n.id));
49
+
50
+ expect([...liveIds].sort()).toEqual([...snapshotIds].sort());
51
+ return snapshotIds;
52
+ }
53
+
54
+ describe("live-match — predicate parity with the query engine", () => {
55
+ it("tags (single, no hierarchy)", async () => {
56
+ await store.createNote("a", { tags: ["chat"] });
57
+ await store.createNote("b", { tags: ["chat", "x"] });
58
+ await store.createNote("c", { tags: ["other"] });
59
+ await store.createNote("d", {});
60
+ const ids = await assertParity({ tags: ["chat"] });
61
+ expect(ids.size).toBe(2);
62
+ });
63
+
64
+ it("tags 'all' (AND) across multiple tags", async () => {
65
+ await store.createNote("a", { tags: ["chat", "x"] });
66
+ await store.createNote("b", { tags: ["chat"] });
67
+ await store.createNote("c", { tags: ["x"] });
68
+ const ids = await assertParity({ tags: ["chat", "x"], tagMatch: "all" });
69
+ expect(ids.size).toBe(1);
70
+ });
71
+
72
+ it("tags 'any' (OR) across multiple tags", async () => {
73
+ await store.createNote("a", { tags: ["chat"] });
74
+ await store.createNote("b", { tags: ["x"] });
75
+ await store.createNote("c", { tags: ["other"] });
76
+ const ids = await assertParity({ tags: ["chat", "x"], tagMatch: "any" });
77
+ expect(ids.size).toBe(2);
78
+ });
79
+
80
+ it("tags with descendant/hierarchy expansion", async () => {
81
+ // Declare voice as a child of manual via parent_names.
82
+ await store.upsertTagRecord("manual", { description: "manual root" });
83
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
84
+ await store.createNote("root", { tags: ["manual"] });
85
+ await store.createNote("child", { tags: ["voice"] });
86
+ await store.createNote("unrelated", { tags: ["other"] });
87
+ // Query for #manual should match notes tagged #voice (a descendant).
88
+ const ids = await assertParity({ tags: ["manual"] });
89
+ expect(ids.has("nonexistent")).toBe(false);
90
+ // Both root + child match.
91
+ const notes = await store.queryNotes({ tags: ["manual"] });
92
+ expect(notes.length).toBe(2);
93
+ });
94
+
95
+ it("excludeTags (raw, no expansion — mirrors engine)", async () => {
96
+ await store.createNote("a", { tags: ["chat"] });
97
+ await store.createNote("b", { tags: ["chat", "muted"] });
98
+ await assertParity({ tags: ["chat"], excludeTags: ["muted"] });
99
+ });
100
+
101
+ it("path (case-insensitive exact)", async () => {
102
+ await store.createNote("a", { path: "Channels/general" });
103
+ await store.createNote("b", { path: "Channels/random" });
104
+ await assertParity({ path: "channels/general" });
105
+ });
106
+
107
+ it("pathPrefix", async () => {
108
+ await store.createNote("a", { path: "Channels/general" });
109
+ await store.createNote("b", { path: "Channels/random" });
110
+ await store.createNote("c", { path: "Other/thing" });
111
+ const ids = await assertParity({ pathPrefix: "Channels/" });
112
+ expect(ids.size).toBe(2);
113
+ });
114
+
115
+ it("pathPrefix (mixed-case — parity with the engine's CI LIKE) (N1)", async () => {
116
+ await store.createNote("a", { path: "Channels/general" });
117
+ await store.createNote("b", { path: "Channels/random" });
118
+ await store.createNote("c", { path: "Other/thing" });
119
+ // Lower-case prefix must still match the title-case paths, same as the
120
+ // engine's `LIKE 'channels/%'` (ASCII case-insensitive).
121
+ const ids = await assertParity({ pathPrefix: "channels/" });
122
+ expect(ids.size).toBe(2);
123
+ });
124
+
125
+ it("hasTags true/false (M1 — presence parity)", async () => {
126
+ await store.createNote("tagged", { tags: ["x"] });
127
+ await store.createNote("bare", {});
128
+ const has = await assertParity({ hasTags: true });
129
+ expect(has.size).toBe(1);
130
+ const none = await assertParity({ hasTags: false });
131
+ expect(none.size).toBe(1);
132
+ });
133
+
134
+ it("hasTags is ignored when a tag filter is also present (engine parity)", async () => {
135
+ // queryNotes drops hasTags when `tags` is set (the tag filter already
136
+ // constrains to tagged notes); the matcher must mirror that exactly.
137
+ await store.createNote("a", { tags: ["chat"] });
138
+ await store.createNote("b", { tags: ["other"] });
139
+ await assertParity({ tags: ["chat"], hasTags: false });
140
+ });
141
+
142
+ it("extension (default md + explicit)", async () => {
143
+ await store.createNote("md1", { path: "n1" });
144
+ await store.createNote("csv1", { path: "n2", extension: "csv" });
145
+ await assertParity({ extension: "csv" });
146
+ await assertParity({ extension: "md" });
147
+ await assertParity({ extension: ["csv", "yaml"] });
148
+ });
149
+
150
+ describe("metadata operators (indexed field)", () => {
151
+ beforeEach(async () => {
152
+ // Declare `channel` + `count` indexed so the snapshot operator path works.
153
+ await declareIndexed("msg", {
154
+ channel: { type: "string", indexed: true },
155
+ count: { type: "integer", indexed: true },
156
+ });
157
+ await store.createNote("g1", { tags: ["msg"], metadata: { channel: "general", count: 5 } });
158
+ await store.createNote("g2", { tags: ["msg"], metadata: { channel: "general", count: 10 } });
159
+ await store.createNote("r1", { tags: ["msg"], metadata: { channel: "random", count: 1 } });
160
+ await store.createNote("n1", { tags: ["msg"] }); // no channel/count
161
+ });
162
+
163
+ it("eq", async () => {
164
+ const ids = await assertParity({ tags: ["msg"], metadata: { channel: { eq: "general" } } });
165
+ expect(ids.size).toBe(2);
166
+ });
167
+ it("ne (absent field passes)", async () => {
168
+ await assertParity({ tags: ["msg"], metadata: { channel: { ne: "general" } } });
169
+ });
170
+ it("gt / gte / lt / lte", async () => {
171
+ await assertParity({ tags: ["msg"], metadata: { count: { gt: 5 } } });
172
+ await assertParity({ tags: ["msg"], metadata: { count: { gte: 5 } } });
173
+ await assertParity({ tags: ["msg"], metadata: { count: { lt: 5 } } });
174
+ await assertParity({ tags: ["msg"], metadata: { count: { lte: 5 } } });
175
+ });
176
+ it("in / not_in", async () => {
177
+ await assertParity({ tags: ["msg"], metadata: { channel: { in: ["general", "random"] } } });
178
+ await assertParity({ tags: ["msg"], metadata: { channel: { not_in: ["random"] } } });
179
+ });
180
+ it("exists true/false", async () => {
181
+ await assertParity({ tags: ["msg"], metadata: { channel: { exists: true } } });
182
+ await assertParity({ tags: ["msg"], metadata: { channel: { exists: false } } });
183
+ });
184
+ it("combined: tag + metadata operator (the channel case)", async () => {
185
+ const ids = await assertParity({
186
+ tags: ["msg"],
187
+ metadata: { channel: { eq: "general" }, count: { gte: 10 } },
188
+ });
189
+ expect(ids.size).toBe(1);
190
+ });
191
+ });
192
+
193
+ it("metadata primitive exact-match (shorthand)", async () => {
194
+ await store.createNote("a", { tags: ["t"], metadata: { kind: "note" } });
195
+ await store.createNote("b", { tags: ["t"], metadata: { kind: "task" } });
196
+ await assertParity({ tags: ["t"], metadata: { kind: "note" } });
197
+ });
198
+ });