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
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:
parent
6c38abc20c
commit
91dfccf226
@ -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:*",
|
||||||
|
|||||||
@ -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");
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
140
apps/api/src/search/postgres-search.provider.ts
Normal file
140
apps/api/src/search/postgres-search.provider.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/api/src/search/reindex.cli.ts
Normal file
27
apps/api/src/search/reindex.cli.ts
Normal 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;
|
||||||
|
});
|
||||||
33
apps/api/src/search/search.controller.ts
Normal file
33
apps/api/src/search/search.controller.ts
Normal 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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/search/search.module.ts
Normal file
17
apps/api/src/search/search.module.ts
Normal 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 {}
|
||||||
52
apps/api/src/search/search.provider.test.ts
Normal file
52
apps/api/src/search/search.provider.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
apps/api/src/search/search.provider.ts
Normal file
20
apps/api/src/search/search.provider.ts
Normal 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>;
|
||||||
|
}
|
||||||
116
apps/api/src/search/search.service.db.test.ts
Normal file
116
apps/api/src/search/search.service.db.test.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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).
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
23
packages/shared/src/search.test.ts
Normal file
23
packages/shared/src/search.test.ts
Normal 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'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
52
packages/shared/src/search.ts
Normal file
52
packages/shared/src/search.ts
Normal 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+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<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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user