@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.
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,129 +1,147 @@
1
- import { inject, Configuration, ConfigurationError } from '@strav/kernel'
2
- import type { SearchEngine } from './search_engine.ts'
3
- import type { SearchConfig, DriverConfig, SearchScope } from './types.ts'
4
- import { MeilisearchDriver } from './drivers/meilisearch_driver.ts'
5
- import { TypesenseDriver } from './drivers/typesense_driver.ts'
6
- import { AlgoliaDriver } from './drivers/algolia_driver.ts'
7
- import { NullDriver } from './drivers/null_driver.ts'
8
- import { EmbeddedDriver } from './drivers/embedded/index.ts'
9
- import { PostgresFtsDriver } from './drivers/postgres/index.ts'
10
-
11
- @inject
12
- export default class SearchManager {
13
- private static _config: SearchConfig
14
- private static _engines = new Map<string, SearchEngine>()
15
- private static _extensions = new Map<string, (config: DriverConfig) => SearchEngine>()
1
+ /**
2
+ * `SearchManager` the facade apps use to talk to the
3
+ * configured full-text search engine(s).
4
+ *
5
+ * Three concept clusters:
6
+ *
7
+ * - **Engines.** Apps declare engines in `config.search.drivers`;
8
+ * the manager constructs them lazily on first `engine(name)`
9
+ * call. Custom drivers register via `manager.extend(name,
10
+ * factory)`.
11
+ *
12
+ * - **Index naming.** `manager.indexName(name)` prepends the
13
+ * configured `prefix` so apps can namespace per-env or
14
+ * per-app without rewriting their model code. The
15
+ * `searchable()` mixin always resolves through this so apps
16
+ * never embed raw index names in queries.
17
+ *
18
+ * - **Test hooks.** `useEngine(name, engine)` swaps an engine
19
+ * instance for the named slot — typically a `MemoryDriver`
20
+ * in unit tests against code that resolves
21
+ * `engine('meilisearch')`.
22
+ *
23
+ * Multitenancy: the V1 surface is single-tenant. The Postgres
24
+ * driver relies on `app.tenant_id` RLS (same hook
25
+ * `@strav/rag` / `@strav/database` use) for per-tenant
26
+ * isolation. Per-tenant index prefixing for Meili/Typesense
27
+ * lands in V1.1 if asked.
28
+ */
16
29
 
17
- constructor(config: Configuration) {
18
- SearchManager._config = {
19
- default: config.get('search.default', 'null') as string,
20
- prefix: config.get('search.prefix', '') as string,
21
- drivers: config.get('search.drivers', {}) as Record<string, DriverConfig>,
22
- }
23
- }
30
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import for the container path that will wire PostgresFtsDriver.
31
+ import { PostgresDatabase } from '@strav/database'
32
+ // biome-ignore lint/style/useImportType: Application value import for the container handle.
33
+ import { Application, inject } from '@strav/kernel'
34
+ import { MeilisearchDriver } from './drivers/meilisearch/meilisearch_driver.ts'
35
+ import { MemoryDriver } from './drivers/memory/memory_driver.ts'
36
+ import { PostgresFtsDriver } from './drivers/postgres/postgres_fts_driver.ts'
37
+ import { TypesenseDriver } from './drivers/typesense/typesense_driver.ts'
38
+ import { SearchConfigError } from './search_error.ts'
39
+ import type { SearchEngine } from './search_engine.ts'
40
+ import type { DriverConfig, SearchConfig } from './types.ts'
24
41
 
25
- static get config(): SearchConfig {
26
- if (!SearchManager._config) {
27
- throw new ConfigurationError(
28
- 'SearchManager not configured. Resolve it through the container first.'
29
- )
30
- }
31
- return SearchManager._config
32
- }
42
+ export interface SearchManagerOptions {
43
+ config: SearchConfig
44
+ /** Optional — required only if a `postgres-fts` driver is configured. */
45
+ db?: PostgresDatabase
46
+ }
33
47
 
34
- /** Get an engine by name, or the default engine. Engines are lazily created. */
35
- static engine(name?: string): SearchEngine {
36
- const key = name ?? SearchManager.config.default
48
+ /** Factory for custom drivers apps register via `manager.extend(name, factory)`. */
49
+ export type EngineFactory = (config: DriverConfig) => SearchEngine
37
50
 
38
- let engine = SearchManager._engines.get(key)
39
- if (engine) return engine
51
+ @inject()
52
+ export class SearchManager {
53
+ readonly config: SearchConfig
54
+ private readonly db: PostgresDatabase | undefined
55
+ private readonly engines = new Map<string, SearchEngine>()
56
+ private readonly extensions = new Map<string, EngineFactory>()
40
57
 
41
- const driverConfig = SearchManager.config.drivers[key]
42
- if (!driverConfig) {
43
- throw new ConfigurationError(`Search driver "${key}" is not configured.`)
58
+ constructor(options: SearchManagerOptions) {
59
+ if (!options.config.drivers[options.config.default]) {
60
+ throw new SearchConfigError(
61
+ `SearchManager: default driver "${options.config.default}" is not configured.`,
62
+ {
63
+ context: {
64
+ default: options.config.default,
65
+ available: Object.keys(options.config.drivers),
66
+ },
67
+ },
68
+ )
44
69
  }
45
-
46
- engine = SearchManager.createEngine(key, driverConfig)
47
- SearchManager._engines.set(key, engine)
48
- return engine
70
+ this.config = options.config
71
+ this.db = options.db
49
72
  }
50
73
 
51
- /** The index name prefix from configuration. */
52
- static get prefix(): string {
53
- return SearchManager._config?.prefix ?? ''
54
- }
74
+ // ─── Engine management ────────────────────────────────────────────────
55
75
 
56
76
  /**
57
- * Resolve a full index name by applying the configured prefix and
58
- * optional per-tenant scope. Per-tenant scoping namespaces the index
59
- * as `${prefix}t${tenantId}_${name}` so two tenants on the same
60
- * shared engine cannot read or overwrite each other's documents.
61
- *
62
- * Apps that don't need multi-tenant isolation can omit the scope.
63
- * The driver layer is unchanged — namespacing happens here at the
64
- * manager boundary.
77
+ * Resolve an engine by name (or the default when omitted).
78
+ * Engines are constructed lazily on first use + memoized.
65
79
  */
66
- static indexName(name: string, scope?: SearchScope | null): string {
67
- const base = SearchManager.prefix ? `${SearchManager.prefix}${name}` : name
68
- if (!scope || scope.tenantId === undefined || scope.tenantId === null) return base
69
- // Validate tenant identifier — anything that ends up in an index
70
- // name lands in URL paths / SQL identifiers downstream, so refuse
71
- // values that could escape the namespace. Letters, digits, dashes,
72
- // underscores only.
73
- const tenantId = String(scope.tenantId)
74
- if (!/^[a-zA-Z0-9_-]+$/.test(tenantId)) {
75
- throw new ConfigurationError(
76
- `SearchManager.indexName: invalid tenantId ${JSON.stringify(tenantId)} — ` +
77
- `must match /^[a-zA-Z0-9_-]+$/.`
78
- )
80
+ engine(name?: string): SearchEngine {
81
+ const key = name ?? this.config.default
82
+ const cached = this.engines.get(key)
83
+ if (cached) return cached
84
+ const cfg = this.config.drivers[key]
85
+ if (!cfg) {
86
+ throw new SearchConfigError(`SearchManager: driver "${key}" is not configured.`, {
87
+ context: { requested: key, available: Object.keys(this.config.drivers) },
88
+ })
79
89
  }
80
- if (SearchManager.prefix) {
81
- return `${SearchManager.prefix}t${tenantId}_${name}`
82
- }
83
- return `t${tenantId}_${name}`
90
+ const engine = this.createEngine(key, cfg)
91
+ this.engines.set(key, engine)
92
+ return engine
84
93
  }
85
94
 
86
- /** Register a custom driver factory. */
87
- static extend(name: string, factory: (config: DriverConfig) => SearchEngine): void {
88
- SearchManager._extensions.set(name, factory)
95
+ /** Register a custom driver. Subsequent `engine(name)` calls resolving to a `driver: <name>` will use it. */
96
+ extend(name: string, factory: EngineFactory): void {
97
+ this.extensions.set(name, factory)
89
98
  }
90
99
 
91
- /** Replace an engine at runtime (e.g. for testing). */
92
- static useEngine(engine: SearchEngine): void {
93
- SearchManager._engines.set(engine.name, engine)
100
+ /** Hand-wire an engine instance under a name (tests / one-off drivers). */
101
+ useEngine(name: string, engine: SearchEngine): void {
102
+ this.engines.set(name, engine)
94
103
  }
95
104
 
96
- /** Reset all state. Intended for test teardown. */
97
- static reset(): void {
98
- SearchManager._engines.clear()
99
- SearchManager._extensions.clear()
100
- SearchManager._config = undefined as any
105
+ /**
106
+ * Compose the configured prefix with `name`. The `searchable()`
107
+ * mixin always routes through this so apps that toggle the
108
+ * prefix in config don't have to rewrite calls.
109
+ */
110
+ indexName(name: string): string {
111
+ return this.config.prefix ? `${this.config.prefix}${name}` : name
101
112
  }
102
113
 
103
- private static createEngine(name: string, config: DriverConfig): SearchEngine {
114
+ // ─── Internals ────────────────────────────────────────────────────────
115
+
116
+ private createEngine(name: string, config: DriverConfig): SearchEngine {
104
117
  const driverName = config.driver ?? name
105
118
 
106
- const extension = SearchManager._extensions.get(driverName)
119
+ const extension = this.extensions.get(driverName)
107
120
  if (extension) return extension(config)
108
121
 
109
122
  switch (driverName) {
123
+ case 'memory':
124
+ return new MemoryDriver()
110
125
  case 'meilisearch':
111
126
  return new MeilisearchDriver(config)
112
127
  case 'typesense':
113
128
  return new TypesenseDriver(config)
114
- case 'algolia':
115
- return new AlgoliaDriver(config)
116
- case 'embedded':
117
- return new EmbeddedDriver(config)
118
- case 'postgres-fts':
119
129
  case 'postgres':
120
- return new PostgresFtsDriver(config)
121
- case 'null':
122
- return new NullDriver()
130
+ case 'postgres-fts':
131
+ if (!this.db) {
132
+ throw new SearchConfigError(
133
+ 'SearchManager: postgres-fts driver requires PostgresDatabase. Register DatabaseProvider before SearchProvider.',
134
+ )
135
+ }
136
+ return PostgresFtsDriver.fromConfig(this.db, config)
123
137
  default:
124
- throw new ConfigurationError(
125
- `Unknown search driver "${driverName}". Register it with SearchManager.extend().`
138
+ throw new SearchConfigError(
139
+ `SearchManager: unknown driver "${driverName}". Register it via \`manager.extend("${driverName}", factory)\`.`,
140
+ { context: { driver: driverName, name } },
126
141
  )
127
142
  }
128
143
  }
129
144
  }
145
+
146
+ /** Public alias for the container-resolution helper apps occasionally pass around. */
147
+ export type SearchManagerResolver = (app: Application) => SearchManager
@@ -1,16 +1,78 @@
1
- import { ServiceProvider } from '@strav/kernel'
2
- import type { Application } from '@strav/kernel'
3
- import SearchManager from './search_manager.ts'
1
+ /**
2
+ * `SearchProvider` `ServiceProvider` that wires `SearchManager`
3
+ * into the container from `config.search`.
4
+ *
5
+ * Eager construction at boot — a malformed config or a missing
6
+ * postgres dependency should fail before the first call hits.
7
+ * Apps register `DatabaseProvider` before this one when any
8
+ * `postgres-fts` driver is configured; the `dependencies` array
9
+ * declares the order.
10
+ *
11
+ * Config defaults: when `config.search` is absent entirely, the
12
+ * provider boots with the in-process `memory` driver as the
13
+ * default so apps can call `search.engine().createIndex(...)` /
14
+ * `engine().search(...)` in dev without configuration.
15
+ * Production apps override via a real `config/search.ts`.
16
+ */
4
17
 
5
- export default class SearchProvider extends ServiceProvider {
6
- readonly name = 'search'
18
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import — required when any postgres-fts driver is configured. Loaded conditionally below.
19
+ import { PostgresDatabase } from '@strav/database'
20
+ import { type Application, ConfigError, ConfigRepository, ServiceProvider } from '@strav/kernel'
21
+ import { SearchManager, type SearchManagerOptions } from './search_manager.ts'
22
+ import { SearchableRegistry } from './searchable_registry.ts'
23
+ import type { SearchConfig } from './types.ts'
24
+
25
+ export class SearchProvider extends ServiceProvider {
26
+ override readonly name = 'search'
7
27
  override readonly dependencies = ['config']
8
28
 
9
29
  override register(app: Application): void {
10
- app.singleton(SearchManager)
30
+ app.singleton(SearchableRegistry, () => new SearchableRegistry())
31
+ app.singleton(SearchManager, (c) => {
32
+ const raw = c.resolve(ConfigRepository).get('search') as Partial<SearchConfig> | undefined
33
+ const config = applyDefaults(raw)
34
+
35
+ const opts: SearchManagerOptions = { config }
36
+
37
+ const needsDb = Object.values(config.drivers).some(
38
+ (d) => d.driver === 'postgres-fts' || d.driver === 'postgres',
39
+ )
40
+ if (needsDb) {
41
+ try {
42
+ opts.db = c.resolve(PostgresDatabase)
43
+ } catch (cause) {
44
+ throw new ConfigError(
45
+ 'SearchProvider: at least one driver uses `driver: "postgres-fts"` but PostgresDatabase is not registered. Register DatabaseProvider before SearchProvider.',
46
+ { cause },
47
+ )
48
+ }
49
+ }
50
+ return new SearchManager(opts)
51
+ })
11
52
  }
12
53
 
13
54
  override boot(app: Application): void {
55
+ // Force-resolve so config errors surface at boot, not on first call.
14
56
  app.resolve(SearchManager)
15
57
  }
16
58
  }
59
+
60
+ /**
61
+ * Fill in defaults for omitted config fields. Apps with no
62
+ * `config/search.ts` at all get a working in-memory setup.
63
+ */
64
+ function applyDefaults(raw: Partial<SearchConfig> | undefined): SearchConfig {
65
+ const config: Partial<SearchConfig> = raw ?? {}
66
+ const drivers = config.drivers ?? { memory: { driver: 'memory' } }
67
+ const def = config.default ?? Object.keys(drivers)[0] ?? 'memory'
68
+ if (!drivers[def]) {
69
+ throw new ConfigError(
70
+ `SearchProvider: default driver "${def}" is not declared in config.search.drivers.`,
71
+ )
72
+ }
73
+ return {
74
+ default: def,
75
+ ...(config.prefix !== undefined ? { prefix: config.prefix } : {}),
76
+ drivers,
77
+ }
78
+ }