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

248 lines
7.0 KiB
TypeScript

import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
import { satisfiesCapability } from "./capability-context";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
HousekeepingDomainManifest,
HousekeepingErrorCode,
HousekeepingWidgetDefinition,
} from "./contracts";
import { ok } from "./contracts";
import { reconcilePreferences } from "./preferences/reconcile";
import type { HousekeepingPreferences } from "./preferences/schema";
import {
type HousekeepingProvider,
runProvider,
} from "./providers/run-provider";
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
interface OwnedRegistryEntry {
id: string;
owner: HousekeepingDomainManifest["id"];
capability: CapabilityRequirement;
}
export interface HousekeepingRegistry {
domains: readonly HousekeepingDomainManifest[];
}
export const WIDGET_POLICY = Object.freeze({ timeoutMs: 2_000 } as const);
export interface HousekeepingWidgetLoadResult {
readonly widgets: readonly { readonly id: string; readonly data: unknown }[];
readonly errors: readonly {
readonly widgetId: string;
readonly code: HousekeepingErrorCode;
}[];
}
function widgetProvider(
widget: HousekeepingWidgetDefinition,
): HousekeepingProvider<unknown> {
return {
id: widget.id,
capability: widget.capability,
async run(context, signal) {
const result = await widget.load(context, signal);
return result.ok ? ok([result.data], result.correlationId) : result;
},
};
}
export async function loadHousekeepingWidgets(
registry: HousekeepingRegistry,
preferences: HousekeepingPreferences,
context: HousekeepingCapabilityContext,
timeoutMs: number = WIDGET_POLICY.timeoutMs,
): Promise<HousekeepingWidgetLoadResult> {
const reconciled = reconcilePreferences(preferences, registry, context);
const enabledOptionalIds = new Set(reconciled.enabledOptionalWidgetIds);
const selected = registry.domains
.filter((domain) => satisfiesCapability(context, domain.capability))
.flatMap((domain) => domain.widgets)
.filter(
(widget) =>
satisfiesCapability(context, widget.capability) &&
(widget.kind === "mandatory" || enabledOptionalIds.has(widget.id)),
);
const selectedById = new Map(selected.map((widget) => [widget.id, widget]));
const orderedIds = reconciled.widgetOrder.filter((id) =>
selectedById.has(id),
);
for (const widget of selected) {
if (!orderedIds.includes(widget.id)) orderedIds.push(widget.id);
}
const ordered = orderedIds.flatMap((id) => {
const widget = selectedById.get(id);
return widget ? [widget] : [];
});
const outcomes = await Promise.all(
ordered.map((widget) =>
runProvider(widgetProvider(widget), context, timeoutMs),
),
);
return {
widgets: outcomes.flatMap((outcome) =>
outcome.error === undefined
? [{ id: outcome.providerId, data: outcome.items[0] }]
: [],
),
errors: outcomes.flatMap((outcome) =>
outcome.error === undefined
? []
: [{ widgetId: outcome.providerId, code: outcome.error }],
),
};
}
export function createHousekeepingRegistry(
manifests: readonly HousekeepingDomainManifest[],
): HousekeepingRegistry {
const domainIds = new Set<string>();
const routeIds = new Set<string>();
const routeHrefs = new Set<string>();
const routeShapes = new Set<string>();
const searchProviderIds = new Set<string>();
const inboxSourceIds = new Set<string>();
const widgetIds = new Set<string>();
for (const manifest of manifests) {
if (!approvedDomainIds.has(manifest.id)) {
throw new Error(`unknown domain id: ${manifest.id}`);
}
if (domainIds.has(manifest.id)) {
throw new Error(`duplicate domain id: ${manifest.id}`);
}
domainIds.add(manifest.id);
if (
manifest.canonicalHref !==
(manifest.id === "operations" ? "/ase" : `/ase/${manifest.id}`)
) {
throw new Error(`invalid canonical href: ${manifest.canonicalHref}`);
}
validateNonEmpty(manifest.labelKey, "label key");
validateNonEmpty(manifest.descriptionKey, "description key");
validateCapability(manifest.capability);
validateOwnedRegistryEntries(
manifest.searchProviders,
manifest.id,
"search provider",
searchProviderIds,
);
validateOwnedRegistryEntries(
manifest.inboxSources,
manifest.id,
"inbox source",
inboxSourceIds,
);
validateOwnedRegistryEntries(
manifest.widgets,
manifest.id,
"widget",
widgetIds,
);
for (const widget of manifest.widgets) {
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
throw new Error(`invalid widget kind: ${widget.id}`);
}
}
for (const route of manifest.routes) {
validateNonEmpty(route.id, "route id");
if (routeIds.has(route.id)) {
throw new Error(`duplicate route id: ${route.id}`);
}
routeIds.add(route.id);
validateNonEmpty(route.href, "route href");
if (route.href !== "/ase" && !route.href.startsWith("/ase/")) {
throw new Error(`invalid canonical route href: ${route.href}`);
}
if (routeHrefs.has(route.href)) {
throw new Error(`duplicate route href: ${route.href}`);
}
routeHrefs.add(route.href);
const routeShape = dynamicRouteShape(route.href);
if (routeShape && routeShapes.has(routeShape)) {
throw new Error(`duplicate dynamic route shape: ${routeShape}`);
}
if (routeShape) routeShapes.add(routeShape);
validateNonEmpty(route.labelKey, "route label key");
validateCapability(route.capability);
}
}
return { domains: Object.freeze([...manifests]) };
}
function dynamicRouteShape(href: string): string | null {
let hasDynamicSegment = false;
const shape = href
.split("/")
.map((segment) => {
if (!segment.startsWith(":")) return segment;
hasDynamicSegment = true;
return ":";
})
.join("/");
return hasDynamicSegment ? shape : null;
}
function validateOwnedRegistryEntries(
entries: readonly OwnedRegistryEntry[],
owner: HousekeepingDomainManifest["id"],
kind: "search provider" | "inbox source" | "widget",
ids: Set<string>,
): void {
for (const entry of entries) {
validateNonEmpty(entry.id, `${kind} id`);
if (ids.has(entry.id)) {
throw new Error(`duplicate ${kind} id: ${entry.id}`);
}
ids.add(entry.id);
if (entry.owner !== owner) {
throw new Error(`${kind} owner mismatch: ${entry.id}`);
}
validateCapability(entry.capability);
}
}
function validateNonEmpty(value: string, name: string): void {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`empty ${name}`);
}
}
function validateCapability(requirement: CapabilityRequirement): void {
if (
typeof requirement !== "object" ||
requirement === null ||
(requirement.mode !== "any" && requirement.mode !== "all") ||
!Array.isArray(requirement.slugs)
) {
throw new Error("invalid capability requirement");
}
if (requirement.slugs.length === 0) {
throw new Error("empty capability requirement");
}
for (const slug of requirement.slugs) {
if (typeof slug !== "string") {
throw new Error("invalid capability requirement");
}
if (!knownCapabilitySlugs.has(slug)) {
throw new Error(`unknown capability slug: ${slug}`);
}
}
}