@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,33 @@
1
+ import type { SearchBuilder } from '../SearchBuilder.ts'
2
+
3
+ export interface SearchResult {
4
+ /** Raw results from the engine (format varies by driver) */
5
+ raw: any
6
+ /** Mapped model keys from the results */
7
+ keys: (string | number)[]
8
+ /** Total number of matching records (for pagination) */
9
+ total: number
10
+ }
11
+
12
+ export interface SearchEngine {
13
+ /** Upsert models into the search index */
14
+ update(models: any[]): Promise<void>
15
+
16
+ /** Remove models from the search index */
17
+ delete(models: any[]): Promise<void>
18
+
19
+ /** Perform a search query */
20
+ search(builder: SearchBuilder): Promise<SearchResult>
21
+
22
+ /** Perform a paginated search query */
23
+ paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult>
24
+
25
+ /** Remove all records for a model from its index */
26
+ flush(indexName: string): Promise<void>
27
+
28
+ /** Create a search index */
29
+ createIndex(name: string, options?: Record<string, any>): Promise<void>
30
+
31
+ /** Delete a search index */
32
+ deleteIndex(name: string): Promise<void>
33
+ }
@@ -0,0 +1,145 @@
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
+ * Algolia search engine driver using REST API (no SDK).
7
+ */
8
+ export class AlgoliaEngine implements SearchEngine {
9
+ private readonly baseUrl: string
10
+
11
+ constructor(
12
+ private readonly applicationId: string,
13
+ private readonly apiKey: string,
14
+ private readonly indexSettings?: Record<string, any>,
15
+ ) {
16
+ this.baseUrl = `https://${applicationId}-dsn.algolia.net`
17
+ }
18
+
19
+ async update(models: any[]): Promise<void> {
20
+ if (models.length === 0) return
21
+
22
+ const indexName = this.resolveIndexName(models[0])
23
+ const objects = models.map((m) => ({
24
+ objectID: String(this.resolveKey(m)),
25
+ ...this.resolveData(m),
26
+ }))
27
+
28
+ await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/batch`, {
29
+ requests: objects.map((obj) => ({ action: 'updateObject', body: obj })),
30
+ })
31
+ }
32
+
33
+ async delete(models: any[]): Promise<void> {
34
+ if (models.length === 0) return
35
+
36
+ const indexName = this.resolveIndexName(models[0])
37
+ const objectIDs = models.map((m) => String(this.resolveKey(m)))
38
+
39
+ await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/batch`, {
40
+ requests: objectIDs.map((id) => ({ action: 'deleteObject', body: { objectID: id } })),
41
+ })
42
+ }
43
+
44
+ async search(builder: SearchBuilder): Promise<SearchResult> {
45
+ const indexName = this.resolveModelIndexName(builder.model)
46
+
47
+ const params: Record<string, any> = { query: builder.query }
48
+ if (builder.getLimit() !== null) params.hitsPerPage = builder.getLimit()
49
+
50
+ const filters = this.buildFilters(builder)
51
+ if (filters) params.filters = filters
52
+
53
+ const res = await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/query`, params)
54
+
55
+ return {
56
+ raw: res,
57
+ keys: (res.hits ?? []).map((h: any) => h.objectID),
58
+ total: res.nbHits ?? 0,
59
+ }
60
+ }
61
+
62
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
63
+ const indexName = this.resolveModelIndexName(builder.model)
64
+
65
+ const params: Record<string, any> = {
66
+ query: builder.query,
67
+ hitsPerPage: perPage,
68
+ page: page - 1, // Algolia is 0-indexed
69
+ }
70
+
71
+ const filters = this.buildFilters(builder)
72
+ if (filters) params.filters = filters
73
+
74
+ const res = await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/query`, params)
75
+
76
+ return {
77
+ raw: res,
78
+ keys: (res.hits ?? []).map((h: any) => h.objectID),
79
+ total: res.nbHits ?? 0,
80
+ }
81
+ }
82
+
83
+ async flush(indexName: string): Promise<void> {
84
+ await this.request('POST', `/1/indexes/${encodeURIComponent(indexName)}/clear`, {})
85
+ }
86
+
87
+ async createIndex(name: string, options?: Record<string, any>): Promise<void> {
88
+ const settings = { ...this.indexSettings, ...options }
89
+ if (Object.keys(settings).length > 0) {
90
+ await this.request('PUT', `/1/indexes/${encodeURIComponent(name)}/settings`, settings)
91
+ }
92
+ }
93
+
94
+ async deleteIndex(name: string): Promise<void> {
95
+ await this.request('DELETE', `/1/indexes/${encodeURIComponent(name)}`)
96
+ }
97
+
98
+ private buildFilters(builder: SearchBuilder): string {
99
+ const parts: string[] = []
100
+ for (const { field, value } of builder.wheres) {
101
+ parts.push(`${field}:${JSON.stringify(value)}`)
102
+ }
103
+ for (const { field, values } of builder.whereIns) {
104
+ const orParts = values.map((v) => `${field}:${JSON.stringify(v)}`)
105
+ parts.push(`(${orParts.join(' OR ')})`)
106
+ }
107
+ return parts.join(' AND ')
108
+ }
109
+
110
+ private async request(method: string, path: string, body?: any): Promise<any> {
111
+ const res = await fetch(`${this.baseUrl}${path}`, {
112
+ method,
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'X-Algolia-Application-Id': this.applicationId,
116
+ 'X-Algolia-API-Key': this.apiKey,
117
+ },
118
+ body: body ? JSON.stringify(body) : undefined,
119
+ })
120
+
121
+ if (!res.ok) {
122
+ const text = await res.text()
123
+ throw new SearchError(`Algolia API error: ${res.status} ${text}`, { status: res.status })
124
+ }
125
+
126
+ return res.json()
127
+ }
128
+
129
+ private resolveIndexName(model: any): string {
130
+ const MC = model.constructor
131
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
132
+ }
133
+
134
+ private resolveModelIndexName(MC: any): string {
135
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
136
+ }
137
+
138
+ private resolveKey(model: any): string | number {
139
+ return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
140
+ }
141
+
142
+ private resolveData(model: any): Record<string, any> {
143
+ return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
144
+ }
145
+ }
@@ -0,0 +1,176 @@
1
+ import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
2
+ import type { SearchBuilder } from '../SearchBuilder.ts'
3
+
4
+ /**
5
+ * In-memory search engine for development and testing.
6
+ * Stores indexed records in a Map and searches with string matching.
7
+ */
8
+ export class CollectionEngine implements SearchEngine {
9
+ private readonly store = new Map<string, Map<string | number, Record<string, any>>>()
10
+
11
+ async update(models: any[]): Promise<void> {
12
+ for (const model of models) {
13
+ const indexName = this.resolveIndexName(model)
14
+ if (!this.store.has(indexName)) this.store.set(indexName, new Map())
15
+ const index = this.store.get(indexName)!
16
+
17
+ const key = this.resolveKey(model)
18
+ const data = typeof model.toSearchableArray === 'function'
19
+ ? model.toSearchableArray()
20
+ : { ...model.attributes }
21
+
22
+ index.set(key, data)
23
+ }
24
+ }
25
+
26
+ async delete(models: any[]): Promise<void> {
27
+ for (const model of models) {
28
+ const indexName = this.resolveIndexName(model)
29
+ const index = this.store.get(indexName)
30
+ if (!index) continue
31
+ index.delete(this.resolveKey(model))
32
+ }
33
+ }
34
+
35
+ async search(builder: SearchBuilder): Promise<SearchResult> {
36
+ const indexName = this.resolveModelIndexName(builder.model)
37
+ const index = this.store.get(indexName)
38
+
39
+ if (!index) return { raw: [], keys: [], total: 0 }
40
+
41
+ let records = Array.from(index.entries()).map(([key, data]) => ({ key, data }))
42
+
43
+ // Full-text filter
44
+ if (builder.query) {
45
+ const q = builder.query.toLowerCase()
46
+ records = records.filter(({ data }) =>
47
+ Object.values(data).some((v) =>
48
+ v !== null && v !== undefined && String(v).toLowerCase().includes(q),
49
+ ),
50
+ )
51
+ }
52
+
53
+ // Where clauses
54
+ for (const { field, value } of builder.wheres) {
55
+ records = records.filter(({ data }) => data[field] === value)
56
+ }
57
+
58
+ // WhereIn clauses
59
+ for (const { field, values } of builder.whereIns) {
60
+ records = records.filter(({ data }) => values.includes(data[field]))
61
+ }
62
+
63
+ const total = records.length
64
+
65
+ // Order
66
+ for (const { column, direction } of [...builder.orders].reverse()) {
67
+ records.sort((a, b) => {
68
+ const av = a.data[column]
69
+ const bv = b.data[column]
70
+ if (av < bv) return direction === 'asc' ? -1 : 1
71
+ if (av > bv) return direction === 'asc' ? 1 : -1
72
+ return 0
73
+ })
74
+ }
75
+
76
+ // Offset
77
+ const offset = builder.getOffset()
78
+ if (offset !== null && offset > 0) {
79
+ records = records.slice(offset)
80
+ }
81
+
82
+ // Limit
83
+ const limit = builder.getLimit()
84
+ if (limit !== null) {
85
+ records = records.slice(0, limit)
86
+ }
87
+
88
+ return {
89
+ raw: records.map((r) => r.data),
90
+ keys: records.map((r) => r.key),
91
+ total,
92
+ }
93
+ }
94
+
95
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
96
+ const indexName = this.resolveModelIndexName(builder.model)
97
+ const index = this.store.get(indexName)
98
+
99
+ if (!index) return { raw: [], keys: [], total: 0 }
100
+
101
+ let records = Array.from(index.entries()).map(([key, data]) => ({ key, data }))
102
+
103
+ // Apply filters (same as search)
104
+ if (builder.query) {
105
+ const q = builder.query.toLowerCase()
106
+ records = records.filter(({ data }) =>
107
+ Object.values(data).some((v) =>
108
+ v !== null && v !== undefined && String(v).toLowerCase().includes(q),
109
+ ),
110
+ )
111
+ }
112
+ for (const { field, value } of builder.wheres) {
113
+ records = records.filter(({ data }) => data[field] === value)
114
+ }
115
+ for (const { field, values } of builder.whereIns) {
116
+ records = records.filter(({ data }) => values.includes(data[field]))
117
+ }
118
+
119
+ const total = records.length
120
+
121
+ for (const { column, direction } of [...builder.orders].reverse()) {
122
+ records.sort((a, b) => {
123
+ const av = a.data[column]
124
+ const bv = b.data[column]
125
+ if (av < bv) return direction === 'asc' ? -1 : 1
126
+ if (av > bv) return direction === 'asc' ? 1 : -1
127
+ return 0
128
+ })
129
+ }
130
+
131
+ const start = (page - 1) * perPage
132
+ records = records.slice(start, start + perPage)
133
+
134
+ return {
135
+ raw: records.map((r) => r.data),
136
+ keys: records.map((r) => r.key),
137
+ total,
138
+ }
139
+ }
140
+
141
+ async flush(indexName: string): Promise<void> {
142
+ this.store.delete(indexName)
143
+ }
144
+
145
+ async createIndex(name: string): Promise<void> {
146
+ if (!this.store.has(name)) this.store.set(name, new Map())
147
+ }
148
+
149
+ async deleteIndex(name: string): Promise<void> {
150
+ this.store.delete(name)
151
+ }
152
+
153
+ /** Get the raw store for testing/debugging. */
154
+ getStore(): Map<string, Map<string | number, Record<string, any>>> {
155
+ return this.store
156
+ }
157
+
158
+ private resolveIndexName(model: any): string {
159
+ const ModelClass = model.constructor
160
+ return typeof ModelClass.searchableAs === 'function'
161
+ ? ModelClass.searchableAs()
162
+ : ModelClass.table ?? ModelClass.name.toLowerCase() + 's'
163
+ }
164
+
165
+ private resolveModelIndexName(ModelClass: any): string {
166
+ return typeof ModelClass.searchableAs === 'function'
167
+ ? ModelClass.searchableAs()
168
+ : ModelClass.table ?? ModelClass.name.toLowerCase() + 's'
169
+ }
170
+
171
+ private resolveKey(model: any): string | number {
172
+ return typeof model.searchableKey === 'function'
173
+ ? model.searchableKey()
174
+ : model.getAttribute?.('id') ?? model.id
175
+ }
176
+ }
@@ -0,0 +1,139 @@
1
+ import type { SearchEngine, SearchResult } from '../contracts/SearchEngine.ts'
2
+ import type { SearchBuilder } from '../SearchBuilder.ts'
3
+
4
+ /**
5
+ * Database-backed search engine using SQL full-text capabilities.
6
+ * - SQLite: LIKE '%query%' across searchable columns
7
+ * - PostgreSQL: to_tsvector() @@ plainto_tsquery()
8
+ * - MySQL: MATCH ... AGAINST (if FULLTEXT index exists, falls back to LIKE)
9
+ */
10
+ export class DatabaseEngine implements SearchEngine {
11
+ constructor(private readonly connectionName?: string) {}
12
+
13
+ async update(_models: any[]): Promise<void> {
14
+ // No-op: data lives in the database already.
15
+ }
16
+
17
+ async delete(_models: any[]): Promise<void> {
18
+ // No-op: data lives in the database already.
19
+ }
20
+
21
+ async search(builder: SearchBuilder): Promise<SearchResult> {
22
+ const ModelClass = builder.model
23
+ let query = ModelClass.query()
24
+
25
+ if (this.connectionName) {
26
+ query = query.connection(this.connectionName)
27
+ }
28
+
29
+ // Apply full-text search
30
+ if (builder.query) {
31
+ query = this.applySearchQuery(query, builder)
32
+ }
33
+
34
+ // Apply where clauses
35
+ for (const { field, value } of builder.wheres) {
36
+ query = query.where(field, value)
37
+ }
38
+ for (const { field, values } of builder.whereIns) {
39
+ query = query.whereIn(field, values)
40
+ }
41
+
42
+ // Apply ordering
43
+ for (const { column, direction } of builder.orders) {
44
+ query = query.orderBy(column, direction)
45
+ }
46
+
47
+ // Apply offset
48
+ const offset = builder.getOffset()
49
+ if (offset !== null && offset > 0) {
50
+ query = query.offset(offset)
51
+ }
52
+
53
+ // Apply limit
54
+ const limit = builder.getLimit()
55
+ if (limit !== null) {
56
+ query = query.limit(limit)
57
+ }
58
+
59
+ const models = await query.get()
60
+ const pk = ModelClass.primaryKey ?? 'id'
61
+ const keys = models.map((m: any) => m.getAttribute(pk))
62
+
63
+ return { raw: models, keys, total: keys.length }
64
+ }
65
+
66
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
67
+ const ModelClass = builder.model
68
+ let query = ModelClass.query()
69
+
70
+ if (this.connectionName) {
71
+ query = query.connection(this.connectionName)
72
+ }
73
+
74
+ if (builder.query) {
75
+ query = this.applySearchQuery(query, builder)
76
+ }
77
+
78
+ for (const { field, value } of builder.wheres) {
79
+ query = query.where(field, value)
80
+ }
81
+ for (const { field, values } of builder.whereIns) {
82
+ query = query.whereIn(field, values)
83
+ }
84
+
85
+ for (const { column, direction } of builder.orders) {
86
+ query = query.orderBy(column, direction)
87
+ }
88
+
89
+ // Get total count
90
+ const countQuery = ModelClass.query()
91
+ if (builder.query) this.applySearchQuery(countQuery, builder)
92
+ for (const { field, value } of builder.wheres) countQuery.where(field, value)
93
+ for (const { field, values } of builder.whereIns) countQuery.whereIn(field, values)
94
+ const total = await countQuery.count()
95
+
96
+ // Paginate
97
+ const models = await query.limit(perPage).offset((page - 1) * perPage).get()
98
+ const pk = ModelClass.primaryKey ?? 'id'
99
+ const keys = models.map((m: any) => m.getAttribute(pk))
100
+
101
+ return { raw: models, keys, total }
102
+ }
103
+
104
+ async flush(_indexName: string): Promise<void> {
105
+ // No-op: we don't manage a separate index.
106
+ }
107
+
108
+ async createIndex(_name: string): Promise<void> {
109
+ // No-op: database tables are the index.
110
+ }
111
+
112
+ async deleteIndex(_name: string): Promise<void> {
113
+ // No-op
114
+ }
115
+
116
+ private applySearchQuery(query: any, builder: SearchBuilder): any {
117
+ const searchTerm = builder.query
118
+ const ModelClass = builder.model
119
+
120
+ // Get searchable columns from the model, or fall back to fillable
121
+ const columns: string[] = typeof ModelClass.searchableColumns === 'function'
122
+ ? ModelClass.searchableColumns()
123
+ : ModelClass.fillable ?? []
124
+
125
+ if (columns.length === 0) return query
126
+
127
+ // Use LIKE for broad compatibility (works on SQLite, Postgres, MySQL)
128
+ return query.where((q: any) => {
129
+ for (let i = 0; i < columns.length; i++) {
130
+ const col = columns[i]!
131
+ if (i === 0) {
132
+ q.where(col, 'LIKE', `%${searchTerm}%`)
133
+ } else {
134
+ q.orWhere(col, 'LIKE', `%${searchTerm}%`)
135
+ }
136
+ }
137
+ })
138
+ }
139
+ }
@@ -0,0 +1,188 @@
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
+ * Elasticsearch engine driver using REST API.
7
+ */
8
+ export class ElasticsearchEngine implements SearchEngine {
9
+ private readonly host: string
10
+
11
+ constructor(
12
+ hosts: string[],
13
+ private readonly apiKey?: string,
14
+ private readonly username?: string,
15
+ private readonly password?: string,
16
+ ) {
17
+ this.host = hosts[0] ?? 'http://127.0.0.1:9200'
18
+ }
19
+
20
+ async update(models: any[]): Promise<void> {
21
+ if (models.length === 0) return
22
+ const indexName = this.resolveIndexName(models[0])
23
+
24
+ // Bulk API
25
+ const lines: string[] = []
26
+ for (const model of models) {
27
+ const id = String(this.resolveKey(model))
28
+ lines.push(JSON.stringify({ index: { _index: indexName, _id: id } }))
29
+ lines.push(JSON.stringify(this.resolveData(model)))
30
+ }
31
+
32
+ await this.request('POST', '/_bulk', lines.join('\n') + '\n', 'application/x-ndjson')
33
+ }
34
+
35
+ async delete(models: any[]): Promise<void> {
36
+ if (models.length === 0) return
37
+ const indexName = this.resolveIndexName(models[0])
38
+
39
+ const lines: string[] = []
40
+ for (const model of models) {
41
+ const id = String(this.resolveKey(model))
42
+ lines.push(JSON.stringify({ delete: { _index: indexName, _id: id } }))
43
+ }
44
+
45
+ await this.request('POST', '/_bulk', lines.join('\n') + '\n', 'application/x-ndjson')
46
+ }
47
+
48
+ async search(builder: SearchBuilder): Promise<SearchResult> {
49
+ const indexName = this.resolveModelIndexName(builder.model)
50
+ const body = this.buildSearchBody(builder)
51
+
52
+ const res = await this.request('POST', `/${indexName}/_search`, body)
53
+ const hits = res.hits?.hits ?? []
54
+
55
+ return {
56
+ raw: res,
57
+ keys: hits.map((h: any) => h._id),
58
+ total: res.hits?.total?.value ?? res.hits?.total ?? 0,
59
+ }
60
+ }
61
+
62
+ async paginate(builder: SearchBuilder, perPage: number, page: number): Promise<SearchResult> {
63
+ const indexName = this.resolveModelIndexName(builder.model)
64
+ const body = this.buildSearchBody(builder)
65
+ body.size = perPage
66
+ body.from = (page - 1) * perPage
67
+
68
+ const res = await this.request('POST', `/${indexName}/_search`, body)
69
+ const hits = res.hits?.hits ?? []
70
+
71
+ return {
72
+ raw: res,
73
+ keys: hits.map((h: any) => h._id),
74
+ total: res.hits?.total?.value ?? res.hits?.total ?? 0,
75
+ }
76
+ }
77
+
78
+ async flush(indexName: string): Promise<void> {
79
+ try {
80
+ await this.request('POST', `/${indexName}/_delete_by_query`, {
81
+ query: { match_all: {} },
82
+ })
83
+ } catch {
84
+ // Index may not exist
85
+ }
86
+ }
87
+
88
+ async createIndex(name: string, options?: Record<string, any>): Promise<void> {
89
+ const body = options ? { settings: options.settings, mappings: options.mappings } : {}
90
+ await this.request('PUT', `/${name}`, body)
91
+ }
92
+
93
+ async deleteIndex(name: string): Promise<void> {
94
+ try {
95
+ await this.request('DELETE', `/${name}`)
96
+ } catch {
97
+ // Index may not exist
98
+ }
99
+ }
100
+
101
+ private buildSearchBody(builder: SearchBuilder): Record<string, any> {
102
+ const body: Record<string, any> = {}
103
+ const must: any[] = []
104
+ const filter: any[] = []
105
+
106
+ // Full-text query
107
+ if (builder.query) {
108
+ must.push({ multi_match: { query: builder.query, type: 'best_fields', fuzziness: 'AUTO' } })
109
+ }
110
+
111
+ // Where clauses as term filters
112
+ for (const { field, value } of builder.wheres) {
113
+ filter.push({ term: { [field]: value } })
114
+ }
115
+ for (const { field, values } of builder.whereIns) {
116
+ filter.push({ terms: { [field]: values } })
117
+ }
118
+
119
+ if (must.length > 0 || filter.length > 0) {
120
+ body.query = { bool: {} }
121
+ if (must.length > 0) body.query.bool.must = must
122
+ if (filter.length > 0) body.query.bool.filter = filter
123
+ } else {
124
+ body.query = { match_all: {} }
125
+ }
126
+
127
+ // Sorting
128
+ if (builder.orders.length > 0) {
129
+ body.sort = builder.orders.map((o) => ({ [o.column]: { order: o.direction } }))
130
+ }
131
+
132
+ // Limit / offset
133
+ const limit = builder.getLimit()
134
+ if (limit !== null) body.size = limit
135
+ const offset = builder.getOffset()
136
+ if (offset !== null) body.from = offset
137
+
138
+ return body
139
+ }
140
+
141
+ private async request(method: string, path: string, body?: any, contentType?: string): Promise<any> {
142
+ const headers: Record<string, string> = {}
143
+
144
+ if (contentType) {
145
+ headers['Content-Type'] = contentType
146
+ } else if (body) {
147
+ headers['Content-Type'] = 'application/json'
148
+ }
149
+
150
+ if (this.apiKey) {
151
+ headers['Authorization'] = `ApiKey ${this.apiKey}`
152
+ } else if (this.username && this.password) {
153
+ headers['Authorization'] = `Basic ${btoa(`${this.username}:${this.password}`)}`
154
+ }
155
+
156
+ const res = await fetch(`${this.host}${path}`, {
157
+ method,
158
+ headers,
159
+ body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
160
+ })
161
+
162
+ if (!res.ok) {
163
+ const text = await res.text()
164
+ throw new SearchError(`Elasticsearch API error: ${res.status} ${text}`, { status: res.status })
165
+ }
166
+
167
+ const ct = res.headers.get('content-type')
168
+ if (ct?.includes('application/json')) return res.json()
169
+ return {}
170
+ }
171
+
172
+ private resolveIndexName(model: any): string {
173
+ const MC = model.constructor
174
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
175
+ }
176
+
177
+ private resolveModelIndexName(MC: any): string {
178
+ return typeof MC.searchableAs === 'function' ? MC.searchableAs() : MC.table ?? MC.name.toLowerCase() + 's'
179
+ }
180
+
181
+ private resolveKey(model: any): string | number {
182
+ return typeof model.searchableKey === 'function' ? model.searchableKey() : model.getAttribute?.('id') ?? model.id
183
+ }
184
+
185
+ private resolveData(model: any): Record<string, any> {
186
+ return typeof model.toSearchableArray === 'function' ? model.toSearchableArray() : { ...model.attributes }
187
+ }
188
+ }