@openparachute/vault 0.7.8 → 0.7.9-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/src/notes.ts CHANGED
@@ -1096,21 +1096,27 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
1096
1096
  params.push(opts.path);
1097
1097
  }
1098
1098
 
1099
- // Path prefix
1099
+ // Path prefix. `escapeLikePattern` neutralizes `%` and `_` inside the
1100
+ // caller-supplied prefix so it matches as a literal string: without it,
1101
+ // `path_prefix=_tags/` silently matched `atags/…` too (`_` is LIKE's
1102
+ // single-char wildcard), returning notes the caller never asked for and
1103
+ // never saw a signal about (vault#659). The trailing `%` we append is
1104
+ // still our actual wildcard. `ESCAPE '\'` is required for the escape to
1105
+ // take effect.
1100
1106
  if (opts.pathPrefix) {
1101
- conditions.push("n.path LIKE ?");
1102
- params.push(opts.pathPrefix + "%");
1107
+ conditions.push("n.path LIKE ? ESCAPE '\\'");
1108
+ params.push(escapeLikePattern(opts.pathPrefix) + "%");
1103
1109
  }
1104
1110
 
1105
1111
  // Path-prefix exclusion (vault#628). Mirrors `pathPrefix` matching
1106
- // (`LIKE prefix || '%'`, SQLite LIKE is ASCII-case-insensitive). NULL
1107
- // paths are kept — they are not under the prefix. Repeatable: a note
1108
- // matching ANY listed prefix is dropped.
1112
+ // (`LIKE prefix || '%'` with the same metachar escaping, SQLite LIKE is
1113
+ // ASCII-case-insensitive). NULL paths are kept — they are not under the
1114
+ // prefix. Repeatable: a note matching ANY listed prefix is dropped.
1109
1115
  if (opts.excludePathPrefix && opts.excludePathPrefix.length > 0) {
1110
1116
  for (const prefix of opts.excludePathPrefix) {
1111
1117
  if (typeof prefix !== "string" || prefix.length === 0) continue;
1112
- conditions.push("(n.path IS NULL OR n.path NOT LIKE ?)");
1113
- params.push(prefix + "%");
1118
+ conditions.push("(n.path IS NULL OR n.path NOT LIKE ? ESCAPE '\\')");
1119
+ params.push(escapeLikePattern(prefix) + "%");
1114
1120
  }
1115
1121
  }
1116
1122
 
package/core/src/types.ts CHANGED
@@ -190,7 +190,8 @@ export interface QueryOpts {
190
190
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
191
191
  /**
192
192
  * Exclude notes whose path matches any of these prefixes. Same matching
193
- * as `pathPrefix` (`n.path LIKE prefix || '%'`, ASCII case-insensitive).
193
+ * as `pathPrefix` (`n.path LIKE prefix || '%' ESCAPE '\'`, ASCII
194
+ * case-insensitive; `%`/`_` in the prefix are escaped to literals).
194
195
  * Repeatable. A note with no path is not excluded (it isn't under the
195
196
  * prefix). vault#628 — `.parachute/` system-space is the first client.
196
197
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.8",
3
+ "version": "0.7.9-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -130,6 +130,23 @@ describe("live-match — predicate parity with the query engine", () => {
130
130
  expect(ids.size).toBe(2);
131
131
  });
132
132
 
133
+ it("pathPrefix with a LIKE metachar matches literally (vault#659)", async () => {
134
+ // `_` is LIKE's single-char wildcard. Unescaped, `LIKE '_tags/%'` also
135
+ // matched `atags/x` in SQL while the matcher's `startsWith` did not —
136
+ // a snapshot/live divergence that also just returned the wrong answer.
137
+ const under = await store.createNote("under", { path: "_tags/x" });
138
+ await store.createNote("decoy", { path: "atags/x" });
139
+ const ids = await assertParity({ pathPrefix: "_tags/" });
140
+ expect([...ids]).toEqual([under.id]);
141
+ });
142
+
143
+ it("excludePathPrefix with a LIKE metachar excludes literally (vault#659)", async () => {
144
+ await store.createNote("under", { path: "_tags/x" });
145
+ const decoy = await store.createNote("decoy", { path: "atags/x" });
146
+ const ids = await assertParity({ excludePathPrefix: ["_tags/"] });
147
+ expect([...ids]).toEqual([decoy.id]);
148
+ });
149
+
133
150
  it("hasTags true/false (M1 — presence parity)", async () => {
134
151
  await store.createNote("tagged", { tags: ["x"] });
135
152
  await store.createNote("bare", {});
package/src/live-match.ts CHANGED
@@ -20,9 +20,13 @@
20
20
  * freezes it for the snapshot query.
21
21
  * - `excludeTags` — raw exact-name match (engine does NOT expand excludes).
22
22
  * - `path` — case-insensitive exact (engine: `n.path = ? COLLATE NOCASE`).
23
- * - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%'`).
23
+ * - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%' ESCAPE '\'`).
24
+ * The engine escapes `%`/`_` in the prefix (vault#659), so the prefix
25
+ * matches literally — exactly what `startsWith` does here. Before that
26
+ * fix the two paths genuinely diverged: `_tags/` matched `atags/…` in
27
+ * SQL and not in the matcher.
24
28
  * - `excludePathPrefix` — NOT those prefixes (engine: `n.path IS NULL OR
25
- * n.path NOT LIKE prefix || '%'`). Repeatable. vault#628.
29
+ * n.path NOT LIKE prefix || '%' ESCAPE '\'`). Repeatable. vault#628.
26
30
  * - `extension` — lower-cased, default "md" (engine: `LOWER(n.extension)`),
27
31
  * a note with no extension is treated as "md".
28
32
  * - `metadata` operator objects (eq/ne/gt/gte/lt/lte/in/not_in/exists) +
package/src/routes.ts CHANGED
@@ -352,9 +352,26 @@ function parseLinkCountDirection(url: URL): "both" | "outbound" | "inbound" {
352
352
  return "both";
353
353
  }
354
354
 
355
+ /**
356
+ * Parse a repeatable, comma-list query param (`tag`, `exclude_tag`,
357
+ * `exclude_path_prefix`). Two accepted shapes, and they compose:
358
+ * - `?tag=a,b` (comma-list)
359
+ * - `?tag=a&tag=b` (repeated param)
360
+ * Both yield `["a", "b"]`. This used to be `searchParams.get()` + split,
361
+ * which silently kept only the FIRST occurrence — a caller passing
362
+ * `?tag=a&tag=b` got results filtered by `a` alone, with no signal that
363
+ * `b` had been dropped (vault#659). Mirrors `parseExtensionFilter` below,
364
+ * with two deliberate differences: this always returns an array (these
365
+ * callers take `string[]`), and it does NOT trim — `exclude_path_prefix`
366
+ * is a path fragment, where surrounding whitespace is data, not noise.
367
+ * Returns undefined when absent so the queryNotes filter is skipped.
368
+ */
355
369
  function parseQueryList(url: URL, key: string): string[] | undefined {
356
- const val = url.searchParams.get(key);
357
- return val ? val.split(",") : undefined;
370
+ const all = url.searchParams.getAll(key);
371
+ if (all.length === 0) return undefined;
372
+ // Flatten comma-lists inside each param.
373
+ const flat = all.flatMap((v) => v.split(",")).filter((s) => s.length > 0);
374
+ return flat.length > 0 ? flat : undefined;
358
375
  }
359
376
 
360
377
  /**
package/src/vault.test.ts CHANGED
@@ -2523,6 +2523,79 @@ describe("HTTP /notes", async () => {
2523
2523
  expect(body.map((n) => n.content).sort()).toEqual(["bare", "user"]);
2524
2524
  });
2525
2525
 
2526
+ // vault#659 — repeated query params used to take the first occurrence
2527
+ // only (`searchParams.get()` + comma-split), so `?tag=a&tag=b` silently
2528
+ // filtered by `a` alone. Both forms accumulate now, and they compose.
2529
+ test("GET /notes?tag=a&tag=b accumulates repeated params (vault#659)", async () => {
2530
+ await store.createNote("only-a", { tags: ["ta"] });
2531
+ await store.createNote("only-b", { tags: ["tb"] });
2532
+ await store.createNote("neither", { tags: ["tc"] });
2533
+ const res = await handleNotes(
2534
+ mkReq("GET", "/notes?tag=ta&tag=tb&include_content=true"),
2535
+ store,
2536
+ "",
2537
+ );
2538
+ expect(res.status).toBe(200);
2539
+ const body = await res.json() as any[];
2540
+ // >1 tag without an explicit tag_match defaults to "any".
2541
+ expect(body.map((n) => n.content).sort()).toEqual(["only-a", "only-b"]);
2542
+ });
2543
+
2544
+ test("GET /notes?tag=a,b&tag=c mixes comma-list and repeated params (vault#659)", async () => {
2545
+ await store.createNote("only-a", { tags: ["ma"] });
2546
+ await store.createNote("only-b", { tags: ["mb"] });
2547
+ await store.createNote("only-c", { tags: ["mc"] });
2548
+ await store.createNote("neither", { tags: ["md"] });
2549
+ const res = await handleNotes(
2550
+ mkReq("GET", "/notes?tag=ma,mb&tag=mc&include_content=true"),
2551
+ store,
2552
+ "",
2553
+ );
2554
+ const body = await res.json() as any[];
2555
+ expect(body.map((n) => n.content).sort()).toEqual(["only-a", "only-b", "only-c"]);
2556
+ });
2557
+
2558
+ test("GET /notes?exclude_path_prefix repeated params accumulate (vault#659)", async () => {
2559
+ await store.createNote("user", { path: "Projects/a" });
2560
+ await store.createNote("sys", { path: ".parachute/notes/settings" });
2561
+ await store.createNote("tmp", { path: "Scratch/x" });
2562
+ const res = await handleNotes(
2563
+ mkReq("GET", "/notes?exclude_path_prefix=.parachute/&exclude_path_prefix=Scratch/&include_content=true"),
2564
+ store,
2565
+ "",
2566
+ );
2567
+ const body = await res.json() as any[];
2568
+ expect(body.map((n) => n.content)).toEqual(["user"]);
2569
+ });
2570
+
2571
+ test("GET /notes?exclude_path_prefix mixes comma-list and repeated params (vault#659)", async () => {
2572
+ await store.createNote("user", { path: "Projects/a" });
2573
+ await store.createNote("sys", { path: ".parachute/notes/settings" });
2574
+ await store.createNote("tmp", { path: "Scratch/x" });
2575
+ await store.createNote("arch", { path: "Archive/old" });
2576
+ const res = await handleNotes(
2577
+ mkReq("GET", "/notes?exclude_path_prefix=.parachute/,Scratch/&exclude_path_prefix=Archive/&include_content=true"),
2578
+ store,
2579
+ "",
2580
+ );
2581
+ const body = await res.json() as any[];
2582
+ expect(body.map((n) => n.content)).toEqual(["user"]);
2583
+ });
2584
+
2585
+ // vault#659 — `_` is LIKE's single-char wildcard; an unescaped prefix
2586
+ // silently pulled in neighbors that merely looked alike.
2587
+ test("GET /notes?path_prefix=_tags/ escapes LIKE metachars (vault#659)", async () => {
2588
+ await store.createNote("under", { path: "_tags/x" });
2589
+ await store.createNote("decoy", { path: "atags/x" });
2590
+ const res = await handleNotes(
2591
+ mkReq("GET", "/notes?path_prefix=_tags/&include_content=true"),
2592
+ store,
2593
+ "",
2594
+ );
2595
+ const body = await res.json() as any[];
2596
+ expect(body.map((n) => n.content)).toEqual(["under"]);
2597
+ });
2598
+
2526
2599
  test("GET /notes?include_metadata=false strips metadata from list", async () => {
2527
2600
  await store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
2528
2601
  await store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });