@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
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
* Typesense driver — communicates with the Typesense REST API via raw `fetch()`.
|
|
14
|
+
*
|
|
15
|
+
* @see https://typesense.org/docs/api/
|
|
16
|
+
*/
|
|
17
|
+
export class TypesenseDriver implements SearchEngine {
|
|
18
|
+
readonly name = 'typesense'
|
|
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 ?? 8108
|
|
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(
|
|
38
|
+
'POST',
|
|
39
|
+
`/collections/${encodeURIComponent(index)}/documents?action=upsert`,
|
|
40
|
+
{ id: String(id), ...document }
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
|
|
45
|
+
const jsonl = documents.map(doc => JSON.stringify({ ...doc, id: String(doc.id) })).join('\n')
|
|
46
|
+
await this.rawRequest(
|
|
47
|
+
'POST',
|
|
48
|
+
`/collections/${encodeURIComponent(index)}/documents/import?action=upsert`,
|
|
49
|
+
jsonl,
|
|
50
|
+
'text/plain'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async delete(index: string, id: string | number): Promise<void> {
|
|
55
|
+
await this.request(
|
|
56
|
+
'DELETE',
|
|
57
|
+
`/collections/${encodeURIComponent(index)}/documents/${encodeURIComponent(String(id))}`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
|
|
62
|
+
const filter = `id:[${ids.map(id => String(id)).join(',')}]`
|
|
63
|
+
await this.request(
|
|
64
|
+
'DELETE',
|
|
65
|
+
`/collections/${encodeURIComponent(index)}/documents?filter_by=${encodeURIComponent(filter)}`
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async flush(index: string): Promise<void> {
|
|
70
|
+
// Typesense has no "delete all documents" endpoint — delete the collection and recreate it.
|
|
71
|
+
// We fetch the current schema first so we can recreate it.
|
|
72
|
+
let schema: any
|
|
73
|
+
try {
|
|
74
|
+
schema = await this.request('GET', `/collections/${encodeURIComponent(index)}`)
|
|
75
|
+
} catch {
|
|
76
|
+
// Collection doesn't exist — nothing to flush
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
|
|
80
|
+
await this.request('POST', '/collections', {
|
|
81
|
+
name: schema.name,
|
|
82
|
+
fields: schema.fields,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async deleteIndex(index: string): Promise<void> {
|
|
87
|
+
await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async createIndex(index: string, options?: IndexSettings): Promise<void> {
|
|
91
|
+
const fields: Record<string, unknown>[] = []
|
|
92
|
+
|
|
93
|
+
if (options?.searchableAttributes) {
|
|
94
|
+
for (const attr of options.searchableAttributes) {
|
|
95
|
+
fields.push({ name: attr, type: 'string', facet: false })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (options?.filterableAttributes) {
|
|
99
|
+
for (const attr of options.filterableAttributes) {
|
|
100
|
+
if (!fields.some(f => f.name === attr)) {
|
|
101
|
+
fields.push({ name: attr, type: 'string', facet: true })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (options?.sortableAttributes) {
|
|
106
|
+
for (const attr of options.sortableAttributes) {
|
|
107
|
+
if (!fields.some(f => f.name === attr)) {
|
|
108
|
+
fields.push({ name: attr, type: 'string', sort: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Always include a wildcard field so untyped fields are auto-detected
|
|
114
|
+
if (fields.length === 0) {
|
|
115
|
+
fields.push({ name: '.*', type: 'auto' })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await this.request('POST', '/collections', {
|
|
119
|
+
name: index,
|
|
120
|
+
fields,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
125
|
+
const perPage = options?.perPage ?? 20
|
|
126
|
+
const page = options?.page ?? 1
|
|
127
|
+
|
|
128
|
+
const params = new URLSearchParams({
|
|
129
|
+
q: query,
|
|
130
|
+
query_by: '*',
|
|
131
|
+
per_page: String(perPage),
|
|
132
|
+
page: String(page),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
if (options?.filter) {
|
|
136
|
+
params.set(
|
|
137
|
+
'filter_by',
|
|
138
|
+
typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (options?.sort) {
|
|
142
|
+
params.set('sort_by', options.sort.map(s => s.replace(':', ':')).join(','))
|
|
143
|
+
}
|
|
144
|
+
if (options?.attributesToRetrieve) {
|
|
145
|
+
params.set('include_fields', options.attributesToRetrieve.join(','))
|
|
146
|
+
}
|
|
147
|
+
if (options?.attributesToHighlight) {
|
|
148
|
+
params.set('highlight_fields', options.attributesToHighlight.join(','))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const data = await this.request(
|
|
152
|
+
'GET',
|
|
153
|
+
`/collections/${encodeURIComponent(index)}/documents/search?${params.toString()}`
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
hits: (data.hits ?? []).map(
|
|
158
|
+
(hit: any): SearchHit => ({
|
|
159
|
+
document: hit.document,
|
|
160
|
+
highlights: hit.highlights?.reduce(
|
|
161
|
+
(acc: Record<string, string>, h: any) => {
|
|
162
|
+
if (h.field && h.snippet) acc[h.field] = h.snippet
|
|
163
|
+
return acc
|
|
164
|
+
},
|
|
165
|
+
{} as Record<string, string>
|
|
166
|
+
),
|
|
167
|
+
})
|
|
168
|
+
),
|
|
169
|
+
totalHits: data.found ?? 0,
|
|
170
|
+
page,
|
|
171
|
+
perPage,
|
|
172
|
+
processingTimeMs: data.search_time_ms,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Private ──────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
private headers(): Record<string, string> {
|
|
179
|
+
return {
|
|
180
|
+
'content-type': 'application/json',
|
|
181
|
+
'x-typesense-api-key': this.apiKey,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async request(method: string, path: string, body?: unknown): Promise<any> {
|
|
186
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
187
|
+
method,
|
|
188
|
+
headers: this.headers(),
|
|
189
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const text = await response.text()
|
|
194
|
+
throw new ExternalServiceError('Typesense', response.status, text)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') return null
|
|
198
|
+
return response.json()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async rawRequest(
|
|
202
|
+
method: string,
|
|
203
|
+
path: string,
|
|
204
|
+
body: string,
|
|
205
|
+
contentType: string
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
208
|
+
method,
|
|
209
|
+
headers: { 'content-type': contentType, 'x-typesense-api-key': this.apiKey },
|
|
210
|
+
body,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const text = await response.text()
|
|
215
|
+
throw new ExternalServiceError('Typesense', response.status, text)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private buildFilter(filter: Record<string, unknown>): string {
|
|
220
|
+
return Object.entries(filter)
|
|
221
|
+
.map(([key, value]) => {
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
return `${key}:[${value.map(v => String(v)).join(',')}]`
|
|
224
|
+
}
|
|
225
|
+
return `${key}:=${value}`
|
|
226
|
+
})
|
|
227
|
+
.join(' && ')
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/kernel'
|
|
2
|
+
|
|
3
|
+
/** Base error class for all search errors. */
|
|
4
|
+
export class SearchError extends StravError {}
|
|
5
|
+
|
|
6
|
+
/** Thrown when a search index is not found. */
|
|
7
|
+
export class IndexNotFoundError extends SearchError {
|
|
8
|
+
constructor(index: string) {
|
|
9
|
+
super(`Search index "${index}" not found.`)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Thrown when a search query fails. */
|
|
14
|
+
export class SearchQueryError extends SearchError {
|
|
15
|
+
constructor(index: string, cause?: string) {
|
|
16
|
+
super(`Search query on "${index}" failed${cause ? `: ${cause}` : ''}.`)
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import SearchManager from './search_manager.ts'
|
|
2
|
+
import type { SearchEngine } from './search_engine.ts'
|
|
3
|
+
import type {
|
|
4
|
+
SearchDocument,
|
|
5
|
+
SearchOptions,
|
|
6
|
+
SearchResult,
|
|
7
|
+
IndexSettings,
|
|
8
|
+
DriverConfig,
|
|
9
|
+
} from './types.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Search helper — the primary convenience API.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { search } from '@stravigor/search'
|
|
16
|
+
*
|
|
17
|
+
* const results = await search.query('articles', 'typescript generics')
|
|
18
|
+
* await search.upsert('articles', 1, { title: 'Guide', body: '...' })
|
|
19
|
+
*/
|
|
20
|
+
export const search = {
|
|
21
|
+
/** Get the underlying engine instance (default or named). */
|
|
22
|
+
engine(name?: string): SearchEngine {
|
|
23
|
+
return SearchManager.engine(name)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/** Register a custom search driver factory. */
|
|
27
|
+
extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
|
|
28
|
+
SearchManager.extend(name, factory)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/** Perform a full-text search query. */
|
|
32
|
+
query(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
|
|
33
|
+
return SearchManager.engine().search(SearchManager.indexName(index), query, options)
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/** Add or update a single document. */
|
|
37
|
+
upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void> {
|
|
38
|
+
return SearchManager.engine().upsert(SearchManager.indexName(index), id, document)
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** Add or update multiple documents. */
|
|
42
|
+
upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
|
|
43
|
+
return SearchManager.engine().upsertMany(SearchManager.indexName(index), documents)
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/** Remove a document from the index. */
|
|
47
|
+
delete(index: string, id: string | number): Promise<void> {
|
|
48
|
+
return SearchManager.engine().delete(SearchManager.indexName(index), id)
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/** Remove multiple documents from the index. */
|
|
52
|
+
deleteMany(index: string, ids: Array<string | number>): Promise<void> {
|
|
53
|
+
return SearchManager.engine().deleteMany(SearchManager.indexName(index), ids)
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/** Remove all documents from an index. */
|
|
57
|
+
flush(index: string): Promise<void> {
|
|
58
|
+
return SearchManager.engine().flush(SearchManager.indexName(index))
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/** Create an index with optional settings. */
|
|
62
|
+
createIndex(index: string, options?: IndexSettings): Promise<void> {
|
|
63
|
+
return SearchManager.engine().createIndex(SearchManager.indexName(index), options)
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** Delete an entire index. */
|
|
67
|
+
deleteIndex(index: string): Promise<void> {
|
|
68
|
+
return SearchManager.engine().deleteIndex(SearchManager.indexName(index))
|
|
69
|
+
},
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Manager
|
|
2
|
+
export { default, default as SearchManager } from './search_manager.ts'
|
|
3
|
+
|
|
4
|
+
// Provider
|
|
5
|
+
export { default as SearchProvider } from './search_provider.ts'
|
|
6
|
+
|
|
7
|
+
// Engine interface
|
|
8
|
+
export type { SearchEngine } from './search_engine.ts'
|
|
9
|
+
|
|
10
|
+
// Drivers
|
|
11
|
+
export { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
|
|
12
|
+
export { TypesenseDriver } from './drivers/typesense_driver.ts'
|
|
13
|
+
export { AlgoliaDriver } from './drivers/algolia_driver.ts'
|
|
14
|
+
export { NullDriver } from './drivers/null_driver.ts'
|
|
15
|
+
|
|
16
|
+
// Mixin
|
|
17
|
+
export { searchable } from './searchable.ts'
|
|
18
|
+
export type { SearchableInstance, SearchableModel } from './searchable.ts'
|
|
19
|
+
|
|
20
|
+
// Helper
|
|
21
|
+
export { search } from './helpers.ts'
|
|
22
|
+
|
|
23
|
+
// Errors
|
|
24
|
+
export { SearchError, IndexNotFoundError, SearchQueryError } from './errors.ts'
|
|
25
|
+
|
|
26
|
+
// Types
|
|
27
|
+
export type {
|
|
28
|
+
SearchConfig,
|
|
29
|
+
DriverConfig,
|
|
30
|
+
SearchDocument,
|
|
31
|
+
SearchOptions,
|
|
32
|
+
SearchResult,
|
|
33
|
+
SearchHit,
|
|
34
|
+
IndexSettings,
|
|
35
|
+
} from './types.ts'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SearchDocument, SearchOptions, SearchResult, IndexSettings } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract that every search engine driver must implement.
|
|
5
|
+
*
|
|
6
|
+
* Drivers communicate with an external search service (Meilisearch,
|
|
7
|
+
* Typesense, Algolia, etc.) via their REST API.
|
|
8
|
+
*/
|
|
9
|
+
export interface SearchEngine {
|
|
10
|
+
/** Driver name (e.g. 'meilisearch', 'typesense', 'algolia'). */
|
|
11
|
+
readonly name: string
|
|
12
|
+
|
|
13
|
+
/** Add or update a single document. */
|
|
14
|
+
upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void>
|
|
15
|
+
|
|
16
|
+
/** Add or update multiple documents at once. */
|
|
17
|
+
upsertMany(index: string, documents: SearchDocument[]): Promise<void>
|
|
18
|
+
|
|
19
|
+
/** Remove a single document by ID. */
|
|
20
|
+
delete(index: string, id: string | number): Promise<void>
|
|
21
|
+
|
|
22
|
+
/** Remove multiple documents by ID. */
|
|
23
|
+
deleteMany(index: string, ids: Array<string | number>): Promise<void>
|
|
24
|
+
|
|
25
|
+
/** Remove all documents from an index (keep the index itself). */
|
|
26
|
+
flush(index: string): Promise<void>
|
|
27
|
+
|
|
28
|
+
/** Delete the entire index. */
|
|
29
|
+
deleteIndex(index: string): Promise<void>
|
|
30
|
+
|
|
31
|
+
/** Create an index with optional settings. */
|
|
32
|
+
createIndex(index: string, options?: IndexSettings): Promise<void>
|
|
33
|
+
|
|
34
|
+
/** Perform a full-text search. */
|
|
35
|
+
search(index: string, query: string, options?: SearchOptions): Promise<SearchResult>
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { inject, Configuration, ConfigurationError } from '@stravigor/kernel'
|
|
2
|
+
import type { SearchEngine } from './search_engine.ts'
|
|
3
|
+
import type { SearchConfig, DriverConfig } from './types.ts'
|
|
4
|
+
import { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
|
|
5
|
+
import { TypesenseDriver } from './drivers/typesense_driver.ts'
|
|
6
|
+
import { AlgoliaDriver } from './drivers/algolia_driver.ts'
|
|
7
|
+
import { NullDriver } from './drivers/null_driver.ts'
|
|
8
|
+
|
|
9
|
+
@inject
|
|
10
|
+
export default class SearchManager {
|
|
11
|
+
private static _config: SearchConfig
|
|
12
|
+
private static _engines = new Map<string, SearchEngine>()
|
|
13
|
+
private static _extensions = new Map<string, (config: DriverConfig) => SearchEngine>()
|
|
14
|
+
|
|
15
|
+
constructor(config: Configuration) {
|
|
16
|
+
SearchManager._config = {
|
|
17
|
+
default: config.get('search.default', 'null') as string,
|
|
18
|
+
prefix: config.get('search.prefix', '') as string,
|
|
19
|
+
drivers: config.get('search.drivers', {}) as Record<string, DriverConfig>,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static get config(): SearchConfig {
|
|
24
|
+
if (!SearchManager._config) {
|
|
25
|
+
throw new ConfigurationError(
|
|
26
|
+
'SearchManager not configured. Resolve it through the container first.'
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return SearchManager._config
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Get an engine by name, or the default engine. Engines are lazily created. */
|
|
33
|
+
static engine(name?: string): SearchEngine {
|
|
34
|
+
const key = name ?? SearchManager.config.default
|
|
35
|
+
|
|
36
|
+
let engine = SearchManager._engines.get(key)
|
|
37
|
+
if (engine) return engine
|
|
38
|
+
|
|
39
|
+
const driverConfig = SearchManager.config.drivers[key]
|
|
40
|
+
if (!driverConfig) {
|
|
41
|
+
throw new ConfigurationError(`Search driver "${key}" is not configured.`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
engine = SearchManager.createEngine(key, driverConfig)
|
|
45
|
+
SearchManager._engines.set(key, engine)
|
|
46
|
+
return engine
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The index name prefix from configuration. */
|
|
50
|
+
static get prefix(): string {
|
|
51
|
+
return SearchManager._config?.prefix ?? ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve a full index name by applying the configured prefix. */
|
|
55
|
+
static indexName(name: string): string {
|
|
56
|
+
return SearchManager.prefix ? `${SearchManager.prefix}${name}` : name
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Register a custom driver factory. */
|
|
60
|
+
static extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
|
|
61
|
+
SearchManager._extensions.set(name, factory)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Replace an engine at runtime (e.g. for testing). */
|
|
65
|
+
static useEngine(engine: SearchEngine): void {
|
|
66
|
+
SearchManager._engines.set(engine.name, engine)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reset all state. Intended for test teardown. */
|
|
70
|
+
static reset(): void {
|
|
71
|
+
SearchManager._engines.clear()
|
|
72
|
+
SearchManager._extensions.clear()
|
|
73
|
+
SearchManager._config = undefined as any
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static createEngine(name: string, config: DriverConfig): SearchEngine {
|
|
77
|
+
const driverName = config.driver ?? name
|
|
78
|
+
|
|
79
|
+
const extension = SearchManager._extensions.get(driverName)
|
|
80
|
+
if (extension) return extension(config)
|
|
81
|
+
|
|
82
|
+
switch (driverName) {
|
|
83
|
+
case 'meilisearch':
|
|
84
|
+
return new MeilisearchDriver(config)
|
|
85
|
+
case 'typesense':
|
|
86
|
+
return new TypesenseDriver(config)
|
|
87
|
+
case 'algolia':
|
|
88
|
+
return new AlgoliaDriver(config)
|
|
89
|
+
case 'null':
|
|
90
|
+
return new NullDriver()
|
|
91
|
+
default:
|
|
92
|
+
throw new ConfigurationError(
|
|
93
|
+
`Unknown search driver "${driverName}". Register it with SearchManager.extend().`
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/kernel'
|
|
2
|
+
import type { Application } from '@stravigor/kernel'
|
|
3
|
+
import SearchManager from './search_manager.ts'
|
|
4
|
+
|
|
5
|
+
export default class SearchProvider extends ServiceProvider {
|
|
6
|
+
readonly name = 'search'
|
|
7
|
+
override readonly dependencies = ['config']
|
|
8
|
+
|
|
9
|
+
override register(app: Application): void {
|
|
10
|
+
app.singleton(SearchManager)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override boot(app: Application): void {
|
|
14
|
+
app.resolve(SearchManager)
|
|
15
|
+
}
|
|
16
|
+
}
|