@stravigor/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/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@stravigor/search",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Full-text search for the Strav framework",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./*": "./src/*.ts"
10
+ },
11
+ "strav": {
12
+ "commands": "src/commands"
13
+ },
14
+ "files": ["src/", "stubs/", "package.json", "tsconfig.json"],
15
+ "peerDependencies": {
16
+ "@stravigor/core": "0.2.5"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test tests/",
20
+ "typecheck": "tsc --noEmit"
21
+ }
22
+ }
@@ -0,0 +1,41 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '@stravigor/core/cli/bootstrap'
4
+ import BaseModel from '@stravigor/core/orm/base_model'
5
+ import SearchManager from '../search_manager.ts'
6
+
7
+ export function register(program: Command): void {
8
+ program
9
+ .command('search:flush <model>')
10
+ .description("Flush all documents from a model's search index")
11
+ .action(async (modelPath: string) => {
12
+ let db
13
+ try {
14
+ const { db: database, config } = await bootstrap()
15
+ db = database
16
+
17
+ new BaseModel(db)
18
+ new SearchManager(config)
19
+
20
+ const resolved = require.resolve(`${process.cwd()}/${modelPath}`)
21
+ const module = await import(resolved)
22
+ const ModelClass = module.default ?? (Object.values(module)[0] as any)
23
+
24
+ if (typeof ModelClass?.flushIndex !== 'function') {
25
+ console.error(chalk.red(`Model "${modelPath}" does not use the searchable() mixin.`))
26
+ process.exit(1)
27
+ }
28
+
29
+ const indexName = ModelClass.searchableAs()
30
+ console.log(chalk.dim(`Flushing "${indexName}"...`))
31
+
32
+ await ModelClass.flushIndex()
33
+ console.log(chalk.green(`Flushed all documents from "${indexName}".`))
34
+ } catch (err) {
35
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
36
+ process.exit(1)
37
+ } finally {
38
+ if (db) await shutdown(db)
39
+ }
40
+ })
41
+ }
@@ -0,0 +1,43 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '@stravigor/core/cli/bootstrap'
4
+ import BaseModel from '@stravigor/core/orm/base_model'
5
+ import SearchManager from '../search_manager.ts'
6
+
7
+ export function register(program: Command): void {
8
+ program
9
+ .command('search:import <model>')
10
+ .description('Import all records for a model into the search index')
11
+ .option('--chunk <size>', 'Records per batch', '500')
12
+ .action(async (modelPath: string, options) => {
13
+ let db
14
+ try {
15
+ const { db: database, config } = await bootstrap()
16
+ db = database
17
+
18
+ new BaseModel(db)
19
+ new SearchManager(config)
20
+
21
+ const resolved = require.resolve(`${process.cwd()}/${modelPath}`)
22
+ const module = await import(resolved)
23
+ const ModelClass = module.default ?? (Object.values(module)[0] as any)
24
+
25
+ if (typeof ModelClass?.importAll !== 'function') {
26
+ console.error(chalk.red(`Model "${modelPath}" does not use the searchable() mixin.`))
27
+ process.exit(1)
28
+ }
29
+
30
+ const chunkSize = parseInt(options.chunk, 10)
31
+ const indexName = ModelClass.searchableAs()
32
+ console.log(chalk.dim(`Importing ${ModelClass.name} into "${indexName}"...`))
33
+
34
+ const count = await ModelClass.importAll(chunkSize)
35
+ console.log(chalk.green(`Imported ${count} record(s) into "${indexName}".`))
36
+ } catch (err) {
37
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
38
+ process.exit(1)
39
+ } finally {
40
+ if (db) await shutdown(db)
41
+ }
42
+ })
43
+ }
@@ -0,0 +1,170 @@
1
+ import { ExternalServiceError } from '@stravigor/core/exceptions/errors'
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
+ * Algolia driver — communicates with the Algolia REST API via raw `fetch()`.
14
+ *
15
+ * @see https://www.algolia.com/doc/rest-api/search/
16
+ */
17
+ export class AlgoliaDriver implements SearchEngine {
18
+ readonly name = 'algolia'
19
+ private appId: string
20
+ private apiKey: string
21
+ private baseUrl: string
22
+
23
+ constructor(config: DriverConfig) {
24
+ this.appId = (config.appId as string) ?? ''
25
+ this.apiKey = (config.apiKey as string) ?? ''
26
+ this.baseUrl = `https://${this.appId}.algolia.net`
27
+ }
28
+
29
+ // ── Interface ────────────────────────────────────────────────────────────
30
+
31
+ async upsert(
32
+ index: string,
33
+ id: string | number,
34
+ document: Record<string, unknown>
35
+ ): Promise<void> {
36
+ await this.request(
37
+ 'PUT',
38
+ `/1/indexes/${encodeURIComponent(index)}/${encodeURIComponent(String(id))}`,
39
+ document
40
+ )
41
+ }
42
+
43
+ async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
44
+ const requests = documents.map(doc => ({
45
+ action: 'updateObject',
46
+ body: { objectID: String(doc.id), ...doc },
47
+ }))
48
+ await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/batch`, { requests })
49
+ }
50
+
51
+ async delete(index: string, id: string | number): Promise<void> {
52
+ await this.request(
53
+ 'DELETE',
54
+ `/1/indexes/${encodeURIComponent(index)}/${encodeURIComponent(String(id))}`
55
+ )
56
+ }
57
+
58
+ async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
59
+ const requests = ids.map(id => ({
60
+ action: 'deleteObject',
61
+ body: { objectID: String(id) },
62
+ }))
63
+ await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/batch`, { requests })
64
+ }
65
+
66
+ async flush(index: string): Promise<void> {
67
+ await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/clear`)
68
+ }
69
+
70
+ async deleteIndex(index: string): Promise<void> {
71
+ await this.request('DELETE', `/1/indexes/${encodeURIComponent(index)}`)
72
+ }
73
+
74
+ async createIndex(index: string, options?: IndexSettings): Promise<void> {
75
+ // Algolia creates indexes implicitly on first write.
76
+ // If settings are provided, configure them.
77
+ if (options) {
78
+ const settings: Record<string, unknown> = {}
79
+ if (options.searchableAttributes) settings.searchableAttributes = options.searchableAttributes
80
+ if (options.displayedAttributes) settings.attributesToRetrieve = options.displayedAttributes
81
+ if (options.filterableAttributes) {
82
+ settings.attributesForFaceting = options.filterableAttributes.map(
83
+ attr => `filterOnly(${attr})`
84
+ )
85
+ }
86
+ if (options.sortableAttributes) settings.ranking = options.sortableAttributes
87
+
88
+ if (Object.keys(settings).length > 0) {
89
+ await this.request('PUT', `/1/indexes/${encodeURIComponent(index)}/settings`, settings)
90
+ }
91
+ }
92
+ }
93
+
94
+ async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
95
+ const perPage = options?.perPage ?? 20
96
+ const page = options?.page ?? 1
97
+
98
+ const body: Record<string, unknown> = {
99
+ query,
100
+ hitsPerPage: perPage,
101
+ page: page - 1, // Algolia uses 0-based pages
102
+ }
103
+
104
+ if (options?.filter) {
105
+ body.filters =
106
+ typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
107
+ }
108
+ if (options?.attributesToRetrieve) body.attributesToRetrieve = options.attributesToRetrieve
109
+ if (options?.attributesToHighlight) body.attributesToHighlight = options.attributesToHighlight
110
+
111
+ const data = await this.request('POST', `/1/indexes/${encodeURIComponent(index)}/query`, body)
112
+
113
+ return {
114
+ hits: (data.hits ?? []).map(
115
+ (hit: any): SearchHit => ({
116
+ document: hit,
117
+ highlights: hit._highlightResult
118
+ ? Object.fromEntries(
119
+ Object.entries(hit._highlightResult).map(([key, val]: [string, any]) => [
120
+ key,
121
+ val.value ?? '',
122
+ ])
123
+ )
124
+ : undefined,
125
+ })
126
+ ),
127
+ totalHits: data.nbHits ?? 0,
128
+ page,
129
+ perPage,
130
+ processingTimeMs: data.processingTimeMS,
131
+ }
132
+ }
133
+
134
+ // ── Private ──────────────────────────────────────────────────────────────
135
+
136
+ private headers(): Record<string, string> {
137
+ return {
138
+ 'content-type': 'application/json',
139
+ 'x-algolia-application-id': this.appId,
140
+ 'x-algolia-api-key': this.apiKey,
141
+ }
142
+ }
143
+
144
+ private async request(method: string, path: string, body?: unknown): Promise<any> {
145
+ const response = await fetch(`${this.baseUrl}${path}`, {
146
+ method,
147
+ headers: this.headers(),
148
+ body: body !== undefined ? JSON.stringify(body) : undefined,
149
+ })
150
+
151
+ if (!response.ok) {
152
+ const text = await response.text()
153
+ throw new ExternalServiceError('Algolia', response.status, text)
154
+ }
155
+
156
+ if (response.status === 204 || response.headers.get('content-length') === '0') return null
157
+ return response.json()
158
+ }
159
+
160
+ private buildFilter(filter: Record<string, unknown>): string {
161
+ return Object.entries(filter)
162
+ .map(([key, value]) => {
163
+ if (Array.isArray(value)) {
164
+ return value.map(v => `${key}:${JSON.stringify(v)}`).join(' OR ')
165
+ }
166
+ return `${key}:${JSON.stringify(value)}`
167
+ })
168
+ .join(' AND ')
169
+ }
170
+ }
@@ -0,0 +1,150 @@
1
+ import { ExternalServiceError } from '@stravigor/core/exceptions/errors'
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
+ * Meilisearch driver — communicates with the Meilisearch REST API via raw `fetch()`.
14
+ *
15
+ * @see https://www.meilisearch.com/docs/reference/api/overview
16
+ */
17
+ export class MeilisearchDriver implements SearchEngine {
18
+ readonly name = 'meilisearch'
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 ?? 7700
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('POST', `/indexes/${encodeURIComponent(index)}/documents`, [
38
+ { id, ...document },
39
+ ])
40
+ }
41
+
42
+ async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
43
+ await this.request('POST', `/indexes/${encodeURIComponent(index)}/documents`, documents)
44
+ }
45
+
46
+ async delete(index: string, id: string | number): Promise<void> {
47
+ await this.request(
48
+ 'DELETE',
49
+ `/indexes/${encodeURIComponent(index)}/documents/${encodeURIComponent(String(id))}`
50
+ )
51
+ }
52
+
53
+ async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
54
+ await this.request('POST', `/indexes/${encodeURIComponent(index)}/documents/delete-batch`, ids)
55
+ }
56
+
57
+ async flush(index: string): Promise<void> {
58
+ await this.request('DELETE', `/indexes/${encodeURIComponent(index)}/documents`)
59
+ }
60
+
61
+ async deleteIndex(index: string): Promise<void> {
62
+ await this.request('DELETE', `/indexes/${encodeURIComponent(index)}`)
63
+ }
64
+
65
+ async createIndex(index: string, options?: IndexSettings): Promise<void> {
66
+ await this.request('POST', '/indexes', {
67
+ uid: index,
68
+ primaryKey: options?.primaryKey ?? 'id',
69
+ })
70
+
71
+ if (options) {
72
+ const settings: Record<string, unknown> = {}
73
+ if (options.searchableAttributes) settings.searchableAttributes = options.searchableAttributes
74
+ if (options.displayedAttributes) settings.displayedAttributes = options.displayedAttributes
75
+ if (options.filterableAttributes) settings.filterableAttributes = options.filterableAttributes
76
+ if (options.sortableAttributes) settings.sortableAttributes = options.sortableAttributes
77
+
78
+ if (Object.keys(settings).length > 0) {
79
+ await this.request('PATCH', `/indexes/${encodeURIComponent(index)}/settings`, settings)
80
+ }
81
+ }
82
+ }
83
+
84
+ async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
85
+ const perPage = options?.perPage ?? 20
86
+ const page = options?.page ?? 1
87
+
88
+ const body: Record<string, unknown> = { q: query, limit: perPage, offset: (page - 1) * perPage }
89
+
90
+ if (options?.filter) {
91
+ body.filter =
92
+ typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
93
+ }
94
+ if (options?.sort) body.sort = options.sort
95
+ if (options?.attributesToRetrieve) body.attributesToRetrieve = options.attributesToRetrieve
96
+ if (options?.attributesToHighlight) {
97
+ body.attributesToHighlight = options.attributesToHighlight
98
+ }
99
+
100
+ const data = await this.request('POST', `/indexes/${encodeURIComponent(index)}/search`, body)
101
+
102
+ return {
103
+ hits: (data.hits ?? []).map(
104
+ (hit: any): SearchHit => ({
105
+ document: hit,
106
+ highlights: hit._formatted,
107
+ })
108
+ ),
109
+ totalHits: data.estimatedTotalHits ?? data.totalHits ?? 0,
110
+ page,
111
+ perPage,
112
+ processingTimeMs: data.processingTimeMs,
113
+ }
114
+ }
115
+
116
+ // ── Private ──────────────────────────────────────────────────────────────
117
+
118
+ private headers(): Record<string, string> {
119
+ const h: Record<string, string> = { 'content-type': 'application/json' }
120
+ if (this.apiKey) h['authorization'] = `Bearer ${this.apiKey}`
121
+ return h
122
+ }
123
+
124
+ private async request(method: string, path: string, body?: unknown): Promise<any> {
125
+ const response = await fetch(`${this.baseUrl}${path}`, {
126
+ method,
127
+ headers: this.headers(),
128
+ body: body !== undefined ? JSON.stringify(body) : undefined,
129
+ })
130
+
131
+ if (!response.ok) {
132
+ const text = await response.text()
133
+ throw new ExternalServiceError('Meilisearch', response.status, text)
134
+ }
135
+
136
+ if (response.status === 204 || response.headers.get('content-length') === '0') return null
137
+ return response.json()
138
+ }
139
+
140
+ private buildFilter(filter: Record<string, unknown>): string {
141
+ return Object.entries(filter)
142
+ .map(([key, value]) => {
143
+ if (Array.isArray(value)) {
144
+ return `${key} IN [${value.map(v => JSON.stringify(v)).join(', ')}]`
145
+ }
146
+ return `${key} = ${JSON.stringify(value)}`
147
+ })
148
+ .join(' AND ')
149
+ }
150
+ }
@@ -0,0 +1,27 @@
1
+ import type { SearchEngine } from '../search_engine.ts'
2
+ import type { SearchDocument, SearchOptions, SearchResult, IndexSettings } from '../types.ts'
3
+
4
+ /**
5
+ * No-op search driver — silently discards all writes and returns empty results.
6
+ *
7
+ * Useful when search is disabled or during testing.
8
+ */
9
+ export class NullDriver implements SearchEngine {
10
+ readonly name = 'null'
11
+
12
+ async upsert(
13
+ _index: string,
14
+ _id: string | number,
15
+ _document: Record<string, unknown>
16
+ ): Promise<void> {}
17
+ async upsertMany(_index: string, _documents: SearchDocument[]): Promise<void> {}
18
+ async delete(_index: string, _id: string | number): Promise<void> {}
19
+ async deleteMany(_index: string, _ids: Array<string | number>): Promise<void> {}
20
+ async flush(_index: string): Promise<void> {}
21
+ async deleteIndex(_index: string): Promise<void> {}
22
+ async createIndex(_index: string, _options?: IndexSettings): Promise<void> {}
23
+
24
+ async search(_index: string, _query: string, options?: SearchOptions): Promise<SearchResult> {
25
+ return { hits: [], totalHits: 0, page: options?.page ?? 1, perPage: options?.perPage ?? 20 }
26
+ }
27
+ }
@@ -0,0 +1,227 @@
1
+ import { ExternalServiceError } from '@stravigor/core/exceptions/errors'
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
+ try {
73
+ const schema = await this.request('GET', `/collections/${encodeURIComponent(index)}`)
74
+ await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
75
+ await this.request('POST', '/collections', {
76
+ name: schema.name,
77
+ fields: schema.fields,
78
+ })
79
+ } catch {
80
+ // If the collection doesn't exist, that's fine
81
+ }
82
+ }
83
+
84
+ async deleteIndex(index: string): Promise<void> {
85
+ await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
86
+ }
87
+
88
+ async createIndex(index: string, options?: IndexSettings): Promise<void> {
89
+ const fields: Record<string, unknown>[] = []
90
+
91
+ if (options?.searchableAttributes) {
92
+ for (const attr of options.searchableAttributes) {
93
+ fields.push({ name: attr, type: 'string', facet: false })
94
+ }
95
+ }
96
+ if (options?.filterableAttributes) {
97
+ for (const attr of options.filterableAttributes) {
98
+ if (!fields.some(f => f.name === attr)) {
99
+ fields.push({ name: attr, type: 'string', facet: true })
100
+ }
101
+ }
102
+ }
103
+ if (options?.sortableAttributes) {
104
+ for (const attr of options.sortableAttributes) {
105
+ if (!fields.some(f => f.name === attr)) {
106
+ fields.push({ name: attr, type: 'string', sort: true })
107
+ }
108
+ }
109
+ }
110
+
111
+ // Always include a wildcard field so untyped fields are auto-detected
112
+ if (fields.length === 0) {
113
+ fields.push({ name: '.*', type: 'auto' })
114
+ }
115
+
116
+ await this.request('POST', '/collections', {
117
+ name: index,
118
+ fields,
119
+ })
120
+ }
121
+
122
+ async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
123
+ const perPage = options?.perPage ?? 20
124
+ const page = options?.page ?? 1
125
+
126
+ const params = new URLSearchParams({
127
+ q: query,
128
+ query_by: '*',
129
+ per_page: String(perPage),
130
+ page: String(page),
131
+ })
132
+
133
+ if (options?.filter) {
134
+ params.set(
135
+ 'filter_by',
136
+ typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
137
+ )
138
+ }
139
+ if (options?.sort) {
140
+ params.set('sort_by', options.sort.map(s => s.replace(':', ':')).join(','))
141
+ }
142
+ if (options?.attributesToRetrieve) {
143
+ params.set('include_fields', options.attributesToRetrieve.join(','))
144
+ }
145
+ if (options?.attributesToHighlight) {
146
+ params.set('highlight_fields', options.attributesToHighlight.join(','))
147
+ }
148
+
149
+ const data = await this.request(
150
+ 'GET',
151
+ `/collections/${encodeURIComponent(index)}/documents/search?${params.toString()}`
152
+ )
153
+
154
+ return {
155
+ hits: (data.hits ?? []).map(
156
+ (hit: any): SearchHit => ({
157
+ document: hit.document,
158
+ highlights: hit.highlights?.reduce(
159
+ (acc: Record<string, string>, h: any) => {
160
+ if (h.field && h.snippet) acc[h.field] = h.snippet
161
+ return acc
162
+ },
163
+ {} as Record<string, string>
164
+ ),
165
+ })
166
+ ),
167
+ totalHits: data.found ?? 0,
168
+ page,
169
+ perPage,
170
+ processingTimeMs: data.search_time_ms,
171
+ }
172
+ }
173
+
174
+ // ── Private ──────────────────────────────────────────────────────────────
175
+
176
+ private headers(): Record<string, string> {
177
+ return {
178
+ 'content-type': 'application/json',
179
+ 'x-typesense-api-key': this.apiKey,
180
+ }
181
+ }
182
+
183
+ private async request(method: string, path: string, body?: unknown): Promise<any> {
184
+ const response = await fetch(`${this.baseUrl}${path}`, {
185
+ method,
186
+ headers: this.headers(),
187
+ body: body !== undefined ? JSON.stringify(body) : undefined,
188
+ })
189
+
190
+ if (!response.ok) {
191
+ const text = await response.text()
192
+ throw new ExternalServiceError('Typesense', response.status, text)
193
+ }
194
+
195
+ if (response.status === 204 || response.headers.get('content-length') === '0') return null
196
+ return response.json()
197
+ }
198
+
199
+ private async rawRequest(
200
+ method: string,
201
+ path: string,
202
+ body: string,
203
+ contentType: string
204
+ ): Promise<void> {
205
+ const response = await fetch(`${this.baseUrl}${path}`, {
206
+ method,
207
+ headers: { 'content-type': contentType, 'x-typesense-api-key': this.apiKey },
208
+ body,
209
+ })
210
+
211
+ if (!response.ok) {
212
+ const text = await response.text()
213
+ throw new ExternalServiceError('Typesense', response.status, text)
214
+ }
215
+ }
216
+
217
+ private buildFilter(filter: Record<string, unknown>): string {
218
+ return Object.entries(filter)
219
+ .map(([key, value]) => {
220
+ if (Array.isArray(value)) {
221
+ return `${key}:[${value.map(v => String(v)).join(',')}]`
222
+ }
223
+ return `${key}:=${value}`
224
+ })
225
+ .join(' && ')
226
+ }
227
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { StravError } from '@stravigor/core/exceptions/strav_error'
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,32 @@
1
+ // Manager
2
+ export { default, default as SearchManager } from './search_manager.ts'
3
+
4
+ // Engine interface
5
+ export type { SearchEngine } from './search_engine.ts'
6
+
7
+ // Drivers
8
+ export { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
9
+ export { TypesenseDriver } from './drivers/typesense_driver.ts'
10
+ export { AlgoliaDriver } from './drivers/algolia_driver.ts'
11
+ export { NullDriver } from './drivers/null_driver.ts'
12
+
13
+ // Mixin
14
+ export { searchable } from './searchable.ts'
15
+ export type { SearchableInstance, SearchableModel } from './searchable.ts'
16
+
17
+ // Helper
18
+ export { search } from './helpers.ts'
19
+
20
+ // Errors
21
+ export { SearchError, IndexNotFoundError, SearchQueryError } from './errors.ts'
22
+
23
+ // Types
24
+ export type {
25
+ SearchConfig,
26
+ DriverConfig,
27
+ SearchDocument,
28
+ SearchOptions,
29
+ SearchResult,
30
+ SearchHit,
31
+ IndexSettings,
32
+ } 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,99 @@
1
+ import { inject } from '@stravigor/core/core'
2
+ import type Configuration from '@stravigor/core/config/configuration'
3
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
4
+ import type { SearchEngine } from './search_engine.ts'
5
+ import type { SearchConfig, DriverConfig } from './types.ts'
6
+ import { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
7
+ import { TypesenseDriver } from './drivers/typesense_driver.ts'
8
+ import { AlgoliaDriver } from './drivers/algolia_driver.ts'
9
+ import { NullDriver } from './drivers/null_driver.ts'
10
+
11
+ @inject
12
+ export default class SearchManager {
13
+ private static _config: SearchConfig
14
+ private static _engines = new Map<string, SearchEngine>()
15
+ private static _extensions = new Map<string, (config: DriverConfig) => SearchEngine>()
16
+
17
+ constructor(config: Configuration) {
18
+ SearchManager._config = {
19
+ default: config.get('search.default', 'null') as string,
20
+ prefix: config.get('search.prefix', '') as string,
21
+ drivers: config.get('search.drivers', {}) as Record<string, DriverConfig>,
22
+ }
23
+ }
24
+
25
+ static get config(): SearchConfig {
26
+ if (!SearchManager._config) {
27
+ throw new ConfigurationError(
28
+ 'SearchManager not configured. Resolve it through the container first.'
29
+ )
30
+ }
31
+ return SearchManager._config
32
+ }
33
+
34
+ /** Get an engine by name, or the default engine. Engines are lazily created. */
35
+ static engine(name?: string): SearchEngine {
36
+ const key = name ?? SearchManager.config.default
37
+
38
+ let engine = SearchManager._engines.get(key)
39
+ if (engine) return engine
40
+
41
+ const driverConfig = SearchManager.config.drivers[key]
42
+ if (!driverConfig) {
43
+ throw new ConfigurationError(`Search driver "${key}" is not configured.`)
44
+ }
45
+
46
+ engine = SearchManager.createEngine(key, driverConfig)
47
+ SearchManager._engines.set(key, engine)
48
+ return engine
49
+ }
50
+
51
+ /** The index name prefix from configuration. */
52
+ static get prefix(): string {
53
+ return SearchManager._config?.prefix ?? ''
54
+ }
55
+
56
+ /** Resolve a full index name by applying the configured prefix. */
57
+ static indexName(name: string): string {
58
+ return SearchManager.prefix ? `${SearchManager.prefix}${name}` : name
59
+ }
60
+
61
+ /** Register a custom driver factory. */
62
+ static extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
63
+ SearchManager._extensions.set(name, factory)
64
+ }
65
+
66
+ /** Replace an engine at runtime (e.g. for testing). */
67
+ static useEngine(engine: SearchEngine): void {
68
+ SearchManager._engines.set(engine.name, engine)
69
+ }
70
+
71
+ /** Reset all state. Intended for test teardown. */
72
+ static reset(): void {
73
+ SearchManager._engines.clear()
74
+ SearchManager._extensions.clear()
75
+ SearchManager._config = undefined as any
76
+ }
77
+
78
+ private static createEngine(name: string, config: DriverConfig): SearchEngine {
79
+ const driverName = config.driver ?? name
80
+
81
+ const extension = SearchManager._extensions.get(driverName)
82
+ if (extension) return extension(config)
83
+
84
+ switch (driverName) {
85
+ case 'meilisearch':
86
+ return new MeilisearchDriver(config)
87
+ case 'typesense':
88
+ return new TypesenseDriver(config)
89
+ case 'algolia':
90
+ return new AlgoliaDriver(config)
91
+ case 'null':
92
+ return new NullDriver()
93
+ default:
94
+ throw new ConfigurationError(
95
+ `Unknown search driver "${driverName}". Register it with SearchManager.extend().`
96
+ )
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,203 @@
1
+ import type BaseModel from '@stravigor/core/orm/base_model'
2
+ import type { NormalizeConstructor } from '@stravigor/core/helpers'
3
+ import Emitter from '@stravigor/core/events/emitter'
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/core/orm'
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/core/helpers'
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
+ await (model as any).searchIndex()
182
+ }
183
+ }
184
+
185
+ const removeFn = async (model: unknown) => {
186
+ if (model && typeof (model as any).searchRemove === 'function') {
187
+ await (model as any).searchRemove()
188
+ }
189
+ }
190
+
191
+ Emitter.on(`${eventPrefix}.created`, indexFn)
192
+ Emitter.on(`${eventPrefix}.updated`, indexFn)
193
+ Emitter.on(`${eventPrefix}.synced`, indexFn)
194
+ Emitter.on(`${eventPrefix}.deleted`, removeFn)
195
+ }
196
+ }
197
+ }
198
+
199
+ /** The instance type of any searchable model. */
200
+ export type SearchableInstance = InstanceType<ReturnType<typeof searchable>>
201
+
202
+ /** The static type of any searchable model class. */
203
+ 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/core/helpers'
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,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }