diff --git a/apps/api/package.json b/apps/api/package.json index 5858c13..0a3a520 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,8 @@ "test": "vitest run --passWithNoTests", "db:migrate:dev": "prisma migrate dev", "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": { "@dorfteich/shared": "workspace:*", diff --git a/apps/api/prisma/migrations/20260709111040_search_vector/migration.sql b/apps/api/prisma/migrations/20260709111040_search_vector/migration.sql new file mode 100644 index 0000000..97f0935 --- /dev/null +++ b/apps/api/prisma/migrations/20260709111040_search_vector/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 173335e..e800ffd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -202,6 +202,11 @@ model PageContentCache { html String outline Json 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) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index dcbb715..d96ee15 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,7 @@ import { PagesModule } from './pages/pages.module'; import { PondsModule } from './ponds/ponds.module'; import { PrismaModule } from './prisma/prisma.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; +import { SearchModule } from './search/search.module'; import { SettingsModule } from './settings/settings.module'; import { TrashModule } from './trash/trash.module'; import { UsersModule } from './users/users.module'; @@ -38,6 +39,7 @@ import { VersionsModule } from './versions/versions.module'; VersionsModule, LabelsModule, LinksModule, + SearchModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/labels/labels.module.ts b/apps/api/src/labels/labels.module.ts index e5e8ca9..556f352 100644 --- a/apps/api/src/labels/labels.module.ts +++ b/apps/api/src/labels/labels.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PondsModule } from '../ponds/ponds.module'; +import { SearchModule } from '../search/search.module'; import { LabelsController } from './labels.controller'; import { LabelsService } from './labels.service'; @Module({ - imports: [PondsModule], + imports: [PondsModule, SearchModule], controllers: [LabelsController], providers: [LabelsService], exports: [LabelsService], diff --git a/apps/api/src/labels/labels.service.ts b/apps/api/src/labels/labels.service.ts index 899982e..8f24814 100644 --- a/apps/api/src/labels/labels.service.ts +++ b/apps/api/src/labels/labels.service.ts @@ -22,6 +22,7 @@ import { PinoLogger } from 'nestjs-pino'; import { InterimAccessService } from '../ponds/interim-access.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. */ type Tx = Prisma.TransactionClient; @@ -45,10 +46,24 @@ export class LabelsService { private readonly prisma: PrismaService, private readonly access: InterimAccessService, private readonly logger: PinoLogger, + private readonly search: SearchProvider, ) { 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 { + 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 { return { id: label.id, @@ -167,6 +182,10 @@ export class LabelsService { 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); } @@ -215,20 +234,27 @@ export class LabelsService { const existing = await this.requireModifiableLabel(user, labelId); 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 subtree = [...collectSubtreeIds(labels, labelId)]; - const assigned = await tx.pageLabel.count({ where: { labelId: { in: subtree } } }); - if (assigned > 0 && !force) { + const assignments = await tx.pageLabel.findMany({ + where: { labelId: { in: subtree } }, + select: { pageId: true }, + distinct: ['pageId'], + }); + if (assignments.length > 0 && !force) { throw new ConflictException({ 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. 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'); } @@ -273,6 +299,7 @@ export class LabelsService { create: { pageId, labelId }, update: {}, }); + await this.search.indexPage(pageId); // labels are a search field (#49) return this.pageLabels(user, pageId); } @@ -280,5 +307,6 @@ export class LabelsService { async unassign(user: User, pageId: string, labelId: string): Promise { await this.requireModifiablePage(user, pageId); await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } }); + await this.search.indexPage(pageId); // labels are a search field (#49) } } diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index 7e6544b..a954701 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PondsModule } from '../ponds/ponds.module'; +import { SearchModule } from '../search/search.module'; import { PagesController } from './pages.controller'; import { PagesService } from './pages.service'; @Module({ - imports: [PondsModule], + imports: [PondsModule, SearchModule], controllers: [PagesController], providers: [PagesService], exports: [PagesService], diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 06d1569..6603775 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -19,6 +19,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { InterimAccessService } from '../ponds/interim-access.service'; import { PrismaService } from '../prisma/prisma.service'; +import { SearchProvider } from '../search/search.provider'; import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key'; import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content'; @@ -45,6 +46,7 @@ export class PagesService { private readonly access: InterimAccessService, private readonly logger: PinoLogger, private readonly config: AppConfig, + private readonly search: SearchProvider, ) { this.logger.setContext(PagesService.name); } @@ -157,6 +159,8 @@ export class PagesService { }); // A new page may satisfy phantom wikilinks that referenced its slug (#47). 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'); return this.viewOf(page); } @@ -247,6 +251,10 @@ export class PagesService { }); // 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); + // 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); } diff --git a/apps/api/src/search/postgres-search.provider.ts b/apps/api/src/search/postgres-search.provider.ts new file mode 100644 index 0000000..3a7820c --- /dev/null +++ b/apps/api/src/search/postgres-search.provider.ts @@ -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 { + const rows = await this.prisma.$queryRaw` + 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 { + await this.prisma + .$executeRaw`UPDATE page_content_cache SET search_vector = NULL WHERE page_id = ${pageId}`; + } + + async reindexAll(): Promise { + 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 { + 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 { + 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(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, + })); + } +} diff --git a/apps/api/src/search/reindex.cli.ts b/apps/api/src/search/reindex.cli.ts new file mode 100644 index 0000000..b9fbc2b --- /dev/null +++ b/apps/api/src/search/reindex.cli.ts @@ -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 { + 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; +}); diff --git a/apps/api/src/search/search.controller.ts b/apps/api/src/search/search.controller.ts new file mode 100644 index 0000000..01712c6 --- /dev/null +++ b/apps/api/src/search/search.controller.ts @@ -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 { + const parsed = searchQuerySchema.safeParse({ + q, + pondId: pondId || undefined, + labels: labels ? labels.split(',').filter(Boolean) : undefined, + } satisfies Record); + if (!parsed.success) { + throw new BadRequestException({ code: 'bad_request' }); + } + return this.search.search(parsed.data as SearchQuery, request.user!); + } +} diff --git a/apps/api/src/search/search.module.ts b/apps/api/src/search/search.module.ts new file mode 100644 index 0000000..78f741c --- /dev/null +++ b/apps/api/src/search/search.module.ts @@ -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 {} diff --git a/apps/api/src/search/search.provider.test.ts b/apps/api/src/search/search.provider.test.ts new file mode 100644 index 0000000..6bfe910 --- /dev/null +++ b/apps/api/src/search/search.provider.test.ts @@ -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, + ); + }); +}); diff --git a/apps/api/src/search/search.provider.ts b/apps/api/src/search/search.provider.ts new file mode 100644 index 0000000..390406f --- /dev/null +++ b/apps/api/src/search/search.provider.ts @@ -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; + /** Drop a page from the index (its cache row is also removed on purge). */ + abstract removePage(pageId: string): Promise; + /** Ranked, permission-filtered results for `user`. */ + abstract search(query: SearchQuery, user: User): Promise; + /** Rebuild the whole index from `page_content_cache`; returns rows indexed. */ + abstract reindexAll(): Promise; +} diff --git a/apps/api/src/search/search.service.db.test.ts b/apps/api/src/search/search.service.db.test.ts new file mode 100644 index 0000000..71a3c9d --- /dev/null +++ b/apps/api/src/search/search.service.db.test.ts @@ -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 { + 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()); + }); +}); diff --git a/apps/collab/src/persistence.ts b/apps/collab/src/persistence.ts index 159e09a..b220683 100644 --- a/apps/collab/src/persistence.ts +++ b/apps/collab/src/persistence.ts @@ -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 * 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 // allocation and guards against storing to a page trashed mid-session. - const page = await client.query<{ pond_id: string }>( - 'SELECT pond_id FROM pages WHERE id = $1 AND deleted_at IS NULL FOR UPDATE', + const page = await client.query<{ pond_id: string; title: string }>( + 'SELECT pond_id, title FROM pages WHERE id = $1 AND deleted_at IS NULL FOR UPDATE', [pageId], ); 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 // 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). diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2a63c10..7a96d22 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export * from './i18n-tools'; export * from './labels'; export * from './links'; export * from './pages'; +export * from './search'; export * from './ponds'; export * from './quotas'; export * from './text-diff'; diff --git a/packages/shared/src/search.test.ts b/packages/shared/src/search.test.ts new file mode 100644 index 0000000..cf5fdcd --- /dev/null +++ b/packages/shared/src/search.test.ts @@ -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'] }); + }); +}); diff --git a/packages/shared/src/search.ts b/packages/shared/src/search.ts new file mode 100644 index 0000000..60845ed --- /dev/null +++ b/packages/shared/src/search.ts @@ -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+0300–U+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; + +/** 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; +}