@nomideusz/svelte-search 0.2.0 → 0.2.1
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/CHANGELOG.md +51 -46
- package/LICENSE +21 -21
- package/README.md +297 -297
- package/dist/core/engine.js +19 -19
- package/dist/core/indexer.js +19 -7
- package/dist/locales/pl.js +463 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,297 +1,297 @@
|
|
|
1
|
-
# @nomideusz/svelte-search
|
|
2
|
-
|
|
3
|
-
A full-text search engine for Svelte 5 apps backed by your own database. Combines FTS5 (SQLite) or `tsvector` (PostgreSQL) with trigram fuzzy matching, geo proximity, synonym expansion, and a pluggable schema adapter. Ships with a Polish locale that handles diacritics, stop words, and locative-case stemming.
|
|
4
|
-
|
|
5
|
-
**[Live demo → svelte-search-eight.vercel.app](https://svelte-search-eight.vercel.app/)**
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
pnpm add @nomideusz/svelte-search
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
> Requires Svelte 5 (`^5.0.0`). Works with any SQL client that exposes an `execute()` method — libsql, better-sqlite3, postgres.js, etc.
|
|
14
|
-
|
|
15
|
-
## Why
|
|
16
|
-
|
|
17
|
-
Most Svelte search libraries index in-memory or hit an external service. This one pushes the query down to your database so it stays fast at any table size, but stays generic: you provide a `SchemaAdapter` that maps the engine's concepts (entities, trigrams, FTS, synonyms) onto your tables. The engine then handles:
|
|
18
|
-
|
|
19
|
-
1. Synonym expansion
|
|
20
|
-
2. Full-text search (FTS5 `MATCH` / Postgres `tsvector @@ tsquery`)
|
|
21
|
-
3. Trigram fuzzy fallback (custom tables on SQLite, `pg_trgm` on Postgres)
|
|
22
|
-
4. Score blending (FTS rank + name similarity + field match + geo)
|
|
23
|
-
5. Quality gate (Levenshtein threshold to reject junk fuzzy hits)
|
|
24
|
-
6. Relevance boundaries (primary radius + "also within reach" list)
|
|
25
|
-
|
|
26
|
-
## Quick Start
|
|
27
|
-
|
|
28
|
-
Define a `SchemaAdapter` that points at your tables and columns, then create an engine:
|
|
29
|
-
|
|
30
|
-
```ts
|
|
31
|
-
import { createSearchEngine, type SchemaAdapter, type SearchResult } from '@nomideusz/svelte-search';
|
|
32
|
-
import { plLocale } from '@nomideusz/svelte-search/locales/pl';
|
|
33
|
-
|
|
34
|
-
interface SchoolResult extends SearchResult {
|
|
35
|
-
city: string;
|
|
36
|
-
styles: string[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const schema: SchemaAdapter<SchoolResult> = {
|
|
40
|
-
tables: {
|
|
41
|
-
entities: 'schools',
|
|
42
|
-
trigrams: 'school_trigrams',
|
|
43
|
-
fts: 'schools_fts',
|
|
44
|
-
synonyms: 'search_synonyms',
|
|
45
|
-
},
|
|
46
|
-
columns: {
|
|
47
|
-
id: 'id',
|
|
48
|
-
name: 'name',
|
|
49
|
-
nameNormalized: 'name_n',
|
|
50
|
-
slug: 'slug',
|
|
51
|
-
lat: 'latitude',
|
|
52
|
-
lng: 'longitude',
|
|
53
|
-
locationSlug: 'city_slug',
|
|
54
|
-
categoriesNormalized: 'styles_n',
|
|
55
|
-
locationNormalized: 'city_n',
|
|
56
|
-
areaNormalized: 'district_n',
|
|
57
|
-
},
|
|
58
|
-
trigramColumns: { trigram: 'trigram', entityId: 'school_id', field: 'field' },
|
|
59
|
-
toResult(row, lat, lng) {
|
|
60
|
-
return {
|
|
61
|
-
id: row.id as string,
|
|
62
|
-
name: row.name as string,
|
|
63
|
-
slug: row.slug as string,
|
|
64
|
-
city: row.city as string,
|
|
65
|
-
styles: JSON.parse((row.styles as string) || '[]'),
|
|
66
|
-
lat: row.latitude as number | null,
|
|
67
|
-
lng: row.longitude as number | null,
|
|
68
|
-
distanceKm: null,
|
|
69
|
-
walkingMin: null,
|
|
70
|
-
score: 0,
|
|
71
|
-
};
|
|
72
|
-
},
|
|
73
|
-
trigramFields(row) {
|
|
74
|
-
return [
|
|
75
|
-
{ text: row.name as string, field: 'name' },
|
|
76
|
-
{ text: row.city as string, field: 'city' },
|
|
77
|
-
];
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const engine = createSearchEngine<SchoolResult>({
|
|
82
|
-
db, // any client with .execute({ sql, args })
|
|
83
|
-
adapter: schema,
|
|
84
|
-
locale: plLocale, // optional
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const response = await engine.search({
|
|
88
|
-
query: 'hatha w poblizu',
|
|
89
|
-
lat: 52.229, lng: 21.012,
|
|
90
|
-
limit: 20,
|
|
91
|
-
});
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
`response.results` has your primary hits, `response.nearby` has matches just outside the primary radius, and `response.nearestLocationWithEntities` suggests where to look if the user's area has none.
|
|
95
|
-
|
|
96
|
-
## Dialects
|
|
97
|
-
|
|
98
|
-
Pick a dialect when you create the engine — defaults to SQLite:
|
|
99
|
-
|
|
100
|
-
```ts
|
|
101
|
-
createSearchEngine({ db, adapter, dialect: 'sqlite' }); // FTS5 + custom trigram tables
|
|
102
|
-
createSearchEngine({ db, adapter, dialect: 'postgres' }); // tsvector + pg_trgm
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
On SQLite, you're expected to maintain your own trigram table and FTS5 virtual table — the indexer helps with that. On Postgres, `pg_trgm` handles trigrams automatically; just keep a `tsvector` column updated (via trigger or `indexer.updateSearchVector()`).
|
|
106
|
-
|
|
107
|
-
## Indexer
|
|
108
|
-
|
|
109
|
-
The indexer rebuilds trigrams and FTS from your entities table:
|
|
110
|
-
|
|
111
|
-
```ts
|
|
112
|
-
import { createIndexer } from '@nomideusz/svelte-search';
|
|
113
|
-
|
|
114
|
-
const indexer = createIndexer({ db, adapter: schema, locale: plLocale });
|
|
115
|
-
|
|
116
|
-
await indexer.indexTrigrams(schoolId, schoolRow); // one entity
|
|
117
|
-
await indexer.reindexAllTrigrams(); // full rebuild
|
|
118
|
-
await indexer.rebuildFts(); // SQLite FTS5 rebuild
|
|
119
|
-
const stats = await indexer.checkFtsSync(); // diagnose drift
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
On Postgres, `indexTrigrams` and `rebuildFts` are no-ops — use your trigger or `updateSearchVector()` instead.
|
|
123
|
-
|
|
124
|
-
## Search parameters
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
engine.search({
|
|
128
|
-
query: 'hatha near me', // raw user input
|
|
129
|
-
locationSlug: 'warsaw', // restrict to city
|
|
130
|
-
categorySlug: 'hatha', // restrict to style
|
|
131
|
-
lat: 52.229, lng: 21.012, // user coords for proximity
|
|
132
|
-
limit: 20, offset: 0,
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
The engine automatically detects geo intent (`"near me"`, `"blisko"`) and strips it before the FTS/trigram step, then uses the supplied coordinates for proximity sorting. Empty queries with coordinates fall back to pure geo search.
|
|
137
|
-
|
|
138
|
-
### Tunables
|
|
139
|
-
|
|
140
|
-
| Option | Default | Description |
|
|
141
|
-
|--------|---------|-------------|
|
|
142
|
-
| `ftsTimeoutMs` | `5000` | FTS query timeout (returns empty on overrun) |
|
|
143
|
-
| `fuzzyTimeoutMs` | `3000` | Trigram fallback timeout |
|
|
144
|
-
| `primaryRadiusKm` | `15` | Max distance for primary results |
|
|
145
|
-
| `nearbyRadiusKm` | `30` | Max distance for "also within reach" results |
|
|
146
|
-
| `maxNearby` | `5` | Cap on nearby entries |
|
|
147
|
-
| `qualityThreshold` | `0.75` | Min Levenshtein similarity for fuzzy-only hits |
|
|
148
|
-
| `maxFtsTerms` | `6` | Cap on terms sent to FTS |
|
|
149
|
-
|
|
150
|
-
## Autocomplete
|
|
151
|
-
|
|
152
|
-
Autocomplete is app-specific (every app wants different suggestion types: cities, styles, neighborhoods, products, …), so the package exports only the `AutocompleteResult` type — you write the query logic against your tables. A typical shape:
|
|
153
|
-
|
|
154
|
-
```ts
|
|
155
|
-
export interface AutocompleteResult {
|
|
156
|
-
text: string;
|
|
157
|
-
type: string; // 'school' | 'city' | 'style' | …
|
|
158
|
-
slug?: string;
|
|
159
|
-
}
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
## Query resolver
|
|
163
|
-
|
|
164
|
-
`parseQuery()` classifies tokens into location / category / area / rest using lookup maps you build from your DB. It's the parsing half of a full resolver — apps provide the dispatch rules:
|
|
165
|
-
|
|
166
|
-
```ts
|
|
167
|
-
import { parseQuery, type ResolverLookups } from '@nomideusz/svelte-search';
|
|
168
|
-
|
|
169
|
-
const lookups: ResolverLookups = {
|
|
170
|
-
locationMap: new Map([['warszawa', 'warsaw'], ['krakow', 'krakow']]),
|
|
171
|
-
categoryMap: new Map([['hatha', 'hatha'], ['vinyasa', 'vinyasa']]),
|
|
172
|
-
areaMap: new Map([['warsaw', ['mokotow', 'praga']]]),
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const parsed = parseQuery('hatha w warszawie mokotow', lookups, plLocale);
|
|
176
|
-
// { location: 'warsaw', category: 'hatha', rest: ['mokotow'], geoIntent: false, ... }
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
`findMatchingArea()` and `findNearestLocationWithEntities()` are helpers for "did they type a neighborhood?" and "what's the nearest populated city?" resolutions.
|
|
180
|
-
|
|
181
|
-
## Geo helpers
|
|
182
|
-
|
|
183
|
-
Pure functions — no DB:
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
import {
|
|
187
|
-
haversineKm, walkingMinutes, boundingBox,
|
|
188
|
-
formatDistance, formatWalkingTime, walkingRoute,
|
|
189
|
-
} from '@nomideusz/svelte-search';
|
|
190
|
-
|
|
191
|
-
haversineKm(52.229, 21.012, 50.062, 19.937); // km between Warsaw and Kraków
|
|
192
|
-
walkingMinutes(0.8); // ~13 (min)
|
|
193
|
-
formatDistance(0.85); // "850 m"
|
|
194
|
-
formatWalkingTime(72); // "1 hr 12 min walk"
|
|
195
|
-
|
|
196
|
-
// Fast SQL pre-filter before exact Haversine:
|
|
197
|
-
const bb = boundingBox(52.229, 21.012, 5); // 5 km box
|
|
198
|
-
// WHERE lat BETWEEN bb.minLat AND bb.maxLat AND lng BETWEEN bb.minLng AND bb.maxLng
|
|
199
|
-
|
|
200
|
-
// Optional real walking route via OSRM (self-host for production):
|
|
201
|
-
const route = await walkingRoute(52.229, 21.012, 52.237, 21.017);
|
|
202
|
-
// { distanceM, durationS } | null
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
## Normalization & similarity
|
|
206
|
-
|
|
207
|
-
```ts
|
|
208
|
-
import {
|
|
209
|
-
normalize, stripDiacriticsGeneric,
|
|
210
|
-
trigrams, trigramSimilarity,
|
|
211
|
-
levenshtein, levenshteinSimilarity,
|
|
212
|
-
isPostcode, hasGeoIntent, stripGeoIntent, stripStopWords,
|
|
213
|
-
} from '@nomideusz/svelte-search';
|
|
214
|
-
|
|
215
|
-
normalize('Łódź, ulica Piotrkowska', plLocale); // 'lodz ulica piotrkowska'
|
|
216
|
-
trigrams('hatha'); // ['hat','ath','tha']
|
|
217
|
-
trigramSimilarity('hatha', 'hata'); // ~0.67
|
|
218
|
-
levenshteinSimilarity('vinyasa', 'vinjasa'); // ~0.86
|
|
219
|
-
isPostcode('00-001'); // true
|
|
220
|
-
hasGeoIntent('yoga near me'); // true
|
|
221
|
-
stripGeoIntent('yoga near me'); // 'yoga'
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
## Locales
|
|
225
|
-
|
|
226
|
-
The package ships a Polish locale handling:
|
|
227
|
-
|
|
228
|
-
- diacritics (`ą → a`, `ł → l`, `ó → o`, …)
|
|
229
|
-
- stop words and phrases (`w`, `na`, `joga`, `szkoła jogi`, …)
|
|
230
|
-
- geo-intent patterns (`blisko mnie`, `niedaleko`, `w okolicy`, …)
|
|
231
|
-
- nominative-form stemming (`warszawie → warszawa`, `krakowie → krakow`, …)
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
import { plLocale } from '@nomideusz/svelte-search/locales/pl';
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Bring your own locale by implementing the `SearchLocale` interface — `stripDiacritics`, `stopTokens`, `stopPhrases`, `geoPatterns`, and optionally `locationStems`. No locale = generic NFD diacritic stripping and no stop words.
|
|
238
|
-
|
|
239
|
-
## Tracking
|
|
240
|
-
|
|
241
|
-
A minimal analytics helper that fire-and-forgets search events via `navigator.sendBeacon`, never throwing:
|
|
242
|
-
|
|
243
|
-
```ts
|
|
244
|
-
import { createTracker } from '@nomideusz/svelte-search';
|
|
245
|
-
|
|
246
|
-
const { track } = createTracker({ endpoint: '/api/search-events' });
|
|
247
|
-
|
|
248
|
-
track({
|
|
249
|
-
query: 'hatha warszawa',
|
|
250
|
-
queryNormalized: 'hatha warszawa',
|
|
251
|
-
page: 'home',
|
|
252
|
-
action: 'filter',
|
|
253
|
-
layer: 'server',
|
|
254
|
-
resultCount: 12,
|
|
255
|
-
});
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
A session ID is stored in `sessionStorage` (no PII). The server endpoint shape is up to you.
|
|
259
|
-
|
|
260
|
-
## Database client interface
|
|
261
|
-
|
|
262
|
-
Any client with an `execute()` matching this shape works:
|
|
263
|
-
|
|
264
|
-
```ts
|
|
265
|
-
interface DatabaseClient {
|
|
266
|
-
execute(query: { sql: string; args: unknown[] } | string): Promise<{
|
|
267
|
-
rows: Record<string, unknown>[];
|
|
268
|
-
lastInsertRowid?: bigint | number;
|
|
269
|
-
}>;
|
|
270
|
-
}
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
libsql's `Client` matches directly. For other drivers, wrap them:
|
|
274
|
-
|
|
275
|
-
```ts
|
|
276
|
-
function wrapClient(client: MyClient): DatabaseClient {
|
|
277
|
-
return {
|
|
278
|
-
execute: (query) => typeof query === 'string'
|
|
279
|
-
? client.execute(query)
|
|
280
|
-
: client.execute({ sql: query.sql, args: query.args as any }),
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
## Development
|
|
286
|
-
|
|
287
|
-
```bash
|
|
288
|
-
pnpm install
|
|
289
|
-
pnpm dev # SvelteKit dev server (demo)
|
|
290
|
-
pnpm check # Typecheck
|
|
291
|
-
pnpm test # Vitest
|
|
292
|
-
pnpm run package # Build the library
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
## License
|
|
296
|
-
|
|
297
|
-
MIT
|
|
1
|
+
# @nomideusz/svelte-search
|
|
2
|
+
|
|
3
|
+
A full-text search engine for Svelte 5 apps backed by your own database. Combines FTS5 (SQLite) or `tsvector` (PostgreSQL) with trigram fuzzy matching, geo proximity, synonym expansion, and a pluggable schema adapter. Ships with a Polish locale that handles diacritics, stop words, and locative-case stemming.
|
|
4
|
+
|
|
5
|
+
**[Live demo → svelte-search-eight.vercel.app](https://svelte-search-eight.vercel.app/)**
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @nomideusz/svelte-search
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> Requires Svelte 5 (`^5.0.0`). Works with any SQL client that exposes an `execute()` method — libsql, better-sqlite3, postgres.js, etc.
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Most Svelte search libraries index in-memory or hit an external service. This one pushes the query down to your database so it stays fast at any table size, but stays generic: you provide a `SchemaAdapter` that maps the engine's concepts (entities, trigrams, FTS, synonyms) onto your tables. The engine then handles:
|
|
18
|
+
|
|
19
|
+
1. Synonym expansion
|
|
20
|
+
2. Full-text search (FTS5 `MATCH` / Postgres `tsvector @@ tsquery`)
|
|
21
|
+
3. Trigram fuzzy fallback (custom tables on SQLite, `pg_trgm` on Postgres)
|
|
22
|
+
4. Score blending (FTS rank + name similarity + field match + geo)
|
|
23
|
+
5. Quality gate (Levenshtein threshold to reject junk fuzzy hits)
|
|
24
|
+
6. Relevance boundaries (primary radius + "also within reach" list)
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
Define a `SchemaAdapter` that points at your tables and columns, then create an engine:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createSearchEngine, type SchemaAdapter, type SearchResult } from '@nomideusz/svelte-search';
|
|
32
|
+
import { plLocale } from '@nomideusz/svelte-search/locales/pl';
|
|
33
|
+
|
|
34
|
+
interface SchoolResult extends SearchResult {
|
|
35
|
+
city: string;
|
|
36
|
+
styles: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const schema: SchemaAdapter<SchoolResult> = {
|
|
40
|
+
tables: {
|
|
41
|
+
entities: 'schools',
|
|
42
|
+
trigrams: 'school_trigrams',
|
|
43
|
+
fts: 'schools_fts',
|
|
44
|
+
synonyms: 'search_synonyms',
|
|
45
|
+
},
|
|
46
|
+
columns: {
|
|
47
|
+
id: 'id',
|
|
48
|
+
name: 'name',
|
|
49
|
+
nameNormalized: 'name_n',
|
|
50
|
+
slug: 'slug',
|
|
51
|
+
lat: 'latitude',
|
|
52
|
+
lng: 'longitude',
|
|
53
|
+
locationSlug: 'city_slug',
|
|
54
|
+
categoriesNormalized: 'styles_n',
|
|
55
|
+
locationNormalized: 'city_n',
|
|
56
|
+
areaNormalized: 'district_n',
|
|
57
|
+
},
|
|
58
|
+
trigramColumns: { trigram: 'trigram', entityId: 'school_id', field: 'field' },
|
|
59
|
+
toResult(row, lat, lng) {
|
|
60
|
+
return {
|
|
61
|
+
id: row.id as string,
|
|
62
|
+
name: row.name as string,
|
|
63
|
+
slug: row.slug as string,
|
|
64
|
+
city: row.city as string,
|
|
65
|
+
styles: JSON.parse((row.styles as string) || '[]'),
|
|
66
|
+
lat: row.latitude as number | null,
|
|
67
|
+
lng: row.longitude as number | null,
|
|
68
|
+
distanceKm: null,
|
|
69
|
+
walkingMin: null,
|
|
70
|
+
score: 0,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
trigramFields(row) {
|
|
74
|
+
return [
|
|
75
|
+
{ text: row.name as string, field: 'name' },
|
|
76
|
+
{ text: row.city as string, field: 'city' },
|
|
77
|
+
];
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const engine = createSearchEngine<SchoolResult>({
|
|
82
|
+
db, // any client with .execute({ sql, args })
|
|
83
|
+
adapter: schema,
|
|
84
|
+
locale: plLocale, // optional
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const response = await engine.search({
|
|
88
|
+
query: 'hatha w poblizu',
|
|
89
|
+
lat: 52.229, lng: 21.012,
|
|
90
|
+
limit: 20,
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`response.results` has your primary hits, `response.nearby` has matches just outside the primary radius, and `response.nearestLocationWithEntities` suggests where to look if the user's area has none.
|
|
95
|
+
|
|
96
|
+
## Dialects
|
|
97
|
+
|
|
98
|
+
Pick a dialect when you create the engine — defaults to SQLite:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
createSearchEngine({ db, adapter, dialect: 'sqlite' }); // FTS5 + custom trigram tables
|
|
102
|
+
createSearchEngine({ db, adapter, dialect: 'postgres' }); // tsvector + pg_trgm
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
On SQLite, you're expected to maintain your own trigram table and FTS5 virtual table — the indexer helps with that. On Postgres, `pg_trgm` handles trigrams automatically; just keep a `tsvector` column updated (via trigger or `indexer.updateSearchVector()`).
|
|
106
|
+
|
|
107
|
+
## Indexer
|
|
108
|
+
|
|
109
|
+
The indexer rebuilds trigrams and FTS from your entities table:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { createIndexer } from '@nomideusz/svelte-search';
|
|
113
|
+
|
|
114
|
+
const indexer = createIndexer({ db, adapter: schema, locale: plLocale });
|
|
115
|
+
|
|
116
|
+
await indexer.indexTrigrams(schoolId, schoolRow); // one entity
|
|
117
|
+
await indexer.reindexAllTrigrams(); // full rebuild
|
|
118
|
+
await indexer.rebuildFts(); // SQLite FTS5 rebuild
|
|
119
|
+
const stats = await indexer.checkFtsSync(); // diagnose drift
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
On Postgres, `indexTrigrams` and `rebuildFts` are no-ops — use your trigger or `updateSearchVector()` instead.
|
|
123
|
+
|
|
124
|
+
## Search parameters
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
engine.search({
|
|
128
|
+
query: 'hatha near me', // raw user input
|
|
129
|
+
locationSlug: 'warsaw', // restrict to city
|
|
130
|
+
categorySlug: 'hatha', // restrict to style
|
|
131
|
+
lat: 52.229, lng: 21.012, // user coords for proximity
|
|
132
|
+
limit: 20, offset: 0,
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The engine automatically detects geo intent (`"near me"`, `"blisko"`) and strips it before the FTS/trigram step, then uses the supplied coordinates for proximity sorting. Empty queries with coordinates fall back to pure geo search.
|
|
137
|
+
|
|
138
|
+
### Tunables
|
|
139
|
+
|
|
140
|
+
| Option | Default | Description |
|
|
141
|
+
|--------|---------|-------------|
|
|
142
|
+
| `ftsTimeoutMs` | `5000` | FTS query timeout (returns empty on overrun) |
|
|
143
|
+
| `fuzzyTimeoutMs` | `3000` | Trigram fallback timeout |
|
|
144
|
+
| `primaryRadiusKm` | `15` | Max distance for primary results |
|
|
145
|
+
| `nearbyRadiusKm` | `30` | Max distance for "also within reach" results |
|
|
146
|
+
| `maxNearby` | `5` | Cap on nearby entries |
|
|
147
|
+
| `qualityThreshold` | `0.75` | Min Levenshtein similarity for fuzzy-only hits |
|
|
148
|
+
| `maxFtsTerms` | `6` | Cap on terms sent to FTS |
|
|
149
|
+
|
|
150
|
+
## Autocomplete
|
|
151
|
+
|
|
152
|
+
Autocomplete is app-specific (every app wants different suggestion types: cities, styles, neighborhoods, products, …), so the package exports only the `AutocompleteResult` type — you write the query logic against your tables. A typical shape:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
export interface AutocompleteResult {
|
|
156
|
+
text: string;
|
|
157
|
+
type: string; // 'school' | 'city' | 'style' | …
|
|
158
|
+
slug?: string;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Query resolver
|
|
163
|
+
|
|
164
|
+
`parseQuery()` classifies tokens into location / category / area / rest using lookup maps you build from your DB. It's the parsing half of a full resolver — apps provide the dispatch rules:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { parseQuery, type ResolverLookups } from '@nomideusz/svelte-search';
|
|
168
|
+
|
|
169
|
+
const lookups: ResolverLookups = {
|
|
170
|
+
locationMap: new Map([['warszawa', 'warsaw'], ['krakow', 'krakow']]),
|
|
171
|
+
categoryMap: new Map([['hatha', 'hatha'], ['vinyasa', 'vinyasa']]),
|
|
172
|
+
areaMap: new Map([['warsaw', ['mokotow', 'praga']]]),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const parsed = parseQuery('hatha w warszawie mokotow', lookups, plLocale);
|
|
176
|
+
// { location: 'warsaw', category: 'hatha', rest: ['mokotow'], geoIntent: false, ... }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`findMatchingArea()` and `findNearestLocationWithEntities()` are helpers for "did they type a neighborhood?" and "what's the nearest populated city?" resolutions.
|
|
180
|
+
|
|
181
|
+
## Geo helpers
|
|
182
|
+
|
|
183
|
+
Pure functions — no DB:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import {
|
|
187
|
+
haversineKm, walkingMinutes, boundingBox,
|
|
188
|
+
formatDistance, formatWalkingTime, walkingRoute,
|
|
189
|
+
} from '@nomideusz/svelte-search';
|
|
190
|
+
|
|
191
|
+
haversineKm(52.229, 21.012, 50.062, 19.937); // km between Warsaw and Kraków
|
|
192
|
+
walkingMinutes(0.8); // ~13 (min)
|
|
193
|
+
formatDistance(0.85); // "850 m"
|
|
194
|
+
formatWalkingTime(72); // "1 hr 12 min walk"
|
|
195
|
+
|
|
196
|
+
// Fast SQL pre-filter before exact Haversine:
|
|
197
|
+
const bb = boundingBox(52.229, 21.012, 5); // 5 km box
|
|
198
|
+
// WHERE lat BETWEEN bb.minLat AND bb.maxLat AND lng BETWEEN bb.minLng AND bb.maxLng
|
|
199
|
+
|
|
200
|
+
// Optional real walking route via OSRM (self-host for production):
|
|
201
|
+
const route = await walkingRoute(52.229, 21.012, 52.237, 21.017);
|
|
202
|
+
// { distanceM, durationS } | null
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Normalization & similarity
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import {
|
|
209
|
+
normalize, stripDiacriticsGeneric,
|
|
210
|
+
trigrams, trigramSimilarity,
|
|
211
|
+
levenshtein, levenshteinSimilarity,
|
|
212
|
+
isPostcode, hasGeoIntent, stripGeoIntent, stripStopWords,
|
|
213
|
+
} from '@nomideusz/svelte-search';
|
|
214
|
+
|
|
215
|
+
normalize('Łódź, ulica Piotrkowska', plLocale); // 'lodz ulica piotrkowska'
|
|
216
|
+
trigrams('hatha'); // ['hat','ath','tha']
|
|
217
|
+
trigramSimilarity('hatha', 'hata'); // ~0.67
|
|
218
|
+
levenshteinSimilarity('vinyasa', 'vinjasa'); // ~0.86
|
|
219
|
+
isPostcode('00-001'); // true
|
|
220
|
+
hasGeoIntent('yoga near me'); // true
|
|
221
|
+
stripGeoIntent('yoga near me'); // 'yoga'
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Locales
|
|
225
|
+
|
|
226
|
+
The package ships a Polish locale handling:
|
|
227
|
+
|
|
228
|
+
- diacritics (`ą → a`, `ł → l`, `ó → o`, …)
|
|
229
|
+
- stop words and phrases (`w`, `na`, `joga`, `szkoła jogi`, …)
|
|
230
|
+
- geo-intent patterns (`blisko mnie`, `niedaleko`, `w okolicy`, …)
|
|
231
|
+
- nominative-form stemming (`warszawie → warszawa`, `krakowie → krakow`, …)
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { plLocale } from '@nomideusz/svelte-search/locales/pl';
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Bring your own locale by implementing the `SearchLocale` interface — `stripDiacritics`, `stopTokens`, `stopPhrases`, `geoPatterns`, and optionally `locationStems`. No locale = generic NFD diacritic stripping and no stop words.
|
|
238
|
+
|
|
239
|
+
## Tracking
|
|
240
|
+
|
|
241
|
+
A minimal analytics helper that fire-and-forgets search events via `navigator.sendBeacon`, never throwing:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { createTracker } from '@nomideusz/svelte-search';
|
|
245
|
+
|
|
246
|
+
const { track } = createTracker({ endpoint: '/api/search-events' });
|
|
247
|
+
|
|
248
|
+
track({
|
|
249
|
+
query: 'hatha warszawa',
|
|
250
|
+
queryNormalized: 'hatha warszawa',
|
|
251
|
+
page: 'home',
|
|
252
|
+
action: 'filter',
|
|
253
|
+
layer: 'server',
|
|
254
|
+
resultCount: 12,
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
A session ID is stored in `sessionStorage` (no PII). The server endpoint shape is up to you.
|
|
259
|
+
|
|
260
|
+
## Database client interface
|
|
261
|
+
|
|
262
|
+
Any client with an `execute()` matching this shape works:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
interface DatabaseClient {
|
|
266
|
+
execute(query: { sql: string; args: unknown[] } | string): Promise<{
|
|
267
|
+
rows: Record<string, unknown>[];
|
|
268
|
+
lastInsertRowid?: bigint | number;
|
|
269
|
+
}>;
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
libsql's `Client` matches directly. For other drivers, wrap them:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
function wrapClient(client: MyClient): DatabaseClient {
|
|
277
|
+
return {
|
|
278
|
+
execute: (query) => typeof query === 'string'
|
|
279
|
+
? client.execute(query)
|
|
280
|
+
: client.execute({ sql: query.sql, args: query.args as any }),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Development
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
pnpm install
|
|
289
|
+
pnpm dev # SvelteKit dev server (demo)
|
|
290
|
+
pnpm check # Typecheck
|
|
291
|
+
pnpm test # Vitest
|
|
292
|
+
pnpm run package # Build the library
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
package/dist/core/engine.js
CHANGED
|
@@ -134,11 +134,11 @@ export function createSearchEngine(config) {
|
|
|
134
134
|
const rankExpr = ftsColumnWeights?.length
|
|
135
135
|
? `bm25(${tables.fts}, ${ftsColumnWeights.join(', ')})`
|
|
136
136
|
: 'fts.rank';
|
|
137
|
-
let sql = `
|
|
138
|
-
SELECT s.*, ${rankExpr} AS _ftsRank
|
|
139
|
-
FROM ${tables.fts} fts
|
|
140
|
-
JOIN ${tables.entities} s ON s.rowid = fts.rowid
|
|
141
|
-
WHERE ${tables.fts} MATCH ?
|
|
137
|
+
let sql = `
|
|
138
|
+
SELECT s.*, ${rankExpr} AS _ftsRank
|
|
139
|
+
FROM ${tables.fts} fts
|
|
140
|
+
JOIN ${tables.entities} s ON s.rowid = fts.rowid
|
|
141
|
+
WHERE ${tables.fts} MATCH ?
|
|
142
142
|
`;
|
|
143
143
|
const args = [ftsQuery];
|
|
144
144
|
if (locationSlug && columns.locationSlug) {
|
|
@@ -161,10 +161,10 @@ export function createSearchEngine(config) {
|
|
|
161
161
|
const tsCol = tables.fts; // e.g. "search_vector"
|
|
162
162
|
const args = [ftsQuery];
|
|
163
163
|
let argIdx = 2;
|
|
164
|
-
let sql = `
|
|
165
|
-
SELECT s.*, ts_rank(s.${tsCol}, to_tsquery('simple', ${ph(1, dialect)})) AS "_ftsRank"
|
|
166
|
-
FROM ${tables.entities} s
|
|
167
|
-
WHERE s.${tsCol} @@ to_tsquery('simple', ${ph(1, dialect)})
|
|
164
|
+
let sql = `
|
|
165
|
+
SELECT s.*, ts_rank(s.${tsCol}, to_tsquery('simple', ${ph(1, dialect)})) AS "_ftsRank"
|
|
166
|
+
FROM ${tables.entities} s
|
|
167
|
+
WHERE s.${tsCol} @@ to_tsquery('simple', ${ph(1, dialect)})
|
|
168
168
|
`;
|
|
169
169
|
if (locationSlug && columns.locationSlug) {
|
|
170
170
|
sql += ` AND s.${columns.locationSlug} = ${ph(argIdx, dialect)}`;
|
|
@@ -194,12 +194,12 @@ export function createSearchEngine(config) {
|
|
|
194
194
|
if (queryTrigrams.length === 0)
|
|
195
195
|
return [];
|
|
196
196
|
const phs = queryTrigrams.map(() => '?').join(',');
|
|
197
|
-
let sql = `
|
|
198
|
-
SELECT s.*, NULL AS _ftsRank,
|
|
199
|
-
(COUNT(DISTINCT t.${trigramColumns.trigram}) * 1.0 / ?) AS _fuzzyScore
|
|
200
|
-
FROM ${tables.trigrams} t
|
|
201
|
-
JOIN ${tables.entities} s ON s.${columns.id} = t.${trigramColumns.entityId}
|
|
202
|
-
WHERE t.${trigramColumns.trigram} IN (${phs})
|
|
197
|
+
let sql = `
|
|
198
|
+
SELECT s.*, NULL AS _ftsRank,
|
|
199
|
+
(COUNT(DISTINCT t.${trigramColumns.trigram}) * 1.0 / ?) AS _fuzzyScore
|
|
200
|
+
FROM ${tables.trigrams} t
|
|
201
|
+
JOIN ${tables.entities} s ON s.${columns.id} = t.${trigramColumns.entityId}
|
|
202
|
+
WHERE t.${trigramColumns.trigram} IN (${phs})
|
|
203
203
|
`;
|
|
204
204
|
const args = [queryTrigrams.length, ...queryTrigrams];
|
|
205
205
|
if (locationSlug && columns.locationSlug) {
|
|
@@ -228,10 +228,10 @@ export function createSearchEngine(config) {
|
|
|
228
228
|
const simExpr = simCols.length === 1
|
|
229
229
|
? `similarity(${simCols[0]}, ${ph(1, dialect)})`
|
|
230
230
|
: `GREATEST(${simCols.map(c => `similarity(COALESCE(${c}, ''), ${ph(1, dialect)})`).join(', ')})`;
|
|
231
|
-
let sql = `
|
|
232
|
-
SELECT s.*, NULL AS "_ftsRank", ${simExpr} AS "_fuzzyScore"
|
|
233
|
-
FROM ${tables.entities} s
|
|
234
|
-
WHERE ${simExpr} > 0.1
|
|
231
|
+
let sql = `
|
|
232
|
+
SELECT s.*, NULL AS "_ftsRank", ${simExpr} AS "_fuzzyScore"
|
|
233
|
+
FROM ${tables.entities} s
|
|
234
|
+
WHERE ${simExpr} > 0.1
|
|
235
235
|
`;
|
|
236
236
|
if (locationSlug && columns.locationSlug) {
|
|
237
237
|
sql += ` AND s.${columns.locationSlug} = ${ph(argIdx, dialect)}`;
|