From 45db68c34d42505d8a2896ddc04d5a0fc936a16d Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 7 Jun 2026 11:26:02 +0200 Subject: [PATCH] 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. --- src/search/opensearch.ts | 261 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/search/opensearch.ts diff --git a/src/search/opensearch.ts b/src/search/opensearch.ts new file mode 100644 index 0000000..5885fb7 --- /dev/null +++ b/src/search/opensearch.ts @@ -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 => { + 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 => { + const url = `${OPENSEARCH_URL}${path}`; + const headers: Record = { ...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 => { + 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 => { + 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 => { + 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 | 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[] = []; + if (opts.discoverableOnly) { + filters.push({ term: { discoverable: true } }); + } + if (opts.did) { + filters.push({ term: { did: opts.did } }); + } + + const body: Record = { + 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; + }) => ({ + 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 }; +};