@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.
@@ -0,0 +1,136 @@
1
+ import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
2
+ import type { SearchBuilder } from '../SearchBuilder.ts'
3
+ import { SearchError } from '../errors/SearchError.ts'
4
+
5
+ /**
6
+ * Meilisearch engine driver using REST API.
7
+ */
8
+ export class MeilisearchEngine implements SearchEngine {
9
+ constructor(
10
+ private readonly host: string,
11
+ private readonly apiKey: string,
12
+ ) {}
13
+
14
+ async update(models: any[]): Promise<void> {
15
+ if (models.length === 0) return
16
+ const indexName = this.resolveIndexName(models[0])
17
+ const documents = models.map((m) => ({
18
+ id: this.resolveKey(m),
19
+ ...this.resolveData(m),
20
+ }))
21
+ await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/documents`, documents)
22
+ }
23
+
24
+ async delete(models: any[]): Promise<void> {
25
+ if (models.length === 0) return
26
+ const indexName = this.resolveIndexName(models[0])
27
+ const ids = models.map((m) => this.resolveKey(m))
28
+ await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/documents/delete-batch`, ids)
29
+ }
30
+
31
+ async search(builder: SearchBuilder): Promise<SearchResult> {
32
+ const indexName = this.resolveModelIndexName(builder.model)
33
+ const body: Record<string, any> = { q: builder.query }
34
+
35
+ if (builder.getLimit() !== null) body.limit = builder.getLimit()
36
+ if (builder.getOffset() !== null) body.offset = builder.getOffset()
37
+
38
+ const filter = this.buildFilter(builder)
39
+ if (filter.length > 0) body.filter = filter
40
+
41
+ const sort = builder.orders.map((o) => `${o.column}:${o.direction}`)
42
+ if (sort.length > 0) body.sort = sort
43
+
44
+ const res = await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/search`, body)
45
+
46
+ return {
47
+ raw: res,
48
+ keys: (res.hits ?? []).map((h: any) => h.id),
49
+ total: res.estimatedTotalHits ?? res.totalHits ?? 0,
50
+ }
51
+ }
52
+
53
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
54
+ const indexName = this.resolveModelIndexName(builder.model)
55
+ const body: Record<string, any> = {
56
+ q: builder.query,
57
+ hitsPerPage: perPage,
58
+ page,
59
+ }
60
+
61
+ const filter = this.buildFilter(builder)
62
+ if (filter.length > 0) body.filter = filter
63
+
64
+ const sort = builder.orders.map((o) => `${o.column}:${o.direction}`)
65
+ if (sort.length > 0) body.sort = sort
66
+
67
+ const res = await this.request('POST', `/indexes/${encodeURIComponent(indexName)}/search`, body)
68
+
69
+ return {
70
+ raw: res,
71
+ keys: (res.hits ?? []).map((h: any) => h.id),
72
+ total: res.totalHits ?? res.estimatedTotalHits ?? 0,
73
+ }
74
+ }
75
+
76
+ async flush(indexName: string): Promise<void> {
77
+ await this.request('DELETE', `/indexes/${encodeURIComponent(indexName)}/documents`)
78
+ }
79
+
80
+ async createIndex(name: string, options?: Record<string, any>): Promise<void> {
81
+ await this.request('POST', '/indexes', { uid: name, primaryKey: options?.primaryKey ?? 'id' })
82
+ }
83
+
84
+ async deleteIndex(name: string): Promise<void> {
85
+ await this.request('DELETE', `/indexes/${encodeURIComponent(name)}`)
86
+ }
87
+
88
+ private buildFilter(builder: SearchBuilder): string[] {
89
+ const filters: string[] = []
90
+ for (const { field, value } of builder.wheres) {
91
+ filters.push(`${field} = ${JSON.stringify(value)}`)
92
+ }
93
+ for (const { field, values } of builder.whereIns) {
94
+ const parts = values.map((v) => `${field} = ${JSON.stringify(v)}`)
95
+ filters.push(parts.join(' OR '))
96
+ }
97
+ return filters
98
+ }
99
+
100
+ private async request(method: string, path: string, body?: any): Promise<any> {
101
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
102
+ if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`
103
+
104
+ const res = await fetch(`${this.host}${path}`, {
105
+ method,
106
+ headers,
107
+ body: body ? JSON.stringify(body) : undefined,
108
+ })
109
+
110
+ if (!res.ok) {
111
+ const text = await res.text()
112
+ throw new SearchError(`Meilisearch API error: ${res.status} ${text}`, { status: res.status })
113
+ }
114
+
115
+ const contentType = res.headers.get('content-type')
116
+ if (contentType?.includes('application/json')) return res.json()
117
+ return {}
118
+ }
119
+
120
+ private resolveIndexName(model: any): string {
121
+ const MC = model.constructor
122
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
123
+ }
124
+
125
+ private resolveModelIndexName(MC: any): string {
126
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
127
+ }
128
+
129
+ private resolveKey(model: any): string | number {
130
+ return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
131
+ }
132
+
133
+ private resolveData(model: any): Record<string, any> {
134
+ return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
135
+ }
136
+ }
@@ -0,0 +1,181 @@
1
+ import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
2
+ import type { SearchBuilder } from '../SearchBuilder.ts'
3
+ import { SearchError } from '../errors/SearchError.ts'
4
+
5
+ /**
6
+ * Typesense engine driver using REST API.
7
+ */
8
+ export class TypesenseEngine implements SearchEngine {
9
+ private readonly baseUrl: string
10
+
11
+ constructor(
12
+ host: string,
13
+ port: number,
14
+ protocol: 'http' | 'https',
15
+ private readonly apiKey: string,
16
+ ) {
17
+ this.baseUrl = `${protocol}://${host}:${port}`
18
+ }
19
+
20
+ async update(models: any[]): Promise<void> {
21
+ if (models.length === 0) return
22
+ const collection = this.resolveIndexName(models[0])
23
+ const documents = models.map((m) => ({
24
+ ...this.resolveData(m),
25
+ id: String(this.resolveKey(m)),
26
+ }))
27
+
28
+ // JSONL import
29
+ const jsonl = documents.map((d) => JSON.stringify(d)).join('\n')
30
+ await this.request('POST', `/collections/${collection}/documents/import?action=upsert`, jsonl, 'text/plain')
31
+ }
32
+
33
+ async delete(models: any[]): Promise<void> {
34
+ if (models.length === 0) return
35
+ const collection = this.resolveIndexName(models[0])
36
+ const ids = models.map((m) => String(this.resolveKey(m)))
37
+
38
+ // Delete individually (Typesense doesn't have batch delete by ID)
39
+ for (const id of ids) {
40
+ try {
41
+ await this.request('DELETE', `/collections/${collection}/documents/${id}`)
42
+ } catch {
43
+ // Ignore 404s
44
+ }
45
+ }
46
+ }
47
+
48
+ async search(builder: SearchBuilder): Promise<SearchResult> {
49
+ const collection = this.resolveModelIndexName(builder.model)
50
+ const params = new URLSearchParams({
51
+ q: builder.query || '*',
52
+ query_by: this.resolveQueryBy(builder.model),
53
+ })
54
+
55
+ if (builder.getLimit() !== null) params.set('per_page', String(builder.getLimit()))
56
+ if (builder.getOffset() !== null) params.set('offset', String(builder.getOffset()))
57
+
58
+ const filter = this.buildFilter(builder)
59
+ if (filter) params.set('filter_by', filter)
60
+
61
+ const sort = builder.orders.map((o) => `${o.column}:${o.direction}`).join(',')
62
+ if (sort) params.set('sort_by', sort)
63
+
64
+ const res = await this.request('GET', `/collections/${collection}/documents/search?${params}`)
65
+
66
+ return {
67
+ raw: res,
68
+ keys: (res.hits ?? []).map((h: any) => h.document?.id),
69
+ total: res.found ?? 0,
70
+ }
71
+ }
72
+
73
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
74
+ const collection = this.resolveModelIndexName(builder.model)
75
+ const params = new URLSearchParams({
76
+ q: builder.query || '*',
77
+ query_by: this.resolveQueryBy(builder.model),
78
+ per_page: String(perPage),
79
+ page: String(page),
80
+ })
81
+
82
+ const filter = this.buildFilter(builder)
83
+ if (filter) params.set('filter_by', filter)
84
+
85
+ const sort = builder.orders.map((o) => `${o.column}:${o.direction}`).join(',')
86
+ if (sort) params.set('sort_by', sort)
87
+
88
+ const res = await this.request('GET', `/collections/${collection}/documents/search?${params}`)
89
+
90
+ return {
91
+ raw: res,
92
+ keys: (res.hits ?? []).map((h: any) => h.document?.id),
93
+ total: res.found ?? 0,
94
+ }
95
+ }
96
+
97
+ async flush(indexName: string): Promise<void> {
98
+ try {
99
+ await this.request('DELETE', `/collections/${indexName}`)
100
+ } catch {
101
+ // Collection may not exist
102
+ }
103
+ }
104
+
105
+ async createIndex(name: string, options?: Record<string, any>): Promise<void> {
106
+ const schema = {
107
+ name,
108
+ fields: options?.fields ?? [{ name: '.*', type: 'auto' }],
109
+ ...options,
110
+ }
111
+ await this.request('POST', '/collections', schema)
112
+ }
113
+
114
+ async deleteIndex(name: string): Promise<void> {
115
+ await this.request('DELETE', `/collections/${name}`)
116
+ }
117
+
118
+ private buildFilter(builder: SearchBuilder): string {
119
+ const parts: string[] = []
120
+ for (const { field, value } of builder.wheres) {
121
+ parts.push(`${field}:=${JSON.stringify(value)}`)
122
+ }
123
+ for (const { field, values } of builder.whereIns) {
124
+ parts.push(`${field}:[${values.map((v) => JSON.stringify(v)).join(',')}]`)
125
+ }
126
+ return parts.join(' && ')
127
+ }
128
+
129
+ private resolveQueryBy(ModelClass: any): string {
130
+ if (typeof ModelClass.searchableColumns === 'function') {
131
+ return ModelClass.searchableColumns().join(',')
132
+ }
133
+ if (ModelClass.fillable && ModelClass.fillable.length > 0) {
134
+ return ModelClass.fillable.join(',')
135
+ }
136
+ return '*'
137
+ }
138
+
139
+ private async request(method: string, path: string, body?: any, contentType?: string): Promise<any> {
140
+ const headers: Record<string, string> = {
141
+ 'X-TYPESENSE-API-KEY': this.apiKey,
142
+ }
143
+ if (contentType) {
144
+ headers['Content-Type'] = contentType
145
+ } else if (body && typeof body !== 'string') {
146
+ headers['Content-Type'] = 'application/json'
147
+ }
148
+
149
+ const res = await fetch(`${this.baseUrl}${path}`, {
150
+ method,
151
+ headers,
152
+ body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
153
+ })
154
+
155
+ if (!res.ok) {
156
+ const text = await res.text()
157
+ throw new SearchError(`Typesense API error: ${res.status} ${text}`, { status: res.status })
158
+ }
159
+
160
+ const ct = res.headers.get('content-type')
161
+ if (ct?.includes('application/json')) return res.json()
162
+ return {}
163
+ }
164
+
165
+ private resolveIndexName(model: any): string {
166
+ const MC = model.constructor
167
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
168
+ }
169
+
170
+ private resolveModelIndexName(MC: any): string {
171
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
172
+ }
173
+
174
+ private resolveKey(model: any): string | number {
175
+ return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
176
+ }
177
+
178
+ private resolveData(model: any): Record<string, any> {
179
+ return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
180
+ }
181
+ }
@@ -0,0 +1,9 @@
1
+ export class SearchError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly context?: Record<string, any>,
5
+ ) {
6
+ super(message)
7
+ this.name = 'SearchError'
8
+ }
9
+ }
@@ -0,0 +1,26 @@
1
+ import type { SearchEngine } from '../contracts/SearchEngine.ts'
2
+ import type { SearchManager } from '../SearchManager.ts'
3
+
4
+ export const SEARCH_MANAGER = Symbol('SearchManager')
5
+
6
+ let _manager: SearchManager | null = null
7
+
8
+ export function setSearchManager(manager: SearchManager): void {
9
+ _manager = manager
10
+ }
11
+
12
+ export function getSearchManager(): SearchManager {
13
+ if (!_manager) {
14
+ throw new Error('SearchManager has not been initialized. Register SearchServiceProvider first.')
15
+ }
16
+ return _manager
17
+ }
18
+
19
+ /** Get the SearchManager or a specific engine. */
20
+ export function search(): SearchManager
21
+ export function search(engine: string): SearchEngine
22
+ export function search(engine?: string): SearchManager | SearchEngine {
23
+ const manager = getSearchManager()
24
+ if (engine === undefined) return manager
25
+ return manager.driver(engine)
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ // @mantiq/search — public API exports
2
+
3
+ // Contracts
4
+ export type { SearchEngine, SearchResult } from './contracts/SearchEngine.ts'
5
+ export type { SearchConfig, EngineConfig } from './contracts/SearchConfig.ts'
6
+ export { DEFAULT_CONFIG } from './contracts/SearchConfig.ts'
7
+
8
+ // Core
9
+ export { SearchManager } from './SearchManager.ts'
10
+ export { SearchBuilder, type PaginatedSearchResult, type WhereClause, type WhereInClause, type OrderClause } from './SearchBuilder.ts'
11
+ export { SearchServiceProvider } from './SearchServiceProvider.ts'
12
+ export { makeSearchable } from './Searchable.ts'
13
+ export { SearchObserver } from './SearchObserver.ts'
14
+
15
+ // Drivers
16
+ export { CollectionEngine } from './drivers/CollectionEngine.ts'
17
+ export { DatabaseEngine } from './drivers/DatabaseEngine.ts'
18
+ export { AlgoliaEngine } from './drivers/AlgoliaEngine.ts'
19
+ export { MeilisearchEngine } from './drivers/MeilisearchEngine.ts'
20
+ export { TypesenseEngine } from './drivers/TypesenseEngine.ts'
21
+ export { ElasticsearchEngine } from './drivers/ElasticsearchEngine.ts'
22
+
23
+ // Commands
24
+ export { SearchImportCommand } from './commands/SearchImportCommand.ts'
25
+ export { SearchFlushCommand } from './commands/SearchFlushCommand.ts'
26
+ export { SearchIndexCommand } from './commands/SearchIndexCommand.ts'
27
+ export { SearchDeleteIndexCommand } from './commands/SearchDeleteIndexCommand.ts'
28
+
29
+ // Jobs
30
+ export { MakeSearchableJob } from './jobs/MakeSearchableJob.ts'
31
+
32
+ // Helpers
33
+ export { search, SEARCH_MANAGER, setSearchManager, getSearchManager } from './helpers/search.ts'
34
+
35
+ // Testing
36
+ export { SearchFake } from './testing/SearchFake.ts'
37
+
38
+ // Errors
39
+ export { SearchError } from './errors/SearchError.ts'
@@ -0,0 +1,22 @@
1
+ import { getSearchManager } from '../helpers/search.ts'
2
+
3
+ export class MakeSearchableJob {
4
+ readonly jobName = 'MakeSearchableJob'
5
+ tries = 3
6
+ backoff = 10
7
+
8
+ constructor(
9
+ private readonly models: any[],
10
+ private readonly action: 'update' | 'delete',
11
+ ) {}
12
+
13
+ async handle(): Promise<void> {
14
+ const engine = getSearchManager().driver()
15
+
16
+ if (this.action === 'update') {
17
+ await engine.update(this.models)
18
+ } else {
19
+ await engine.delete(this.models)
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,136 @@
1
+ import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
2
+ import type { SearchBuilder } from '../SearchBuilder.ts'
3
+
4
+ /**
5
+ * Fake search engine for testing. Stores all indexed records in memory
6
+ * and provides assertion methods.
7
+ */
8
+ export class SearchFake implements SearchEngine {
9
+ private indexed = new Map<string, Map<string | number, Record<string, any>>>()
10
+ private deleted = new Map<string, Set<string | number>>()
11
+ private flushed = new Set<string>()
12
+
13
+ async update(models: any[]): Promise<void> {
14
+ for (const model of models) {
15
+ const index = this.resolveIndexName(model)
16
+ if (!this.indexed.has(index)) this.indexed.set(index, new Map())
17
+ const key = this.resolveKey(model)
18
+ const data = typeof model.toSearchableArray === 'function'
19
+ ? model.toSearchableArray()
20
+ : { ...model.attributes }
21
+ this.indexed.get(index)!.set(key, data)
22
+ }
23
+ }
24
+
25
+ async delete(models: any[]): Promise<void> {
26
+ for (const model of models) {
27
+ const index = this.resolveIndexName(model)
28
+ if (!this.deleted.has(index)) this.deleted.set(index, new Set())
29
+ const key = this.resolveKey(model)
30
+ this.deleted.get(index)!.add(key)
31
+ this.indexed.get(index)?.delete(key)
32
+ }
33
+ }
34
+
35
+ async search(builder: SearchBuilder): Promise<SearchResult> {
36
+ const index = this.resolveModelIndexName(builder.model)
37
+ const records = this.indexed.get(index)
38
+ if (!records) return { raw: [], keys: [], total: 0 }
39
+
40
+ const keys = Array.from(records.keys())
41
+ return { raw: Array.from(records.values()), keys, total: keys.length }
42
+ }
43
+
44
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
45
+ const result = await this.search(builder)
46
+ const start = (page - 1) * perPage
47
+ const keys = result.keys.slice(start, start + perPage)
48
+ return { raw: result.raw.slice(start, start + perPage), keys, total: result.total }
49
+ }
50
+
51
+ async flush(indexName: string): Promise<void> {
52
+ this.indexed.delete(indexName)
53
+ this.flushed.add(indexName)
54
+ }
55
+
56
+ async createIndex(_name: string): Promise<void> {}
57
+ async deleteIndex(_name: string): Promise<void> {}
58
+
59
+ // ── Assertions ────────────────────────────────────────────────────
60
+
61
+ assertIndexed(modelClass: any, count?: number): void {
62
+ const index = this.resolveStaticIndexName(modelClass)
63
+ const records = this.indexed.get(index)
64
+ const actual = records?.size ?? 0
65
+ if (actual === 0) {
66
+ throw new Error(`Expected [${modelClass.name}] to be indexed, but it was not.`)
67
+ }
68
+ if (count !== undefined && actual !== count) {
69
+ throw new Error(`Expected [${modelClass.name}] to be indexed ${count} time(s), but was indexed ${actual} time(s).`)
70
+ }
71
+ }
72
+
73
+ assertNotIndexed(modelClass: any): void {
74
+ const index = this.resolveStaticIndexName(modelClass)
75
+ const records = this.indexed.get(index)
76
+ if (records && records.size > 0) {
77
+ throw new Error(`Expected [${modelClass.name}] to not be indexed, but ${records.size} record(s) found.`)
78
+ }
79
+ }
80
+
81
+ assertNothingIndexed(): void {
82
+ let total = 0
83
+ for (const records of this.indexed.values()) total += records.size
84
+ if (total > 0) {
85
+ throw new Error(`Expected nothing to be indexed, but ${total} record(s) found.`)
86
+ }
87
+ }
88
+
89
+ assertDeleted(modelClass: any, count?: number): void {
90
+ const index = this.resolveStaticIndexName(modelClass)
91
+ const deletedKeys = this.deleted.get(index)
92
+ const actual = deletedKeys?.size ?? 0
93
+ if (actual === 0) {
94
+ throw new Error(`Expected [${modelClass.name}] to have deletions, but none found.`)
95
+ }
96
+ if (count !== undefined && actual !== count) {
97
+ throw new Error(`Expected ${count} deletion(s) for [${modelClass.name}], but found ${actual}.`)
98
+ }
99
+ }
100
+
101
+ assertFlushed(modelClass: any): void {
102
+ const index = this.resolveStaticIndexName(modelClass)
103
+ if (!this.flushed.has(index)) {
104
+ throw new Error(`Expected [${modelClass.name}] index to be flushed, but it was not.`)
105
+ }
106
+ }
107
+
108
+ /** Get all indexed records for a model class. */
109
+ getIndexed(modelClass: any): Map<string | number, Record<string, any>> {
110
+ const index = this.resolveStaticIndexName(modelClass)
111
+ return this.indexed.get(index) ?? new Map()
112
+ }
113
+
114
+ reset(): void {
115
+ this.indexed.clear()
116
+ this.deleted.clear()
117
+ this.flushed.clear()
118
+ }
119
+
120
+ private resolveIndexName(model: any): string {
121
+ const MC = model.constructor
122
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
123
+ }
124
+
125
+ private resolveModelIndexName(MC: any): string {
126
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
127
+ }
128
+
129
+ private resolveStaticIndexName(MC: any): string {
130
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
131
+ }
132
+
133
+ private resolveKey(model: any): string | number {
134
+ return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
135
+ }
136
+ }