@strav/search 0.4.30 → 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
package/src/searchable.ts CHANGED
@@ -1,211 +1,224 @@
1
- import type { BaseModel } from '@strav/database'
2
- import type { NormalizeConstructor } from '@strav/kernel'
3
- import { Emitter } from '@strav/kernel'
4
- import SearchManager from './search_manager.ts'
5
- import type { SearchOptions, SearchResult, SearchDocument, IndexSettings } from './types.ts'
6
-
7
1
  /**
8
- * Mixin that adds full-text search capabilities to a BaseModel subclass.
2
+ * `searchable(Repository)` — class mixin that bolts full-text
3
+ * search methods onto a Repository so apps can index a row and
4
+ * query its engine without juggling `SearchManager` calls by
5
+ * hand.
9
6
  *
10
- * @example
11
- * import { BaseModel } from '@strav/database'
12
- * import { searchable } from '@strav/search'
7
+ * ```ts
8
+ * export class ArticleRepository extends searchable(Repository<Article>) {
9
+ * static override readonly schema = articleSchema
10
+ * static override readonly model = Article
13
11
  *
14
- * class Article extends searchable(BaseModel) {
15
- * declare id: number
16
- * declare title: string
17
- * declare body: string
12
+ * constructor(options: RepositoryOptions, search: SearchManager) {
13
+ * super(options)
14
+ * this.search = search
15
+ * }
18
16
  *
19
- * static searchableAs() { return 'articles' }
17
+ * protected override toSearchableDocument(a: Article) {
18
+ * return { id: a.id, title: a.title, body: a.body, status: a.status }
19
+ * }
20
20
  *
21
- * toSearchableArray() {
22
- * return { id: this.id, title: this.title, body: this.body }
21
+ * protected static override searchableSettings() {
22
+ * return {
23
+ * searchableAttributes: ['title', 'body'],
24
+ * filterableAttributes: ['status'],
25
+ * sortableAttributes: ['created_at'],
26
+ * }
23
27
  * }
24
28
  * }
29
+ * ```
30
+ *
31
+ * Usage:
32
+ *
33
+ * ```ts
34
+ * await articles.createIndex() // first boot
35
+ * const a = await articles.create(...)
36
+ * await articles.index(a) // push to engine
37
+ *
38
+ * const r = await articles.search('hello', { perPage: 10 })
39
+ *
40
+ * await articles.delete(a)
41
+ * await articles.removeFromIndex(a)
42
+ * ```
43
+ *
44
+ * Why not auto-index on `create` / `update`?
45
+ *
46
+ * V1 ships the explicit pattern (mirrors `@strav/rag`'s
47
+ * `retrievable()`). An auto-hook would couple persistence to
48
+ * the search engine's availability — a transient
49
+ * Meilisearch outage would fail the create call. Apps that
50
+ * want auto-index wire it themselves via
51
+ * `events.on('article.created', m => articles.index(m))` so
52
+ * they own the failure mode (fire-and-forget vs awaited vs
53
+ * queued via `@strav/queue`).
54
+ *
55
+ * Extension points (all optional overrides):
56
+ *
57
+ * - `indexName()` — defaults to the table name from the
58
+ * schema. Override to point at a different index.
25
59
  *
26
- * // Composable with other mixins:
27
- * import { compose } from '@strav/kernel'
28
- * class Article extends compose(BaseModel, softDeletes, searchable) { }
60
+ * - `toSearchableDocument(model)` defaults to copying every
61
+ * own non-underscore field on the model. The default works
62
+ * for simple row shapes; apps with derived / nested fields
63
+ * override.
29
64
  *
30
- * // Boot auto-indexing (in app bootstrap):
31
- * Article.bootSearch('article')
65
+ * - `static searchableSettings()` `IndexSettings` for
66
+ * `createIndex()`. Defaults to `undefined` (engine
67
+ * defaults).
32
68
  *
33
- * // Search:
34
- * const results = await Article.search('typescript')
69
+ * - `shouldBeSearchable(model)` — gates indexing. Return
70
+ * `false` for drafts / soft-deleted / private rows. The
71
+ * default is `true`.
35
72
  */
36
- export function searchable<T extends NormalizeConstructor<typeof BaseModel>>(Base: T) {
37
- return class Searchable extends Base {
38
- private static _searchBooted = false
39
73
 
74
+ import type { Repository } from '@strav/database'
75
+ import type { SearchManager } from './search_manager.ts'
76
+ import type { SearchOptions, SearchResult } from './types.ts'
77
+
78
+ /** Minimal constructor type we can mix into. Wider than `typeof Repository` so subclasses with extra ctor args still type-check. */
79
+ // biome-ignore lint/suspicious/noExplicitAny: mixin constructor signatures intentionally accept any[]; the user-side subclass narrows.
80
+ type RepositoryConstructor<TModel extends object> = new (...args: any[]) => Repository<TModel>
81
+
82
+ export function searchable<TModel extends object, TBase extends RepositoryConstructor<TModel>>(
83
+ Base: TBase,
84
+ ) {
85
+ abstract class SearchableRepository extends Base {
40
86
  /**
41
- * The search index name for this model.
42
- * Defaults to the table name. Override to customize.
87
+ * The framework's `SearchManager`. Assigned by the user-side
88
+ * subclass constructor. Public on purpose apps that want to
89
+ * drop to the raw engine (`search.engine().…`) have a hook.
43
90
  */
44
- static searchableAs(): string {
45
- return (this as unknown as typeof BaseModel).tableName
46
- }
91
+ search!: SearchManager
47
92
 
48
93
  /**
49
- * Convert this model instance to a document for the search index.
50
- * Override in subclass to control which fields are indexed.
51
- *
52
- * Default: returns all own properties that don't start with '_'.
94
+ * Index name for this repository's documents. Defaults to
95
+ * the schema name. Override to point at a different index or
96
+ * to compose per-env suffixes.
53
97
  */
54
- toSearchableArray(): Record<string, unknown> {
55
- const data: Record<string, unknown> = {}
56
- for (const key of Object.keys(this)) {
57
- if (key.startsWith('_')) continue
58
- data[key] = (this as any)[key]
59
- }
60
- return data
98
+ protected indexName(): string {
99
+ const ctor = this.constructor as unknown as { schema: { name: string } }
100
+ return ctor.schema.name
61
101
  }
62
102
 
63
103
  /**
64
- * Whether this model instance should be indexed.
65
- * Override to conditionally exclude records (e.g. drafts).
104
+ * Build the document persisted to the engine. Default copies
105
+ * every non-underscore own property. Apps with structured
106
+ * content override.
66
107
  */
67
- shouldBeSearchable(): boolean {
68
- return true
108
+ protected toSearchableDocument(model: TModel): Record<string, unknown> {
109
+ const out: Record<string, unknown> = {}
110
+ for (const [key, value] of Object.entries(model as Record<string, unknown>)) {
111
+ if (key.startsWith('_')) continue
112
+ out[key] = value
113
+ }
114
+ return out
69
115
  }
70
116
 
71
117
  /**
72
- * Index settings for this model (searchable/filterable/sortable attributes).
73
- * Override to configure. Returns undefined by default (use engine defaults).
118
+ * Per-class index settings `searchableAttributes`,
119
+ * `filterableAttributes`, etc. Returns `undefined` by
120
+ * default; engines fall back to their own defaults.
74
121
  */
75
- static searchableSettings(): IndexSettings | undefined {
122
+ protected static searchableSettings(): import('./types.ts').IndexSettings | undefined {
76
123
  return undefined
77
124
  }
78
125
 
79
- // ── Instance methods ─────────────────────────────────────────────────
80
-
81
- /** Index (upsert) this model instance in the search engine. */
82
- async searchIndex(): Promise<void> {
83
- if (!this.shouldBeSearchable()) return
84
- const ctor = this.constructor as typeof Searchable
85
- const index = SearchManager.indexName(ctor.searchableAs())
86
- const pkProp = (ctor as unknown as typeof BaseModel).primaryKeyProperty
87
- const id = (this as any)[pkProp]
88
- const document = this.toSearchableArray()
89
- await SearchManager.engine().upsert(index, id, document)
126
+ /** Whether `model` should currently be indexed. Override to skip drafts / soft-deleted. */
127
+ protected shouldBeSearchable(_model: TModel): boolean {
128
+ return true
90
129
  }
91
130
 
92
- /** Remove this model instance from the search index. */
93
- async searchRemove(): Promise<void> {
94
- const ctor = this.constructor as typeof Searchable
95
- const index = SearchManager.indexName(ctor.searchableAs())
96
- const pkProp = (ctor as unknown as typeof BaseModel).primaryKeyProperty
97
- const id = (this as any)[pkProp]
98
- await SearchManager.engine().delete(index, id)
131
+ /** Resolve the fully-prefixed index name through the manager. */
132
+ private resolvedIndex(): string {
133
+ return this.search.indexName(this.indexName())
99
134
  }
100
135
 
101
- // ── Static methods ───────────────────────────────────────────────────
102
-
103
- /** Perform a full-text search on this model's index. */
104
- static async search(query: string, options?: SearchOptions): Promise<SearchResult> {
105
- const index = SearchManager.indexName(this.searchableAs())
106
- return SearchManager.engine().search(index, query, options)
107
- }
136
+ // ─── Per-row writes ───────────────────────────────────────────────────
108
137
 
109
138
  /**
110
- * Import all records into the search index. Fetches from DB in chunks.
111
- * @param chunkSize Number of records per batch.
112
- * @returns The number of documents indexed.
139
+ * Upsert one model into the engine. When
140
+ * `shouldBeSearchable(model)` returns `false`, the row is
141
+ * dropped from the index instead (so the "this just became
142
+ * private" path doesn't need a separate call).
113
143
  */
114
- static async importAll(chunkSize: number = 500): Promise<number> {
115
- const ModelCtor = this as unknown as typeof BaseModel & typeof Searchable
116
- const index = SearchManager.indexName(this.searchableAs())
117
- const db = ModelCtor.db
118
- const table = ModelCtor.tableName
119
- const pkCol = ModelCtor.primaryKeyColumn
120
-
121
- let imported = 0
122
- let offset = 0
123
-
124
- while (true) {
125
- const rows = (await db.sql.unsafe(
126
- `SELECT * FROM "${table}" ORDER BY "${pkCol}" LIMIT $1 OFFSET $2`,
127
- [chunkSize, offset]
128
- )) as Record<string, unknown>[]
144
+ async index(model: TModel): Promise<void> {
145
+ const id = modelId(model)
146
+ const indexName = this.resolvedIndex()
147
+ const engine = this.search.engine()
148
+ if (!this.shouldBeSearchable(model)) {
149
+ await engine.delete(indexName, id)
150
+ return
151
+ }
152
+ const document = this.toSearchableDocument(model)
153
+ await engine.upsert(indexName, id, document)
154
+ }
129
155
 
130
- if (rows.length === 0) break
156
+ /** Drop one model from the engine. */
157
+ async removeFromIndex(model: TModel): Promise<void> {
158
+ const id = modelId(model)
159
+ await this.search.engine().delete(this.resolvedIndex(), id)
160
+ }
131
161
 
132
- const documents: SearchDocument[] = []
133
- for (const row of rows) {
134
- const instance = ModelCtor.hydrate(row) as InstanceType<typeof Searchable>
135
- if (instance.shouldBeSearchable()) {
136
- const doc = instance.toSearchableArray()
137
- const pkProp = ModelCtor.primaryKeyProperty
138
- documents.push({ id: (instance as any)[pkProp], ...doc })
139
- }
140
- }
141
-
142
- if (documents.length > 0) {
143
- await SearchManager.engine().upsertMany(index, documents)
144
- imported += documents.length
145
- }
146
-
147
- offset += chunkSize
148
- if (rows.length < chunkSize) break
149
- }
162
+ // ─── Reads ────────────────────────────────────────────────────────────
150
163
 
151
- return imported
164
+ /** Full-text search this repository's index. */
165
+ async searchQuery(query: string, options?: SearchOptions): Promise<SearchResult> {
166
+ return this.search.engine().search(this.resolvedIndex(), query, options)
152
167
  }
153
168
 
154
- /** Flush all documents from this model's search index. */
155
- static async flushIndex(): Promise<void> {
156
- const index = SearchManager.indexName(this.searchableAs())
157
- await SearchManager.engine().flush(index)
169
+ // ─── Bulk operations ──────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Create this repository's index with the static
173
+ * `searchableSettings()` payload. Idempotent on every
174
+ * driver. Call once at boot or as part of a deploy.
175
+ */
176
+ async createIndex(): Promise<void> {
177
+ const ctor = this.constructor as unknown as typeof SearchableRepository
178
+ const settings = ctor.searchableSettings()
179
+ await this.search.engine().createIndex(this.resolvedIndex(), settings)
158
180
  }
159
181
 
160
- /** Create this model's search index with configured settings. */
161
- static async createSearchIndex(): Promise<void> {
162
- const index = SearchManager.indexName(this.searchableAs())
163
- const settings = this.searchableSettings()
164
- await SearchManager.engine().createIndex(index, settings)
182
+ /** Drop every document in this repository's index (keeps the index itself). */
183
+ async flushIndex(): Promise<void> {
184
+ await this.search.engine().flush(this.resolvedIndex())
165
185
  }
166
186
 
167
187
  /**
168
- * Register Emitter listeners for auto-indexing on model events.
169
- *
170
- * Hooks into `<prefix>.created`, `<prefix>.updated`, `<prefix>.synced`,
171
- * and `<prefix>.deleted` events emitted by generated services.
172
- *
173
- * @param eventPrefix The event prefix (e.g. 'article' for ArticleEvents).
188
+ * Walk every row and upsert it into the engine. Useful for
189
+ * backfilling a new index or recovering after a schema
190
+ * change. Returns the total row count processed.
174
191
  */
175
- static bootSearch(eventPrefix: string): void {
176
- if (this._searchBooted) return
177
- this._searchBooted = true
178
-
179
- const indexFn = async (model: unknown) => {
180
- if (model && typeof (model as any).searchIndex === 'function') {
181
- try {
182
- await (model as any).searchIndex()
183
- } catch {
184
- // Search indexing is secondary — failures should not break the event pipeline
185
- }
186
- }
187
- }
188
-
189
- const removeFn = async (model: unknown) => {
190
- if (model && typeof (model as any).searchRemove === 'function') {
191
- try {
192
- await (model as any).searchRemove()
193
- } catch {
194
- // Search removal is secondary — failures should not break the event pipeline
195
- }
196
- }
192
+ async importAll(batchSize: number = 500): Promise<number> {
193
+ let processed = 0
194
+ let offset = 0
195
+ while (true) {
196
+ const rows = await this.query().orderBy('id', 'asc').limit(batchSize).offset(offset).get()
197
+ if (rows.length === 0) break
198
+ for (const row of rows) await this.index(row)
199
+ processed += rows.length
200
+ offset += rows.length
201
+ if (rows.length < batchSize) break
197
202
  }
198
-
199
- Emitter.on(`${eventPrefix}.created`, indexFn)
200
- Emitter.on(`${eventPrefix}.updated`, indexFn)
201
- Emitter.on(`${eventPrefix}.synced`, indexFn)
202
- Emitter.on(`${eventPrefix}.deleted`, removeFn)
203
+ return processed
203
204
  }
204
205
  }
206
+ return SearchableRepository
205
207
  }
206
208
 
207
- /** The instance type of any searchable model. */
208
- export type SearchableInstance = InstanceType<ReturnType<typeof searchable>>
209
-
210
- /** The static type of any searchable model class. */
211
- export type SearchableModel = ReturnType<typeof searchable>
209
+ /**
210
+ * Coerce a model's `id` to the form the engine accepts. Models
211
+ * use ULID / UUID / bigSerial — all three round-trip through
212
+ * `String(...)` cleanly. We pass numbers through verbatim so
213
+ * engines that key on numeric ids don't lose precision.
214
+ */
215
+ function modelId(model: object): string | number {
216
+ const id = (model as { id?: unknown }).id
217
+ if (id === undefined || id === null) {
218
+ throw new Error(
219
+ `searchable: model has no \`id\`. The mixin only works on models with a single-column id.`,
220
+ )
221
+ }
222
+ if (typeof id === 'number') return id
223
+ return String(id)
224
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `SearchableRegistry` — bag of named pointers to searchable
3
+ * repositories so the `search:import` and `search:flush`
4
+ * console commands can resolve them at runtime.
5
+ *
6
+ * The framework can't statically discover which repositories use
7
+ * the `searchable()` mixin — apps register them at boot:
8
+ *
9
+ * const registry = app.resolve(SearchableRegistry)
10
+ * registry.register('articles', ArticleRepository)
11
+ *
12
+ * Then:
13
+ *
14
+ * bun strav search:import articles
15
+ * bun strav search:import --all
16
+ *
17
+ * resolves the repository through the container and calls the
18
+ * mixin methods. The repo class must expose `importAll`,
19
+ * `flushIndex`, and `createIndex` — the `searchable()` mixin
20
+ * provides all three.
21
+ */
22
+
23
+ import { inject } from '@strav/kernel'
24
+ import { SearchError } from './search_error.ts'
25
+
26
+ export interface SearchableTarget {
27
+ importAll(batchSize?: number): Promise<number>
28
+ flushIndex(): Promise<void>
29
+ createIndex(): Promise<void>
30
+ }
31
+
32
+ // biome-ignore lint/suspicious/noExplicitAny: container-resolved constructor; the user-side class narrows.
33
+ type SearchableConstructor = new (...args: any[]) => SearchableTarget
34
+
35
+ @inject()
36
+ export class SearchableRegistry {
37
+ private readonly targets = new Map<string, SearchableConstructor>()
38
+
39
+ /** Register a repository class under `name`. Resolved through the container at command time. */
40
+ register(name: string, ctor: SearchableConstructor): void {
41
+ this.targets.set(name, ctor)
42
+ }
43
+
44
+ /** List every registered name — used by `search:import --all`. */
45
+ names(): readonly string[] {
46
+ return [...this.targets.keys()]
47
+ }
48
+
49
+ /** Resolve the constructor for one name. Throws when unregistered. */
50
+ resolve(name: string): SearchableConstructor {
51
+ const ctor = this.targets.get(name)
52
+ if (ctor === undefined) {
53
+ throw new SearchError(`SearchableRegistry: no searchable registered under "${name}".`, {
54
+ code: 'search.registry.not_found',
55
+ status: 404,
56
+ context: { requested: name, available: this.names() },
57
+ })
58
+ }
59
+ return ctor
60
+ }
61
+ }
package/src/types.ts CHANGED
@@ -1,96 +1,106 @@
1
- // ── Documents ─────────────────────────────────────────────────────────────
2
-
3
- export interface SearchDocument {
4
- id: string | number
5
- [key: string]: unknown
6
- }
7
-
8
- // ── Multi-tenant scope ────────────────────────────────────────────────────
9
-
10
1
  /**
11
- * Per-tenant scope applied at index-name resolution. Drivers don't see
12
- * the scope directly `SearchManager.indexName(name, scope)` rewrites
13
- * the index name to `${prefix}t${tenantId}_${name}` so two tenants on
14
- * the same shared engine read independent indexes.
2
+ * `@strav/search` types the data shapes apps see when indexing
3
+ * and querying full-text search indexes.
4
+ *
5
+ * Three concept clusters:
15
6
  *
16
- * The tenantId must match `/^[a-zA-Z0-9_-]+$/`; anything else throws
17
- * (the value lands in URL paths and SQL identifiers downstream).
7
+ * - **Documents** the unit a search engine indexes. Every
8
+ * document has an `id` (string or number) and an arbitrary
9
+ * bag of fields. The mixin's `toSearchableDocument(model)`
10
+ * produces these.
11
+ *
12
+ * - **Queries + results** — `SearchOptions` carries filters,
13
+ * sort, paging, and highlight hints; `SearchResult` carries
14
+ * the matching hits, total count, and per-engine processing
15
+ * time when available.
16
+ *
17
+ * - **Configuration** — `SearchConfig` is `config.search`.
18
+ * Apps declare drivers under `drivers`, pick one as
19
+ * `default`, and optionally prefix index names with
20
+ * `prefix` to namespace per-app or per-environment.
18
21
  */
19
- export interface SearchScope {
20
- tenantId: string | number
22
+
23
+ // ─── Documents ───────────────────────────────────────────────────────────
24
+
25
+ export interface SearchDocument {
26
+ id: string | number
27
+ [field: string]: unknown
21
28
  }
22
29
 
23
- // ── Index settings ────────────────────────────────────────────────────────
30
+ // ─── Index settings ──────────────────────────────────────────────────────
24
31
 
25
32
  export interface IndexSettings {
26
- /** Fields to use for full-text search. */
33
+ /** Fields to use for full-text search. Order matters — earlier = higher BM25 weight on engines that honor it. */
27
34
  searchableAttributes?: string[]
28
- /** Fields to return in results. */
35
+ /** Fields to return on each hit. Defaults to every field. */
29
36
  displayedAttributes?: string[]
30
- /** Fields that can be used as filters. */
37
+ /** Fields that can be used in `SearchOptions.filter`. */
31
38
  filterableAttributes?: string[]
32
- /** Fields that can be used for sorting. */
39
+ /** Fields that can be used in `SearchOptions.sort`. */
33
40
  sortableAttributes?: string[]
34
- /** Primary key field name (defaults to 'id'). */
41
+ /** Primary key field name (defaults to `'id'`). */
35
42
  primaryKey?: string
36
43
  }
37
44
 
38
- // ── Search options & results ──────────────────────────────────────────────
45
+ // ─── Search options & results ────────────────────────────────────────────
39
46
 
40
47
  export interface SearchOptions {
41
- /** Filters — key-value pairs or engine-native filter string. */
42
- filter?: Record<string, unknown> | string
43
- /** Sort by field(s), e.g. ['created_at:desc']. */
48
+ /**
49
+ * Filter on indexed fields. Object form is the portable shape:
50
+ * `{ status: 'published', kind: 'doc' }` — flat equality AND.
51
+ * Driver-specific filter strings are NOT accepted in V1 to keep
52
+ * cross-driver portability.
53
+ */
54
+ filter?: Record<string, unknown>
55
+ /** Sort directives, e.g. `['created_at:desc']`. Engine support varies. */
44
56
  sort?: string[]
45
- /** Page number (1-based). */
57
+ /** Page number (1-based). Default `1`. */
46
58
  page?: number
47
- /** Results per page. */
59
+ /** Hits per page. Default `20`. */
48
60
  perPage?: number
49
- /** Fields to return in results. */
61
+ /** Restrict returned fields. */
50
62
  attributesToRetrieve?: string[]
51
- /** Fields to highlight in results. */
63
+ /** Highlight these fields. Engines wrap matches with `<mark>` tags. */
52
64
  attributesToHighlight?: string[]
53
65
  }
54
66
 
55
67
  export interface SearchResult {
56
- /** The matching documents. */
57
68
  hits: SearchHit[]
58
- /** Total number of matching documents (estimated). */
69
+ /** Total matching documents (estimated on engines that don't compute the exact total). */
59
70
  totalHits: number
60
- /** Current page. */
71
+ /** The page that was returned (1-based). */
61
72
  page: number
62
- /** Results per page. */
73
+ /** The page size that was returned. */
63
74
  perPage: number
64
- /** Processing time in milliseconds (if provided by the engine). */
75
+ /** Engine-side processing time when reported, in milliseconds. */
65
76
  processingTimeMs?: number
66
77
  }
67
78
 
68
79
  export interface SearchHit {
69
- /** The document data. */
70
80
  document: Record<string, unknown>
71
- /** Highlighted fields (if requested). */
81
+ /** Highlighted fields when `attributesToHighlight` was requested. */
72
82
  highlights?: Record<string, string>
73
83
  }
74
84
 
75
- // ── Configuration ─────────────────────────────────────────────────────────
85
+ // ─── Configuration ───────────────────────────────────────────────────────
76
86
 
87
+ /**
88
+ * `config.search` shape. Apps that don't configure search get a
89
+ * sensible default (the in-process `memory` driver) — see
90
+ * `SearchProvider.boot()`.
91
+ */
77
92
  export interface SearchConfig {
78
- /** Default driver name. */
93
+ /** Default driver name — must be a key in `drivers`. */
79
94
  default: string
80
- /** Index name prefix (e.g. 'myapp_'). */
81
- prefix: string
95
+ /** Optional index-name prefix, applied via `SearchManager.indexName(name)`. */
96
+ prefix?: string
82
97
  /** Driver configurations keyed by name. */
83
98
  drivers: Record<string, DriverConfig>
84
99
  }
85
100
 
86
101
  export interface DriverConfig {
102
+ /** Driver kind — `'memory'`, `'meilisearch'`, `'typesense'`, `'postgres-fts'`, or a name registered via `manager.extend(...)`. */
87
103
  driver: string
88
- host?: string
89
- port?: number
90
- apiKey?: string
91
- /** Algolia application ID. */
92
- appId?: string
93
- /** Protocol — 'http' or 'https'. */
94
- protocol?: string
104
+ /** Free-form driver-specific fields (host, apiKey, schema, …). */
95
105
  [key: string]: unknown
96
106
  }