aboutsummaryrefslogtreecommitdiffstats
path: root/packages/plugins/search-meilisearch
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-08 14:50:00 +0000
committerGitHub <noreply@github.com>2025-11-08 14:50:00 +0000
commit99413db0e79a156a1b87eacd3c6a7b83e9df946e (patch)
tree73f0a5fceb507f75f662a109b00beeb3fa6b16fb /packages/plugins/search-meilisearch
parent737b03172c2e063ba311c23d6552418bd2ab1955 (diff)
downloadkarakeep-99413db0e79a156a1b87eacd3c6a7b83e9df946e.tar.zst
refactor: consolidate multiple karakeep plugins into one package (#2101)
* refactor: consolidate plugin packages into single plugins directory - Create new `packages/plugins` directory with consolidated package.json - Move queue-liteque, queue-restate, and search-meilisearch to subdirectories - Update imports in packages/shared-server/src/plugins.ts - Remove individual plugin package directories - Update shared-server dependency to use @karakeep/plugins This reduces overhead of maintaining multiple separate packages for plugins. * refactor: consolidate plugin config files to root level - Move .oxlintrc.json to packages/plugins root - Move vitest.config.ts to packages/plugins root - Update vitest config paths to work from root - Remove individual config files from plugin subdirectories This reduces configuration duplication across plugin subdirectories. --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages/plugins/search-meilisearch')
-rw-r--r--packages/plugins/search-meilisearch/index.ts12
-rw-r--r--packages/plugins/search-meilisearch/src/env.ts8
-rw-r--r--packages/plugins/search-meilisearch/src/index.ts159
3 files changed, 179 insertions, 0 deletions
diff --git a/packages/plugins/search-meilisearch/index.ts b/packages/plugins/search-meilisearch/index.ts
new file mode 100644
index 00000000..3496d52f
--- /dev/null
+++ b/packages/plugins/search-meilisearch/index.ts
@@ -0,0 +1,12 @@
+// Auto-register the MeiliSearch provider when this package is imported
+import { PluginManager, PluginType } from "@karakeep/shared/plugins";
+
+import { MeiliSearchProvider } from "./src";
+
+if (MeiliSearchProvider.isConfigured()) {
+ PluginManager.register({
+ type: PluginType.Search,
+ name: "MeiliSearch",
+ provider: new MeiliSearchProvider(),
+ });
+}
diff --git a/packages/plugins/search-meilisearch/src/env.ts b/packages/plugins/search-meilisearch/src/env.ts
new file mode 100644
index 00000000..c06fdd55
--- /dev/null
+++ b/packages/plugins/search-meilisearch/src/env.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const envConfig = z
+ .object({
+ MEILI_ADDR: z.string().optional(),
+ MEILI_MASTER_KEY: z.string().default(""),
+ })
+ .parse(process.env);
diff --git a/packages/plugins/search-meilisearch/src/index.ts b/packages/plugins/search-meilisearch/src/index.ts
new file mode 100644
index 00000000..30da4a64
--- /dev/null
+++ b/packages/plugins/search-meilisearch/src/index.ts
@@ -0,0 +1,159 @@
+import type { Index } from "meilisearch";
+import { MeiliSearch } from "meilisearch";
+
+import type {
+ BookmarkSearchDocument,
+ FilterQuery,
+ SearchIndexClient,
+ SearchOptions,
+ SearchResponse,
+} from "@karakeep/shared/search";
+import serverConfig from "@karakeep/shared/config";
+import { PluginProvider } from "@karakeep/shared/plugins";
+
+import { envConfig } from "./env";
+
+function filterToMeiliSearchFilter(filter: FilterQuery): string {
+ switch (filter.type) {
+ case "eq":
+ return `${filter.field} = "${filter.value}"`;
+ case "in":
+ return `${filter.field} IN [${filter.values.join(",")}]`;
+ default: {
+ const exhaustiveCheck: never = filter;
+ throw new Error(`Unhandled color case: ${exhaustiveCheck}`);
+ }
+ }
+}
+
+class MeiliSearchIndexClient implements SearchIndexClient {
+ constructor(private index: Index<BookmarkSearchDocument>) {}
+
+ async addDocuments(documents: BookmarkSearchDocument[]): Promise<void> {
+ const task = await this.index.addDocuments(documents, {
+ primaryKey: "id",
+ });
+ await this.ensureTaskSuccess(task.taskUid);
+ }
+
+ async deleteDocuments(ids: string[]): Promise<void> {
+ const task = await this.index.deleteDocuments(ids);
+ await this.ensureTaskSuccess(task.taskUid);
+ }
+
+ async search(options: SearchOptions): Promise<SearchResponse> {
+ const result = await this.index.search(options.query, {
+ filter: options.filter?.map((f) => filterToMeiliSearchFilter(f)),
+ limit: options.limit,
+ offset: options.offset,
+ sort: options.sort?.map((s) => `${s.field}:${s.order}`),
+ attributesToRetrieve: ["id"],
+ showRankingScore: true,
+ });
+
+ return {
+ hits: result.hits.map((hit) => ({
+ id: hit.id,
+ score: hit._rankingScore,
+ })),
+ totalHits: result.estimatedTotalHits ?? 0,
+ processingTimeMs: result.processingTimeMs,
+ };
+ }
+
+ async clearIndex(): Promise<void> {
+ const task = await this.index.deleteAllDocuments();
+ await this.ensureTaskSuccess(task.taskUid);
+ }
+
+ private async ensureTaskSuccess(taskUid: number): Promise<void> {
+ const task = await this.index.waitForTask(taskUid, {
+ intervalMs: 200,
+ timeOutMs: serverConfig.search.jobTimeoutSec * 1000 * 0.9,
+ });
+ if (task.error) {
+ throw new Error(`Search task failed: ${task.error.message}`);
+ }
+ }
+}
+
+export class MeiliSearchProvider implements PluginProvider<SearchIndexClient> {
+ private client: MeiliSearch | undefined;
+ private indexClient: SearchIndexClient | undefined;
+ private readonly indexName = "bookmarks";
+
+ constructor() {
+ if (MeiliSearchProvider.isConfigured()) {
+ this.client = new MeiliSearch({
+ host: envConfig.MEILI_ADDR!,
+ apiKey: envConfig.MEILI_MASTER_KEY,
+ });
+ }
+ }
+
+ static isConfigured(): boolean {
+ return !!envConfig.MEILI_ADDR;
+ }
+
+ async getClient(): Promise<SearchIndexClient | null> {
+ if (this.indexClient) {
+ return this.indexClient;
+ }
+
+ if (!this.client) {
+ return null;
+ }
+
+ const indices = await this.client.getIndexes();
+ let indexFound = indices.results.find((i) => i.uid === this.indexName);
+
+ if (!indexFound) {
+ const idx = await this.client.createIndex(this.indexName, {
+ primaryKey: "id",
+ });
+ await this.client.waitForTask(idx.taskUid);
+ indexFound = await this.client.getIndex<BookmarkSearchDocument>(
+ this.indexName,
+ );
+ }
+
+ await this.configureIndex(indexFound);
+ this.indexClient = new MeiliSearchIndexClient(indexFound);
+ return this.indexClient;
+ }
+
+ private async configureIndex(
+ index: Index<BookmarkSearchDocument>,
+ ): Promise<void> {
+ const desiredFilterableAttributes = ["id", "userId"].sort();
+ const desiredSortableAttributes = ["createdAt"].sort();
+
+ const settings = await index.getSettings();
+
+ if (
+ JSON.stringify(settings.filterableAttributes?.sort()) !==
+ JSON.stringify(desiredFilterableAttributes)
+ ) {
+ console.log(
+ `[meilisearch] Updating desired filterable attributes to ${desiredFilterableAttributes} from ${settings.filterableAttributes}`,
+ );
+ const taskId = await index.updateFilterableAttributes(
+ desiredFilterableAttributes,
+ );
+ await this.client!.waitForTask(taskId.taskUid);
+ }
+
+ if (
+ JSON.stringify(settings.sortableAttributes?.sort()) !==
+ JSON.stringify(desiredSortableAttributes)
+ ) {
+ console.log(
+ `[meilisearch] Updating desired sortable attributes to ${desiredSortableAttributes} from ${settings.sortableAttributes}`,
+ );
+ const taskId = await index.updateSortableAttributes(
+ desiredSortableAttributes,
+ );
+ await this.client!.waitForTask(taskId.taskUid);
+ }
+ }
+}