dorfteich/packages/shared/src/search.ts
Claude Opus 4.8 91dfccf226
All checks were successful
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m19s
CI / Auth e2e pack (push) Successful in 2m51s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
Add SearchProvider interface with PostgreSQL FTS (#49)
Full-text search behind a swappable interface (ADR 0010).

- prisma: `page_content_cache.search_vector tsvector` (Unsupported column);
  migration adds it plus a GIN index (raw SQL — the index is a production
  perf optimization; correctness holds without it, so schema-pushed test DBs
  work unchanged).
- shared: `normalizeForSearch` (NFKD + strip diacritics + lowercase) folds
  both the indexed text and the query, so 'Baume' finds 'Bäume' without the
  Postgres `unaccent` extension; search query schema + result view + highlight
  sentinels.
- api search module:
  - abstract `SearchProvider` (DI token: indexPage / removePage / search /
    reindexAll) so an external engine can replace the binding — a fake proves
    the seam in a test.
  - `PostgresSearchProvider`: weighted vector (title A, labels B, body C),
    `websearch_to_tsquery`, `ts_headline` snippets, results filtered to the
    ponds the user may read; `GET /search?q=&pondId=&labels=`.
  - `search:reindex` CLI (rebuilds from the content cache, idempotent).
  - reindex hooks: page create/rename (title) and label assign/unassign/
    rename/delete (labels are weight-B).
- collab: the persistence hook maintains `search_vector` in the same
  transaction as the content cache (same weighting, normalized).
- tests: shared normalize/schema; api db (title ranks above body, highlight,
  diacritic-insensitive match, permission filter, idempotent reindex) and the
  fake-provider DI test; collab persistence already covers the write path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 13:25:05 +02:00

53 lines
2.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from 'zod';
/**
* Search schemas and views (issue #49/#50, ADR 0010). Full-text search runs on
* PostgreSQL behind the `SearchProvider` interface. Diacritic-insensitive
* matching ('Baume' finds 'Bäume') is achieved by normalizing both the indexed
* text and the query in application code — so no Postgres `unaccent` extension
* is required (which keeps the schema-pushed test databases working).
*/
// Combining diacritical marks (U+0300U+036F), removed after NFKD decomposition.
const COMBINING_MARKS = /[̀-ͯ]/g;
/**
* Fold a string for search: strip diacritics (NFKD + drop combining marks) and
* lowercase, leaving tokenization to Postgres `to_tsvector('simple', …)`. Used
* for BOTH the stored search vector and the query, so they always agree.
*/
export function normalizeForSearch(text: string): string {
return text.normalize('NFKD').replace(COMBINING_MARKS, '').toLowerCase();
}
/** Sentinels wrapping a matched span in a result snippet (`ts_headline`), split
* by the UI to render highlights without trusting HTML from the content.
* Private-use codepoints that never occur in page text. */
export const SEARCH_HIGHLIGHT_START = String.fromCodePoint(0xe000);
export const SEARCH_HIGHLIGHT_END = String.fromCodePoint(0xe001);
/** How many results one search returns (v1, ADR 0010 target scale). */
export const SEARCH_RESULT_LIMIT = 30;
export const searchQuerySchema = z.object({
q: z.string().trim().min(1, 'validation.required').max(200, 'validation.tooLong'),
/** Restrict to one pond; omitted = all ponds the user may read. */
pondId: z.string().min(1).optional(),
/** Restrict to pages carrying any of these label ids. */
labels: z.array(z.string().min(1)).optional(),
});
export type SearchQuery = z.infer<typeof searchQuerySchema>;
/** One hit: the page, its pond, its labels, and a highlighted snippet. */
export interface SearchResultView {
pageId: string;
title: string;
slug: string;
pondId: string;
pondSlug: string;
pondName: string;
labelIds: string[];
/** Snippet with matches wrapped in the highlight sentinels above. */
snippet: string;
}