Skip to content

fix(aggregator): match a partly typed last search word as a prefix - #3960

Open
KirbyBT wants to merge 1 commit into
emdash-cms:mainfrom
KirbyBT:fix/search-prefix-last-token
Open

KirbyBT wants to merge 1 commit into
emdash-cms:mainfrom
KirbyBT:fix/search-prefix-last-token

Conversation

@KirbyBT

@KirbyBT KirbyBT commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the prefix case from #3620: a partly typed word finds nothing.

quoteFtsQuery wrapped the whole query as a single FTS5 phrase with no prefix operator, so newslet returned nothing while newsletter found Bulletin. Porter stemming hid this for some words (analyt works) but not others.

As suggested in the issue ("foo" "bar"*), each whitespace-separated term is now quoted on its own and the last term gets *. Embedded quotes are still doubled, so user input still can't form FTS5 operators: alpha OR beta still matches nothing because OR is a literal term. Terms with no letters or digits (for example a lone ( or *) are dropped, since the tokenizer would turn them into empty phrases.

One behavior change to be aware of: multi-word queries now match records that contain all the terms, rather than only the exact adjacent phrase. For example, gallery image now finds a package described as "Image gallery".

The remaining parts of #3620 (indexing slugs, handles and capabilities) need schema changes, so they aren't included here.

Part of #3620

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (ran tsgo --noEmit in apps/aggregator)
  • pnpm lint passes (ran type-aware oxlint --deny-warnings on the changed files)
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a, no UI strings
  • I have added and reviewed the user-facing changeset (if this PR changes a published package) — n/a, @emdash-cms/aggregator is private and ignored by changesets
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... — n/a, bug fix
  • I have included screenshots below if this PR changes the UI — n/a, no UI change

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Code (Claude Opus 5.5)

Screenshots / test output

Not applicable (no UI change).

New tests in apps/aggregator/test/read-api.test.ts:

  • email newslet finds a package described as "Email newsletters"
  • a query with no word characters (( * ") returns 200 with no matches

The existing operator-escaping and FTS-unsafe-character tests still pass; I updated the comment on the operator test to match the new quoting.

Without the change: 1 failed | 51 passed (52) (the prefix test). With it: 52 passed (52). Full aggregator suite: 12 passed files, 326 passed tests.

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 emdash-cms#3620

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f3392c1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet size/M cla: signed labels Oct 7, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the diff, the search route, the FTS table/tokenizer definitions (apps/aggregator/migrations/0001_init.sql and 0003_listing_projection.sql), and the existing test context. This is the right change for #3620 — the old single-phrase quoting really did defeat prefix search, and switching to per-term quoted phrases with a controlled trailing * matches the pattern used elsewhere in the repo (packages/core/src/search/query.ts).

The code is small and focused, the new regression tests exercise the reported failure, and there are no query-count or security concerns (the query is still passed as a bound ? value).

Two non-blocking observations:

  • quoteFtsQuery drops lone * terms, but it does not strip * from terms that also contain word characters. An input like foo* would become "foo*"* — a literal * inside a phrase plus a phrase-level prefix *. I can't run SQLite here to confirm, but that looks like a potential FTS5 syntax error that would break the existing "FTS-unsafe chars don't 500" invariant. Since the code already controls prefixing, sanitizing * out of user terms before quoting would remove that risk.
  • The regression test covers a partly typed last word inside a multi-word query. Adding a single-term test such as q=newslet would pin the exact reported case (a lone partly typed word) and guard against the prefix * accidentally being applied to the joined string rather than the final term.

Neither blocks merge, so I'm leaving these as suggestions.

.filter((term) => WORD_CHAR_RE.test(term))
.map((term) => `"${term.replace(FTS_QUOTE_RE, '""')}"`);
if (terms.length === 0) return '""';
return `${terms.join(" ")}*`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The function appends its own * after the last quoted term, but it doesn't remove user-supplied * characters from the term itself. An input like foo* would produce "foo*"* — a literal * inside a phrase plus a phrase-level prefix *. I can't run SQLite here to verify, but that looks like a potential FTS5 syntax error and would weaken the existing "FTS-unsafe chars don't 500" guarantee. Since the code already owns prefixing, consider stripping * from terms before quoting.

Suggested change
return `${terms.join(" ")}*`;
function quoteFtsQuery(raw: string): string {
const terms = raw
.replace(/\*/g, "")
.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(" ")}*`;
}

await expect(res.json()).resolves.toEqual({ packages: [] });
});

it("doesn't blow up on FTS-unsafe query chars (defensive quoting)", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new regression test validates prefix matching inside a multi-word query (email newslet). Adding a single-term test for the exact reported case — e.g., q=newslet returning bulletin — would pin that a lone partly typed word now matches and guard against the prefix * being applied to the wrong part of the joined output.

Suggested change
it("doesn't blow up on FTS-unsafe query chars (defensive quoting)", async () => {
it("matches a single partly typed word as a prefix", async () => {
await seedPackage({ slug: "bulletin", name: "Bulletin", description: "Email newsletters" });
const res = await SELF.fetch(
`https://test/xrpc/${NSID.aggregatorSearchPackages}?q=${encodeURIComponent("newslet")}`,
);
const body = (await res.json()) as { packages: Array<{ slug: string }> };
expect(body.packages.map((p) => p.slug)).toEqual(["bulletin"]);
});

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed review/awaiting-author Reviewed; waiting on the author to respond size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant