import "server-only"; import { satisfiesCapability } from "../capability-context"; import { listHousekeepingCommands } from "../commands/registry"; import type { HousekeepingCapabilityContext, HousekeepingErrorCode, HousekeepingSearchProvider, HousekeepingSearchResult, } from "../contracts"; import { createCorrelationId } from "../correlation"; import { isSafeHousekeepingHref } from "../housekeeping-href"; import { createHousekeepingRegistry, type HousekeepingRegistry, } from "../registry"; import { type DiscoverableHousekeepingCommand, type HousekeepingCommandHit, type HousekeepingNavigationHit, normalizeHousekeepingSearchTerm, searchNavigationAndCommands, } from "./navigation-search"; export const SEARCH_POLICY = Object.freeze({ minimumEntityTermLength: 2, timeoutMs: 2_000, providerLimit: 25, combinedEntityLimit: 50, } as const); export interface HousekeepingSearchResponse { readonly navigation: readonly HousekeepingNavigationHit[]; readonly commands: readonly HousekeepingCommandHit[]; readonly entities: readonly HousekeepingSearchResult[]; readonly errors: readonly { providerId: string; code: HousekeepingErrorCode; }[]; readonly correlationId: string; } export interface HousekeepingSearchServiceDependencies { readonly registry: HousekeepingRegistry; readonly commands: readonly DiscoverableHousekeepingCommand[]; readonly translate: (key: string) => string; readonly createCorrelationId?: () => string; readonly timeoutMs?: number; } type ProviderOutcome = { readonly providerId: string; readonly items: readonly HousekeepingSearchResult[]; readonly error?: HousekeepingErrorCode; }; function validEntityResult( item: HousekeepingSearchResult, provider: HousekeepingSearchProvider, context: HousekeepingCapabilityContext, ): boolean { return ( item !== null && typeof item === "object" && item.type === "entity" && item.domain === provider.owner && typeof item.id === "string" && item.id.trim().length > 0 && typeof item.title === "string" && item.title.trim().length > 0 && typeof item.href === "string" && isSafeHousekeepingHref(item.href) && satisfiesCapability(context, item.capability) ); } async function runProvider( provider: HousekeepingSearchProvider, context: HousekeepingCapabilityContext, term: string, timeoutMs: number, ): Promise { let timeout: ReturnType | undefined; const timedOut = new Promise((resolve) => { timeout = setTimeout( () => resolve({ providerId: provider.id, items: [], error: "TIMEOUT", }), timeoutMs, ); }); const searched = provider .search(context, { term, limit: SEARCH_POLICY.providerLimit }) .then((result) => { if (!result.ok) { return { providerId: provider.id, items: [], error: result.error.code, }; } return { providerId: provider.id, items: result.data .filter((item) => validEntityResult(item, provider, context)) .slice(0, SEARCH_POLICY.providerLimit), }; }) .catch(() => ({ providerId: provider.id, items: [], error: "INTERNAL", })); try { return await Promise.race([searched, timedOut]); } finally { if (timeout !== undefined) clearTimeout(timeout); } } export function createHousekeepingSearchService({ registry, commands, translate, createCorrelationId: correlationIdFactory = createCorrelationId, timeoutMs = SEARCH_POLICY.timeoutMs, }: HousekeepingSearchServiceDependencies) { return async function runSearch( term: string, context: HousekeepingCapabilityContext, ): Promise { const normalizedTerm = normalizeHousekeepingSearchTerm(term); const navigationAndCommands = searchNavigationAndCommands({ registry, commands, context, term: normalizedTerm, translate, }); if (normalizedTerm.length < SEARCH_POLICY.minimumEntityTermLength) { return { ...navigationAndCommands, entities: [], errors: [], correlationId: correlationIdFactory(), }; } const providers = registry.domains.flatMap((domain) => { if (!satisfiesCapability(context, domain.capability)) return []; return domain.searchProviders.filter((provider) => satisfiesCapability(context, provider.capability), ); }); const outcomes = await Promise.all( providers.map((provider) => runProvider(provider, context, normalizedTerm, timeoutMs), ), ); const seen = new Set(); const entities: HousekeepingSearchResult[] = []; for (const outcome of outcomes) { for (const item of outcome.items) { const key = `${item.domain}:${item.type}:${item.id}`; if (seen.has(key)) continue; seen.add(key); entities.push(item); if (entities.length === SEARCH_POLICY.combinedEntityLimit) break; } if (entities.length === SEARCH_POLICY.combinedEntityLimit) break; } return { ...navigationAndCommands, entities, errors: outcomes.flatMap((outcome) => outcome.error ? [{ providerId: outcome.providerId, code: outcome.error }] : [], ), correlationId: correlationIdFactory(), }; }; } const MESSAGE_PREFIX = "pages.housekeeping."; export async function searchHousekeeping( term: string, context: HousekeepingCapabilityContext, ): Promise { await import("../commands/bootstrap"); const [{ getTranslations }, { HOUSEKEEPING_MANIFESTS }] = await Promise.all([ import("next-intl/server"), import("../../manifests"), ]); const translate = await getTranslations("pages.housekeeping"); const service = createHousekeepingSearchService({ registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS), commands: listHousekeepingCommands(), translate: (key) => { if (!key.startsWith(MESSAGE_PREFIX)) return key; return translate(key.slice(MESSAGE_PREFIX.length) as never); }, }); return service(term, context); }