@strav/search 0.3.19 → 0.3.21
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/README.md +74 -3
- package/package.json +4 -4
- package/src/commands/search_optimize.ts +52 -0
- package/src/drivers/embedded/embedded_driver.ts +136 -0
- package/src/drivers/embedded/engine/field_registry.ts +97 -0
- package/src/drivers/embedded/engine/fts_query_builder.ts +184 -0
- package/src/drivers/embedded/engine/query_compiler.ts +134 -0
- package/src/drivers/embedded/engine/schema.ts +99 -0
- package/src/drivers/embedded/engine/snippet_formatter.ts +29 -0
- package/src/drivers/embedded/engine/sqlite_engine.ts +255 -0
- package/src/drivers/embedded/engine/typo_expander.ts +138 -0
- package/src/drivers/embedded/errors.ts +15 -0
- package/src/drivers/embedded/filters/filter_compiler.ts +136 -0
- package/src/drivers/embedded/index.ts +3 -0
- package/src/drivers/embedded/storage/paths.ts +23 -0
- package/src/drivers/embedded/types.ts +34 -0
- package/src/index.ts +6 -0
- package/src/search_manager.ts +3 -0
- package/stubs/config/search.ts +10 -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
|
|
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,87 @@ 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
|
|
54
55
|
- **Meilisearch** — fast, typo-tolerant, self-hosted
|
|
55
56
|
- **Typesense** — open-source, instant search
|
|
56
57
|
- **Algolia** — hosted search-as-a-service
|
|
57
58
|
- **Null** — no-op driver for testing
|
|
58
59
|
|
|
60
|
+
### Embedded driver
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
Features:
|
|
65
|
+
|
|
66
|
+
- BM25 ranking with per-field weights (via `searchableAttributes` ordering)
|
|
67
|
+
- Prefix (`type*`), phrase (`"quick brown fox"`), negation (`-foo`), required (`+foo`)
|
|
68
|
+
- Porter stemmer for English morphology
|
|
69
|
+
- Typo tolerance (Levenshtein-1) on the fly, configurable
|
|
70
|
+
- Highlighted snippets with `<mark>` tags
|
|
71
|
+
- Object-form filters with equality, `in`, and comparison operators
|
|
72
|
+
|
|
73
|
+
Limitations for v1:
|
|
74
|
+
|
|
75
|
+
- English stemming only (other languages are tokenised but not stemmed)
|
|
76
|
+
- One writer at a time per index file (SQLite WAL — concurrent reads are fine)
|
|
77
|
+
- Object-form filters only; raw SQL filter strings are rejected
|
|
78
|
+
- Index settings changes require recreating the index
|
|
79
|
+
|
|
80
|
+
Configuration:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// config/search.ts
|
|
84
|
+
embedded: {
|
|
85
|
+
driver: 'embedded',
|
|
86
|
+
path: env('SEARCH_PATH', './storage/search'), // directory of .sqlite files
|
|
87
|
+
synchronous: 'NORMAL', // 'OFF' | 'NORMAL' | 'FULL'
|
|
88
|
+
typoTolerance: 'auto', // 'off' | 'auto' | { minTokenLength, maxDistance }
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Select it as the default with `SEARCH_DRIVER=embedded`.
|
|
93
|
+
|
|
94
|
+
Model example with per-field weights (column order determines BM25 weight — title first = highest):
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
class Ticket extends searchable(BaseModel) {
|
|
98
|
+
static searchableSettings() {
|
|
99
|
+
return {
|
|
100
|
+
searchableAttributes: ['subject', 'body'],
|
|
101
|
+
filterableAttributes: ['status', 'priority'],
|
|
102
|
+
sortableAttributes: ['priority', 'created_at'],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### Replacing Postgres `tsvector`
|
|
109
|
+
|
|
110
|
+
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:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// Before: hand-rolled tsvector query
|
|
114
|
+
const rows = await db.sql`
|
|
115
|
+
SELECT id, subject, ts_rank_cd(fts, q) AS rank
|
|
116
|
+
FROM tickets, websearch_to_tsquery('english', ${q}) q
|
|
117
|
+
WHERE fts @@ q ORDER BY rank DESC LIMIT 20
|
|
118
|
+
`
|
|
119
|
+
|
|
120
|
+
// After: searchable() + embedded driver
|
|
121
|
+
const results = await Ticket.search(q, {
|
|
122
|
+
perPage: 20,
|
|
123
|
+
attributesToHighlight: ['subject', 'body'],
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
You run `bun strav search:import Ticket` once to populate the index, then model events keep it up to date.
|
|
128
|
+
|
|
59
129
|
## CLI
|
|
60
130
|
|
|
61
131
|
```bash
|
|
62
|
-
bun strav search:import
|
|
63
|
-
bun strav search:flush
|
|
132
|
+
bun strav search:import <model> # Import all records for a model
|
|
133
|
+
bun strav search:flush <model> # Flush all documents from an index
|
|
134
|
+
bun strav search:optimize <model> # (embedded) Merge FTS5 segments; run periodically
|
|
64
135
|
```
|
|
65
136
|
|
|
66
137
|
## Documentation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/search",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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.
|
|
22
|
-
"@strav/database": "0.3.
|
|
23
|
-
"@strav/cli": "0.3.
|
|
21
|
+
"@strav/kernel": "0.3.21",
|
|
22
|
+
"@strav/database": "0.3.21",
|
|
23
|
+
"@strav/cli": "0.3.21"
|
|
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,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
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate a user-facing query string into a sanitized FTS5 MATCH expression.
|
|
3
|
+
*
|
|
4
|
+
* Supported syntax (subset of Google-style search):
|
|
5
|
+
* - `"foo bar"` — exact phrase
|
|
6
|
+
* - `-foo` — exclude documents containing this token
|
|
7
|
+
* - `+foo` — required (default for all positive tokens — accepted for symmetry)
|
|
8
|
+
* - `foo*` — prefix match
|
|
9
|
+
*
|
|
10
|
+
* Everything else is treated as a positive ANDed token.
|
|
11
|
+
*
|
|
12
|
+
* Defends against FTS5 syntax injection by stripping or escaping any FTS5
|
|
13
|
+
* operator characters from raw user tokens. The user never gets to write a
|
|
14
|
+
* raw MATCH expression.
|
|
15
|
+
*/
|
|
16
|
+
export interface FtsExpression {
|
|
17
|
+
/** Final MATCH expression, ready to bind into a query. */
|
|
18
|
+
match: string
|
|
19
|
+
/** The positive tokens (no quotes, no operators) — useful for typo expansion. */
|
|
20
|
+
positiveTokens: string[]
|
|
21
|
+
/** Whether the expression is empty (caller may short-circuit to "match all"). */
|
|
22
|
+
isEmpty: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ParsedToken {
|
|
26
|
+
text: string
|
|
27
|
+
negate: boolean
|
|
28
|
+
phrase: boolean
|
|
29
|
+
prefix: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const FTS5_RESERVED = /["()*:^]/g
|
|
33
|
+
const PHRASE_RE = /"([^"]*)"/g
|
|
34
|
+
|
|
35
|
+
export function compileQuery(input: string): FtsExpression {
|
|
36
|
+
const trimmed = input.trim()
|
|
37
|
+
if (!trimmed) return { match: '', positiveTokens: [], isEmpty: true }
|
|
38
|
+
|
|
39
|
+
const tokens = parseTokens(trimmed)
|
|
40
|
+
if (tokens.length === 0) return { match: '', positiveTokens: [], isEmpty: true }
|
|
41
|
+
|
|
42
|
+
const positives: string[] = []
|
|
43
|
+
const negatives: string[] = []
|
|
44
|
+
const positiveTokens: string[] = []
|
|
45
|
+
|
|
46
|
+
for (const tok of tokens) {
|
|
47
|
+
const rendered = renderToken(tok)
|
|
48
|
+
if (!rendered) continue
|
|
49
|
+
|
|
50
|
+
if (tok.negate) {
|
|
51
|
+
negatives.push(rendered)
|
|
52
|
+
} else {
|
|
53
|
+
positives.push(rendered)
|
|
54
|
+
if (!tok.phrase && !tok.prefix) positiveTokens.push(tok.text.toLowerCase())
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (positives.length === 0 && negatives.length === 0) {
|
|
59
|
+
return { match: '', positiveTokens: [], isEmpty: true }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Pure-negative queries can't be expressed in FTS5 — fall back to no-match.
|
|
63
|
+
if (positives.length === 0) {
|
|
64
|
+
return { match: '', positiveTokens: [], isEmpty: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let expr = positives.join(' AND ')
|
|
68
|
+
if (negatives.length > 0) {
|
|
69
|
+
expr = `${expr} NOT (${negatives.join(' OR ')})`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { match: expr, positiveTokens, isEmpty: false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Re-render a previously parsed query but with extra OR-candidates injected
|
|
77
|
+
* for each positive token. Used by the typo expander.
|
|
78
|
+
*/
|
|
79
|
+
export function compileQueryWithExpansions(
|
|
80
|
+
input: string,
|
|
81
|
+
expansions: Map<string, string[]>
|
|
82
|
+
): FtsExpression {
|
|
83
|
+
const trimmed = input.trim()
|
|
84
|
+
if (!trimmed) return { match: '', positiveTokens: [], isEmpty: true }
|
|
85
|
+
|
|
86
|
+
const tokens = parseTokens(trimmed)
|
|
87
|
+
const positives: string[] = []
|
|
88
|
+
const negatives: string[] = []
|
|
89
|
+
const positiveTokens: string[] = []
|
|
90
|
+
|
|
91
|
+
for (const tok of tokens) {
|
|
92
|
+
if (tok.negate) {
|
|
93
|
+
const r = renderToken(tok)
|
|
94
|
+
if (r) negatives.push(r)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (tok.phrase || tok.prefix) {
|
|
99
|
+
const r = renderToken(tok)
|
|
100
|
+
if (r) positives.push(r)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sanitized = sanitizeBareToken(tok.text)
|
|
105
|
+
if (!sanitized) continue
|
|
106
|
+
positiveTokens.push(sanitized.toLowerCase())
|
|
107
|
+
|
|
108
|
+
const cands = expansions.get(sanitized.toLowerCase()) ?? []
|
|
109
|
+
if (cands.length === 0) {
|
|
110
|
+
positives.push(sanitized)
|
|
111
|
+
} else {
|
|
112
|
+
const all = [sanitized, ...cands].map(t => sanitizeBareToken(t)).filter(Boolean) as string[]
|
|
113
|
+
const unique = Array.from(new Set(all))
|
|
114
|
+
positives.push(`(${unique.join(' OR ')})`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (positives.length === 0) return { match: '', positiveTokens: [], isEmpty: true }
|
|
119
|
+
|
|
120
|
+
let expr = positives.join(' AND ')
|
|
121
|
+
if (negatives.length > 0) {
|
|
122
|
+
expr = `${expr} NOT (${negatives.join(' OR ')})`
|
|
123
|
+
}
|
|
124
|
+
return { match: expr, positiveTokens, isEmpty: false }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseTokens(input: string): ParsedToken[] {
|
|
128
|
+
const tokens: ParsedToken[] = []
|
|
129
|
+
let cursor = 0
|
|
130
|
+
let working = input
|
|
131
|
+
|
|
132
|
+
// Pull out phrase tokens first to avoid splitting on inner whitespace.
|
|
133
|
+
working = working.replace(PHRASE_RE, (_, phrase, offset) => {
|
|
134
|
+
const negate = offset > 0 && input[offset - 1] === '-'
|
|
135
|
+
tokens.push({ text: phrase, negate, phrase: true, prefix: false })
|
|
136
|
+
return ' '.repeat(_.length + (negate ? 1 : 0))
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
for (const raw of working.split(/\s+/)) {
|
|
140
|
+
if (!raw) continue
|
|
141
|
+
let text = raw
|
|
142
|
+
let negate = false
|
|
143
|
+
let prefix = false
|
|
144
|
+
|
|
145
|
+
if (text.startsWith('-')) {
|
|
146
|
+
negate = true
|
|
147
|
+
text = text.slice(1)
|
|
148
|
+
} else if (text.startsWith('+')) {
|
|
149
|
+
text = text.slice(1)
|
|
150
|
+
}
|
|
151
|
+
if (text.endsWith('*')) {
|
|
152
|
+
prefix = true
|
|
153
|
+
text = text.slice(0, -1)
|
|
154
|
+
}
|
|
155
|
+
if (!text) continue
|
|
156
|
+
|
|
157
|
+
tokens.push({ text, negate, phrase: false, prefix })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
void cursor
|
|
161
|
+
return tokens
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderToken(tok: ParsedToken): string | null {
|
|
165
|
+
if (tok.phrase) {
|
|
166
|
+
const cleaned = tok.text.replace(/"/g, '').trim()
|
|
167
|
+
if (!cleaned) return null
|
|
168
|
+
return `"${cleaned}"`
|
|
169
|
+
}
|
|
170
|
+
const sanitized = sanitizeBareToken(tok.text)
|
|
171
|
+
if (!sanitized) return null
|
|
172
|
+
return tok.prefix ? `${sanitized}*` : sanitized
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sanitizeBareToken(token: string): string {
|
|
176
|
+
// Replace any FTS5 operator characters with a space, then collapse to one
|
|
177
|
+
// word. If only one word survives we use it bare; otherwise wrap in quotes
|
|
178
|
+
// so FTS5 treats it as a phrase rather than two ANDed tokens.
|
|
179
|
+
const cleaned = token.replace(FTS5_RESERVED, ' ').trim()
|
|
180
|
+
if (!cleaned) return ''
|
|
181
|
+
const parts = cleaned.split(/\s+/).filter(Boolean)
|
|
182
|
+
if (parts.length === 1) return parts[0]!
|
|
183
|
+
return `"${parts.join(' ')}"`
|
|
184
|
+
}
|