From f3392c12860d8ec3d120978830e95f458860f613 Mon Sep 17 00:00:00 2001 From: KirbyBT Date: Mon, 5 Oct 2026 18:28:13 -0400 Subject: [PATCH 1/2] fix(aggregator): match a partly typed last search word as a prefix Package search quoted the whole query as one FTS5 phrase with no prefix operator, so a partly typed word found nothing: `newslet` missed the Bulletin newsletter plugin while `newsletter` found it. Quote each term separately and add `*` to the last one. User input still can't form FTS5 operators, and terms with no letters or digits are dropped instead of becoming empty phrases. Part of #3620 Co-Authored-By: Claude Opus 5.5 --- .../src/routes/xrpc/searchPackages.ts | 20 +++++++----- apps/aggregator/test/read-api.test.ts | 31 ++++++++++++++++--- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/aggregator/src/routes/xrpc/searchPackages.ts b/apps/aggregator/src/routes/xrpc/searchPackages.ts index 36f6ce824c..47f82883ef 100644 --- a/apps/aggregator/src/routes/xrpc/searchPackages.ts +++ b/apps/aggregator/src/routes/xrpc/searchPackages.ts @@ -347,15 +347,21 @@ const CAPABILITY_FILTER_SQL = ` `; /** Quote a user-supplied search string for FTS5 MATCH. FTS5 treats `"`, - * `*`, `(`, `)`, `.`, `:`, `^`, `+`, `-` as syntax. The simplest robust - * escape is to wrap the whole query as a single phrase string and double - * any embedded quotes. This loses prefix-search functionality - * (`"foo*"` is treated literally) but is safe and sufficient for v1; if - * advanced query syntax becomes a product feature we'll layer a parsed - * mode on top. */ + * `*`, `(`, `)`, `.`, `:`, `^`, `+`, `-` as syntax, so each whitespace-separated + * term becomes its own quoted phrase with embedded quotes doubled, and user + * input can never form an operator. The last term gets a `*` so a partly typed + * word still matches (`newslet` finds `newsletter`). Terms with no letters or + * digits are dropped: the tokenizer would turn them into empty phrases. */ const FTS_QUOTE_RE = /"/g; +const WHITESPACE_RE = /\s+/; +const WORD_CHAR_RE = /[\p{L}\p{N}]/u; function quoteFtsQuery(raw: string): string { - return `"${raw.replace(FTS_QUOTE_RE, '""')}"`; + const terms = raw + .split(WHITESPACE_RE) + .filter((term) => WORD_CHAR_RE.test(term)) + .map((term) => `"${term.replace(FTS_QUOTE_RE, '""')}"`); + if (terms.length === 0) return '""'; + return `${terms.join(" ")}*`; } function clampLimit(raw: number | undefined): number { diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index c8bedce37d..e77c74d66c 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -686,6 +686,29 @@ describe("searchPackages", () => { expect(overlap).toEqual([]); }); + it("matches a partly typed last word as a prefix", async () => { + await seedPackage({ slug: "bulletin", name: "Bulletin", description: "Email newsletters" }); + await seedPackage({ slug: "gallery", name: "Gallery", description: "Image gallery" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorSearchPackages}?q=${encodeURIComponent("email newslet")}`, + ); + const body = (await res.json()) as { packages: Array<{ slug: string }> }; + + expect(body.packages.map((p) => p.slug)).toEqual(["bulletin"]); + }); + + it("returns no matches for a query with no word characters", async () => { + await seedPackage({ slug: "demo", name: "Demo" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorSearchPackages}?q=${encodeURIComponent('( * "')}`, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ packages: [] }); + }); + it("doesn't blow up on FTS-unsafe query chars (defensive quoting)", async () => { await seedPackage({ slug: "demo", name: "Demo" }); const res = await SELF.fetch( @@ -699,10 +722,10 @@ describe("searchPackages", () => { await seedPackage({ slug: "alpha", name: "Alpha" }); await seedPackage({ slug: "beta", name: "Beta" }); // `alpha OR beta` would match both packages if `OR` were interpreted - // as the FTS5 operator. With proper escaping the whole string is one - // literal phrase that can't possibly appear in either record's - // indexed text → zero matches. A buggy escape that stripped the - // quotes would return *both* packages. + // as the FTS5 operator. With proper escaping every term is a literal + // that must appear, and neither record contains all three → zero + // matches. A buggy escape that stripped the quotes would return + // *both* packages. const res = await SELF.fetch( `https://test/xrpc/${NSID.aggregatorSearchPackages}?q=${encodeURIComponent("alpha OR beta")}`, ); From fd32787959bee070650dda7c736d119cc398b71c Mon Sep 17 00:00:00 2001 From: KirbyBT Date: Wed, 7 Oct 2026 03:34:34 -0400 Subject: [PATCH 2/2] test(aggregator): cover a single partly typed search word, with and without `*` Co-Authored-By: Claude Opus 5.5 --- apps/aggregator/test/read-api.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/aggregator/test/read-api.test.ts b/apps/aggregator/test/read-api.test.ts index e77c74d66c..7eb6be6c2b 100644 --- a/apps/aggregator/test/read-api.test.ts +++ b/apps/aggregator/test/read-api.test.ts @@ -698,6 +698,21 @@ describe("searchPackages", () => { expect(body.packages.map((p) => p.slug)).toEqual(["bulletin"]); }); + it.each(["newslet", "newslet*", "newslet**"])( + "matches a single partly typed word as a prefix: %s", + async (query) => { + await seedPackage({ slug: "bulletin", name: "Bulletin", description: "Email newsletters" }); + + const res = await SELF.fetch( + `https://test/xrpc/${NSID.aggregatorSearchPackages}?q=${encodeURIComponent(query)}`, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { packages: Array<{ slug: string }> }; + expect(body.packages.map((p) => p.slug)).toEqual(["bulletin"]); + }, + ); + it("returns no matches for a query with no word characters", async () => { await seedPackage({ slug: "demo", name: "Demo" });