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/CHANGELOG.md +13 -0
- package/README-es.md +21 -5
- package/README-hi.md +22 -5
- package/README-pt.md +22 -5
- package/README.en.md +22 -5
- package/README.md +22 -5
- package/client.js +931 -41
- package/lib/combined.d.ts +20 -3
- package/lib/combined.d.ts.map +1 -1
- package/lib/combined.js +72 -12
- package/lib/combined.js.map +1 -1
- package/lib/content-search-worker.d.ts +3 -3
- package/lib/content-search-worker.js +11 -6
- package/lib/content-search-worker.js.map +1 -1
- package/lib/content-search.d.ts +81 -7
- package/lib/content-search.d.ts.map +1 -1
- package/lib/content-search.js +454 -36
- package/lib/content-search.js.map +1 -1
- package/lib/content.d.ts +20 -0
- package/lib/content.d.ts.map +1 -1
- package/lib/content.js +71 -21
- package/lib/content.js.map +1 -1
- package/lib/find.d.ts.map +1 -1
- package/lib/find.js +2 -1
- package/lib/find.js.map +1 -1
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +9 -2
- package/lib/http.js.map +1 -1
- package/package.json +1 -1
- package/src/combined.ts +95 -14
- package/src/content-search-worker.ts +15 -8
- package/src/content-search.ts +559 -36
- package/src/content.ts +81 -18
- package/src/find.ts +3 -2
- package/src/http.ts +11 -2
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
|
-
*
|
|
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<
|
|
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:
|
|
247
|
+
query: nameQuery,
|
|
194
248
|
limit: request.limit,
|
|
195
249
|
offset: 0,
|
|
196
250
|
sort: request.sort,
|
|
197
251
|
desc: request.desc,
|
|
198
|
-
regex:
|
|
199
|
-
caseSensitive:
|
|
200
|
-
wholeWord:
|
|
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 {
|
|
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
|
|
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:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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 {
|
|
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,
|
|
58
|
+
parentPort?.postMessage({ id: request.id, result, error })
|
|
52
59
|
})
|