dorfteich/apps/api/src/search/postgres-search.provider.ts
Claude Fable 5 960a806ee3
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m4s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m44s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m53s
CI / Import/export fidelity gate (push) Successful in 53s
#195: trashed content leaves the search index itself
Trashing a page (promote and subtree modes) clears the affected search
vectors, restoring rebuilds them; pond trash clears every page vector of
the pond, pond restore reindexes only the live pages (pages trashed
inside stay out); the GDPR pseudonymization's personal-pond trash does
the same. reindexAll now converges to the invariant (clears trashed,
rebuilds live), and a one-off migration backfills vectors of
already-trashed content.

The query-side deleted_at guards stay untouched as the independent
second layer - the test proves both layers separately, including writing
a vector back onto a trashed page (simulating a future path that forgot
the clear) and asserting the query still hides it. New provider methods
removePond/reindexPond behind the SearchProvider seam.

Refs #195

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 14:07:34 +02:00

216 lines
8.3 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
SEARCH_HIGHLIGHT_END,
SEARCH_HIGHLIGHT_START,
SEARCH_RESULT_LIMIT,
SearchQuery,
SearchResultView,
normalizeForSearch,
} from '@dorfteich/shared';
import { Prisma, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from './search.provider';
/** ts_headline options — one fragment, matches wrapped in the shared sentinels. */
const HEADLINE_OPTIONS =
`StartSel=${SEARCH_HIGHLIGHT_START}, StopSel=${SEARCH_HIGHLIGHT_END}, ` +
'MaxFragments=1, MaxWords=30, MinWords=8, ShortWord=0';
/** Raw title/labels/body for one page, before normalization. */
interface IndexSource {
title: string;
labels: string | null;
plain_text: string | null;
}
interface SearchRow {
pageId: string;
title: string;
slug: string;
pondId: string;
pondSlug: string;
pondName: string;
labelIds: string[];
snippet: string;
}
/**
* PostgreSQL full-text search (ADR 0010, issue #49). The weighted `tsvector`
* (title A, labels B, body C) is stored on `page_content_cache.search_vector`
* and maintained here (and by the collab persistence hook on content changes);
* both fold text through {@link normalizeForSearch} before `to_tsvector` so
* matching is diacritic-insensitive without the `unaccent` extension. Queries
* are folded the same way; results are filtered to the ponds the user may read.
*/
@Injectable()
export class PostgresSearchProvider extends SearchProvider {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {
super();
}
async indexPage(pageId: string): Promise<void> {
const rows = await this.prisma.$queryRaw<IndexSource[]>`
SELECT p.title,
c.plain_text,
(SELECT string_agg(l.name, ' ')
FROM page_labels pl JOIN labels l ON l.id = pl.label_id
WHERE pl.page_id = p.id) AS labels
FROM pages p
LEFT JOIN page_content_cache c ON c.page_id = p.id
WHERE p.id = ${pageId}`;
const source = rows[0];
if (!source) return;
await this.writeVector(pageId, source);
}
async removePage(pageId: string): Promise<void> {
await this.prisma
.$executeRaw`UPDATE page_content_cache SET search_vector = NULL WHERE page_id = ${pageId}`;
}
async removePond(pondId: string): Promise<void> {
await this.prisma.$executeRaw`
UPDATE page_content_cache SET search_vector = NULL
WHERE page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
}
async reindexPond(pondId: string): Promise<number> {
const sources = await this.prisma.$queryRaw<(IndexSource & { page_id: string })[]>`
SELECT p.id AS page_id, p.title, c.plain_text,
(SELECT string_agg(l.name, ' ')
FROM page_labels pl JOIN labels l ON l.id = pl.label_id
WHERE pl.page_id = p.id) AS labels
FROM page_content_cache c
JOIN pages p ON p.id = c.page_id
WHERE p.pond_id = ${pondId} AND p.deleted_at IS NULL`;
for (const source of sources) await this.writeVector(source.page_id, source);
return sources.length;
}
async reindexAll(): Promise<number> {
// Converge to the issue-#195 invariant: trashed content leaves the
// index entirely, live content is rebuilt.
await this.prisma.$executeRaw`
UPDATE page_content_cache c SET search_vector = NULL
FROM pages p LEFT JOIN ponds po ON po.id = p.pond_id
WHERE p.id = c.page_id
AND (p.deleted_at IS NOT NULL OR po.deleted_at IS NOT NULL)`;
const sources = await this.prisma.$queryRaw<(IndexSource & { page_id: string })[]>`
SELECT p.id AS page_id, p.title, c.plain_text,
(SELECT string_agg(l.name, ' ')
FROM page_labels pl JOIN labels l ON l.id = pl.label_id
WHERE pl.page_id = p.id) AS labels
FROM page_content_cache c
JOIN pages p ON p.id = c.page_id
JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL
WHERE p.deleted_at IS NULL`;
for (const source of sources) await this.writeVector(source.page_id, source);
return sources.length;
}
/** Writes the weighted, normalized vector for one page (shared by index/reindex). */
private async writeVector(pageId: string, source: IndexSource): Promise<void> {
const title = normalizeForSearch(source.title ?? '');
const labels = normalizeForSearch(source.labels ?? '');
const body = normalizeForSearch(source.plain_text ?? '');
await this.prisma.$executeRaw`
UPDATE page_content_cache SET search_vector =
setweight(to_tsvector('simple', ${title}), 'A')
|| setweight(to_tsvector('simple', ${labels}), 'B')
|| setweight(to_tsvector('simple', ${body}), 'C')
WHERE page_id = ${pageId}`;
}
async search(query: SearchQuery, user: User): Promise<SearchResultView[]> {
const normalized = normalizeForSearch(query.q);
// A phrase that folds to nothing (e.g. only punctuation) matches nothing.
if (normalized.trim() === '') return [];
// Substring fallback (M10 follow-up): the tsquery only matches whole
// words, so a plain LIKE over title/body catches partial words too. The
// pattern keeps the raw (lowercased) input — umlauts etc. match verbatim;
// diacritic-insensitive matching stays the FTS branch's job. FTS hits
// still rank first (ts_rank is 0 for LIKE-only matches).
const likePattern = `%${query.q
.trim()
.toLowerCase()
.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
// Pond-level prefilter (visible ponds only) keeps the LIMIT meaningful;
// the exact per-page resolution happens below (issue #52, ADR 0010).
const visiblePondIds = await this.permissions.visiblePondIds(user);
if (visiblePondIds !== null && visiblePondIds.length === 0) return [];
const visibility =
visiblePondIds === null
? Prisma.empty
: Prisma.sql`AND p.pond_id = ANY(${visiblePondIds}::text[])`;
const scope = query.pondId ? Prisma.sql`AND p.pond_id = ${query.pondId}` : Prisma.empty;
const labelFilter =
query.labels && query.labels.length > 0
? Prisma.sql`AND EXISTS (SELECT 1 FROM page_labels pl
WHERE pl.page_id = p.id AND pl.label_id = ANY(${query.labels}))`
: Prisma.empty;
const rows = await this.prisma.$queryRaw<SearchRow[]>(Prisma.sql`
SELECT p.id AS "pageId", p.title, p.slug, p.pond_id AS "pondId",
po.slug AS "pondSlug", po.name AS "pondName",
COALESCE(
ARRAY(SELECT pl.label_id FROM page_labels pl WHERE pl.page_id = p.id),
'{}'
) AS "labelIds",
ts_headline('simple', c.plain_text,
websearch_to_tsquery('simple', ${query.q}), ${HEADLINE_OPTIONS}) AS snippet
FROM page_content_cache c
JOIN pages p ON p.id = c.page_id AND p.deleted_at IS NULL
JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL,
websearch_to_tsquery('simple', ${normalized}) q
WHERE (c.search_vector @@ q
OR lower(p.title) LIKE ${likePattern}
OR lower(c.plain_text) LIKE ${likePattern})
${visibility}
${scope}
${labelFilter}
ORDER BY ts_rank(c.search_vector, q) DESC, p.updated_at DESC
LIMIT ${SEARCH_RESULT_LIMIT}`);
// Per-page resolution (label-/page-scope grants, deny-wins) per pond.
const rowsByPond = new Map<string, SearchRow[]>();
for (const row of rows) {
const group = rowsByPond.get(row.pondId) ?? [];
group.push(row);
rowsByPond.set(row.pondId, group);
}
const readableByPond = new Map<string, Set<string>>();
for (const [pondId, group] of rowsByPond) {
readableByPond.set(
pondId,
await this.permissions.filterPages(
user,
pondId,
group.map((row) => ({ id: row.pageId, labelIds: row.labelIds })),
'read',
),
);
}
return rows
.filter((row) => readableByPond.get(row.pondId)?.has(row.pageId))
.map((row) => ({
pageId: row.pageId,
title: row.title,
slug: row.slug,
pondId: row.pondId,
pondSlug: row.pondSlug,
pondName: row.pondName,
labelIds: row.labelIds,
snippet: row.snippet,
}));
}
}