@strav/search 0.1.0
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 +72 -0
- package/package.json +32 -0
- package/src/commands/search_flush.ts +41 -0
- package/src/commands/search_import.ts +43 -0
- package/src/drivers/algolia_driver.ts +170 -0
- package/src/drivers/meilisearch_driver.ts +150 -0
- package/src/drivers/null_driver.ts +27 -0
- package/src/drivers/typesense_driver.ts +229 -0
- package/src/errors.ts +18 -0
- package/src/helpers.ts +70 -0
- package/src/index.ts +35 -0
- package/src/search_engine.ts +36 -0
- package/src/search_manager.ts +97 -0
- package/src/search_provider.ts +16 -0
- package/src/searchable.ts +211 -0
- package/src/types.ts +81 -0
- package/stubs/config/search.ts +32 -0
- package/tsconfig.json +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @stravigor/search
|
|
2
|
+
|
|
3
|
+
Full-text search for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Unified API for Meilisearch, Typesense, and Algolia with automatic indexing via model events.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/search
|
|
9
|
+
bun strav install search
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { SearchProvider } from '@stravigor/search'
|
|
18
|
+
|
|
19
|
+
app.use(new SearchProvider())
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Searchable Models
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { searchable } from '@stravigor/search'
|
|
26
|
+
|
|
27
|
+
class Post extends searchable(BaseModel) {
|
|
28
|
+
static searchableAs = 'posts'
|
|
29
|
+
|
|
30
|
+
toSearchableDocument() {
|
|
31
|
+
return { id: this.id, title: this.title, body: this.body }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { search } from '@stravigor/search'
|
|
40
|
+
|
|
41
|
+
// Search
|
|
42
|
+
const results = await search.query('posts', 'hello world', {
|
|
43
|
+
filters: 'status = published',
|
|
44
|
+
limit: 20,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Manual indexing
|
|
48
|
+
await search.index('posts', [{ id: 1, title: 'Hello' }])
|
|
49
|
+
await search.delete('posts', ['1'])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Drivers
|
|
53
|
+
|
|
54
|
+
- **Meilisearch** — fast, typo-tolerant, self-hosted
|
|
55
|
+
- **Typesense** — open-source, instant search
|
|
56
|
+
- **Algolia** — hosted search-as-a-service
|
|
57
|
+
- **Null** — no-op driver for testing
|
|
58
|
+
|
|
59
|
+
## CLI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
bun strav search:import # Import all searchable models
|
|
63
|
+
bun strav search:flush # Flush all indexes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
See the full [Search guide](../../guides/search.md).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Full-text search for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"strav": {
|
|
12
|
+
"commands": "src/commands"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src/",
|
|
16
|
+
"stubs/",
|
|
17
|
+
"package.json",
|
|
18
|
+
"tsconfig.json"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@strav/kernel": "0.1.0",
|
|
22
|
+
"@strav/database": "0.1.0",
|
|
23
|
+
"@strav/cli": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "bun test tests/",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"commander": "^14.0.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { bootstrap, shutdown } from '@stravigor/cli'
|
|
4
|
+
import { BaseModel } from '@stravigor/database'
|
|
5
|
+
import SearchManager from '../search_manager.ts'
|
|
6
|
+
|
|
7
|
+
export function register(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command('search:flush <model>')
|
|
10
|
+
.description("Flush all documents from a model's search index")
|
|
11
|
+
.action(async (modelPath: string) => {
|
|
12
|
+
let db
|
|
13
|
+
try {
|
|
14
|
+
const { db: database, config } = await bootstrap()
|
|
15
|
+
db = database
|
|
16
|
+
|
|
17
|
+
new BaseModel(db)
|
|
18
|
+
new SearchManager(config)
|
|
19
|
+
|
|
20
|
+
const resolved = require.resolve(`${process.cwd()}/${modelPath}`)
|
|
21
|
+
const module = await import(resolved)
|
|
22
|
+
const ModelClass = module.default ?? (Object.values(module)[0] as any)
|
|
23
|
+
|
|
24
|
+
if (typeof ModelClass?.flushIndex !== 'function') {
|
|
25
|
+
console.error(chalk.red(`Model "${modelPath}" does not use the searchable() mixin.`))
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const indexName = ModelClass.searchableAs()
|
|
30
|
+
console.log(chalk.dim(`Flushing "${indexName}"...`))
|
|
31
|
+
|
|
32
|
+
await ModelClass.flushIndex()
|
|
33
|
+
console.log(chalk.green(`Flushed all documents from "${indexName}".`))
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
36
|
+
process.exit(1)
|
|
37
|
+
} finally {
|
|
38
|
+
if (db) await shutdown(db)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { bootstrap, shutdown } from '@stravigor/cli'
|
|
4
|
+
import { BaseModel } from '@stravigor/database'
|
|
5
|
+
import SearchManager from '../search_manager.ts'
|
|
6
|
+
|
|
7
|
+
export function register(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command('search:import <model>')
|
|
10
|
+
.description('Import all records for a model into the search index')
|
|
11
|
+
.option('--chunk <size>', 'Records per batch', '500')
|
|
12
|
+
.action(async (modelPath: string, options: { chunk: string }) => {
|
|
13
|
+
let db
|
|
14
|
+
try {
|
|
15
|
+
const { db: database, config } = await bootstrap()
|
|
16
|
+
db = database
|
|
17
|
+
|
|
18
|
+
new BaseModel(db)
|
|
19
|
+
new SearchManager(config)
|
|
20
|
+
|
|
21
|
+
const resolved = require.resolve(`${process.cwd()}/${modelPath}`)
|
|
22
|
+
const module = await import(resolved)
|
|
23
|
+
const ModelClass = module.default ?? (Object.values(module)[0] as any)
|
|
24
|
+
|
|
25
|
+
if (typeof ModelClass?.importAll !== 'function') {
|
|
26
|
+
console.error(chalk.red(`Model "${modelPath}" does not use the searchable() mixin.`))
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const chunkSize = parseInt(options.chunk, 10)
|
|
31
|
+
const indexName = ModelClass.searchableAs()
|
|
32
|
+
console.log(chalk.dim(`Importing ${ModelClass.name} into "${indexName}"...`))
|
|
33
|
+
|
|
34
|
+
const count = await ModelClass.importAll(chunkSize)
|
|
35
|
+
console.log(chalk.green(`Imported ${count} record(s) into "${indexName}".`))
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
38
|
+
process.exit(1)
|
|
39
|
+
} finally {
|
|
40
|
+
if (db) await shutdown(db)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/kernel'
|
|
2
|
+
import type { SearchEngine } from '../search_engine.ts'
|
|
3
|
+
import type {
|
|
4
|
+
SearchDocument,
|
|
5
|
+
SearchOptions,
|
|
6
|
+
SearchResult,
|
|
7
|
+
SearchHit,
|
|
8
|
+
IndexSettings,
|
|
9
|
+
DriverConfig,
|
|
10
|
+
} from '../types.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Algolia driver — communicates with the Algolia REST API via raw `fetch()`.
|
|
14
|
+
*
|
|
15
|
+
* @see https://www.algolia.com/doc/rest-api/search/
|
|
16
|
+
*/
|
|
17
|
+
export class AlgoliaDriver implements SearchEngine {
|
|
18
|
+
readonly name = 'algolia'
|
|
19
|
+
private appId: string
|
|
20
|
+
private apiKey: string
|
|
21
|
+
private baseUrl: string
|
|
22
|
+
|
|
23
|
+
constructor(config: DriverConfig) {
|
|
24
|
+
this.appId = (config.appId as string) ?? ''
|
|
25
|
+
this.apiKey = (config.apiKey as string) ?? ''
|
|
26
|
+
this.baseUrl = `https://${this.appId}.algolia.net`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Interface ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
async upsert(
|
|
32
|
+
index: string,
|
|
33
|
+
id: string | number,
|
|
34
|
+
document: Record<string, unknown>
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
await this.request(
|
|
37
|
+
'PUT',
|
|
38
|
+
`/1/indexes/${encodeURIComponent(index)}/${encodeURIComponent(String(id))}`,
|
|
39
|
+
document
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
|
|
44
|
+
const requests = documents.map(doc => ({
|
|
45
|
+
action: 'updateObject',
|
|
46
|
+
body: { objectID: String(doc.id), ...doc },
|
|
47
|
+
}))
|
|
48
|
+
await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/batch`, { requests })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async delete(index: string, id: string | number): Promise<void> {
|
|
52
|
+
await this.request(
|
|
53
|
+
'DELETE',
|
|
54
|
+
`/1/indexes/${encodeURIComponent(index)}/${encodeURIComponent(String(id))}`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
|
|
59
|
+
const requests = ids.map(id => ({
|
|
60
|
+
action: 'deleteObject',
|
|
61
|
+
body: { objectID: String(id) },
|
|
62
|
+
}))
|
|
63
|
+
await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/batch`, { requests })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async flush(index: string): Promise<void> {
|
|
67
|
+
await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/clear`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async deleteIndex(index: string): Promise<void> {
|
|
71
|
+
await this.request('DELETE', `/1/indexes/${encodeURIComponent(index)}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async createIndex(index: string, options?: IndexSettings): Promise<void> {
|
|
75
|
+
// Algolia creates indexes implicitly on first write.
|
|
76
|
+
// If settings are provided, configure them.
|
|
77
|
+
if (options) {
|
|
78
|
+
const settings: Record<string, unknown> = {}
|
|
79
|
+
if (options.searchableAttributes) settings.searchableAttributes = options.searchableAttributes
|
|
80
|
+
if (options.displayedAttributes) settings.attributesToRetrieve = options.displayedAttributes
|
|
81
|
+
if (options.filterableAttributes) {
|
|
82
|
+
settings.attributesForFaceting = options.filterableAttributes.map(
|
|
83
|
+
attr => `filterOnly(${attr})`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (options.sortableAttributes) settings.ranking = options.sortableAttributes
|
|
87
|
+
|
|
88
|
+
if (Object.keys(settings).length > 0) {
|
|
89
|
+
await this.request('PUT', `/1/indexes/${encodeURIComponent(index)}/settings`, settings)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
95
|
+
const perPage = options?.perPage ?? 20
|
|
96
|
+
const page = options?.page ?? 1
|
|
97
|
+
|
|
98
|
+
const body: Record<string, unknown> = {
|
|
99
|
+
query,
|
|
100
|
+
hitsPerPage: perPage,
|
|
101
|
+
page: page - 1, // Algolia uses 0-based pages
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options?.filter) {
|
|
105
|
+
body.filters =
|
|
106
|
+
typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
|
|
107
|
+
}
|
|
108
|
+
if (options?.attributesToRetrieve) body.attributesToRetrieve = options.attributesToRetrieve
|
|
109
|
+
if (options?.attributesToHighlight) body.attributesToHighlight = options.attributesToHighlight
|
|
110
|
+
|
|
111
|
+
const data = await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/query`, body)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
hits: (data.hits ?? []).map(
|
|
115
|
+
(hit: any): SearchHit => ({
|
|
116
|
+
document: hit,
|
|
117
|
+
highlights: hit._highlightResult
|
|
118
|
+
? Object.fromEntries(
|
|
119
|
+
Object.entries(hit._highlightResult).map(([key, val]: [string, any]) => [
|
|
120
|
+
key,
|
|
121
|
+
val.value ?? '',
|
|
122
|
+
])
|
|
123
|
+
)
|
|
124
|
+
: undefined,
|
|
125
|
+
})
|
|
126
|
+
),
|
|
127
|
+
totalHits: data.nbHits ?? 0,
|
|
128
|
+
page,
|
|
129
|
+
perPage,
|
|
130
|
+
processingTimeMs: data.processingTimeMS,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Private ──────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
private headers(): Record<string, string> {
|
|
137
|
+
return {
|
|
138
|
+
'content-type': 'application/json',
|
|
139
|
+
'x-algolia-application-id': this.appId,
|
|
140
|
+
'x-algolia-api-key': this.apiKey,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async request(method: string, path: string, body?: unknown): Promise<any> {
|
|
145
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
146
|
+
method,
|
|
147
|
+
headers: this.headers(),
|
|
148
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
const text = await response.text()
|
|
153
|
+
throw new ExternalServiceError('Algolia', response.status, text)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') return null
|
|
157
|
+
return response.json()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private buildFilter(filter: Record<string, unknown>): string {
|
|
161
|
+
return Object.entries(filter)
|
|
162
|
+
.map(([key, value]) => {
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return value.map(v => `${key}:${JSON.stringify(v)}`).join(' OR ')
|
|
165
|
+
}
|
|
166
|
+
return `${key}:${JSON.stringify(value)}`
|
|
167
|
+
})
|
|
168
|
+
.join(' AND ')
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/kernel'
|
|
2
|
+
import type { SearchEngine } from '../search_engine.ts'
|
|
3
|
+
import type {
|
|
4
|
+
SearchDocument,
|
|
5
|
+
SearchOptions,
|
|
6
|
+
SearchResult,
|
|
7
|
+
SearchHit,
|
|
8
|
+
IndexSettings,
|
|
9
|
+
DriverConfig,
|
|
10
|
+
} from '../types.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Meilisearch driver — communicates with the Meilisearch REST API via raw `fetch()`.
|
|
14
|
+
*
|
|
15
|
+
* @see https://www.meilisearch.com/docs/reference/api/overview
|
|
16
|
+
*/
|
|
17
|
+
export class MeilisearchDriver implements SearchEngine {
|
|
18
|
+
readonly name = 'meilisearch'
|
|
19
|
+
private baseUrl: string
|
|
20
|
+
private apiKey: string
|
|
21
|
+
|
|
22
|
+
constructor(config: DriverConfig) {
|
|
23
|
+
const protocol = config.protocol ?? 'http'
|
|
24
|
+
const host = config.host ?? 'localhost'
|
|
25
|
+
const port = config.port ?? 7700
|
|
26
|
+
this.baseUrl = `${protocol}://${host}:${port}`
|
|
27
|
+
this.apiKey = (config.apiKey as string) ?? ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Interface ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async upsert(
|
|
33
|
+
index: string,
|
|
34
|
+
id: string | number,
|
|
35
|
+
document: Record<string, unknown>
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
await this.request('POST', `/indexes/${encodeURIComponent(index)}/documents`, [
|
|
38
|
+
{ id, ...document },
|
|
39
|
+
])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
|
|
43
|
+
await this.request('POST', `/indexes/${encodeURIComponent(index)}/documents`, documents)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async delete(index: string, id: string | number): Promise<void> {
|
|
47
|
+
await this.request(
|
|
48
|
+
'DELETE',
|
|
49
|
+
`/indexes/${encodeURIComponent(index)}/documents/${encodeURIComponent(String(id))}`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
|
|
54
|
+
await this.request('POST', `/indexes/${encodeURIComponent(index)}/documents/delete-batch`, ids)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async flush(index: string): Promise<void> {
|
|
58
|
+
await this.request('DELETE', `/indexes/${encodeURIComponent(index)}/documents`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async deleteIndex(index: string): Promise<void> {
|
|
62
|
+
await this.request('DELETE', `/indexes/${encodeURIComponent(index)}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async createIndex(index: string, options?: IndexSettings): Promise<void> {
|
|
66
|
+
await this.request('POST', '/indexes', {
|
|
67
|
+
uid: index,
|
|
68
|
+
primaryKey: options?.primaryKey ?? 'id',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (options) {
|
|
72
|
+
const settings: Record<string, unknown> = {}
|
|
73
|
+
if (options.searchableAttributes) settings.searchableAttributes = options.searchableAttributes
|
|
74
|
+
if (options.displayedAttributes) settings.displayedAttributes = options.displayedAttributes
|
|
75
|
+
if (options.filterableAttributes) settings.filterableAttributes = options.filterableAttributes
|
|
76
|
+
if (options.sortableAttributes) settings.sortableAttributes = options.sortableAttributes
|
|
77
|
+
|
|
78
|
+
if (Object.keys(settings).length > 0) {
|
|
79
|
+
await this.request('PATCH', `/indexes/${encodeURIComponent(index)}/settings`, settings)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
85
|
+
const perPage = options?.perPage ?? 20
|
|
86
|
+
const page = options?.page ?? 1
|
|
87
|
+
|
|
88
|
+
const body: Record<string, unknown> = { q: query, limit: perPage, offset: (page - 1) * perPage }
|
|
89
|
+
|
|
90
|
+
if (options?.filter) {
|
|
91
|
+
body.filter =
|
|
92
|
+
typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
|
|
93
|
+
}
|
|
94
|
+
if (options?.sort) body.sort = options.sort
|
|
95
|
+
if (options?.attributesToRetrieve) body.attributesToRetrieve = options.attributesToRetrieve
|
|
96
|
+
if (options?.attributesToHighlight) {
|
|
97
|
+
body.attributesToHighlight = options.attributesToHighlight
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const data = await this.request('POST', `/indexes/${encodeURIComponent(index)}/search`, body)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
hits: (data.hits ?? []).map(
|
|
104
|
+
(hit: any): SearchHit => ({
|
|
105
|
+
document: hit,
|
|
106
|
+
highlights: hit._formatted,
|
|
107
|
+
})
|
|
108
|
+
),
|
|
109
|
+
totalHits: data.estimatedTotalHits ?? data.totalHits ?? 0,
|
|
110
|
+
page,
|
|
111
|
+
perPage,
|
|
112
|
+
processingTimeMs: data.processingTimeMs,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Private ──────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
private headers(): Record<string, string> {
|
|
119
|
+
const h: Record<string, string> = { 'content-type': 'application/json' }
|
|
120
|
+
if (this.apiKey) h['authorization'] = `Bearer ${this.apiKey}`
|
|
121
|
+
return h
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async request(method: string, path: string, body?: unknown): Promise<any> {
|
|
125
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
126
|
+
method,
|
|
127
|
+
headers: this.headers(),
|
|
128
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const text = await response.text()
|
|
133
|
+
throw new ExternalServiceError('Meilisearch', response.status, text)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') return null
|
|
137
|
+
return response.json()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private buildFilter(filter: Record<string, unknown>): string {
|
|
141
|
+
return Object.entries(filter)
|
|
142
|
+
.map(([key, value]) => {
|
|
143
|
+
if (Array.isArray(value)) {
|
|
144
|
+
return `${key} IN [${value.map(v => JSON.stringify(v)).join(', ')}]`
|
|
145
|
+
}
|
|
146
|
+
return `${key} = ${JSON.stringify(value)}`
|
|
147
|
+
})
|
|
148
|
+
.join(' AND ')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SearchEngine } from '../search_engine.ts'
|
|
2
|
+
import type { SearchDocument, SearchOptions, SearchResult, IndexSettings } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* No-op search driver — silently discards all writes and returns empty results.
|
|
6
|
+
*
|
|
7
|
+
* Useful when search is disabled or during testing.
|
|
8
|
+
*/
|
|
9
|
+
export class NullDriver implements SearchEngine {
|
|
10
|
+
readonly name = 'null'
|
|
11
|
+
|
|
12
|
+
async upsert(
|
|
13
|
+
_index: string,
|
|
14
|
+
_id: string | number,
|
|
15
|
+
_document: Record<string, unknown>
|
|
16
|
+
): Promise<void> {}
|
|
17
|
+
async upsertMany(_index: string, _documents: SearchDocument[]): Promise<void> {}
|
|
18
|
+
async delete(_index: string, _id: string | number): Promise<void> {}
|
|
19
|
+
async deleteMany(_index: string, _ids: Array<string | number>): Promise<void> {}
|
|
20
|
+
async flush(_index: string): Promise<void> {}
|
|
21
|
+
async deleteIndex(_index: string): Promise<void> {}
|
|
22
|
+
async createIndex(_index: string, _options?: IndexSettings): Promise<void> {}
|
|
23
|
+
|
|
24
|
+
async search(_index: string, _query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
25
|
+
return { hits: [], totalHits: 0, page: options?.page ?? 1, perPage: options?.perPage ?? 20 }
|
|
26
|
+
}
|
|
27
|
+
}
|