Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/foundation/search/search-service.ts
T

212 lines
5.8 KiB
TypeScript

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<ProviderOutcome> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<ProviderOutcome>((resolve) => {
timeout = setTimeout(
() =>
resolve({
providerId: provider.id,
items: [],
error: "TIMEOUT",
}),
timeoutMs,
);
});
const searched = provider
.search(context, { term, limit: SEARCH_POLICY.providerLimit })
.then<ProviderOutcome>((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<ProviderOutcome>(() => ({
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<HousekeepingSearchResponse> {
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<string>();
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<HousekeepingSearchResponse> {
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);
}