@strav/search 0.4.31 → 1.0.0-alpha.33
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/package.json +20 -22
- package/src/console/index.ts +5 -0
- package/src/console/search_console_provider.ts +20 -0
- package/src/console/search_flush.ts +49 -0
- package/src/console/search_import.ts +103 -0
- package/src/console/search_list.ts +46 -0
- package/src/console/search_reindex.ts +94 -0
- package/src/drivers/meilisearch/meilisearch_driver.ts +304 -0
- package/src/drivers/memory/memory_driver.ts +344 -0
- package/src/drivers/postgres/apply_search_migration.ts +74 -0
- package/src/drivers/postgres/postgres_fts_driver.ts +493 -135
- package/src/drivers/typesense/typesense_driver.ts +345 -0
- package/src/index.ts +50 -39
- package/src/search_engine.ts +40 -25
- package/src/search_error.ts +86 -0
- package/src/search_manager.ts +112 -94
- package/src/search_provider.ts +68 -6
- package/src/searchable.ts +173 -160
- package/src/searchable_registry.ts +61 -0
- package/src/types.ts +59 -49
- package/README.md +0 -191
- package/src/commands/search_flush.ts +0 -41
- package/src/commands/search_import.ts +0 -43
- package/src/commands/search_optimize.ts +0 -52
- package/src/commands/search_rebuild.ts +0 -73
- package/src/drivers/algolia_driver.ts +0 -170
- package/src/drivers/embedded/embedded_driver.ts +0 -136
- package/src/drivers/embedded/engine/field_registry.ts +0 -97
- package/src/drivers/embedded/engine/fts_query_builder.ts +0 -184
- package/src/drivers/embedded/engine/query_compiler.ts +0 -134
- package/src/drivers/embedded/engine/schema.ts +0 -99
- package/src/drivers/embedded/engine/snippet_formatter.ts +0 -29
- package/src/drivers/embedded/engine/sqlite_engine.ts +0 -255
- package/src/drivers/embedded/engine/typo_expander.ts +0 -138
- package/src/drivers/embedded/errors.ts +0 -15
- package/src/drivers/embedded/filters/filter_compiler.ts +0 -136
- package/src/drivers/embedded/index.ts +0 -3
- package/src/drivers/embedded/storage/paths.ts +0 -23
- package/src/drivers/embedded/types.ts +0 -34
- package/src/drivers/meilisearch_driver.ts +0 -150
- package/src/drivers/null_driver.ts +0 -27
- package/src/drivers/postgres/engine/field_registry.ts +0 -116
- package/src/drivers/postgres/engine/fts_query_builder.ts +0 -105
- package/src/drivers/postgres/engine/pg_engine.ts +0 -300
- package/src/drivers/postgres/engine/query_compiler.ts +0 -165
- package/src/drivers/postgres/engine/schema.ts +0 -187
- package/src/drivers/postgres/engine/snippet_formatter.ts +0 -31
- package/src/drivers/postgres/engine/typo_expander.ts +0 -131
- package/src/drivers/postgres/errors.ts +0 -33
- package/src/drivers/postgres/filters/filter_compiler.ts +0 -138
- package/src/drivers/postgres/index.ts +0 -14
- package/src/drivers/postgres/rebuild/rebuild_inplace.ts +0 -113
- package/src/drivers/postgres/storage/identifiers.ts +0 -46
- package/src/drivers/postgres/types.ts +0 -53
- package/src/drivers/typesense_driver.ts +0 -229
- package/src/errors.ts +0 -18
- package/src/helpers.ts +0 -120
- package/stubs/config/search.ts +0 -57
- package/tsconfig.json +0 -5
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TypesenseDriver` — REST client for [Typesense](https://typesense.org).
|
|
3
|
+
*
|
|
4
|
+
* Speaks the v0.x HTTP API via raw `fetch()` — no `typesense-js`
|
|
5
|
+
* SDK dependency. Typesense indexes are called "collections"
|
|
6
|
+
* upstream; we translate `index` → collection at the boundary so
|
|
7
|
+
* the `SearchEngine` contract stays uniform across drivers.
|
|
8
|
+
*
|
|
9
|
+
* Notable Typesense quirks the driver papers over:
|
|
10
|
+
*
|
|
11
|
+
* - **Document IDs are strings.** We coerce numeric ids to
|
|
12
|
+
* strings on write + leave them as strings on read.
|
|
13
|
+
* - **Schema is required.** Typesense rejects upserts against
|
|
14
|
+
* unknown collections; `createIndex(name, settings)` builds a
|
|
15
|
+
* schema from `IndexSettings`. When settings are omitted we
|
|
16
|
+
* fall back to an auto-schema (`fields: [{ name: '.*', type: 'auto' }]`).
|
|
17
|
+
* - **No "delete all documents" endpoint.** `flush()` drops the
|
|
18
|
+
* collection and recreates it with the original schema.
|
|
19
|
+
* - **No async tasks.** Writes return synchronously, so reads
|
|
20
|
+
* after writes are immediately consistent.
|
|
21
|
+
*
|
|
22
|
+
* Config (under `config.search.drivers.<name>`):
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* typesense: {
|
|
26
|
+
* driver: 'typesense',
|
|
27
|
+
* host: 'http://localhost:8108', // or split host/port/protocol
|
|
28
|
+
* apiKey: env('TYPESENSE_KEY'),
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Error mapping:
|
|
33
|
+
*
|
|
34
|
+
* - `404` on writes / reads → `IndexNotFoundError`.
|
|
35
|
+
* - All other non-2xx → `SearchQueryError` with the Typesense
|
|
36
|
+
* payload on `cause`.
|
|
37
|
+
*
|
|
38
|
+
* @see https://typesense.org/docs/0.25.2/api/
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { IndexNotFoundError, SearchQueryError } from '../../search_error.ts'
|
|
42
|
+
import type { SearchEngine } from '../../search_engine.ts'
|
|
43
|
+
import type {
|
|
44
|
+
DriverConfig,
|
|
45
|
+
IndexSettings,
|
|
46
|
+
SearchDocument,
|
|
47
|
+
SearchHit,
|
|
48
|
+
SearchOptions,
|
|
49
|
+
SearchResult,
|
|
50
|
+
} from '../../types.ts'
|
|
51
|
+
|
|
52
|
+
interface CollectionSchema {
|
|
53
|
+
name: string
|
|
54
|
+
fields: Array<Record<string, unknown>>
|
|
55
|
+
default_sorting_field?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class TypesenseDriver implements SearchEngine {
|
|
59
|
+
readonly name = 'typesense'
|
|
60
|
+
|
|
61
|
+
private readonly baseUrl: string
|
|
62
|
+
private readonly apiKey: string
|
|
63
|
+
private readonly defaultQueryBy: string
|
|
64
|
+
|
|
65
|
+
constructor(config: DriverConfig) {
|
|
66
|
+
this.baseUrl = resolveBaseUrl(config)
|
|
67
|
+
this.apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''
|
|
68
|
+
// Typesense ≥ 0.24 accepts `*` to query every string field;
|
|
69
|
+
// apps that want to scope can set `queryBy: 'title,body'`.
|
|
70
|
+
this.defaultQueryBy = typeof config.queryBy === 'string' ? config.queryBy : '*'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Index lifecycle ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async createIndex(index: string, settings: IndexSettings = {}): Promise<void> {
|
|
76
|
+
// Idempotent — swallow the 409 Typesense returns for "already exists".
|
|
77
|
+
await this.request<CollectionSchema>('POST', '/collections', {
|
|
78
|
+
body: schemaFromSettings(index, settings),
|
|
79
|
+
acceptStatuses: [409],
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async deleteIndex(index: string): Promise<void> {
|
|
84
|
+
await this.request('DELETE', `/collections/${encodeURIComponent(index)}`, {
|
|
85
|
+
acceptStatuses: [404],
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async flush(index: string): Promise<void> {
|
|
90
|
+
// Typesense has no "delete all documents" endpoint; we drop +
|
|
91
|
+
// recreate using the live schema so the index identity stays
|
|
92
|
+
// stable for callers.
|
|
93
|
+
let schema: CollectionSchema | null
|
|
94
|
+
try {
|
|
95
|
+
schema = await this.request<CollectionSchema>(
|
|
96
|
+
'GET',
|
|
97
|
+
`/collections/${encodeURIComponent(index)}`,
|
|
98
|
+
)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err instanceof IndexNotFoundError) return
|
|
101
|
+
throw err
|
|
102
|
+
}
|
|
103
|
+
if (!schema) return
|
|
104
|
+
await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
|
|
105
|
+
await this.request('POST', '/collections', {
|
|
106
|
+
body: {
|
|
107
|
+
name: schema.name,
|
|
108
|
+
fields: schema.fields,
|
|
109
|
+
...(schema.default_sorting_field
|
|
110
|
+
? { default_sorting_field: schema.default_sorting_field }
|
|
111
|
+
: {}),
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Writes ─────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async upsert(
|
|
119
|
+
index: string,
|
|
120
|
+
id: string | number,
|
|
121
|
+
document: Record<string, unknown>,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
await this.request(
|
|
124
|
+
'POST',
|
|
125
|
+
`/collections/${encodeURIComponent(index)}/documents?action=upsert`,
|
|
126
|
+
{ body: { ...document, id: String(id) } },
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async upsertMany(index: string, documents: readonly SearchDocument[]): Promise<void> {
|
|
131
|
+
if (documents.length === 0) return
|
|
132
|
+
const jsonl = documents
|
|
133
|
+
.map((doc) => JSON.stringify({ ...doc, id: String(doc.id) }))
|
|
134
|
+
.join('\n')
|
|
135
|
+
await this.rawRequest(
|
|
136
|
+
'POST',
|
|
137
|
+
`/collections/${encodeURIComponent(index)}/documents/import?action=upsert`,
|
|
138
|
+
jsonl,
|
|
139
|
+
'text/plain',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async delete(index: string, id: string | number): Promise<void> {
|
|
144
|
+
await this.request(
|
|
145
|
+
'DELETE',
|
|
146
|
+
`/collections/${encodeURIComponent(index)}/documents/${encodeURIComponent(String(id))}`,
|
|
147
|
+
{ acceptStatuses: [404] },
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async deleteMany(index: string, ids: readonly (string | number)[]): Promise<void> {
|
|
152
|
+
if (ids.length === 0) return
|
|
153
|
+
const filter = `id:[${ids.map((id) => String(id)).join(',')}]`
|
|
154
|
+
await this.request(
|
|
155
|
+
'DELETE',
|
|
156
|
+
`/collections/${encodeURIComponent(index)}/documents?filter_by=${encodeURIComponent(filter)}`,
|
|
157
|
+
{ acceptStatuses: [404] },
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Reads ──────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
async search(index: string, query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
164
|
+
const perPage = options.perPage ?? 20
|
|
165
|
+
const page = options.page ?? 1
|
|
166
|
+
|
|
167
|
+
const params = new URLSearchParams({
|
|
168
|
+
q: query.length === 0 ? '*' : query,
|
|
169
|
+
query_by: this.defaultQueryBy,
|
|
170
|
+
per_page: String(perPage),
|
|
171
|
+
page: String(page),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
if (options.filter !== undefined) {
|
|
175
|
+
if (typeof options.filter !== 'object' || options.filter === null || Array.isArray(options.filter)) {
|
|
176
|
+
throw new SearchQueryError(
|
|
177
|
+
'TypesenseDriver: `filter` must be a flat key/value object. Engine-native strings are not portable.',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
params.set('filter_by', buildTypesenseFilter(options.filter))
|
|
181
|
+
}
|
|
182
|
+
if (options.sort) params.set('sort_by', options.sort.join(','))
|
|
183
|
+
if (options.attributesToRetrieve) {
|
|
184
|
+
params.set('include_fields', options.attributesToRetrieve.join(','))
|
|
185
|
+
}
|
|
186
|
+
if (options.attributesToHighlight) {
|
|
187
|
+
params.set('highlight_fields', options.attributesToHighlight.join(','))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const data = await this.request<{
|
|
191
|
+
hits?: Array<{
|
|
192
|
+
document: Record<string, unknown>
|
|
193
|
+
highlights?: Array<{ field?: string; snippet?: string }>
|
|
194
|
+
}>
|
|
195
|
+
found?: number
|
|
196
|
+
search_time_ms?: number
|
|
197
|
+
}>(
|
|
198
|
+
'GET',
|
|
199
|
+
`/collections/${encodeURIComponent(index)}/documents/search?${params.toString()}`,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const hits: SearchHit[] = (data?.hits ?? []).map((hit) => {
|
|
203
|
+
const out: SearchHit = { document: hit.document }
|
|
204
|
+
if (hit.highlights && hit.highlights.length > 0) {
|
|
205
|
+
const highlights: Record<string, string> = {}
|
|
206
|
+
for (const h of hit.highlights) {
|
|
207
|
+
if (h.field && h.snippet) highlights[h.field] = h.snippet
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(highlights).length > 0) out.highlights = highlights
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
hits,
|
|
216
|
+
totalHits: data?.found ?? 0,
|
|
217
|
+
page,
|
|
218
|
+
perPage,
|
|
219
|
+
...(typeof data?.search_time_ms === 'number'
|
|
220
|
+
? { processingTimeMs: data.search_time_ms }
|
|
221
|
+
: {}),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── HTTP ──────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
private async request<T = unknown>(
|
|
228
|
+
method: string,
|
|
229
|
+
path: string,
|
|
230
|
+
options: { body?: unknown; acceptStatuses?: number[] } = {},
|
|
231
|
+
): Promise<T | null> {
|
|
232
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
233
|
+
method,
|
|
234
|
+
headers: this.jsonHeaders(),
|
|
235
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
236
|
+
})
|
|
237
|
+
return this.readResponse<T>(response, method, path, options.acceptStatuses)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async rawRequest(
|
|
241
|
+
method: string,
|
|
242
|
+
path: string,
|
|
243
|
+
body: string,
|
|
244
|
+
contentType: string,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
247
|
+
method,
|
|
248
|
+
headers: { 'content-type': contentType, 'x-typesense-api-key': this.apiKey },
|
|
249
|
+
body,
|
|
250
|
+
})
|
|
251
|
+
await this.readResponse(response, method, path)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async readResponse<T>(
|
|
255
|
+
response: Response,
|
|
256
|
+
method: string,
|
|
257
|
+
path: string,
|
|
258
|
+
acceptStatuses: number[] = [],
|
|
259
|
+
): Promise<T | null> {
|
|
260
|
+
if (response.ok || acceptStatuses.includes(response.status)) {
|
|
261
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') return null
|
|
262
|
+
const text = await response.text()
|
|
263
|
+
return text.length === 0 ? null : (JSON.parse(text) as T)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const text = await response.text()
|
|
267
|
+
const payload = safeJson(text)
|
|
268
|
+
if (response.status === 404) {
|
|
269
|
+
const index = extractIndexName(path)
|
|
270
|
+
throw new IndexNotFoundError(index ?? '<unknown>', this.name)
|
|
271
|
+
}
|
|
272
|
+
throw new SearchQueryError(
|
|
273
|
+
`TypesenseDriver: ${method} ${path} returned ${response.status}.`,
|
|
274
|
+
{ context: { status: response.status, body: payload ?? text }, cause: payload ?? text },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private jsonHeaders(): Record<string, string> {
|
|
279
|
+
return {
|
|
280
|
+
'content-type': 'application/json',
|
|
281
|
+
'x-typesense-api-key': this.apiKey,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resolveBaseUrl(config: DriverConfig): string {
|
|
287
|
+
const host = typeof config.host === 'string' ? config.host : 'localhost'
|
|
288
|
+
if (host.startsWith('http://') || host.startsWith('https://')) {
|
|
289
|
+
return host.replace(/\/+$/, '')
|
|
290
|
+
}
|
|
291
|
+
const protocol = typeof config.protocol === 'string' ? config.protocol : 'http'
|
|
292
|
+
const port = typeof config.port === 'number' ? config.port : 8108
|
|
293
|
+
return `${protocol}://${host}:${port}`
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function schemaFromSettings(index: string, settings: IndexSettings): CollectionSchema {
|
|
297
|
+
const fields: Array<Record<string, unknown>> = []
|
|
298
|
+
const seen = new Set<string>()
|
|
299
|
+
const push = (field: Record<string, unknown>) => {
|
|
300
|
+
const name = field.name as string
|
|
301
|
+
if (seen.has(name)) return
|
|
302
|
+
seen.add(name)
|
|
303
|
+
fields.push(field)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const attr of settings.searchableAttributes ?? []) {
|
|
307
|
+
push({ name: attr, type: 'string' })
|
|
308
|
+
}
|
|
309
|
+
for (const attr of settings.filterableAttributes ?? []) {
|
|
310
|
+
push({ name: attr, type: 'string', facet: true })
|
|
311
|
+
}
|
|
312
|
+
for (const attr of settings.sortableAttributes ?? []) {
|
|
313
|
+
push({ name: attr, type: 'string', sort: true })
|
|
314
|
+
}
|
|
315
|
+
if (fields.length === 0) {
|
|
316
|
+
push({ name: '.*', type: 'auto' })
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { name: index, fields }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildTypesenseFilter(filter: Record<string, unknown>): string {
|
|
323
|
+
return Object.entries(filter)
|
|
324
|
+
.map(([key, value]) => {
|
|
325
|
+
if (Array.isArray(value)) {
|
|
326
|
+
return `${key}:[${value.map((v) => String(v)).join(',')}]`
|
|
327
|
+
}
|
|
328
|
+
return `${key}:=${JSON.stringify(value)}`
|
|
329
|
+
})
|
|
330
|
+
.join(' && ')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function extractIndexName(path: string): string | undefined {
|
|
334
|
+
const match = /\/collections\/([^/?]+)/.exec(path)
|
|
335
|
+
return match ? decodeURIComponent(match[1]!) : undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function safeJson(text: string): unknown {
|
|
339
|
+
if (text.length === 0) return undefined
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(text)
|
|
342
|
+
} catch {
|
|
343
|
+
return undefined
|
|
344
|
+
}
|
|
345
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,48 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// Public API of `@strav/search`.
|
|
2
|
+
//
|
|
3
|
+
// Slice A (current):
|
|
4
|
+
// - SearchEngine interface + portable types.
|
|
5
|
+
// - SearchManager + SearchProvider service wiring.
|
|
6
|
+
// - MemoryDriver (in-process BM25 engine for tests / dev).
|
|
7
|
+
// - `searchable()` Repository mixin + SearchableRegistry.
|
|
8
|
+
//
|
|
9
|
+
// Subsequent slices add Meilisearch, Typesense, and Postgres FTS
|
|
10
|
+
// drivers, plus the `search:import` / `search:flush` /
|
|
11
|
+
// `search:reindex` console commands.
|
|
3
12
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
13
|
+
export {
|
|
14
|
+
SearchConsoleProvider,
|
|
15
|
+
SearchFlush,
|
|
16
|
+
SearchImport,
|
|
17
|
+
SearchList,
|
|
18
|
+
SearchReindex,
|
|
19
|
+
} from './console/index.ts'
|
|
20
|
+
export { MeilisearchDriver } from './drivers/meilisearch/meilisearch_driver.ts'
|
|
21
|
+
export { MemoryDriver } from './drivers/memory/memory_driver.ts'
|
|
22
|
+
export {
|
|
23
|
+
type ApplySearchMigrationOptions,
|
|
24
|
+
applySearchMigration,
|
|
25
|
+
DEFAULT_SEARCH_SCHEMA,
|
|
26
|
+
} from './drivers/postgres/apply_search_migration.ts'
|
|
27
|
+
export {
|
|
28
|
+
PostgresFtsDriver,
|
|
29
|
+
type PostgresFtsDriverOptions,
|
|
30
|
+
} from './drivers/postgres/postgres_fts_driver.ts'
|
|
31
|
+
export { TypesenseDriver } from './drivers/typesense/typesense_driver.ts'
|
|
8
32
|
export type { SearchEngine } from './search_engine.ts'
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from './
|
|
21
|
-
export {
|
|
22
|
-
export type {
|
|
23
|
-
PostgresFtsConfig,
|
|
24
|
-
PgIndexSettings,
|
|
25
|
-
} from './drivers/postgres/index.ts'
|
|
26
|
-
|
|
27
|
-
// Mixin
|
|
33
|
+
export {
|
|
34
|
+
IndexNotFoundError,
|
|
35
|
+
SearchConfigError,
|
|
36
|
+
SearchError,
|
|
37
|
+
SearchQueryError,
|
|
38
|
+
} from './search_error.ts'
|
|
39
|
+
export {
|
|
40
|
+
type EngineFactory,
|
|
41
|
+
SearchManager,
|
|
42
|
+
type SearchManagerOptions,
|
|
43
|
+
type SearchManagerResolver,
|
|
44
|
+
} from './search_manager.ts'
|
|
45
|
+
export { SearchProvider } from './search_provider.ts'
|
|
28
46
|
export { searchable } from './searchable.ts'
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export type { ScopedSearch } from './helpers.ts'
|
|
34
|
-
|
|
35
|
-
// Errors
|
|
36
|
-
export { SearchError, IndexNotFoundError, SearchQueryError } from './errors.ts'
|
|
37
|
-
|
|
38
|
-
// Types
|
|
47
|
+
export {
|
|
48
|
+
SearchableRegistry,
|
|
49
|
+
type SearchableTarget,
|
|
50
|
+
} from './searchable_registry.ts'
|
|
39
51
|
export type {
|
|
40
|
-
SearchConfig,
|
|
41
52
|
DriverConfig,
|
|
53
|
+
IndexSettings,
|
|
54
|
+
SearchConfig,
|
|
42
55
|
SearchDocument,
|
|
56
|
+
SearchHit,
|
|
43
57
|
SearchOptions,
|
|
44
58
|
SearchResult,
|
|
45
|
-
SearchHit,
|
|
46
|
-
IndexSettings,
|
|
47
|
-
SearchScope,
|
|
48
59
|
} from './types.ts'
|
package/src/search_engine.ts
CHANGED
|
@@ -1,36 +1,51 @@
|
|
|
1
|
-
import type { SearchDocument, SearchOptions, SearchResult, IndexSettings } from './types.ts'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
2
|
+
* `SearchEngine` — the storage abstraction every search driver
|
|
3
|
+
* (`MemoryDriver`, `MeilisearchDriver`, `TypesenseDriver`,
|
|
4
|
+
* `PostgresFtsDriver`, custom drivers registered via
|
|
5
|
+
* `manager.extend(...)`) implements.
|
|
6
|
+
*
|
|
7
|
+
* Index lifecycle:
|
|
8
|
+
*
|
|
9
|
+
* - `createIndex(name, settings?)` — idempotent. For
|
|
10
|
+
* `MemoryDriver` this allocates the bucket; for
|
|
11
|
+
* `MeilisearchDriver` / `TypesenseDriver` it creates the
|
|
12
|
+
* remote index; for `PostgresFtsDriver` it provisions the
|
|
13
|
+
* per-index table + GIN index.
|
|
14
|
+
* - `deleteIndex(name)` — drops the index entirely.
|
|
15
|
+
* - `flush(name)` — drops every document but keeps the index
|
|
16
|
+
* itself. Faster than `deleteIndex` for "wipe + reimport".
|
|
17
|
+
*
|
|
18
|
+
* Writes:
|
|
19
|
+
*
|
|
20
|
+
* - `upsert(index, id, doc)` / `upsertMany(index, docs)` —
|
|
21
|
+
* insert or replace. Drivers MAY batch internally; callers
|
|
22
|
+
* don't need to chunk for portability.
|
|
23
|
+
* - `delete(index, id)` / `deleteMany(index, ids)` — remove.
|
|
5
24
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
25
|
+
* Reads:
|
|
26
|
+
*
|
|
27
|
+
* - `search(index, query, options?)` — full-text search. The
|
|
28
|
+
* `SearchOptions` shape is portable across drivers; engines
|
|
29
|
+
* ignore options they don't support rather than throwing.
|
|
8
30
|
*/
|
|
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
31
|
|
|
16
|
-
|
|
17
|
-
upsertMany(index: string, documents: SearchDocument[]): Promise<void>
|
|
32
|
+
import type { IndexSettings, SearchDocument, SearchOptions, SearchResult } from './types.ts'
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
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>
|
|
34
|
+
export interface SearchEngine {
|
|
35
|
+
/** Driver identifier — `'memory'`, `'meilisearch'`, `'typesense'`, `'postgres-fts'`, or the name passed to `manager.extend`. */
|
|
36
|
+
readonly name: string
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
// ─── Index lifecycle ───────────────────────────────────────────────────
|
|
39
|
+
createIndex(index: string, settings?: IndexSettings): Promise<void>
|
|
29
40
|
deleteIndex(index: string): Promise<void>
|
|
41
|
+
flush(index: string): Promise<void>
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
// ─── Writes ────────────────────────────────────────────────────────────
|
|
44
|
+
upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void>
|
|
45
|
+
upsertMany(index: string, documents: readonly SearchDocument[]): Promise<void>
|
|
46
|
+
delete(index: string, id: string | number): Promise<void>
|
|
47
|
+
deleteMany(index: string, ids: readonly (string | number)[]): Promise<void>
|
|
33
48
|
|
|
34
|
-
|
|
49
|
+
// ─── Reads ─────────────────────────────────────────────────────────────
|
|
35
50
|
search(index: string, query: string, options?: SearchOptions): Promise<SearchResult>
|
|
36
51
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SearchError` hierarchy — typed wrappers for failures in the
|
|
3
|
+
* search stack. Apps branch on the concrete subclass at the
|
|
4
|
+
* call site instead of parsing error messages.
|
|
5
|
+
*
|
|
6
|
+
* Three concrete subclasses ship in V1:
|
|
7
|
+
*
|
|
8
|
+
* - `IndexNotFoundError` — a query / mutation referenced an
|
|
9
|
+
* index that doesn't exist on the active engine. Apps create
|
|
10
|
+
* the index via `manager.engine().createIndex(name, …)` (or
|
|
11
|
+
* via the mixin's `Repository.createIndex()`) before the
|
|
12
|
+
* first upsert.
|
|
13
|
+
*
|
|
14
|
+
* - `SearchQueryError` — the underlying engine rejected the
|
|
15
|
+
* query (bad filter shape, unknown attribute, malformed
|
|
16
|
+
* sort directive, etc.). Cause carries the driver-native
|
|
17
|
+
* error.
|
|
18
|
+
*
|
|
19
|
+
* - `SearchConfigError` — surfaced from the manager when the
|
|
20
|
+
* requested driver isn't configured or a custom factory
|
|
21
|
+
* wasn't registered.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { StravError } from '@strav/kernel'
|
|
25
|
+
|
|
26
|
+
export class SearchError extends StravError {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
options: {
|
|
30
|
+
code?: string
|
|
31
|
+
status?: number
|
|
32
|
+
context?: Record<string, unknown>
|
|
33
|
+
cause?: unknown
|
|
34
|
+
} = {},
|
|
35
|
+
) {
|
|
36
|
+
super(
|
|
37
|
+
message,
|
|
38
|
+
{ code: options.code ?? 'search.error', status: options.status ?? 500 },
|
|
39
|
+
{
|
|
40
|
+
...(options.context ? { context: options.context } : {}),
|
|
41
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class IndexNotFoundError extends SearchError {
|
|
48
|
+
constructor(index: string, engine: string) {
|
|
49
|
+
super(
|
|
50
|
+
`Search index "${index}" does not exist on engine "${engine}". Call \`engine.createIndex("${index}", …)\` before the first upsert.`,
|
|
51
|
+
{
|
|
52
|
+
code: 'search.index_not_found',
|
|
53
|
+
status: 404,
|
|
54
|
+
context: { index, engine },
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class SearchQueryError extends SearchError {
|
|
61
|
+
constructor(
|
|
62
|
+
message: string,
|
|
63
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
64
|
+
) {
|
|
65
|
+
super(message, {
|
|
66
|
+
code: 'search.query',
|
|
67
|
+
status: 400,
|
|
68
|
+
...(options.context ? { context: options.context } : {}),
|
|
69
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class SearchConfigError extends SearchError {
|
|
75
|
+
constructor(
|
|
76
|
+
message: string,
|
|
77
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
78
|
+
) {
|
|
79
|
+
super(message, {
|
|
80
|
+
code: 'search.config',
|
|
81
|
+
status: 500,
|
|
82
|
+
...(options.context ? { context: options.context } : {}),
|
|
83
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|