Add SearchProvider interface with PostgreSQL FTS (#49)
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

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
This commit is contained in:
Claude Opus 4.8 2026-07-09 13:25:05 +02:00
parent 6c38abc20c
commit 91dfccf226
19 changed files with 568 additions and 10 deletions

View File

@ -12,7 +12,8 @@
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"db:migrate:dev": "prisma migrate dev", "db:migrate:dev": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"fixtures:regenerate": "tsx prisma/fixtures/regenerate.ts" "fixtures:regenerate": "tsx prisma/fixtures/regenerate.ts",
"search:reindex": "tsx src/search/reindex.cli.ts"
}, },
"dependencies": { "dependencies": {
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "page_content_cache" ADD COLUMN "search_vector" tsvector;
-- GIN index for the weighted full-text search vector (issue #49, ADR 0010).
-- The vector itself is maintained in application code (SearchProvider /
-- collab persistence); this index only speeds up matching in production.
CREATE INDEX "page_content_cache_search_vector_idx"
ON "page_content_cache" USING GIN ("search_vector");

View File

@ -202,6 +202,11 @@ model PageContentCache {
html String html String
outline Json outline Json
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in
/// the migration (raw SQL — Prisma cannot index an Unsupported column).
searchVector Unsupported("tsvector")? @map("search_vector")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)

View File

@ -17,6 +17,7 @@ import { PagesModule } from './pages/pages.module';
import { PondsModule } from './ponds/ponds.module'; import { PondsModule } from './ponds/ponds.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { RateLimitModule } from './rate-limit/rate-limit.module'; import { RateLimitModule } from './rate-limit/rate-limit.module';
import { SearchModule } from './search/search.module';
import { SettingsModule } from './settings/settings.module'; import { SettingsModule } from './settings/settings.module';
import { TrashModule } from './trash/trash.module'; import { TrashModule } from './trash/trash.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@ -38,6 +39,7 @@ import { VersionsModule } from './versions/versions.module';
VersionsModule, VersionsModule,
LabelsModule, LabelsModule,
LinksModule, LinksModule,
SearchModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { SearchModule } from '../search/search.module';
import { LabelsController } from './labels.controller'; import { LabelsController } from './labels.controller';
import { LabelsService } from './labels.service'; import { LabelsService } from './labels.service';
@Module({ @Module({
imports: [PondsModule], imports: [PondsModule, SearchModule],
controllers: [LabelsController], controllers: [LabelsController],
providers: [LabelsService], providers: [LabelsService],
exports: [LabelsService], exports: [LabelsService],

View File

@ -22,6 +22,7 @@ import { PinoLogger } from 'nestjs-pino';
import { InterimAccessService } from '../ponds/interim-access.service'; import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from '../search/search.provider';
/** Transaction client type, so the locked helpers can read and write atomically. */ /** Transaction client type, so the locked helpers can read and write atomically. */
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@ -45,10 +46,24 @@ export class LabelsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly access: InterimAccessService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly search: SearchProvider,
) { ) {
this.logger.setContext(LabelsService.name); this.logger.setContext(LabelsService.name);
} }
/** Re-index every page that carries any of the given labels (issue #49):
* label names are the weight-B search field, so an assignment/rename/delete
* changes those pages' search entries. */
private async reindexPagesWithLabels(labelIds: string[]): Promise<void> {
if (labelIds.length === 0) return;
const rows = await this.prisma.pageLabel.findMany({
where: { labelId: { in: labelIds } },
select: { pageId: true },
distinct: ['pageId'],
});
for (const row of rows) await this.search.indexPage(row.pageId);
}
viewOf(label: Label): LabelView { viewOf(label: Label): LabelView {
return { return {
id: label.id, id: label.id,
@ -167,6 +182,10 @@ export class LabelsService {
data: { name: input.name, color: input.color }, data: { name: input.name, color: input.color },
}); });
}); });
// A renamed label changes the search entry of every page carrying it (#49).
if (input.name !== undefined && input.name !== existing.name) {
await this.reindexPagesWithLabels([labelId]);
}
return this.viewOf(label); return this.viewOf(label);
} }
@ -215,20 +234,27 @@ export class LabelsService {
const existing = await this.requireModifiableLabel(user, labelId); const existing = await this.requireModifiableLabel(user, labelId);
const pondId = existing.pondId; const pondId = existing.pondId;
await this.withPondLock(pondId, async (tx) => { const affectedPageIds = await this.withPondLock(pondId, async (tx) => {
const labels = await this.allPondLabels(tx, pondId); const labels = await this.allPondLabels(tx, pondId);
const subtree = [...collectSubtreeIds(labels, labelId)]; const subtree = [...collectSubtreeIds(labels, labelId)];
const assigned = await tx.pageLabel.count({ where: { labelId: { in: subtree } } }); const assignments = await tx.pageLabel.findMany({
if (assigned > 0 && !force) { where: { labelId: { in: subtree } },
select: { pageId: true },
distinct: ['pageId'],
});
if (assignments.length > 0 && !force) {
throw new ConflictException({ throw new ConflictException({
code: 'label_has_pages', code: 'label_has_pages',
details: { count: [String(assigned)] }, details: { count: [String(assignments.length)] },
}); });
} }
// Deleting the root cascades to the subtree and to all page assignments. // Deleting the root cascades to the subtree and to all page assignments.
await tx.label.delete({ where: { id: labelId } }); await tx.label.delete({ where: { id: labelId } });
return assignments.map((a) => a.pageId);
}); });
// Those pages lost a label → their search entries change (#49).
for (const pageId of affectedPageIds) await this.search.indexPage(pageId);
this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted'); this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted');
} }
@ -273,6 +299,7 @@ export class LabelsService {
create: { pageId, labelId }, create: { pageId, labelId },
update: {}, update: {},
}); });
await this.search.indexPage(pageId); // labels are a search field (#49)
return this.pageLabels(user, pageId); return this.pageLabels(user, pageId);
} }
@ -280,5 +307,6 @@ export class LabelsService {
async unassign(user: User, pageId: string, labelId: string): Promise<void> { async unassign(user: User, pageId: string, labelId: string): Promise<void> {
await this.requireModifiablePage(user, pageId); await this.requireModifiablePage(user, pageId);
await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } }); await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } });
await this.search.indexPage(pageId); // labels are a search field (#49)
} }
} }

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { SearchModule } from '../search/search.module';
import { PagesController } from './pages.controller'; import { PagesController } from './pages.controller';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
@Module({ @Module({
imports: [PondsModule], imports: [PondsModule, SearchModule],
controllers: [PagesController], controllers: [PagesController],
providers: [PagesService], providers: [PagesService],
exports: [PagesService], exports: [PagesService],

View File

@ -19,6 +19,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { InterimAccessService } from '../ponds/interim-access.service'; import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from '../search/search.provider';
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key'; import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content'; import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
@ -45,6 +46,7 @@ export class PagesService {
private readonly access: InterimAccessService, private readonly access: InterimAccessService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly search: SearchProvider,
) { ) {
this.logger.setContext(PagesService.name); this.logger.setContext(PagesService.name);
} }
@ -157,6 +159,8 @@ export class PagesService {
}); });
// A new page may satisfy phantom wikilinks that referenced its slug (#47). // A new page may satisfy phantom wikilinks that referenced its slug (#47).
await this.resolvePhantomLinks(pond.id, slug, page.id); await this.resolvePhantomLinks(pond.id, slug, page.id);
// Index the (empty) page so a title-only match is findable immediately (#49).
await this.search.indexPage(page.id);
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created'); this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
return this.viewOf(page); return this.viewOf(page);
} }
@ -247,6 +251,10 @@ export class PagesService {
}); });
// Renaming to a slug pages already link to resolves those phantom links (#47). // Renaming to a slug pages already link to resolves those phantom links (#47).
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id); if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
// A changed title changes the (weighted) search entry (#49).
if (input.title !== undefined && input.title !== page.title) {
await this.search.indexPage(page.id);
}
return this.viewOf(updated); return this.viewOf(updated);
} }

View File

@ -0,0 +1,140 @@
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 { 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) {
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 reindexAll(): 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`;
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 [];
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
AND (${user.isSiteAdmin}::boolean OR po.owner_id = ${user.id})
${scope}
${labelFilter}
ORDER BY ts_rank(c.search_vector, q) DESC, p.updated_at DESC
LIMIT ${SEARCH_RESULT_LIMIT}`);
return rows.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,
}));
}
}

View File

@ -0,0 +1,27 @@
import { PrismaService } from '../prisma/prisma.service';
import { PostgresSearchProvider } from './postgres-search.provider';
/**
* `search:reindex` CLI (ADR 0010, issue #49): rebuilds the whole search index
* from `page_content_cache`, idempotently safe to run after a schema change,
* a reseed, or a provider swap. It instantiates the PostgreSQL provider
* directly (no Nest DI): the CLI is run with `tsx`, whose esbuild transform does
* not emit the decorator metadata Nest injection relies on. The provider itself
* is still the one bound in the app (proven by the DI test) only the CLI's
* wiring is manual.
*/
async function main(): Promise<void> {
const prisma = new PrismaService();
const provider = new PostgresSearchProvider(prisma);
try {
const count = await provider.reindexAll();
console.log(`search:reindex — indexed ${count} page(s)`);
} finally {
await prisma.$disconnect();
}
}
void main().catch((error) => {
console.error('search:reindex failed:', error);
process.exitCode = 1;
});

View File

@ -0,0 +1,33 @@
import { BadRequestException, Controller, Get, Query, Req } from '@nestjs/common';
import { SearchQuery, SearchResultView, searchQuerySchema } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { SearchProvider } from './search.provider';
/** Full-text search (issue #49, ADR 0010). */
@Controller()
export class SearchController {
constructor(private readonly search: SearchProvider) {}
/**
* `GET /search?q=…&pondId=…&labels=a,b` ranked, permission-filtered hits.
* `labels` is a comma-separated list; scope defaults to all readable ponds.
*/
@Get('search')
async query(
@Query('q') q: string,
@Query('pondId') pondId: string | undefined,
@Query('labels') labels: string | undefined,
@Req() request: AuthedRequest,
): Promise<SearchResultView[]> {
const parsed = searchQuerySchema.safeParse({
q,
pondId: pondId || undefined,
labels: labels ? labels.split(',').filter(Boolean) : undefined,
} satisfies Record<string, unknown>);
if (!parsed.success) {
throw new BadRequestException({ code: 'bad_request' });
}
return this.search.search(parsed.data as SearchQuery, request.user!);
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { PostgresSearchProvider } from './postgres-search.provider';
import { SearchProvider } from './search.provider';
/**
* Search module (ADR 0010). Binds the `SearchProvider` token to the PostgreSQL
* implementation; swapping in an external engine is a one-line provider change.
* Exports the provider so pages/labels can re-index on title/label changes.
*/
@Module({
controllers: [SearchController],
providers: [{ provide: SearchProvider, useClass: PostgresSearchProvider }],
exports: [SearchProvider],
})
export class SearchModule {}

View File

@ -0,0 +1,52 @@
import { SearchResultView } from '@dorfteich/shared';
import { Test } from '@nestjs/testing';
import { User } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { AuthedRequest } from '../auth/auth.guard';
import { SearchController } from './search.controller';
import { SearchProvider } from './search.provider';
/**
* Proves the `SearchProvider` seam (ADR 0010 / issue #49): the controller
* depends on the abstract token, so a fake implementation can replace the
* PostgreSQL binding entirely in a test which is exactly how an external
* engine would be swapped in.
*/
describe('SearchProvider DI seam (issue #49)', () => {
it('lets a fake provider replace the real binding', async () => {
const hit: SearchResultView = {
pageId: 'p1',
title: 'Hit',
slug: 'hit',
pondId: 'pond1',
pondSlug: 'pond',
pondName: 'Pond',
labelIds: [],
snippet: 'a snippet',
};
const fake: SearchProvider = {
indexPage: vi.fn(),
removePage: vi.fn(),
reindexAll: vi.fn(),
search: vi.fn().mockResolvedValue([hit]),
};
const moduleRef = await Test.createTestingModule({
controllers: [SearchController],
providers: [{ provide: SearchProvider, useValue: fake }],
}).compile();
const controller = moduleRef.get(SearchController);
const user = { id: 'u1', isSiteAdmin: false } as User;
const result = await controller.query('hello', 'pond1', 'a,b', {
user,
} as AuthedRequest);
expect(result).toEqual([hit]);
expect(fake.search).toHaveBeenCalledWith(
{ q: 'hello', pondId: 'pond1', labels: ['a', 'b'] },
user,
);
});
});

View File

@ -0,0 +1,20 @@
import { SearchQuery, SearchResultView } from '@dorfteich/shared';
import { User } from '@prisma/client';
/**
* Search behind an interface (ADR 0010, issue #49) so an external engine can
* replace the PostgreSQL binding without touching call sites. Bound via DI as an
* abstract-class token a fake implementation can be provided in tests, which
* is what proves the seam. `search` receives the requesting user so results are
* permission-filtered; scope (pond, labels) lives in the query.
*/
export abstract class SearchProvider {
/** (Re)compute the search entry for one page from its current content. */
abstract indexPage(pageId: string): Promise<void>;
/** Drop a page from the index (its cache row is also removed on purge). */
abstract removePage(pageId: string): Promise<void>;
/** Ranked, permission-filtered results for `user`. */
abstract search(query: SearchQuery, user: User): Promise<SearchResultView[]>;
/** Rebuild the whole index from `page_content_cache`; returns rows indexed. */
abstract reindexAll(): Promise<number>;
}

View File

@ -0,0 +1,116 @@
import { randomUUID } from 'node:crypto';
import { SEARCH_HIGHLIGHT_START } from '@dorfteich/shared';
import { INestApplication } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { SearchProvider } from './search.provider';
describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let search: SearchProvider;
const suffix = uniqueSuffix();
const term = `zeb${suffix}`; // a term unique to this run
let owner: User;
let outsider: User;
let pondId: string;
const pageIds: string[] = [];
/** Creates a page with an indexed content cache, then indexes it. */
async function makePage(title: string, plainText: string): Promise<string> {
const id = randomUUID();
await prisma.page.create({
data: {
id,
pondId,
title,
slug: `p-${id.slice(0, 8)}`,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: `a${pageIds.length}`,
createdBy: owner.id,
contentCache: { create: { plainText, markdown: plainText, html: plainText, outline: [] } },
},
});
pageIds.push(id);
await search.indexPage(id);
return id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
search = app.get(SearchProvider);
owner = await prisma.user.create({
data: {
username: `srch-owner-${suffix}`,
email: `srch-owner-${suffix}@example.test`,
displayName: 'Search Owner',
},
});
outsider = await prisma.user.create({
data: {
username: `srch-out-${suffix}`,
email: `srch-out-${suffix}@example.test`,
displayName: 'Search Outsider',
},
});
const pond = await prisma.pond.create({
data: {
slug: `srch-pond-${suffix}`,
name: 'Search Pond',
type: 'PERSONAL',
ownerId: owner.id,
},
});
pondId = pond.id;
});
afterAll(async () => {
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
await prisma.$disconnect();
await app.close();
});
it('ranks a title match above a body match', async () => {
const titleHit = await makePage(`${term} in the title`, 'unrelated body text');
await makePage('unrelated title', `a long story that mentions ${term} in the body only`);
const results = await search.search({ q: term }, owner);
expect(results.length).toBeGreaterThanOrEqual(2);
// Weight A (title) outranks weight C (body).
expect(results[0]!.pageId).toBe(titleHit);
});
it('highlights the match in the snippet', async () => {
const results = await search.search({ q: term }, owner);
const bodyHit = results.find((r) => r.snippet.includes(SEARCH_HIGHLIGHT_START));
expect(bodyHit).toBeDefined();
});
it('matches diacritics-insensitively (Baume finds Bäume)', async () => {
const page = await makePage('Wald', `viele Bäume-${suffix} im Wald`);
const results = await search.search({ q: `Baume-${suffix}` }, owner);
expect(results.map((r) => r.pageId)).toContain(page);
});
it('never returns pages the requester may not read', async () => {
const results = await search.search({ q: term }, outsider);
expect(results).toEqual([]);
});
it('reindexAll rebuilds from the cache and is idempotent', async () => {
const before = await search.search({ q: term }, owner);
await search.reindexAll();
await search.reindexAll();
const after = await search.search({ q: term }, owner);
expect(after.map((r) => r.pageId).sort()).toEqual(before.map((r) => r.pageId).sort());
});
});

View File

@ -1,4 +1,4 @@
import { MAX_PAGE_DOCUMENT_BYTES } from '@dorfteich/shared'; import { MAX_PAGE_DOCUMENT_BYTES, normalizeForSearch } from '@dorfteich/shared';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import * as Y from 'yjs'; import * as Y from 'yjs';
@ -101,8 +101,8 @@ export class PostgresPagePersistence implements PagePersistence {
// Lock the page row for the duration of the flush: this serialises seq // Lock the page row for the duration of the flush: this serialises seq
// allocation and guards against storing to a page trashed mid-session. // allocation and guards against storing to a page trashed mid-session.
const page = await client.query<{ pond_id: string }>( const page = await client.query<{ pond_id: string; title: string }>(
'SELECT pond_id FROM pages WHERE id = $1 AND deleted_at IS NULL FOR UPDATE', 'SELECT pond_id, title FROM pages WHERE id = $1 AND deleted_at IS NULL FOR UPDATE',
[pageId], [pageId],
); );
const pageMeta = page.rows[0]; const pageMeta = page.rows[0];
@ -165,6 +165,29 @@ export class PostgresPagePersistence implements PagePersistence {
); );
} }
// Maintain the weighted full-text search vector (issue #49) in the same
// transaction as the cache — the same weighting the api's SearchProvider
// uses, folded through normalizeForSearch for diacritic-insensitive match.
const labelRow = await client.query<{ names: string | null }>(
`SELECT string_agg(l.name, ' ') AS names
FROM page_labels pl JOIN labels l ON l.id = pl.label_id
WHERE pl.page_id = $1`,
[pageId],
);
await client.query(
`UPDATE page_content_cache SET search_vector =
setweight(to_tsvector('simple', $2), 'A')
|| setweight(to_tsvector('simple', $3), 'B')
|| setweight(to_tsvector('simple', $4), 'C')
WHERE page_id = $1`,
[
pageId,
normalizeForSearch(pageMeta.title ?? ''),
normalizeForSearch(labelRow.rows[0]?.names ?? ''),
normalizeForSearch(derived.plainText),
],
);
// Rewrite this page's outgoing wikilink index (issue #47): replace all its // Rewrite this page's outgoing wikilink index (issue #47): replace all its
// rows with one per distinct target slug, resolved to a page in the same // rows with one per distinct target slug, resolved to a page in the same
// pond (null `to_page_id` = phantom, target does not exist yet). // pond (null `to_page_id` = phantom, target does not exist yet).

View File

@ -9,6 +9,7 @@ export * from './i18n-tools';
export * from './labels'; export * from './labels';
export * from './links'; export * from './links';
export * from './pages'; export * from './pages';
export * from './search';
export * from './ponds'; export * from './ponds';
export * from './quotas'; export * from './quotas';
export * from './text-diff'; export * from './text-diff';

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { normalizeForSearch, searchQuerySchema } from './search';
describe('normalizeForSearch (issue #49)', () => {
it('strips diacritics and lowercases so accents do not matter', () => {
expect(normalizeForSearch('Bäume')).toBe('baume');
expect(normalizeForSearch('Café')).toBe('cafe');
expect(normalizeForSearch('Zürich Über')).toBe('zurich uber');
});
it('leaves ascii text lowercased and otherwise intact', () => {
expect(normalizeForSearch('Zebra Alpha')).toBe('zebra alpha');
});
});
describe('searchQuerySchema (issue #49)', () => {
it('requires a non-empty query and parses optional scope', () => {
expect(searchQuerySchema.safeParse({ q: '' }).success).toBe(false);
const ok = searchQuerySchema.parse({ q: ' hi ', pondId: 'p1', labels: ['a', 'b'] });
expect(ok).toEqual({ q: 'hi', pondId: 'p1', labels: ['a', 'b'] });
});
});

View File

@ -0,0 +1,52 @@
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;
}