UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

345 lines 11.9 kB
/** * @beignet/core/search * * Provider-neutral search primitives for Beignet applications. */ import { createProvider, createProviderInstrumentation, } from "../providers/index.js"; /** * Error thrown when search inputs are invalid. */ export class SearchOptionsError extends Error { constructor(message) { super(message); this.name = "SearchOptionsError"; } } /** * Define a typed search index. */ export function defineSearchIndex(name, options = {}) { if (!name) throw new SearchOptionsError("Search index name is required."); return { kind: "search-index", name, primaryKey: options.primaryKey ?? "id", searchableAttributes: options.searchableAttributes, filterableAttributes: options.filterableAttributes, sortableAttributes: options.sortableAttributes, displayedAttributes: options.displayedAttributes, metadata: options.metadata, }; } /** * Index documents through any `SearchPort`. */ export function indexSearchDocuments(search, index, documents) { return search.indexDocuments(index, documents); } /** * Query documents through any `SearchPort`. */ export function searchDocuments(search, index, query) { return search.search(index, query); } /** * Create an in-memory search port for tests and single-process development. */ export function createMemorySearch(_options = {}) { const indexes = new Map(); const port = { indexes, async configureIndex(index) { validateIndex(index); stateFor(indexes, index).definition = index; }, async indexDocuments(index, documents) { validateIndex(index); const state = stateFor(indexes, index); state.definition = state.definition ?? index; const list = Array.isArray(documents) ? documents : [documents]; for (const document of list) { validateDocument(index, document); state.documents.set(String(document[index.primaryKey]), clone(document)); } return { indexed: list.length }; }, async deleteDocuments(index, ids) { validateIndex(index); const state = stateFor(indexes, index); const list = Array.isArray(ids) ? ids : [ids]; let deleted = 0; for (const id of list) { if (state.documents.delete(id)) deleted++; } return { deleted }; }, async clearIndex(index) { validateIndex(index); const state = stateFor(indexes, index); const deleted = state.documents.size; state.documents.clear(); return { deleted }; }, async search(index, query) { validateIndex(index); validateQuery(query); const state = stateFor(indexes, index); const effectiveIndex = state.definition ?? index; const queryText = query.query?.trim() ?? ""; const limit = query.limit ?? 20; const offset = query.offset ?? 0; const filtered = [...state.documents.values()] .filter((document) => matchesFilters(document, query.filters)) .filter((document) => matchesQuery(document, effectiveIndex, queryText)); const sorted = sortDocuments(filtered, query.sort); const hits = sorted.slice(offset, offset + limit).map(clone); return { hits, query: queryText, page: { kind: "offset", limit, offset, total: filtered.length, hasMore: offset + hits.length < filtered.length, }, facets: buildFacets(sorted, query.facets), }; }, reset(index) { if (index) { indexes.delete(index.name); return; } indexes.clear(); }, }; return port; } /** * Create a provider that contributes an in-memory search port. */ export function createMemorySearchProvider(options = {}) { const { name = "memory-search", ...searchOptions } = options; return createProvider({ name, metadata: { ports: ["search"], watchers: ["search"], }, setup({ ports }) { const instrumentation = createProviderInstrumentation(ports, { providerName: name, watcher: "search", }); return { ports: { search: instrumentSearch(createMemorySearch(searchOptions), instrumentation), }, }; }, }); } function instrumentSearch(search, instrumentation) { return { async configureIndex(index) { const startedAt = Date.now(); await search.configureIndex(index); instrumentation.custom({ name: "search.configureIndex", label: "Search index configured", summary: index.name, details: { index: index.name, durationMs: Date.now() - startedAt }, }); }, async indexDocuments(index, documents) { const startedAt = Date.now(); const result = await search.indexDocuments(index, documents); instrumentation.custom({ name: "search.indexDocuments", label: "Search documents indexed", summary: `${index.name}: ${result.indexed}`, details: { index: index.name, indexed: result.indexed, durationMs: Date.now() - startedAt, }, }); return result; }, async deleteDocuments(index, ids) { const startedAt = Date.now(); const result = await search.deleteDocuments(index, ids); instrumentation.custom({ name: "search.deleteDocuments", label: "Search documents deleted", summary: `${index.name}: ${result.deleted}`, details: { index: index.name, deleted: result.deleted, durationMs: Date.now() - startedAt, }, }); return result; }, async clearIndex(index) { const startedAt = Date.now(); const result = await search.clearIndex(index); instrumentation.custom({ name: "search.clearIndex", label: "Search index cleared", summary: `${index.name}: ${result.deleted}`, details: { index: index.name, deleted: result.deleted, durationMs: Date.now() - startedAt, }, }); return result; }, async search(index, query) { const startedAt = Date.now(); const result = await search.search(index, query); instrumentation.custom({ name: "search.query", label: "Search query", summary: `${index.name}: ${result.query}`, details: { index: index.name, query: result.query, hits: result.hits.length, total: result.page.total, durationMs: Date.now() - startedAt, }, }); return result; }, }; } function stateFor(indexes, index) { let state = indexes.get(index.name); if (!state) { state = { documents: new Map() }; indexes.set(index.name, state); } return state; } function validateIndex(index) { if (!index.name) throw new SearchOptionsError("Search index name is required."); if (!index.primaryKey) { throw new SearchOptionsError("Search index primaryKey is required."); } } function validateDocument(index, document) { const id = document[index.primaryKey]; if (typeof id !== "string" || id.length === 0) { throw new SearchOptionsError(`Search document for index "${index.name}" must include a non-empty "${index.primaryKey}" string.`); } } function validateQuery(query) { if (query.limit !== undefined && (!Number.isFinite(query.limit) || !Number.isInteger(query.limit) || query.limit < 1)) { throw new SearchOptionsError("Search query limit must be a positive integer."); } if (query.offset !== undefined && (!Number.isFinite(query.offset) || !Number.isInteger(query.offset) || query.offset < 0)) { throw new SearchOptionsError("Search query offset must be a zero or greater integer."); } } function matchesFilters(document, filters) { if (!filters) return true; return Object.entries(filters).every(([field, expected]) => { const actual = document[field]; return matchesFilterValue(actual, expected); }); } function matchesFilterValue(actual, expected) { if (Array.isArray(expected)) { if (Array.isArray(actual)) { return actual.some((entry) => expected.some((expectedEntry) => expectedEntry === entry)); } return expected.some((entry) => entry === actual); } if (Array.isArray(actual)) { return actual.some((entry) => entry === expected); } return actual === expected; } function matchesQuery(document, index, query) { if (!query) return true; const needle = query.toLowerCase(); const fields = index.searchableAttributes ?? Object.keys(document); return fields.some((field) => searchableText(document[field]) .toLowerCase() .includes(needle)); } function sortDocuments(documents, sort) { if (!sort || sort.length === 0) return documents; return [...documents].sort((left, right) => { for (const expression of sort) { const [field, direction = "asc"] = expression.split(":"); const result = compareValues(left[field], right[field]); if (result !== 0) { return direction === "desc" ? -result : result; } } return 0; }); } function compareValues(left, right) { if (left === right) return 0; if (left === undefined || left === null) return 1; if (right === undefined || right === null) return -1; if (typeof left === "number" && typeof right === "number") { return left - right; } return String(left).localeCompare(String(right)); } function buildFacets(documents, facets) { if (!facets || facets.length === 0) return undefined; const result = {}; for (const facet of facets) { const counts = {}; for (const document of documents) { const value = document[facet]; const values = Array.isArray(value) ? value : [value]; for (const entry of values) { if (entry === undefined || entry === null) continue; const key = String(entry); counts[key] = (counts[key] ?? 0) + 1; } } result[facet] = counts; } return result; } function searchableText(value) { if (value === undefined || value === null) return ""; if (Array.isArray(value)) return value.map(searchableText).join(" "); if (typeof value === "object") { return Object.values(value).map(searchableText).join(" "); } return String(value); } function clone(value) { return JSON.parse(JSON.stringify(value)); } //# sourceMappingURL=index.js.map