@runbooks/search 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Dorokhovich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # `@runbooks/search`
2
+
3
+ BM25, the query semantics, and `/v1/search` as a function.
4
+
5
+ **Tasks:** [M-03](../../tasks/v1/M-03-search-api.md) · **Normative:** RUNBOOK.md §12, §17, §10.1
6
+
7
+ Search runs client-side over the prebuilt index until size forces an endpoint, and
8
+ `handleSearch` **is** that endpoint: a URL and the published artifact in, a response out,
9
+ with no framework and no state. That is what makes §10.1's degradation claim literal —
10
+ there is nothing in the endpoint the file does not already contain.
11
+
12
+ Two rules the API cannot bend. `available` means **every** capability, not any: a client
13
+ reading it the other way is handed procedures it can start and cannot finish. And
14
+ `trust_min` defaults to T2, so reaching below it is something a caller did on purpose.
15
+
16
+ The vector half of §17's hybrid is an injected function with no default
17
+ ([why](../../docs/decisions/M03-vector-half.md)): BM25 finds fewer things, and finding
18
+ fewer things is recoverable in a way that inventing a ranking is not.
package/dist/bm25.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * BM25 over the prebuilt index (M-03, §10.1).
3
+ *
4
+ * Pure, and running in the reader's browser over an artifact the build produced. §10.1's
5
+ * rule is that search is client-side until size forces a Lambda, and that the Lambda is
6
+ * a pure transform over the same artifacts — never a source of truth. Keeping the ranking
7
+ * here rather than behind an endpoint is what makes that literally true: the same code
8
+ * ranks in both places, so switching costs nothing and changes nothing.
9
+ */
10
+ export interface Document {
11
+ readonly ref: string;
12
+ readonly text: string;
13
+ }
14
+ export interface Bm25Index {
15
+ readonly documents: readonly {
16
+ readonly ref: string;
17
+ readonly length: number;
18
+ readonly terms: Readonly<Record<string, number>>;
19
+ }[];
20
+ readonly documentFrequency: Readonly<Record<string, number>>;
21
+ readonly averageLength: number;
22
+ /**
23
+ * The table this index was built through.
24
+ *
25
+ * Carried rather than passed again at query time: an index folded one way and a query
26
+ * folded another is the disagreement this whole change is about, one layer down.
27
+ */
28
+ readonly synonyms: Synonyms;
29
+ }
30
+ /**
31
+ * A synonym mapped to the word this catalog uses for it.
32
+ *
33
+ * Data rather than a dependency: `packages/search` has none on purpose — it is the
34
+ * library the browser runs — and the vocabulary lives in `@runbooks/lint`, which pulls in
35
+ * the schema and the graph. So the map travels: the build resolves it once from S-09's
36
+ * targets vocabulary and publishes it in `/v1/search-index.json`, and both the index and
37
+ * the query are folded through the same table.
38
+ */
39
+ export type Synonyms = Readonly<Record<string, string>>;
40
+ /**
41
+ * The words a document or a query is made of.
42
+ *
43
+ * This used to say it was "the same tokenizer the clustering uses in spirit", and *in
44
+ * spirit* was the tell: clustering folds `kubernetes` onto `k8s` through S-09's
45
+ * vocabulary and this did not, so a reader who typed the word Kubernetes calls itself
46
+ * found nothing while the clustering that groups records into topics treated the two as
47
+ * one word. `postgresql` and `postgres` were the same story.
48
+ *
49
+ * What is shared is now shared literally — the same table, applied at both ends. What
50
+ * still differs is stated rather than implied: clustering drops stop words and keeps
51
+ * single characters, because it is comparing whole documents for sameness; this keeps
52
+ * stop words, because BM25 already discounts them by their document frequency, and drops
53
+ * single characters, because a one-letter term matches everything and ranks nothing.
54
+ */
55
+ export declare function terms(text: string, synonyms?: Synonyms): string[];
56
+ export declare function buildIndex(documents: readonly Document[], synonyms?: Synonyms): Bm25Index;
57
+ /**
58
+ * Score a query. Standard BM25, no tuning of our own.
59
+ *
60
+ * A ranking somebody cannot reproduce is a ranking they have to trust, which is the
61
+ * thing §19 refuses about scores generally. The parameters are the textbook ones and the
62
+ * formula is the textbook formula, so a result order can be argued with.
63
+ */
64
+ export declare function search(index: Bm25Index, query: string, synonyms?: Synonyms): {
65
+ ref: string;
66
+ score: number;
67
+ }[];
package/dist/bm25.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The words a document or a query is made of.
3
+ *
4
+ * This used to say it was "the same tokenizer the clustering uses in spirit", and *in
5
+ * spirit* was the tell: clustering folds `kubernetes` onto `k8s` through S-09's
6
+ * vocabulary and this did not, so a reader who typed the word Kubernetes calls itself
7
+ * found nothing while the clustering that groups records into topics treated the two as
8
+ * one word. `postgresql` and `postgres` were the same story.
9
+ *
10
+ * What is shared is now shared literally — the same table, applied at both ends. What
11
+ * still differs is stated rather than implied: clustering drops stop words and keeps
12
+ * single characters, because it is comparing whole documents for sameness; this keeps
13
+ * stop words, because BM25 already discounts them by their document frequency, and drops
14
+ * single characters, because a one-letter term matches everything and ranks nothing.
15
+ */
16
+ export function terms(text, synonyms = {}) {
17
+ return text
18
+ .toLowerCase()
19
+ .normalize("NFKD")
20
+ .replace(/\p{M}/gu, "")
21
+ .split(/[^a-z0-9]+/u)
22
+ .filter((word) => word.length > 1)
23
+ .map((word) => synonyms[word] ?? word);
24
+ }
25
+ export function buildIndex(documents, synonyms = {}) {
26
+ const df = {};
27
+ const built = documents.map((document) => {
28
+ const counted = {};
29
+ const words = terms(document.text, synonyms);
30
+ for (const word of words)
31
+ counted[word] = (counted[word] ?? 0) + 1;
32
+ for (const word of Object.keys(counted))
33
+ df[word] = (df[word] ?? 0) + 1;
34
+ return { ref: document.ref, length: words.length, terms: counted };
35
+ });
36
+ return {
37
+ documents: built,
38
+ documentFrequency: df,
39
+ averageLength: built.length === 0 ? 0 : built.reduce((sum, d) => sum + d.length, 0) / built.length,
40
+ synonyms,
41
+ };
42
+ }
43
+ const K1 = 1.2;
44
+ const B = 0.75;
45
+ /**
46
+ * Score a query. Standard BM25, no tuning of our own.
47
+ *
48
+ * A ranking somebody cannot reproduce is a ranking they have to trust, which is the
49
+ * thing §19 refuses about scores generally. The parameters are the textbook ones and the
50
+ * formula is the textbook formula, so a result order can be argued with.
51
+ */
52
+ export function search(index, query, synonyms = index.synonyms) {
53
+ const queryTerms = terms(query, synonyms);
54
+ if (queryTerms.length === 0 || index.documents.length === 0)
55
+ return [];
56
+ const total = index.documents.length;
57
+ return index.documents
58
+ .map((document) => {
59
+ let score = 0;
60
+ for (const term of queryTerms) {
61
+ const frequency = document.terms[term];
62
+ if (!frequency)
63
+ continue;
64
+ const df = index.documentFrequency[term] ?? 0;
65
+ const idf = Math.log(1 + (total - df + 0.5) / (df + 0.5));
66
+ const norm = 1 - B + (B * document.length) / (index.averageLength || 1);
67
+ score += idf * ((frequency * (K1 + 1)) / (frequency + K1 * norm));
68
+ }
69
+ return { ref: document.ref, score };
70
+ })
71
+ .filter((hit) => hit.score > 0)
72
+ .sort((a, b) => (b.score === a.score ? (a.ref < b.ref ? -1 : 1) : b.score - a.score));
73
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Search pagination (M-03, §12).
3
+ *
4
+ * `search.ts` said its cursor "names where to continue, not an offset to trust", two
5
+ * lines above a `parseInt` of exactly that offset. The claim was the right one and the
6
+ * code was not, and an offset is wrong for a reason that shows up only in use: the set is
7
+ * rebuilt for every request, so a page-2 offset taken against yesterday's catalog skips
8
+ * or repeats whatever moved across the boundary — silently, and in the direction of
9
+ * showing a caller fewer records than exist.
10
+ *
11
+ * So a cursor names the last hit the caller saw and the query it belonged to. Continuing
12
+ * means resuming after that ref, and a cursor from a different query is refused rather
13
+ * than reinterpreted: it names a position in a set that was never returned.
14
+ */
15
+ import type { SearchQuery } from "./search.js";
16
+ export interface Cursor {
17
+ /** The ref the previous page ended on. The next page starts after it. */
18
+ readonly after: string;
19
+ /** Which query this position belongs to. */
20
+ readonly query: string;
21
+ }
22
+ /**
23
+ * What makes two queries the same question.
24
+ *
25
+ * Everything that changes the result set, and nothing that changes only its size: a
26
+ * caller paging through with a different `limit` is still asking the same thing.
27
+ */
28
+ export declare function fingerprint(query: SearchQuery): string;
29
+ export declare function encodeCursor(after: string, query: SearchQuery): string;
30
+ export type CursorRead = {
31
+ readonly ok: true;
32
+ readonly cursor: Cursor;
33
+ } | {
34
+ readonly ok: false;
35
+ readonly why: string;
36
+ };
37
+ /**
38
+ * Read a cursor, or say why not.
39
+ *
40
+ * Refusing beats guessing in both directions here: an unreadable cursor silently treated
41
+ * as "start from the beginning" hands a caller page 1 while they believe they are on
42
+ * page 3, and a cursor from another query silently honoured hands them a slice of a set
43
+ * they never asked for.
44
+ */
45
+ export declare function readCursor(raw: string, query: SearchQuery): CursorRead;
package/dist/cursor.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What makes two queries the same question.
3
+ *
4
+ * Everything that changes the result set, and nothing that changes only its size: a
5
+ * caller paging through with a different `limit` is still asking the same thing.
6
+ */
7
+ export function fingerprint(query) {
8
+ return JSON.stringify([
9
+ query.symptom ?? "",
10
+ query.target ?? "",
11
+ query.risk_max ?? "",
12
+ query.execution ?? "",
13
+ query.profile ?? "",
14
+ query.trust_min ?? "",
15
+ [...(query.available ?? [])].sort(),
16
+ ]);
17
+ }
18
+ const encode = (value) => Buffer.from(value, "utf8").toString("base64url");
19
+ const decode = (value) => Buffer.from(value, "base64url").toString("utf8");
20
+ export function encodeCursor(after, query) {
21
+ return encode(JSON.stringify({ after, query: fingerprint(query) }));
22
+ }
23
+ /**
24
+ * Read a cursor, or say why not.
25
+ *
26
+ * Refusing beats guessing in both directions here: an unreadable cursor silently treated
27
+ * as "start from the beginning" hands a caller page 1 while they believe they are on
28
+ * page 3, and a cursor from another query silently honoured hands them a slice of a set
29
+ * they never asked for.
30
+ */
31
+ export function readCursor(raw, query) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(decode(raw));
35
+ }
36
+ catch {
37
+ return { ok: false, why: "This cursor is not one of ours. Cursors come from next_cursor in a previous response; they are not constructed." };
38
+ }
39
+ if (typeof parsed.after !== "string" || typeof parsed.query !== "string") {
40
+ return { ok: false, why: "This cursor is missing the position or the query it belongs to." };
41
+ }
42
+ if (parsed.query !== fingerprint(query)) {
43
+ return {
44
+ ok: false,
45
+ why: "This cursor belongs to a different query. A position in one result set does not name a position in another, and continuing anyway would return a page of something the caller never asked for.",
46
+ };
47
+ }
48
+ return { ok: true, cursor: { after: parsed.after, query: parsed.query } };
49
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `GET /v1/search` (M-03, §12, §17, §10.1).
3
+ *
4
+ * A function of a URL and an artifact, and nothing else. There is no request object here,
5
+ * no framework and no I/O: whatever deploys this — a Lambda behind CloudFront, a route
6
+ * handler, a script on somebody's laptop — hands it the query string and the index it
7
+ * already has. That is what makes §10.1's claim literally true rather than aspirational:
8
+ * killing the endpoint degrades `/catalog` to the same search over the same file, because
9
+ * there is nothing in the endpoint that the file does not already contain.
10
+ *
11
+ * Three things the HTTP surface owes a caller that the library does not:
12
+ *
13
+ * - **Refusing a query it cannot answer**, by name. A misspelled `risk_max` silently
14
+ * ignored returns destructive records to somebody who asked for read-only ones.
15
+ * - **Saying what it applied.** Defaults that are invisible are defaults nobody chose.
16
+ * - **An ETag that changes when the answer would.** The index is content-addressed and
17
+ * the query is part of the answer, so both are in it.
18
+ */
19
+ import { type IndexedRecord, type SearchQuery, type SearchOptions } from "./search.js";
20
+ import { type ZeroResultLine } from "./log.js";
21
+ export interface EndpointRequest {
22
+ /** The full URL, or anything `URL` accepts with a base. */
23
+ readonly url: string;
24
+ /** Header names lowercased, as every runtime hands them over eventually. */
25
+ readonly headers?: Readonly<Record<string, string>>;
26
+ }
27
+ export interface EndpointResponse {
28
+ readonly status: number;
29
+ readonly headers: Readonly<Record<string, string>>;
30
+ /** Absent on 304, which is the point of a 304. */
31
+ readonly body?: string;
32
+ }
33
+ export interface EndpointOptions extends SearchOptions {
34
+ /** The artifact's own hash, so the ETag changes exactly when the catalog does. */
35
+ readonly indexHash: string;
36
+ readonly today: string;
37
+ /**
38
+ * Where a query that found nothing is recorded (§17). Injected: an endpoint that
39
+ * decided on its own where to write would be a supervisor of somebody's disk, and a
40
+ * deployment that wants no log passes nothing.
41
+ */
42
+ readonly log?: (line: ZeroResultLine) => void;
43
+ }
44
+ export declare function parseQuery(url: string): {
45
+ ok: true;
46
+ query: SearchQuery;
47
+ } | {
48
+ ok: false;
49
+ why: string;
50
+ };
51
+ /** The ETag: this catalog, this query. Both, because either changes the answer. */
52
+ export declare function etagFor(indexHash: string, query: SearchQuery): string;
53
+ export declare function handleSearch(request: EndpointRequest, index: readonly IndexedRecord[], options: EndpointOptions): EndpointResponse;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `GET /v1/search` (M-03, §12, §17, §10.1).
3
+ *
4
+ * A function of a URL and an artifact, and nothing else. There is no request object here,
5
+ * no framework and no I/O: whatever deploys this — a Lambda behind CloudFront, a route
6
+ * handler, a script on somebody's laptop — hands it the query string and the index it
7
+ * already has. That is what makes §10.1's claim literally true rather than aspirational:
8
+ * killing the endpoint degrades `/catalog` to the same search over the same file, because
9
+ * there is nothing in the endpoint that the file does not already contain.
10
+ *
11
+ * Three things the HTTP surface owes a caller that the library does not:
12
+ *
13
+ * - **Refusing a query it cannot answer**, by name. A misspelled `risk_max` silently
14
+ * ignored returns destructive records to somebody who asked for read-only ones.
15
+ * - **Saying what it applied.** Defaults that are invisible are defaults nobody chose.
16
+ * - **An ETag that changes when the answer would.** The index is content-addressed and
17
+ * the query is part of the answer, so both are in it.
18
+ */
19
+ import { runSearch, DEFAULT_LIMIT } from "./search.js";
20
+ import { readCursor } from "./cursor.js";
21
+ import { zeroResultLine } from "./log.js";
22
+ const RISKS = ["read-only", "reversible-write", "destructive", "irreversible"];
23
+ const TRUSTS = ["T0", "T1", "T2", "T3", "T4"];
24
+ const MAX_LIMIT = 100;
25
+ /** A refusal that names the parameter, because "bad request" is not actionable. */
26
+ function refuse(status, why) {
27
+ return {
28
+ status,
29
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
30
+ body: `${JSON.stringify({ error: why }, null, 2)}\n`,
31
+ };
32
+ }
33
+ export function parseQuery(url) {
34
+ const params = new URL(url, "https://runbooks.directory").searchParams;
35
+ const value = (name) => params.get(name) ?? undefined;
36
+ const risk = value("risk_max");
37
+ if (risk !== undefined && !RISKS.includes(risk)) {
38
+ return { ok: false, why: `risk_max must be one of ${RISKS.join(", ")}. An unrecognised value is refused rather than ignored: a filter that silently does not apply returns exactly what the caller asked not to see.` };
39
+ }
40
+ const trust = value("trust_min");
41
+ if (trust !== undefined && !TRUSTS.includes(trust)) {
42
+ return { ok: false, why: `trust_min must be one of ${TRUSTS.join(", ")}.` };
43
+ }
44
+ const rawLimit = value("limit");
45
+ let limit;
46
+ if (rawLimit !== undefined) {
47
+ limit = Number.parseInt(rawLimit, 10);
48
+ if (!Number.isFinite(limit) || limit < 1 || limit > MAX_LIMIT) {
49
+ return { ok: false, why: `limit must be between 1 and ${MAX_LIMIT}. The default is ${DEFAULT_LIMIT}.` };
50
+ }
51
+ }
52
+ // `available` repeats or comma-separates: both shapes arrive from real clients, and
53
+ // refusing one of them teaches nothing.
54
+ const available = params.getAll("available").flatMap((entry) => entry.split(",")).map((entry) => entry.trim()).filter(Boolean);
55
+ return {
56
+ ok: true,
57
+ query: {
58
+ ...(value("symptom") ? { symptom: value("symptom") } : {}),
59
+ ...(value("target") ? { target: value("target") } : {}),
60
+ ...(risk ? { risk_max: risk } : {}),
61
+ ...(value("execution") ? { execution: value("execution") } : {}),
62
+ ...(trust ? { trust_min: trust } : {}),
63
+ ...(value("profile") ? { profile: value("profile") } : {}),
64
+ ...(available.length > 0 ? { available } : {}),
65
+ ...(limit !== undefined ? { limit } : {}),
66
+ ...(value("cursor") ? { cursor: value("cursor") } : {}),
67
+ },
68
+ };
69
+ }
70
+ /** The ETag: this catalog, this query. Both, because either changes the answer. */
71
+ export function etagFor(indexHash, query) {
72
+ const hash = indexHash.replace(/^sha256:/, "");
73
+ const shape = Buffer.from(JSON.stringify(query), "utf8").toString("base64url");
74
+ return `"${hash}.${shape}"`;
75
+ }
76
+ export function handleSearch(request, index, options) {
77
+ const parsed = parseQuery(request.url);
78
+ if (!parsed.ok)
79
+ return refuse(400, parsed.why);
80
+ if (parsed.query.cursor) {
81
+ const read = readCursor(parsed.query.cursor, parsed.query);
82
+ if (!read.ok)
83
+ return refuse(400, read.why);
84
+ }
85
+ const etag = etagFor(options.indexHash, parsed.query);
86
+ const headers = {
87
+ "content-type": "application/json",
88
+ etag,
89
+ // A published catalog changes when it is rebuilt and not between: an hour is a
90
+ // compromise between a stale answer and a request per keystroke.
91
+ "cache-control": "public, max-age=3600",
92
+ };
93
+ if (request.headers?.["if-none-match"] === etag) {
94
+ return { status: 304, headers };
95
+ }
96
+ const { indexHash: _hash, today, log, ...searchOptions } = options;
97
+ const result = runSearch(index, parsed.query, searchOptions);
98
+ // §17: an empty result is a request for content, and the fact that it happened is the
99
+ // only part worth keeping. `zeroResultLine` cannot carry the query text at all.
100
+ if (result.hits.length === 0 && log)
101
+ log(zeroResultLine(parsed.query, today));
102
+ return { status: 200, headers, body: `${JSON.stringify(result, null, 2)}\n` };
103
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { handleSearch, parseQuery, etagFor } from "./endpoint.js";
3
+ import { readCursor, encodeCursor } from "./cursor.js";
4
+ import { runSearch } from "./search.js";
5
+ const HASH = "sha256:" + "a".repeat(64);
6
+ const record = (over) => ({
7
+ title: over.ref,
8
+ description: "",
9
+ targets: [],
10
+ capabilities: [],
11
+ trust: "T2",
12
+ risk: "read-only",
13
+ profile: "P1",
14
+ ...over,
15
+ });
16
+ const INDEX = [
17
+ record({ ref: "std/a", title: "disk pressure", capabilities: ["cli:df"], targets: ["linux"] }),
18
+ record({ ref: "std/b", title: "kafka lag", capabilities: ["cli:kafka", "cli:kubectl"], risk: "destructive" }),
19
+ record({ ref: "std/c", title: "untrusted thing", trust: "T1" }),
20
+ record({ ref: "std/d", title: "draft thing", trust: "T0" }),
21
+ ];
22
+ const get = (query, headers, log) => handleSearch({ url: `/v1/search${query}`, ...(headers ? { headers } : {}) }, INDEX, {
23
+ indexHash: HASH,
24
+ today: "2026-09-03",
25
+ ...(log ? { log } : {}),
26
+ });
27
+ const hits = (response) => JSON.parse(response.body).hits.map((h) => h.ref);
28
+ /** "The default response contains no record below T2." */
29
+ describe("the floor is a default, not an option nobody set", () => {
30
+ it("returns nothing below T2 when nobody asked", () => {
31
+ expect(hits(get(""))).toEqual(["std/a", "std/b"]);
32
+ });
33
+ it("says which floor it applied", () => {
34
+ const body = JSON.parse(get("").body);
35
+ expect(body.applied.trust_min).toBe("T2");
36
+ });
37
+ it("reaches below only when asked in as many words", () => {
38
+ expect(hits(get("?trust_min=T0"))).toContain("std/c");
39
+ });
40
+ it("refuses a trust level it does not know rather than ignoring it", () => {
41
+ const response = get("?trust_min=T9");
42
+ expect(response.status).toBe(400);
43
+ expect(response.body).toMatch(/trust_min must be one of/);
44
+ });
45
+ });
46
+ /**
47
+ * "Capability search returns exactly the records whose declared capabilities are a subset
48
+ * of those offered — and the semantics are documented, because the alternative reading is
49
+ * a security footgun."
50
+ */
51
+ describe("available means every capability, not any", () => {
52
+ it("returns a record only when all of its capabilities are offered", () => {
53
+ expect(hits(get("?available=cli:df"))).toEqual(["std/a"]);
54
+ expect(hits(get("?available=cli:kafka"))).toEqual([]);
55
+ expect(hits(get("?available=cli:kafka,cli:kubectl,cli:df")).sort()).toEqual(["std/a", "std/b"]);
56
+ });
57
+ it("accepts the repeated and the comma-separated form", () => {
58
+ expect(hits(get("?available=cli:kafka&available=cli:kubectl"))).toEqual(["std/b"]);
59
+ });
60
+ it("repeats the rule in the answer, so a caller who assumed otherwise finds out here", () => {
61
+ const body = JSON.parse(get("?available=cli:df").body);
62
+ expect(body.applied.capability_semantics).toBe("all-required-available");
63
+ });
64
+ });
65
+ /** A filter that silently does not apply returns what the caller asked not to see. */
66
+ describe("an unreadable query is refused by name", () => {
67
+ it("refuses an unknown risk ceiling", () => {
68
+ const response = get("?risk_max=scary");
69
+ expect(response.status).toBe(400);
70
+ expect(response.body).toMatch(/risk_max must be one of/);
71
+ expect(response.body).toMatch(/asked not to see/);
72
+ });
73
+ it("refuses a limit outside what it will serve", () => {
74
+ expect(get("?limit=0").status).toBe(400);
75
+ expect(get("?limit=1000").status).toBe(400);
76
+ expect(get("?limit=2").status).toBe(200);
77
+ });
78
+ it("refuses a cursor from another query rather than answering a different question", () => {
79
+ const cursor = encodeCursor("std/a", { symptom: "disk" });
80
+ const response = get(`?symptom=kafka&cursor=${cursor}`);
81
+ expect(response.status).toBe(400);
82
+ expect(response.body).toMatch(/belongs to a different query/);
83
+ });
84
+ it("refuses a cursor nobody issued", () => {
85
+ expect(get("?cursor=not-a-cursor").status).toBe(400);
86
+ });
87
+ });
88
+ /** A cursor names a position in a set, and the set is rebuilt on every request. */
89
+ describe("pagination survives the catalog changing underneath it", () => {
90
+ const many = Array.from({ length: 6 }, (_, i) => record({ ref: `std/r${i}` }));
91
+ it("continues after the last hit rather than at an offset", () => {
92
+ const first = runSearch(many, { limit: 2 });
93
+ expect(first.next_cursor).toBeDefined();
94
+ const read = readCursor(first.next_cursor, { limit: 2 });
95
+ expect(read.ok && read.cursor.after).toBe(first.hits[1].ref);
96
+ });
97
+ it("does not skip a record inserted before the page boundary", () => {
98
+ const first = runSearch(many, { limit: 2 });
99
+ const grown = [record({ ref: "std/r-new" }), ...many];
100
+ const second = runSearch(grown, { limit: 2, cursor: first.next_cursor });
101
+ // An offset cursor would have started at index 2 of the grown list and skipped r1.
102
+ expect(second.hits.map((h) => h.ref)).toEqual(["std/r2", "std/r3"]);
103
+ });
104
+ it("stops when the page is the last one", () => {
105
+ expect(runSearch(many, { limit: 100 }).next_cursor).toBeUndefined();
106
+ });
107
+ it("pages to the end without repeating or losing a record", () => {
108
+ const seen = [];
109
+ let cursor;
110
+ for (let guard = 0; guard < 10; guard++) {
111
+ const page = runSearch(many, { limit: 2, ...(cursor ? { cursor } : {}) });
112
+ seen.push(...page.hits.map((h) => h.ref));
113
+ cursor = page.next_cursor;
114
+ if (!cursor)
115
+ break;
116
+ }
117
+ expect(seen).toEqual(many.map((r) => r.ref));
118
+ });
119
+ });
120
+ /** M-01's convention: an ETag that changes exactly when the answer would. */
121
+ describe("the answer is cacheable and says when it did not change", () => {
122
+ it("answers 304 to the same query with the same catalog", () => {
123
+ const first = get("?symptom=disk");
124
+ const again = get("?symptom=disk", { "if-none-match": first.headers["etag"] });
125
+ expect(again.status).toBe(304);
126
+ expect(again.body).toBeUndefined();
127
+ });
128
+ it("changes when the catalog does", () => {
129
+ const other = "sha256:" + "b".repeat(64);
130
+ expect(etagFor(HASH, { symptom: "disk" })).not.toBe(etagFor(other, { symptom: "disk" }));
131
+ });
132
+ it("changes when the query does", () => {
133
+ expect(etagFor(HASH, { symptom: "disk" })).not.toBe(etagFor(HASH, { symptom: "kafka" }));
134
+ });
135
+ it("does not cache a refusal", () => {
136
+ expect(get("?limit=0").headers["cache-control"]).toBe("no-store");
137
+ });
138
+ });
139
+ /** "Query logs cannot be tied to an identifiable client." */
140
+ describe("what an empty result records", () => {
141
+ it("records that it happened, and the facets, and nothing else", () => {
142
+ const lines = [];
143
+ const response = get("?symptom=a+hostname+nobody+else+has&target=linux", undefined, (line) => lines.push(line));
144
+ expect(hits(response)).toEqual([]);
145
+ expect(lines.length).toBe(1);
146
+ expect(JSON.stringify(lines[0])).not.toMatch(/hostname/);
147
+ expect(lines[0].symptom_words).toBe(5);
148
+ expect([...lines[0].facets].sort()).toEqual(["symptom", "target"]);
149
+ });
150
+ it("records nothing for a query that found something", () => {
151
+ const lines = [];
152
+ get("?symptom=disk", undefined, (line) => lines.push(line));
153
+ expect(lines).toEqual([]);
154
+ });
155
+ it("logs nowhere when no log was given", () => {
156
+ expect(() => get("?symptom=nothing-matches-this")).not.toThrow();
157
+ });
158
+ it("keeps no time of day, since a timestamp series is a movement log", () => {
159
+ const lines = [];
160
+ get("?symptom=nothing-matches-this", undefined, (line) => lines.push(line));
161
+ expect(lines[0].date).toBe("2026-09-03");
162
+ expect(JSON.stringify(lines[0])).not.toMatch(/\d{2}:\d{2}/);
163
+ });
164
+ });
165
+ /**
166
+ * "Killing the search Lambda degrades to the client-side index rather than breaking
167
+ * /catalog." The endpoint is a function of the artifact: there is nothing in it that the
168
+ * file does not already contain, which is what makes that true rather than hoped.
169
+ */
170
+ describe("the endpoint is the same function over the same file", () => {
171
+ it("returns what the library returns for the same query", () => {
172
+ const direct = runSearch(INDEX, { symptom: "disk" });
173
+ expect(JSON.parse(get("?symptom=disk").body)).toEqual(JSON.parse(JSON.stringify(direct)));
174
+ });
175
+ it("holds no state of its own between requests", () => {
176
+ expect(get("?symptom=disk").body).toBe(get("?symptom=disk").body);
177
+ });
178
+ it("parses a query without needing a host", () => {
179
+ const parsed = parseQuery("/v1/search?symptom=disk&target=linux");
180
+ expect(parsed.ok && parsed.query).toEqual({ symptom: "disk", target: "linux" });
181
+ });
182
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Search over the prebuilt index (M-03, §12, §17, §10.1).
3
+ *
4
+ * Client-side until size forces a Lambda, and the Lambda is the same code over the same
5
+ * artifacts — never a source of truth. That is what makes killing it a degradation rather
6
+ * than an outage.
7
+ */
8
+ export * from "./bm25.js";
9
+ export * from "./search.js";
10
+ export * from "./log.js";
11
+ export * from "./cursor.js";
12
+ export * from "./endpoint.js";
13
+ export * from "./stack.js";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Search over the prebuilt index (M-03, §12, §17, §10.1).
3
+ *
4
+ * Client-side until size forces a Lambda, and the Lambda is the same code over the same
5
+ * artifacts — never a source of truth. That is what makes killing it a degradation rather
6
+ * than an outage.
7
+ */
8
+ export * from "./bm25.js";
9
+ export * from "./search.js";
10
+ export * from "./log.js";
11
+ export * from "./cursor.js";
12
+ export * from "./endpoint.js";
13
+ export * from "./stack.js";