@strav/search 0.4.31 → 1.0.0-alpha.31

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.
Files changed (59) hide show
  1. package/package.json +20 -22
  2. package/src/console/index.ts +5 -0
  3. package/src/console/search_console_provider.ts +20 -0
  4. package/src/console/search_flush.ts +49 -0
  5. package/src/console/search_import.ts +103 -0
  6. package/src/console/search_list.ts +46 -0
  7. package/src/console/search_reindex.ts +94 -0
  8. package/src/drivers/meilisearch/meilisearch_driver.ts +304 -0
  9. package/src/drivers/memory/memory_driver.ts +344 -0
  10. package/src/drivers/postgres/apply_search_migration.ts +74 -0
  11. package/src/drivers/postgres/postgres_fts_driver.ts +493 -135
  12. package/src/drivers/typesense/typesense_driver.ts +345 -0
  13. package/src/index.ts +50 -39
  14. package/src/search_engine.ts +40 -25
  15. package/src/search_error.ts +86 -0
  16. package/src/search_manager.ts +112 -94
  17. package/src/search_provider.ts +68 -6
  18. package/src/searchable.ts +173 -160
  19. package/src/searchable_registry.ts +61 -0
  20. package/src/types.ts +59 -49
  21. package/README.md +0 -191
  22. package/src/commands/search_flush.ts +0 -41
  23. package/src/commands/search_import.ts +0 -43
  24. package/src/commands/search_optimize.ts +0 -52
  25. package/src/commands/search_rebuild.ts +0 -73
  26. package/src/drivers/algolia_driver.ts +0 -170
  27. package/src/drivers/embedded/embedded_driver.ts +0 -136
  28. package/src/drivers/embedded/engine/field_registry.ts +0 -97
  29. package/src/drivers/embedded/engine/fts_query_builder.ts +0 -184
  30. package/src/drivers/embedded/engine/query_compiler.ts +0 -134
  31. package/src/drivers/embedded/engine/schema.ts +0 -99
  32. package/src/drivers/embedded/engine/snippet_formatter.ts +0 -29
  33. package/src/drivers/embedded/engine/sqlite_engine.ts +0 -255
  34. package/src/drivers/embedded/engine/typo_expander.ts +0 -138
  35. package/src/drivers/embedded/errors.ts +0 -15
  36. package/src/drivers/embedded/filters/filter_compiler.ts +0 -136
  37. package/src/drivers/embedded/index.ts +0 -3
  38. package/src/drivers/embedded/storage/paths.ts +0 -23
  39. package/src/drivers/embedded/types.ts +0 -34
  40. package/src/drivers/meilisearch_driver.ts +0 -150
  41. package/src/drivers/null_driver.ts +0 -27
  42. package/src/drivers/postgres/engine/field_registry.ts +0 -116
  43. package/src/drivers/postgres/engine/fts_query_builder.ts +0 -105
  44. package/src/drivers/postgres/engine/pg_engine.ts +0 -300
  45. package/src/drivers/postgres/engine/query_compiler.ts +0 -165
  46. package/src/drivers/postgres/engine/schema.ts +0 -187
  47. package/src/drivers/postgres/engine/snippet_formatter.ts +0 -31
  48. package/src/drivers/postgres/engine/typo_expander.ts +0 -131
  49. package/src/drivers/postgres/errors.ts +0 -33
  50. package/src/drivers/postgres/filters/filter_compiler.ts +0 -138
  51. package/src/drivers/postgres/index.ts +0 -14
  52. package/src/drivers/postgres/rebuild/rebuild_inplace.ts +0 -113
  53. package/src/drivers/postgres/storage/identifiers.ts +0 -46
  54. package/src/drivers/postgres/types.ts +0 -53
  55. package/src/drivers/typesense_driver.ts +0 -229
  56. package/src/errors.ts +0 -18
  57. package/src/helpers.ts +0 -120
  58. package/stubs/config/search.ts +0 -57
  59. 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
- // Manager
2
- export { default, default as SearchManager } from './search_manager.ts'
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
- // Provider
5
- export { default as SearchProvider } from './search_provider.ts'
6
-
7
- // Engine interface
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
- // 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
- export { EmbeddedDriver } from './drivers/embedded/index.ts'
16
- export type {
17
- EmbeddedConfig,
18
- TypoToleranceMode,
19
- TypoToleranceSettings,
20
- } from './drivers/embedded/index.ts'
21
- export { PostgresFtsDriver } from './drivers/postgres/index.ts'
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 type { SearchableInstance, SearchableModel } from './searchable.ts'
30
-
31
- // Helper
32
- export { search } from './helpers.ts'
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'
@@ -1,36 +1,51 @@
1
- import type { SearchDocument, SearchOptions, SearchResult, IndexSettings } from './types.ts'
2
-
3
1
  /**
4
- * Contract that every search engine driver must implement.
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
- * Drivers communicate with an external search service (Meilisearch,
7
- * Typesense, Algolia, etc.) via their REST API.
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
- /** Add or update multiple documents at once. */
17
- upsertMany(index: string, documents: SearchDocument[]): Promise<void>
32
+ import type { IndexSettings, SearchDocument, SearchOptions, SearchResult } from './types.ts'
18
33
 
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>
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
- /** Delete the entire index. */
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
- /** Create an index with optional settings. */
32
- createIndex(index: string, options?: IndexSettings): Promise<void>
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
- /** Perform a full-text search. */
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
+ }