import { type Kysely } from 'kysely'; import type { Database, SearchSource } from '../db/schema.js'; /** * What visitors searched for, and whether the site had an answer. * * **This cannot be built by counting requests, and that is the whole reason it is a separate write * path.** `/delivery/search` answers with `s-maxage=86400`, and a consumer's own search page is * cached too — so the second person to search "nursing" is served from an edge cache and never * reaches an origin at all. A log fed by the read path would therefore undercount in proportion to * how popular a term is, and "top searches" would rank the terms nobody repeats. It has to be an * uncached report from the consumer, which is what `search:write` exists for. * * Nothing identifying is stored — see `0026_search_log`. Prefix collapsing happens in the browser, * before anything is sent, so the session id that makes it possible never leaves the visitor. */ /** * Longest query kept. * * Not validation — a search box will happily accept a pasted paragraph, and refusing to log it * would blind the report to exactly the searches most likely to fail. Truncated so one paste cannot * put a megabyte in a table whose rows are otherwise a few dozen bytes. */ export declare const MAX_LOGGED_QUERY = 200; export declare const isSearchSource: (value: string) => value is SearchSource; /** * The grouping key: two searches are the same search when this matches. * * Folded with `foldSearchText`, the tokenizer's own rule, so "Peña" and "pena" group together * exactly as they match together — a report that split them would be describing a distinction the * search engine does not make. Whitespace is collapsed as well, because "financial aid" and * "financial aid" are one search to everybody except a `group by`. * * Deliberately **not** SQL's `lower()`. That folds ASCII and stops, so the grouping would agree * with the matcher for English and quietly disagree for every name with an accent in it. */ export declare function normalizeSearchQuery(query: string): string; export interface SearchLogInput { query: string; resultCount: number; source: SearchSource; } /** * Append one search. * * Never throws, for `recordAuditEntry`'s reason one step further along: the search it describes has * already been answered and the visitor already has their results, so a failure here has nothing to * report to anybody who can act on it. A blank query is dropped rather than stored — an empty * search box submitted by accident is not a search, and it would be the top row of every report. */ export declare function recordSearch(db: Kysely, input: SearchLogInput): Promise; export interface SearchLogFilters { /** Only searches at or after this moment. The screen's date window. */ since: Date; /** Sources to include. Omitted means all three. */ sources?: readonly SearchSource[]; limit?: number; } export interface SearchTermSummary { /** The folded grouping key. */ normalized: string; /** The spelling most people used, for display. */ query: string; /** How many times it was searched in the window. */ searches: number; /** Results the *most recent* of those searches found — see below for why not an average. */ resultCount: number; lastSearchedAt: string; } /** * The most-searched terms in a window. * * Three things about the shape: * * **`query` is the most common spelling, not the first or the last.** A report headed `NURSING` * because one person shouted is a report people stop reading; the modal spelling is what the term * looks like to most visitors. * * **`resultCount` is the latest, not the mean.** A term that returned nothing for three weeks and * then started working averages to "some results" and disappears from the report that matters — * while an editor who has just fixed it wants to see that it is fixed. The latest value answers * "does this work now", which is the question being asked. * * **Sources are filtered, never summed blindly.** An `abandoned` row may be a fragment, so mixing * it into a top-terms list is how a report recommends writing a page about "nursi". */ export declare function topSearchTerms(db: Kysely, filters: SearchLogFilters): Promise; /** * Terms that found nothing. * * The report the table exists for: content that is missing, or titled something no visitor would * guess. Judged on the **most recent** search for a term rather than on any of them, so a term * stops appearing here the moment somebody publishes the page — a `min(result_count) = 0` would * keep accusing them of it for as long as the window is open. */ export declare function zeroResultSearchTerms(db: Kysely, filters: SearchLogFilters): Promise; export interface SearchLogStats { /** Every search in the window, whatever its source. */ total: number; /** Distinct terms, after folding. */ terms: number; /** Searches that found nothing — the headline number this screen exists to move. */ zeroResult: number; /** When the log starts, or null when it is empty. Says how much history a report is reading. */ since: string | null; } export declare function searchLogStats(db: Kysely, filters: Pick): Promise; /** * Retention: drop everything older than a date. * * Blunt and dated, exactly as `purgeAuditLogBefore` is. There is no targeted delete here and there * should not be — but note the reason differs from the audit log's. There, aiming a delete is what * lets somebody erase evidence about themselves. Here, nothing identifies anybody, so the risk is * the other way round: a log kept forever slowly becomes a corpus somebody could correlate against * other records. Age is the only axis either of them needs. * * **Nothing calls this yet, and that is the same status `purgeAuditLogBefore` has had since it was * written.** The capability is here so an operator has one; scheduling it is a decision about * deleting a deployment's data, which is not one a library should make on its behalf by adding a * delete to a cron that already runs every five minutes. If this does get wired into * `publishDueItems`' sweep, the interval and the age want stating on Settings → System, because a * report that silently stops going back further than ninety days is one people misread. */ export declare function purgeSearchLogBefore(db: Kysely, before: Date, /** Most rows to remove in one call. See `purgeAuditLogBefore`, which is bounded the same way. */ limit?: number): Promise;