feat(housekeeping): unify complete rebuild in gated preview
This commit is contained in:
1 parent
4ad007d241
commit
43470ebf82
402 files changed
+78313
-1663
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../commands/registry";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingDomainManifest,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import { searchNavigationAndCommands } from "./navigation-search";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const peopleManifest = {
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase-next/people",
|
||||
capability: { mode: "any", slugs: [PERMS.USERS_VIEW] },
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
href: "/ase-next/people/users",
|
||||
labelKey: "pages.housekeeping.routes.people.users",
|
||||
capability: { mode: "any", slugs: [PERMS.USERS_VIEW] },
|
||||
},
|
||||
{
|
||||
id: "people.moderation",
|
||||
href: "/ase-next/people/moderation",
|
||||
labelKey: "pages.housekeeping.routes.people.moderation",
|
||||
capability: { mode: "any", slugs: [PERMS.MODERATION_VIEW] },
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} as const satisfies HousekeepingDomainManifest;
|
||||
|
||||
const registry: HousekeepingRegistry = { domains: [peopleManifest] };
|
||||
|
||||
function command(
|
||||
id: string,
|
||||
permission: string,
|
||||
): HousekeepingCommand<Record<string, never>, never> {
|
||||
return {
|
||||
id,
|
||||
owner: "people",
|
||||
risk: "safe",
|
||||
capability: { mode: "any", slugs: [permission] },
|
||||
input: z.object({}),
|
||||
requiresReason: false,
|
||||
rateLimit: { attempts: 3, windowMs: 60_000 },
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
data: undefined as never,
|
||||
correlationId: "command",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"pages.housekeeping.domains.people.title": "People",
|
||||
"pages.housekeeping.domains.people.description": "Accounts and moderation",
|
||||
"pages.housekeeping.routes.people.users": "User accounts",
|
||||
"pages.housekeeping.routes.people.moderation": "Moderation queue",
|
||||
};
|
||||
|
||||
describe("navigation and command search", () => {
|
||||
it("matches permitted domain/routes/commands and omits forbidden entries", () => {
|
||||
const result = searchNavigationAndCommands({
|
||||
registry,
|
||||
commands: [
|
||||
command("people.user.refresh", PERMS.USERS_VIEW),
|
||||
command("people.moderation.ban", PERMS.MODERATION_VIEW),
|
||||
],
|
||||
context: context([PERMS.USERS_VIEW]),
|
||||
term: "user",
|
||||
translate: (key) => translations[key] ?? key,
|
||||
});
|
||||
|
||||
expect(result.navigation.map((hit) => hit.id)).toEqual([
|
||||
"route:people.users",
|
||||
]);
|
||||
expect(result.commands.map((hit) => hit.commandId)).toEqual([
|
||||
"people.user.refresh",
|
||||
]);
|
||||
expect(result.navigation[0]?.href).toBe("/ase-next/people/users");
|
||||
expect(result.commands[0]?.href).toBe("/ase-next/people");
|
||||
});
|
||||
|
||||
it("normalizes terms and keeps registry/command order stable", () => {
|
||||
const result = searchNavigationAndCommands({
|
||||
registry,
|
||||
commands: [
|
||||
command("people.user.refresh", PERMS.USERS_VIEW),
|
||||
command("people.user.inspect", PERMS.USERS_VIEW),
|
||||
],
|
||||
context: context([PERMS.USERS_VIEW]),
|
||||
term: " PEOPLE ",
|
||||
translate: (key) => translations[key] ?? key,
|
||||
});
|
||||
|
||||
expect(result.navigation.map((hit) => hit.id)).toEqual(["domain:people"]);
|
||||
expect(result.commands.map((hit) => hit.commandId)).toEqual([
|
||||
"people.user.refresh",
|
||||
"people.user.inspect",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import type {
|
||||
CanonicalHousekeepingHref,
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingSearchResult,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
|
||||
export interface HousekeepingNavigationHit
|
||||
extends Omit<HousekeepingSearchResult, "type"> {
|
||||
readonly type: "navigation";
|
||||
}
|
||||
|
||||
export interface HousekeepingCommandHit
|
||||
extends Omit<HousekeepingSearchResult, "type"> {
|
||||
readonly type: "command";
|
||||
readonly commandId: string;
|
||||
readonly risk: "safe" | "sensitive";
|
||||
readonly requiresReason: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoverableHousekeepingCommand {
|
||||
readonly id: string;
|
||||
readonly owner: HousekeepingCommandHit["domain"];
|
||||
readonly risk: HousekeepingCommandHit["risk"];
|
||||
readonly capability: CapabilityRequirement;
|
||||
readonly requiresReason: boolean;
|
||||
}
|
||||
|
||||
export interface NavigationAndCommandSearchInput {
|
||||
readonly registry: HousekeepingRegistry;
|
||||
readonly commands: readonly DiscoverableHousekeepingCommand[];
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly term: string;
|
||||
readonly translate: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface NavigationAndCommandSearchResult {
|
||||
readonly navigation: readonly HousekeepingNavigationHit[];
|
||||
readonly commands: readonly HousekeepingCommandHit[];
|
||||
}
|
||||
|
||||
export function normalizeHousekeepingSearchTerm(term: string): string {
|
||||
return term.normalize("NFC").trim().slice(0, 128);
|
||||
}
|
||||
|
||||
function searchTokens(term: string): readonly string[] {
|
||||
return normalizeHousekeepingSearchTerm(term)
|
||||
.toLocaleLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function matches(tokens: readonly string[], ...values: readonly string[]) {
|
||||
if (tokens.length === 0) return true;
|
||||
const haystack = values.join(" ").normalize("NFC").toLocaleLowerCase();
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
function commandTitle(
|
||||
commandId: string,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
const key = `pages.housekeeping.commands.${commandId}`;
|
||||
const translated = translate(key);
|
||||
if (translated !== key) return translated;
|
||||
return commandId
|
||||
.split(".")
|
||||
.slice(1)
|
||||
.flatMap((segment) => segment.split("-"))
|
||||
.map((segment) =>
|
||||
segment.length === 0
|
||||
? segment
|
||||
: `${segment[0]?.toLocaleUpperCase()}${segment.slice(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function searchNavigationAndCommands({
|
||||
registry,
|
||||
commands,
|
||||
context,
|
||||
term,
|
||||
translate,
|
||||
}: NavigationAndCommandSearchInput): NavigationAndCommandSearchResult {
|
||||
const tokens = searchTokens(term);
|
||||
const navigation: HousekeepingNavigationHit[] = [];
|
||||
|
||||
for (const domain of registry.domains) {
|
||||
if (!satisfiesCapability(context, domain.capability)) continue;
|
||||
const domainTitle = translate(domain.labelKey);
|
||||
const domainDescription = translate(domain.descriptionKey);
|
||||
if (matches(tokens, domain.id, domainTitle, domainDescription)) {
|
||||
navigation.push({
|
||||
id: `domain:${domain.id}`,
|
||||
domain: domain.id,
|
||||
type: "navigation",
|
||||
title: domainTitle,
|
||||
description: domainDescription,
|
||||
href: domain.canonicalHref,
|
||||
capability: domain.capability,
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of domain.routes) {
|
||||
if (!satisfiesCapability(context, route.capability)) continue;
|
||||
const title = translate(route.labelKey);
|
||||
const localRouteId = route.id.split(".").slice(1).join(" ");
|
||||
if (!matches(tokens, localRouteId, title)) continue;
|
||||
navigation.push({
|
||||
id: `route:${route.id}`,
|
||||
domain: domain.id,
|
||||
type: "navigation",
|
||||
title,
|
||||
href: route.href,
|
||||
capability: route.capability,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const commandsFound: HousekeepingCommandHit[] = [];
|
||||
for (const command of commands) {
|
||||
if (!satisfiesCapability(context, command.capability)) continue;
|
||||
const domain = registry.domains.find(
|
||||
(candidate) => candidate.id === command.owner,
|
||||
);
|
||||
if (!domain || !satisfiesCapability(context, domain.capability)) continue;
|
||||
const title = commandTitle(command.id, translate);
|
||||
if (!matches(tokens, command.id, command.owner, title)) continue;
|
||||
commandsFound.push({
|
||||
id: `command:${command.id}`,
|
||||
commandId: command.id,
|
||||
domain: command.owner,
|
||||
type: "command",
|
||||
title,
|
||||
description: command.id,
|
||||
href: domain.canonicalHref as CanonicalHousekeepingHref,
|
||||
capability: command.capability,
|
||||
risk: command.risk,
|
||||
requiresReason: command.requiresReason,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
navigation: navigation.slice(0, 50),
|
||||
commands: commandsFound.slice(0, 50),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingSearchProvider,
|
||||
ok,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRegistry } from "../registry";
|
||||
import {
|
||||
createHousekeepingSearchService,
|
||||
SEARCH_POLICY,
|
||||
} from "./search-service";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
function provider(
|
||||
id: string,
|
||||
permission: string,
|
||||
search: HousekeepingSearchProvider["search"],
|
||||
): HousekeepingSearchProvider {
|
||||
return {
|
||||
id,
|
||||
owner: "people",
|
||||
capability: anyCapability(permission),
|
||||
search,
|
||||
};
|
||||
}
|
||||
|
||||
function registry(
|
||||
searchProviders: readonly HousekeepingSearchProvider[],
|
||||
): HousekeepingRegistry {
|
||||
return {
|
||||
domains: [
|
||||
{
|
||||
id: "people",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase-next/people",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
routes: [
|
||||
{
|
||||
id: "people.users",
|
||||
href: "/ase-next/people/users",
|
||||
labelKey: "pages.housekeeping.routes.people.users",
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
searchProviders,
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function entity(id: string) {
|
||||
return {
|
||||
id,
|
||||
domain: "people" as const,
|
||||
type: "entity" as const,
|
||||
title: `User ${id}`,
|
||||
href: "/ase-next/people/users" as const,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Housekeeping search service", () => {
|
||||
it("locks the provider timeout and result limits", () => {
|
||||
expect(SEARCH_POLICY).toEqual({
|
||||
minimumEntityTermLength: 2,
|
||||
timeoutMs: 2_000,
|
||||
providerLimit: 25,
|
||||
combinedEntityLimit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("searches navigation/commands only below two trimmed characters", async () => {
|
||||
const search = vi.fn(async () => ok([entity("1")], "provider"));
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([provider("people.users", PERMS.USERS_VIEW, search)]),
|
||||
commands: [],
|
||||
translate: (key) =>
|
||||
key.endsWith("people.users") ? "Accounts" : "People",
|
||||
createCorrelationId: () => "search-short",
|
||||
});
|
||||
|
||||
const response = await service(" a ", context([PERMS.USERS_VIEW]));
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(response.navigation.map((hit) => hit.title)).toContain("Accounts");
|
||||
expect(response.entities).toEqual([]);
|
||||
expect(response.correlationId).toBe("search-short");
|
||||
});
|
||||
|
||||
it("invokes only permitted providers for two-character terms", async () => {
|
||||
const permitted = vi.fn(async () => ok([entity("1")], "permitted"));
|
||||
const forbidden = vi.fn(async () => ok([entity("2")], "forbidden"));
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([
|
||||
provider("people.users", PERMS.USERS_VIEW, permitted),
|
||||
provider("people.bans", PERMS.BANS_VIEW, forbidden),
|
||||
]),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-two",
|
||||
});
|
||||
|
||||
const response = await service("ab", context([PERMS.USERS_VIEW]));
|
||||
expect(permitted).toHaveBeenCalledWith(expect.any(Object), {
|
||||
term: "ab",
|
||||
limit: 25,
|
||||
});
|
||||
expect(forbidden).not.toHaveBeenCalled();
|
||||
expect(response.entities.map((item) => item.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("does not invoke providers owned by a forbidden domain", async () => {
|
||||
const search = vi.fn(async () => ok([entity("1")], "forbidden-domain"));
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([provider("people.bans", PERMS.BANS_VIEW, search)]),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-forbidden-domain",
|
||||
});
|
||||
|
||||
const response = await service("ab", context([PERMS.BANS_VIEW]));
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(response.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps providers at 25, deduplicates stably, and caps combined entities at 50", async () => {
|
||||
const ranges = [
|
||||
Array.from({ length: 30 }, (_, index) => entity(String(index))),
|
||||
Array.from({ length: 30 }, (_, index) => entity(String(index + 20))),
|
||||
Array.from({ length: 30 }, (_, index) => entity(String(index + 45))),
|
||||
];
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry(
|
||||
ranges.map((items, index) =>
|
||||
provider(`people.${index}`, PERMS.USERS_VIEW, async () =>
|
||||
ok(items, `provider-${index}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-limits",
|
||||
});
|
||||
|
||||
const response = await service("user", context([PERMS.USERS_VIEW]));
|
||||
expect(response.entities).toHaveLength(50);
|
||||
expect(response.entities.map((item) => item.id)).toEqual(
|
||||
Array.from({ length: 50 }, (_, index) => String(index)),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns successful entities with stable typed timeout/provider errors", async () => {
|
||||
const never = new Promise<never>(() => undefined);
|
||||
const service = createHousekeepingSearchService({
|
||||
registry: registry([
|
||||
provider("people.timeout", PERMS.USERS_VIEW, () => never),
|
||||
provider("people.failed", PERMS.USERS_VIEW, async () =>
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"failed",
|
||||
),
|
||||
),
|
||||
provider("people.ready", PERMS.USERS_VIEW, async () =>
|
||||
ok([entity("ready")], "ready"),
|
||||
),
|
||||
]),
|
||||
commands: [],
|
||||
translate: (key) => key,
|
||||
createCorrelationId: () => "search-partial",
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
const response = await service("user", context([PERMS.USERS_VIEW]));
|
||||
expect(response.entities.map((item) => item.id)).toEqual(["ready"]);
|
||||
expect(response.errors).toEqual([
|
||||
{ providerId: "people.timeout", code: "TIMEOUT" },
|
||||
{ providerId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
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> {
|
||||
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