import type { API } from "@opensearch-project/opensearch"; 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; }; export 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; }; export const encodeCursor = (score: number, rkey: string): string => btoa(JSON.stringify([score, rkey])); export 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 searchAfter = opts.cursor ? decodeCursor(opts.cursor) : undefined; const body: API.Search_RequestBody = { 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", }, title: { number_of_fragments: 0 }, }, }, sort: [{ _score: "desc" }, { rkey: "desc" }], _source: ["did", "rkey", "title", "content", "publishedAt"], ...(searchAfter ? { search_after: searchAfter } : {}), }; 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 }; };