@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.
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }