searchsocket 0.4.0 → 0.6.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,181 @@
1
+ import type { SearchRequest, SearchResponse, SearchResult } from "../types";
2
+ export { default as SearchSocket } from "./SearchSocket.svelte";
3
+
4
+ export interface CreateSearchOptions {
5
+ endpoint?: string;
6
+ debounce?: number;
7
+ cache?: boolean;
8
+ cacheSize?: number;
9
+ fetchImpl?: typeof fetch;
10
+ topK?: number;
11
+ scope?: string;
12
+ pathPrefix?: string;
13
+ tags?: string[];
14
+ filters?: Record<string, string | number | boolean>;
15
+ groupBy?: "page" | "chunk";
16
+ maxSubResults?: number;
17
+ }
18
+
19
+ class LruCache<K, V> {
20
+ private map = new Map<K, V>();
21
+ constructor(private maxSize: number) {}
22
+
23
+ get(key: K): V | undefined {
24
+ const value = this.map.get(key);
25
+ if (value !== undefined) {
26
+ // Move to end (most recently used)
27
+ this.map.delete(key);
28
+ this.map.set(key, value);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ set(key: K, value: V): void {
34
+ this.map.delete(key);
35
+ this.map.set(key, value);
36
+ if (this.map.size > this.maxSize) {
37
+ const oldest = this.map.keys().next().value!;
38
+ this.map.delete(oldest);
39
+ }
40
+ }
41
+
42
+ get size(): number {
43
+ return this.map.size;
44
+ }
45
+ }
46
+
47
+ function buildCacheKey(query: string, options: CreateSearchOptions): string {
48
+ const parts: Record<string, unknown> = { q: query };
49
+ if (options.topK !== undefined) parts.topK = options.topK;
50
+ if (options.scope !== undefined) parts.scope = options.scope;
51
+ if (options.pathPrefix !== undefined) parts.pathPrefix = options.pathPrefix;
52
+ if (options.tags !== undefined) parts.tags = options.tags;
53
+ if (options.filters !== undefined) parts.filters = options.filters;
54
+ if (options.groupBy !== undefined) parts.groupBy = options.groupBy;
55
+ if (options.maxSubResults !== undefined) parts.maxSubResults = options.maxSubResults;
56
+ return JSON.stringify(parts);
57
+ }
58
+
59
+ export interface SearchState {
60
+ query: string;
61
+ readonly results: SearchResult[];
62
+ readonly loading: boolean;
63
+ readonly error: Error | null;
64
+ readonly destroy: () => void;
65
+ }
66
+
67
+ export function createSearch(options: CreateSearchOptions = {}): SearchState {
68
+ const endpoint = options.endpoint ?? "/api/search";
69
+ const debounceMs = options.debounce ?? 250;
70
+ const cacheEnabled = options.cache !== false;
71
+ const cacheSize = options.cacheSize ?? 50;
72
+ const fetchFn = options.fetchImpl ?? fetch;
73
+
74
+ const resultCache = new LruCache<string, SearchResult[]>(cacheSize);
75
+
76
+ let query = $state("");
77
+ let results = $state<SearchResult[]>([]);
78
+ let loading = $state(false);
79
+ let error = $state<Error | null>(null);
80
+
81
+ const destroy = $effect.root(() => {
82
+ $effect(() => {
83
+ const currentQuery = query;
84
+
85
+ if (!currentQuery.trim()) {
86
+ results = [];
87
+ loading = false;
88
+ error = null;
89
+ return;
90
+ }
91
+
92
+ const cacheKey = buildCacheKey(currentQuery, options);
93
+
94
+ if (cacheEnabled) {
95
+ const cached = resultCache.get(cacheKey);
96
+ if (cached) {
97
+ results = cached;
98
+ loading = false;
99
+ error = null;
100
+ return;
101
+ }
102
+ }
103
+
104
+ loading = true;
105
+ const controller = new AbortController();
106
+
107
+ const timer = setTimeout(async () => {
108
+ const request: SearchRequest = { q: currentQuery };
109
+ if (options.topK !== undefined) request.topK = options.topK;
110
+ if (options.scope !== undefined) request.scope = options.scope;
111
+ if (options.pathPrefix !== undefined) request.pathPrefix = options.pathPrefix;
112
+ if (options.tags !== undefined) request.tags = options.tags;
113
+ if (options.filters !== undefined) request.filters = options.filters;
114
+ if (options.groupBy !== undefined) request.groupBy = options.groupBy;
115
+ if (options.maxSubResults !== undefined) request.maxSubResults = options.maxSubResults;
116
+
117
+ try {
118
+ const response = await fetchFn(endpoint, {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json" },
121
+ body: JSON.stringify(request),
122
+ signal: controller.signal,
123
+ });
124
+
125
+ let payload: unknown;
126
+ try {
127
+ payload = await response.json();
128
+ } catch {
129
+ throw new Error(response.ok ? "Invalid search response" : "Search failed");
130
+ }
131
+
132
+ if (!response.ok) {
133
+ const message =
134
+ (payload as { error?: { message?: string } }).error?.message ?? "Search failed";
135
+ throw new Error(message);
136
+ }
137
+
138
+ const data = payload as SearchResponse;
139
+ if (cacheEnabled) {
140
+ resultCache.set(cacheKey, data.results);
141
+ }
142
+ results = data.results;
143
+ error = null;
144
+ } catch (err) {
145
+ if (err instanceof DOMException && err.name === "AbortError") return;
146
+ if (controller.signal.aborted) return;
147
+ error = err instanceof Error ? err : new Error(String(err));
148
+ results = [];
149
+ } finally {
150
+ if (!controller.signal.aborted) {
151
+ loading = false;
152
+ }
153
+ }
154
+ }, debounceMs);
155
+
156
+ return () => {
157
+ clearTimeout(timer);
158
+ controller.abort();
159
+ };
160
+ });
161
+ });
162
+
163
+ return {
164
+ get query() {
165
+ return query;
166
+ },
167
+ set query(v: string) {
168
+ query = v;
169
+ },
170
+ get results() {
171
+ return results;
172
+ },
173
+ get loading() {
174
+ return loading;
175
+ },
176
+ get error() {
177
+ return error;
178
+ },
179
+ destroy,
180
+ };
181
+ }