feat(housekeeping): add global command deck search
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user