@strav/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/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # @stravigor/search
2
+
3
+ Full-text search for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Unified API for Meilisearch, Typesense, and Algolia with automatic indexing via model events.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @stravigor/search
9
+ bun strav install search
10
+ ```
11
+
12
+ Requires `@stravigor/core` as a peer dependency.
13
+
14
+ ## Setup
15
+
16
+ ```ts
17
+ import { SearchProvider } from '@stravigor/search'
18
+
19
+ app.use(new SearchProvider())
20
+ ```
21
+
22
+ ## Searchable Models
23
+
24
+ ```ts
25
+ import { searchable } from '@stravigor/search'
26
+
27
+ class Post extends searchable(BaseModel) {
28
+ static searchableAs = 'posts'
29
+
30
+ toSearchableDocument() {
31
+ return { id: this.id, title: this.title, body: this.body }
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```ts
39
+ import { search } from '@stravigor/search'
40
+
41
+ // Search
42
+ const results = await search.query('posts', 'hello world', {
43
+ filters: 'status = published',
44
+ limit: 20,
45
+ })
46
+
47
+ // Manual indexing
48
+ await search.index('posts', [{ id: 1, title: 'Hello' }])
49
+ await search.delete('posts', ['1'])
50
+ ```
51
+
52
+ ## Drivers
53
+
54
+ - **Meilisearch** — fast, typo-tolerant, self-hosted
55
+ - **Typesense** — open-source, instant search
56
+ - **Algolia** — hosted search-as-a-service
57
+ - **Null** — no-op driver for testing
58
+
59
+ ## CLI
60
+
61
+ ```bash
62
+ bun strav search:import # Import all searchable models
63
+ bun strav search:flush # Flush all indexes
64
+ ```
65
+
66
+ ## Documentation
67
+
68
+ See the full [Search guide](../../guides/search.md).
69
+
70
+ ## License
71
+
72
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@strav/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": [
15
+ "src/",
16
+ "stubs/",
17
+ "package.json",
18
+ "tsconfig.json"
19
+ ],
20
+ "peerDependencies": {
21
+ "@strav/kernel": "0.1.0",
22
+ "@strav/database": "0.1.0",
23
+ "@strav/cli": "0.1.0"
24
+ },
25
+ "scripts": {
26
+ "test": "bun test tests/",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "devDependencies": {
30
+ "commander": "^14.0.3"
31
+ }
32
+ }
@@ -0,0 +1,41 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '@stravigor/cli'
4
+ import { BaseModel } from '@stravigor/database'
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/cli'
4
+ import { BaseModel } from '@stravigor/database'
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: { chunk: string }) => {
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/kernel'
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/kernel'
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
+ }