mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
b91d9e9a72
* feat: Extract search into pluggable provider system Refactors the monolithic SearchHelper into a pluggable search provider architecture, enabling alternative search backends (Elasticsearch, Turbopuffer, etc.) while preserving PostgreSQL full-text search as the default. The SEARCH_PROVIDER env var selects the active provider. - Add BaseSearchProvider abstract class and SearchProviderManager - Add Hook.SearchProvider to the plugin system - Move PostgreSQL search logic into plugins/postgres-search/ - Add SearchIndexProcessor for event-driven index sync - Update all callers to use the provider manager directly - Keep SearchHelper as a deprecated thin wrapper for backwards compat Closes #11347 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Remove deprecated SearchHelper wrapper All callers now use SearchProviderManager directly, so the thin delegation wrapper is no longer needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Rename postgres-search plugin to search-postgres Renames the plugin folder and id so that future search provider plugins (e.g. search-elasticsearch, search-turbopuffer) will be colocated alphabetically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Remove special-case plugin import from SearchProviderManager Make PluginManager.loadPlugins resilient to individual plugin load failures so SearchProviderManager can use the standard getHooks path without needing to directly import the search-postgres plugin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add missing search provider tests for full coverage parity Adds all tests that existed in the old SearchHelper.test.ts but were missing from PostgresSearchProvider.test.ts, including searchTitlesForUser status filters, collection filtering, group memberships, and sorting tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feedback --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
140 lines
3.9 KiB
TypeScript
140 lines
3.9 KiB
TypeScript
import { SearchableModel } from "@shared/types";
|
|
import { Document, Collection, Comment } from "@server/models";
|
|
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
|
import type {
|
|
DocumentEvent,
|
|
DocumentMovedEvent,
|
|
CollectionEvent,
|
|
CommentEvent,
|
|
CommentUpdateEvent,
|
|
Event,
|
|
} from "@server/types";
|
|
import SearchProviderManager from "@server/utils/SearchProviderManager";
|
|
|
|
/**
|
|
* Processor that keeps the search index in sync with data changes.
|
|
* For PostgreSQL this is largely a no-op since tsvector triggers handle
|
|
* indexing, but external providers (Elasticsearch, etc.) rely on these
|
|
* events to maintain their indexes.
|
|
*/
|
|
export default class SearchIndexProcessor extends BaseProcessor {
|
|
static applicableEvents: Event["name"][] = [
|
|
"documents.publish",
|
|
"documents.update.delayed",
|
|
"documents.archive",
|
|
"documents.unarchive",
|
|
"documents.delete",
|
|
"documents.permanent_delete",
|
|
"documents.move",
|
|
"collections.create",
|
|
"collections.update",
|
|
"collections.delete",
|
|
"comments.create",
|
|
"comments.update",
|
|
"comments.delete",
|
|
];
|
|
|
|
async perform(
|
|
event: DocumentEvent | DocumentMovedEvent | CollectionEvent | CommentEvent
|
|
): Promise<void> {
|
|
const provider = SearchProviderManager.getProvider();
|
|
|
|
// When using the built-in Postgres search provider, tsvector triggers
|
|
// handle indexing directly and the provider methods are effectively no-ops for now.
|
|
if (process.env.SEARCH_PROVIDER === "postgres") {
|
|
return;
|
|
}
|
|
|
|
switch (event.name) {
|
|
case "documents.publish":
|
|
case "documents.update.delayed":
|
|
case "documents.unarchive": {
|
|
const document = await Document.findByPk(
|
|
(event as DocumentEvent).documentId
|
|
);
|
|
if (document) {
|
|
await provider.index(SearchableModel.Document, document);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "documents.archive":
|
|
case "documents.delete": {
|
|
const document = await Document.findByPk(
|
|
(event as DocumentEvent).documentId,
|
|
{ paranoid: false }
|
|
);
|
|
if (document) {
|
|
await provider.updateMetadata(SearchableModel.Document, document.id, {
|
|
archivedAt: document.archivedAt,
|
|
deletedAt: document.deletedAt,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "documents.permanent_delete": {
|
|
await provider.remove(
|
|
SearchableModel.Document,
|
|
(event as DocumentEvent).documentId,
|
|
event.teamId
|
|
);
|
|
break;
|
|
}
|
|
|
|
case "documents.move": {
|
|
const movedEvent = event as DocumentMovedEvent;
|
|
for (const documentId of movedEvent.data.documentIds) {
|
|
await provider.updateMetadata(SearchableModel.Document, documentId, {
|
|
collectionId: movedEvent.collectionId,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "collections.create":
|
|
case "collections.update": {
|
|
const collection = await Collection.findByPk(
|
|
(event as CollectionEvent).collectionId
|
|
);
|
|
if (collection) {
|
|
await provider.index(SearchableModel.Collection, collection);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "collections.delete": {
|
|
await provider.remove(
|
|
SearchableModel.Collection,
|
|
(event as CollectionEvent).collectionId,
|
|
event.teamId
|
|
);
|
|
break;
|
|
}
|
|
|
|
case "comments.create":
|
|
case "comments.update": {
|
|
const comment = await Comment.findByPk(
|
|
(event as CommentEvent | CommentUpdateEvent).modelId
|
|
);
|
|
if (comment) {
|
|
await provider.index(SearchableModel.Comment, comment);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "comments.delete": {
|
|
await provider.remove(
|
|
SearchableModel.Comment,
|
|
(event as CommentEvent).modelId,
|
|
event.teamId
|
|
);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|