@strav/search 0.4.31 → 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.
- 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/package.json
CHANGED
|
@@ -1,32 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.31",
|
|
4
|
+
"description": "Strav search module — unified full-text search abstraction over Meilisearch, Typesense, and Postgres FTS, plus an in-process Memory driver. Ships a `searchable()` Repository mixin and console commands.",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".": "./src/index.ts"
|
|
9
|
-
"./*": "./src/*.ts"
|
|
10
|
-
},
|
|
11
|
-
"strav": {
|
|
12
|
-
"commands": "src/commands"
|
|
9
|
+
".": "./src/index.ts"
|
|
13
10
|
},
|
|
14
11
|
"files": [
|
|
15
|
-
"src
|
|
16
|
-
"
|
|
17
|
-
"package.json",
|
|
18
|
-
"tsconfig.json"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
19
14
|
],
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.14"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
24
20
|
},
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@strav/cli": "1.0.0-alpha.31",
|
|
23
|
+
"@strav/database": "1.0.0-alpha.31",
|
|
24
|
+
"@strav/kernel": "1.0.0-alpha.31"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@types/bun": ">=1.3.14"
|
|
28
28
|
},
|
|
29
|
-
"devDependencies":
|
|
30
|
-
"commander": "^14.0.3"
|
|
31
|
-
}
|
|
29
|
+
"devDependencies": null
|
|
32
30
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SearchConsoleProvider } from './search_console_provider.ts'
|
|
2
|
+
export { SearchFlush } from './search_flush.ts'
|
|
3
|
+
export { SearchImport } from './search_import.ts'
|
|
4
|
+
export { SearchList } from './search_list.ts'
|
|
5
|
+
export { SearchReindex } from './search_reindex.ts'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SearchConsoleProvider` — declares the search console commands.
|
|
3
|
+
*
|
|
4
|
+
* Apps add it to `bootstrap/providers.ts` alongside
|
|
5
|
+
* `SearchProvider`. Separate provider (mirrors
|
|
6
|
+
* `RagConsoleProvider` and `QueueConsoleProvider`) so apps
|
|
7
|
+
* that don't use the CLI don't pay the cost of resolving the
|
|
8
|
+
* commands at boot.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ConsoleProvider } from '@strav/cli'
|
|
12
|
+
import { SearchFlush } from './search_flush.ts'
|
|
13
|
+
import { SearchImport } from './search_import.ts'
|
|
14
|
+
import { SearchList } from './search_list.ts'
|
|
15
|
+
import { SearchReindex } from './search_reindex.ts'
|
|
16
|
+
|
|
17
|
+
export class SearchConsoleProvider extends ConsoleProvider {
|
|
18
|
+
override readonly name = 'console.search'
|
|
19
|
+
override readonly commands = [SearchFlush, SearchImport, SearchList, SearchReindex] as const
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav search:flush <index> [--driver=name] [--force]` —
|
|
3
|
+
* drop every document in an index on the active (or named)
|
|
4
|
+
* search engine.
|
|
5
|
+
*
|
|
6
|
+
* Use cases:
|
|
7
|
+
*
|
|
8
|
+
* - Wiping a corrupted index before re-import.
|
|
9
|
+
* - Cleaning up a dev / staging environment.
|
|
10
|
+
* - Recovering after a settings change.
|
|
11
|
+
*
|
|
12
|
+
* The command confirms before running unless `--force` is set.
|
|
13
|
+
* Doesn't touch the source data — apps re-import afterward via
|
|
14
|
+
* `search:import` (or `repo.importAll()` in their own code).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
18
|
+
import { SearchManager } from '../search_manager.ts'
|
|
19
|
+
|
|
20
|
+
export class SearchFlush extends Command {
|
|
21
|
+
static signature = 'search:flush {index} {--driver=} {--force}'
|
|
22
|
+
static description =
|
|
23
|
+
'Delete every document in an index (on the active or --driver= named engine).'
|
|
24
|
+
static providers = ['config', 'logger', 'search']
|
|
25
|
+
|
|
26
|
+
override async execute({ args, flags }: ExecuteArgs): Promise<number> {
|
|
27
|
+
const index = args.index as string
|
|
28
|
+
const driverName =
|
|
29
|
+
typeof flags.driver === 'string' && flags.driver.length > 0 ? flags.driver : undefined
|
|
30
|
+
|
|
31
|
+
const manager = this.app.resolve(SearchManager)
|
|
32
|
+
const fullIndex = manager.indexName(index)
|
|
33
|
+
const driverLabel = driverName ?? manager.config.default
|
|
34
|
+
|
|
35
|
+
if (flags.force !== true) {
|
|
36
|
+
const ok = await this.confirm(
|
|
37
|
+
`Delete every document in index "${fullIndex}" on driver "${driverLabel}"? This is irreversible.`,
|
|
38
|
+
)
|
|
39
|
+
if (!ok) {
|
|
40
|
+
this.info('Aborted.')
|
|
41
|
+
return ExitCode.Success
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await manager.engine(driverName).flush(fullIndex)
|
|
46
|
+
this.success(`Flushed index "${fullIndex}" on driver "${driverLabel}".`)
|
|
47
|
+
return ExitCode.Success
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav search:import {name?} [--all] [--batch=500]` —
|
|
3
|
+
* walk a registered searchable repository and upsert every row
|
|
4
|
+
* into its search index.
|
|
5
|
+
*
|
|
6
|
+
* Apps register repos at boot:
|
|
7
|
+
*
|
|
8
|
+
* const registry = app.resolve(SearchableRegistry)
|
|
9
|
+
* registry.register('articles', ArticleRepository)
|
|
10
|
+
*
|
|
11
|
+
* Then:
|
|
12
|
+
*
|
|
13
|
+
* bun strav search:import articles # one repo
|
|
14
|
+
* bun strav search:import --all # every registered repo
|
|
15
|
+
*
|
|
16
|
+
* The repo class must implement `importAll(batchSize?)` and
|
|
17
|
+
* `createIndex()` — the `searchable()` mixin provides both. The
|
|
18
|
+
* command calls `createIndex()` first so a fresh deploy doesn't
|
|
19
|
+
* have to remember to run it separately.
|
|
20
|
+
*
|
|
21
|
+
* Long-running on large corpora — apps that need cron-driven or
|
|
22
|
+
* queued imports ship a custom command pointing at the same
|
|
23
|
+
* `importAll` method.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
27
|
+
import { SearchError } from '../search_error.ts'
|
|
28
|
+
import { SearchableRegistry } from '../searchable_registry.ts'
|
|
29
|
+
|
|
30
|
+
export class SearchImport extends Command {
|
|
31
|
+
static signature = 'search:import {name?} {--all} {--batch=500}'
|
|
32
|
+
static description =
|
|
33
|
+
'Re-import one registered searchable repository (or every one with --all).'
|
|
34
|
+
static providers = ['config', 'logger', 'search', 'database']
|
|
35
|
+
|
|
36
|
+
override async execute({ args, flags }: ExecuteArgs): Promise<number> {
|
|
37
|
+
const registry = this.app.resolve(SearchableRegistry)
|
|
38
|
+
const batchSize = parseBatch(flags.batch)
|
|
39
|
+
|
|
40
|
+
if (flags.all === true) {
|
|
41
|
+
const names = registry.names()
|
|
42
|
+
if (names.length === 0) {
|
|
43
|
+
this.warn(
|
|
44
|
+
'No searchables registered. Call `registry.register(name, Repo)` from a service provider first.',
|
|
45
|
+
)
|
|
46
|
+
return ExitCode.Success
|
|
47
|
+
}
|
|
48
|
+
let total = 0
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
const processed = await this.importOne(registry, name, batchSize)
|
|
51
|
+
total += processed
|
|
52
|
+
}
|
|
53
|
+
this.success(
|
|
54
|
+
`Imported ${total} rows across ${names.length} repositor${names.length === 1 ? 'y' : 'ies'}.`,
|
|
55
|
+
)
|
|
56
|
+
return ExitCode.Success
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const name = args.name
|
|
60
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
61
|
+
this.error(
|
|
62
|
+
'search:import requires a repository name, or --all to import every registered repository.',
|
|
63
|
+
)
|
|
64
|
+
this.info(`Registered: ${registry.names().join(', ') || '(none)'}`)
|
|
65
|
+
return ExitCode.UsageError
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const processed = await this.importOne(registry, name, batchSize)
|
|
70
|
+
this.success(`Imported ${processed} rows in "${name}".`)
|
|
71
|
+
return ExitCode.Success
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof SearchError) {
|
|
74
|
+
this.error(err.message)
|
|
75
|
+
this.info(`Registered: ${registry.names().join(', ') || '(none)'}`)
|
|
76
|
+
return ExitCode.GenericFailure
|
|
77
|
+
}
|
|
78
|
+
throw err
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async importOne(
|
|
83
|
+
registry: SearchableRegistry,
|
|
84
|
+
name: string,
|
|
85
|
+
batchSize: number,
|
|
86
|
+
): Promise<number> {
|
|
87
|
+
this.info(`Importing "${name}"…`)
|
|
88
|
+
const repo = this.app.resolve(registry.resolve(name))
|
|
89
|
+
await repo.createIndex()
|
|
90
|
+
const processed = await repo.importAll(batchSize)
|
|
91
|
+
this.info(` ${processed} rows.`)
|
|
92
|
+
return processed
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseBatch(raw: unknown): number {
|
|
97
|
+
if (typeof raw === 'number' && raw > 0) return Math.floor(raw)
|
|
98
|
+
if (typeof raw === 'string') {
|
|
99
|
+
const n = Number.parseInt(raw, 10)
|
|
100
|
+
if (Number.isFinite(n) && n > 0) return n
|
|
101
|
+
}
|
|
102
|
+
return 500
|
|
103
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav search:list` — print the configured search drivers
|
|
3
|
+
* + registered searchable repositories.
|
|
4
|
+
*
|
|
5
|
+
* Diagnostic only — no mutations. Useful for verifying that
|
|
6
|
+
* `config/search.ts` parses correctly and that the registered
|
|
7
|
+
* driver / repository names match what's expected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
11
|
+
import { SearchManager } from '../search_manager.ts'
|
|
12
|
+
import { SearchableRegistry } from '../searchable_registry.ts'
|
|
13
|
+
|
|
14
|
+
export class SearchList extends Command {
|
|
15
|
+
static signature = 'search:list'
|
|
16
|
+
static description = 'List configured search drivers + registered searchable repositories.'
|
|
17
|
+
static providers = ['config', 'logger', 'search']
|
|
18
|
+
|
|
19
|
+
override async execute(_args: ExecuteArgs): Promise<number> {
|
|
20
|
+
const manager = this.app.resolve(SearchManager)
|
|
21
|
+
const registry = this.app.resolve(SearchableRegistry)
|
|
22
|
+
const config = manager.config
|
|
23
|
+
|
|
24
|
+
this.info(`Default driver: ${config.default}`)
|
|
25
|
+
if (config.prefix) this.info(`Index prefix: ${config.prefix}`)
|
|
26
|
+
|
|
27
|
+
this.info('')
|
|
28
|
+
this.info('Drivers:')
|
|
29
|
+
for (const [name, driver] of Object.entries(config.drivers)) {
|
|
30
|
+
const flag = name === config.default ? ' (default)' : ''
|
|
31
|
+
this.info(` ${name}${flag}: driver=${driver.driver}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.info('')
|
|
35
|
+
const names = registry.names()
|
|
36
|
+
if (names.length === 0) {
|
|
37
|
+
this.info('Registered searchables: (none)')
|
|
38
|
+
this.info(
|
|
39
|
+
' Register repositories at boot: `app.resolve(SearchableRegistry).register(name, Repo)`',
|
|
40
|
+
)
|
|
41
|
+
} else {
|
|
42
|
+
this.info(`Registered searchables: ${names.join(', ')}`)
|
|
43
|
+
}
|
|
44
|
+
return ExitCode.Success
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav search:reindex {name?} [--all] [--batch=500]` —
|
|
3
|
+
* flush a registered searchable repository's index, then walk
|
|
4
|
+
* every row and re-upsert.
|
|
5
|
+
*
|
|
6
|
+
* Use after changing `searchableSettings()` (e.g., adding a new
|
|
7
|
+
* `searchableAttribute` or `filterableAttribute`) — for the
|
|
8
|
+
* Postgres FTS driver, existing rows keep their old `fts` until
|
|
9
|
+
* they're rewritten. The combined "flush + import" is what apps
|
|
10
|
+
* usually want; pure imports are available via `search:import`.
|
|
11
|
+
*
|
|
12
|
+
* Both `flushIndex()` + `importAll()` come from the
|
|
13
|
+
* `searchable()` mixin.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
17
|
+
import { SearchError } from '../search_error.ts'
|
|
18
|
+
import { SearchableRegistry } from '../searchable_registry.ts'
|
|
19
|
+
|
|
20
|
+
export class SearchReindex extends Command {
|
|
21
|
+
static signature = 'search:reindex {name?} {--all} {--batch=500}'
|
|
22
|
+
static description =
|
|
23
|
+
'Flush + re-import one registered searchable repository (or every one with --all).'
|
|
24
|
+
static providers = ['config', 'logger', 'search', 'database']
|
|
25
|
+
|
|
26
|
+
override async execute({ args, flags }: ExecuteArgs): Promise<number> {
|
|
27
|
+
const registry = this.app.resolve(SearchableRegistry)
|
|
28
|
+
const batchSize = parseBatch(flags.batch)
|
|
29
|
+
|
|
30
|
+
if (flags.all === true) {
|
|
31
|
+
const names = registry.names()
|
|
32
|
+
if (names.length === 0) {
|
|
33
|
+
this.warn(
|
|
34
|
+
'No searchables registered. Call `registry.register(name, Repo)` from a service provider first.',
|
|
35
|
+
)
|
|
36
|
+
return ExitCode.Success
|
|
37
|
+
}
|
|
38
|
+
let total = 0
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
const processed = await this.reindexOne(registry, name, batchSize)
|
|
41
|
+
total += processed
|
|
42
|
+
}
|
|
43
|
+
this.success(
|
|
44
|
+
`Re-indexed ${total} rows across ${names.length} repositor${names.length === 1 ? 'y' : 'ies'}.`,
|
|
45
|
+
)
|
|
46
|
+
return ExitCode.Success
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const name = args.name
|
|
50
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
51
|
+
this.error(
|
|
52
|
+
'search:reindex requires a repository name, or --all to re-index every registered repository.',
|
|
53
|
+
)
|
|
54
|
+
this.info(`Registered: ${registry.names().join(', ') || '(none)'}`)
|
|
55
|
+
return ExitCode.UsageError
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const processed = await this.reindexOne(registry, name, batchSize)
|
|
60
|
+
this.success(`Re-indexed ${processed} rows in "${name}".`)
|
|
61
|
+
return ExitCode.Success
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof SearchError) {
|
|
64
|
+
this.error(err.message)
|
|
65
|
+
this.info(`Registered: ${registry.names().join(', ') || '(none)'}`)
|
|
66
|
+
return ExitCode.GenericFailure
|
|
67
|
+
}
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async reindexOne(
|
|
73
|
+
registry: SearchableRegistry,
|
|
74
|
+
name: string,
|
|
75
|
+
batchSize: number,
|
|
76
|
+
): Promise<number> {
|
|
77
|
+
this.info(`Re-indexing "${name}"…`)
|
|
78
|
+
const repo = this.app.resolve(registry.resolve(name))
|
|
79
|
+
await repo.createIndex()
|
|
80
|
+
await repo.flushIndex()
|
|
81
|
+
const processed = await repo.importAll(batchSize)
|
|
82
|
+
this.info(` ${processed} rows.`)
|
|
83
|
+
return processed
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseBatch(raw: unknown): number {
|
|
88
|
+
if (typeof raw === 'number' && raw > 0) return Math.floor(raw)
|
|
89
|
+
if (typeof raw === 'string') {
|
|
90
|
+
const n = Number.parseInt(raw, 10)
|
|
91
|
+
if (Number.isFinite(n) && n > 0) return n
|
|
92
|
+
}
|
|
93
|
+
return 500
|
|
94
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MeilisearchDriver` — REST client for [Meilisearch](https://meilisearch.com).
|
|
3
|
+
*
|
|
4
|
+
* Speaks the v1 HTTP API via raw `fetch()` — no `meilisearch-js`
|
|
5
|
+
* SDK dependency. The driver mirrors the engine contract: every
|
|
6
|
+
* write awaits the underlying Meili task before returning so
|
|
7
|
+
* subsequent reads see the result. Apps that want fire-and-forget
|
|
8
|
+
* indexing drop down to the raw engine and inspect `taskUid`s
|
|
9
|
+
* themselves.
|
|
10
|
+
*
|
|
11
|
+
* Config (under `config.search.drivers.<name>`):
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* meili: {
|
|
15
|
+
* driver: 'meilisearch',
|
|
16
|
+
* host: 'http://localhost:7700', // or split host/port/protocol
|
|
17
|
+
* apiKey: env('MEILI_KEY'),
|
|
18
|
+
* taskTimeoutMs: 10_000, // optional, defaults 10s
|
|
19
|
+
* taskPollIntervalMs: 25, // optional, defaults 25ms
|
|
20
|
+
* awaitTasks: true, // optional, defaults true
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Error mapping:
|
|
25
|
+
*
|
|
26
|
+
* - `404` on writes / reads → `IndexNotFoundError`.
|
|
27
|
+
* - All other non-2xx + task `failed` → `SearchQueryError`
|
|
28
|
+
* with the Meili payload on `cause`.
|
|
29
|
+
*
|
|
30
|
+
* @see https://www.meilisearch.com/docs/reference/api/overview
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { IndexNotFoundError, SearchQueryError } from '../../search_error.ts'
|
|
34
|
+
import type { SearchEngine } from '../../search_engine.ts'
|
|
35
|
+
import type {
|
|
36
|
+
DriverConfig,
|
|
37
|
+
IndexSettings,
|
|
38
|
+
SearchDocument,
|
|
39
|
+
SearchHit,
|
|
40
|
+
SearchOptions,
|
|
41
|
+
SearchResult,
|
|
42
|
+
} from '../../types.ts'
|
|
43
|
+
|
|
44
|
+
interface TaskResponse {
|
|
45
|
+
taskUid?: number
|
|
46
|
+
uid?: number
|
|
47
|
+
status?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface TaskDetail {
|
|
51
|
+
uid: number
|
|
52
|
+
status: 'enqueued' | 'processing' | 'succeeded' | 'failed' | 'canceled'
|
|
53
|
+
error?: unknown
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class MeilisearchDriver implements SearchEngine {
|
|
57
|
+
readonly name = 'meilisearch'
|
|
58
|
+
|
|
59
|
+
private readonly baseUrl: string
|
|
60
|
+
private readonly apiKey: string
|
|
61
|
+
private readonly awaitTasks: boolean
|
|
62
|
+
private readonly taskTimeoutMs: number
|
|
63
|
+
private readonly taskPollIntervalMs: number
|
|
64
|
+
|
|
65
|
+
constructor(config: DriverConfig) {
|
|
66
|
+
this.baseUrl = resolveBaseUrl(config)
|
|
67
|
+
this.apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''
|
|
68
|
+
this.awaitTasks = config.awaitTasks !== false
|
|
69
|
+
this.taskTimeoutMs = typeof config.taskTimeoutMs === 'number' ? config.taskTimeoutMs : 10_000
|
|
70
|
+
this.taskPollIntervalMs =
|
|
71
|
+
typeof config.taskPollIntervalMs === 'number' ? config.taskPollIntervalMs : 25
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Index lifecycle ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async createIndex(index: string, settings: IndexSettings = {}): Promise<void> {
|
|
77
|
+
// Meili's `POST /indexes` errors when the index already exists.
|
|
78
|
+
// We swallow the conflict so `createIndex` stays idempotent.
|
|
79
|
+
const created = await this.request<TaskResponse>('POST', '/indexes', {
|
|
80
|
+
body: { uid: index, primaryKey: settings.primaryKey ?? 'id' },
|
|
81
|
+
acceptStatuses: [409],
|
|
82
|
+
})
|
|
83
|
+
if (created && typeof created.taskUid === 'number') await this.awaitTask(created.taskUid)
|
|
84
|
+
|
|
85
|
+
const payload: Record<string, unknown> = {}
|
|
86
|
+
if (settings.searchableAttributes) payload.searchableAttributes = settings.searchableAttributes
|
|
87
|
+
if (settings.displayedAttributes) payload.displayedAttributes = settings.displayedAttributes
|
|
88
|
+
if (settings.filterableAttributes) payload.filterableAttributes = settings.filterableAttributes
|
|
89
|
+
if (settings.sortableAttributes) payload.sortableAttributes = settings.sortableAttributes
|
|
90
|
+
|
|
91
|
+
if (Object.keys(payload).length === 0) return
|
|
92
|
+
const task = await this.request<TaskResponse>(
|
|
93
|
+
'PATCH',
|
|
94
|
+
`/indexes/${encodeURIComponent(index)}/settings`,
|
|
95
|
+
{ body: payload },
|
|
96
|
+
)
|
|
97
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async deleteIndex(index: string): Promise<void> {
|
|
101
|
+
const task = await this.request<TaskResponse>(
|
|
102
|
+
'DELETE',
|
|
103
|
+
`/indexes/${encodeURIComponent(index)}`,
|
|
104
|
+
{ acceptStatuses: [404] },
|
|
105
|
+
)
|
|
106
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async flush(index: string): Promise<void> {
|
|
110
|
+
const task = await this.request<TaskResponse>(
|
|
111
|
+
'DELETE',
|
|
112
|
+
`/indexes/${encodeURIComponent(index)}/documents`,
|
|
113
|
+
{ acceptStatuses: [404] },
|
|
114
|
+
)
|
|
115
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Writes ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
async upsert(
|
|
121
|
+
index: string,
|
|
122
|
+
id: string | number,
|
|
123
|
+
document: Record<string, unknown>,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const task = await this.request<TaskResponse>(
|
|
126
|
+
'POST',
|
|
127
|
+
`/indexes/${encodeURIComponent(index)}/documents`,
|
|
128
|
+
{ body: [{ id, ...document }] },
|
|
129
|
+
)
|
|
130
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async upsertMany(index: string, documents: readonly SearchDocument[]): Promise<void> {
|
|
134
|
+
if (documents.length === 0) return
|
|
135
|
+
const task = await this.request<TaskResponse>(
|
|
136
|
+
'POST',
|
|
137
|
+
`/indexes/${encodeURIComponent(index)}/documents`,
|
|
138
|
+
{ body: documents },
|
|
139
|
+
)
|
|
140
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async delete(index: string, id: string | number): Promise<void> {
|
|
144
|
+
const task = await this.request<TaskResponse>(
|
|
145
|
+
'DELETE',
|
|
146
|
+
`/indexes/${encodeURIComponent(index)}/documents/${encodeURIComponent(String(id))}`,
|
|
147
|
+
{ acceptStatuses: [404] },
|
|
148
|
+
)
|
|
149
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async deleteMany(index: string, ids: readonly (string | number)[]): Promise<void> {
|
|
153
|
+
if (ids.length === 0) return
|
|
154
|
+
const task = await this.request<TaskResponse>(
|
|
155
|
+
'POST',
|
|
156
|
+
`/indexes/${encodeURIComponent(index)}/documents/delete-batch`,
|
|
157
|
+
{ body: ids },
|
|
158
|
+
)
|
|
159
|
+
if (task && typeof task.taskUid === 'number') await this.awaitTask(task.taskUid)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Reads ──────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
async search(index: string, query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
165
|
+
const perPage = options.perPage ?? 20
|
|
166
|
+
const page = options.page ?? 1
|
|
167
|
+
|
|
168
|
+
const body: Record<string, unknown> = {
|
|
169
|
+
q: query,
|
|
170
|
+
limit: perPage,
|
|
171
|
+
offset: (page - 1) * perPage,
|
|
172
|
+
}
|
|
173
|
+
if (options.filter !== undefined) {
|
|
174
|
+
if (typeof options.filter !== 'object' || options.filter === null || Array.isArray(options.filter)) {
|
|
175
|
+
throw new SearchQueryError(
|
|
176
|
+
'MeilisearchDriver: `filter` must be a flat key/value object. Engine-native strings are not portable.',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
body.filter = buildMeiliFilter(options.filter)
|
|
180
|
+
}
|
|
181
|
+
if (options.sort) body.sort = options.sort
|
|
182
|
+
if (options.attributesToRetrieve) body.attributesToRetrieve = options.attributesToRetrieve
|
|
183
|
+
if (options.attributesToHighlight) body.attributesToHighlight = options.attributesToHighlight
|
|
184
|
+
|
|
185
|
+
const data = await this.request<{
|
|
186
|
+
hits?: Array<Record<string, unknown> & { _formatted?: Record<string, string> }>
|
|
187
|
+
estimatedTotalHits?: number
|
|
188
|
+
totalHits?: number
|
|
189
|
+
processingTimeMs?: number
|
|
190
|
+
}>('POST', `/indexes/${encodeURIComponent(index)}/search`, { body })
|
|
191
|
+
|
|
192
|
+
const hits: SearchHit[] = (data?.hits ?? []).map((hit) => {
|
|
193
|
+
const { _formatted, ...document } = hit
|
|
194
|
+
const out: SearchHit = { document }
|
|
195
|
+
if (_formatted) out.highlights = _formatted
|
|
196
|
+
return out
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
hits,
|
|
201
|
+
totalHits: data?.estimatedTotalHits ?? data?.totalHits ?? 0,
|
|
202
|
+
page,
|
|
203
|
+
perPage,
|
|
204
|
+
...(typeof data?.processingTimeMs === 'number' ? { processingTimeMs: data.processingTimeMs } : {}),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── HTTP / task polling ───────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
private async awaitTask(uid: number): Promise<void> {
|
|
211
|
+
if (!this.awaitTasks) return
|
|
212
|
+
const deadline = Date.now() + this.taskTimeoutMs
|
|
213
|
+
while (true) {
|
|
214
|
+
const task = await this.request<TaskDetail>('GET', `/tasks/${uid}`, {})
|
|
215
|
+
if (!task) throw new SearchQueryError(`MeilisearchDriver: task ${uid} returned no payload.`)
|
|
216
|
+
if (task.status === 'succeeded') return
|
|
217
|
+
if (task.status === 'failed' || task.status === 'canceled') {
|
|
218
|
+
throw new SearchQueryError(`MeilisearchDriver: task ${uid} ${task.status}.`, {
|
|
219
|
+
context: { uid, status: task.status },
|
|
220
|
+
cause: task.error,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
if (Date.now() > deadline) {
|
|
224
|
+
throw new SearchQueryError(
|
|
225
|
+
`MeilisearchDriver: task ${uid} did not finish within ${this.taskTimeoutMs}ms.`,
|
|
226
|
+
{ context: { uid, status: task.status } },
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
await sleep(this.taskPollIntervalMs)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async request<T>(
|
|
234
|
+
method: string,
|
|
235
|
+
path: string,
|
|
236
|
+
options: { body?: unknown; acceptStatuses?: number[] } = {},
|
|
237
|
+
): Promise<T | null> {
|
|
238
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
239
|
+
if (this.apiKey) headers.authorization = `Bearer ${this.apiKey}`
|
|
240
|
+
|
|
241
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
242
|
+
method,
|
|
243
|
+
headers,
|
|
244
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
if (response.ok || options.acceptStatuses?.includes(response.status)) {
|
|
248
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') return null
|
|
249
|
+
const text = await response.text()
|
|
250
|
+
return text.length === 0 ? null : (JSON.parse(text) as T)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const text = await response.text()
|
|
254
|
+
const payload = safeJson(text)
|
|
255
|
+
if (response.status === 404) {
|
|
256
|
+
const index = extractIndexName(path)
|
|
257
|
+
throw new IndexNotFoundError(index ?? '<unknown>', this.name)
|
|
258
|
+
}
|
|
259
|
+
throw new SearchQueryError(
|
|
260
|
+
`MeilisearchDriver: ${method} ${path} returned ${response.status}.`,
|
|
261
|
+
{ context: { status: response.status, body: payload ?? text }, cause: payload ?? text },
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function resolveBaseUrl(config: DriverConfig): string {
|
|
267
|
+
// Accept either a complete `host` URL or split protocol/host/port.
|
|
268
|
+
const host = typeof config.host === 'string' ? config.host : 'localhost'
|
|
269
|
+
if (host.startsWith('http://') || host.startsWith('https://')) {
|
|
270
|
+
return host.replace(/\/+$/, '')
|
|
271
|
+
}
|
|
272
|
+
const protocol = typeof config.protocol === 'string' ? config.protocol : 'http'
|
|
273
|
+
const port = typeof config.port === 'number' ? config.port : 7700
|
|
274
|
+
return `${protocol}://${host}:${port}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildMeiliFilter(filter: Record<string, unknown>): string {
|
|
278
|
+
return Object.entries(filter)
|
|
279
|
+
.map(([key, value]) => {
|
|
280
|
+
if (Array.isArray(value)) {
|
|
281
|
+
return `${key} IN [${value.map((v) => JSON.stringify(v)).join(', ')}]`
|
|
282
|
+
}
|
|
283
|
+
return `${key} = ${JSON.stringify(value)}`
|
|
284
|
+
})
|
|
285
|
+
.join(' AND ')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function extractIndexName(path: string): string | undefined {
|
|
289
|
+
const match = /\/indexes\/([^/?]+)/.exec(path)
|
|
290
|
+
return match ? decodeURIComponent(match[1]!) : undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function safeJson(text: string): unknown {
|
|
294
|
+
if (text.length === 0) return undefined
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(text)
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sleep(ms: number): Promise<void> {
|
|
303
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
304
|
+
}
|