@strav/search 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/package.json +32 -0
- package/src/commands/search_flush.ts +41 -0
- package/src/commands/search_import.ts +43 -0
- package/src/drivers/algolia_driver.ts +170 -0
- package/src/drivers/meilisearch_driver.ts +150 -0
- package/src/drivers/null_driver.ts +27 -0
- package/src/drivers/typesense_driver.ts +229 -0
- package/src/errors.ts +18 -0
- package/src/helpers.ts +70 -0
- package/src/index.ts +35 -0
- package/src/search_engine.ts +36 -0
- package/src/search_manager.ts +97 -0
- package/src/search_provider.ts +16 -0
- package/src/searchable.ts +211 -0
- package/src/types.ts +81 -0
- package/stubs/config/search.ts +32 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { BaseModel } from '@stravigor/database'
|
|
2
|
+
import type { NormalizeConstructor } from '@stravigor/kernel'
|
|
3
|
+
import { Emitter } from '@stravigor/kernel'
|
|
4
|
+
import SearchManager from './search_manager.ts'
|
|
5
|
+
import type { SearchOptions, SearchResult, SearchDocument, IndexSettings } from './types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mixin that adds full-text search capabilities to a BaseModel subclass.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { BaseModel } from '@stravigor/database'
|
|
12
|
+
* import { searchable } from '@stravigor/search'
|
|
13
|
+
*
|
|
14
|
+
* class Article extends searchable(BaseModel) {
|
|
15
|
+
* declare id: number
|
|
16
|
+
* declare title: string
|
|
17
|
+
* declare body: string
|
|
18
|
+
*
|
|
19
|
+
* static searchableAs() { return 'articles' }
|
|
20
|
+
*
|
|
21
|
+
* toSearchableArray() {
|
|
22
|
+
* return { id: this.id, title: this.title, body: this.body }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* // Composable with other mixins:
|
|
27
|
+
* import { compose } from '@stravigor/kernel'
|
|
28
|
+
* class Article extends compose(BaseModel, softDeletes, searchable) { }
|
|
29
|
+
*
|
|
30
|
+
* // Boot auto-indexing (in app bootstrap):
|
|
31
|
+
* Article.bootSearch('article')
|
|
32
|
+
*
|
|
33
|
+
* // Search:
|
|
34
|
+
* const results = await Article.search('typescript')
|
|
35
|
+
*/
|
|
36
|
+
export function searchable<T extends NormalizeConstructor<typeof BaseModel>>(Base: T) {
|
|
37
|
+
return class Searchable extends Base {
|
|
38
|
+
private static _searchBooted = false
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The search index name for this model.
|
|
42
|
+
* Defaults to the table name. Override to customize.
|
|
43
|
+
*/
|
|
44
|
+
static searchableAs(): string {
|
|
45
|
+
return (this as unknown as typeof BaseModel).tableName
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
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 '_'.
|
|
53
|
+
*/
|
|
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
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether this model instance should be indexed.
|
|
65
|
+
* Override to conditionally exclude records (e.g. drafts).
|
|
66
|
+
*/
|
|
67
|
+
shouldBeSearchable(): boolean {
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Index settings for this model (searchable/filterable/sortable attributes).
|
|
73
|
+
* Override to configure. Returns undefined by default (use engine defaults).
|
|
74
|
+
*/
|
|
75
|
+
static searchableSettings(): IndexSettings | undefined {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
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)
|
|
90
|
+
}
|
|
91
|
+
|
|
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)
|
|
99
|
+
}
|
|
100
|
+
|
|
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
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
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.
|
|
113
|
+
*/
|
|
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>[]
|
|
129
|
+
|
|
130
|
+
if (rows.length === 0) break
|
|
131
|
+
|
|
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
|
+
}
|
|
150
|
+
|
|
151
|
+
return imported
|
|
152
|
+
}
|
|
153
|
+
|
|
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)
|
|
158
|
+
}
|
|
159
|
+
|
|
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)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
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).
|
|
174
|
+
*/
|
|
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
|
+
}
|
|
197
|
+
}
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
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>
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ── Documents ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface SearchDocument {
|
|
4
|
+
id: string | number
|
|
5
|
+
[key: string]: unknown
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// ── Index settings ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface IndexSettings {
|
|
11
|
+
/** Fields to use for full-text search. */
|
|
12
|
+
searchableAttributes?: string[]
|
|
13
|
+
/** Fields to return in results. */
|
|
14
|
+
displayedAttributes?: string[]
|
|
15
|
+
/** Fields that can be used as filters. */
|
|
16
|
+
filterableAttributes?: string[]
|
|
17
|
+
/** Fields that can be used for sorting. */
|
|
18
|
+
sortableAttributes?: string[]
|
|
19
|
+
/** Primary key field name (defaults to 'id'). */
|
|
20
|
+
primaryKey?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Search options & results ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface SearchOptions {
|
|
26
|
+
/** Filters — key-value pairs or engine-native filter string. */
|
|
27
|
+
filter?: Record<string, unknown> | string
|
|
28
|
+
/** Sort by field(s), e.g. ['created_at:desc']. */
|
|
29
|
+
sort?: string[]
|
|
30
|
+
/** Page number (1-based). */
|
|
31
|
+
page?: number
|
|
32
|
+
/** Results per page. */
|
|
33
|
+
perPage?: number
|
|
34
|
+
/** Fields to return in results. */
|
|
35
|
+
attributesToRetrieve?: string[]
|
|
36
|
+
/** Fields to highlight in results. */
|
|
37
|
+
attributesToHighlight?: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SearchResult {
|
|
41
|
+
/** The matching documents. */
|
|
42
|
+
hits: SearchHit[]
|
|
43
|
+
/** Total number of matching documents (estimated). */
|
|
44
|
+
totalHits: number
|
|
45
|
+
/** Current page. */
|
|
46
|
+
page: number
|
|
47
|
+
/** Results per page. */
|
|
48
|
+
perPage: number
|
|
49
|
+
/** Processing time in milliseconds (if provided by the engine). */
|
|
50
|
+
processingTimeMs?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SearchHit {
|
|
54
|
+
/** The document data. */
|
|
55
|
+
document: Record<string, unknown>
|
|
56
|
+
/** Highlighted fields (if requested). */
|
|
57
|
+
highlights?: Record<string, string>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Configuration ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface SearchConfig {
|
|
63
|
+
/** Default driver name. */
|
|
64
|
+
default: string
|
|
65
|
+
/** Index name prefix (e.g. 'myapp_'). */
|
|
66
|
+
prefix: string
|
|
67
|
+
/** Driver configurations keyed by name. */
|
|
68
|
+
drivers: Record<string, DriverConfig>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DriverConfig {
|
|
72
|
+
driver: string
|
|
73
|
+
host?: string
|
|
74
|
+
port?: number
|
|
75
|
+
apiKey?: string
|
|
76
|
+
/** Algolia application ID. */
|
|
77
|
+
appId?: string
|
|
78
|
+
/** Protocol — 'http' or 'https'. */
|
|
79
|
+
protocol?: string
|
|
80
|
+
[key: string]: unknown
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { env } from '@stravigor/kernel'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
/** The default search driver to use. */
|
|
5
|
+
default: env('SEARCH_DRIVER', 'meilisearch'),
|
|
6
|
+
|
|
7
|
+
/** Index name prefix (useful for multi-tenant or multi-environment). */
|
|
8
|
+
prefix: env('SEARCH_PREFIX', ''),
|
|
9
|
+
|
|
10
|
+
drivers: {
|
|
11
|
+
meilisearch: {
|
|
12
|
+
driver: 'meilisearch',
|
|
13
|
+
host: env('MEILISEARCH_HOST', 'localhost'),
|
|
14
|
+
port: env('MEILISEARCH_PORT', '7700').int(),
|
|
15
|
+
apiKey: env('MEILISEARCH_KEY', ''),
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
typesense: {
|
|
19
|
+
driver: 'typesense',
|
|
20
|
+
host: env('TYPESENSE_HOST', 'localhost'),
|
|
21
|
+
port: env('TYPESENSE_PORT', '8108').int(),
|
|
22
|
+
apiKey: env('TYPESENSE_KEY', ''),
|
|
23
|
+
protocol: 'http',
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
algolia: {
|
|
27
|
+
driver: 'algolia',
|
|
28
|
+
appId: env('ALGOLIA_APP_ID', ''),
|
|
29
|
+
apiKey: env('ALGOLIA_SECRET', ''),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}
|