UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

635 lines (570 loc) 16.9 kB
/** * @beignet/core/search * * Provider-neutral search primitives for Beignet applications. */ import { createProvider, createProviderInstrumentation, } from "../providers/index.js"; /** * Primitive values accepted in indexed documents and provider-neutral filters. */ export type SearchPrimitive = null | boolean | number | string; /** * JSON-like values accepted in indexed documents. */ export type SearchValue = | SearchPrimitive | readonly SearchValue[] | { readonly [key: string]: SearchValue }; /** * Minimal document shape accepted by search indexes. */ export type SearchDocumentBase = { id: string; }; /** * General search document shape. */ export type SearchDocument = SearchDocumentBase & { readonly [field: string]: SearchValue | undefined; }; type SearchField<TDocument extends SearchDocumentBase> = Extract< keyof TDocument, string >; /** * Provider-neutral index definition. */ export type SearchIndexDef< TDocument extends SearchDocumentBase = SearchDocument, > = { kind: "search-index"; name: string; primaryKey: SearchField<TDocument>; searchableAttributes?: readonly SearchField<TDocument>[]; filterableAttributes?: readonly SearchField<TDocument>[]; sortableAttributes?: readonly SearchField<TDocument>[]; displayedAttributes?: readonly SearchField<TDocument>[]; metadata?: Record<string, unknown>; }; /** * Options accepted when defining a search index. */ export type DefineSearchIndexOptions<TDocument extends SearchDocumentBase> = Omit<SearchIndexDef<TDocument>, "kind" | "name" | "primaryKey"> & { primaryKey?: SearchField<TDocument>; }; /** * Provider-neutral filter values. */ export type SearchFilterValue = | SearchPrimitive | readonly Exclude<SearchPrimitive, null>[]; /** * Provider-neutral exact-match filters. */ export type SearchFilters = Record<string, SearchFilterValue>; /** * Provider-neutral sort expression. Use `field:asc` or `field:desc`. */ export type SearchSort = `${string}:asc` | `${string}:desc` | (string & {}); /** * Query accepted by search providers. */ export type SearchQuery = { query?: string; filters?: SearchFilters; sort?: readonly SearchSort[]; facets?: readonly string[]; limit?: number; offset?: number; }; /** * Page metadata returned from search providers. */ export type SearchResultPage = { kind: "offset"; limit: number; offset: number; total?: number; hasMore: boolean; }; /** * Search result payload. */ export type SearchResults<TDocument extends SearchDocumentBase> = { hits: TDocument[]; query: string; page: SearchResultPage; processingTimeMs?: number; facets?: Record<string, Record<string, number>>; }; /** * Result returned after indexing documents. */ export type SearchIndexResult = { indexed: number; taskId?: string | number; }; /** * Result returned after deleting indexed documents. */ export type SearchDeleteResult = { deleted: number; taskId?: string | number; }; /** * App-facing search port. */ export type SearchPort = { configureIndex<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, ): Promise<void>; indexDocuments<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, documents: TDocument | readonly TDocument[], ): Promise<SearchIndexResult>; deleteDocuments<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, ids: string | readonly string[], ): Promise<SearchDeleteResult>; clearIndex<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, ): Promise<SearchDeleteResult>; search<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, query: SearchQuery, ): Promise<SearchResults<TDocument>>; }; /** * Captured memory index state exposed for tests. */ export type MemorySearchIndexState< TDocument extends SearchDocumentBase = SearchDocument, > = { definition?: SearchIndexDef<TDocument>; documents: Map<string, TDocument>; }; /** * In-memory search port exposed for assertions in tests. */ export type MemorySearchPort = SearchPort & { indexes: Map<string, MemorySearchIndexState>; reset(index?: SearchIndexDef): void; }; /** * Options for the in-memory search adapter. */ export type CreateMemorySearchOptions = { now?: () => Date; }; /** * Options for the in-memory search provider. */ export type MemorySearchProviderOptions = CreateMemorySearchOptions & { name?: string; }; /** * Ports contributed by the memory search provider. */ export interface MemorySearchProviderPorts { search: SearchPort; } /** * Error thrown when search inputs are invalid. */ export class SearchOptionsError extends Error { constructor(message: string) { super(message); this.name = "SearchOptionsError"; } } /** * Define a typed search index. */ export function defineSearchIndex< TDocument extends SearchDocumentBase = SearchDocument, >( name: string, options: DefineSearchIndexOptions<TDocument> = {}, ): SearchIndexDef<TDocument> { if (!name) throw new SearchOptionsError("Search index name is required."); return { kind: "search-index", name, primaryKey: options.primaryKey ?? ("id" as SearchField<TDocument>), searchableAttributes: options.searchableAttributes, filterableAttributes: options.filterableAttributes, sortableAttributes: options.sortableAttributes, displayedAttributes: options.displayedAttributes, metadata: options.metadata, }; } /** * Index documents through any `SearchPort`. */ export function indexSearchDocuments<TDocument extends SearchDocumentBase>( search: SearchPort, index: SearchIndexDef<TDocument>, documents: TDocument | readonly TDocument[], ): Promise<SearchIndexResult> { return search.indexDocuments(index, documents); } /** * Query documents through any `SearchPort`. */ export function searchDocuments<TDocument extends SearchDocumentBase>( search: SearchPort, index: SearchIndexDef<TDocument>, query: SearchQuery, ): Promise<SearchResults<TDocument>> { return search.search(index, query); } /** * Create an in-memory search port for tests and single-process development. */ export function createMemorySearch( _options: CreateMemorySearchOptions = {}, ): MemorySearchPort { const indexes = new Map<string, MemorySearchIndexState>(); const port: MemorySearchPort = { 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: MemorySearchProviderOptions = {}, ) { 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, ), } satisfies MemorySearchProviderPorts, }; }, }); } function instrumentSearch( search: SearchPort, instrumentation: ReturnType<typeof createProviderInstrumentation>, ): SearchPort { 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<TDocument extends SearchDocumentBase>( indexes: Map<string, MemorySearchIndexState>, index: SearchIndexDef<TDocument>, ): MemorySearchIndexState<TDocument> { let state = indexes.get(index.name); if (!state) { state = { documents: new Map() }; indexes.set(index.name, state); } return state as unknown as MemorySearchIndexState<TDocument>; } function validateIndex<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, ) { if (!index.name) throw new SearchOptionsError("Search index name is required."); if (!index.primaryKey) { throw new SearchOptionsError("Search index primaryKey is required."); } } function validateDocument<TDocument extends SearchDocumentBase>( index: SearchIndexDef<TDocument>, document: TDocument, ) { 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: SearchQuery) { 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: SearchDocumentBase, filters: SearchFilters | undefined, ): boolean { if (!filters) return true; return Object.entries(filters).every(([field, expected]) => { const actual = (document as Record<string, unknown>)[field]; return matchesFilterValue(actual, expected); }); } function matchesFilterValue( actual: unknown, expected: SearchFilterValue, ): boolean { 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<TDocument extends SearchDocumentBase>( document: TDocument, index: SearchIndexDef<TDocument>, query: string, ): boolean { if (!query) return true; const needle = query.toLowerCase(); const fields = index.searchableAttributes ?? (Object.keys(document) as SearchField<TDocument>[]); return fields.some((field) => searchableText((document as Record<string, unknown>)[field]) .toLowerCase() .includes(needle), ); } function sortDocuments<TDocument extends SearchDocumentBase>( documents: TDocument[], sort: readonly SearchSort[] | undefined, ): TDocument[] { 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 as Record<string, unknown>)[field], (right as Record<string, unknown>)[field], ); if (result !== 0) { return direction === "desc" ? -result : result; } } return 0; }); } function compareValues(left: unknown, right: unknown): number { 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<TDocument extends SearchDocumentBase>( documents: readonly TDocument[], facets: readonly string[] | undefined, ): Record<string, Record<string, number>> | undefined { if (!facets || facets.length === 0) return undefined; const result: Record<string, Record<string, number>> = {}; for (const facet of facets) { const counts: Record<string, number> = {}; for (const document of documents) { const value = (document as Record<string, unknown>)[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: unknown): string { 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<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }