@nomideusz/svelte-search 0.1.0

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.
@@ -0,0 +1,384 @@
1
+ // ============================================================
2
+ // @nomideusz/svelte-search — Search engine
3
+ // ============================================================
4
+ // Generic search engine driven by a SchemaAdapter. Handles:
5
+ // 1. Synonym expansion
6
+ // 2. Full-text search (FTS5 for SQLite, tsvector for PostgreSQL)
7
+ // 3. Trigram fuzzy fallback (custom tables for SQLite, pg_trgm for PostgreSQL)
8
+ // 4. Score blending (FTS rank + name similarity + field match + geo)
9
+ // 5. Quality gate (Levenshtein threshold for fuzzy-only results)
10
+ // 6. Relevance boundaries (distance-based result splitting)
11
+ //
12
+ // Dialect support:
13
+ // - 'sqlite' (default): FTS5 MATCH, custom trigram tables, rowid joins
14
+ // - 'postgres': tsvector/tsquery @@, pg_trgm similarity(), PK joins
15
+ import { normalize, trigrams, trigramSimilarity, levenshteinSimilarity, hasGeoIntent, stripGeoIntent } from './normalize.js';
16
+ import { haversineKm, walkingMinutes, boundingBox } from './geo.js';
17
+ // ── Query timeout helper ───────────────────────────────────
18
+ function withTimeout(promise, ms, fallback) {
19
+ let timer;
20
+ return Promise.race([
21
+ promise,
22
+ new Promise(resolve => { timer = setTimeout(() => resolve(fallback), ms); }),
23
+ ]).finally(() => clearTimeout(timer));
24
+ }
25
+ // ── Placeholder helpers ────────────────────────────────────
26
+ /** Returns $1, $2, ... for postgres or ?, ?, ... for sqlite */
27
+ function placeholders(count, dialect, startAt = 1) {
28
+ if (dialect === 'postgres') {
29
+ return Array.from({ length: count }, (_, i) => `$${startAt + i}`).join(',');
30
+ }
31
+ return Array(count).fill('?').join(',');
32
+ }
33
+ /** Returns the next placeholder: $N for postgres, ? for sqlite */
34
+ function ph(index, dialect) {
35
+ return dialect === 'postgres' ? `$${index}` : '?';
36
+ }
37
+ // ── Create search engine ───────────────────────────────────
38
+ export function createSearchEngine(config) {
39
+ const { db, adapter, locale, dialect = 'sqlite', ftsTimeoutMs = 5000, fuzzyTimeoutMs = 3000, primaryRadiusKm = 15, nearbyRadiusKm = 30, maxNearby = 5, qualityThreshold = 0.75, maxFtsTerms = 6, } = config;
40
+ const { tables, columns, trigramColumns } = adapter;
41
+ // ── Main search ────────────────────────────────────────
42
+ async function search(params) {
43
+ const { query, locationSlug, categorySlug, lat, lng, limit = 20, offset = 0 } = params;
44
+ if (!query?.trim()) {
45
+ if (locationSlug)
46
+ return searchAllInLocation(locationSlug, categorySlug, lat, lng, limit);
47
+ if (lat != null && lng != null)
48
+ return geoOnlySearch(lat, lng, limit);
49
+ return empty();
50
+ }
51
+ // Normalize + strip geo intent
52
+ const geoIntent = hasGeoIntent(query, locale);
53
+ const cleanQuery = geoIntent ? stripGeoIntent(query, locale) : query;
54
+ const normalized = normalize(cleanQuery, locale);
55
+ if (geoIntent && !normalized) {
56
+ if (locationSlug)
57
+ return searchAllInLocation(locationSlug, categorySlug, lat, lng, limit);
58
+ if (lat != null && lng != null)
59
+ return geoOnlySearch(lat, lng, limit);
60
+ return empty();
61
+ }
62
+ // Expand synonyms
63
+ const expanded = await expandSynonyms(normalized);
64
+ const ftsQuery = buildFtsQuery(expanded);
65
+ // Full-text search
66
+ const ftsResults = await withTimeout(ftsSearch(ftsQuery, locationSlug, categorySlug, limit * 3), ftsTimeoutMs, []);
67
+ // Trigram fallback if FTS returned few results
68
+ let fuzzyResults = [];
69
+ if (ftsResults.length < 5 && normalized.length >= 3) {
70
+ fuzzyResults = await withTimeout(trigramFuzzySearch(normalized, locationSlug, limit * 2), fuzzyTimeoutMs, []);
71
+ }
72
+ // Merge, score, and rank
73
+ const merged = deduplicateById([...ftsResults, ...fuzzyResults]);
74
+ const scored = scoreResults(merged, normalized, lat, lng, geoIntent);
75
+ // Quality gate — fuzzy-only results need high Levenshtein similarity
76
+ const qualified = scored.filter(r => {
77
+ if (r._hasFts)
78
+ return true;
79
+ const best = Math.max(levenshteinSimilarity(normalized, r._nameN || '', locale), levenshteinSimilarity(normalized, r._locationN || '', locale), levenshteinSimilarity(normalized, r._categoriesN || '', locale));
80
+ return best >= qualityThreshold;
81
+ });
82
+ qualified.sort((a, b) => b.score - a.score);
83
+ return applyRelevanceBoundaries(qualified, lat, lng, locationSlug, normalized, limit, offset);
84
+ }
85
+ // ── Location-scoped search ─────────────────────────────
86
+ async function searchAllInLocation(locationSlug, categorySlug, lat, lng, limit) {
87
+ const args = [locationSlug];
88
+ let argIdx = 2;
89
+ let sql = `SELECT * FROM ${tables.entities} WHERE ${columns.locationSlug} = ${ph(1, dialect)}`;
90
+ if (categorySlug && columns.categoriesNormalized) {
91
+ const catName = normalize(categorySlug.replace(/-/g, ' '), locale);
92
+ sql += ` AND ${columns.categoriesNormalized} LIKE ${ph(argIdx++, dialect)}`;
93
+ args.push(`%${catName}%`);
94
+ }
95
+ sql += ` LIMIT ${ph(argIdx, dialect)}`;
96
+ args.push(limit);
97
+ const result = await db.execute({ sql, args });
98
+ const rows = (result.rows).map(r => adapter.toResult(r, lat, lng));
99
+ if (lat != null && lng != null) {
100
+ rows.sort((a, b) => (a.distanceKm ?? 999) - (b.distanceKm ?? 999));
101
+ }
102
+ return { results: rows, nearby: [], noLocalResults: false, searchedPlace: null, nearestLocationWithEntities: null, totalFound: rows.length };
103
+ }
104
+ // ── Geo-only search ────────────────────────────────────
105
+ async function geoOnlySearch(lat, lng, limit) {
106
+ if (!columns.lat || !columns.lng)
107
+ return empty();
108
+ const bbox = boundingBox(lat, lng, primaryRadiusKm);
109
+ const result = await db.execute({
110
+ sql: `SELECT * FROM ${tables.entities} WHERE ${columns.lat} BETWEEN ${ph(1, dialect)} AND ${ph(2, dialect)} AND ${columns.lng} BETWEEN ${ph(3, dialect)} AND ${ph(4, dialect)} LIMIT ${ph(5, dialect)}`,
111
+ args: [bbox.minLat, bbox.maxLat, bbox.minLng, bbox.maxLng, limit * 2],
112
+ });
113
+ const rows = (result.rows).map(r => adapter.toResult(r, lat, lng));
114
+ rows.sort((a, b) => (a.distanceKm ?? 999) - (b.distanceKm ?? 999));
115
+ const capped = rows.slice(0, limit);
116
+ if (capped.length === 0) {
117
+ return { results: [], nearby: [], noLocalResults: true, searchedPlace: null, nearestLocationWithEntities: null, totalFound: 0 };
118
+ }
119
+ return { results: capped, nearby: [], noLocalResults: false, searchedPlace: null, nearestLocationWithEntities: null, totalFound: capped.length };
120
+ }
121
+ // ── Full-text search ───────────────────────────────────
122
+ async function ftsSearch(ftsQuery, locationSlug, categorySlug, limit) {
123
+ if (!ftsQuery)
124
+ return [];
125
+ if (dialect === 'postgres') {
126
+ return ftsSearchPostgres(ftsQuery, locationSlug, categorySlug, limit);
127
+ }
128
+ return ftsSearchSqlite(ftsQuery, locationSlug, categorySlug, limit);
129
+ }
130
+ async function ftsSearchSqlite(ftsQuery, locationSlug, categorySlug, limit) {
131
+ let sql = `
132
+ SELECT s.*, fts.rank AS _ftsRank
133
+ FROM ${tables.fts} fts
134
+ JOIN ${tables.entities} s ON s.rowid = fts.rowid
135
+ WHERE ${tables.fts} MATCH ?
136
+ `;
137
+ const args = [ftsQuery];
138
+ if (locationSlug && columns.locationSlug) {
139
+ sql += ` AND s.${columns.locationSlug} = ?`;
140
+ args.push(locationSlug);
141
+ }
142
+ if (categorySlug && columns.categoriesNormalized) {
143
+ const catName = normalize(categorySlug.replace(/-/g, ' '), locale);
144
+ sql += ` AND s.${columns.categoriesNormalized} LIKE ?`;
145
+ args.push(`%${catName}%`);
146
+ }
147
+ sql += ' ORDER BY fts.rank LIMIT ?';
148
+ args.push(limit);
149
+ const result = await db.execute({ sql, args });
150
+ return result.rows;
151
+ }
152
+ async function ftsSearchPostgres(ftsQuery, locationSlug, categorySlug, limit) {
153
+ // PostgreSQL: use tsvector column + tsquery
154
+ // The FTS table name is used as the tsvector column name on the entities table
155
+ const tsCol = tables.fts; // e.g. "search_vector"
156
+ const args = [ftsQuery];
157
+ let argIdx = 2;
158
+ let sql = `
159
+ SELECT s.*, ts_rank(s.${tsCol}, to_tsquery('simple', ${ph(1, dialect)})) AS "_ftsRank"
160
+ FROM ${tables.entities} s
161
+ WHERE s.${tsCol} @@ to_tsquery('simple', ${ph(1, dialect)})
162
+ `;
163
+ if (locationSlug && columns.locationSlug) {
164
+ sql += ` AND s.${columns.locationSlug} = ${ph(argIdx, dialect)}`;
165
+ args.push(locationSlug);
166
+ argIdx++;
167
+ }
168
+ if (categorySlug && columns.categoriesNormalized) {
169
+ const catName = normalize(categorySlug.replace(/-/g, ' '), locale);
170
+ sql += ` AND s.${columns.categoriesNormalized} ILIKE ${ph(argIdx, dialect)}`;
171
+ args.push(`%${catName}%`);
172
+ argIdx++;
173
+ }
174
+ sql += ` ORDER BY "_ftsRank" DESC LIMIT ${ph(argIdx, dialect)}`;
175
+ args.push(limit);
176
+ const result = await db.execute({ sql, args });
177
+ return result.rows;
178
+ }
179
+ // ── Trigram fuzzy search ───────────────────────────────
180
+ async function trigramFuzzySearch(normalized, locationSlug, limit) {
181
+ if (dialect === 'postgres') {
182
+ return trigramFuzzyPostgres(normalized, locationSlug, limit);
183
+ }
184
+ return trigramFuzzySqlite(normalized, locationSlug, limit);
185
+ }
186
+ async function trigramFuzzySqlite(normalized, locationSlug, limit) {
187
+ const queryTrigrams = trigrams(normalized, locale);
188
+ if (queryTrigrams.length === 0)
189
+ return [];
190
+ const phs = queryTrigrams.map(() => '?').join(',');
191
+ let sql = `
192
+ SELECT s.*, NULL AS _ftsRank,
193
+ (COUNT(DISTINCT t.${trigramColumns.trigram}) * 1.0 / ?) AS _fuzzyScore
194
+ FROM ${tables.trigrams} t
195
+ JOIN ${tables.entities} s ON s.${columns.id} = t.${trigramColumns.entityId}
196
+ WHERE t.${trigramColumns.trigram} IN (${phs})
197
+ `;
198
+ const args = [queryTrigrams.length, ...queryTrigrams];
199
+ if (locationSlug && columns.locationSlug) {
200
+ sql += ` AND s.${columns.locationSlug} = ?`;
201
+ args.push(locationSlug);
202
+ }
203
+ const minOverlap = queryTrigrams.length <= 3
204
+ ? Math.max(1, queryTrigrams.length - 1)
205
+ : Math.max(2, Math.ceil(queryTrigrams.length * 0.45));
206
+ sql += ` GROUP BY t.${trigramColumns.entityId} HAVING COUNT(DISTINCT t.${trigramColumns.trigram}) >= ? ORDER BY _fuzzyScore DESC LIMIT ?`;
207
+ args.push(minOverlap, limit);
208
+ const result = await db.execute({ sql, args });
209
+ return result.rows;
210
+ }
211
+ async function trigramFuzzyPostgres(normalized, locationSlug, limit) {
212
+ // PostgreSQL: use pg_trgm extension's similarity() function
213
+ // Searches against the nameNormalized column (primary) and optionally others
214
+ const args = [normalized];
215
+ let argIdx = 2;
216
+ // Build similarity expression across searchable text columns
217
+ const simCols = [columns.nameNormalized];
218
+ if (columns.categoriesNormalized)
219
+ simCols.push(columns.categoriesNormalized);
220
+ if (columns.locationNormalized)
221
+ simCols.push(columns.locationNormalized);
222
+ const simExpr = simCols.length === 1
223
+ ? `similarity(${simCols[0]}, ${ph(1, dialect)})`
224
+ : `GREATEST(${simCols.map(c => `similarity(COALESCE(${c}, ''), ${ph(1, dialect)})`).join(', ')})`;
225
+ let sql = `
226
+ SELECT s.*, NULL AS "_ftsRank", ${simExpr} AS "_fuzzyScore"
227
+ FROM ${tables.entities} s
228
+ WHERE ${simExpr} > 0.1
229
+ `;
230
+ if (locationSlug && columns.locationSlug) {
231
+ sql += ` AND s.${columns.locationSlug} = ${ph(argIdx, dialect)}`;
232
+ args.push(locationSlug);
233
+ argIdx++;
234
+ }
235
+ sql += ` ORDER BY "_fuzzyScore" DESC LIMIT ${ph(argIdx, dialect)}`;
236
+ args.push(limit);
237
+ const result = await db.execute({ sql, args });
238
+ return result.rows;
239
+ }
240
+ // ── Synonym expansion ──────────────────────────────────
241
+ async function expandSynonyms(normalized) {
242
+ const tokens = normalized.split(/\s+/).filter(Boolean);
243
+ const expanded = [...tokens];
244
+ const aliases = [...tokens];
245
+ for (let i = 0; i < tokens.length - 1; i++) {
246
+ aliases.push(`${tokens[i]} ${tokens[i + 1]}`);
247
+ }
248
+ if (aliases.length > 0) {
249
+ const phs = placeholders(aliases.length, dialect);
250
+ const result = await db.execute({
251
+ sql: `SELECT canonical FROM ${tables.synonyms} WHERE alias IN (${phs})`,
252
+ args: aliases,
253
+ });
254
+ for (const row of result.rows) {
255
+ const canonical = row.canonical;
256
+ if (!expanded.includes(canonical))
257
+ expanded.push(canonical);
258
+ }
259
+ }
260
+ return expanded;
261
+ }
262
+ // ── FTS query builder ──────────────────────────────────
263
+ function buildFtsQuery(tokens) {
264
+ const terms = tokens
265
+ .map(t => t.replace(/['"(){}*:^~\-!&|<>]/g, ''))
266
+ .filter(Boolean)
267
+ .slice(0, maxFtsTerms);
268
+ if (terms.length === 0)
269
+ return '';
270
+ if (dialect === 'postgres') {
271
+ // PostgreSQL tsquery: term1:* | term2:*
272
+ return terms.map(t => `${t}:*`).join(' | ');
273
+ }
274
+ // SQLite FTS5: "term1"* OR "term2"*
275
+ const fts5Terms = terms.map(t => `"${t}"*`);
276
+ return fts5Terms.length === 1 ? fts5Terms[0] : fts5Terms.join(' OR ');
277
+ }
278
+ // ── Scoring ────────────────────────────────────────────
279
+ function scoreResults(rows, normalized, lat, lng, geoBoost) {
280
+ return rows.map(row => {
281
+ const result = adapter.toResult(row, lat, lng);
282
+ let score = 0;
283
+ // FTS rank
284
+ const ftsRank = row._ftsRank;
285
+ if (ftsRank != null) {
286
+ if (dialect === 'postgres') {
287
+ // PostgreSQL ts_rank returns positive values, higher = better
288
+ score += Math.min(1, ftsRank) * 0.40;
289
+ }
290
+ else {
291
+ // SQLite FTS5 rank is negative, lower = better
292
+ score += Math.min(1, Math.max(0, -ftsRank / 20)) * 0.40;
293
+ }
294
+ }
295
+ // Name similarity
296
+ score += Math.max(trigramSimilarity(normalized, result._nameN || '', locale), levenshteinSimilarity(normalized, result._nameN || '', locale)) * 0.25;
297
+ // Field match (categories/location/area)
298
+ score += Math.max(trigramSimilarity(normalized, result._categoriesN || '', locale), trigramSimilarity(normalized, result._locationN || '', locale)) * 0.15;
299
+ // Fuzzy score from trigram search
300
+ const fuzzyScore = row._fuzzyScore;
301
+ if (fuzzyScore != null)
302
+ score += fuzzyScore * 0.30;
303
+ // Geo proximity
304
+ if (lat != null && lng != null && result.lat != null && result.lng != null) {
305
+ const distanceKm = haversineKm(lat, lng, result.lat, result.lng);
306
+ result.distanceKm = distanceKm;
307
+ result.walkingMin = walkingMinutes(distanceKm);
308
+ const proxScore = Math.max(0, 1 - distanceKm / 30);
309
+ score += proxScore * 0.15;
310
+ if (geoBoost)
311
+ score += proxScore * 0.25;
312
+ }
313
+ result.score = score;
314
+ result._hasFts = ftsRank != null;
315
+ return result;
316
+ });
317
+ }
318
+ // ── Relevance boundaries ───────────────────────────────
319
+ async function applyRelevanceBoundaries(scored, lat, lng, locationSlug, searchedPlace, limit, offset) {
320
+ if (locationSlug) {
321
+ const capped = stripInternal(scored.slice(offset, offset + limit));
322
+ return {
323
+ results: capped, nearby: [], noLocalResults: capped.length === 0,
324
+ searchedPlace, nearestLocationWithEntities: null,
325
+ totalFound: scored.length,
326
+ };
327
+ }
328
+ if (lat == null || lng == null) {
329
+ const capped = stripInternal(scored.slice(offset, offset + limit));
330
+ return {
331
+ results: capped, nearby: [], noLocalResults: capped.length === 0,
332
+ searchedPlace, nearestLocationWithEntities: null,
333
+ totalFound: scored.length,
334
+ };
335
+ }
336
+ const primary = [];
337
+ const nearby = [];
338
+ for (const r of scored) {
339
+ if (r.distanceKm == null) {
340
+ primary.push(r);
341
+ continue;
342
+ }
343
+ if (r.distanceKm <= primaryRadiusKm)
344
+ primary.push(r);
345
+ else if (r.distanceKm <= nearbyRadiusKm)
346
+ nearby.push(r);
347
+ }
348
+ if (primary.length === 0 && nearby.length === 0) {
349
+ return {
350
+ results: [], nearby: [], noLocalResults: true,
351
+ searchedPlace, nearestLocationWithEntities: null,
352
+ totalFound: 0,
353
+ };
354
+ }
355
+ return {
356
+ results: stripInternal(primary.slice(offset, offset + limit)),
357
+ nearby: stripInternal(nearby.slice(0, maxNearby)),
358
+ noLocalResults: false, searchedPlace,
359
+ nearestLocationWithEntities: null,
360
+ totalFound: primary.length + nearby.length,
361
+ };
362
+ }
363
+ // ── Helpers ────────────────────────────────────────────
364
+ function stripInternal(results) {
365
+ return results.map(r => {
366
+ const { _hasFts, _nameN, _locationN, _categoriesN, ...rest } = r;
367
+ return rest;
368
+ });
369
+ }
370
+ function deduplicateById(rows) {
371
+ const seen = new Set();
372
+ return rows.filter(r => {
373
+ const id = r[columns.id];
374
+ if (seen.has(id))
375
+ return false;
376
+ seen.add(id);
377
+ return true;
378
+ });
379
+ }
380
+ function empty() {
381
+ return { results: [], nearby: [], noLocalResults: false, searchedPlace: null, nearestLocationWithEntities: null, totalFound: 0 };
382
+ }
383
+ return { search };
384
+ }
@@ -0,0 +1,24 @@
1
+ /** Haversine distance in km between two points. */
2
+ export declare function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number;
3
+ /** Estimated walking time in minutes (1.3x for non-straight routes). */
4
+ export declare function walkingMinutes(km: number): number;
5
+ /** Bounding box for fast SQL pre-filter before exact Haversine. */
6
+ export declare function boundingBox(lat: number, lng: number, radiusKm: number): {
7
+ minLat: number;
8
+ maxLat: number;
9
+ minLng: number;
10
+ maxLng: number;
11
+ };
12
+ /** Format distance: <1km → "850 m", ≥1km → "2.3 km" */
13
+ export declare function formatDistance(km: number): string;
14
+ /** Format walking time: <60 → "12 min walk", ≥60 → "1 hr 15 min walk" */
15
+ export declare function formatWalkingTime(min: number): string;
16
+ /**
17
+ * Get real walking route from OSRM. Call only for top N results.
18
+ * Self-host OSRM with foot.lua profile for production.
19
+ * Falls back gracefully to null on any error.
20
+ */
21
+ export declare function walkingRoute(fromLat: number, fromLng: number, toLat: number, toLng: number, osrmBase?: string): Promise<{
22
+ distanceM: number;
23
+ durationS: number;
24
+ } | null>;
@@ -0,0 +1,54 @@
1
+ // ============================================================
2
+ // @nomideusz/svelte-search — Geo utilities
3
+ // ============================================================
4
+ // Haversine distance, bounding boxes, walking time, OSRM routing.
5
+ // Pure functions — no DB, no side effects.
6
+ const R = 6371; // Earth radius in km
7
+ const WALK_SPEED = 4.8; // km/h
8
+ /** Haversine distance in km between two points. */
9
+ export function haversineKm(lat1, lng1, lat2, lng2) {
10
+ const toRad = (d) => (d * Math.PI) / 180;
11
+ const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
12
+ const a = Math.sin(dLat / 2) ** 2 +
13
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
14
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
15
+ }
16
+ /** Estimated walking time in minutes (1.3x for non-straight routes). */
17
+ export function walkingMinutes(km) {
18
+ return Math.round((km * 1.3) / WALK_SPEED * 60);
19
+ }
20
+ /** Bounding box for fast SQL pre-filter before exact Haversine. */
21
+ export function boundingBox(lat, lng, radiusKm) {
22
+ const latD = radiusKm / 111.32;
23
+ const lngD = radiusKm / (111.32 * Math.cos((lat * Math.PI) / 180));
24
+ return { minLat: lat - latD, maxLat: lat + latD, minLng: lng - lngD, maxLng: lng + lngD };
25
+ }
26
+ /** Format distance: <1km → "850 m", ≥1km → "2.3 km" */
27
+ export function formatDistance(km) {
28
+ return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
29
+ }
30
+ /** Format walking time: <60 → "12 min walk", ≥60 → "1 hr 15 min walk" */
31
+ export function formatWalkingTime(min) {
32
+ if (min < 60)
33
+ return `${min} min walk`;
34
+ const h = Math.floor(min / 60), m = min % 60;
35
+ return m ? `${h} hr ${m} min walk` : `${h} hr walk`;
36
+ }
37
+ /**
38
+ * Get real walking route from OSRM. Call only for top N results.
39
+ * Self-host OSRM with foot.lua profile for production.
40
+ * Falls back gracefully to null on any error.
41
+ */
42
+ export async function walkingRoute(fromLat, fromLng, toLat, toLng, osrmBase = 'https://router.project-osrm.org') {
43
+ try {
44
+ const url = `${osrmBase}/route/v1/foot/${fromLng},${fromLat};${toLng},${toLat}?overview=false`;
45
+ const res = await fetch(url);
46
+ const data = await res.json();
47
+ if (data.code !== 'Ok' || !data.routes?.[0])
48
+ return null;
49
+ return { distanceM: data.routes[0].distance, durationS: data.routes[0].duration };
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,60 @@
1
+ import type { DatabaseClient, SchemaAdapter, SearchResult, SearchLocale, SqlDialect, ResolverLookups } from './types.js';
2
+ export interface IndexerConfig<TResult extends SearchResult = SearchResult> {
3
+ db: DatabaseClient;
4
+ adapter: SchemaAdapter<TResult>;
5
+ locale?: SearchLocale;
6
+ /** SQL dialect: 'sqlite' (default) or 'postgres' */
7
+ dialect?: SqlDialect;
8
+ }
9
+ export declare function createIndexer<TResult extends SearchResult = SearchResult>(config: IndexerConfig<TResult>): {
10
+ indexTrigrams: (entityId: string | number, entity: Record<string, unknown>) => Promise<void>;
11
+ reindexAllTrigrams: () => Promise<number>;
12
+ rebuildFts: () => Promise<void>;
13
+ checkFtsSync: () => Promise<{
14
+ inEntities: number;
15
+ inFts: number;
16
+ missingFromFts: number;
17
+ orphanedInFts: number;
18
+ }>;
19
+ updateSearchVector: (entityId: string | number, searchText: string, tsConfig?: string) => Promise<void>;
20
+ rebuildAllSearchVectors: (tsConfig?: string) => Promise<number>;
21
+ renormalizeAll: (updateRow: (db: DatabaseClient, row: Record<string, unknown>) => Promise<void>) => Promise<number>;
22
+ setupPostgresExtensions: () => Promise<void>;
23
+ };
24
+ export interface LoadLookupsConfig {
25
+ db: DatabaseClient;
26
+ locale?: SearchLocale;
27
+ /** SQL dialect: 'sqlite' (default) or 'postgres' */
28
+ dialect?: SqlDialect;
29
+ /** SQL + column mapping for locations */
30
+ locations: {
31
+ sql: string;
32
+ slugCol: string;
33
+ nameNormalizedCol: string;
34
+ countCol?: string;
35
+ latCol?: string;
36
+ lngCol?: string;
37
+ nameCol?: string;
38
+ };
39
+ /** SQL + column mapping for categories */
40
+ categories: {
41
+ sql: string;
42
+ slugCol: string;
43
+ nameNormalizedCol: string;
44
+ aliasesNormalizedCol?: string;
45
+ };
46
+ /** SQL + column mapping for areas/districts */
47
+ areas?: {
48
+ sql: string;
49
+ locationSlugCol: string;
50
+ areasJsonCol: string;
51
+ };
52
+ /** SQL for synonym aliases */
53
+ synonymsSql?: string;
54
+ /** Cache TTL in ms (default: 5 minutes) */
55
+ cacheTtlMs?: number;
56
+ }
57
+ export declare function createLookupsLoader(config: LoadLookupsConfig): {
58
+ load: () => Promise<ResolverLookups>;
59
+ invalidate: () => void;
60
+ };