@openparachute/vault 0.7.6 → 0.7.7

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 (54) hide show
  1. package/README.md +16 -16
  2. package/core/src/attachment/policy.test.ts +7 -0
  3. package/core/src/attachment/policy.ts +9 -0
  4. package/core/src/attachment-tickets-tool.test.ts +15 -0
  5. package/core/src/conformance.test.ts +78 -0
  6. package/core/src/conformance.ts +34 -5
  7. package/core/src/connection-pragmas.test.ts +27 -1
  8. package/core/src/core.test.ts +88 -3
  9. package/core/src/cursor.ts +2 -0
  10. package/core/src/do-param-cap.test.ts +167 -0
  11. package/core/src/lede.test.ts +60 -0
  12. package/core/src/mcp-manifest.ts +15 -1
  13. package/core/src/mcp.ts +37 -5
  14. package/core/src/notes.ts +120 -48
  15. package/core/src/query-operators.ts +87 -5
  16. package/core/src/query-warnings.ts +11 -3
  17. package/core/src/schema.ts +32 -0
  18. package/core/src/seed-packs.ts +74 -5
  19. package/core/src/sql-in.ts +32 -2
  20. package/core/src/store.ts +16 -49
  21. package/core/src/test-preload.ts +48 -3
  22. package/core/src/types.ts +13 -1
  23. package/core/src/wikilinks.test.ts +57 -0
  24. package/core/src/wikilinks.ts +63 -29
  25. package/package.json +1 -1
  26. package/src/attachment-tickets.test.ts +62 -0
  27. package/src/attachment-tickets.ts +2 -2
  28. package/src/cli.ts +36 -8
  29. package/src/config.ts +34 -1
  30. package/src/contract-honest-queries.test.ts +33 -1
  31. package/src/contract-search.test.ts +47 -0
  32. package/src/embedding/select.ts +16 -3
  33. package/src/live-match.test.ts +8 -0
  34. package/src/live-match.ts +15 -0
  35. package/src/mcp-http.test.ts +12 -0
  36. package/src/mcp-http.ts +1 -0
  37. package/src/mcp-tools.ts +51 -17
  38. package/src/mirror-routes.test.ts +22 -31
  39. package/src/onboarding-seed.test.ts +68 -0
  40. package/src/routes.ts +88 -25
  41. package/src/routing.test.ts +24 -0
  42. package/src/routing.ts +2 -0
  43. package/src/subscriptions.ts +18 -2
  44. package/src/tag-scope-note-tags.test.ts +476 -0
  45. package/src/tag-scope.ts +73 -5
  46. package/src/test-home-isolation.test.ts +137 -0
  47. package/src/test-support/spawn.ts +12 -0
  48. package/src/transcription/download.test.ts +187 -1
  49. package/src/transcription/download.ts +149 -2
  50. package/src/transcription/install-python.test.ts +23 -2
  51. package/src/transcription/install-python.ts +13 -4
  52. package/src/vault.test.ts +39 -4
  53. package/src/version.test.ts +8 -0
  54. package/src/ws-server.ts +12 -2
package/README.md CHANGED
@@ -949,26 +949,26 @@ Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name: lowerc
949
949
 
950
950
  ### Cloud platforms
951
951
 
952
- #### Renderrecommended (hub-managed, v0.6)
952
+ #### A VPS the tested path
953
953
 
954
- Most users deploy vault via parachute-hub's `/admin/modules` after the
955
- hub itself is on Render. See <https://parachute.computer/deploy/render/>
956
- for the primary v0.6 self-host story: one Render Blueprint provisions
957
- hub on a persistent disk, then you install vault (and the other
958
- Parachute modules) from the hub's admin UI. The hub container
959
- supervises vault's process, the shared persistent disk holds vault
960
- state, and module upgrades flow through the admin UI rather than
961
- separate Render redeploys.
954
+ The current self-host story is a **bun-native install on a plain
955
+ Ubuntu/Debian box**: create the box, open a root shell, paste one line.
956
+ See <https://parachute.computer/start/>, which walks it click by click
957
+ for DigitalOcean and works the same on Hetzner, EC2, or a machine in
958
+ your closet. That's the path the project tests and supports.
962
959
 
963
- #### Render standalone vault (advanced)
960
+ The PaaS Blueprints below still work and stay in tree for operators who
961
+ want them, but they're a **legacy** shape — reach for them only if you
962
+ specifically want that platform's ergonomics.
963
+
964
+ #### Render — standalone vault (legacy)
964
965
 
965
966
  The `render.yaml` Blueprint at the repo root deploys vault as its own
966
- Render web service, separate from hub. This is the **advanced path**
967
- useful when you want vault on its own container (separate scaling,
968
- isolated logs, vault-only deploy without a hub) but not what the
969
- typical v0.6 self-host wants. If you're not sure, use the hub-managed
970
- path above. The standalone Blueprint stays in tree because some
971
- operators specifically want this shape (vault#341).
967
+ Render web service, separate from hub useful when you want vault on
968
+ its own container (separate scaling, isolated logs, vault-only deploy
969
+ without a hub). If you're not sure, use the VPS path above. The
970
+ Blueprint stays in tree because some operators specifically want this
971
+ shape (vault#341).
972
972
 
973
973
  Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name:
974
974
  lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it
@@ -4,6 +4,7 @@ import {
4
4
  ATTACHMENT_MIME_TYPES,
5
5
  sanitizeAttachmentExtension,
6
6
  mimeForAttachmentExtension,
7
+ contentTypeForAttachmentPath,
7
8
  } from "./policy.ts";
8
9
 
9
10
  describe("sanitizeAttachmentExtension", () => {
@@ -49,6 +50,12 @@ describe("mimeForAttachmentExtension", () => {
49
50
  expect(mimeForAttachmentExtension(".azw3")).toBe("application/octet-stream");
50
51
  });
51
52
 
53
+ test("contentTypeForAttachmentPath ignores a caller-asserted active mime (vault#617)", () => {
54
+ expect(contentTypeForAttachmentPath("2026-07-17/photo.png")).toBe("image/png");
55
+ expect(contentTypeForAttachmentPath("notes/evil.png")).toBe("image/png");
56
+ expect(contentTypeForAttachmentPath("notes/unknown.azw3")).toBe("application/octet-stream");
57
+ });
58
+
52
59
  test("no MIME entry maps to a browser-active type (INVARIANT)", () => {
53
60
  const activeTypes = new Set([
54
61
  "text/html",
@@ -129,3 +129,12 @@ export function sanitizeAttachmentExtension(filename: string): string {
129
129
  export function mimeForAttachmentExtension(ext: string): string {
130
130
  return ATTACHMENT_MIME_TYPES[ext] ?? "application/octet-stream";
131
131
  }
132
+
133
+ /**
134
+ * Download Content-Type for an attachment path (vault#617).
135
+ * Extension wins; the row's caller-asserted `mime_type` is never consulted.
136
+ * Matches REST `GET /storage/<path>` (`src/routes.ts`).
137
+ */
138
+ export function contentTypeForAttachmentPath(path: string): string {
139
+ return mimeForAttachmentExtension(sanitizeAttachmentExtension(path));
140
+ }
@@ -245,6 +245,21 @@ describe("request-attachment-download — mint", () => {
245
245
  expect(stored.attachmentId).toBe(attachment.id);
246
246
  });
247
247
 
248
+ test("mime_type is derived from the path, not the caller-asserted row mime (vault#617)", async () => {
249
+ const note = await store.createNote("# T\n", { path: "t7-mime" });
250
+ const attachment = await store.addAttachment(note.id, "2026-07-17/photo.png", "text/html", { size: 42 });
251
+ const provider = new FakeTicketProvider();
252
+ const tools = generateMcpTools(store, {
253
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
254
+ });
255
+ const result = await callTool(findTool(tools, "request-attachment-download"), {
256
+ attachment_id: attachment.id,
257
+ });
258
+ expect(result.mime_type).toBe("image/png");
259
+ const ticketId = result.url.split("/tickets/")[1] as string;
260
+ expect(provider.tickets.get(ticketId)!.mimeType).toBe("image/png");
261
+ });
262
+
248
263
  test("missing attachment_id → missing_required_field", async () => {
249
264
  const provider = new FakeTicketProvider();
250
265
  const tools = generateMcpTools(store, {
@@ -99,6 +99,84 @@ describe("countConformanceViolations", () => {
99
99
  expect(r.violating_notes).toBe(5);
100
100
  expect(r.sample.length).toBe(2);
101
101
  });
102
+ describe("past the queryNotes default LIMIT 100 (vault#592)", () => {
103
+ it("counts every note on a >100-note tag, not just the first page", async () => {
104
+ for (let i = 0; i < 137; i++) {
105
+ await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
106
+ }
107
+ const r = countConformanceViolations(db, "task", {
108
+ req: { type: "string", required: true },
109
+ });
110
+ expect(r.total_notes).toBe(137);
111
+ expect(r.violating_notes).toBe(137);
112
+ });
113
+
114
+ it("reports total_notes honestly past the cap with no fields proposed", async () => {
115
+ for (let i = 0; i < 137; i++) {
116
+ await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
117
+ }
118
+ const r = countConformanceViolations(db, "task", {});
119
+ expect(r.total_notes).toBe(137);
120
+ expect(r.violating_notes).toBe(0);
121
+ });
122
+
123
+ it("finds violations that live only past the first 100 notes", async () => {
124
+ // 120 conforming notes first, then 5 violators — under the old LIMIT 100
125
+ // walk the violators were never even looked at.
126
+ for (let i = 0; i < 120; i++) {
127
+ await store.createNote(`ok${i}`, { tags: ["task"], metadata: { req: "here" } });
128
+ }
129
+ for (let i = 0; i < 5; i++) {
130
+ await store.createNote(`bad${i}`, { tags: ["task"], metadata: {} });
131
+ }
132
+ const r = countConformanceViolations(db, "task", {
133
+ req: { type: "string", required: true },
134
+ });
135
+ expect(r.total_notes).toBe(125);
136
+ expect(r.violating_notes).toBe(5);
137
+ });
138
+
139
+ it("counts across a tag hierarchy past the cap (descendants included)", async () => {
140
+ await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
141
+ for (let i = 0; i < 60; i++) {
142
+ await store.createNote(`p${i}`, { tags: ["dev"], metadata: {} });
143
+ }
144
+ for (let i = 0; i < 60; i++) {
145
+ await store.createNote(`c${i}`, { tags: ["dev/log"], metadata: {} });
146
+ }
147
+ const r = countConformanceViolations(db, "dev", {
148
+ req: { type: "string", required: true },
149
+ });
150
+ expect(r.total_notes).toBe(120);
151
+ expect(r.violating_notes).toBe(120);
152
+ });
153
+
154
+ it("counts a note carrying BOTH the tag and a descendant exactly once", async () => {
155
+ await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
156
+ for (let i = 0; i < 105; i++) {
157
+ await store.createNote(`both${i}`, { tags: ["dev", "dev/log"], metadata: {} });
158
+ }
159
+ const r = countConformanceViolations(db, "dev", {
160
+ req: { type: "string", required: true },
161
+ });
162
+ expect(r.total_notes).toBe(105);
163
+ expect(r.violating_notes).toBe(105);
164
+ });
165
+
166
+ it("still caps the sample at sampleLimit while counting the full set", async () => {
167
+ for (let i = 0; i < 137; i++) {
168
+ await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
169
+ }
170
+ const r = countConformanceViolations(
171
+ db,
172
+ "task",
173
+ { req: { type: "string", required: true } },
174
+ { sampleLimit: 3 },
175
+ );
176
+ expect(r.violating_notes).toBe(137);
177
+ expect(r.sample.length).toBe(3);
178
+ });
179
+ });
102
180
  });
103
181
 
104
182
  describe("store.countTagConformance", () => {
@@ -36,7 +36,8 @@ import {
36
36
  } from "./schema-defaults.ts";
37
37
  import type { TagFieldSchema } from "./tag-schemas.ts";
38
38
  import { getTagDescendants, loadTagHierarchy } from "./tag-hierarchy.ts";
39
- import { queryNotes, getNoteTagsForNotes } from "./notes.ts";
39
+ import { getNotes, getNoteTagsForNotes } from "./notes.ts";
40
+ import { chunkForInClause } from "./sql-in.ts";
40
41
 
41
42
  export interface ConformanceViolation {
42
43
  /** Note id of a non-conforming note. */
@@ -131,6 +132,9 @@ function overlayProposedSchema(
131
132
  *
132
133
  * Pure read; no mutation. Returns 0 violating notes when `proposedFields` is
133
134
  * empty (nothing to enforce).
135
+ *
136
+ * The walk is UNBOUNDED — every note carrying the tag or a descendant, not a
137
+ * `queryNotes` page (vault#592). `total_notes` is a real total.
134
138
  */
135
139
  export function countConformanceViolations(
136
140
  db: Database,
@@ -155,10 +159,35 @@ export function countConformanceViolations(
155
159
  const tagSet = Array.from(getTagDescendants(hierarchy, tag));
156
160
  if (tagSet.length === 0) return empty;
157
161
 
158
- // All notes carrying the tag (or a descendant). `tagMatch: "any"` over the
159
- // expanded set — a note on ANY of these tags is in scope. We hydrate tags
160
- // ourselves (batched) so the resolver sees the full ancestor set per note.
161
- const notes = queryNotes(db, { tags: tagSet, tagMatch: "any" });
162
+ // All notes carrying the tag (or a descendant) a note on ANY of these
163
+ // tags is in scope.
164
+ //
165
+ // UNBOUNDED id sweep (vault#592). This used to call
166
+ // `queryNotes(db, { tags: tagSet, tagMatch: "any" })` with no `limit`,
167
+ // which silently inherits that function's default `LIMIT 100`
168
+ // (`notes.ts`) — so on a tag with more than 100 notes both `total_notes`
169
+ // and the violation scan reported the first page only. The operator was
170
+ // told "3 of 100 notes violate this" for a 1,200-note tag, and violations
171
+ // living past note 100 were never even looked at. A conformance count that
172
+ // undercounts is worse than no count: it is the number the operator
173
+ // decides on.
174
+ //
175
+ // Same shape as `SqliteStore.backfillReferenceFieldLinks` (store.ts),
176
+ // which fixed the identical inherited-cap bug on the reference backfill: a
177
+ // direct `note_tags` scan chunked under the IN-param cap, deduped across
178
+ // tags (a note carrying both `dev` and `dev/log` is ONE note), then
179
+ // hydrated in a batch. Still a pure read.
180
+ const idSet = new Set<string>();
181
+ for (const chunk of chunkForInClause(tagSet)) {
182
+ const placeholders = chunk.map(() => "?").join(", ");
183
+ const rows = db
184
+ .prepare(`SELECT DISTINCT note_id FROM note_tags WHERE tag_name IN (${placeholders})`)
185
+ .all(...chunk) as { note_id: string }[];
186
+ for (const r of rows) idSet.add(r.note_id);
187
+ }
188
+ if (idSet.size === 0) return empty;
189
+
190
+ const notes = getNotes(db, Array.from(idSet));
162
191
  if (notes.length === 0) return empty;
163
192
 
164
193
  if (checkedFields.length === 0) {
@@ -13,7 +13,7 @@ import { Database } from "bun:sqlite";
13
13
  import { mkdtempSync, rmSync } from "fs";
14
14
  import { join } from "path";
15
15
  import { tmpdir } from "os";
16
- import { applyConnectionPragmas, initSchema } from "./schema.ts";
16
+ import { applyConnectionPragmas, BUSY_TIMEOUT_MS, initSchema } from "./schema.ts";
17
17
 
18
18
  let dir: string;
19
19
 
@@ -133,6 +133,32 @@ describe("applyConnectionPragmas — :memory: DB", () => {
133
133
  expect(pragma(db, "foreign_keys")).toBe(1);
134
134
  db.close();
135
135
  });
136
+
137
+ it("sets busy_timeout on the non-WAL branch too (vault#527)", () => {
138
+ // SQLITE_BUSY is not WAL-specific — a rollback-journal DB serializes even
139
+ // harder. The timeout must not be gated on the WAL success path.
140
+ const db = new Database(":memory:");
141
+ applyConnectionPragmas(db);
142
+ expect(pragma(db, "busy_timeout")).toBe(BUSY_TIMEOUT_MS);
143
+ db.close();
144
+ });
145
+ });
146
+
147
+ describe("applyConnectionPragmas — busy_timeout (vault#527)", () => {
148
+ it("sets a non-zero busy_timeout so SQLITE_BUSY retries instead of failing instantly", () => {
149
+ const db = new Database(join(dir, "busy.db"));
150
+ applyConnectionPragmas(db);
151
+ expect(pragma(db, "busy_timeout")).toBe(BUSY_TIMEOUT_MS);
152
+ expect(BUSY_TIMEOUT_MS).toBeGreaterThan(0);
153
+ db.close();
154
+ });
155
+
156
+ it("initSchema's open path carries the busy_timeout", () => {
157
+ const db = new Database(join(dir, "busy-init.db"));
158
+ initSchema(db);
159
+ expect(pragma(db, "busy_timeout")).toBe(BUSY_TIMEOUT_MS);
160
+ db.close();
161
+ });
136
162
  });
137
163
 
138
164
  describe("applyConnectionPragmas — WAL-unsupported on-disk filesystem (simulated)", () => {
@@ -2334,6 +2334,25 @@ describe("searchNotes", async () => {
2334
2334
  expect(results[0].tags).toContain("daily");
2335
2335
  });
2336
2336
 
2337
+ it("filters search by excludeTags (vault#647)", async () => {
2338
+ await store.createNote("Flagstaff keep", { tags: ["daily"] });
2339
+ await store.createNote("Flagstaff drop", { tags: ["daily", "archived"] });
2340
+
2341
+ const results = await store.searchNotes("Flagstaff", { tags: ["daily"], excludeTags: ["archived"] });
2342
+ expect(results).toHaveLength(1);
2343
+ expect(results[0].content).toContain("keep");
2344
+ expect(results[0].tags).not.toContain("archived");
2345
+ });
2346
+
2347
+ it("filters search by dateFrom (vault#647)", async () => {
2348
+ await store.createNote("Flagstaff recent", { tags: ["daily"], created_at: "2026-07-01T00:00:00.000Z" });
2349
+ await store.createNote("Flagstaff old", { tags: ["daily"], created_at: "2023-06-01T00:00:00.000Z" });
2350
+
2351
+ const results = await store.searchNotes("Flagstaff", { tags: ["daily"], dateFrom: "2026-06-01" });
2352
+ expect(results).toHaveLength(1);
2353
+ expect(results[0].content).toContain("recent");
2354
+ });
2355
+
2337
2356
  it("returns empty for no match", async () => {
2338
2357
  await store.createNote("Hello world");
2339
2358
  const results = await store.searchNotes("nonexistent");
@@ -4339,7 +4358,38 @@ describe("MCP tools", async () => {
4339
4358
  }) as any;
4340
4359
  const page = Array.isArray(pageResult) ? pageResult : pageResult.notes;
4341
4360
  expect(page).toHaveLength(2);
4342
- expect(pageResult.warnings?.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
4361
+ // vault#601: explicit offset is deliberate paging no truncated warning.
4362
+ expect(pageResult.warnings?.some((w: any) => w.code === "truncated") ?? false).toBe(false);
4363
+ });
4364
+
4365
+ it("query-notes exclude_path_prefix drops matching paths and keeps pathless notes (vault#628)", async () => {
4366
+ await store.createNote("user", { path: "Projects/a" });
4367
+ await store.createNote("sys", { path: ".parachute/notes/settings" });
4368
+ await store.createNote("bare"); // no path
4369
+ const tools = generateMcpTools(store);
4370
+ const query = tools.find((t) => t.name === "query-notes")!;
4371
+ const r = await query.execute({ exclude_path_prefix: ".parachute/", include_content: true }) as any[];
4372
+ const contents = r.map((n: any) => n.content).sort();
4373
+ expect(contents).toEqual(["bare", "user"]);
4374
+ });
4375
+
4376
+ it("query-notes search composes with exclude_path_prefix (vault#628)", async () => {
4377
+ // The MCP search branch enumerates QueryOpts fields rather than
4378
+ // spreading them. After #656, REST search picks up exclude_path_prefix
4379
+ // via ...parsed.queryOpts; omitting the field here is the silent-drop
4380
+ // class this lane kills.
4381
+ await store.createNote("unique-628-search-term keep", { path: "Projects/a" });
4382
+ await store.createNote("unique-628-search-term drop", { path: ".parachute/notes/settings" });
4383
+ const tools = generateMcpTools(store);
4384
+ const query = tools.find((t) => t.name === "query-notes")!;
4385
+ const r = await query.execute({
4386
+ search: "unique-628-search-term",
4387
+ exclude_path_prefix: ".parachute/",
4388
+ include_content: true,
4389
+ }) as any[];
4390
+ const contents = r.map((n: any) => n.content);
4391
+ expect(contents).toContain("unique-628-search-term keep");
4392
+ expect(contents).not.toContain("unique-628-search-term drop");
4343
4393
  });
4344
4394
 
4345
4395
  it("query-notes full-text search works", async () => {
@@ -4411,13 +4461,17 @@ describe("MCP tools", async () => {
4411
4461
  expect(b.metadata).toEqual({ summary: "b" }); // status absent → omitted
4412
4462
  });
4413
4463
 
4414
- it("query-notes include_metadata: string[] with no matching fields returns undefined metadata", async () => {
4464
+ it("query-notes include_metadata: string[] with no matching fields keeps metadata: {} (vault#600)", async () => {
4465
+ // V1.1 invariant: rowToNote always emits `metadata: {}`. The projection
4466
+ // branch used to set `metadata: undefined` when no requested keys
4467
+ // matched, so the key vanished from the wire again.
4415
4468
  await store.createNote("A", { tags: ["no-match-meta"], metadata: { summary: "a" } });
4416
4469
  const tools = generateMcpTools(store);
4417
4470
  const query = tools.find((t) => t.name === "query-notes")!;
4418
4471
  const result = await query.execute({ tag: "no-match-meta", include_metadata: ["nonexistent"] }) as any[];
4419
4472
  expect(result).toHaveLength(1);
4420
- expect(result[0].metadata).toBeUndefined();
4473
+ expect(result[0]).toHaveProperty("metadata", {});
4474
+ expect(JSON.parse(JSON.stringify(result[0]))).toHaveProperty("metadata", {});
4421
4475
  });
4422
4476
 
4423
4477
  it("query-notes near param scopes results to graph neighborhood", async () => {
@@ -5386,6 +5440,37 @@ describe("MCP tools", async () => {
5386
5440
  expect(r[0].content).toBe("a");
5387
5441
  });
5388
5442
 
5443
+ it("query-notes search composes with exclude_tags (vault#647)", async () => {
5444
+ // Pre-fix the FTS branch forwarded only `{tags, limit, expand, mode, sort}`
5445
+ // so exclude_tags was silently dropped: the response looked like a real
5446
+ // result set that simply answered a different question.
5447
+ await store.createNote("widget keep", { tags: ["email"] });
5448
+ await store.createNote("widget drop", { tags: ["email", "urgent"] });
5449
+ const tools = generateMcpTools(store);
5450
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
5451
+ const r = await queryNotes.execute({
5452
+ search: "widget",
5453
+ tag: "email",
5454
+ exclude_tags: "urgent",
5455
+ include_content: true,
5456
+ }) as any[];
5457
+ expect(r.map((n: any) => n.content)).toEqual(["widget keep"]);
5458
+ });
5459
+
5460
+ it("query-notes search composes with date_from (vault#647)", async () => {
5461
+ await store.createNote("widget recent", { tags: ["email"], created_at: "2026-07-01T00:00:00.000Z" });
5462
+ await store.createNote("widget old", { tags: ["email"], created_at: "2023-06-01T00:00:00.000Z" });
5463
+ const tools = generateMcpTools(store);
5464
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
5465
+ const r = await queryNotes.execute({
5466
+ search: "widget",
5467
+ tag: "email",
5468
+ date_from: "2026-06-01",
5469
+ include_content: true,
5470
+ }) as any[];
5471
+ expect(r.map((n: any) => n.content)).toEqual(["widget recent"]);
5472
+ });
5473
+
5389
5474
  it("query-notes routes through store.queryNotes so tag-hierarchy expansion fires", async () => {
5390
5475
  // `voice` and `text` declare "manual" as their parent via the v14
5391
5476
  // tags.parent_names column. A query for `tag: "manual"` should match
@@ -176,6 +176,7 @@ export interface QueryHashInputs {
176
176
  hasBrokenLinks?: boolean;
177
177
  path?: string;
178
178
  pathPrefix?: string;
179
+ excludePathPrefix?: string[];
179
180
  extension?: string | string[];
180
181
  ids?: string[];
181
182
  metadata?: Record<string, unknown>;
@@ -249,6 +250,7 @@ function canonicalize(value: unknown): unknown {
249
250
  const ORDER_IRRELEVANT_STRING_ARRAYS = new Set([
250
251
  "tags",
251
252
  "excludeTags",
253
+ "excludePathPrefix",
252
254
  "ids",
253
255
  "extension",
254
256
  ]);
@@ -133,6 +133,173 @@ describe("id-list query paths stay under the DO 100-bound-param cap", () => {
133
133
  expect(notes.length).toBe(0);
134
134
  });
135
135
 
136
+ describe("metadata in / not_in operators (vault#536)", () => {
137
+ /**
138
+ * `in` / `not_in` used to build `meta_<field> IN (?, ?, …)` — one bound
139
+ * param per user-supplied value, with no chunking and no json_each. It's
140
+ * an embedded filter inside a paginated statement, so chunking isn't
141
+ * available (it would break the shared LIMIT/OFFSET window); the fix is
142
+ * the single-json_each-param shape #535 introduced for `near`.
143
+ */
144
+ beforeEach(async () => {
145
+ await store.upsertTagRecord("doc", {
146
+ fields: { rank: { type: "integer", indexed: true } },
147
+ });
148
+ for (let i = 0; i < 250; i++) {
149
+ await store.updateNote(`n${pad(i)}`, { tags: ["doc"], metadata: { rank: i } });
150
+ }
151
+ });
152
+
153
+ it("a >100-value `in` binds one json_each param, not one per value", async () => {
154
+ const wanted = Array.from({ length: 150 }, (_, i) => i);
155
+ const spy = installBindSpy(db);
156
+ let notes;
157
+ try {
158
+ notes = await store.queryNotes({
159
+ metadata: { rank: { in: wanted } },
160
+ limit: 1000,
161
+ sort: "asc",
162
+ });
163
+ } finally {
164
+ spy.restore();
165
+ }
166
+ expect(notes.length).toBe(150);
167
+ expect(spy.usedJsonEach()).toBe(true);
168
+ expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
169
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
170
+ });
171
+
172
+ it("a >100-value `not_in` binds one json_each param too", async () => {
173
+ const excluded = Array.from({ length: 150 }, (_, i) => i);
174
+ const spy = installBindSpy(db);
175
+ let notes;
176
+ try {
177
+ notes = await store.queryNotes({
178
+ metadata: { rank: { not_in: excluded } },
179
+ limit: 1000,
180
+ sort: "asc",
181
+ });
182
+ } finally {
183
+ spy.restore();
184
+ }
185
+ // 250 notes, 150 excluded → the 100 with rank >= 150.
186
+ expect(notes.length).toBe(100);
187
+ expect(spy.usedJsonEach()).toBe(true);
188
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
189
+ });
190
+
191
+ it("stays under the cap alongside LIMIT/OFFSET pagination params", async () => {
192
+ const wanted = Array.from({ length: 200 }, (_, i) => i);
193
+ const spy = installBindSpy(db);
194
+ let notes;
195
+ try {
196
+ notes = await store.queryNotes({
197
+ metadata: { rank: { in: wanted } },
198
+ limit: 25,
199
+ offset: 10,
200
+ sort: "asc",
201
+ });
202
+ } finally {
203
+ spy.restore();
204
+ }
205
+ expect(notes.length).toBe(25);
206
+ expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
207
+ });
208
+ });
209
+
210
+ // -------------------------------------------------------------------------
211
+ // Cross-type affinity: `in`/`not_in` route the value-set through a
212
+ // `json_each` subquery, which — unlike a placeholder `IN (?, …)` list — does
213
+ // NOT apply the left column's type affinity to each candidate. The fix CASTs
214
+ // the set to the column's declared storage type so numeric-vs-TEXT (and the
215
+ // reverse) matches stay identical to the pre-#536 placeholder-list path
216
+ // (vault#676). These probes send the exact shapes the reviewer flagged and
217
+ // pin the row-sets that a naive json_each would silently narrow.
218
+ // -------------------------------------------------------------------------
219
+ describe("in / not_in preserve cross-type affinity (vault#676)", () => {
220
+ it("string-typed indexed column matches BARE NUMERIC in/not_in values", async () => {
221
+ // `version` is a TEXT-affinity column; the stored values are strings.
222
+ await store.upsertTagRecord("release", {
223
+ fields: { version: { type: "string", indexed: true } },
224
+ });
225
+ await store.updateNote("n00000", { tags: ["release"], metadata: { version: "5" } });
226
+ await store.updateNote("n00001", { tags: ["release"], metadata: { version: "6" } });
227
+ await store.updateNote("n00002", { tags: ["release"], metadata: { version: "7" } });
228
+
229
+ // The reviewer's probe: bare JSON numbers against a string column.
230
+ const inHit = await store.queryNotes({
231
+ metadata: { version: { in: [5, 6] } },
232
+ limit: 100,
233
+ sort: "asc",
234
+ });
235
+ expect(inHit.map((n) => n.id)).toEqual(["n00000", "n00001"]);
236
+
237
+ // Same set as JSON strings must return the SAME rows (the control the
238
+ // placeholder-list path always satisfied).
239
+ const inHitStr = await store.queryNotes({
240
+ metadata: { version: { in: ["5", "6"] } },
241
+ limit: 100,
242
+ sort: "asc",
243
+ });
244
+ expect(inHitStr.map((n) => n.id)).toEqual(inHit.map((n) => n.id));
245
+
246
+ // not_in with bare numbers must exclude the string rows `"5"`/`"6"` and
247
+ // keep `"7"` — the affinity-reconciled mirror of the `in` probe. (Rows
248
+ // with no `version` at all have a NULL column and legitimately match
249
+ // not_in, so assert membership rather than an exact set.)
250
+ const notInHit = await store.queryNotes({
251
+ metadata: { version: { not_in: [5, 6] } },
252
+ limit: 1000,
253
+ sort: "asc",
254
+ });
255
+ const notInIds = new Set(notInHit.map((n) => n.id));
256
+ expect(notInIds.has("n00000")).toBe(false); // version "5" — excluded
257
+ expect(notInIds.has("n00001")).toBe(false); // version "6" — excluded
258
+ expect(notInIds.has("n00002")).toBe(true); // version "7" — kept
259
+
260
+ // The bare-number not_in must exclude exactly the same rows a
261
+ // string-valued not_in does (the placeholder-list contract).
262
+ const notInHitStr = await store.queryNotes({
263
+ metadata: { version: { not_in: ["5", "6"] } },
264
+ limit: 1000,
265
+ sort: "asc",
266
+ });
267
+ expect(notInHitStr.map((n) => n.id)).toEqual(notInHit.map((n) => n.id));
268
+ });
269
+
270
+ it("integer-typed indexed column matches STRING in/not_in values (reverse direction)", async () => {
271
+ // `rank` is an INTEGER-affinity column populated 0..249; string-form
272
+ // membership (e.g. `"5"`) must still match under both shapes.
273
+ await store.upsertTagRecord("doc", {
274
+ fields: { rank: { type: "integer", indexed: true } },
275
+ });
276
+ for (let i = 0; i < 250; i++) {
277
+ await store.updateNote(`n${pad(i)}`, { tags: ["doc"], metadata: { rank: i } });
278
+ }
279
+ const inHit = await store.queryNotes({
280
+ metadata: { rank: { in: ["5", "6"] } },
281
+ limit: 100,
282
+ sort: "asc",
283
+ });
284
+ const inHitNum = await store.queryNotes({
285
+ metadata: { rank: { in: [5, 6] } },
286
+ limit: 100,
287
+ sort: "asc",
288
+ });
289
+ expect(inHit.map((n) => n.id)).toEqual(["n00005", "n00006"]);
290
+ expect(inHit.map((n) => n.id)).toEqual(inHitNum.map((n) => n.id));
291
+
292
+ const notInHit = await store.queryNotes({
293
+ metadata: { rank: { not_in: ["5"] } },
294
+ limit: 1000,
295
+ sort: "asc",
296
+ });
297
+ // 250 rows, only rank 5 excluded.
298
+ expect(notInHit.length).toBe(249);
299
+ expect(notInHit.some((n) => n.id === "n00005")).toBe(false);
300
+ });
301
+ });
302
+
136
303
  describe("export a >100-note vault", () => {
137
304
  let outDir: string;
138
305
  beforeEach(() => { outDir = mkdtempSync(join(tmpdir(), "do-cap-export-")); });