@strav/search 0.4.31 → 1.0.0-alpha.33
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 +20 -22
- package/src/console/index.ts +5 -0
- package/src/console/search_console_provider.ts +20 -0
- package/src/console/search_flush.ts +49 -0
- package/src/console/search_import.ts +103 -0
- package/src/console/search_list.ts +46 -0
- package/src/console/search_reindex.ts +94 -0
- package/src/drivers/meilisearch/meilisearch_driver.ts +304 -0
- package/src/drivers/memory/memory_driver.ts +344 -0
- package/src/drivers/postgres/apply_search_migration.ts +74 -0
- package/src/drivers/postgres/postgres_fts_driver.ts +493 -135
- package/src/drivers/typesense/typesense_driver.ts +345 -0
- package/src/index.ts +50 -39
- package/src/search_engine.ts +40 -25
- package/src/search_error.ts +86 -0
- package/src/search_manager.ts +112 -94
- package/src/search_provider.ts +68 -6
- package/src/searchable.ts +173 -160
- package/src/searchable_registry.ts +61 -0
- package/src/types.ts +59 -49
- package/README.md +0 -191
- package/src/commands/search_flush.ts +0 -41
- package/src/commands/search_import.ts +0 -43
- package/src/commands/search_optimize.ts +0 -52
- package/src/commands/search_rebuild.ts +0 -73
- package/src/drivers/algolia_driver.ts +0 -170
- package/src/drivers/embedded/embedded_driver.ts +0 -136
- package/src/drivers/embedded/engine/field_registry.ts +0 -97
- package/src/drivers/embedded/engine/fts_query_builder.ts +0 -184
- package/src/drivers/embedded/engine/query_compiler.ts +0 -134
- package/src/drivers/embedded/engine/schema.ts +0 -99
- package/src/drivers/embedded/engine/snippet_formatter.ts +0 -29
- package/src/drivers/embedded/engine/sqlite_engine.ts +0 -255
- package/src/drivers/embedded/engine/typo_expander.ts +0 -138
- package/src/drivers/embedded/errors.ts +0 -15
- package/src/drivers/embedded/filters/filter_compiler.ts +0 -136
- package/src/drivers/embedded/index.ts +0 -3
- package/src/drivers/embedded/storage/paths.ts +0 -23
- package/src/drivers/embedded/types.ts +0 -34
- package/src/drivers/meilisearch_driver.ts +0 -150
- package/src/drivers/null_driver.ts +0 -27
- package/src/drivers/postgres/engine/field_registry.ts +0 -116
- package/src/drivers/postgres/engine/fts_query_builder.ts +0 -105
- package/src/drivers/postgres/engine/pg_engine.ts +0 -300
- package/src/drivers/postgres/engine/query_compiler.ts +0 -165
- package/src/drivers/postgres/engine/schema.ts +0 -187
- package/src/drivers/postgres/engine/snippet_formatter.ts +0 -31
- package/src/drivers/postgres/engine/typo_expander.ts +0 -131
- package/src/drivers/postgres/errors.ts +0 -33
- package/src/drivers/postgres/filters/filter_compiler.ts +0 -138
- package/src/drivers/postgres/index.ts +0 -14
- package/src/drivers/postgres/rebuild/rebuild_inplace.ts +0 -113
- package/src/drivers/postgres/storage/identifiers.ts +0 -46
- package/src/drivers/postgres/types.ts +0 -53
- package/src/drivers/typesense_driver.ts +0 -229
- package/src/errors.ts +0 -18
- package/src/helpers.ts +0 -120
- package/stubs/config/search.ts +0 -57
- package/tsconfig.json +0 -5
package/README.md
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
# @strav/search
|
|
2
|
-
|
|
3
|
-
Full-text search for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Unified API across several engines — including a built-in `embedded` driver that runs in-process with no external service to deploy.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bun add @strav/search
|
|
9
|
-
bun strav install search
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Requires `@strav/core` as a peer dependency.
|
|
13
|
-
|
|
14
|
-
## Setup
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
import { SearchProvider } from '@strav/search'
|
|
18
|
-
|
|
19
|
-
app.use(new SearchProvider())
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Searchable Models
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
import { searchable } from '@strav/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 '@strav/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
|
-
- **Embedded** — in-process SQLite FTS5, zero deps, recommended for self-host / SMB (~50k–500k docs)
|
|
55
|
-
- **Postgres FTS** — tsvector + GIN + pg_trgm, drop-in upgrade for higher volume (1M–100M docs)
|
|
56
|
-
- **Meilisearch** — fast, typo-tolerant, self-hosted
|
|
57
|
-
- **Typesense** — open-source, instant search
|
|
58
|
-
- **Algolia** — hosted search-as-a-service
|
|
59
|
-
- **Null** — no-op driver for testing
|
|
60
|
-
|
|
61
|
-
### Embedded driver
|
|
62
|
-
|
|
63
|
-
Runs entirely inside your app process using `bun:sqlite`'s FTS5 engine — no Meilisearch/Typesense container to run. Each index is a single `.sqlite` file in the configured data directory.
|
|
64
|
-
|
|
65
|
-
Features:
|
|
66
|
-
|
|
67
|
-
- BM25 ranking with per-field weights (via `searchableAttributes` ordering)
|
|
68
|
-
- Prefix (`type*`), phrase (`"quick brown fox"`), negation (`-foo`), required (`+foo`)
|
|
69
|
-
- Porter stemmer for English morphology
|
|
70
|
-
- Typo tolerance (Levenshtein-1) on the fly, configurable
|
|
71
|
-
- Highlighted snippets with `<mark>` tags
|
|
72
|
-
- Object-form filters with equality, `in`, and comparison operators
|
|
73
|
-
|
|
74
|
-
Limitations for v1:
|
|
75
|
-
|
|
76
|
-
- English stemming only (other languages are tokenised but not stemmed)
|
|
77
|
-
- One writer at a time per index file (SQLite WAL — concurrent reads are fine)
|
|
78
|
-
- Object-form filters only; raw SQL filter strings are rejected
|
|
79
|
-
- Index settings changes require recreating the index
|
|
80
|
-
|
|
81
|
-
Configuration:
|
|
82
|
-
|
|
83
|
-
```ts
|
|
84
|
-
// config/search.ts
|
|
85
|
-
embedded: {
|
|
86
|
-
driver: 'embedded',
|
|
87
|
-
path: env('SEARCH_PATH', './storage/search'), // directory of .sqlite files
|
|
88
|
-
synchronous: 'NORMAL', // 'OFF' | 'NORMAL' | 'FULL'
|
|
89
|
-
typoTolerance: 'auto', // 'off' | 'auto' | { minTokenLength, maxDistance }
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
Select it as the default with `SEARCH_DRIVER=embedded`.
|
|
94
|
-
|
|
95
|
-
### Postgres FTS driver
|
|
96
|
-
|
|
97
|
-
Higher-volume tier (1M–100M docs per index) backed by your existing Postgres. Same `SearchEngine` interface as the embedded driver — drop-in swap by changing one config line.
|
|
98
|
-
|
|
99
|
-
Features:
|
|
100
|
-
|
|
101
|
-
- BM25-shaped ranking via `ts_rank_cd(fts, q, 1 | 32)` with per-field weights (`A`/`B`/`C`/`D`)
|
|
102
|
-
- `websearch_to_tsquery` Google-style queries plus prefix (`type*`)
|
|
103
|
-
- Multi-language stemming via Postgres text-search configurations (`english`, `french`, ...) — set per index
|
|
104
|
-
- Levenshtein-near typo tolerance via `pg_trgm` + optional `fuzzystrmatch`
|
|
105
|
-
- `<mark>`-highlighted snippets via `ts_headline`, computed only on the top-K to keep latency bounded
|
|
106
|
-
- Object-form filters with `eq`/`neq`/`gt`/`gte`/`lt`/`lte`/`in`/`nin` against generated typed columns
|
|
107
|
-
- One table per index in a dedicated `strav_search` schema (auto-created)
|
|
108
|
-
|
|
109
|
-
Requirements:
|
|
110
|
-
|
|
111
|
-
- Postgres ≥ 15
|
|
112
|
-
- `pg_trgm` extension (auto-`CREATE EXTENSION IF NOT EXISTS` on first use; superuser or owner privilege)
|
|
113
|
-
- `fuzzystrmatch` is optional — if present, typo expansion re-ranks trigram candidates with bounded Levenshtein for higher precision
|
|
114
|
-
|
|
115
|
-
Configuration:
|
|
116
|
-
|
|
117
|
-
```ts
|
|
118
|
-
postgres: {
|
|
119
|
-
driver: 'postgres-fts',
|
|
120
|
-
// Optional: pass a Bun SQL handle. Falls back to @strav/database's Database.raw.
|
|
121
|
-
// connection: db.sql,
|
|
122
|
-
schema: env('SEARCH_PG_SCHEMA', 'strav_search'),
|
|
123
|
-
language: env('SEARCH_PG_LANGUAGE', 'english'),
|
|
124
|
-
typoTolerance: env('SEARCH_TYPO_TOLERANCE', 'auto'),
|
|
125
|
-
workMem: env('SEARCH_PG_WORK_MEM', '64MB'),
|
|
126
|
-
gin: { fastupdate: false }, // better tail latency
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Select it with `SEARCH_DRIVER=postgres`.
|
|
131
|
-
|
|
132
|
-
Limitations for v1:
|
|
133
|
-
|
|
134
|
-
- Settings change (e.g. add a new searchable attribute) requires `bun strav search:rebuild <model>`. Tier picked by row count: in-place UPDATE under 100k, batched UPDATE up to 10M, dual-table swap deferred to v1.1 with a clear error above 10M.
|
|
135
|
-
- Adding a new `filterableAttribute` on an existing large table currently rewrites the whole heap (`ALTER TABLE ADD COLUMN ... GENERATED ... STORED`). Plan an offline window for big tables in v1.
|
|
136
|
-
- One language per index — mixed-locale indexes deferred.
|
|
137
|
-
- Object-form filters only; raw SQL filter strings rejected.
|
|
138
|
-
|
|
139
|
-
Ranking note: `ts_rank_cd` is BM25-*shaped* (length normalisation + bounded mapping), not strict BM25. For the size and shape of corpora the driver targets, the difference is small in practice; the embedded driver remains the answer when strict BM25 matters and the corpus fits.
|
|
140
|
-
|
|
141
|
-
Model example with per-field weights (column order determines BM25 weight — title first = highest):
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
class Ticket extends searchable(BaseModel) {
|
|
145
|
-
static searchableSettings() {
|
|
146
|
-
return {
|
|
147
|
-
searchableAttributes: ['subject', 'body'],
|
|
148
|
-
filterableAttributes: ['status', 'priority'],
|
|
149
|
-
sortableAttributes: ['priority', 'created_at'],
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
#### Replacing Postgres `tsvector`
|
|
156
|
-
|
|
157
|
-
If you've been using raw `tsvector` columns, the embedded driver gives you better ranking, typo tolerance, and highlighted snippets without adding a network service. The migration is roughly:
|
|
158
|
-
|
|
159
|
-
```ts
|
|
160
|
-
// Before: hand-rolled tsvector query
|
|
161
|
-
const rows = await db.sql`
|
|
162
|
-
SELECT id, subject, ts_rank_cd(fts, q) AS rank
|
|
163
|
-
FROM tickets, websearch_to_tsquery('english', ${q}) q
|
|
164
|
-
WHERE fts @@ q ORDER BY rank DESC LIMIT 20
|
|
165
|
-
`
|
|
166
|
-
|
|
167
|
-
// After: searchable() + embedded driver
|
|
168
|
-
const results = await Ticket.search(q, {
|
|
169
|
-
perPage: 20,
|
|
170
|
-
attributesToHighlight: ['subject', 'body'],
|
|
171
|
-
})
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
You run `bun strav search:import Ticket` once to populate the index, then model events keep it up to date.
|
|
175
|
-
|
|
176
|
-
## CLI
|
|
177
|
-
|
|
178
|
-
```bash
|
|
179
|
-
bun strav search:import <model> # Import all records for a model
|
|
180
|
-
bun strav search:flush <model> # Flush all documents from an index
|
|
181
|
-
bun strav search:optimize <model> # (embedded) Merge FTS5 segments; run periodically
|
|
182
|
-
bun strav search:rebuild <model> # (postgres) Recompute fts after settings change
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
## Documentation
|
|
186
|
-
|
|
187
|
-
See the full [Search guide](../../guides/search.md).
|
|
188
|
-
|
|
189
|
-
## License
|
|
190
|
-
|
|
191
|
-
MIT
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { Command } from 'commander'
|
|
2
|
-
import chalk from 'chalk'
|
|
3
|
-
import { bootstrap, shutdown } from '@strav/cli'
|
|
4
|
-
import { BaseModel } from '@strav/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
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import type { Command } from 'commander'
|
|
2
|
-
import chalk from 'chalk'
|
|
3
|
-
import { bootstrap, shutdown } from '@strav/cli'
|
|
4
|
-
import { BaseModel } from '@strav/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
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import type { Command } from 'commander'
|
|
2
|
-
import chalk from 'chalk'
|
|
3
|
-
import { bootstrap, shutdown } from '@strav/cli'
|
|
4
|
-
import { BaseModel } from '@strav/database'
|
|
5
|
-
import SearchManager from '../search_manager.ts'
|
|
6
|
-
import { EmbeddedDriver } from '../drivers/embedded/index.ts'
|
|
7
|
-
|
|
8
|
-
export function register(program: Command): void {
|
|
9
|
-
program
|
|
10
|
-
.command('search:optimize <model>')
|
|
11
|
-
.description("Merge FTS5 segments for a model's index (embedded driver only)")
|
|
12
|
-
.action(async (modelPath: 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?.searchableAs !== 'function') {
|
|
26
|
-
console.error(chalk.red(`Model "${modelPath}" does not use the searchable() mixin.`))
|
|
27
|
-
process.exit(1)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const indexName = SearchManager.indexName(ModelClass.searchableAs())
|
|
31
|
-
const engine = SearchManager.engine()
|
|
32
|
-
|
|
33
|
-
if (!(engine instanceof EmbeddedDriver)) {
|
|
34
|
-
console.error(
|
|
35
|
-
chalk.red(
|
|
36
|
-
`search:optimize is only meaningful for the embedded driver (current: ${engine.name}).`
|
|
37
|
-
)
|
|
38
|
-
)
|
|
39
|
-
process.exit(1)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
console.log(chalk.dim(`Optimizing "${indexName}"...`))
|
|
43
|
-
engine.optimize(indexName)
|
|
44
|
-
console.log(chalk.green(`Optimized "${indexName}".`))
|
|
45
|
-
} catch (err) {
|
|
46
|
-
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
47
|
-
process.exit(1)
|
|
48
|
-
} finally {
|
|
49
|
-
if (db) await shutdown(db)
|
|
50
|
-
}
|
|
51
|
-
})
|
|
52
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { Command } from 'commander'
|
|
2
|
-
import chalk from 'chalk'
|
|
3
|
-
import { bootstrap, shutdown } from '@strav/cli'
|
|
4
|
-
import { BaseModel } from '@strav/database'
|
|
5
|
-
import SearchManager from '../search_manager.ts'
|
|
6
|
-
import { PostgresFtsDriver } from '../drivers/postgres/index.ts'
|
|
7
|
-
|
|
8
|
-
export function register(program: Command): void {
|
|
9
|
-
program
|
|
10
|
-
.command('search:rebuild <model>')
|
|
11
|
-
.description("Recompute a model's fts column in place (postgres-fts driver only)")
|
|
12
|
-
.option('--no-reindex', "Skip the GIN REINDEX after the rebuild")
|
|
13
|
-
.option('--pause <ms>', 'Pause between batches in tier-2 mode (default 50)', '50')
|
|
14
|
-
.action(async (modelPath: string, options: { reindex: boolean; pause: string }) => {
|
|
15
|
-
let db
|
|
16
|
-
try {
|
|
17
|
-
const { db: database, config } = await bootstrap()
|
|
18
|
-
db = database
|
|
19
|
-
|
|
20
|
-
new BaseModel(db)
|
|
21
|
-
new SearchManager(config)
|
|
22
|
-
|
|
23
|
-
const resolved = require.resolve(`${process.cwd()}/${modelPath}`)
|
|
24
|
-
const module = await import(resolved)
|
|
25
|
-
const ModelClass = module.default ?? (Object.values(module)[0] as any)
|
|
26
|
-
|
|
27
|
-
if (typeof ModelClass?.searchableAs !== 'function') {
|
|
28
|
-
console.error(chalk.red(`Model "${modelPath}" does not use the searchable() mixin.`))
|
|
29
|
-
process.exit(1)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const indexName = SearchManager.indexName(ModelClass.searchableAs())
|
|
33
|
-
const engine = SearchManager.engine()
|
|
34
|
-
|
|
35
|
-
if (!(engine instanceof PostgresFtsDriver)) {
|
|
36
|
-
console.error(
|
|
37
|
-
chalk.red(
|
|
38
|
-
`search:rebuild is only meaningful for the postgres-fts driver (current: ${engine.name}).`
|
|
39
|
-
)
|
|
40
|
-
)
|
|
41
|
-
process.exit(1)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Make sure the engine knows about the model's settings (so rebuild
|
|
45
|
-
// computes fts with the right weights/language).
|
|
46
|
-
const settings = (ModelClass.searchableSettings?.() ?? undefined) as any
|
|
47
|
-
if (settings) await engine.createIndex(indexName, settings)
|
|
48
|
-
|
|
49
|
-
console.log(chalk.dim(`Rebuilding "${indexName}"...`))
|
|
50
|
-
const result = await engine.rebuild(indexName, {
|
|
51
|
-
reindex: options.reindex !== false,
|
|
52
|
-
pauseMs: Number(options.pause),
|
|
53
|
-
onProgress: (done, total) => {
|
|
54
|
-
const pct = total > 0 ? Math.round((done / total) * 100) : 100
|
|
55
|
-
process.stdout.write(`\r ${done}/${total} rows (${pct}%) `)
|
|
56
|
-
},
|
|
57
|
-
})
|
|
58
|
-
if (result.tier === 2) process.stdout.write('\n')
|
|
59
|
-
|
|
60
|
-
console.log(
|
|
61
|
-
chalk.green(
|
|
62
|
-
`Rebuilt ${result.rows} row(s) in "${indexName}" using tier-${result.tier} ` +
|
|
63
|
-
`strategy (${result.elapsedMs}ms).`
|
|
64
|
-
)
|
|
65
|
-
)
|
|
66
|
-
} catch (err) {
|
|
67
|
-
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
68
|
-
process.exit(1)
|
|
69
|
-
} finally {
|
|
70
|
-
if (db) await shutdown(db)
|
|
71
|
-
}
|
|
72
|
-
})
|
|
73
|
-
}
|
|
@@ -1,170 +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
|
-
* 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
|
-
}
|