aboutsummaryrefslogtreecommitdiffstats
path: root/packages/plugins-search-meilisearch
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2025-07-27 19:37:11 +0100
committerMohamedBassem <me@mbassem.com>2025-07-27 19:37:11 +0100
commitb94896a0f8fa43b957a9bdd6ab57ada0ab8101af (patch)
treeed8f79ce7d407379fa0d8210db52959f849fac0e /packages/plugins-search-meilisearch
parent7bb7f18fbf8e374efde2fe28bacfc29157b9fa19 (diff)
downloadkarakeep-b94896a0f8fa43b957a9bdd6ab57ada0ab8101af.tar.zst
refactor: Extract meilisearch as a plugin
Diffstat (limited to 'packages/plugins-search-meilisearch')
-rw-r--r--packages/plugins-search-meilisearch/.oxlintrc.json19
-rw-r--r--packages/plugins-search-meilisearch/index.ts12
-rw-r--r--packages/plugins-search-meilisearch/package.json26
-rw-r--r--packages/plugins-search-meilisearch/src/env.ts8
-rw-r--r--packages/plugins-search-meilisearch/src/index.ts174
-rw-r--r--packages/plugins-search-meilisearch/tsconfig.json9
6 files changed, 248 insertions, 0 deletions
diff --git a/packages/plugins-search-meilisearch/.oxlintrc.json b/packages/plugins-search-meilisearch/.oxlintrc.json
new file mode 100644
index 00000000..79ba0255
--- /dev/null
+++ b/packages/plugins-search-meilisearch/.oxlintrc.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "../../node_modules/oxlint/configuration_schema.json",
+ "extends": [
+ "../../tooling/oxlint/oxlint-base.json"
+ ],
+ "env": {
+ "builtin": true,
+ "commonjs": true
+ },
+ "ignorePatterns": [
+ "**/*.config.js",
+ "**/*.config.cjs",
+ "**/.eslintrc.cjs",
+ "**/.next",
+ "**/dist",
+ "**/build",
+ "**/pnpm-lock.yaml"
+ ]
+}
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/package.json b/packages/plugins-search-meilisearch/package.json
new file mode 100644
index 00000000..3bc9db80
--- /dev/null
+++ b/packages/plugins-search-meilisearch/package.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@karakeep/plugins-search-meilisearch",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "format": "prettier . --ignore-path ../../.prettierignore",
+ "format:fix": "prettier . --write --ignore-path ../../.prettierignore",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint . --fix",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "@karakeep/shared": "workspace:*",
+ "meilisearch": "^0.45.0"
+ },
+ "devDependencies": {
+ "@karakeep/prettier-config": "workspace:^0.1.0",
+ "@karakeep/tsconfig": "workspace:^0.1.0",
+ "vite-tsconfig-paths": "^4.3.1",
+ "vitest": "^3.2.4"
+ },
+ "prettier": "@karakeep/prettier-config"
+}
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..6153a9c4
--- /dev/null
+++ b/packages/plugins-search-meilisearch/src/index.ts
@@ -0,0 +1,174 @@
+import type { Index } from "meilisearch";
+import { MeiliSearch } from "meilisearch";
+
+import type {
+ BookmarkSearchDocument,
+ SearchIndexClient,
+ SearchOptions,
+ SearchResponse,
+} from "@karakeep/shared/search";
+import { PluginProvider } from "@karakeep/shared/plugins";
+
+import { envConfig } from "./env";
+
+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.index.waitForTask(task.taskUid);
+ const taskResult = await this.index.getTask(task.taskUid);
+ if (taskResult.error) {
+ throw new Error(
+ `MeiliSearch add documents failed: ${taskResult.error.message}`,
+ );
+ }
+ }
+
+ async updateDocuments(documents: BookmarkSearchDocument[]): Promise<void> {
+ const task = await this.index.updateDocuments(documents, {
+ primaryKey: "id",
+ });
+ await this.index.waitForTask(task.taskUid);
+ const taskResult = await this.index.getTask(task.taskUid);
+ if (taskResult.error) {
+ throw new Error(
+ `MeiliSearch update documents failed: ${taskResult.error.message}`,
+ );
+ }
+ }
+
+ async deleteDocument(id: string): Promise<void> {
+ const task = await this.index.deleteDocument(id);
+ await this.index.waitForTask(task.taskUid);
+ const taskResult = await this.index.getTask(task.taskUid);
+ if (taskResult.error) {
+ throw new Error(
+ `MeiliSearch delete document failed: ${taskResult.error.message}`,
+ );
+ }
+ }
+
+ async deleteDocuments(ids: string[]): Promise<void> {
+ const task = await this.index.deleteDocuments(ids);
+ await this.index.waitForTask(task.taskUid);
+ const taskResult = await this.index.getTask(task.taskUid);
+ if (taskResult.error) {
+ throw new Error(
+ `MeiliSearch delete documents failed: ${taskResult.error.message}`,
+ );
+ }
+ }
+
+ async search(options: SearchOptions): Promise<SearchResponse> {
+ const result = await this.index.search(options.query, {
+ filter: options.filter,
+ limit: options.limit,
+ offset: options.offset,
+ sort: options.sort,
+ });
+
+ 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.index.waitForTask(task.taskUid);
+ const taskResult = await this.index.getTask(task.taskUid);
+ if (taskResult.error) {
+ throw new Error(
+ `MeiliSearch clear index failed: ${taskResult.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);
+ }
+ }
+}
diff --git a/packages/plugins-search-meilisearch/tsconfig.json b/packages/plugins-search-meilisearch/tsconfig.json
new file mode 100644
index 00000000..a795b96a
--- /dev/null
+++ b/packages/plugins-search-meilisearch/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@karakeep/tsconfig/node.json",
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ }
+}