/** * The global search index (`GET /search`; api-types 0.127.0): an in-memory text store per entity * type, scanned per query under a hard time budget. * * Why not SQLite FTS5: the daemon loads no SQLite library at all (wicked-core#631; enforced by * tests/bus-no-write.test.ts). The engine owns every SQLite file in this process, so an FTS index * belongs in the engine; until it has one, this store is the daemon's. It is a scan, not an * inverted index: each entity keeps a case-folded copy of its title and (capped) body, and a query * runs `indexOf` per word over them. That keeps substring and prefix matches exact and the code * small, and it is fast enough for an operator's store (a few MB of text is a few ms of scanning). * * It never holds the event loop: the scan yields every {@link SLICE_MS}, stops when its signal * aborts (the client went away: a superseded query), and stops at the time budget with * `partial: true` (totals are then lower bounds). */ import type { SearchGroup, SearchSpan, SearchTarget, SearchType } from '../core/types.js'; /** Every searchable type, in the order a response lists them. */ export declare const SEARCH_TYPES: readonly SearchType[]; /** One entity as a source hands it to the index. */ export interface SearchDoc { type: SearchType; id: string; title: string; /** Searchable text beyond the title (capped by the index at {@link SearchIndexOptions.maxBodyChars}). */ body: string; /** Unix millis, when known. */ updatedAt: number | null; projectId: string | null; status: string | null; target: SearchTarget; /** Added to the score of every match (negative for archived/retired entities). */ boost?: number; } interface Stored { doc: SearchDoc; titleFold: string; bodyFold: string; } export interface SearchIndexOptions { /** Body text kept per entity; longer bodies keep their head and tail. Default 64 Ki chars. */ maxBodyChars?: number; /** Total body text kept per type; past it the least recently updated entities leave. Default 8 Mi chars. */ maxCharsPerType?: number; } export interface ParsedQuery { /** The trimmed query as asked. */ text: string; /** Case-folded words and phrases; every one must match. */ terms: string[]; } export interface QueryOptions { types: readonly SearchType[]; /** Hits per group. */ limit: number; /** Offset into one type's ranked matches (the cursor). */ offset?: number; /** Hard wall-clock budget for the scan, ms. */ budgetMs: number; signal?: AbortSignal; /** Clock (tests). */ now?: () => number; /** The recency bonus's "now" (tests), unix millis. */ at?: number; } export interface QueryResult { groups: SearchGroup[]; partial: boolean; } /** Query limits (the route answers 400 past them). */ export declare const MAX_QUERY_CHARS = 256; export declare const MIN_QUERY_CHARS = 2; /** * Lower-case without changing length, so offsets in the folded copy are offsets in the original * (a few code points lower-case to two units — those keep their case). */ export declare function fold(s: string): string; /** Words and `"quoted phrases"`, folded, de-duplicated, at most {@link MAX_TERMS}. */ export declare function parseQuery(raw: string): ParsedQuery; /** The match score of one entity, or 0 when a term is missing (every term must match). */ export declare function scoreOf(s: Stored, terms: readonly string[], at: number): number; /** Every occurrence of every term in `folded`, merged into ascending, non-overlapping spans. */ export declare function spansOf(folded: string, terms: readonly string[]): SearchSpan[]; /** * The window of the body that shows the match best: around the term occurrence whose window holds * the most distinct terms. Whitespace is collapsed in the window (folded and original alike, so * spans stay aligned); `…` marks a cut. */ export declare function snippetOf(s: Stored, terms: readonly string[]): { snippet: string | null; spans: SearchSpan[]; }; /** Head and tail of a long text, joined by an elision line. */ export declare function capText(text: string, max: number): string; export declare class SearchIndex { private readonly byType; private readonly charsByType; private readonly leftOut; private readonly maxBodyChars; private readonly maxCharsPerType; constructor(opts?: SearchIndexOptions); private mapOf; /** Entities of `type` held. */ count(type: SearchType): number; /** Body characters of `type` held (the budget {@link SearchIndexOptions.maxCharsPerType} bounds). */ chars(type: SearchType): number; has(type: SearchType, id: string): boolean; ids(type: SearchType): string[]; /** Add or replace one entity. */ upsert(input: SearchDoc): void; private put; remove(type: SearchType, id: string): void; /** Remove every entity of `type` whose id `drop` accepts. */ removeWhere(type: SearchType, drop: (id: string) => boolean): void; /** * A full re-read of a source: hold what it holds, drop what it no longer does. Newest first, and * only while the type's text budget lasts — the rest is never folded, so a re-read of a store past * the budget costs one sort, not an evict per entity. */ replaceType(type: SearchType, docs: readonly SearchDoc[]): void; /** Entities the last re-read of `type` left out to stay inside its text budget. */ leftOutOf(type: SearchType): number; /** Over budget: the least recently updated entities leave first (undated ones before dated). */ private evict; /** * Scan `types` for `q`. Yields every {@link SLICE_MS}; throws the signal's reason when it aborts; * stops at `budgetMs` with `partial: true`. Groups with no match are left out. */ query(q: ParsedQuery, opts: QueryOptions): Promise; private pushGroup; } export {}; //# sourceMappingURL=search-index.d.ts.map