@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.
- 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
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* constructor(options: RepositoryOptions, search: SearchManager) {
|
|
13
|
+
* super(options)
|
|
14
|
+
* this.search = search
|
|
15
|
+
* }
|
|
18
16
|
*
|
|
19
|
-
*
|
|
17
|
+
* protected override toSearchableDocument(a: Article) {
|
|
18
|
+
* return { id: a.id, title: a.title, body: a.body, status: a.status }
|
|
19
|
+
* }
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
* return {
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
65
|
+
* - `static searchableSettings()` — `IndexSettings` for
|
|
66
|
+
* `createIndex()`. Defaults to `undefined` (engine
|
|
67
|
+
* defaults).
|
|
32
68
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
42
|
-
*
|
|
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
|
-
|
|
45
|
-
return (this as unknown as typeof BaseModel).tableName
|
|
46
|
-
}
|
|
91
|
+
search!: SearchManager
|
|
47
92
|
|
|
48
93
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
22
|
+
|
|
23
|
+
// ─── Documents ───────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface SearchDocument {
|
|
26
|
+
id: string | number
|
|
27
|
+
[field: string]: unknown
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
//
|
|
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
|
|
35
|
+
/** Fields to return on each hit. Defaults to every field. */
|
|
29
36
|
displayedAttributes?: string[]
|
|
30
|
-
/** Fields that can be used
|
|
37
|
+
/** Fields that can be used in `SearchOptions.filter`. */
|
|
31
38
|
filterableAttributes?: string[]
|
|
32
|
-
/** Fields that can be used
|
|
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
|
-
//
|
|
45
|
+
// ─── Search options & results ────────────────────────────────────────────
|
|
39
46
|
|
|
40
47
|
export interface SearchOptions {
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
/**
|
|
59
|
+
/** Hits per page. Default `20`. */
|
|
48
60
|
perPage?: number
|
|
49
|
-
/**
|
|
61
|
+
/** Restrict returned fields. */
|
|
50
62
|
attributesToRetrieve?: string[]
|
|
51
|
-
/**
|
|
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
|
|
69
|
+
/** Total matching documents (estimated on engines that don't compute the exact total). */
|
|
59
70
|
totalHits: number
|
|
60
|
-
/**
|
|
71
|
+
/** The page that was returned (1-based). */
|
|
61
72
|
page: number
|
|
62
|
-
/**
|
|
73
|
+
/** The page size that was returned. */
|
|
63
74
|
perPage: number
|
|
64
|
-
/**
|
|
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
|
|
81
|
+
/** Highlighted fields when `attributesToHighlight` was requested. */
|
|
72
82
|
highlights?: Record<string, string>
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
//
|
|
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
|
-
/**
|
|
81
|
-
prefix
|
|
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
|
|
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
|
}
|