@86d-app/search 0.0.3

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,395 @@
1
+ import type { ModuleDataService } from "@86d-app/core";
2
+ import type {
3
+ SearchController,
4
+ SearchIndexItem,
5
+ SearchQuery,
6
+ SearchResult,
7
+ SearchSynonym,
8
+ } from "./service";
9
+
10
+ function normalize(text: string): string {
11
+ return text.toLowerCase().trim().replace(/\s+/g, " ");
12
+ }
13
+
14
+ function tokenize(text: string): string[] {
15
+ return normalize(text)
16
+ .split(/[\s\-_/,.]+/)
17
+ .filter((t) => t.length > 0);
18
+ }
19
+
20
+ function scoreMatch(
21
+ item: SearchIndexItem,
22
+ queryTokens: string[],
23
+ expandedTerms: Set<string>,
24
+ ): number {
25
+ let score = 0;
26
+ const titleLower = normalize(item.title);
27
+ const bodyLower = item.body ? normalize(item.body) : "";
28
+ const tagLower = item.tags.map((t) => normalize(t));
29
+
30
+ for (const token of queryTokens) {
31
+ const allTerms = [token, ...expandedTerms];
32
+ for (const term of allTerms) {
33
+ // Exact title match is highest value
34
+ if (titleLower === term) {
35
+ score += 100;
36
+ } else if (titleLower.startsWith(term)) {
37
+ score += 50;
38
+ } else if (titleLower.includes(term)) {
39
+ score += 25;
40
+ }
41
+
42
+ // Body match
43
+ if (bodyLower.includes(term)) {
44
+ score += 10;
45
+ }
46
+
47
+ // Tag match
48
+ for (const tag of tagLower) {
49
+ if (tag === term) {
50
+ score += 30;
51
+ } else if (tag.includes(term)) {
52
+ score += 15;
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ return score;
59
+ }
60
+
61
+ export function createSearchController(
62
+ data: ModuleDataService,
63
+ ): SearchController {
64
+ return {
65
+ async indexItem(params) {
66
+ // Check if already indexed — update if so
67
+ const existing = await data.findMany("searchIndex", {
68
+ where: {
69
+ entityType: params.entityType,
70
+ entityId: params.entityId,
71
+ },
72
+ take: 1,
73
+ });
74
+ const existingItems = existing as unknown as SearchIndexItem[];
75
+
76
+ const id =
77
+ existingItems.length > 0 ? existingItems[0].id : crypto.randomUUID();
78
+ const item: SearchIndexItem = {
79
+ id,
80
+ entityType: params.entityType,
81
+ entityId: params.entityId,
82
+ title: params.title,
83
+ body: params.body,
84
+ tags: params.tags ?? [],
85
+ url: params.url,
86
+ image: params.image,
87
+ metadata: params.metadata ?? {},
88
+ indexedAt: new Date(),
89
+ };
90
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
91
+ await data.upsert("searchIndex", id, item as Record<string, any>);
92
+ return item;
93
+ },
94
+
95
+ async removeFromIndex(entityType, entityId) {
96
+ const items = await data.findMany("searchIndex", {
97
+ where: { entityType, entityId },
98
+ });
99
+ const found = items as unknown as SearchIndexItem[];
100
+ if (found.length === 0) return false;
101
+ for (const item of found) {
102
+ await data.delete("searchIndex", item.id);
103
+ }
104
+ return true;
105
+ },
106
+
107
+ async search(query, options) {
108
+ const limit = options?.limit ?? 20;
109
+ const skip = options?.skip ?? 0;
110
+ const queryTokens = tokenize(query);
111
+
112
+ if (queryTokens.length === 0) {
113
+ return { results: [], total: 0 };
114
+ }
115
+
116
+ // Load synonyms for query expansion
117
+ const allSynonyms = (await data.findMany(
118
+ "searchSynonym",
119
+ {},
120
+ )) as unknown as SearchSynonym[];
121
+ const expandedTerms = new Set<string>();
122
+ for (const syn of allSynonyms) {
123
+ const synTermNorm = normalize(syn.term);
124
+ for (const token of queryTokens) {
125
+ if (token === synTermNorm) {
126
+ for (const s of syn.synonyms) {
127
+ expandedTerms.add(normalize(s));
128
+ }
129
+ }
130
+ // Also reverse: if a query token matches a synonym, expand to the term
131
+ for (const s of syn.synonyms) {
132
+ if (normalize(s) === token) {
133
+ expandedTerms.add(synTermNorm);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
140
+ const where: Record<string, any> = {};
141
+ if (options?.entityType) {
142
+ where.entityType = options.entityType;
143
+ }
144
+
145
+ const allItems = (await data.findMany("searchIndex", {
146
+ ...(Object.keys(where).length > 0 ? { where } : {}),
147
+ })) as unknown as SearchIndexItem[];
148
+
149
+ // Score and rank
150
+ const scored: SearchResult[] = [];
151
+ for (const item of allItems) {
152
+ const score = scoreMatch(item, queryTokens, expandedTerms);
153
+ if (score > 0) {
154
+ scored.push({ item, score });
155
+ }
156
+ }
157
+
158
+ scored.sort((a, b) => b.score - a.score);
159
+ const total = scored.length;
160
+ const results = scored.slice(skip, skip + limit);
161
+
162
+ return { results, total };
163
+ },
164
+
165
+ async suggest(prefix, limit = 10) {
166
+ const prefixNorm = normalize(prefix);
167
+ if (prefixNorm.length === 0) return [];
168
+
169
+ // Combine popular terms + index titles
170
+ const allQueries = (await data.findMany(
171
+ "searchQuery",
172
+ {},
173
+ )) as unknown as SearchQuery[];
174
+
175
+ // Count query frequency
176
+ const termCounts = new Map<string, number>();
177
+ for (const q of allQueries) {
178
+ if (q.resultCount > 0 && q.normalizedTerm.startsWith(prefixNorm)) {
179
+ termCounts.set(q.term, (termCounts.get(q.term) ?? 0) + 1);
180
+ }
181
+ }
182
+
183
+ // Also match index titles
184
+ const allItems = (await data.findMany(
185
+ "searchIndex",
186
+ {},
187
+ )) as unknown as SearchIndexItem[];
188
+ const titleSuggestions: string[] = [];
189
+ for (const item of allItems) {
190
+ if (normalize(item.title).includes(prefixNorm)) {
191
+ titleSuggestions.push(item.title);
192
+ }
193
+ }
194
+
195
+ // Merge: popular terms first, then title suggestions
196
+ const popularTerms = Array.from(termCounts.entries())
197
+ .sort((a, b) => b[1] - a[1])
198
+ .map(([term]) => term);
199
+
200
+ const seen = new Set<string>();
201
+ const suggestions: string[] = [];
202
+ for (const term of popularTerms) {
203
+ const norm = normalize(term);
204
+ if (!seen.has(norm)) {
205
+ seen.add(norm);
206
+ suggestions.push(term);
207
+ }
208
+ }
209
+ for (const title of titleSuggestions) {
210
+ const norm = normalize(title);
211
+ if (!seen.has(norm)) {
212
+ seen.add(norm);
213
+ suggestions.push(title);
214
+ }
215
+ }
216
+
217
+ return suggestions.slice(0, limit);
218
+ },
219
+
220
+ async recordQuery(term, resultCount, sessionId) {
221
+ const id = crypto.randomUUID();
222
+ const query: SearchQuery = {
223
+ id,
224
+ term,
225
+ normalizedTerm: normalize(term),
226
+ resultCount,
227
+ sessionId,
228
+ searchedAt: new Date(),
229
+ };
230
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
231
+ await data.upsert("searchQuery", id, query as Record<string, any>);
232
+ return query;
233
+ },
234
+
235
+ async getRecentQueries(sessionId, limit = 10) {
236
+ const all = (await data.findMany("searchQuery", {
237
+ where: { sessionId },
238
+ })) as unknown as SearchQuery[];
239
+
240
+ // Sort by date desc, deduplicate by normalized term
241
+ all.sort(
242
+ (a, b) =>
243
+ new Date(b.searchedAt).getTime() - new Date(a.searchedAt).getTime(),
244
+ );
245
+
246
+ const seen = new Set<string>();
247
+ const results: SearchQuery[] = [];
248
+ for (const q of all) {
249
+ if (!seen.has(q.normalizedTerm)) {
250
+ seen.add(q.normalizedTerm);
251
+ results.push(q);
252
+ }
253
+ if (results.length >= limit) break;
254
+ }
255
+
256
+ return results;
257
+ },
258
+
259
+ async getPopularTerms(limit = 20) {
260
+ const all = (await data.findMany(
261
+ "searchQuery",
262
+ {},
263
+ )) as unknown as SearchQuery[];
264
+
265
+ const termStats = new Map<
266
+ string,
267
+ { term: string; count: number; totalResults: number }
268
+ >();
269
+ for (const q of all) {
270
+ const existing = termStats.get(q.normalizedTerm);
271
+ if (existing) {
272
+ existing.count += 1;
273
+ existing.totalResults += q.resultCount;
274
+ } else {
275
+ termStats.set(q.normalizedTerm, {
276
+ term: q.term,
277
+ count: 1,
278
+ totalResults: q.resultCount,
279
+ });
280
+ }
281
+ }
282
+
283
+ return Array.from(termStats.values())
284
+ .map((s) => ({
285
+ term: s.term,
286
+ count: s.count,
287
+ avgResultCount: Math.round(s.totalResults / s.count),
288
+ }))
289
+ .sort((a, b) => b.count - a.count)
290
+ .slice(0, limit);
291
+ },
292
+
293
+ async getZeroResultQueries(limit = 20) {
294
+ const all = (await data.findMany(
295
+ "searchQuery",
296
+ {},
297
+ )) as unknown as SearchQuery[];
298
+
299
+ const termStats = new Map<string, { term: string; count: number }>();
300
+ for (const q of all) {
301
+ if (q.resultCount === 0) {
302
+ const existing = termStats.get(q.normalizedTerm);
303
+ if (existing) {
304
+ existing.count += 1;
305
+ } else {
306
+ termStats.set(q.normalizedTerm, {
307
+ term: q.term,
308
+ count: 1,
309
+ });
310
+ }
311
+ }
312
+ }
313
+
314
+ return Array.from(termStats.values())
315
+ .map((s) => ({
316
+ term: s.term,
317
+ count: s.count,
318
+ avgResultCount: 0,
319
+ }))
320
+ .sort((a, b) => b.count - a.count)
321
+ .slice(0, limit);
322
+ },
323
+
324
+ async getAnalytics() {
325
+ const all = (await data.findMany(
326
+ "searchQuery",
327
+ {},
328
+ )) as unknown as SearchQuery[];
329
+
330
+ if (all.length === 0) {
331
+ return {
332
+ totalQueries: 0,
333
+ uniqueTerms: 0,
334
+ avgResultCount: 0,
335
+ zeroResultCount: 0,
336
+ zeroResultRate: 0,
337
+ };
338
+ }
339
+
340
+ const uniqueTerms = new Set(all.map((q) => q.normalizedTerm));
341
+ const totalResults = all.reduce((sum, q) => sum + q.resultCount, 0);
342
+ const zeroResultCount = all.filter((q) => q.resultCount === 0).length;
343
+
344
+ return {
345
+ totalQueries: all.length,
346
+ uniqueTerms: uniqueTerms.size,
347
+ avgResultCount: Math.round(totalResults / all.length),
348
+ zeroResultCount,
349
+ zeroResultRate: Math.round((zeroResultCount / all.length) * 100),
350
+ };
351
+ },
352
+
353
+ async addSynonym(term, synonyms) {
354
+ // Check if synonym for this term already exists
355
+ const existing = await data.findMany("searchSynonym", {
356
+ where: { term: normalize(term) },
357
+ take: 1,
358
+ });
359
+ const existingItems = existing as unknown as SearchSynonym[];
360
+
361
+ const id =
362
+ existingItems.length > 0 ? existingItems[0].id : crypto.randomUUID();
363
+ const synonym: SearchSynonym = {
364
+ id,
365
+ term: normalize(term),
366
+ synonyms: synonyms.map((s) => s.trim()),
367
+ createdAt:
368
+ existingItems.length > 0 ? existingItems[0].createdAt : new Date(),
369
+ };
370
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
371
+ await data.upsert("searchSynonym", id, synonym as Record<string, any>);
372
+ return synonym;
373
+ },
374
+
375
+ async removeSynonym(id) {
376
+ const existing = await data.get("searchSynonym", id);
377
+ if (!existing) return false;
378
+ await data.delete("searchSynonym", id);
379
+ return true;
380
+ },
381
+
382
+ async listSynonyms() {
383
+ const all = (await data.findMany(
384
+ "searchSynonym",
385
+ {},
386
+ )) as unknown as SearchSynonym[];
387
+ return all;
388
+ },
389
+
390
+ async getIndexCount() {
391
+ const all = await data.findMany("searchIndex", {});
392
+ return all.length;
393
+ },
394
+ };
395
+ }
package/src/service.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { ModuleController } from "@86d-app/core";
2
+
3
+ export interface SearchIndexItem {
4
+ id: string;
5
+ entityType: string;
6
+ entityId: string;
7
+ title: string;
8
+ body?: string | undefined;
9
+ tags: string[];
10
+ url: string;
11
+ image?: string | undefined;
12
+ metadata: Record<string, unknown>;
13
+ indexedAt: Date;
14
+ }
15
+
16
+ export interface SearchQuery {
17
+ id: string;
18
+ term: string;
19
+ normalizedTerm: string;
20
+ resultCount: number;
21
+ sessionId?: string | undefined;
22
+ searchedAt: Date;
23
+ }
24
+
25
+ export interface SearchSynonym {
26
+ id: string;
27
+ term: string;
28
+ synonyms: string[];
29
+ createdAt: Date;
30
+ }
31
+
32
+ export interface SearchResult {
33
+ item: SearchIndexItem;
34
+ score: number;
35
+ }
36
+
37
+ export interface SearchAnalyticsSummary {
38
+ totalQueries: number;
39
+ uniqueTerms: number;
40
+ avgResultCount: number;
41
+ zeroResultCount: number;
42
+ zeroResultRate: number;
43
+ }
44
+
45
+ export interface PopularTerm {
46
+ term: string;
47
+ count: number;
48
+ avgResultCount: number;
49
+ }
50
+
51
+ export interface SearchController extends ModuleController {
52
+ indexItem(params: {
53
+ entityType: string;
54
+ entityId: string;
55
+ title: string;
56
+ body?: string | undefined;
57
+ tags?: string[] | undefined;
58
+ url: string;
59
+ image?: string | undefined;
60
+ metadata?: Record<string, unknown> | undefined;
61
+ }): Promise<SearchIndexItem>;
62
+
63
+ removeFromIndex(entityType: string, entityId: string): Promise<boolean>;
64
+
65
+ search(
66
+ query: string,
67
+ options?: {
68
+ entityType?: string | undefined;
69
+ limit?: number | undefined;
70
+ skip?: number | undefined;
71
+ },
72
+ ): Promise<{ results: SearchResult[]; total: number }>;
73
+
74
+ suggest(prefix: string, limit?: number): Promise<string[]>;
75
+
76
+ recordQuery(
77
+ term: string,
78
+ resultCount: number,
79
+ sessionId?: string | undefined,
80
+ ): Promise<SearchQuery>;
81
+
82
+ getRecentQueries(sessionId: string, limit?: number): Promise<SearchQuery[]>;
83
+
84
+ getPopularTerms(limit?: number): Promise<PopularTerm[]>;
85
+
86
+ getZeroResultQueries(limit?: number): Promise<PopularTerm[]>;
87
+
88
+ getAnalytics(): Promise<SearchAnalyticsSummary>;
89
+
90
+ addSynonym(term: string, synonyms: string[]): Promise<SearchSynonym>;
91
+
92
+ removeSynonym(id: string): Promise<boolean>;
93
+
94
+ listSynonyms(): Promise<SearchSynonym[]>;
95
+
96
+ getIndexCount(): Promise<number>;
97
+ }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+
5
+ export function useSearchApi() {
6
+ const client = useModuleClient();
7
+ return {
8
+ search: client.module("search").store["/search"],
9
+ suggest: client.module("search").store["/search/suggest"],
10
+ recent: client.module("search").store["/search/recent"],
11
+ };
12
+ }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import type { MDXComponents } from "mdx/types";
4
+ import { SearchBar } from "./search-bar";
5
+ import { SearchPage } from "./search-page";
6
+ import { SearchResults } from "./search-results";
7
+
8
+ export default {
9
+ SearchBar,
10
+ SearchResults,
11
+ SearchPage,
12
+ } satisfies MDXComponents;
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useSearchApi } from "./_hooks";
5
+
6
+ interface SearchBarProps {
7
+ placeholder?: string | undefined;
8
+ onSearch?: ((query: string) => void) | undefined;
9
+ }
10
+
11
+ export function SearchBar({
12
+ placeholder = "Search...",
13
+ onSearch,
14
+ }: SearchBarProps) {
15
+ const api = useSearchApi();
16
+ const [query, setQuery] = useState("");
17
+ const [suggestions, setSuggestions] = useState<string[]>([]);
18
+ const [showSuggestions, setShowSuggestions] = useState(false);
19
+ const [selectedIndex, setSelectedIndex] = useState(-1);
20
+ const inputRef = useRef<HTMLInputElement>(null);
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+
23
+ const { data: suggestData } =
24
+ query.trim().length >= 2
25
+ ? (api.suggest.useQuery({ q: query.trim(), limit: "8" }) as {
26
+ data: { suggestions: string[] } | undefined;
27
+ })
28
+ : { data: undefined };
29
+
30
+ useEffect(() => {
31
+ if (suggestData?.suggestions) {
32
+ setSuggestions(suggestData.suggestions);
33
+ } else {
34
+ setSuggestions([]);
35
+ }
36
+ }, [suggestData]);
37
+
38
+ const handleSubmit = useCallback(
39
+ (term: string) => {
40
+ const trimmed = term.trim();
41
+ if (trimmed.length === 0) return;
42
+ setShowSuggestions(false);
43
+ setSelectedIndex(-1);
44
+ onSearch?.(trimmed);
45
+ },
46
+ [onSearch],
47
+ );
48
+
49
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
50
+ if (e.key === "ArrowDown") {
51
+ e.preventDefault();
52
+ setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
53
+ } else if (e.key === "ArrowUp") {
54
+ e.preventDefault();
55
+ setSelectedIndex((i) => Math.max(i - 1, -1));
56
+ } else if (e.key === "Enter") {
57
+ e.preventDefault();
58
+ if (selectedIndex >= 0 && suggestions[selectedIndex]) {
59
+ setQuery(suggestions[selectedIndex]);
60
+ handleSubmit(suggestions[selectedIndex]);
61
+ } else {
62
+ handleSubmit(query);
63
+ }
64
+ } else if (e.key === "Escape") {
65
+ setShowSuggestions(false);
66
+ setSelectedIndex(-1);
67
+ }
68
+ };
69
+
70
+ // Close suggestions on click outside
71
+ useEffect(() => {
72
+ const handleClick = (e: MouseEvent) => {
73
+ if (
74
+ containerRef.current &&
75
+ !containerRef.current.contains(e.target as Node)
76
+ ) {
77
+ setShowSuggestions(false);
78
+ }
79
+ };
80
+ document.addEventListener("mousedown", handleClick);
81
+ return () => document.removeEventListener("mousedown", handleClick);
82
+ }, []);
83
+
84
+ return (
85
+ <div ref={containerRef} className="relative w-full">
86
+ <div className="relative">
87
+ <svg
88
+ className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
89
+ xmlns="http://www.w3.org/2000/svg"
90
+ fill="none"
91
+ viewBox="0 0 24 24"
92
+ strokeWidth={2}
93
+ stroke="currentColor"
94
+ aria-hidden="true"
95
+ >
96
+ <title>Search</title>
97
+ <path
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
101
+ />
102
+ </svg>
103
+ <input
104
+ ref={inputRef}
105
+ type="search"
106
+ value={query}
107
+ onChange={(e) => {
108
+ setQuery(e.target.value);
109
+ setShowSuggestions(true);
110
+ setSelectedIndex(-1);
111
+ }}
112
+ onFocus={() => setShowSuggestions(true)}
113
+ onKeyDown={handleKeyDown}
114
+ placeholder={placeholder}
115
+ className="w-full rounded-lg border border-border bg-background py-2.5 pr-4 pl-10 text-foreground text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
116
+ aria-label="Search"
117
+ aria-autocomplete="list"
118
+ aria-expanded={showSuggestions && suggestions.length > 0}
119
+ role="combobox"
120
+ />
121
+ </div>
122
+
123
+ {showSuggestions && suggestions.length > 0 && (
124
+ <div
125
+ role="listbox"
126
+ className="absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-background shadow-lg"
127
+ >
128
+ {suggestions.map((suggestion, index) => (
129
+ <div
130
+ key={suggestion}
131
+ role="option"
132
+ tabIndex={-1}
133
+ aria-selected={index === selectedIndex}
134
+ className={`cursor-pointer px-4 py-2 text-sm ${
135
+ index === selectedIndex
136
+ ? "bg-muted text-foreground"
137
+ : "text-foreground hover:bg-muted/50"
138
+ }`}
139
+ onMouseDown={(e) => {
140
+ e.preventDefault();
141
+ setQuery(suggestion);
142
+ handleSubmit(suggestion);
143
+ }}
144
+ onMouseEnter={() => setSelectedIndex(index)}
145
+ >
146
+ {suggestion}
147
+ </div>
148
+ ))}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { SearchBar } from "./search-bar";
5
+ import { SearchResults } from "./search-results";
6
+
7
+ export function SearchPage({ sessionId }: { sessionId?: string | undefined }) {
8
+ const [query, setQuery] = useState("");
9
+
10
+ const handleSearch = useCallback((term: string) => {
11
+ setQuery(term);
12
+ }, []);
13
+
14
+ return (
15
+ <div className="mx-auto max-w-2xl px-4 py-8">
16
+ <h1 className="mb-6 font-semibold text-2xl text-foreground">Search</h1>
17
+ <SearchBar
18
+ placeholder="Search products, articles..."
19
+ onSearch={handleSearch}
20
+ />
21
+ <div className="mt-6">
22
+ <SearchResults query={query} sessionId={sessionId} />
23
+ </div>
24
+ </div>
25
+ );
26
+ }