@strav/search 0.4.30 → 1.0.0-alpha.31

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.
Files changed (59) hide show
  1. package/package.json +20 -22
  2. package/src/console/index.ts +5 -0
  3. package/src/console/search_console_provider.ts +20 -0
  4. package/src/console/search_flush.ts +49 -0
  5. package/src/console/search_import.ts +103 -0
  6. package/src/console/search_list.ts +46 -0
  7. package/src/console/search_reindex.ts +94 -0
  8. package/src/drivers/meilisearch/meilisearch_driver.ts +304 -0
  9. package/src/drivers/memory/memory_driver.ts +344 -0
  10. package/src/drivers/postgres/apply_search_migration.ts +74 -0
  11. package/src/drivers/postgres/postgres_fts_driver.ts +493 -135
  12. package/src/drivers/typesense/typesense_driver.ts +345 -0
  13. package/src/index.ts +50 -39
  14. package/src/search_engine.ts +40 -25
  15. package/src/search_error.ts +86 -0
  16. package/src/search_manager.ts +112 -94
  17. package/src/search_provider.ts +68 -6
  18. package/src/searchable.ts +173 -160
  19. package/src/searchable_registry.ts +61 -0
  20. package/src/types.ts +59 -49
  21. package/README.md +0 -191
  22. package/src/commands/search_flush.ts +0 -41
  23. package/src/commands/search_import.ts +0 -43
  24. package/src/commands/search_optimize.ts +0 -52
  25. package/src/commands/search_rebuild.ts +0 -73
  26. package/src/drivers/algolia_driver.ts +0 -170
  27. package/src/drivers/embedded/embedded_driver.ts +0 -136
  28. package/src/drivers/embedded/engine/field_registry.ts +0 -97
  29. package/src/drivers/embedded/engine/fts_query_builder.ts +0 -184
  30. package/src/drivers/embedded/engine/query_compiler.ts +0 -134
  31. package/src/drivers/embedded/engine/schema.ts +0 -99
  32. package/src/drivers/embedded/engine/snippet_formatter.ts +0 -29
  33. package/src/drivers/embedded/engine/sqlite_engine.ts +0 -255
  34. package/src/drivers/embedded/engine/typo_expander.ts +0 -138
  35. package/src/drivers/embedded/errors.ts +0 -15
  36. package/src/drivers/embedded/filters/filter_compiler.ts +0 -136
  37. package/src/drivers/embedded/index.ts +0 -3
  38. package/src/drivers/embedded/storage/paths.ts +0 -23
  39. package/src/drivers/embedded/types.ts +0 -34
  40. package/src/drivers/meilisearch_driver.ts +0 -150
  41. package/src/drivers/null_driver.ts +0 -27
  42. package/src/drivers/postgres/engine/field_registry.ts +0 -116
  43. package/src/drivers/postgres/engine/fts_query_builder.ts +0 -105
  44. package/src/drivers/postgres/engine/pg_engine.ts +0 -300
  45. package/src/drivers/postgres/engine/query_compiler.ts +0 -165
  46. package/src/drivers/postgres/engine/schema.ts +0 -187
  47. package/src/drivers/postgres/engine/snippet_formatter.ts +0 -31
  48. package/src/drivers/postgres/engine/typo_expander.ts +0 -131
  49. package/src/drivers/postgres/errors.ts +0 -33
  50. package/src/drivers/postgres/filters/filter_compiler.ts +0 -138
  51. package/src/drivers/postgres/index.ts +0 -14
  52. package/src/drivers/postgres/rebuild/rebuild_inplace.ts +0 -113
  53. package/src/drivers/postgres/storage/identifiers.ts +0 -46
  54. package/src/drivers/postgres/types.ts +0 -53
  55. package/src/drivers/typesense_driver.ts +0 -229
  56. package/src/errors.ts +0 -18
  57. package/src/helpers.ts +0 -120
  58. package/stubs/config/search.ts +0 -57
  59. package/tsconfig.json +0 -5
@@ -1,14 +0,0 @@
1
- export { PostgresFtsDriver } from './postgres_fts_driver.ts'
2
- export type {
3
- PostgresFtsConfig,
4
- TypoToleranceMode,
5
- TypoToleranceSettings,
6
- PgIndexSettings,
7
- } from './types.ts'
8
- export {
9
- PostgresFtsError,
10
- MissingExtensionError,
11
- RebuildRequiredError,
12
- UnsupportedFilterError,
13
- MissingConnectionError,
14
- } from './errors.ts'
@@ -1,113 +0,0 @@
1
- import type { SQL } from 'bun'
2
- import type { FieldRegistry } from '../engine/field_registry.ts'
3
- import { indexTableName, quoteLiteral, quoteIdent } from '../storage/identifiers.ts'
4
- import { RebuildRequiredError } from '../errors.ts'
5
-
6
- /** Tier boundaries for rebuild strategy selection. */
7
- const TIER1_MAX = 100_000
8
- const TIER2_MAX = 10_000_000
9
-
10
- /** Batch size for tier-2 batched UPDATE. */
11
- const BATCH_SIZE = 5_000
12
-
13
- export interface RebuildOptions {
14
- /** If true, run REINDEX on the GIN index after the rebuild. Default true. */
15
- reindex?: boolean
16
- /** Per-batch sleep in milliseconds (tier 2 only). Default 50. */
17
- pauseMs?: number
18
- /** Optional progress callback fired after each batch. */
19
- onProgress?: (done: number, total: number) => void
20
- }
21
-
22
- /**
23
- * Rebuild an index's `fts` column in place using the current registry's
24
- * language + weight scheme. Picks tier by row count:
25
- * - < 100k → single UPDATE
26
- * - 100k-10M → batched UPDATE with pauses
27
- * - > 10M → RebuildRequiredError (defer to v1.1 swap strategy)
28
- */
29
- export async function rebuildInPlace(
30
- sql: SQL,
31
- schema: string,
32
- index: string,
33
- registry: FieldRegistry,
34
- options: RebuildOptions = {}
35
- ): Promise<{ tier: 1 | 2; rows: number; elapsedMs: number }> {
36
- const reindex = options.reindex ?? true
37
- const pauseMs = options.pauseMs ?? 50
38
- const table = indexTableName(schema, index)
39
- const start = performance.now()
40
-
41
- const countRows = (await sql.unsafe(
42
- `SELECT COUNT(*)::bigint AS n FROM ${table}`
43
- )) as Array<{ n: string | number }>
44
- const total = Number(countRows[0]?.n ?? 0)
45
-
46
- if (total > TIER2_MAX) {
47
- throw new RebuildRequiredError(
48
- `Index "${index}" has ${total} rows (>${TIER2_MAX}). ` +
49
- `In-place / batched rebuild is unsafe at this scale. ` +
50
- `Use the v1.1 dual-table swap strategy (not yet shipped).`
51
- )
52
- }
53
-
54
- const ftsExpr = buildSetFtsExpression(registry)
55
-
56
- if (total <= TIER1_MAX) {
57
- await sql.unsafe(`UPDATE ${table} SET fts = ${ftsExpr}`)
58
- if (reindex) await reindexGin(sql, schema, index)
59
- return { tier: 1, rows: total, elapsedMs: Math.round(performance.now() - start) }
60
- }
61
-
62
- // Tier 2: batched update keyed by id, with pauses for autovacuum.
63
- let cursor: string | null = null
64
- let done = 0
65
-
66
- while (true) {
67
- const where = cursor === null ? '' : `WHERE id > $1`
68
- const params = cursor === null ? [] : [cursor]
69
- const batch = (await sql.unsafe(
70
- `SELECT id FROM ${table} ${where} ORDER BY id LIMIT ${BATCH_SIZE}`,
71
- params
72
- )) as Array<{ id: string }>
73
- if (batch.length === 0) break
74
-
75
- const ids = batch.map(r => r.id)
76
- const placeholders = ids.map((_, i) => `$${i + 1}`).join(', ')
77
- await sql.unsafe(
78
- `UPDATE ${table} SET fts = ${ftsExpr} WHERE id IN (${placeholders})`,
79
- ids
80
- )
81
-
82
- done += batch.length
83
- cursor = ids[ids.length - 1]!
84
- options.onProgress?.(done, total)
85
- if (pauseMs > 0) await new Promise(r => setTimeout(r, pauseMs))
86
- }
87
-
88
- if (reindex) await reindexGin(sql, schema, index)
89
- return { tier: 2, rows: total, elapsedMs: Math.round(performance.now() - start) }
90
- }
91
-
92
- function buildSetFtsExpression(registry: FieldRegistry): string {
93
- const lang = `${quoteLiteral(registry.language)}::regconfig`
94
- if (registry.usesDefaultTextColumn) {
95
- return (
96
- `setweight(to_tsvector(${lang}, ` +
97
- `(SELECT coalesce(string_agg(value, ' '), '') FROM jsonb_each_text(doc))), 'A')`
98
- )
99
- }
100
- return registry.searchable
101
- .map(attr => {
102
- const weight = registry.weights.get(attr)!
103
- return (
104
- `setweight(to_tsvector(${lang}, coalesce(doc->>${quoteLiteral(attr)}, '')), '${weight}')`
105
- )
106
- })
107
- .join(' || ')
108
- }
109
-
110
- async function reindexGin(sql: SQL, schema: string, index: string): Promise<void> {
111
- const ginName = `${quoteIdent(schema)}.${quoteIdent(`search_${index}_fts_gin`)}`
112
- await sql.unsafe(`REINDEX INDEX ${ginName}`)
113
- }
@@ -1,46 +0,0 @@
1
- import { PostgresFtsError } from '../errors.ts'
2
-
3
- const PG_IDENT_MAX = 63
4
-
5
- /**
6
- * Quote a Postgres identifier (schema, table, column). Throws on identifiers
7
- * containing NUL or exceeding the 63-byte name limit.
8
- */
9
- export function quoteIdent(name: string): string {
10
- if (name.includes('\0')) throw new PostgresFtsError(`Invalid identifier: contains NUL byte.`)
11
- if (Buffer.byteLength(name, 'utf8') > PG_IDENT_MAX) {
12
- throw new PostgresFtsError(
13
- `Identifier "${name}" exceeds Postgres' ${PG_IDENT_MAX}-byte limit.`
14
- )
15
- }
16
- return `"${name.replace(/"/g, '""')}"`
17
- }
18
-
19
- /** Quote a single-quoted SQL string literal (used inside DDL options). */
20
- export function quoteLiteral(value: string): string {
21
- return `'${value.replace(/'/g, "''")}'`
22
- }
23
-
24
- /** Build the schema-qualified table name for a search index. */
25
- export function indexTableName(schema: string, index: string): string {
26
- return `${quoteIdent(schema)}.${quoteIdent(`search_${index}`)}`
27
- }
28
-
29
- /** Terms-dictionary table name for a given index. */
30
- export function termsTableName(schema: string, index: string): string {
31
- return `${quoteIdent(schema)}.${quoteIdent(`search_${index}_terms`)}`
32
- }
33
-
34
- /** Meta table — single shared table; rows keyed by (index_name, key). */
35
- export function metaTableName(schema: string): string {
36
- return `${quoteIdent(schema)}.${quoteIdent('_meta')}`
37
- }
38
-
39
- /** Bare (unquoted) tablename — useful for pg_class lookups. */
40
- export function bareIndexTable(index: string): string {
41
- return `search_${index}`
42
- }
43
-
44
- export function bareTermsTable(index: string): string {
45
- return `search_${index}_terms`
46
- }
@@ -1,53 +0,0 @@
1
- import type { SQL } from 'bun'
2
- import type { DriverConfig, IndexSettings } from '../../types.ts'
3
-
4
- export type TypoToleranceMode = 'off' | 'auto'
5
-
6
- export interface TypoToleranceSettings {
7
- /** Minimum token length to consider for fuzzy expansion (default 4). */
8
- minTokenLength?: number
9
- /** Maximum Levenshtein distance to tolerate (default 1; 2 is supported but slower). */
10
- maxDistance?: number
11
- /** pg_trgm similarity threshold (default 0.4). Higher = stricter. */
12
- similarity?: number
13
- }
14
-
15
- export interface PostgresFtsConfig extends DriverConfig {
16
- driver: string
17
- /**
18
- * Bun SQL connection. If omitted, the driver falls back to
19
- * `Database.raw` from `@strav/database` (must be bootstrapped first).
20
- */
21
- connection?: SQL
22
- /** Postgres schema for index tables. Default 'strav_search'. */
23
- schema?: string
24
- /** Default text-search configuration ('english', 'french', ...). */
25
- language?: string
26
- /** Typo tolerance: 'off' disables; 'auto' uses defaults; object for fine-grained control. */
27
- typoTolerance?: TypoToleranceMode | TypoToleranceSettings
28
- /** GIN index tuning. */
29
- gin?: {
30
- /** Default false — better tail latency for read-heavy search. */
31
- fastupdate?: boolean
32
- }
33
- /** Per-search-transaction work_mem hint, e.g. '64MB'. Set to null/empty to skip. */
34
- workMem?: string | null
35
- }
36
-
37
- /** Resolved typo tolerance settings (after defaults applied). */
38
- export interface ResolvedTypoTolerance {
39
- enabled: boolean
40
- minTokenLength: number
41
- maxDistance: number
42
- similarity: number
43
- }
44
-
45
- /** Per-index extra settings stored in `_meta`. */
46
- export interface PgIndexSettings extends IndexSettings {
47
- language?: string
48
- /**
49
- * Per-attribute weight tier override. Keys must appear in `searchableAttributes`.
50
- * Values: 'A' | 'B' | 'C' | 'D'. Default = positional (1st=A, 2nd=B, ...).
51
- */
52
- weights?: Record<string, 'A' | 'B' | 'C' | 'D'>
53
- }
@@ -1,229 +0,0 @@
1
- import { ExternalServiceError } from '@strav/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
- * 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
- let schema: any
73
- try {
74
- schema = await this.request('GET', `/collections/${encodeURIComponent(index)}`)
75
- } catch {
76
- // Collection doesn't exist — nothing to flush
77
- return
78
- }
79
- await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
80
- await this.request('POST', '/collections', {
81
- name: schema.name,
82
- fields: schema.fields,
83
- })
84
- }
85
-
86
- async deleteIndex(index: string): Promise<void> {
87
- await this.request('DELETE', `/collections/${encodeURIComponent(index)}`)
88
- }
89
-
90
- async createIndex(index: string, options?: IndexSettings): Promise<void> {
91
- const fields: Record<string, unknown>[] = []
92
-
93
- if (options?.searchableAttributes) {
94
- for (const attr of options.searchableAttributes) {
95
- fields.push({ name: attr, type: 'string', facet: false })
96
- }
97
- }
98
- if (options?.filterableAttributes) {
99
- for (const attr of options.filterableAttributes) {
100
- if (!fields.some(f => f.name === attr)) {
101
- fields.push({ name: attr, type: 'string', facet: true })
102
- }
103
- }
104
- }
105
- if (options?.sortableAttributes) {
106
- for (const attr of options.sortableAttributes) {
107
- if (!fields.some(f => f.name === attr)) {
108
- fields.push({ name: attr, type: 'string', sort: true })
109
- }
110
- }
111
- }
112
-
113
- // Always include a wildcard field so untyped fields are auto-detected
114
- if (fields.length === 0) {
115
- fields.push({ name: '.*', type: 'auto' })
116
- }
117
-
118
- await this.request('POST', '/collections', {
119
- name: index,
120
- fields,
121
- })
122
- }
123
-
124
- async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
125
- const perPage = options?.perPage ?? 20
126
- const page = options?.page ?? 1
127
-
128
- const params = new URLSearchParams({
129
- q: query,
130
- query_by: '*',
131
- per_page: String(perPage),
132
- page: String(page),
133
- })
134
-
135
- if (options?.filter) {
136
- params.set(
137
- 'filter_by',
138
- typeof options.filter === 'string' ? options.filter : this.buildFilter(options.filter)
139
- )
140
- }
141
- if (options?.sort) {
142
- params.set('sort_by', options.sort.map(s => s.replace(':', ':')).join(','))
143
- }
144
- if (options?.attributesToRetrieve) {
145
- params.set('include_fields', options.attributesToRetrieve.join(','))
146
- }
147
- if (options?.attributesToHighlight) {
148
- params.set('highlight_fields', options.attributesToHighlight.join(','))
149
- }
150
-
151
- const data = await this.request(
152
- 'GET',
153
- `/collections/${encodeURIComponent(index)}/documents/search?${params.toString()}`
154
- )
155
-
156
- return {
157
- hits: (data.hits ?? []).map(
158
- (hit: any): SearchHit => ({
159
- document: hit.document,
160
- highlights: hit.highlights?.reduce(
161
- (acc: Record<string, string>, h: any) => {
162
- if (h.field && h.snippet) acc[h.field] = h.snippet
163
- return acc
164
- },
165
- {} as Record<string, string>
166
- ),
167
- })
168
- ),
169
- totalHits: data.found ?? 0,
170
- page,
171
- perPage,
172
- processingTimeMs: data.search_time_ms,
173
- }
174
- }
175
-
176
- // ── Private ──────────────────────────────────────────────────────────────
177
-
178
- private headers(): Record<string, string> {
179
- return {
180
- 'content-type': 'application/json',
181
- 'x-typesense-api-key': this.apiKey,
182
- }
183
- }
184
-
185
- private async request(method: string, path: string, body?: unknown): Promise<any> {
186
- const response = await fetch(`${this.baseUrl}${path}`, {
187
- method,
188
- headers: this.headers(),
189
- body: body !== undefined ? JSON.stringify(body) : undefined,
190
- })
191
-
192
- if (!response.ok) {
193
- const text = await response.text()
194
- throw new ExternalServiceError('Typesense', response.status, text)
195
- }
196
-
197
- if (response.status === 204 || response.headers.get('content-length') === '0') return null
198
- return response.json()
199
- }
200
-
201
- private async rawRequest(
202
- method: string,
203
- path: string,
204
- body: string,
205
- contentType: string
206
- ): Promise<void> {
207
- const response = await fetch(`${this.baseUrl}${path}`, {
208
- method,
209
- headers: { 'content-type': contentType, 'x-typesense-api-key': this.apiKey },
210
- body,
211
- })
212
-
213
- if (!response.ok) {
214
- const text = await response.text()
215
- throw new ExternalServiceError('Typesense', response.status, text)
216
- }
217
- }
218
-
219
- private buildFilter(filter: Record<string, unknown>): string {
220
- return Object.entries(filter)
221
- .map(([key, value]) => {
222
- if (Array.isArray(value)) {
223
- return `${key}:[${value.map(v => String(v)).join(',')}]`
224
- }
225
- return `${key}:=${value}`
226
- })
227
- .join(' && ')
228
- }
229
- }
package/src/errors.ts DELETED
@@ -1,18 +0,0 @@
1
- import { StravError } from '@strav/kernel'
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 DELETED
@@ -1,120 +0,0 @@
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
- SearchScope,
10
- } from './types.ts'
11
-
12
- /**
13
- * Search helper — the primary convenience API.
14
- *
15
- * @example
16
- * import { search } from '@strav/search'
17
- *
18
- * const results = await search.query('articles', 'typescript generics')
19
- * await search.upsert('articles', 1, { title: 'Guide', body: '...' })
20
- */
21
- export const search = {
22
- /** Get the underlying engine instance (default or named). */
23
- engine(name?: string): SearchEngine {
24
- return SearchManager.engine(name)
25
- },
26
-
27
- /** Register a custom search driver factory. */
28
- extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
29
- SearchManager.extend(name, factory)
30
- },
31
-
32
- /** Perform a full-text search query. */
33
- query(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
34
- return SearchManager.engine().search(SearchManager.indexName(index), query, options)
35
- },
36
-
37
- /** Add or update a single document. */
38
- upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void> {
39
- return SearchManager.engine().upsert(SearchManager.indexName(index), id, document)
40
- },
41
-
42
- /** Add or update multiple documents. */
43
- upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
44
- return SearchManager.engine().upsertMany(SearchManager.indexName(index), documents)
45
- },
46
-
47
- /** Remove a document from the index. */
48
- delete(index: string, id: string | number): Promise<void> {
49
- return SearchManager.engine().delete(SearchManager.indexName(index), id)
50
- },
51
-
52
- /** Remove multiple documents from the index. */
53
- deleteMany(index: string, ids: Array<string | number>): Promise<void> {
54
- return SearchManager.engine().deleteMany(SearchManager.indexName(index), ids)
55
- },
56
-
57
- /** Remove all documents from an index. */
58
- flush(index: string): Promise<void> {
59
- return SearchManager.engine().flush(SearchManager.indexName(index))
60
- },
61
-
62
- /** Create an index with optional settings. */
63
- createIndex(index: string, options?: IndexSettings): Promise<void> {
64
- return SearchManager.engine().createIndex(SearchManager.indexName(index), options)
65
- },
66
-
67
- /** Delete an entire index. */
68
- deleteIndex(index: string): Promise<void> {
69
- return SearchManager.engine().deleteIndex(SearchManager.indexName(index))
70
- },
71
-
72
- /**
73
- * Return a tenant-scoped wrapper of this helper. All index names
74
- * resolved through it are namespaced as `${prefix}t${tenantId}_${name}`,
75
- * giving two tenants on the same shared engine independent indexes.
76
- *
77
- * @example
78
- * await search.for({ tenantId: 42 }).upsert('articles', 1, { … })
79
- * await search.for({ tenantId: 42 }).query('articles', 'lookup')
80
- *
81
- * Apps that don't need multi-tenant isolation skip `.for()` and call
82
- * the top-level helpers directly.
83
- */
84
- for(scope: SearchScope): ScopedSearch {
85
- return makeScoped(scope)
86
- },
87
- }
88
-
89
- // ── Scoped helper ────────────────────────────────────────────────────────
90
-
91
- export interface ScopedSearch {
92
- query(index: string, query: string, options?: SearchOptions): Promise<SearchResult>
93
- upsert(index: string, id: string | number, document: Record<string, unknown>): Promise<void>
94
- upsertMany(index: string, documents: SearchDocument[]): Promise<void>
95
- delete(index: string, id: string | number): Promise<void>
96
- deleteMany(index: string, ids: Array<string | number>): Promise<void>
97
- flush(index: string): Promise<void>
98
- createIndex(index: string, options?: IndexSettings): Promise<void>
99
- deleteIndex(index: string): Promise<void>
100
- }
101
-
102
- function makeScoped(scope: SearchScope): ScopedSearch {
103
- return {
104
- query: (index, query, options) =>
105
- SearchManager.engine().search(SearchManager.indexName(index, scope), query, options),
106
- upsert: (index, id, document) =>
107
- SearchManager.engine().upsert(SearchManager.indexName(index, scope), id, document),
108
- upsertMany: (index, documents) =>
109
- SearchManager.engine().upsertMany(SearchManager.indexName(index, scope), documents),
110
- delete: (index, id) =>
111
- SearchManager.engine().delete(SearchManager.indexName(index, scope), id),
112
- deleteMany: (index, ids) =>
113
- SearchManager.engine().deleteMany(SearchManager.indexName(index, scope), ids),
114
- flush: index => SearchManager.engine().flush(SearchManager.indexName(index, scope)),
115
- createIndex: (index, options) =>
116
- SearchManager.engine().createIndex(SearchManager.indexName(index, scope), options),
117
- deleteIndex: index =>
118
- SearchManager.engine().deleteIndex(SearchManager.indexName(index, scope)),
119
- }
120
- }
@@ -1,57 +0,0 @@
1
- import { env } from '@strav/kernel'
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
- embedded: {
33
- driver: 'embedded',
34
- /** Directory holding per-index `.sqlite` files. Use ':memory:' for tests. */
35
- path: env('SEARCH_PATH', './storage/search'),
36
- /** SQLite synchronous pragma. NORMAL is crash-safe with sub-second-of-writes loss. */
37
- synchronous: env('SEARCH_SYNCHRONOUS', 'NORMAL'),
38
- /** Typo tolerance: 'off' to disable, 'auto' for defaults, or { minTokenLength, maxDistance }. */
39
- typoTolerance: env('SEARCH_TYPO_TOLERANCE', 'auto'),
40
- },
41
-
42
- postgres: {
43
- driver: 'postgres-fts',
44
- /** Postgres schema for index tables. */
45
- schema: env('SEARCH_PG_SCHEMA', 'strav_search'),
46
- /** Default text-search configuration ('english', 'french', ...). */
47
- language: env('SEARCH_PG_LANGUAGE', 'english'),
48
- /** Typo tolerance: 'off' to disable, 'auto' for defaults, or { minTokenLength, maxDistance, similarity }. */
49
- typoTolerance: env('SEARCH_TYPO_TOLERANCE', 'auto'),
50
- /** Per-search work_mem hint. Set to null/empty to skip. */
51
- workMem: env('SEARCH_PG_WORK_MEM', '64MB'),
52
- /** GIN index tuning — fastupdate=off improves read tail latency. */
53
- gin: { fastupdate: false },
54
- // `connection` (Bun SQL instance) is resolved from @strav/database at runtime.
55
- },
56
- },
57
- }
package/tsconfig.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*.ts"],
4
- "exclude": ["node_modules", "tests"]
5
- }