@meeovi/search 1.0.0 → 1.0.2
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/README.md +55 -0
- package/package.json +2 -2
- package/src/types/api/global-search.ts +8 -0
- package/src/utils/search/analytics.ts +21 -0
- package/src/utils/search/client.ts +43 -0
- package/src/utils/search/configurations.ts +16 -0
- package/src/utils/search/filters.ts +25 -0
- package/src/utils/search/indexing.ts +40 -0
- package/src/utils/search/migrations.ts +21 -0
- package/src/utils/search/multiSearch.ts +17 -0
- package/src/utils/search/permissions.ts +23 -0
- package/src/utils/search/relevancy.ts +16 -0
- package/src/utils/search/security.ts +17 -0
- package/src/utils/search/sorting.ts +9 -0
- package/src/utils/search/tasks.ts +14 -0
- package/src/utils/search/telemetry.ts +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
# 📦 `@meeovi/search` — README.md
|
|
5
|
+
|
|
6
|
+
```md
|
|
7
|
+
# @meeovi/search
|
|
8
|
+
|
|
9
|
+
A backend‑agnostic search abstraction for Meeovi.
|
|
10
|
+
Supports Searchkit, Meilisearch, Typesense, Algolia, and custom search engines.
|
|
11
|
+
|
|
12
|
+
## ✨ Features
|
|
13
|
+
|
|
14
|
+
- Unified `useSearch()` composable
|
|
15
|
+
- Pluggable search providers
|
|
16
|
+
- Runtime configuration
|
|
17
|
+
- Works with any search backend
|
|
18
|
+
|
|
19
|
+
## 📦 Installation
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @meeovi/search
|
|
23
|
+
|
|
24
|
+
⚙️ Configuration
|
|
25
|
+
|
|
26
|
+
import { setSearchConfig } from '@meeovi/search'
|
|
27
|
+
|
|
28
|
+
setSearchConfig({
|
|
29
|
+
searchProvider: 'searchkit',
|
|
30
|
+
searchUrl: 'https://api.meeovi.com/search'
|
|
31
|
+
})
|
|
32
|
+
🧩 Usage
|
|
33
|
+
|
|
34
|
+
import { useSearch } from '@meeovi/search'
|
|
35
|
+
|
|
36
|
+
const { query, fetch } = useSearch()
|
|
37
|
+
|
|
38
|
+
const results = await fetch(query('laptops'))
|
|
39
|
+
🔌 Providers
|
|
40
|
+
|
|
41
|
+
export interface SearchProvider {
|
|
42
|
+
query(input: string): any
|
|
43
|
+
fetch(query: any): Promise<any>
|
|
44
|
+
}
|
|
45
|
+
Register:
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
registerSearchProvider('searchkit', { query, fetch })
|
|
49
|
+
🧱 Folder Structure
|
|
50
|
+
Code
|
|
51
|
+
src/
|
|
52
|
+
providers/
|
|
53
|
+
config.
|
|
54
|
+
registry.
|
|
55
|
+
useSearch.
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getAdminClient } from './client'
|
|
2
|
+
|
|
3
|
+
const TELEMETRY_INDEX = 'search_telemetry'
|
|
4
|
+
|
|
5
|
+
export async function logSearchEvent(event: Record<string, any>) {
|
|
6
|
+
const client = getAdminClient()
|
|
7
|
+
const idx = client.index(TELEMETRY_INDEX)
|
|
8
|
+
const payload = Object.assign({}, event, { ts: new Date().toISOString() })
|
|
9
|
+
return idx.addDocuments([payload])
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function getTelemetryStats() {
|
|
13
|
+
const client = getAdminClient()
|
|
14
|
+
try {
|
|
15
|
+
return client.index(TELEMETRY_INDEX).getStats()
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default { logSearchEvent, getTelemetryStats }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Instantiate a Searchkit SDK client. The SDK is expected to be installed.
|
|
2
|
+
import Client from '@searchkit/instantsearch-client'
|
|
3
|
+
import { useRuntimeConfig } from '#imports'
|
|
4
|
+
|
|
5
|
+
function getRuntimeConfig(): any {
|
|
6
|
+
try {
|
|
7
|
+
return useRuntimeConfig()
|
|
8
|
+
} catch (e) {
|
|
9
|
+
return undefined
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getAdminClient(): any {
|
|
14
|
+
const cfg = getRuntimeConfig()
|
|
15
|
+
const host = cfg?.public?.searchkit?.host || process.env.SEARCHKIT_HOST || process.env.ELASTICSEARCH_HOST || cfg?.public?.meilisearch?.host || process.env.MEILISEARCH_HOST
|
|
16
|
+
const apiKey = cfg?.searchkit?.adminApiKey || process.env.SEARCHKIT_ADMIN_API_KEY || cfg?.meilisearch?.adminApiKey || process.env.MEILISEARCH_ADMIN_API_KEY
|
|
17
|
+
if (!host) throw new Error('Search host not configured')
|
|
18
|
+
try {
|
|
19
|
+
return new (Client as any)({ host, apiKey })
|
|
20
|
+
} catch (e) {
|
|
21
|
+
// Fallback to a minimal placeholder if SDK instantiation fails
|
|
22
|
+
return { host, apiKey, _client: 'searchkit-fallback' } as any
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getSearchClient(): any {
|
|
27
|
+
const cfg = getRuntimeConfig()
|
|
28
|
+
const host = cfg?.public?.searchkit?.host || process.env.SEARCHKIT_HOST || process.env.ELASTICSEARCH_HOST || cfg?.public?.meilisearch?.host || process.env.MEILISEARCH_HOST
|
|
29
|
+
const apiKey = cfg?.public?.searchkit?.searchApiKey || process.env.SEARCHKIT_SEARCH_API_KEY || cfg?.public?.meilisearch?.searchApiKey || process.env.MEILISEARCH_SEARCH_API_KEY
|
|
30
|
+
if (!host) throw new Error('Search host not configured')
|
|
31
|
+
try {
|
|
32
|
+
return new (Client as any)({ host, apiKey })
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { host, apiKey, _client: 'searchkit-fallback' } as any
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getIndexName(): string {
|
|
39
|
+
const cfg = getRuntimeConfig()
|
|
40
|
+
return cfg?.public?.searchkit?.indexName || process.env.SEARCHKIT_INDEX_NAME || cfg?.public?.meilisearch?.indexName || process.env.MEILISEARCH_INDEX_NAME || 'default'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type TaskInfo = any
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getAdminClient, getIndexName } from './client'
|
|
2
|
+
|
|
3
|
+
export async function updateSettings(indexName: string | undefined, settings: Record<string, any>) {
|
|
4
|
+
const client = getAdminClient()
|
|
5
|
+
const idx = client.index(indexName || getIndexName())
|
|
6
|
+
return idx.updateSettings(settings)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getSettings(indexName?: string) {
|
|
10
|
+
const client = getAdminClient()
|
|
11
|
+
const idx = client.index(indexName || getIndexName())
|
|
12
|
+
return idx.getSettings()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default { updateSettings, getSettings }
|
|
16
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type FilterValue = string | number | boolean
|
|
2
|
+
|
|
3
|
+
function fmt(v: FilterValue) {
|
|
4
|
+
if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`
|
|
5
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
|
6
|
+
return String(v)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function toFilterString(filters: Record<string, FilterValue | FilterValue[]>): string | undefined {
|
|
10
|
+
const parts: string[] = []
|
|
11
|
+
for (const key of Object.keys(filters)) {
|
|
12
|
+
const val = filters[key]
|
|
13
|
+
if (val === undefined) continue
|
|
14
|
+
if (Array.isArray(val)) {
|
|
15
|
+
const orParts = val.map(v => `${key} = ${fmt(v)}`)
|
|
16
|
+
parts.push(`(${orParts.join(' OR ')})`)
|
|
17
|
+
} else {
|
|
18
|
+
parts.push(`${key} = ${fmt(val)}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (parts.length === 0) return undefined
|
|
22
|
+
return parts.join(' AND ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default toFilterString
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getAdminClient, getIndexName } from './client'
|
|
2
|
+
|
|
3
|
+
export async function addDocuments(indexName: string | undefined, docs: any[], primaryKey?: string) {
|
|
4
|
+
const client = getAdminClient()
|
|
5
|
+
const idx = client.index(indexName || getIndexName())
|
|
6
|
+
return idx.addDocuments(docs, { primaryKey })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function updateDocuments(indexName: string | undefined, docs: any[]) {
|
|
10
|
+
const client = getAdminClient()
|
|
11
|
+
const idx = client.index(indexName || getIndexName())
|
|
12
|
+
return idx.updateDocuments(docs)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function deleteDocuments(indexName: string | undefined, ids: Array<string | number>) {
|
|
16
|
+
const client = getAdminClient()
|
|
17
|
+
const idx = client.index(indexName || getIndexName())
|
|
18
|
+
const stringIds = ids.map(id => String(id)) as string[]
|
|
19
|
+
return idx.deleteDocuments(stringIds)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function clearIndex(indexName?: string) {
|
|
23
|
+
const client = getAdminClient()
|
|
24
|
+
const idx = client.index(indexName || getIndexName())
|
|
25
|
+
return idx.deleteAllDocuments()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getDocument(indexName: string | undefined, id: string | number) {
|
|
29
|
+
const client = getAdminClient()
|
|
30
|
+
const idx = client.index(indexName || getIndexName())
|
|
31
|
+
return idx.getDocument(id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
addDocuments,
|
|
36
|
+
updateDocuments,
|
|
37
|
+
deleteDocuments,
|
|
38
|
+
clearIndex,
|
|
39
|
+
getDocument,
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getAdminClient, getIndexName } from './client'
|
|
2
|
+
|
|
3
|
+
export async function ensureIndex(indexName?: string, options?: { primaryKey?: string }) {
|
|
4
|
+
const client = getAdminClient()
|
|
5
|
+
const name = indexName || getIndexName()
|
|
6
|
+
try {
|
|
7
|
+
await client.getIndex(name)
|
|
8
|
+
return { existed: true }
|
|
9
|
+
} catch (err) {
|
|
10
|
+
const created = await client.createIndex(name, options)
|
|
11
|
+
return { existed: false, created }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function deleteIndex(indexName?: string) {
|
|
16
|
+
const client = getAdminClient()
|
|
17
|
+
const name = indexName || getIndexName()
|
|
18
|
+
return client.deleteIndex(name)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default { ensureIndex, deleteIndex }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getSearchClient } from './client'
|
|
2
|
+
|
|
3
|
+
export type MultiQuery = {
|
|
4
|
+
indexName: string
|
|
5
|
+
query?: string
|
|
6
|
+
params?: Record<string, any>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function multiSearch(queries: MultiQuery[]) {
|
|
10
|
+
const client = getSearchClient()
|
|
11
|
+
const ms = queries.map(q => ({ indexUid: q.indexName, query: q.query || '', params: q.params || {} }))
|
|
12
|
+
// MeiliSearch SDK expects: { queries: [{ indexUid, query, params }] }
|
|
13
|
+
return client.multiSearch({ queries: ms })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default multiSearch
|
|
17
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getAdminClient } from './client'
|
|
2
|
+
|
|
3
|
+
export async function createKey(params: {
|
|
4
|
+
description?: string
|
|
5
|
+
actions: string[]
|
|
6
|
+
indexes?: string[]
|
|
7
|
+
expiresAt?: string
|
|
8
|
+
}) {
|
|
9
|
+
const client = getAdminClient()
|
|
10
|
+
return client.createKey(params)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function listKeys() {
|
|
14
|
+
const client = getAdminClient()
|
|
15
|
+
return client.getKeys()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function deleteKey(key: string) {
|
|
19
|
+
const client = getAdminClient()
|
|
20
|
+
return client.deleteKey(key)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default { createKey, listKeys, deleteKey }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getAdminClient, getIndexName } from './client'
|
|
2
|
+
|
|
3
|
+
export async function updateRankingRules(indexName: string | undefined, rankingRules: string[]) {
|
|
4
|
+
const client = getAdminClient()
|
|
5
|
+
const idx = client.index(indexName || getIndexName())
|
|
6
|
+
return idx.updateSettings({ rankingRules })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function updateSearchableAttributes(indexName: string | undefined, attrs: string[]) {
|
|
10
|
+
const client = getAdminClient()
|
|
11
|
+
const idx = client.index(indexName || getIndexName())
|
|
12
|
+
return idx.updateSettings({ searchableAttributes: attrs })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default { updateRankingRules, updateSearchableAttributes }
|
|
16
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import toFilterString from './filters'
|
|
2
|
+
|
|
3
|
+
export function sanitizeQuery(q?: string) {
|
|
4
|
+
if (!q) return ''
|
|
5
|
+
// basic sanitize to avoid control sequences
|
|
6
|
+
return q.replace(/[\u0000-\u001F\u007F]/g, '').trim()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildUserFilter(user?: { id?: string | number; roles?: string[] }, extra?: Record<string, any>) {
|
|
10
|
+
const filters: Record<string, any> = {}
|
|
11
|
+
if (user?.id) filters['owner_id'] = user.id
|
|
12
|
+
if (user?.roles && user.roles.length > 0) filters['role'] = user.roles
|
|
13
|
+
if (extra) Object.assign(filters, extra)
|
|
14
|
+
return toFilterString(filters)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default { sanitizeQuery, buildUserFilter }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type SortSpec = { field: string; direction?: 'asc' | 'desc' }
|
|
2
|
+
|
|
3
|
+
export function buildSortClauses(specs: SortSpec[] | undefined) {
|
|
4
|
+
if (!specs || specs.length === 0) return undefined
|
|
5
|
+
return specs.map(s => `${s.field}:${s.direction || 'asc'}`)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default buildSortClauses
|
|
9
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getAdminClient, getIndexName } from './client'
|
|
2
|
+
|
|
3
|
+
export async function batchIndexDocuments(docs: any[], indexName?: string, batchSize = 1000) {
|
|
4
|
+
const client = getAdminClient()
|
|
5
|
+
const idx = client.index(indexName || getIndexName())
|
|
6
|
+
const tasks = [] as Promise<any>[]
|
|
7
|
+
for (let i = 0; i < docs.length; i += batchSize) {
|
|
8
|
+
const chunk = docs.slice(i, i + batchSize)
|
|
9
|
+
tasks.push(idx.addDocuments(chunk))
|
|
10
|
+
}
|
|
11
|
+
return Promise.all(tasks)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default { batchIndexDocuments }
|