@86d-app/search 0.0.4 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +72 -0
- package/README.md +171 -28
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/embedding-provider.test.d.ts +2 -0
- package/dist/__tests__/embedding-provider.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/meilisearch-provider.test.d.ts +2 -0
- package/dist/__tests__/meilisearch-provider.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +2 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/search-analytics.d.ts +2 -0
- package/dist/admin/components/search-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/analytics.d.ts +15 -0
- package/dist/admin/endpoints/analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/bulk-index.d.ts +20 -0
- package/dist/admin/endpoints/bulk-index.d.ts.map +1 -0
- package/dist/admin/endpoints/click-analytics.d.ts +7 -0
- package/dist/admin/endpoints/click-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/get-settings.d.ts +17 -0
- package/dist/admin/endpoints/get-settings.d.ts.map +1 -0
- package/dist/admin/endpoints/index-manage.d.ts +26 -0
- package/dist/admin/endpoints/index-manage.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +125 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/popular.d.ts +10 -0
- package/dist/admin/endpoints/popular.d.ts.map +1 -0
- package/dist/admin/endpoints/synonyms.d.ts +30 -0
- package/dist/admin/endpoints/synonyms.d.ts.map +1 -0
- package/dist/admin/endpoints/zero-results.d.ts +10 -0
- package/dist/admin/endpoints/zero-results.d.ts.map +1 -0
- package/dist/embedding-provider.d.ts +28 -0
- package/dist/embedding-provider.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/meilisearch-provider.d.ts +104 -0
- package/dist/meilisearch-provider.d.ts.map +1 -0
- package/dist/schema.d.ts +133 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +6 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +127 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +6 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +10 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/search-bar.d.ts +7 -0
- package/dist/store/components/search-bar.d.ts.map +1 -0
- package/dist/store/components/search-page.d.ts +4 -0
- package/dist/store/components/search-page.d.ts.map +1 -0
- package/dist/store/components/search-results.d.ts +9 -0
- package/dist/store/components/search-results.d.ts.map +1 -0
- package/dist/store/endpoints/click.d.ts +14 -0
- package/dist/store/endpoints/click.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +85 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/recent.d.ts +15 -0
- package/dist/store/endpoints/recent.d.ts.map +1 -0
- package/dist/store/endpoints/search.d.ts +36 -0
- package/dist/store/endpoints/search.d.ts.map +1 -0
- package/dist/store/endpoints/store-search.d.ts +16 -0
- package/dist/store/endpoints/store-search.d.ts.map +1 -0
- package/dist/store/endpoints/suggest.d.ts +11 -0
- package/dist/store/endpoints/suggest.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1026 -0
- package/src/__tests__/embedding-provider.test.ts +195 -0
- package/src/__tests__/endpoint-security.test.ts +300 -0
- package/src/__tests__/meilisearch-provider.test.ts +400 -0
- package/src/__tests__/service-impl.test.ts +341 -8
- package/src/admin/components/search-analytics.tsx +120 -0
- package/src/admin/endpoints/bulk-index.ts +34 -0
- package/src/admin/endpoints/click-analytics.ts +16 -0
- package/src/admin/endpoints/get-settings.ts +56 -0
- package/src/admin/endpoints/index-manage.ts +4 -1
- package/src/admin/endpoints/index.ts +6 -0
- package/src/admin/endpoints/synonyms.ts +1 -1
- package/src/embedding-provider.ts +99 -0
- package/src/index.ts +60 -4
- package/src/meilisearch-provider.ts +239 -0
- package/src/schema.ts +15 -0
- package/src/service-impl.ts +605 -34
- package/src/service.ts +60 -1
- package/src/store/endpoints/click.ts +21 -0
- package/src/store/endpoints/index.ts +2 -0
- package/src/store/endpoints/recent.ts +1 -1
- package/src/store/endpoints/search.ts +38 -10
- package/src/store/endpoints/store-search.ts +1 -1
- package/src/store/endpoints/suggest.ts +2 -2
- package/vitest.config.ts +2 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { analyticsEndpoint } from "./analytics";
|
|
2
|
+
import { bulkIndex } from "./bulk-index";
|
|
3
|
+
import { clickAnalyticsEndpoint } from "./click-analytics";
|
|
4
|
+
import { getSettings } from "./get-settings";
|
|
2
5
|
import { indexItem, removeFromIndex } from "./index-manage";
|
|
3
6
|
import { popularEndpoint } from "./popular";
|
|
4
7
|
import { addSynonym, listSynonyms, removeSynonym } from "./synonyms";
|
|
5
8
|
import { zeroResultsEndpoint } from "./zero-results";
|
|
6
9
|
|
|
7
10
|
export const adminEndpoints = {
|
|
11
|
+
"/admin/search/settings": getSettings,
|
|
8
12
|
"/admin/search/analytics": analyticsEndpoint,
|
|
9
13
|
"/admin/search/popular": popularEndpoint,
|
|
10
14
|
"/admin/search/zero-results": zeroResultsEndpoint,
|
|
15
|
+
"/admin/search/clicks": clickAnalyticsEndpoint,
|
|
11
16
|
"/admin/search/synonyms": listSynonyms,
|
|
12
17
|
"/admin/search/synonyms/add": addSynonym,
|
|
13
18
|
"/admin/search/synonyms/:id/delete": removeSynonym,
|
|
14
19
|
"/admin/search/index": indexItem,
|
|
15
20
|
"/admin/search/index/remove": removeFromIndex,
|
|
21
|
+
"/admin/search/index/bulk": bulkIndex,
|
|
16
22
|
};
|
|
@@ -42,7 +42,7 @@ export const removeSynonym = createAdminEndpoint(
|
|
|
42
42
|
const controller = ctx.context.controllers.search as SearchController;
|
|
43
43
|
const removed = await controller.removeSynonym(ctx.params.id);
|
|
44
44
|
if (!removed) {
|
|
45
|
-
|
|
45
|
+
return { error: "Synonym not found", status: 404 };
|
|
46
46
|
}
|
|
47
47
|
return { success: true };
|
|
48
48
|
},
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding provider for AI-powered semantic search.
|
|
3
|
+
*
|
|
4
|
+
* Calls OpenAI's text-embedding-3-small model to generate vector embeddings
|
|
5
|
+
* for indexed items and search queries. When configured, search results are
|
|
6
|
+
* ranked using a hybrid of lexical scoring and cosine similarity.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface EmbeddingResponse {
|
|
10
|
+
data: Array<{ embedding: number[]; index: number }>;
|
|
11
|
+
model: string;
|
|
12
|
+
usage: { prompt_tokens: number; total_tokens: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface EmbeddingErrorResponse {
|
|
16
|
+
error: { message: string; type: string; code?: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EmbeddingProvider {
|
|
20
|
+
generateEmbedding(text: string): Promise<number[] | null>;
|
|
21
|
+
generateEmbeddings(texts: string[]): Promise<Array<number[] | null>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
25
|
+
private readonly apiKey: string;
|
|
26
|
+
private readonly model: string;
|
|
27
|
+
private readonly baseUrl: string;
|
|
28
|
+
|
|
29
|
+
constructor(apiKey: string, options?: { model?: string; baseUrl?: string }) {
|
|
30
|
+
this.apiKey = apiKey;
|
|
31
|
+
this.model = options?.model ?? "text-embedding-3-small";
|
|
32
|
+
this.baseUrl = options?.baseUrl ?? "https://api.openai.com/v1";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async generateEmbedding(text: string): Promise<number[] | null> {
|
|
36
|
+
const results = await this.generateEmbeddings([text]);
|
|
37
|
+
return results[0];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async generateEmbeddings(texts: string[]): Promise<Array<number[] | null>> {
|
|
41
|
+
if (texts.length === 0) return [];
|
|
42
|
+
|
|
43
|
+
// Truncate and clean input texts
|
|
44
|
+
const cleaned = texts.map((t) => t.slice(0, 8000).trim()).filter(Boolean);
|
|
45
|
+
if (cleaned.length === 0) return texts.map(() => null);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${this.baseUrl}/embeddings`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
input: cleaned,
|
|
56
|
+
model: this.model,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const err = (await res.json()) as EmbeddingErrorResponse;
|
|
62
|
+
console.error(
|
|
63
|
+
`Embedding API error: ${err.error?.message ?? `HTTP ${res.status}`}`,
|
|
64
|
+
);
|
|
65
|
+
return texts.map(() => null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const json = (await res.json()) as EmbeddingResponse;
|
|
69
|
+
const results: Array<number[] | null> = texts.map(() => null);
|
|
70
|
+
for (const item of json.data) {
|
|
71
|
+
if (item.index < cleaned.length) {
|
|
72
|
+
results[item.index] = item.embedding;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error("Embedding API request failed:", err);
|
|
78
|
+
return texts.map(() => null);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cosine similarity between two vectors.
|
|
85
|
+
* Returns a value between -1 and 1 (1 = identical direction).
|
|
86
|
+
*/
|
|
87
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
88
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
89
|
+
let dot = 0;
|
|
90
|
+
let normA = 0;
|
|
91
|
+
let normB = 0;
|
|
92
|
+
for (let i = 0; i < a.length; i++) {
|
|
93
|
+
dot += a[i] * b[i];
|
|
94
|
+
normA += a[i] * a[i];
|
|
95
|
+
normB += b[i] * b[i];
|
|
96
|
+
}
|
|
97
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
98
|
+
return denom === 0 ? 0 : dot / denom;
|
|
99
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,35 +1,91 @@
|
|
|
1
1
|
import type { Module, ModuleConfig, ModuleContext } from "@86d-app/core";
|
|
2
2
|
import { adminEndpoints } from "./admin/endpoints";
|
|
3
|
+
import { OpenAIEmbeddingProvider } from "./embedding-provider";
|
|
4
|
+
import { MeiliSearchProvider } from "./meilisearch-provider";
|
|
3
5
|
import { searchSchema } from "./schema";
|
|
4
6
|
import { createSearchController } from "./service-impl";
|
|
5
7
|
import { storeEndpoints } from "./store/endpoints";
|
|
6
8
|
|
|
9
|
+
export type { EmbeddingProvider } from "./embedding-provider";
|
|
10
|
+
export { OpenAIEmbeddingProvider } from "./embedding-provider";
|
|
11
|
+
export type { MeiliSearchProvider } from "./meilisearch-provider";
|
|
7
12
|
export type {
|
|
13
|
+
SearchClick,
|
|
8
14
|
SearchController,
|
|
15
|
+
SearchFacets,
|
|
16
|
+
SearchHighlight,
|
|
9
17
|
SearchIndexItem,
|
|
10
18
|
SearchQuery,
|
|
11
19
|
SearchResult,
|
|
20
|
+
SearchSortField,
|
|
12
21
|
SearchSynonym,
|
|
13
22
|
} from "./service";
|
|
14
23
|
|
|
15
24
|
export interface SearchOptions extends ModuleConfig {
|
|
16
25
|
/** Maximum number of search results per query */
|
|
17
26
|
maxResults?: number;
|
|
27
|
+
/** OpenAI API key for AI-powered semantic search */
|
|
28
|
+
openaiApiKey?: string;
|
|
29
|
+
/** OpenRouter API key (alternative to OpenAI) */
|
|
30
|
+
openrouterApiKey?: string;
|
|
31
|
+
/** Embedding model name (default: text-embedding-3-small) */
|
|
32
|
+
embeddingModel?: string;
|
|
33
|
+
/** MeiliSearch host URL (e.g. http://localhost:7700) */
|
|
34
|
+
meilisearchHost?: string;
|
|
35
|
+
/** MeiliSearch API key (master or search key) */
|
|
36
|
+
meilisearchApiKey?: string;
|
|
37
|
+
/** MeiliSearch index name (default: "search") */
|
|
38
|
+
meilisearchIndexUid?: string;
|
|
18
39
|
}
|
|
19
40
|
|
|
20
41
|
export default function search(options?: SearchOptions): Module {
|
|
42
|
+
// Create embedding provider from env-var-based API keys
|
|
43
|
+
let embeddingProvider: OpenAIEmbeddingProvider | undefined;
|
|
44
|
+
if (options?.openaiApiKey) {
|
|
45
|
+
embeddingProvider = new OpenAIEmbeddingProvider(options.openaiApiKey, {
|
|
46
|
+
...(options.embeddingModel ? { model: options.embeddingModel } : {}),
|
|
47
|
+
});
|
|
48
|
+
} else if (options?.openrouterApiKey) {
|
|
49
|
+
embeddingProvider = new OpenAIEmbeddingProvider(options.openrouterApiKey, {
|
|
50
|
+
model: options.embeddingModel ?? "openai/text-embedding-3-small",
|
|
51
|
+
baseUrl: "https://openrouter.ai/api/v1" as string,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Create MeiliSearch provider when host and API key are configured
|
|
56
|
+
let meiliProvider: MeiliSearchProvider | undefined;
|
|
57
|
+
if (options?.meilisearchHost && options?.meilisearchApiKey) {
|
|
58
|
+
meiliProvider = new MeiliSearchProvider(
|
|
59
|
+
options.meilisearchHost,
|
|
60
|
+
options.meilisearchApiKey,
|
|
61
|
+
options.meilisearchIndexUid,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
21
65
|
return {
|
|
22
66
|
id: "search",
|
|
23
|
-
version: "0.0
|
|
67
|
+
version: "0.1.0",
|
|
24
68
|
schema: searchSchema,
|
|
25
69
|
exports: {
|
|
26
70
|
read: ["searchIndexCount", "popularTerms"],
|
|
27
71
|
},
|
|
28
72
|
events: {
|
|
29
|
-
emits: [
|
|
73
|
+
emits: [
|
|
74
|
+
"search.queried",
|
|
75
|
+
"search.indexed",
|
|
76
|
+
"search.removed",
|
|
77
|
+
"search.clicked",
|
|
78
|
+
],
|
|
30
79
|
},
|
|
31
80
|
init: async (ctx: ModuleContext) => {
|
|
32
|
-
|
|
81
|
+
if (meiliProvider) {
|
|
82
|
+
void meiliProvider.configureIndex();
|
|
83
|
+
}
|
|
84
|
+
const controller = createSearchController(
|
|
85
|
+
ctx.data,
|
|
86
|
+
embeddingProvider,
|
|
87
|
+
meiliProvider,
|
|
88
|
+
);
|
|
33
89
|
return { controllers: { search: controller } };
|
|
34
90
|
},
|
|
35
91
|
search: { store: "/search/store-search" },
|
|
@@ -44,7 +100,7 @@ export default function search(options?: SearchOptions): Module {
|
|
|
44
100
|
component: "SearchAnalytics",
|
|
45
101
|
label: "Search",
|
|
46
102
|
icon: "MagnifyingGlass",
|
|
47
|
-
group: "
|
|
103
|
+
group: "System",
|
|
48
104
|
},
|
|
49
105
|
],
|
|
50
106
|
},
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeiliSearch provider for real search engine integration.
|
|
3
|
+
*
|
|
4
|
+
* When configured with a MeiliSearch host and API key, the search module
|
|
5
|
+
* delegates indexing and querying to a dedicated MeiliSearch instance instead
|
|
6
|
+
* of relying on in-application lexical search. Falls back gracefully when
|
|
7
|
+
* the MeiliSearch instance is unreachable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface MeiliSearchDocument {
|
|
11
|
+
id: string;
|
|
12
|
+
entityType: string;
|
|
13
|
+
entityId: string;
|
|
14
|
+
title: string;
|
|
15
|
+
body?: string | undefined;
|
|
16
|
+
tags: string[];
|
|
17
|
+
url: string;
|
|
18
|
+
image?: string | undefined;
|
|
19
|
+
indexedAt: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MeiliSearchHit {
|
|
24
|
+
id: string;
|
|
25
|
+
entityType: string;
|
|
26
|
+
entityId: string;
|
|
27
|
+
title: string;
|
|
28
|
+
body?: string;
|
|
29
|
+
tags: string[];
|
|
30
|
+
url: string;
|
|
31
|
+
image?: string;
|
|
32
|
+
indexedAt: string;
|
|
33
|
+
_formatted?: {
|
|
34
|
+
title?: string;
|
|
35
|
+
body?: string;
|
|
36
|
+
[key: string]: string | undefined;
|
|
37
|
+
};
|
|
38
|
+
_rankingScore?: number;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MeiliSearchResponse {
|
|
43
|
+
hits: MeiliSearchHit[];
|
|
44
|
+
query: string;
|
|
45
|
+
processingTimeMs: number;
|
|
46
|
+
estimatedTotalHits?: number;
|
|
47
|
+
totalHits?: number;
|
|
48
|
+
facetDistribution?: Record<string, Record<string, number>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MeiliSearchTaskResponse {
|
|
52
|
+
taskUid: number;
|
|
53
|
+
indexUid: string;
|
|
54
|
+
status: "enqueued" | "processing" | "succeeded" | "failed";
|
|
55
|
+
type: string;
|
|
56
|
+
enqueuedAt: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface MeiliSearchError {
|
|
60
|
+
message: string;
|
|
61
|
+
code: string;
|
|
62
|
+
type: string;
|
|
63
|
+
link?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface MeiliSearchHealthResponse {
|
|
67
|
+
status: "available";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface MeiliSearchStatsResponse {
|
|
71
|
+
numberOfDocuments: number;
|
|
72
|
+
isIndexing: boolean;
|
|
73
|
+
fieldDistribution: Record<string, number>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class MeiliSearchProvider {
|
|
77
|
+
private readonly host: string;
|
|
78
|
+
private readonly apiKey: string;
|
|
79
|
+
private readonly indexUid: string;
|
|
80
|
+
|
|
81
|
+
constructor(host: string, apiKey: string, indexUid = "search") {
|
|
82
|
+
// Strip trailing slash from host
|
|
83
|
+
this.host = host.replace(/\/+$/, "");
|
|
84
|
+
this.apiKey = apiKey;
|
|
85
|
+
this.indexUid = indexUid;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Add or replace documents in the MeiliSearch index.
|
|
90
|
+
* MeiliSearch uses the `id` field as the primary key by default.
|
|
91
|
+
*/
|
|
92
|
+
async addDocuments(
|
|
93
|
+
documents: MeiliSearchDocument[],
|
|
94
|
+
): Promise<MeiliSearchTaskResponse> {
|
|
95
|
+
const res = await this.request(
|
|
96
|
+
`/indexes/${this.indexUid}/documents`,
|
|
97
|
+
"POST",
|
|
98
|
+
documents,
|
|
99
|
+
);
|
|
100
|
+
return res as MeiliSearchTaskResponse;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Delete a single document by its ID.
|
|
105
|
+
*/
|
|
106
|
+
async deleteDocument(documentId: string): Promise<MeiliSearchTaskResponse> {
|
|
107
|
+
const res = await this.request(
|
|
108
|
+
`/indexes/${this.indexUid}/documents/${encodeURIComponent(documentId)}`,
|
|
109
|
+
"DELETE",
|
|
110
|
+
);
|
|
111
|
+
return res as MeiliSearchTaskResponse;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Search the MeiliSearch index.
|
|
116
|
+
*/
|
|
117
|
+
async search(
|
|
118
|
+
query: string,
|
|
119
|
+
options?: {
|
|
120
|
+
limit?: number | undefined;
|
|
121
|
+
offset?: number | undefined;
|
|
122
|
+
filter?: string | undefined;
|
|
123
|
+
sort?: string[] | undefined;
|
|
124
|
+
facets?: string[] | undefined;
|
|
125
|
+
attributesToHighlight?: string[] | undefined;
|
|
126
|
+
highlightPreTag?: string | undefined;
|
|
127
|
+
highlightPostTag?: string | undefined;
|
|
128
|
+
showRankingScore?: boolean | undefined;
|
|
129
|
+
matchingStrategy?: "last" | "all" | "frequency" | undefined;
|
|
130
|
+
},
|
|
131
|
+
): Promise<MeiliSearchResponse> {
|
|
132
|
+
const body: Record<string, unknown> = { q: query };
|
|
133
|
+
|
|
134
|
+
if (options?.limit !== undefined) body.limit = options.limit;
|
|
135
|
+
if (options?.offset !== undefined) body.offset = options.offset;
|
|
136
|
+
if (options?.filter) body.filter = options.filter;
|
|
137
|
+
if (options?.sort) body.sort = options.sort;
|
|
138
|
+
if (options?.facets) body.facets = options.facets;
|
|
139
|
+
if (options?.attributesToHighlight) {
|
|
140
|
+
body.attributesToHighlight = options.attributesToHighlight;
|
|
141
|
+
}
|
|
142
|
+
if (options?.highlightPreTag) {
|
|
143
|
+
body.highlightPreTag = options.highlightPreTag;
|
|
144
|
+
}
|
|
145
|
+
if (options?.highlightPostTag) {
|
|
146
|
+
body.highlightPostTag = options.highlightPostTag;
|
|
147
|
+
}
|
|
148
|
+
if (options?.showRankingScore) body.showRankingScore = true;
|
|
149
|
+
if (options?.matchingStrategy) {
|
|
150
|
+
body.matchingStrategy = options.matchingStrategy;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const res = await this.request(
|
|
154
|
+
`/indexes/${this.indexUid}/search`,
|
|
155
|
+
"POST",
|
|
156
|
+
body,
|
|
157
|
+
);
|
|
158
|
+
return res as MeiliSearchResponse;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if the MeiliSearch instance is reachable and healthy.
|
|
163
|
+
*/
|
|
164
|
+
async isHealthy(): Promise<boolean> {
|
|
165
|
+
try {
|
|
166
|
+
const res = await this.request("/health", "GET");
|
|
167
|
+
return (res as MeiliSearchHealthResponse).status === "available";
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get index statistics (document count, indexing status).
|
|
175
|
+
*/
|
|
176
|
+
async getStats(): Promise<MeiliSearchStatsResponse | null> {
|
|
177
|
+
try {
|
|
178
|
+
const res = await this.request(`/indexes/${this.indexUid}/stats`, "GET");
|
|
179
|
+
return res as MeiliSearchStatsResponse;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Configure index settings: filterable and sortable attributes.
|
|
187
|
+
* Called once on init to ensure MeiliSearch knows which fields to index.
|
|
188
|
+
*/
|
|
189
|
+
async configureIndex(): Promise<void> {
|
|
190
|
+
try {
|
|
191
|
+
await this.request(`/indexes/${this.indexUid}/settings`, "PATCH", {
|
|
192
|
+
filterableAttributes: ["entityType", "tags"],
|
|
193
|
+
sortableAttributes: ["indexedAt", "title"],
|
|
194
|
+
searchableAttributes: ["title", "body", "tags"],
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
// Non-critical — index may already be configured or not yet exist.
|
|
198
|
+
// MeiliSearch auto-creates indexes on first document addition.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async request(
|
|
203
|
+
path: string,
|
|
204
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
205
|
+
body?: unknown,
|
|
206
|
+
): Promise<unknown> {
|
|
207
|
+
const url = `${this.host}${path}`;
|
|
208
|
+
const headers: Record<string, string> = {
|
|
209
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
210
|
+
};
|
|
211
|
+
if (body !== undefined) {
|
|
212
|
+
headers["Content-Type"] = "application/json";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const res = await fetch(url, {
|
|
216
|
+
method,
|
|
217
|
+
headers,
|
|
218
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
let errorMessage = `MeiliSearch ${method} ${path} failed: HTTP ${res.status}`;
|
|
223
|
+
try {
|
|
224
|
+
const err = (await res.json()) as MeiliSearchError;
|
|
225
|
+
if (err.message) {
|
|
226
|
+
errorMessage = `MeiliSearch error: ${err.message} (${err.code})`;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Response body may not be JSON
|
|
230
|
+
}
|
|
231
|
+
throw new Error(errorMessage);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// DELETE and some endpoints may return 204 No Content
|
|
235
|
+
if (res.status === 204) return {};
|
|
236
|
+
|
|
237
|
+
return res.json();
|
|
238
|
+
}
|
|
239
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -45,4 +45,19 @@ export const searchSchema = {
|
|
|
45
45
|
},
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
|
+
searchClick: {
|
|
49
|
+
fields: {
|
|
50
|
+
id: { type: "string", required: true },
|
|
51
|
+
queryId: { type: "string", required: true },
|
|
52
|
+
term: { type: "string", required: true },
|
|
53
|
+
entityType: { type: "string", required: true },
|
|
54
|
+
entityId: { type: "string", required: true },
|
|
55
|
+
position: { type: "number", required: true },
|
|
56
|
+
clickedAt: {
|
|
57
|
+
type: "date",
|
|
58
|
+
required: true,
|
|
59
|
+
defaultValue: () => new Date(),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
48
63
|
} satisfies ModuleSchema;
|