@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,229 @@
1
+ import { ExternalServiceError } from '@stravigor/kernel'
2
+ import type { SearchEngine } from '../search_engine.ts'
3
+ import type {
4
+ SearchDocument,
5
+ SearchOptions,
6
+ SearchResult,
7
+ SearchHit,
8
+ IndexSettings,
9
+ DriverConfig,
10
+ } from '../types.ts'
11
+
12
+ /**
13
+ * Typesense driver — communicates with the Typesense REST API via raw `fetch()`.
14
+ *
15
+ * @see https://typesense.org/docs/api/
16
+ */
17
+ export class TypesenseDriver implements SearchEngine {
18
+ readonly name = 'typesense'
19
+ private baseUrl: string
20
+ private apiKey: string
21
+
22
+ constructor(config: DriverConfig) {
23
+ const protocol = config.protocol ?? 'http'
24
+ const host = config.host ?? 'localhost'
25
+ const port = config.port ?? 8108
26
+ this.baseUrl = `${protocol}://${host}:${port}`
27
+ this.apiKey = (config.apiKey as string) ?? ''
28
+ }
29
+
30
+ // ── Interface ────────────────────────────────────────────────────────────
31
+
32
+ async upsert(
33
+ index: string,
34
+ id: string | number,
35
+ document: Record<string, unknown>
36
+ ): Promise<void> {
37
+ await this.request(
38
+ 'POST',
39
+ `/collections/${encodeURIComponent(index)}/documents?action=upsert`,
40
+ { id: String(id), ...document }
41
+ )
42
+ }
43
+
44
+ async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
45
+ const jsonl = documents.map(doc => JSON.stringify({ ...doc, id: String(doc.id) })).join('\n')
46
+ await this.rawRequest(
47
+ 'POST',
48
+ `/collections/${encodeURIComponent(index)}/documents/import?action=upsert`,
49
+ jsonl,
50
+ 'text/plain'
51
+ )
52
+ }
53
+
54
+ async delete(index: string, id: string | number): Promise<void> {
55
+ await this.request(
56
+ 'DELETE',
57
+ `/collections/${encodeURIComponent(index)}/documents/${encodeURIComponent(String(id))}`
58
+ )
59
+ }
60
+
61
+ async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
62
+ const filter = `id:[${ids.map(id => String(id)).join(',')}]`
63
+ await this.request(
64
+ 'DELETE',
65
+ `/collections/${encodeURIComponent(index)}/documents?filter_by=${encodeURIComponent(filter)}`
66
+ )
67
+ }
68
+
69
+ async flush(index: string): Promise<void> {
70
+ // Typesense has no "delete all documents" endpoint — delete the collection and recreate it.
71
+ // We fetch the current schema first so we can recreate it.
72
+ let schema: any
73
+ try {
74
+ schema = await this.request('GET', `/collections/${encodeURIComponent(index)}`)
75
+ } catch {
76
+ // Collection doesn't exist — nothing to flush
77
+ return
78
+ }
79
+ await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
80
+ await this.request('POST', '/collections', {
81
+ name: schema.name,
82
+ fields: schema.fields,
83
+ })
84
+ }
85
+
86
+ async deleteIndex(index: string): Promise<void> {
87
+ await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
88
+ }
89
+
90
+ async createIndex(index: string, options?: IndexSettings): Promise<void> {
91
+ const fields: Record<string, unknown>[] = []
92
+
93
+ if (options?.searchableAttributes) {
94
+ for (const attr of options.searchableAttributes) {
95
+ fields.push({ name: attr, type: 'string', facet: false })
96
+ }
97
+ }
98
+ if (options?.filterableAttributes) {
99
+ for (const attr of options.filterableAttributes) {
100
+ if (!fields.some(f => f.name === attr)) {
101
+ fields.push({ name: attr, type: 'string', facet: true })
102
+ }
103
+ }
104
+ }
105
+ if (options?.sortableAttributes) {
106
+ for (const attr of options.sortableAttributes) {
107
+ if (!fields.some(f => f.name === attr)) {
108
+ fields.push({ name: attr, type: 'string', sort: true })
109
+ }
110
+ }
111
+ }
112
+
113
+ // Always include a wildcard field so untyped fields are auto-detected
114
+ if (fields.length === 0) {
115
+ fields.push({ name: '.*', type: 'auto' })
116
+ }
117
+
118
+ await this.request('POST', '/collections', {
119
+ name: index,
120
+ fields,
121
+ })
122
+ }
123
+
124
+ async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
125
+ const perPage = options?.perPage ?? 20
126
+ const page = options?.page ?? 1
127
+
128
+ const params = new URLSearchParams({
129
+ q: query,
130
+ query_by: '*',
131
+ per_page: String(perPage),
132
+ page: String(page),
133
+ })
134
+
135
+ if (options?.filter) {
136
+ params.set(
137
+ 'filter_by',
138
+ typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
139
+ )
140
+ }
141
+ if (options?.sort) {
142
+ params.set('sort_by', options.sort.map(s => s.replace(':', ':')).join(','))
143
+ }
144
+ if (options?.attributesToRetrieve) {
145
+ params.set('include_fields', options.attributesToRetrieve.join(','))
146
+ }
147
+ if (options?.attributesToHighlight) {
148
+ params.set('highlight_fields', options.attributesToHighlight.join(','))
149
+ }
150
+
151
+ const data = await this.request(
152
+ 'GET',
153
+ `/collections/${encodeURIComponent(index)}/documents/search?${params.toString()}`
154
+ )
155
+
156
+ return {
157
+ hits: (data.hits ?? []).map(
158
+ (hit: any): SearchHit => ({
159
+ document: hit.document,
160
+ highlights: hit.highlights?.reduce(
161
+ (acc: Record<string, string>, h: any) => {
162
+ if (h.field && h.snippet) acc[h.field] = h.snippet
163
+ return acc
164
+ },
165
+ {} as Record<string, string>
166
+ ),
167
+ })
168
+ ),
169
+ totalHits: data.found ?? 0,
170
+ page,
171
+ perPage,
172
+ processingTimeMs: data.search_time_ms,
173
+ }
174
+ }
175
+
176
+ // ── Private ──────────────────────────────────────────────────────────────
177
+
178
+ private headers(): Record<string, string> {
179
+ return {
180
+ 'content-type': 'application/json',
181
+ 'x-typesense-api-key': this.apiKey,
182
+ }
183
+ }
184
+
185
+ private async request(method: string, path: string, body?: unknown): Promise<any> {
186
+ const response = await fetch(`${this.baseUrl}${path}`, {
187
+ method,
188
+ headers: this.headers(),
189
+ body: body !== undefined ? JSON.stringify(body) : undefined,
190
+ })
191
+
192
+ if (!response.ok) {
193
+ const text = await response.text()
194
+ throw new ExternalServiceError('Typesense', response.status, text)
195
+ }
196
+
197
+ if (response.status === 204 || response.headers.get('content-length') === '0') return null
198
+ return response.json()
199
+ }
200
+
201
+ private async rawRequest(
202
+ method: string,
203
+ path: string,
204
+ body: string,
205
+ contentType: string
206
+ ): Promise<void> {
207
+ const response = await fetch(`${this.baseUrl}${path}`, {
208
+ method,
209
+ headers: { 'content-type': contentType, 'x-typesense-api-key': this.apiKey },
210
+ body,
211
+ })
212
+
213
+ if (!response.ok) {
214
+ const text = await response.text()
215
+ throw new ExternalServiceError('Typesense', response.status, text)
216
+ }
217
+ }
218
+
219
+ private buildFilter(filter: Record<string, unknown>): string {
220
+ return Object.entries(filter)
221
+ .map(([key, value]) => {
222
+ if (Array.isArray(value)) {
223
+ return `${key}:[${value.map(v => String(v)).join(',')}]`
224
+ }
225
+ return `${key}:=${value}`
226
+ })
227
+ .join(' && ')
228
+ }
229
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { StravError } from '@stravigor/kernel'
2
+
3
+ /** Base error class for all search errors. */
4
+ export class SearchError extends StravError {}
5
+
6
+ /** Thrown when a search index is not found. */
7
+ export class IndexNotFoundError extends SearchError {
8
+ constructor(index: string) {
9
+ super(`Search index "${index}" not found.`)
10
+ }
11
+ }
12
+
13
+ /** Thrown when a search query fails. */
14
+ export class SearchQueryError extends SearchError {
15
+ constructor(index: string, cause?: string) {
16
+ super(`Search query on "${index}" failed${cause ? `: ${cause}` : ''}.`)
17
+ }
18
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,70 @@
1
+ import SearchManager from './search_manager.ts'
2
+ import type { SearchEngine } from './search_engine.ts'
3
+ import type {
4
+ SearchDocument,
5
+ SearchOptions,
6
+ SearchResult,
7
+ IndexSettings,
8
+ DriverConfig,
9
+ } from './types.ts'
10
+
11
+ /**
12
+ * Search helper — the primary convenience API.
13
+ *
14
+ * @example
15
+ * import { search } from '@stravigor/search'
16
+ *
17
+ * const results = await search.query('articles', 'typescript generics')
18
+ * await search.upsert('articles', 1, { title: 'Guide', body: '...' })
19
+ */
20
+ export const search = {
21
+ /** Get the underlying engine instance (default or named). */
22
+ engine(name?: string): SearchEngine {
23
+ return SearchManager.engine(name)
24
+ },
25
+
26
+ /** Register a custom search driver factory. */
27
+ extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
28
+ SearchManager.extend(name, factory)
29
+ },
30
+
31
+ /** Perform a full-text search query. */
32
+ query(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
33
+ return SearchManager.engine().search(SearchManager.indexName(index), query, options)
34
+ },
35
+
36
+ /** Add or update a single document. */
37
+ upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void> {
38
+ return SearchManager.engine().upsert(SearchManager.indexName(index), id, document)
39
+ },
40
+
41
+ /** Add or update multiple documents. */
42
+ upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
43
+ return SearchManager.engine().upsertMany(SearchManager.indexName(index), documents)
44
+ },
45
+
46
+ /** Remove a document from the index. */
47
+ delete(index: string, id: string | number): Promise<void> {
48
+ return SearchManager.engine().delete(SearchManager.indexName(index), id)
49
+ },
50
+
51
+ /** Remove multiple documents from the index. */
52
+ deleteMany(index: string, ids: Array<string | number>): Promise<void> {
53
+ return SearchManager.engine().deleteMany(SearchManager.indexName(index), ids)
54
+ },
55
+
56
+ /** Remove all documents from an index. */
57
+ flush(index: string): Promise<void> {
58
+ return SearchManager.engine().flush(SearchManager.indexName(index))
59
+ },
60
+
61
+ /** Create an index with optional settings. */
62
+ createIndex(index: string, options?: IndexSettings): Promise<void> {
63
+ return SearchManager.engine().createIndex(SearchManager.indexName(index), options)
64
+ },
65
+
66
+ /** Delete an entire index. */
67
+ deleteIndex(index: string): Promise<void> {
68
+ return SearchManager.engine().deleteIndex(SearchManager.indexName(index))
69
+ },
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Manager
2
+ export { default, default as SearchManager } from './search_manager.ts'
3
+
4
+ // Provider
5
+ export { default as SearchProvider } from './search_provider.ts'
6
+
7
+ // Engine interface
8
+ 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
+
16
+ // Mixin
17
+ export { searchable } from './searchable.ts'
18
+ export type { SearchableInstance, SearchableModel } from './searchable.ts'
19
+
20
+ // Helper
21
+ export { search } from './helpers.ts'
22
+
23
+ // Errors
24
+ export { SearchError, IndexNotFoundError, SearchQueryError } from './errors.ts'
25
+
26
+ // Types
27
+ export type {
28
+ SearchConfig,
29
+ DriverConfig,
30
+ SearchDocument,
31
+ SearchOptions,
32
+ SearchResult,
33
+ SearchHit,
34
+ IndexSettings,
35
+ } from './types.ts'
@@ -0,0 +1,36 @@
1
+ import type { SearchDocument, SearchOptions, SearchResult, IndexSettings } from './types.ts'
2
+
3
+ /**
4
+ * Contract that every search engine driver must implement.
5
+ *
6
+ * Drivers communicate with an external search service (Meilisearch,
7
+ * Typesense, Algolia, etc.) via their REST API.
8
+ */
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
+
16
+ /** Add or update multiple documents at once. */
17
+ upsertMany(index: string, documents: SearchDocument[]): Promise<void>
18
+
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>
27
+
28
+ /** Delete the entire index. */
29
+ deleteIndex(index: string): Promise<void>
30
+
31
+ /** Create an index with optional settings. */
32
+ createIndex(index: string, options?: IndexSettings): Promise<void>
33
+
34
+ /** Perform a full-text search. */
35
+ search(index: string, query: string, options?: SearchOptions): Promise<SearchResult>
36
+ }
@@ -0,0 +1,97 @@
1
+ import { inject, Configuration, ConfigurationError } from '@stravigor/kernel'
2
+ import type { SearchEngine } from './search_engine.ts'
3
+ import type { SearchConfig, DriverConfig } from './types.ts'
4
+ import { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
5
+ import { TypesenseDriver } from './drivers/typesense_driver.ts'
6
+ import { AlgoliaDriver } from './drivers/algolia_driver.ts'
7
+ import { NullDriver } from './drivers/null_driver.ts'
8
+
9
+ @inject
10
+ export default class SearchManager {
11
+ private static _config: SearchConfig
12
+ private static _engines = new Map<string, SearchEngine>()
13
+ private static _extensions = new Map<string, (config: DriverConfig) => SearchEngine>()
14
+
15
+ constructor(config: Configuration) {
16
+ SearchManager._config = {
17
+ default: config.get('search.default', 'null') as string,
18
+ prefix: config.get('search.prefix', '') as string,
19
+ drivers: config.get('search.drivers', {}) as Record<string, DriverConfig>,
20
+ }
21
+ }
22
+
23
+ static get config(): SearchConfig {
24
+ if (!SearchManager._config) {
25
+ throw new ConfigurationError(
26
+ 'SearchManager not configured. Resolve it through the container first.'
27
+ )
28
+ }
29
+ return SearchManager._config
30
+ }
31
+
32
+ /** Get an engine by name, or the default engine. Engines are lazily created. */
33
+ static engine(name?: string): SearchEngine {
34
+ const key = name ?? SearchManager.config.default
35
+
36
+ let engine = SearchManager._engines.get(key)
37
+ if (engine) return engine
38
+
39
+ const driverConfig = SearchManager.config.drivers[key]
40
+ if (!driverConfig) {
41
+ throw new ConfigurationError(`Search driver "${key}" is not configured.`)
42
+ }
43
+
44
+ engine = SearchManager.createEngine(key, driverConfig)
45
+ SearchManager._engines.set(key, engine)
46
+ return engine
47
+ }
48
+
49
+ /** The index name prefix from configuration. */
50
+ static get prefix(): string {
51
+ return SearchManager._config?.prefix ?? ''
52
+ }
53
+
54
+ /** Resolve a full index name by applying the configured prefix. */
55
+ static indexName(name: string): string {
56
+ return SearchManager.prefix ? `${SearchManager.prefix}${name}` : name
57
+ }
58
+
59
+ /** Register a custom driver factory. */
60
+ static extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
61
+ SearchManager._extensions.set(name, factory)
62
+ }
63
+
64
+ /** Replace an engine at runtime (e.g. for testing). */
65
+ static useEngine(engine: SearchEngine): void {
66
+ SearchManager._engines.set(engine.name, engine)
67
+ }
68
+
69
+ /** Reset all state. Intended for test teardown. */
70
+ static reset(): void {
71
+ SearchManager._engines.clear()
72
+ SearchManager._extensions.clear()
73
+ SearchManager._config = undefined as any
74
+ }
75
+
76
+ private static createEngine(name: string, config: DriverConfig): SearchEngine {
77
+ const driverName = config.driver ?? name
78
+
79
+ const extension = SearchManager._extensions.get(driverName)
80
+ if (extension) return extension(config)
81
+
82
+ switch (driverName) {
83
+ case 'meilisearch':
84
+ return new MeilisearchDriver(config)
85
+ case 'typesense':
86
+ return new TypesenseDriver(config)
87
+ case 'algolia':
88
+ return new AlgoliaDriver(config)
89
+ case 'null':
90
+ return new NullDriver()
91
+ default:
92
+ throw new ConfigurationError(
93
+ `Unknown search driver "${driverName}". Register it with SearchManager.extend().`
94
+ )
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,16 @@
1
+ import { ServiceProvider } from '@stravigor/kernel'
2
+ import type { Application } from '@stravigor/kernel'
3
+ import SearchManager from './search_manager.ts'
4
+
5
+ export default class SearchProvider extends ServiceProvider {
6
+ readonly name = 'search'
7
+ override readonly dependencies = ['config']
8
+
9
+ override register(app: Application): void {
10
+ app.singleton(SearchManager)
11
+ }
12
+
13
+ override boot(app: Application): void {
14
+ app.resolve(SearchManager)
15
+ }
16
+ }