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:
261
src/search/opensearch.ts
Normal file
261
src/search/opensearch.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user