@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.
Files changed (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -13,6 +13,7 @@ const baseInput = {
13
13
  bindHost: "127.0.0.1",
14
14
  port: 1940,
15
15
  mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
16
+ vaultName: "default",
16
17
  };
17
18
 
18
19
  function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
@@ -96,16 +97,70 @@ describe("buildInitSummaryLines", () => {
96
97
  });
97
98
  });
98
99
 
99
- describe("MCP=N + token=N (unreachable)", () => {
100
+ // vault#442: the DEFAULT init path — MCP wired, NO token minted (per-user
101
+ // OAuth). The summary must LEAD with the OAuth connect path, never mint, and
102
+ // never surface the old "no token issued" failure copy.
103
+ describe("MCP=Y + no token (vault#442 OAuth default)", () => {
104
+ const out = lines(true, false, undefined).join("\n");
105
+
106
+ test("leads with the OAuth connect message — no token needed", () => {
107
+ expect(out).toContain("no token needed, you'll sign in on first use");
108
+ });
109
+
110
+ test("tells the user Claude Code is already wired in", () => {
111
+ expect(out).toContain("Claude Code is already wired in");
112
+ });
113
+
114
+ test("shows the OAuth `claude mcp add` command for other clients", () => {
115
+ expect(out).toContain(
116
+ "claude mcp add --transport http parachute-vault http://127.0.0.1:1940/vault/default/mcp",
117
+ );
118
+ });
119
+
120
+ test("offers the scope-narrow opt-in mint for scripts (full vault:<name>:read, never admin)", () => {
121
+ // Must be the three-segment named-resource form the hub mint-token model
122
+ // requires — a bare `vault:read` would mint a malformed scope (vault#443).
123
+ expect(out).toContain("parachute auth mint-token --scope vault:default:read");
124
+ expect(out).not.toContain("--scope vault:read ");
125
+ expect(out).not.toMatch(/--scope vault:read$/m);
126
+ expect(out).not.toContain("vault:admin");
127
+ });
128
+
129
+ test("does NOT print or imply any minted token", () => {
130
+ expect(out).not.toContain("Your API token:");
131
+ expect(out).not.toContain("Baked into ~/.claude.json");
132
+ expect(out).not.toContain("Authorization: Bearer");
133
+ });
134
+
135
+ test("does NOT surface the old no-token-issued failure copy", () => {
136
+ expect(out).not.toContain("No token issued");
137
+ });
138
+
139
+ test("threads a non-default vault name into the mint-token scope", () => {
140
+ const out2 = buildInitSummaryLines({
141
+ ...baseInput,
142
+ vaultName: "journal",
143
+ mcpUrl: "http://127.0.0.1:1940/vault/journal/mcp",
144
+ addMcp: true,
145
+ addToken: false,
146
+ apiKey: undefined,
147
+ }).join("\n");
148
+ expect(out2).toContain("parachute auth mint-token --scope vault:journal:read");
149
+ expect(out2).not.toContain("vault:default:read");
150
+ });
151
+ });
152
+
153
+ describe("MCP=N + token=N (OAuth default, Claude Code not wired)", () => {
100
154
  const out = lines(false, false, undefined).join("\n");
101
155
 
102
- test("warns the vault is unreachable", () => {
103
- expect(out).toContain("your vault isn't reachable by any client");
156
+ test("frames skipping the MCP entry as OAuth-first, not 'unreachable'", () => {
157
+ expect(out).toContain("uses per-user OAuth, no token needed");
158
+ expect(out).not.toContain("your vault isn't reachable by any client");
104
159
  });
105
160
 
106
- test("points to the mcp-install recovery path (hub JWT)", () => {
161
+ test("points to mcp-install (no token-minting framing)", () => {
107
162
  expect(out).toContain("parachute-vault mcp-install");
108
- expect(out).toContain("mints a hub JWT");
163
+ expect(out).not.toContain("mints a hub JWT");
109
164
  });
110
165
 
111
166
  test("does not print any token", () => {
@@ -118,6 +173,66 @@ describe("buildInitSummaryLines", () => {
118
173
  });
119
174
  });
120
175
 
176
+ // Explicit opt-in but no hub reachable to mint (vault#282 Stage 2 path,
177
+ // reached only when the operator passes --token without a hub).
178
+ describe("MCP=N + token=Y but no hub (opt-in mint failed, standalone)", () => {
179
+ const out = buildInitSummaryLines({
180
+ ...baseInput,
181
+ addMcp: false,
182
+ addToken: true,
183
+ apiKey: undefined,
184
+ noTokenGuidance: "No token issued — hub unreachable.",
185
+ hubPresent: false,
186
+ }).join("\n");
187
+
188
+ test("surfaces the no-token-issued guidance + recovery", () => {
189
+ expect(out).toContain("No token issued");
190
+ expect(out).toContain("parachute-vault mcp-install");
191
+ });
192
+
193
+ test("standalone framing — points at bringing a hub up / VAULT_AUTH_TOKEN", () => {
194
+ expect(out).toContain("Once a hub is running");
195
+ expect(out).toContain("VAULT_AUTH_TOKEN");
196
+ });
197
+
198
+ test("does NOT claim the vault is reachable (no hub present)", () => {
199
+ expect(out).not.toContain("Your vault is still reachable");
200
+ });
201
+ });
202
+
203
+ // #445: opted into a token, none minted, but a HUB IS PRESENT. The vault is
204
+ // reachable via the hub's browser OAuth flow even with no header-auth token,
205
+ // so the standalone "isn't reachable" framing would be false here.
206
+ describe("MCP=N + token=Y, no token minted, but hub present (#445)", () => {
207
+ const out = buildInitSummaryLines({
208
+ ...baseInput,
209
+ addMcp: false,
210
+ addToken: true,
211
+ apiKey: undefined,
212
+ noTokenGuidance: "No token yet — the hub's admin wizard mints it.",
213
+ hubPresent: true,
214
+ }).join("\n");
215
+
216
+ test("affirms the vault is still reachable via the hub's OAuth flow", () => {
217
+ expect(out).toContain("Your vault is still reachable");
218
+ expect(out).toContain("sign-in (OAuth)");
219
+ });
220
+
221
+ test("frames a header-auth token as optional (scripts / non-OAuth clients)", () => {
222
+ expect(out).toContain("only needed for scripts");
223
+ expect(out).toContain("parachute-vault mcp-install");
224
+ });
225
+
226
+ test("does NOT print the standalone 'Once a hub is running' / VAULT_AUTH_TOKEN copy", () => {
227
+ expect(out).not.toContain("Once a hub is running");
228
+ expect(out).not.toContain("VAULT_AUTH_TOKEN");
229
+ });
230
+
231
+ test("never claims the vault isn't reachable by any client", () => {
232
+ expect(out).not.toContain("isn't reachable by any client");
233
+ });
234
+ });
235
+
121
236
  test("always prints Config: and Server: lines", () => {
122
237
  for (const [addMcp, addToken] of [
123
238
  [true, true],
@@ -12,6 +12,13 @@ export type InitSummaryInput = {
12
12
  bindHost: string;
13
13
  port: number;
14
14
  mcpUrl: string;
15
+ /**
16
+ * The default vault's name — used to emit the three-segment
17
+ * `vault:<vaultName>:read` scope in the OAuth-first mint-token suggestion
18
+ * (the hub mint-token model requires the named-resource form;
19
+ * a bare `vault:read` would mint a malformed scope). vault#442/#443.
20
+ */
21
+ vaultName: string;
15
22
  /**
16
23
  * Guidance from the bootstrap-credential step when no token could be issued
17
24
  * (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
@@ -19,66 +26,101 @@ export type InitSummaryInput = {
19
26
  * undefined, so they know why and how to make the vault reachable.
20
27
  */
21
28
  noTokenGuidance?: string | undefined;
29
+ /**
30
+ * Whether a hub is present on this host (live `/health` probe or a
31
+ * configured hub origin — see `detectHubPresence`). Branches the
32
+ * opted-into-a-token-but-none-minted copy: under a hub the vault is reachable
33
+ * via the hub's browser OAuth flow even with no header-auth token, so the
34
+ * old "your vault isn't reachable by any client" framing is false. #445.
35
+ * Undefined → treat as the conservative standalone case.
36
+ */
37
+ hubPresent?: boolean | undefined;
22
38
  };
23
39
 
24
40
  /**
25
41
  * Build the post-install summary lines for `vault init`, branched on the
26
- * (addMcp, addToken, apiKey) decision matrix. Post-0.6.0 the token is a
27
- * hub-issued JWT minted via operator.token; when no hub is reachable `apiKey`
28
- * is undefined even though the operator opted in (`addToken`/`addMcp`):
42
+ * (addMcp, addToken, apiKey) decision matrix.
43
+ *
44
+ * vault#442: the DEFAULT is per-user OAuth no token is minted, and the
45
+ * Claude Code MCP entry is written without a baked bearer (browser sign-in on
46
+ * first connect). A token is minted only on explicit opt-in (`addToken`), and
47
+ * then scope-narrow. Branches:
29
48
  *
30
- * addMcp, addToken, apiKey token baked into claude.json + printed
31
- * addMcp, !addToken, apiKey → token baked into claude.json, hint
32
- * !addMcp, addToken, apiKey → token printed prominently
33
- * wanted-token-but-no-hub guidance: no token issued, recovery paths
34
- * !addMcp, !addToken warning: vault unreachable; recovery paths
49
+ * addMcp, !apiKey OAuth-first: connect, sign in on first use
50
+ * addMcp, addToken, apiKey → token baked into claude.json + printed
51
+ * addMcp, !addToken, apiKey → token baked into claude.json, hint
52
+ * !addMcp, addToken, apiKey → token printed prominently
53
+ * !addMcp, addToken, !apiKey opted into a token but no hub reachable
54
+ * !addMcp, !addToken → OAuth-first: add Claude Code later
35
55
  */
36
56
  export function buildInitSummaryLines(input: InitSummaryInput): string[] {
37
- const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
57
+ const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance, hubPresent } = input;
38
58
  const lines: string[] = [];
39
59
  lines.push("");
40
60
  lines.push("---");
41
61
 
42
- const wantedToken = addMcp || addToken;
43
-
44
- if (addMcp && addToken && apiKey) {
62
+ if (addMcp && apiKey && addToken) {
45
63
  lines.push("");
46
64
  lines.push(`Your API token: ${apiKey}`);
47
65
  lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
48
66
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
49
67
  lines.push(` - Won't be shown again — save it now.`);
50
- } else if (addMcp && !addToken && apiKey) {
68
+ } else if (addMcp && apiKey && !addToken) {
51
69
  lines.push("");
52
70
  lines.push(
53
71
  "Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
54
72
  );
73
+ } else if (addMcp && !apiKey) {
74
+ // vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
75
+ // Claude Code signs in via browser OAuth on first connect. No token needed.
76
+ lines.push("");
77
+ lines.push("Connect your AI — no token needed, you'll sign in on first use:");
78
+ lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
79
+ lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
80
+ lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
55
81
  } else if (!addMcp && addToken && apiKey) {
56
82
  lines.push("");
57
83
  lines.push(`Your API token: ${apiKey}`);
58
84
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
59
85
  lines.push(` - Won't be shown again — save it now.`);
60
- } else if (wantedToken && !apiKey) {
61
- // Opted into a token but no hub was reachable to mint one (vault#282
62
- // Stage 2 — vault no longer mints local pvt_* tokens). Surface why and
63
- // the recovery paths.
86
+ } else if (!addMcp && addToken && !apiKey) {
87
+ // Explicitly opted into a token but none was minted (vault#282 Stage 2 —
88
+ // vault no longer mints local pvt_* tokens). Surface why + recovery.
64
89
  lines.push("");
65
90
  lines.push(
66
91
  noTokenGuidance ??
67
92
  "No token issued — no hub was reachable to mint a hub JWT.",
68
93
  );
69
- lines.push(
70
- " Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
71
- );
72
- lines.push(
73
- " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
74
- );
94
+ if (hubPresent) {
95
+ // A hub IS present the vault is already reachable via the hub's
96
+ // browser OAuth flow / web UI. A header-auth token is optional, only for
97
+ // non-OAuth clients + scripts. The "isn't reachable" framing is false
98
+ // here (#445).
99
+ lines.push(
100
+ " Your vault is still reachable — clients connect through the hub's browser",
101
+ );
102
+ lines.push(
103
+ " sign-in (OAuth); a header-auth token is only needed for scripts / non-OAuth",
104
+ );
105
+ lines.push(
106
+ " clients. Run `parachute-vault mcp-install` to mint + wire one when you want it.",
107
+ );
108
+ } else {
109
+ lines.push(
110
+ " Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
111
+ );
112
+ lines.push(
113
+ " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
114
+ );
115
+ }
75
116
  } else if (!addMcp && !addToken) {
117
+ // OAuth-first, but the operator skipped wiring Claude Code too.
76
118
  lines.push("");
77
119
  lines.push(
78
- "You've skipped both MCP install and token generationyour vault isn't reachable by any client.",
120
+ "Skipped the Claude Code MCP entry. Add it anytimeit uses per-user OAuth, no token needed:",
79
121
  );
80
122
  lines.push(
81
- " Add Claude Code later with `parachute-vault mcp-install`, which mints a hub JWT (needs a hub running).",
123
+ " parachute-vault mcp-install",
82
124
  );
83
125
  }
84
126
 
@@ -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
+ });