feat(search): add OpenSearch client module

Exposes ensureIndex, indexNote, removeNote, and searchNotes. searchNotes
runs a fuzzy multi_match across title+content, returns sentence-bounded
highlights, and paginates via base64-encoded search_after cursors. All
operations no-op when OPENSEARCH_URL is unset so callers stay safe.
This commit is contained in:
Julien Calixte
2026-06-07 11:26:02 +02:00
parent db1e07060c
commit 45db68c34d

261
src/search/opensearch.ts Normal file
View File

@@ -0,0 +1,261 @@
import { log } from "../log.ts";
const OPENSEARCH_URL = Deno.env.get("OPENSEARCH_URL");
const OPENSEARCH_USERNAME = Deno.env.get("OPENSEARCH_USERNAME");
const OPENSEARCH_PASSWORD = Deno.env.get("OPENSEARCH_PASSWORD");
const OPENSEARCH_INDEX = Deno.env.get("OPENSEARCH_INDEX") ?? "notes";
const MAX_LIMIT = 100;
const enabled = (): boolean => {
if (!OPENSEARCH_URL) return false;
return true;
};
const authHeaders = (): Record<string, string> => {
if (OPENSEARCH_USERNAME && OPENSEARCH_PASSWORD) {
const creds = btoa(`${OPENSEARCH_USERNAME}:${OPENSEARCH_PASSWORD}`);
return { Authorization: `Basic ${creds}` };
}
return {};
};
const docId = (did: string, rkey: string): string =>
encodeURIComponent(`${did}:${rkey}`);
const request = async (
method: string,
path: string,
body?: unknown,
): Promise<Response> => {
const url = `${OPENSEARCH_URL}${path}`;
const headers: Record<string, string> = { ...authHeaders() };
if (body !== undefined) headers["Content-Type"] = "application/json";
return await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
};
const indexMappings = {
settings: {
"index.mapping.total_fields.limit": 64,
},
mappings: {
properties: {
did: { type: "keyword" },
rkey: { type: "keyword" },
title: { type: "text", analyzer: "standard" },
content: { type: "text", analyzer: "standard" },
language: { type: "keyword" },
discoverable: { type: "boolean" },
publishedAt: { type: "date" },
createdAt: { type: "date" },
},
},
};
export const ensureIndex = async (): Promise<void> => {
if (!enabled()) return;
const head = await request("HEAD", `/${OPENSEARCH_INDEX}`);
if (head.status === 200) return;
const res = await request("PUT", `/${OPENSEARCH_INDEX}`, indexMappings);
if (res.ok) {
log(`[opensearch] created index ${OPENSEARCH_INDEX}`);
return;
}
const text = await res.text();
if (text.includes("resource_already_exists_exception")) return;
throw new Error(`Failed to create index: ${res.status} ${text}`);
};
export type IndexedNote = {
did: string;
rkey: string;
title: string;
content?: string;
language?: string;
discoverable?: boolean;
publishedAt?: string;
createdAt?: string;
};
export const indexNote = async (note: IndexedNote): Promise<void> => {
if (!enabled()) return;
const doc = {
did: note.did,
rkey: note.rkey,
title: note.title,
content: note.content ?? "",
language: note.language,
discoverable: note.discoverable !== false,
publishedAt: note.publishedAt,
createdAt: note.createdAt,
};
const res = await request(
"PUT",
`/${OPENSEARCH_INDEX}/_doc/${docId(note.did, note.rkey)}`,
doc,
);
if (!res.ok) {
const text = await res.text();
throw new Error(
`Failed to index ${note.did}/${note.rkey}: ${res.status} ${text}`,
);
}
};
export const removeNote = async (
did: string,
rkey: string,
): Promise<void> => {
if (!enabled()) return;
const res = await request(
"DELETE",
`/${OPENSEARCH_INDEX}/_doc/${docId(did, rkey)}`,
);
if (!res.ok && res.status !== 404) {
const text = await res.text();
throw new Error(`Failed to delete ${did}/${rkey}: ${res.status} ${text}`);
}
};
export type SearchHit = {
did: string;
rkey: string;
title: string;
snippet: string;
score: number;
publishedAt?: string;
};
export type SearchOptions = {
q: string;
discoverableOnly: boolean;
did?: string;
cursor?: string;
limit: number;
};
const decodeCursor = (cursor: string): [number, string] | undefined => {
try {
const parsed = JSON.parse(atob(cursor));
if (
Array.isArray(parsed) &&
parsed.length === 2 &&
typeof parsed[0] === "number" &&
typeof parsed[1] === "string"
) {
return [parsed[0], parsed[1]];
}
} catch {
// fall through
}
log(`[opensearch] ignoring malformed cursor: ${cursor}`);
return undefined;
};
const encodeCursor = (score: number, rkey: string): string =>
btoa(JSON.stringify([score, rkey]));
const firstSnippet = (
highlight: Record<string, string[]> | undefined,
source: { content?: string; title?: string },
): string => {
if (highlight?.content?.length) return highlight.content[0];
if (highlight?.title?.length) return highlight.title[0];
if (source.content) return source.content.slice(0, 200);
return source.title ?? "";
};
export const searchNotes = async (
opts: SearchOptions,
): Promise<{ results: SearchHit[]; cursor?: string }> => {
if (!enabled()) {
log("[opensearch] search requested but OPENSEARCH_URL is not set");
return { results: [] };
}
const limit = Math.max(1, Math.min(opts.limit, MAX_LIMIT));
const filters: Record<string, unknown>[] = [];
if (opts.discoverableOnly) {
filters.push({ term: { discoverable: true } });
}
if (opts.did) {
filters.push({ term: { did: opts.did } });
}
const body: Record<string, unknown> = {
size: limit,
query: {
bool: {
must: [
{
multi_match: {
query: opts.q,
fields: ["title^2", "content"],
fuzziness: "AUTO",
},
},
],
filter: filters,
},
},
highlight: {
fields: {
content: {
fragment_size: 200,
number_of_fragments: 1,
boundary_scanner: "sentence",
boundary_scanner_locale: "en-US",
},
title: { number_of_fragments: 0 },
},
},
sort: [{ _score: "desc" }, { rkey: "desc" }],
_source: ["did", "rkey", "title", "content", "publishedAt"],
};
if (opts.cursor) {
const decoded = decodeCursor(opts.cursor);
if (decoded) body.search_after = decoded;
}
const res = await request("POST", `/${OPENSEARCH_INDEX}/_search`, body);
if (!res.ok) {
const text = await res.text();
throw new Error(`Search failed: ${res.status} ${text}`);
}
const json = await res.json();
const hits = json?.hits?.hits ?? [];
const results: SearchHit[] = hits.map(
(h: {
_source: {
did: string;
rkey: string;
title: string;
content?: string;
publishedAt?: string;
};
_score: number;
highlight?: Record<string, string[]>;
}) => ({
did: h._source.did,
rkey: h._source.rkey,
title: h._source.title,
snippet: firstSnippet(h.highlight, h._source),
score: h._score,
publishedAt: h._source.publishedAt,
}),
);
const cursor = results.length === limit
? encodeCursor(
results[results.length - 1].score,
results[results.length - 1].rkey,
)
: undefined;
return { results, cursor };
};