@beignet/core
Version:
Core framework primitives for Beignet
345 lines • 11.9 kB
JavaScript
/**
* @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