212 lines
5.8 KiB
TypeScript
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);
|
|
}
|