@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 +22 -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 +227 -0
- package/src/errors.ts +18 -0
- package/src/helpers.ts +70 -0
- package/src/index.ts +32 -0
- package/src/search_engine.ts +36 -0
- package/src/search_manager.ts +99 -0
- package/src/searchable.ts +203 -0
- package/src/types.ts +81 -0
- package/stubs/config/search.ts +32 -0
- package/tsconfig.json +4 -0
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