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
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
216 lines
8.3 KiB
TypeScript
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,
|
|
}));
|
|
}
|
|
}
|