@beignet/core
Version:
Core framework primitives for Beignet
635 lines (570 loc) • 16.9 kB
text/typescript
/**
* @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;
}