dsh-locator 0.8.2 → 0.9.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.
package/src/combined.ts CHANGED
@@ -4,12 +4,21 @@
4
4
  *
5
5
  * The name index and the content index live in different stores, so this module
6
6
  * owns the orchestration and the merge rules as pure, injectable functions:
7
- * scope gating, the lazy-build guard for empty queries, per-group degradation
8
- * and de-duplication of a path that appears both as a body hit and a name hit.
7
+ * scope gating, the lazy-build guard for empty queries, per-group degradation,
8
+ * match-option routing (content vs name engine), page counts and de-duplication
9
+ * of a path that appears both as a body hit and a name hit.
9
10
  * @module dsh-locator/combined
10
11
  */
11
12
 
12
13
  import type { ContentHit, ContentState } from './content.ts'
14
+ import { decodeExtendedForNames } from './content-search.ts'
15
+ import type {
16
+ ContentCount,
17
+ ContentCountMode,
18
+ ContentMatchMode,
19
+ ContentSearchOptions,
20
+ ContentSearchResult,
21
+ } from './content-search.ts'
13
22
  import type { SearchArgs, SearchHit, SearchResult } from './runtime.ts'
14
23
 
15
24
  /** Which groups a combined request asked for; every scope is on by default. */
@@ -27,6 +36,15 @@ export interface CombinedRequest {
27
36
  sort: string
28
37
  desc: boolean
29
38
  scopes: CombinedScopes
39
+ /** How the query text is interpreted (`normal` by default). */
40
+ matchMode: ContentMatchMode
41
+ caseSensitive: boolean
42
+ wholeWord: boolean
43
+ /** Restrict the content group to one session (null means every document). */
44
+ sessionId: string | null
45
+ /** How much counting work the groups may spend. */
46
+ count: ContentCountMode
47
+ countLimit: number
30
48
  }
31
49
 
32
50
  /** One name-index group: files or directories. */
@@ -37,7 +55,10 @@ export interface NameGroup {
37
55
  truncated: boolean
38
56
  /** Rows dropped because the same path already appears in the content group. */
39
57
  deduped: number
58
+ /** The name engine always returns an exact total. */
59
+ count: ContentCount
40
60
  tookMs: number
61
+ warnings?: string[]
41
62
  error?: string
42
63
  }
43
64
 
@@ -47,6 +68,9 @@ export interface ContentGroup {
47
68
  returned: number
48
69
  /** True when the group was cut off by its page limit. */
49
70
  capped: boolean
71
+ /** Exact or bounded match count; `unknown` when counting was skipped. */
72
+ count: ContentCount
73
+ warnings: string[]
50
74
  building: boolean
51
75
  files: number
52
76
  sessions: number
@@ -71,7 +95,7 @@ export interface CombinedResponse {
71
95
  /** Dependencies required to run a combined search; the HTTP layer supplies them. */
72
96
  export interface CombinedDeps {
73
97
  search(args: SearchArgs): Promise<SearchResult>
74
- contentSearch?: (query: string, limit: number) => Promise<ContentHit[]>
98
+ contentSearch?: (query: string, limit: number, options: ContentSearchOptions) => Promise<ContentSearchResult>
75
99
  contentStatus?: () => ContentState
76
100
  ensure?: () => void
77
101
  maxResults: number
@@ -109,6 +133,12 @@ export function readCombinedRequest(body: Record<string, unknown>, maxResults: n
109
133
  sort: sortKey(body.sort),
110
134
  desc: body.desc === true,
111
135
  scopes: readScopes(body),
136
+ matchMode: matchMode(body.matchMode),
137
+ caseSensitive: body.caseSensitive === true,
138
+ wholeWord: body.wholeWord === true,
139
+ sessionId: typeof body.sessionId === 'string' && body.sessionId.trim() !== '' ? body.sessionId : null,
140
+ count: countMode(body.count),
141
+ countLimit: clamp(body.countLimit, 1, 5000, 1000),
112
142
  }
113
143
  }
114
144
 
@@ -188,16 +218,40 @@ export async function runCombinedSearch(deps: CombinedDeps, request: CombinedReq
188
218
  /** Page through a directory-shaped name query. */
189
219
  async function nameGroup(deps: CombinedDeps, request: CombinedRequest, files: boolean): Promise<NameGroup> {
190
220
  const started = Date.now()
221
+ const empty = (error?: string, warnings?: string[]): NameGroup => ({
222
+ hits: [],
223
+ returned: 0,
224
+ total: 0,
225
+ truncated: false,
226
+ deduped: 0,
227
+ count: { value: 0, kind: 'exact' },
228
+ tookMs: Date.now() - started,
229
+ ...(warnings === undefined || warnings.length === 0 ? {} : { warnings }),
230
+ ...(error === undefined ? {} : { error }),
231
+ })
232
+ // The name engine understands everything except Notepad++-style escapes:
233
+ // decode them here and drop control characters (file names cannot contain
234
+ // them) instead of searching for a literal backslash sequence.
235
+ let nameQuery = request.query
236
+ let warnings: string[] = []
237
+ if (request.matchMode === 'extended') {
238
+ const decoded = decodeExtendedForNames(request.query)
239
+ warnings = decoded.warnings
240
+ if (!decoded.ok) return empty(decoded.error ?? 'invalid-escape', warnings)
241
+ nameQuery = decoded.text
242
+ // An all-control-escape query must not degrade into "match everything".
243
+ if (nameQuery.trim() === '') return empty(undefined, [...new Set([...warnings, 'name-extended-empty'])])
244
+ }
191
245
  try {
192
246
  const result = await deps.search({
193
- query: request.query,
247
+ query: nameQuery,
194
248
  limit: request.limit,
195
249
  offset: 0,
196
250
  sort: request.sort,
197
251
  desc: request.desc,
198
- regex: false,
199
- caseSensitive: false,
200
- wholeWord: false,
252
+ regex: request.matchMode === 'regex',
253
+ caseSensitive: request.caseSensitive,
254
+ wholeWord: request.wholeWord,
201
255
  filesOnly: files,
202
256
  dirsOnly: !files,
203
257
  })
@@ -207,23 +261,27 @@ async function nameGroup(deps: CombinedDeps, request: CombinedRequest, files: bo
207
261
  total: result.total,
208
262
  truncated: result.truncated,
209
263
  deduped: 0,
264
+ count: { value: result.total, kind: 'exact' },
210
265
  tookMs: result.tookNs / 1e6,
266
+ ...(warnings.length === 0 ? {} : { warnings: [...new Set(warnings)] }),
211
267
  }
212
268
  } catch (error) {
213
- return { hits: [], returned: 0, total: 0, truncated: false, deduped: 0, tookMs: Date.now() - started, error: messageOf(error) }
269
+ return { ...empty(messageOf(error)), tookMs: Date.now() - started }
214
270
  }
215
271
  }
216
272
 
217
273
  /** Query the content store, or report its state without starting a build. */
218
274
  async function contentGroup(deps: CombinedDeps, request: CombinedRequest, ask: boolean): Promise<ContentGroup> {
219
275
  const started = Date.now()
220
- const empty = (): ContentGroup => {
276
+ const empty = (warnings: string[] = []): ContentGroup => {
221
277
  let state: ContentState | undefined
222
278
  try { state = deps.contentStatus?.() } catch { state = undefined }
223
279
  return {
224
280
  hits: [],
225
281
  returned: 0,
226
282
  capped: false,
283
+ count: { value: 0, kind: 'unknown' },
284
+ warnings,
227
285
  building: state?.building ?? false,
228
286
  files: state?.files ?? 0,
229
287
  sessions: state?.sessions ?? 0,
@@ -232,21 +290,37 @@ async function contentGroup(deps: CombinedDeps, request: CombinedRequest, ask: b
232
290
  tookMs: 0,
233
291
  }
234
292
  }
293
+ // 0.9.0 never scans the whole corpus from a panel keystroke: the content
294
+ // group pauses itself in regex mode while the name groups still run.
295
+ if (request.matchMode === 'regex') {
296
+ return { ...empty(['content-regex-paused']), error: 'content-regex-not-supported' }
297
+ }
235
298
  if (!ask || deps.contentSearch === undefined) return empty()
299
+ const options: ContentSearchOptions = {
300
+ matchMode: request.matchMode,
301
+ caseSensitive: request.caseSensitive,
302
+ wholeWord: request.wholeWord,
303
+ count: request.count,
304
+ countLimit: request.countLimit,
305
+ ...(request.sessionId === null ? {} : { sessionId: request.sessionId }),
306
+ }
236
307
  try {
237
308
  deps.ensure?.()
238
- const hits = await deps.contentSearch(request.query, request.contentLimit)
309
+ const result = await deps.contentSearch(request.query, request.contentLimit, options)
239
310
  const state = deps.contentStatus?.()
240
311
  return {
241
- hits,
242
- returned: hits.length,
243
- capped: hits.length >= request.contentLimit,
312
+ hits: result.hits,
313
+ returned: result.hits.length,
314
+ capped: result.hits.length >= request.contentLimit,
315
+ count: result.count,
316
+ warnings: result.warnings,
244
317
  building: state?.building ?? false,
245
318
  files: state?.files ?? 0,
246
319
  sessions: state?.sessions ?? 0,
247
320
  messages: state?.messages ?? 0,
248
321
  truncated: state?.truncated ?? false,
249
322
  tookMs: Date.now() - started,
323
+ ...(result.error === undefined ? {} : { error: result.error }),
250
324
  }
251
325
  } catch (error) {
252
326
  return { ...empty(), error: messageOf(error), tookMs: Date.now() - started }
@@ -258,6 +332,14 @@ function clamp(value: unknown, min: number, max: number, fallback: number): numb
258
332
  return Math.min(max, Math.max(min, Math.trunc(value)))
259
333
  }
260
334
 
335
+ function matchMode(value: unknown): ContentMatchMode {
336
+ return value === 'extended' || value === 'regex' ? value : 'normal'
337
+ }
338
+
339
+ function countMode(value: unknown): ContentCountMode {
340
+ return value === 'bounded' || value === 'none' ? value : 'exact'
341
+ }
342
+
261
343
  function sortKey(value: unknown): string {
262
344
  return value === 'name' || value === 'path' || value === 'size' || value === 'mtime' || value === 'ext' ? value : 'mtime'
263
345
  }
@@ -265,4 +347,3 @@ function sortKey(value: unknown): string {
265
347
  function messageOf(error: unknown): string {
266
348
  return error instanceof Error ? error.message : String(error)
267
349
  }
268
-
@@ -1,14 +1,13 @@
1
1
  /**
2
- * Search worker: the FTS5 query runs here so a slow content query can never
3
- * stall the DSH host's event loop. A read-only connection makes the build's
4
- * writes in the main thread safe to observe concurrently (WAL).
2
+ * Search worker: content queries run here so a slow query can never stall the
3
+ * DSH host's event loop. A read-only connection makes the build's writes in the
4
+ * main thread safe to observe concurrently (WAL).
5
5
  * @module dsh-locator/content-search-worker
6
6
  */
7
7
 
8
8
  import { parentPort, workerData } from 'node:worker_threads'
9
9
  import { DatabaseSync } from 'node:sqlite'
10
- import { searchContent } from './content-search.ts'
11
- import type { ContentHit } from './content.ts'
10
+ import { searchContent, type ContentSearchOptions, type ContentSearchResult } from './content-search.ts'
12
11
 
13
12
  interface Request {
14
13
  id: number
@@ -16,6 +15,7 @@ interface Request {
16
15
  close?: boolean
17
16
  query?: string
18
17
  limit?: number
18
+ options?: ContentSearchOptions
19
19
  }
20
20
 
21
21
  let db: DatabaseSync | null = null
@@ -41,12 +41,19 @@ parentPort?.on('message', (request: Request) => {
41
41
  parentPort?.postMessage({ id: request.id, ok: true })
42
42
  return
43
43
  }
44
- let hits: ContentHit[] = []
44
+ let result: ContentSearchResult = {
45
+ hits: [],
46
+ count: { value: 0, kind: 'unknown' },
47
+ warnings: [],
48
+ error: 'content-index-unavailable',
49
+ }
45
50
  let error = ''
46
51
  if (db !== null) {
47
- try { hits = searchContent(db, request.query ?? '', request.limit ?? 30) } catch (cause) {
52
+ try {
53
+ result = searchContent(db, request.query ?? '', request.limit ?? 30, request.options ?? {})
54
+ } catch (cause) {
48
55
  error = cause instanceof Error ? cause.message : String(cause)
49
56
  }
50
57
  }
51
- parentPort?.postMessage({ id: request.id, hits, error })
58
+ parentPort?.postMessage({ id: request.id, result, error })
52
59
  })