@deepwatch/dsh-library 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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/lib/client/components.d.ts +75 -0
- package/lib/client/components.js +60 -0
- package/lib/client/index.d.ts +46 -0
- package/lib/client/index.js +52 -0
- package/lib/client/library-mode.d.ts +37 -0
- package/lib/client/library-mode.js +21 -0
- package/lib/client/read-plane.d.ts +134 -0
- package/lib/client/read-plane.js +193 -0
- package/lib/client/search-view.d.ts +44 -0
- package/lib/client/search-view.js +233 -0
- package/lib/client.js +1882 -0
- package/lib/client.js.map +1 -0
- package/lib/index-store.d.ts +221 -0
- package/lib/index-store.js +570 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +20 -0
- package/lib/search.d.ts +129 -0
- package/lib/search.js +180 -0
- package/lib/sources.d.ts +146 -0
- package/lib/sources.js +135 -0
- package/package.json +101 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library's local search index.
|
|
3
|
+
*
|
|
4
|
+
* The evidence store is the source of truth. This is a *derived* structure: it
|
|
5
|
+
* can be thrown away and rebuilt from the records at any time, and every design
|
|
6
|
+
* decision here follows from that one fact. A derived index that cannot be
|
|
7
|
+
* safely deleted is not derived, it is a second database with none of the
|
|
8
|
+
* guarantees of the first.
|
|
9
|
+
*
|
|
10
|
+
* What it is:
|
|
11
|
+
*
|
|
12
|
+
* - **Local.** An inverted index over tokens, held in memory and serialisable
|
|
13
|
+
* to plain JSON. No service, no network, no embedding model. Semantic
|
|
14
|
+
* retrieval stays a future optional plugin; lexical matching is what works
|
|
15
|
+
* offline on any machine, today.
|
|
16
|
+
* - **Versioned.** Every serialised index carries `INDEX_VERSION` and a
|
|
17
|
+
* digest of its own contents. A version it does not recognise, or a digest
|
|
18
|
+
* that does not match, is a corrupt index — detected on load, reported, and
|
|
19
|
+
* rebuilt rather than half-trusted.
|
|
20
|
+
* - **Incremental and idempotent.** Indexing the same record twice leaves the
|
|
21
|
+
* index identical. Re-indexing a changed record replaces its postings
|
|
22
|
+
* rather than adding a second copy, so a document cannot accumulate ghosts
|
|
23
|
+
* of its former text.
|
|
24
|
+
* - **Recoverable.** Indexing records progress, so an interrupted run resumes
|
|
25
|
+
* from what it completed instead of starting over or, worse, believing it
|
|
26
|
+
* finished.
|
|
27
|
+
*
|
|
28
|
+
* Queries are bounded, paginated and cancellable by construction: a query
|
|
29
|
+
* carries its own limit, and a caller can pass an `AbortSignal`. An unbounded
|
|
30
|
+
* search over a large corpus is a denial of service you wrote yourself.
|
|
31
|
+
*
|
|
32
|
+
* @module @deepwatch/dsh-library/index-store
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Bumped when the serialised shape changes.
|
|
36
|
+
*
|
|
37
|
+
* An index written by a newer build is refused rather than reinterpreted. A
|
|
38
|
+
* structure read under the wrong assumptions produces confident wrong answers,
|
|
39
|
+
* which is worse than producing none.
|
|
40
|
+
*/
|
|
41
|
+
export const INDEX_VERSION = 1;
|
|
42
|
+
/** The largest page anyone may ask for. */
|
|
43
|
+
export const MAX_LIMIT = 200;
|
|
44
|
+
const DEFAULT_LIMIT = 25;
|
|
45
|
+
/** Han, Hiragana, Katakana — scripts written without spaces. */
|
|
46
|
+
const CJK = /[-ヿ㐀-䶿一-鿿]/u;
|
|
47
|
+
/**
|
|
48
|
+
* Split text into searchable tokens.
|
|
49
|
+
*
|
|
50
|
+
* Unicode-aware on purpose. Splitting on `[a-z0-9]+` would silently drop every
|
|
51
|
+
* Arabic, Chinese, Cyrillic and Greek record in the corpus — they would index
|
|
52
|
+
* as nothing and return nothing, and the failure would look like an empty
|
|
53
|
+
* library rather than a broken tokenizer.
|
|
54
|
+
*
|
|
55
|
+
* CJK has no spaces, so a run is emitted as its characters and its adjacent
|
|
56
|
+
* bigrams rather than whole. Keeping the run would make it a token only an
|
|
57
|
+
* exact repetition could match, and since every query term must be present,
|
|
58
|
+
* that run token would then fail a query whose characters are all indexed.
|
|
59
|
+
*
|
|
60
|
+
* Case folding is `toLowerCase`, which is a no-op for scripts without case and
|
|
61
|
+
* correct for those with it. Diacritics are deliberately *kept*: the original
|
|
62
|
+
* text is the evidence, and folding "عَلَم" into "علم" would make a citation
|
|
63
|
+
* resolve to something the source does not say.
|
|
64
|
+
*
|
|
65
|
+
* `\p{M}` is in the continuation class for the same reason, and its absence was
|
|
66
|
+
* a real bug. Arabic harakat are Unicode *Mark*, not *Letter*, so a class of
|
|
67
|
+
* letters and numbers alone breaks at every vowel sign: vocalised "عَلَم"
|
|
68
|
+
* tokenized as three separate consonants, and no query could ever match it.
|
|
69
|
+
*/
|
|
70
|
+
export function tokenize(text) {
|
|
71
|
+
if (text === '')
|
|
72
|
+
return [];
|
|
73
|
+
const tokens = [];
|
|
74
|
+
for (const match of text.toLowerCase().matchAll(/[\p{L}\p{N}][\p{L}\p{N}\p{M}_'-]*/gu)) {
|
|
75
|
+
const token = match[0];
|
|
76
|
+
if (CJK.test(token) && token.length > 1) {
|
|
77
|
+
// Characters and adjacent bigrams, and deliberately *not* the whole run.
|
|
78
|
+
//
|
|
79
|
+
// Emitting the run would make "安装程序" a token only an exact repetition
|
|
80
|
+
// could match: a document containing "安装程序报告错误" indexes that entire
|
|
81
|
+
// string, and a search for the first four characters finds nothing. Since
|
|
82
|
+
// every term has to be present, the run token would then fail the query
|
|
83
|
+
// even though the characters are all there. Bigrams are the standard
|
|
84
|
+
// answer to a script with no spaces and no segmenter.
|
|
85
|
+
for (const character of token)
|
|
86
|
+
tokens.push(character);
|
|
87
|
+
for (let at = 0; at + 1 < token.length; at += 1)
|
|
88
|
+
tokens.push(token.slice(at, at + 2));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
tokens.push(token);
|
|
92
|
+
}
|
|
93
|
+
return tokens;
|
|
94
|
+
}
|
|
95
|
+
/** A stable digest over the index's own contents, for corruption detection. */
|
|
96
|
+
function digestOf(documents, postings) {
|
|
97
|
+
// Order-independent: the same content must produce the same digest whatever
|
|
98
|
+
// order it was added in, or every rebuild would look like corruption.
|
|
99
|
+
let hash = 0x811c9dc5;
|
|
100
|
+
const parts = [
|
|
101
|
+
...documents.map(document => `${document.recordId}@${document.revisionId}`).sort(),
|
|
102
|
+
...[...postings.keys()].sort().map(token => `${token}:${String(postings.get(token)?.size ?? 0)}`),
|
|
103
|
+
];
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
for (let index = 0; index < part.length; index += 1) {
|
|
106
|
+
hash ^= part.charCodeAt(index);
|
|
107
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return hash.toString(16).padStart(8, '0');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Decode one round of percent-escapes, without ever throwing.
|
|
114
|
+
*
|
|
115
|
+
* `decodeURIComponent` is the obvious tool and the wrong one: it throws on a
|
|
116
|
+
* malformed escape, so a file legitimately named `100%.json` would be refused
|
|
117
|
+
* as hostile. This decodes only well-formed `%XX` pairs and leaves everything
|
|
118
|
+
* else exactly as it arrived.
|
|
119
|
+
*/
|
|
120
|
+
function decodeOnce(value) {
|
|
121
|
+
return value.replace(/%([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Every form a path can decode to, including the one that arrived.
|
|
125
|
+
*
|
|
126
|
+
* A traversal survives encoding, and it survives being encoded twice. Checking
|
|
127
|
+
* only the string as received missed `..%2f` — literal dots joined by an
|
|
128
|
+
* encoded separator — which reads as harmless until something downstream
|
|
129
|
+
* decodes it and it becomes `../`. Bounded at four rounds, which is three more
|
|
130
|
+
* than anything legitimate needs.
|
|
131
|
+
*/
|
|
132
|
+
function decodings(candidate) {
|
|
133
|
+
const forms = [candidate];
|
|
134
|
+
let current = candidate;
|
|
135
|
+
for (let round = 0; round < 4; round += 1) {
|
|
136
|
+
const next = decodeOnce(current);
|
|
137
|
+
if (next === current)
|
|
138
|
+
break;
|
|
139
|
+
forms.push(next);
|
|
140
|
+
current = next;
|
|
141
|
+
}
|
|
142
|
+
return forms;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Is this path inside one of the roots the caller allows?
|
|
146
|
+
*
|
|
147
|
+
* Refusal is the safe direction, so anything ambiguous is refused. The root
|
|
148
|
+
* comparison is case-sensitive: on a case-insensitive filesystem that can
|
|
149
|
+
* refuse a legitimate path, which is a nuisance, but it can never admit an
|
|
150
|
+
* illegitimate one.
|
|
151
|
+
*/
|
|
152
|
+
export function isWithinRoots(candidate, roots) {
|
|
153
|
+
if (candidate === '')
|
|
154
|
+
return false;
|
|
155
|
+
// The traversal check runs against every form, not only the one that arrived.
|
|
156
|
+
for (const form of decodings(candidate)) {
|
|
157
|
+
const normalized = form.replace(/\\/g, '/');
|
|
158
|
+
if (normalized.split('/').includes('..'))
|
|
159
|
+
return false;
|
|
160
|
+
if (normalized.includes('\0'))
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const normalized = candidate.replace(/\\/g, '/');
|
|
164
|
+
return roots.some(root => {
|
|
165
|
+
const base = root.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
166
|
+
return normalized === base || normalized.startsWith(`${base}/`);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** The local, derived, rebuildable search index. */
|
|
170
|
+
export class LibraryIndex {
|
|
171
|
+
#documents = new Map();
|
|
172
|
+
#postings = new Map();
|
|
173
|
+
#health = 'empty';
|
|
174
|
+
#builtAt = null;
|
|
175
|
+
#pending = new Set();
|
|
176
|
+
#notes = [];
|
|
177
|
+
get health() {
|
|
178
|
+
return this.#health;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* One record by id, or undefined.
|
|
182
|
+
*
|
|
183
|
+
* A direct lookup rather than a search. The read plane's `get` was briefly
|
|
184
|
+
* implemented as a search with `limit: 1` whose single result was then
|
|
185
|
+
* compared to the requested id, which reports every record except the
|
|
186
|
+
* first-ranked one as missing. `#documents` is already keyed by record id;
|
|
187
|
+
* this is the accessor that key exists for.
|
|
188
|
+
*/
|
|
189
|
+
record(recordId) {
|
|
190
|
+
return this.#documents.get(recordId);
|
|
191
|
+
}
|
|
192
|
+
get size() {
|
|
193
|
+
return this.#documents.size;
|
|
194
|
+
}
|
|
195
|
+
/** Ids indexing began but did not finish, so a resumed run knows where it was. */
|
|
196
|
+
get pending() {
|
|
197
|
+
return [...this.#pending];
|
|
198
|
+
}
|
|
199
|
+
get diagnostics() {
|
|
200
|
+
return [...this.#notes];
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Add or replace one record.
|
|
204
|
+
*
|
|
205
|
+
* Idempotent by construction: the record's existing postings are removed
|
|
206
|
+
* before the new ones are written, so re-indexing changed text cannot leave
|
|
207
|
+
* the old words behind still pointing at the document. Indexing identical
|
|
208
|
+
* content twice is a no-op, which is what makes an interrupted run safe to
|
|
209
|
+
* simply repeat.
|
|
210
|
+
*/
|
|
211
|
+
add(input) {
|
|
212
|
+
// Normalized at the door, exactly as `load` already does. The type says
|
|
213
|
+
// every field is present, and the type is not enforced at runtime: these
|
|
214
|
+
// records are built by walking tool output, which crosses a JSON boundary
|
|
215
|
+
// and arrives as whatever the tool actually returned. A record missing
|
|
216
|
+
// `tags` used to throw "not iterable" from inside the indexer, turning one
|
|
217
|
+
// malformed record into a failed index.
|
|
218
|
+
const record = normalizeRecord(input);
|
|
219
|
+
if (record.recordId === '')
|
|
220
|
+
return;
|
|
221
|
+
this.#pending.add(record.recordId);
|
|
222
|
+
this.#removePostings(record.recordId);
|
|
223
|
+
this.#documents.set(record.recordId, record);
|
|
224
|
+
const haystack = [
|
|
225
|
+
record.title,
|
|
226
|
+
record.text,
|
|
227
|
+
record.source ?? '',
|
|
228
|
+
record.runId ?? '',
|
|
229
|
+
record.verdict ?? '',
|
|
230
|
+
...record.tags,
|
|
231
|
+
].join(' ');
|
|
232
|
+
for (const token of tokenize(haystack)) {
|
|
233
|
+
let postings = this.#postings.get(token);
|
|
234
|
+
if (postings === undefined) {
|
|
235
|
+
postings = new Set();
|
|
236
|
+
this.#postings.set(token, postings);
|
|
237
|
+
}
|
|
238
|
+
postings.add(record.recordId);
|
|
239
|
+
}
|
|
240
|
+
this.#pending.delete(record.recordId);
|
|
241
|
+
this.#health = this.#documents.size === 0 ? 'empty' : 'ready';
|
|
242
|
+
this.#builtAt = new Date().toISOString();
|
|
243
|
+
}
|
|
244
|
+
/** Index many, reporting progress so an interrupted run can resume. */
|
|
245
|
+
addAll(records, signal) {
|
|
246
|
+
this.#health = 'indexing';
|
|
247
|
+
let done = 0;
|
|
248
|
+
for (const record of records) {
|
|
249
|
+
if (signal?.aborted ?? false) {
|
|
250
|
+
this.#health = this.#documents.size === 0 ? 'empty' : 'stale';
|
|
251
|
+
this.#notes.push(`indexing cancelled after ${String(done)} of ${String(records.length)}`);
|
|
252
|
+
return done;
|
|
253
|
+
}
|
|
254
|
+
this.add(record);
|
|
255
|
+
done += 1;
|
|
256
|
+
}
|
|
257
|
+
this.#health = this.#documents.size === 0 ? 'empty' : 'ready';
|
|
258
|
+
return done;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Forget a record entirely.
|
|
262
|
+
*
|
|
263
|
+
* A deleted record must not survive as a search hit. Removing the document
|
|
264
|
+
* without its postings would leave a token pointing at an id that no longer
|
|
265
|
+
* resolves — a result that cannot be opened, which is worse than no result.
|
|
266
|
+
*/
|
|
267
|
+
remove(recordId) {
|
|
268
|
+
if (!this.#documents.has(recordId))
|
|
269
|
+
return false;
|
|
270
|
+
this.#removePostings(recordId);
|
|
271
|
+
this.#documents.delete(recordId);
|
|
272
|
+
this.#pending.delete(recordId);
|
|
273
|
+
if (this.#documents.size === 0)
|
|
274
|
+
this.#health = 'empty';
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
/** Throw everything away. The point of a derived index. */
|
|
278
|
+
clear() {
|
|
279
|
+
this.#documents.clear();
|
|
280
|
+
this.#postings.clear();
|
|
281
|
+
this.#pending.clear();
|
|
282
|
+
this.#notes = [];
|
|
283
|
+
this.#health = 'empty';
|
|
284
|
+
this.#builtAt = null;
|
|
285
|
+
}
|
|
286
|
+
/** Mark the index as behind the store, without discarding what it has. */
|
|
287
|
+
markStale(reason) {
|
|
288
|
+
if (this.#health === 'ready')
|
|
289
|
+
this.#health = 'stale';
|
|
290
|
+
this.#notes.push(reason);
|
|
291
|
+
}
|
|
292
|
+
#removePostings(recordId) {
|
|
293
|
+
for (const [token, ids] of this.#postings) {
|
|
294
|
+
if (ids.delete(recordId) && ids.size === 0)
|
|
295
|
+
this.#postings.delete(token);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Search.
|
|
300
|
+
*
|
|
301
|
+
* Every term must be present — an AND over tokens. OR would return a page of
|
|
302
|
+
* documents sharing one common word, which reads as the search being broken.
|
|
303
|
+
*
|
|
304
|
+
* The query string is never interpreted: it is tokenized exactly like indexed
|
|
305
|
+
* text, so a regular expression, a glob, a SQL fragment or a path traversal
|
|
306
|
+
* in the box is simply a set of words that will not be found. There is no
|
|
307
|
+
* escaping to get wrong because there is nothing to escape into.
|
|
308
|
+
*/
|
|
309
|
+
search(query) {
|
|
310
|
+
const notes = [];
|
|
311
|
+
const limit = Math.min(Math.max(1, query.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
312
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
313
|
+
// Read through a function, not a narrowed property. TypeScript narrows
|
|
314
|
+
// `aborted` after the first check and then believes it can never be true
|
|
315
|
+
// again — which is exactly what a cancellation signal is for.
|
|
316
|
+
const cancelled = () => query.signal?.aborted ?? false;
|
|
317
|
+
if (this.#health === 'corrupt') {
|
|
318
|
+
return {
|
|
319
|
+
results: [], total: 0, offset, limit, health: 'corrupt',
|
|
320
|
+
notes: ['The index is unreadable and must be rebuilt.', ...this.#notes],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (cancelled()) {
|
|
324
|
+
return { results: [], total: 0, offset, limit, health: this.#health, notes: ['Search cancelled.'] };
|
|
325
|
+
}
|
|
326
|
+
const terms = tokenize(query.text);
|
|
327
|
+
let candidates;
|
|
328
|
+
if (terms.length === 0) {
|
|
329
|
+
// An empty query lists everything the filters allow rather than nothing.
|
|
330
|
+
// "Show me the library" is a real request.
|
|
331
|
+
candidates = new Set(this.#documents.keys());
|
|
332
|
+
notes.push('No search terms: showing everything the filters allow.');
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
candidates = this.#intersect(terms);
|
|
336
|
+
}
|
|
337
|
+
const matched = [];
|
|
338
|
+
for (const recordId of candidates) {
|
|
339
|
+
if (cancelled()) {
|
|
340
|
+
return { results: [], total: 0, offset, limit, health: this.#health, notes: ['Search cancelled.'] };
|
|
341
|
+
}
|
|
342
|
+
const record = this.#documents.get(recordId);
|
|
343
|
+
if (record === undefined)
|
|
344
|
+
continue;
|
|
345
|
+
if (!passesFilters(record, query))
|
|
346
|
+
continue;
|
|
347
|
+
matched.push({ record, score: scoreOf(record, terms) });
|
|
348
|
+
}
|
|
349
|
+
sortMatches(matched, query.sort ?? 'relevance');
|
|
350
|
+
const total = matched.length;
|
|
351
|
+
const page = matched.slice(offset, offset + limit);
|
|
352
|
+
if (total > offset + page.length) {
|
|
353
|
+
notes.push(`Showing ${String(offset + 1)}–${String(offset + page.length)} of ${String(total)}.`);
|
|
354
|
+
}
|
|
355
|
+
if (this.#health === 'stale') {
|
|
356
|
+
notes.push('The index is behind the store; some recent records may be missing.');
|
|
357
|
+
}
|
|
358
|
+
if (this.#health === 'indexing') {
|
|
359
|
+
notes.push('Indexing is still running; this answer is partial.');
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
results: page.map(({ record, score }) => toResult(record, terms, score)),
|
|
363
|
+
total,
|
|
364
|
+
offset,
|
|
365
|
+
limit,
|
|
366
|
+
health: this.#health,
|
|
367
|
+
notes,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
#intersect(terms) {
|
|
371
|
+
let smallest = null;
|
|
372
|
+
for (const term of terms) {
|
|
373
|
+
const postings = this.#postings.get(term);
|
|
374
|
+
if (postings === undefined)
|
|
375
|
+
return new Set();
|
|
376
|
+
if (smallest === null || postings.size < smallest.size)
|
|
377
|
+
smallest = postings;
|
|
378
|
+
}
|
|
379
|
+
if (smallest === null)
|
|
380
|
+
return new Set();
|
|
381
|
+
const out = new Set();
|
|
382
|
+
for (const candidate of smallest) {
|
|
383
|
+
if (terms.every(term => this.#postings.get(term)?.has(candidate) === true))
|
|
384
|
+
out.add(candidate);
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
/** Serialise, with a digest so a later load can tell it was not damaged. */
|
|
389
|
+
serialize() {
|
|
390
|
+
const documents = [...this.#documents.values()];
|
|
391
|
+
return {
|
|
392
|
+
version: INDEX_VERSION,
|
|
393
|
+
digest: digestOf(documents, this.#postings),
|
|
394
|
+
builtAt: this.#builtAt ?? new Date().toISOString(),
|
|
395
|
+
documents,
|
|
396
|
+
postings: Object.fromEntries([...this.#postings.entries()].map(([token, ids]) => [token, [...ids].sort()])),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Load a serialised index, refusing anything it cannot trust.
|
|
401
|
+
*
|
|
402
|
+
* A wrong version, a failed digest or a malformed body all produce a
|
|
403
|
+
* `corrupt` index rather than a partial one. Half-loading is the failure that
|
|
404
|
+
* looks like success: queries answer, and they answer wrongly.
|
|
405
|
+
*/
|
|
406
|
+
static load(value) {
|
|
407
|
+
const index = new LibraryIndex();
|
|
408
|
+
const fail = (reason) => {
|
|
409
|
+
index.#health = 'corrupt';
|
|
410
|
+
index.#notes.push(reason);
|
|
411
|
+
return index;
|
|
412
|
+
};
|
|
413
|
+
if (typeof value !== 'object' || value === null)
|
|
414
|
+
return fail('The stored index is not an object.');
|
|
415
|
+
const stored = value;
|
|
416
|
+
if (stored.version !== INDEX_VERSION) {
|
|
417
|
+
return fail(`Index version ${String(stored.version)} cannot be read by this build (expects ${String(INDEX_VERSION)}).`);
|
|
418
|
+
}
|
|
419
|
+
if (!Array.isArray(stored.documents) || typeof stored.postings !== 'object' || stored.postings === null) {
|
|
420
|
+
return fail('The stored index is missing its documents or postings.');
|
|
421
|
+
}
|
|
422
|
+
const documents = [];
|
|
423
|
+
for (const document of stored.documents) {
|
|
424
|
+
if (typeof document !== 'object' || document === null)
|
|
425
|
+
return fail('A stored document is malformed.');
|
|
426
|
+
const record = document;
|
|
427
|
+
if (typeof record.recordId !== 'string' || record.recordId === '') {
|
|
428
|
+
return fail('A stored document has no id.');
|
|
429
|
+
}
|
|
430
|
+
documents.push(normalizeRecord(record));
|
|
431
|
+
}
|
|
432
|
+
const postings = new Map();
|
|
433
|
+
for (const [token, ids] of Object.entries(stored.postings)) {
|
|
434
|
+
if (!Array.isArray(ids))
|
|
435
|
+
return fail(`Postings for "${token}" are malformed.`);
|
|
436
|
+
postings.set(token, new Set(ids.filter((id) => typeof id === 'string')));
|
|
437
|
+
}
|
|
438
|
+
if (digestOf(documents, postings) !== stored.digest) {
|
|
439
|
+
return fail('The stored index failed its own digest — it has been modified or truncated.');
|
|
440
|
+
}
|
|
441
|
+
for (const document of documents)
|
|
442
|
+
index.#documents.set(document.recordId, document);
|
|
443
|
+
index.#postings = postings;
|
|
444
|
+
index.#builtAt = typeof stored.builtAt === 'string' ? stored.builtAt : null;
|
|
445
|
+
index.#health = documents.length === 0 ? 'empty' : 'ready';
|
|
446
|
+
return index;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** Fill in what a stored record may be missing, without inventing content. */
|
|
450
|
+
function normalizeRecord(record) {
|
|
451
|
+
return {
|
|
452
|
+
recordId: record.recordId ?? '',
|
|
453
|
+
revisionId: typeof record.revisionId === 'string' ? record.revisionId : '',
|
|
454
|
+
title: typeof record.title === 'string' ? record.title : '',
|
|
455
|
+
kind: record.kind ?? 'document',
|
|
456
|
+
text: typeof record.text === 'string' ? record.text : '',
|
|
457
|
+
source: typeof record.source === 'string' ? record.source : null,
|
|
458
|
+
runId: typeof record.runId === 'string' ? record.runId : null,
|
|
459
|
+
observedAt: typeof record.observedAt === 'string' ? record.observedAt : null,
|
|
460
|
+
verdict: typeof record.verdict === 'string' ? record.verdict : null,
|
|
461
|
+
tags: Array.isArray(record.tags) ? record.tags.filter((tag) => typeof tag === 'string') : [],
|
|
462
|
+
evidenceIds: Array.isArray(record.evidenceIds)
|
|
463
|
+
? record.evidenceIds.filter((id) => typeof id === 'string')
|
|
464
|
+
: [],
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function passesFilters(record, query) {
|
|
468
|
+
if (query.kinds !== undefined && query.kinds.length > 0 && !query.kinds.includes(record.kind))
|
|
469
|
+
return false;
|
|
470
|
+
if (query.runIds !== undefined && query.runIds.length > 0) {
|
|
471
|
+
if (record.runId === null || !query.runIds.includes(record.runId))
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
if (query.verdicts !== undefined && query.verdicts.length > 0) {
|
|
475
|
+
if (record.verdict === null || !query.verdicts.includes(record.verdict))
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
if (query.sources !== undefined && query.sources.length > 0) {
|
|
479
|
+
if (record.source === null || !query.sources.includes(record.source))
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
if (query.tags !== undefined && query.tags.length > 0) {
|
|
483
|
+
if (!query.tags.some(tag => record.tags.includes(tag)))
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
if (query.from !== undefined && (record.observedAt === null || record.observedAt < query.from))
|
|
487
|
+
return false;
|
|
488
|
+
if (query.to !== undefined && (record.observedAt === null || record.observedAt > query.to))
|
|
489
|
+
return false;
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Score a match.
|
|
494
|
+
*
|
|
495
|
+
* Term frequency with a title bonus, and nothing more. A more elaborate
|
|
496
|
+
* relevance model would be guessing, and this one is at least explicable: a
|
|
497
|
+
* record whose title contains your words outranks one that merely mentions
|
|
498
|
+
* them, and more mentions outrank fewer.
|
|
499
|
+
*/
|
|
500
|
+
function scoreOf(record, terms) {
|
|
501
|
+
if (terms.length === 0)
|
|
502
|
+
return 0;
|
|
503
|
+
const title = new Set(tokenize(record.title));
|
|
504
|
+
const body = tokenize(record.text);
|
|
505
|
+
let score = 0;
|
|
506
|
+
for (const term of terms) {
|
|
507
|
+
if (title.has(term))
|
|
508
|
+
score += 5;
|
|
509
|
+
score += body.filter(token => token === term).length;
|
|
510
|
+
}
|
|
511
|
+
return score;
|
|
512
|
+
}
|
|
513
|
+
function sortMatches(matched, sort) {
|
|
514
|
+
const time = (record) => record.observedAt ?? '';
|
|
515
|
+
matched.sort((left, right) => {
|
|
516
|
+
if (sort === 'newest')
|
|
517
|
+
return time(right.record).localeCompare(time(left.record));
|
|
518
|
+
if (sort === 'oldest')
|
|
519
|
+
return time(left.record).localeCompare(time(right.record));
|
|
520
|
+
if (sort === 'title')
|
|
521
|
+
return left.record.title.localeCompare(right.record.title);
|
|
522
|
+
const byScore = right.score - left.score;
|
|
523
|
+
// Ties break on id so the same corpus always pages identically. A stable
|
|
524
|
+
// order is what makes "page 2" mean anything.
|
|
525
|
+
return byScore !== 0 ? byScore : left.record.recordId.localeCompare(right.record.recordId);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Build the snippet a person reads, around the first match.
|
|
530
|
+
*
|
|
531
|
+
* The text is returned verbatim and un-escaped — it is evidence, and altering
|
|
532
|
+
* it here would make the snippet disagree with the source. Rendering is the
|
|
533
|
+
* caller's job, and React escapes by default; this deliberately produces no
|
|
534
|
+
* markup for a renderer to trust.
|
|
535
|
+
*/
|
|
536
|
+
export function snippetFor(text, terms, radius = 90) {
|
|
537
|
+
if (text === '' || terms.length === 0)
|
|
538
|
+
return text.slice(0, radius * 2);
|
|
539
|
+
const lower = text.toLowerCase();
|
|
540
|
+
let at = -1;
|
|
541
|
+
for (const term of terms) {
|
|
542
|
+
const found = lower.indexOf(term);
|
|
543
|
+
if (found >= 0 && (at < 0 || found < at))
|
|
544
|
+
at = found;
|
|
545
|
+
}
|
|
546
|
+
if (at < 0)
|
|
547
|
+
return text.slice(0, radius * 2);
|
|
548
|
+
const start = Math.max(0, at - radius);
|
|
549
|
+
const end = Math.min(text.length, at + radius);
|
|
550
|
+
return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
|
|
551
|
+
}
|
|
552
|
+
function toResult(record, terms, score) {
|
|
553
|
+
const hit = {
|
|
554
|
+
sourceId: record.recordId,
|
|
555
|
+
sourceRevisionId: record.revisionId,
|
|
556
|
+
range: null,
|
|
557
|
+
text: snippetFor(record.text === '' ? record.title : record.text, terms),
|
|
558
|
+
path: 'lexical',
|
|
559
|
+
score,
|
|
560
|
+
evidenceIds: record.evidenceIds,
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
sourceId: record.recordId,
|
|
564
|
+
title: record.title,
|
|
565
|
+
kind: record.kind,
|
|
566
|
+
hits: [hit],
|
|
567
|
+
current: true,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
//# sourceMappingURL=index-store.js.map
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library: sources and evidence, kept apart from memory.
|
|
3
|
+
*
|
|
4
|
+
* @module @deepwatch/dsh-library
|
|
5
|
+
*/
|
|
6
|
+
export * from './sources.js';
|
|
7
|
+
export * from './search.js';
|
|
8
|
+
export * from './index-store.js';
|
|
9
|
+
/**
|
|
10
|
+
* The host-side loader entry.
|
|
11
|
+
*
|
|
12
|
+
* There is no host behaviour to install — this package's product surface is
|
|
13
|
+
* entirely browser-side, under `./client`. The entry exists because the DSH
|
|
14
|
+
* loader mounts a package's node half first and reads its `dsh.client`
|
|
15
|
+
* declaration from there; a row whose module exports no `apply` is refused
|
|
16
|
+
* with "invalid plugin, expect function or object with an apply method", and
|
|
17
|
+
* the whole plugin tree fails to load with it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function apply(): void;
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library: sources and evidence, kept apart from memory.
|
|
3
|
+
*
|
|
4
|
+
* @module @deepwatch/dsh-library
|
|
5
|
+
*/
|
|
6
|
+
export * from './sources.js';
|
|
7
|
+
export * from './search.js';
|
|
8
|
+
export * from './index-store.js';
|
|
9
|
+
/**
|
|
10
|
+
* The host-side loader entry.
|
|
11
|
+
*
|
|
12
|
+
* There is no host behaviour to install — this package's product surface is
|
|
13
|
+
* entirely browser-side, under `./client`. The entry exists because the DSH
|
|
14
|
+
* loader mounts a package's node half first and reads its `dsh.client`
|
|
15
|
+
* declaration from there; a row whose module exports no `apply` is refused
|
|
16
|
+
* with "invalid plugin, expect function or object with an apply method", and
|
|
17
|
+
* the whole plugin tree fails to load with it.
|
|
18
|
+
*/
|
|
19
|
+
export function apply() { }
|
|
20
|
+
//# sourceMappingURL=index.js.map
|