@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 +14 -8
- package/core/src/types.ts +2 -1
- package/package.json +1 -1
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +6 -2
- package/src/routes.ts +19 -2
- package/src/vault.test.ts +73 -0
- package/web/ui/dist/assets/{index-g3_KwmRE.js → index-xHUke-uy.js} +9 -9
- package/web/ui/dist/index.html +1 -1
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 || '%'
|
|
1107
|
-
// paths are kept — they are not under the
|
|
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
|
|
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
package/src/live-match.test.ts
CHANGED
|
@@ -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
|
|
357
|
-
|
|
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" } });
|