@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
package/package.json CHANGED
@@ -1,32 +1,30 @@
1
1
  {
2
2
  "name": "@strav/search",
3
- "version": "0.4.30",
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
- "description": "Full-text search for the Strav framework",
6
- "license": "MIT",
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
- "stubs/",
17
- "package.json",
18
- "tsconfig.json"
12
+ "src",
13
+ "README.md"
19
14
  ],
20
- "peerDependencies": {
21
- "@strav/kernel": "0.4.30",
22
- "@strav/database": "0.4.30",
23
- "@strav/cli": "0.4.30"
15
+ "engines": {
16
+ "bun": ">=1.3.14"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
24
20
  },
25
- "scripts": {
26
- "test": "bun test tests/",
27
- "typecheck": "tsc --noEmit"
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
+ }