@mantiq/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 +60 -0
- package/src/SearchBuilder.ts +149 -0
- package/src/SearchManager.ts +95 -0
- package/src/SearchObserver.ts +62 -0
- package/src/SearchServiceProvider.ts +59 -0
- package/src/Searchable.ts +89 -0
- package/src/commands/SearchDeleteIndexCommand.ts +22 -0
- package/src/commands/SearchFlushCommand.ts +44 -0
- package/src/commands/SearchImportCommand.ts +45 -0
- package/src/commands/SearchIndexCommand.ts +22 -0
- package/src/contracts/SearchConfig.ts +30 -0
- package/src/contracts/SearchEngine.ts +33 -0
- package/src/drivers/AlgoliaEngine.ts +145 -0
- package/src/drivers/CollectionEngine.ts +176 -0
- package/src/drivers/DatabaseEngine.ts +139 -0
- package/src/drivers/ElasticsearchEngine.ts +188 -0
- package/src/drivers/MeilisearchEngine.ts +136 -0
- package/src/drivers/TypesenseEngine.ts +181 -0
- package/src/errors/SearchError.ts +9 -0
- package/src/helpers/search.ts +26 -0
- package/src/index.ts +39 -0
- package/src/jobs/MakeSearchableJob.ts +22 -0
- package/src/testing/SearchFake.ts +136 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Full-text search abstraction — Algolia, Meilisearch, Typesense, Elasticsearch, database, and collection drivers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/mantiqjs/mantiq/tree/main/packages/search",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/mantiqjs/mantiq.git",
|
|
12
|
+
"directory": "packages/search"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/mantiqjs/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"search",
|
|
21
|
+
"algolia",
|
|
22
|
+
"meilisearch",
|
|
23
|
+
"typesense",
|
|
24
|
+
"elasticsearch",
|
|
25
|
+
"full-text-search"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"bun": ">=1.1.0"
|
|
29
|
+
},
|
|
30
|
+
"main": "./src/index.ts",
|
|
31
|
+
"types": "./src/index.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"bun": "./src/index.ts",
|
|
35
|
+
"default": "./src/index.ts"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src/",
|
|
40
|
+
"package.json",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun --packages=external",
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"clean": "rm -rf dist"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@mantiq/core": "^0.1.0",
|
|
52
|
+
"@mantiq/database": "^0.1.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"bun-types": "latest",
|
|
56
|
+
"typescript": "^5.7.0",
|
|
57
|
+
"@mantiq/core": "workspace:*",
|
|
58
|
+
"@mantiq/database": "workspace:*"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { SearchEngine, SearchResult } from './contracts/SearchEngine.ts'
|
|
2
|
+
|
|
3
|
+
export interface WhereClause {
|
|
4
|
+
field: string
|
|
5
|
+
value: any
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WhereInClause {
|
|
9
|
+
field: string
|
|
10
|
+
values: any[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OrderClause {
|
|
14
|
+
column: string
|
|
15
|
+
direction: 'asc' | 'desc'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaginatedSearchResult<T = any> {
|
|
19
|
+
data: T[]
|
|
20
|
+
total: number
|
|
21
|
+
perPage: number
|
|
22
|
+
currentPage: number
|
|
23
|
+
lastPage: number
|
|
24
|
+
hasMorePages: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SearchBuilder {
|
|
28
|
+
readonly model: any
|
|
29
|
+
readonly query: string
|
|
30
|
+
readonly wheres: WhereClause[] = []
|
|
31
|
+
readonly whereIns: WhereInClause[] = []
|
|
32
|
+
readonly orders: OrderClause[] = []
|
|
33
|
+
|
|
34
|
+
private _limit: number | null = null
|
|
35
|
+
private _offset: number | null = null
|
|
36
|
+
private _callback: ((engine: SearchEngine, query: string, builder: SearchBuilder) => any) | null = null
|
|
37
|
+
private _engine: SearchEngine
|
|
38
|
+
|
|
39
|
+
constructor(model: any, query: string, engine: SearchEngine, callback?: (engine: SearchEngine, query: string, builder: SearchBuilder) => any) {
|
|
40
|
+
this.model = model
|
|
41
|
+
this.query = query
|
|
42
|
+
this._engine = engine
|
|
43
|
+
this._callback = callback ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
where(field: string, value: any): this {
|
|
47
|
+
this.wheres.push({ field, value })
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
whereIn(field: string, values: any[]): this {
|
|
52
|
+
this.whereIns.push({ field, values })
|
|
53
|
+
return this
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
orderBy(column: string, direction: 'asc' | 'desc' = 'asc'): this {
|
|
57
|
+
this.orders.push({ column, direction })
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
take(count: number): this {
|
|
62
|
+
this._limit = count
|
|
63
|
+
return this
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
limit(count: number): this {
|
|
67
|
+
return this.take(count)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
skip(count: number): this {
|
|
71
|
+
this._offset = count
|
|
72
|
+
return this
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
offset(count: number): this {
|
|
76
|
+
return this.skip(count)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getLimit(): number | null {
|
|
80
|
+
return this._limit
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getOffset(): number | null {
|
|
84
|
+
return this._offset
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Execute the search and return hydrated models. */
|
|
88
|
+
async get(): Promise<any[]> {
|
|
89
|
+
const results = await this.raw()
|
|
90
|
+
if (results.keys.length === 0) return []
|
|
91
|
+
return this.hydrateModels(results)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Execute a paginated search and return hydrated models with pagination info. */
|
|
95
|
+
async paginate(perPage = 15, page = 1): Promise<PaginatedSearchResult> {
|
|
96
|
+
const results = await this._engine.paginate(this, perPage, page)
|
|
97
|
+
const data = results.keys.length > 0 ? await this.hydrateModels(results) : []
|
|
98
|
+
const lastPage = Math.max(1, Math.ceil(results.total / perPage))
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
data,
|
|
102
|
+
total: results.total,
|
|
103
|
+
perPage,
|
|
104
|
+
currentPage: page,
|
|
105
|
+
lastPage,
|
|
106
|
+
hasMorePages: page < lastPage,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Get raw engine results without hydrating models. */
|
|
111
|
+
async raw(): Promise<SearchResult> {
|
|
112
|
+
if (this._callback) {
|
|
113
|
+
return this._callback(this._engine, this.query, this)
|
|
114
|
+
}
|
|
115
|
+
return this._engine.search(this)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get just the matching model keys/IDs. */
|
|
119
|
+
async keys(): Promise<(string | number)[]> {
|
|
120
|
+
const results = await this.raw()
|
|
121
|
+
return results.keys
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Get the total count of matching records. */
|
|
125
|
+
async count(): Promise<number> {
|
|
126
|
+
const results = await this.raw()
|
|
127
|
+
return results.total
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Hydrate model instances from the database using the search result keys. */
|
|
131
|
+
private async hydrateModels(results: SearchResult): Promise<any[]> {
|
|
132
|
+
if (results.keys.length === 0) return []
|
|
133
|
+
|
|
134
|
+
const ModelClass = this.model
|
|
135
|
+
const pk = ModelClass.primaryKey ?? 'id'
|
|
136
|
+
|
|
137
|
+
const models = await ModelClass.query().whereIn(pk, results.keys).get()
|
|
138
|
+
|
|
139
|
+
// Preserve search result ordering
|
|
140
|
+
const modelMap = new Map<string | number, any>()
|
|
141
|
+
for (const m of models) {
|
|
142
|
+
modelMap.set(m.getAttribute(pk), m)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return results.keys
|
|
146
|
+
.map((key) => modelMap.get(key))
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { SearchEngine } from './contracts/SearchEngine.ts'
|
|
2
|
+
import type { SearchConfig, EngineConfig } from './contracts/SearchConfig.ts'
|
|
3
|
+
import { SearchError } from './errors/SearchError.ts'
|
|
4
|
+
|
|
5
|
+
type EngineFactory = (config: EngineConfig) => SearchEngine
|
|
6
|
+
|
|
7
|
+
export class SearchManager {
|
|
8
|
+
private readonly engines = new Map<string, SearchEngine>()
|
|
9
|
+
private readonly customEngines = new Map<string, EngineFactory>()
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly config: SearchConfig,
|
|
13
|
+
private readonly builtInEngines: Map<string, EngineFactory> = new Map(),
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** Get an engine instance by name, lazy-loaded and cached. */
|
|
17
|
+
driver(name?: string): SearchEngine {
|
|
18
|
+
const engineName = name ?? this.config.default
|
|
19
|
+
if (this.engines.has(engineName)) return this.engines.get(engineName)!
|
|
20
|
+
|
|
21
|
+
const engineConfig = this.config.engines[engineName]
|
|
22
|
+
if (!engineConfig) {
|
|
23
|
+
throw new SearchError(`Search engine "${engineName}" is not configured`, { engine: engineName })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const engine = this.createEngine(engineConfig)
|
|
27
|
+
this.engines.set(engineName, engine)
|
|
28
|
+
return engine
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Register a custom engine driver. */
|
|
32
|
+
extend(name: string, factory: EngineFactory): void {
|
|
33
|
+
this.customEngines.set(name, factory)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Get the default driver name. */
|
|
37
|
+
getDefaultDriver(): string {
|
|
38
|
+
return this.config.default
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Get the search config. */
|
|
42
|
+
getConfig(): SearchConfig {
|
|
43
|
+
return this.config
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get the index prefix. */
|
|
47
|
+
getPrefix(): string {
|
|
48
|
+
return this.config.prefix
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Forget a cached engine instance. */
|
|
52
|
+
forget(name: string): void {
|
|
53
|
+
this.engines.delete(name)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Delegation to default engine ──────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async update(models: any[]): Promise<void> {
|
|
59
|
+
return this.driver().update(models)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(models: any[]): Promise<void> {
|
|
63
|
+
return this.driver().delete(models)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async flush(indexName: string): Promise<void> {
|
|
67
|
+
return this.driver().flush(indexName)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async createIndex(name: string, options?: Record<string, any>): Promise<void> {
|
|
71
|
+
return this.driver().createIndex(name, options)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async deleteIndex(name: string): Promise<void> {
|
|
75
|
+
return this.driver().deleteIndex(name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Private ───────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
private createEngine(config: EngineConfig): SearchEngine {
|
|
81
|
+
const driverName = config.driver
|
|
82
|
+
|
|
83
|
+
// Custom engines first
|
|
84
|
+
if (this.customEngines.has(driverName)) {
|
|
85
|
+
return this.customEngines.get(driverName)!(config)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Built-in engines
|
|
89
|
+
if (this.builtInEngines.has(driverName)) {
|
|
90
|
+
return this.builtInEngines.get(driverName)!(config)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new SearchError(`Unsupported search driver "${driverName}"`, { driver: driverName })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getSearchManager } from './helpers/search.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model observer that automatically syncs models with the search index.
|
|
5
|
+
* Registered by makeSearchable() on each searchable model class.
|
|
6
|
+
*/
|
|
7
|
+
export class SearchObserver {
|
|
8
|
+
async saved(model: any): Promise<void> {
|
|
9
|
+
if (typeof model.shouldBeSearchable === 'function' && !model.shouldBeSearchable()) {
|
|
10
|
+
await this.tryDelete(model)
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
await this.tryUpdate(model)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async deleted(model: any): Promise<void> {
|
|
17
|
+
await this.tryDelete(model)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async forceDeleted(model: any): Promise<void> {
|
|
21
|
+
await this.tryDelete(model)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async restored(model: any): Promise<void> {
|
|
25
|
+
await this.tryUpdate(model)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async tryUpdate(model: any): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
const manager = getSearchManager()
|
|
31
|
+
const config = manager.getConfig()
|
|
32
|
+
|
|
33
|
+
if (config.queue) {
|
|
34
|
+
// Dispatch to queue if configured
|
|
35
|
+
try {
|
|
36
|
+
const { dispatch } = await import('@mantiq/queue')
|
|
37
|
+
const { MakeSearchableJob } = await import('./jobs/MakeSearchableJob.ts')
|
|
38
|
+
const job = new MakeSearchableJob([model], 'update')
|
|
39
|
+
const queueName = typeof config.queue === 'string' ? config.queue : undefined
|
|
40
|
+
const pending = dispatch(job)
|
|
41
|
+
if (queueName) pending.onQueue(queueName)
|
|
42
|
+
} catch {
|
|
43
|
+
// Queue not available, fall back to sync
|
|
44
|
+
await manager.driver().update([model])
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
await manager.driver().update([model])
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Silently fail — search indexing should not break model operations
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async tryDelete(model: any): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
const manager = getSearchManager()
|
|
57
|
+
await manager.driver().delete([model])
|
|
58
|
+
} catch {
|
|
59
|
+
// Silently fail
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SearchManager } from './SearchManager.ts'
|
|
2
|
+
import { setSearchManager, SEARCH_MANAGER } from './helpers/search.ts'
|
|
3
|
+
import type { SearchConfig } from './contracts/SearchConfig.ts'
|
|
4
|
+
import { DEFAULT_CONFIG } from './contracts/SearchConfig.ts'
|
|
5
|
+
import type { SearchEngine } from './contracts/SearchEngine.ts'
|
|
6
|
+
|
|
7
|
+
export class SearchServiceProvider {
|
|
8
|
+
constructor(private readonly app: any) {}
|
|
9
|
+
|
|
10
|
+
register(): void {
|
|
11
|
+
this.app.singleton(SearchManager, () => {
|
|
12
|
+
const configRepo = this.app.make?.('ConfigRepository') ?? this.app.config?.()
|
|
13
|
+
const config: SearchConfig = configRepo?.get?.('search') ?? DEFAULT_CONFIG
|
|
14
|
+
|
|
15
|
+
const builtInEngines = new Map<string, (cfg: any) => SearchEngine>()
|
|
16
|
+
|
|
17
|
+
builtInEngines.set('collection', () => {
|
|
18
|
+
const { CollectionEngine } = require('./drivers/CollectionEngine.ts')
|
|
19
|
+
return new CollectionEngine()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
builtInEngines.set('database', (cfg) => {
|
|
23
|
+
const { DatabaseEngine } = require('./drivers/DatabaseEngine.ts')
|
|
24
|
+
return new DatabaseEngine(cfg.connection)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
builtInEngines.set('algolia', (cfg) => {
|
|
28
|
+
const { AlgoliaEngine } = require('./drivers/AlgoliaEngine.ts')
|
|
29
|
+
return new AlgoliaEngine(cfg.applicationId, cfg.apiKey, cfg.indexSettings)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
builtInEngines.set('meilisearch', (cfg) => {
|
|
33
|
+
const { MeilisearchEngine } = require('./drivers/MeilisearchEngine.ts')
|
|
34
|
+
return new MeilisearchEngine(cfg.host, cfg.apiKey)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
builtInEngines.set('typesense', (cfg) => {
|
|
38
|
+
const { TypesenseEngine } = require('./drivers/TypesenseEngine.ts')
|
|
39
|
+
return new TypesenseEngine(cfg.host, cfg.port, cfg.protocol, cfg.apiKey)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
builtInEngines.set('elasticsearch', (cfg) => {
|
|
43
|
+
const { ElasticsearchEngine } = require('./drivers/ElasticsearchEngine.ts')
|
|
44
|
+
return new ElasticsearchEngine(cfg.hosts, cfg.apiKey, cfg.username, cfg.password)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const manager = new SearchManager(config, builtInEngines)
|
|
48
|
+
setSearchManager(manager)
|
|
49
|
+
return manager
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
this.app.alias?.(SearchManager, SEARCH_MANAGER)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
boot(): void {
|
|
56
|
+
// Service provider boot — nothing needed here.
|
|
57
|
+
// Models opt in to search via makeSearchable() in their booted().
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { SearchBuilder } from './SearchBuilder.ts'
|
|
2
|
+
import { SearchObserver } from './SearchObserver.ts'
|
|
3
|
+
import { getSearchManager } from './helpers/search.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Makes a Model class searchable. Call in static booted().
|
|
7
|
+
*
|
|
8
|
+
* Adds: Model.search(query), Model.makeAllSearchable(), Model.removeAllFromSearch(),
|
|
9
|
+
* instance.toSearchableArray(), instance.searchableKey(), instance.shouldBeSearchable()
|
|
10
|
+
*/
|
|
11
|
+
export function makeSearchable(ModelClass: any): void {
|
|
12
|
+
// ── Static methods ──────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Start a search query against the model's search index. */
|
|
15
|
+
ModelClass.search = function (query: string, callback?: any): SearchBuilder {
|
|
16
|
+
const manager = getSearchManager()
|
|
17
|
+
return new SearchBuilder(this, query, manager.driver(), callback)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Get the search index name for this model. */
|
|
21
|
+
if (!ModelClass.searchableAs) {
|
|
22
|
+
ModelClass.searchableAs = function (): string {
|
|
23
|
+
const prefix = getSearchManager().getPrefix()
|
|
24
|
+
return prefix + (this.table ?? this.name.toLowerCase() + 's')
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Import all model records into the search index. */
|
|
29
|
+
ModelClass.makeAllSearchable = async function (chunkSize = 500): Promise<void> {
|
|
30
|
+
const manager = getSearchManager()
|
|
31
|
+
const engine = manager.driver()
|
|
32
|
+
let offset = 0
|
|
33
|
+
|
|
34
|
+
while (true) {
|
|
35
|
+
const models = await this.query().limit(chunkSize).offset(offset).get()
|
|
36
|
+
if (models.length === 0) break
|
|
37
|
+
|
|
38
|
+
const searchable = models.filter((m: any) =>
|
|
39
|
+
typeof m.shouldBeSearchable === 'function' ? m.shouldBeSearchable() : true,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (searchable.length > 0) {
|
|
43
|
+
await engine.update(searchable)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (models.length < chunkSize) break
|
|
47
|
+
offset += chunkSize
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Remove all model records from the search index. */
|
|
52
|
+
ModelClass.removeAllFromSearch = async function (): Promise<void> {
|
|
53
|
+
const manager = getSearchManager()
|
|
54
|
+
const indexName = this.searchableAs()
|
|
55
|
+
await manager.driver().flush(indexName)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Instance methods (defaults, overridable by the model) ───────
|
|
59
|
+
|
|
60
|
+
if (!ModelClass.prototype.toSearchableArray) {
|
|
61
|
+
ModelClass.prototype.toSearchableArray = function (): Record<string, any> {
|
|
62
|
+
return this.toObject ? this.toObject() : { ...this.attributes }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!ModelClass.prototype.searchableKey) {
|
|
67
|
+
ModelClass.prototype.searchableKey = function (): string | number {
|
|
68
|
+
const pk = (this.constructor as any).primaryKey ?? 'id'
|
|
69
|
+
return this.getAttribute(pk)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!ModelClass.prototype.shouldBeSearchable) {
|
|
74
|
+
ModelClass.prototype.shouldBeSearchable = function (): boolean {
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Register observer for auto-indexing ─────────────────────────
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const observer = new SearchObserver()
|
|
83
|
+
if (typeof ModelClass.observe === 'function') {
|
|
84
|
+
ModelClass.observe(observer)
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Events package may not be available — manual indexing only
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getSearchManager } from '../helpers/search.ts'
|
|
2
|
+
|
|
3
|
+
export class SearchDeleteIndexCommand {
|
|
4
|
+
readonly name = 'search:delete-index'
|
|
5
|
+
readonly description = 'Delete a search index'
|
|
6
|
+
readonly usage = 'search:delete-index <name>'
|
|
7
|
+
|
|
8
|
+
async handle(args: { positionals: string[] }, io: any): Promise<void> {
|
|
9
|
+
const name = args.positionals[0]
|
|
10
|
+
if (!name) {
|
|
11
|
+
io.error('Please provide an index name: search:delete-index <name>')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await getSearchManager().deleteIndex(name)
|
|
17
|
+
io.success(`Index "${name}" deleted.`)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
io.error(`Failed to delete index: ${err instanceof Error ? err.message : String(err)}`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getSearchManager } from '../helpers/search.ts'
|
|
2
|
+
|
|
3
|
+
export class SearchFlushCommand {
|
|
4
|
+
readonly name = 'search:flush'
|
|
5
|
+
readonly description = 'Remove all records for a model from the search index'
|
|
6
|
+
readonly usage = 'search:flush <model>'
|
|
7
|
+
|
|
8
|
+
async handle(args: { positionals: string[] }, io: any): Promise<void> {
|
|
9
|
+
const modelName = args.positionals[0]
|
|
10
|
+
if (!modelName) {
|
|
11
|
+
io.error('Please provide a model name: search:flush <model>')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
io.info(`Flushing ${modelName} from search index...`)
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const ModelClass = await this.resolveModel(modelName)
|
|
19
|
+
if (typeof ModelClass.removeAllFromSearch === 'function') {
|
|
20
|
+
await ModelClass.removeAllFromSearch()
|
|
21
|
+
io.success(`All ${modelName} records have been removed from the search index.`)
|
|
22
|
+
} else {
|
|
23
|
+
io.error(`${modelName} is not searchable. Call makeSearchable() in its booted() method.`)
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
io.error(`Failed to flush: ${err instanceof Error ? err.message : String(err)}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async resolveModel(name: string): Promise<any> {
|
|
31
|
+
const paths = [
|
|
32
|
+
`../../app/Models/${name}.ts`,
|
|
33
|
+
`../../Models/${name}.ts`,
|
|
34
|
+
`../../app/Models/${name}`,
|
|
35
|
+
]
|
|
36
|
+
for (const p of paths) {
|
|
37
|
+
try {
|
|
38
|
+
const mod = await import(p)
|
|
39
|
+
return mod[name] ?? mod.default
|
|
40
|
+
} catch { /* try next */ }
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Could not resolve model "${name}".`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getSearchManager } from '../helpers/search.ts'
|
|
2
|
+
|
|
3
|
+
export class SearchImportCommand {
|
|
4
|
+
readonly name = 'search:import'
|
|
5
|
+
readonly description = 'Import all records for a model into the search index'
|
|
6
|
+
readonly usage = 'search:import <model>'
|
|
7
|
+
|
|
8
|
+
async handle(args: { positionals: string[] }, io: any): Promise<void> {
|
|
9
|
+
const modelName = args.positionals[0]
|
|
10
|
+
if (!modelName) {
|
|
11
|
+
io.error('Please provide a model name: search:import <model>')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
io.info(`Importing ${modelName}...`)
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const ModelClass = await this.resolveModel(modelName)
|
|
19
|
+
if (typeof ModelClass.makeAllSearchable === 'function') {
|
|
20
|
+
await ModelClass.makeAllSearchable()
|
|
21
|
+
io.success(`All ${modelName} records have been imported.`)
|
|
22
|
+
} else {
|
|
23
|
+
io.error(`${modelName} is not searchable. Call makeSearchable() in its booted() method.`)
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
io.error(`Failed to import: ${err instanceof Error ? err.message : String(err)}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async resolveModel(name: string): Promise<any> {
|
|
31
|
+
// Try common model paths
|
|
32
|
+
const paths = [
|
|
33
|
+
`../../app/Models/${name}.ts`,
|
|
34
|
+
`../../Models/${name}.ts`,
|
|
35
|
+
`../../app/Models/${name}`,
|
|
36
|
+
]
|
|
37
|
+
for (const p of paths) {
|
|
38
|
+
try {
|
|
39
|
+
const mod = await import(p)
|
|
40
|
+
return mod[name] ?? mod.default
|
|
41
|
+
} catch { /* try next */ }
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Could not resolve model "${name}". Ensure it exists in app/Models/.`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getSearchManager } from '../helpers/search.ts'
|
|
2
|
+
|
|
3
|
+
export class SearchIndexCommand {
|
|
4
|
+
readonly name = 'search:index'
|
|
5
|
+
readonly description = 'Create a search index'
|
|
6
|
+
readonly usage = 'search:index <name>'
|
|
7
|
+
|
|
8
|
+
async handle(args: { positionals: string[] }, io: any): Promise<void> {
|
|
9
|
+
const name = args.positionals[0]
|
|
10
|
+
if (!name) {
|
|
11
|
+
io.error('Please provide an index name: search:index <name>')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await getSearchManager().createIndex(name)
|
|
17
|
+
io.success(`Index "${name}" created.`)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
io.error(`Failed to create index: ${err instanceof Error ? err.message : String(err)}`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type EngineConfig =
|
|
2
|
+
| { driver: 'collection' }
|
|
3
|
+
| { driver: 'database'; connection?: string }
|
|
4
|
+
| { driver: 'algolia'; applicationId: string; apiKey: string; indexSettings?: Record<string, any> }
|
|
5
|
+
| { driver: 'meilisearch'; host: string; apiKey: string }
|
|
6
|
+
| { driver: 'typesense'; host: string; port: number; protocol: 'http' | 'https'; apiKey: string }
|
|
7
|
+
| { driver: 'elasticsearch'; hosts: string[]; apiKey?: string; username?: string; password?: string }
|
|
8
|
+
|
|
9
|
+
export interface SearchConfig {
|
|
10
|
+
/** Default engine name */
|
|
11
|
+
default: string
|
|
12
|
+
/** Index name prefix (e.g., 'prod_') */
|
|
13
|
+
prefix: string
|
|
14
|
+
/** false = sync, true = default queue, string = named queue */
|
|
15
|
+
queue: boolean | string
|
|
16
|
+
/** Include __soft_deleted field in indexed data */
|
|
17
|
+
softDelete: boolean
|
|
18
|
+
/** Engine configurations keyed by name */
|
|
19
|
+
engines: Record<string, EngineConfig>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_CONFIG: SearchConfig = {
|
|
23
|
+
default: 'collection',
|
|
24
|
+
prefix: '',
|
|
25
|
+
queue: false,
|
|
26
|
+
softDelete: false,
|
|
27
|
+
engines: {
|
|
28
|
+
collection: { driver: 'collection' },
|
|
29
|
+
},
|
|
30
|
+
}
|