encodeCursor/decodeCursor/firstSnippet (opensearch) and decodeJwtPayload (verify) become public exports so tests can hit them without round-tripping through fetch.
260 lines
6.7 KiB
TypeScript
260 lines
6.7 KiB
TypeScript
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<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;
|
|
};
|
|
|
|
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<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 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<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 };
|
|
};
|