@strav/search 0.3.20 → 0.3.22

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 (34) hide show
  1. package/README.md +122 -3
  2. package/package.json +4 -4
  3. package/src/commands/search_optimize.ts +52 -0
  4. package/src/commands/search_rebuild.ts +73 -0
  5. package/src/drivers/embedded/embedded_driver.ts +136 -0
  6. package/src/drivers/embedded/engine/field_registry.ts +97 -0
  7. package/src/drivers/embedded/engine/fts_query_builder.ts +184 -0
  8. package/src/drivers/embedded/engine/query_compiler.ts +134 -0
  9. package/src/drivers/embedded/engine/schema.ts +99 -0
  10. package/src/drivers/embedded/engine/snippet_formatter.ts +29 -0
  11. package/src/drivers/embedded/engine/sqlite_engine.ts +255 -0
  12. package/src/drivers/embedded/engine/typo_expander.ts +138 -0
  13. package/src/drivers/embedded/errors.ts +15 -0
  14. package/src/drivers/embedded/filters/filter_compiler.ts +136 -0
  15. package/src/drivers/embedded/index.ts +3 -0
  16. package/src/drivers/embedded/storage/paths.ts +23 -0
  17. package/src/drivers/embedded/types.ts +34 -0
  18. package/src/drivers/postgres/engine/field_registry.ts +116 -0
  19. package/src/drivers/postgres/engine/fts_query_builder.ts +105 -0
  20. package/src/drivers/postgres/engine/pg_engine.ts +300 -0
  21. package/src/drivers/postgres/engine/query_compiler.ts +165 -0
  22. package/src/drivers/postgres/engine/schema.ts +187 -0
  23. package/src/drivers/postgres/engine/snippet_formatter.ts +31 -0
  24. package/src/drivers/postgres/engine/typo_expander.ts +131 -0
  25. package/src/drivers/postgres/errors.ts +33 -0
  26. package/src/drivers/postgres/filters/filter_compiler.ts +138 -0
  27. package/src/drivers/postgres/index.ts +14 -0
  28. package/src/drivers/postgres/postgres_fts_driver.ts +184 -0
  29. package/src/drivers/postgres/rebuild/rebuild_inplace.ts +113 -0
  30. package/src/drivers/postgres/storage/identifiers.ts +46 -0
  31. package/src/drivers/postgres/types.ts +53 -0
  32. package/src/index.ts +11 -0
  33. package/src/search_manager.ts +7 -0
  34. package/stubs/config/search.ts +25 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @strav/search
2
2
 
3
- Full-text search for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Unified API for Meilisearch, Typesense, and Algolia with automatic indexing via model events.
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
4
 
5
5
  ## Install
6
6
 
@@ -51,16 +51,135 @@ await search.delete('posts', ['1'])
51
51
 
52
52
  ## Drivers
53
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)
54
56
  - **Meilisearch** — fast, typo-tolerant, self-hosted
55
57
  - **Typesense** — open-source, instant search
56
58
  - **Algolia** — hosted search-as-a-service
57
59
  - **Null** — no-op driver for testing
58
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
+
59
176
  ## CLI
60
177
 
61
178
  ```bash
62
- bun strav search:import # Import all searchable models
63
- bun strav search:flush # Flush all indexes
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
64
183
  ```
65
184
 
66
185
  ## Documentation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/search",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "type": "module",
5
5
  "description": "Full-text search for the Strav framework",
6
6
  "license": "MIT",
@@ -18,9 +18,9 @@
18
18
  "tsconfig.json"
19
19
  ],
20
20
  "peerDependencies": {
21
- "@strav/kernel": "0.3.20",
22
- "@strav/database": "0.3.20",
23
- "@strav/cli": "0.3.20"
21
+ "@strav/kernel": "0.3.22",
22
+ "@strav/database": "0.3.22",
23
+ "@strav/cli": "0.3.22"
24
24
  },
25
25
  "scripts": {
26
26
  "test": "bun test tests/",
@@ -0,0 +1,52 @@
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
+ }
@@ -0,0 +1,73 @@
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
+ }
@@ -0,0 +1,136 @@
1
+ import type { SearchEngine } from '../../search_engine.ts'
2
+ import type {
3
+ SearchDocument,
4
+ SearchOptions,
5
+ SearchResult,
6
+ IndexSettings,
7
+ DriverConfig,
8
+ } from '../../types.ts'
9
+ import { SqliteEngine } from './engine/sqlite_engine.ts'
10
+ import { resolveTypoTolerance } from './engine/typo_expander.ts'
11
+ import { resolveIndexPath, MEMORY_PATH } from './storage/paths.ts'
12
+ import type { EmbeddedConfig, ResolvedTypoTolerance } from './types.ts'
13
+
14
+ /**
15
+ * In-process full-text search driver backed by SQLite FTS5.
16
+ *
17
+ * Each index lives in its own SQLite file (or `:memory:` for tests). The
18
+ * driver maintains a `Map<indexName, SqliteEngine>` and creates engines
19
+ * lazily on first reference. This means a fresh `upsert()` against a
20
+ * never-created index will auto-create a default schema (single `_text`
21
+ * column). Callers that want per-field weights call `createIndex()` first
22
+ * with their `IndexSettings`.
23
+ */
24
+ export class EmbeddedDriver implements SearchEngine {
25
+ readonly name = 'embedded'
26
+
27
+ private readonly config: EmbeddedConfig
28
+ private readonly synchronous: 'OFF' | 'NORMAL' | 'FULL'
29
+ private readonly typo: ResolvedTypoTolerance
30
+ private readonly engines = new Map<string, SqliteEngine>()
31
+ /** Pending settings for indexes that haven't been opened yet. */
32
+ private readonly pendingSettings = new Map<string, IndexSettings>()
33
+
34
+ constructor(config: DriverConfig) {
35
+ this.config = (config ?? {}) as EmbeddedConfig
36
+ this.synchronous = this.config.synchronous ?? 'NORMAL'
37
+ this.typo = resolveTypoTolerance(this.config.typoTolerance)
38
+ }
39
+
40
+ // ── Document operations ──────────────────────────────────────────────────
41
+
42
+ async upsert(
43
+ index: string,
44
+ id: string | number,
45
+ document: Record<string, unknown>
46
+ ): Promise<void> {
47
+ this.engineFor(index).upsert(id, document)
48
+ }
49
+
50
+ async upsertMany(index: string, documents: SearchDocument[]): Promise<void> {
51
+ this.engineFor(index).upsertMany(documents)
52
+ }
53
+
54
+ async delete(index: string, id: string | number): Promise<void> {
55
+ this.engineFor(index).delete(id)
56
+ }
57
+
58
+ async deleteMany(index: string, ids: Array<string | number>): Promise<void> {
59
+ this.engineFor(index).deleteMany(ids)
60
+ }
61
+
62
+ // ── Index operations ─────────────────────────────────────────────────────
63
+
64
+ async flush(index: string): Promise<void> {
65
+ this.engineFor(index).flush()
66
+ }
67
+
68
+ async deleteIndex(index: string): Promise<void> {
69
+ const engine = this.engines.get(index)
70
+ if (engine) {
71
+ engine.close()
72
+ this.engines.delete(index)
73
+ }
74
+ this.pendingSettings.delete(index)
75
+
76
+ const path = resolveIndexPath(this.config, index)
77
+ if (path === MEMORY_PATH) return
78
+
79
+ const fs = await import('node:fs/promises')
80
+ for (const suffix of ['', '-wal', '-shm']) {
81
+ try {
82
+ await fs.unlink(`${path}${suffix}`)
83
+ } catch (err: any) {
84
+ if (err?.code !== 'ENOENT') throw err
85
+ }
86
+ }
87
+ }
88
+
89
+ async createIndex(index: string, options?: IndexSettings): Promise<void> {
90
+ if (options) this.pendingSettings.set(index, options)
91
+ // Force engine instantiation so the schema exists on disk.
92
+ this.engineFor(index)
93
+ }
94
+
95
+ // ── Search ───────────────────────────────────────────────────────────────
96
+
97
+ async search(index: string, query: string, options?: SearchOptions): Promise<SearchResult> {
98
+ return this.engineFor(index).search(query, options)
99
+ }
100
+
101
+ // ── Lifecycle ────────────────────────────────────────────────────────────
102
+
103
+ /** Close all open engines. Call from app shutdown. */
104
+ close(): void {
105
+ for (const engine of this.engines.values()) engine.close()
106
+ this.engines.clear()
107
+ }
108
+
109
+ /** Run FTS5 segment merge on every open index. Use from CLI for periodic ops. */
110
+ optimize(index?: string): void {
111
+ if (index) {
112
+ this.engineFor(index).optimize()
113
+ return
114
+ }
115
+ for (const engine of this.engines.values()) engine.optimize()
116
+ }
117
+
118
+ // ── Internals ────────────────────────────────────────────────────────────
119
+
120
+ private engineFor(index: string): SqliteEngine {
121
+ let engine = this.engines.get(index)
122
+ if (engine) return engine
123
+
124
+ const settings = this.pendingSettings.get(index)
125
+ engine = new SqliteEngine({
126
+ path: resolveIndexPath(this.config, index),
127
+ synchronous: this.synchronous,
128
+ typoTolerance: this.typo,
129
+ indexName: index,
130
+ settings,
131
+ })
132
+ this.engines.set(index, engine)
133
+ this.pendingSettings.delete(index)
134
+ return engine
135
+ }
136
+ }
@@ -0,0 +1,97 @@
1
+ import type { IndexSettings } from '../../../types.ts'
2
+
3
+ /** The default searchable column name used when no `searchableAttributes` are configured. */
4
+ export const DEFAULT_TEXT_COLUMN = '_text'
5
+
6
+ /**
7
+ * The schema layout for one index: which document attributes feed which FTS5
8
+ * column and which typed `documents` columns exist for filtering / sorting.
9
+ *
10
+ * When a caller doesn't declare `searchableAttributes`, we fall back to a
11
+ * single `_text` column that concatenates every string-valued field at
12
+ * indexing time. Users who want per-field weights opt in by passing
13
+ * `IndexSettings`.
14
+ */
15
+ export class FieldRegistry {
16
+ /** FTS5 columns in declaration order — also the order BM25 weights apply in. */
17
+ readonly searchable: string[]
18
+ /** Filterable attributes — materialized as typed columns on `documents`. */
19
+ readonly filterable: string[]
20
+ /** Sortable attributes — materialized as typed columns on `documents`. */
21
+ readonly sortable: string[]
22
+ /** Union of filterable + sortable, deduplicated. */
23
+ readonly typedColumns: string[]
24
+ /** Primary key field name — defaults to 'id'. */
25
+ readonly primaryKey: string
26
+
27
+ constructor(settings?: IndexSettings) {
28
+ this.primaryKey = settings?.primaryKey ?? 'id'
29
+ this.searchable =
30
+ settings?.searchableAttributes && settings.searchableAttributes.length > 0
31
+ ? [...settings.searchableAttributes]
32
+ : [DEFAULT_TEXT_COLUMN]
33
+ this.filterable = settings?.filterableAttributes ?? []
34
+ this.sortable = settings?.sortableAttributes ?? []
35
+ this.typedColumns = Array.from(new Set([...this.filterable, ...this.sortable]))
36
+ }
37
+
38
+ /** Whether this registry uses the synthesised `_text` column. */
39
+ get usesDefaultTextColumn(): boolean {
40
+ return this.searchable.length === 1 && this.searchable[0] === DEFAULT_TEXT_COLUMN
41
+ }
42
+
43
+ /**
44
+ * Project a document into the values that go into the FTS5 row.
45
+ * For default mode, concatenate every string-valued field.
46
+ * For declared mode, pick each named attribute (coerced to string).
47
+ */
48
+ projectFtsValues(document: Record<string, unknown>): string[] {
49
+ if (this.usesDefaultTextColumn) {
50
+ const parts: string[] = []
51
+ for (const value of Object.values(document)) {
52
+ if (typeof value === 'string' && value.length > 0) parts.push(value)
53
+ else if (Array.isArray(value)) {
54
+ for (const item of value) {
55
+ if (typeof item === 'string' && item.length > 0) parts.push(item)
56
+ }
57
+ }
58
+ }
59
+ return [parts.join(' ')]
60
+ }
61
+
62
+ return this.searchable.map(attr => coerceText(document[attr]))
63
+ }
64
+
65
+ /**
66
+ * Project a document into the typed-column values stored on `documents`.
67
+ * Returned in the same order as `typedColumns`.
68
+ */
69
+ projectTypedValues(document: Record<string, unknown>): unknown[] {
70
+ return this.typedColumns.map(attr => coerceTyped(document[attr]))
71
+ }
72
+
73
+ /**
74
+ * Concatenate every searchable attribute into one long string suitable for
75
+ * tokenization (used for terms-dictionary maintenance).
76
+ */
77
+ concatSearchableText(document: Record<string, unknown>): string {
78
+ return this.projectFtsValues(document).join(' ')
79
+ }
80
+ }
81
+
82
+ function coerceText(value: unknown): string {
83
+ if (value === null || value === undefined) return ''
84
+ if (typeof value === 'string') return value
85
+ if (Array.isArray(value)) {
86
+ return value.map(v => coerceText(v)).filter(Boolean).join(' ')
87
+ }
88
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
89
+ return ''
90
+ }
91
+
92
+ function coerceTyped(value: unknown): unknown {
93
+ if (value === null || value === undefined) return null
94
+ if (Array.isArray(value)) return JSON.stringify(value)
95
+ if (typeof value === 'object') return JSON.stringify(value)
96
+ return value
97
+ }