refactor(housekeeping): stabilize service contracts
This commit is contained in:
1 parent
67cb4b488d
commit
b7edbca043
10 files changed
+145
-28
No files matched your search
@@ -13,6 +13,7 @@ import {
|
||||
type HousekeepingSearchResult,
|
||||
type HousekeepingWidgetDefinition,
|
||||
type HousekeepingWorkItem,
|
||||
mapUnknownError,
|
||||
ok,
|
||||
} from ".";
|
||||
|
||||
@@ -25,18 +26,32 @@ const workItem = {
|
||||
domain: "people",
|
||||
capability,
|
||||
severity: "warning",
|
||||
priority: "high",
|
||||
ageMs: 60_000,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-24T12:00:00.000Z",
|
||||
titleKey: "pages.housekeeping.items.ticket",
|
||||
context: { ticketId: "42" },
|
||||
href: "/ase-next/people/tickets/42",
|
||||
href: "/ase/people/tickets/42",
|
||||
freshness: "fresh",
|
||||
actions: [
|
||||
{
|
||||
id: "people.ticket.open",
|
||||
labelKey: "pages.housekeeping.items.ticket.open",
|
||||
commandId: "people.ticket.open",
|
||||
capability,
|
||||
},
|
||||
],
|
||||
} satisfies HousekeepingWorkItem;
|
||||
|
||||
const searchResult = {
|
||||
id: "user-42",
|
||||
domain: "people",
|
||||
type: "entity",
|
||||
title: "operator",
|
||||
href: "/ase-next/people/users/42",
|
||||
description: "A Housekeeping operator",
|
||||
href: "/ase/people/users/42",
|
||||
capability,
|
||||
} satisfies HousekeepingSearchResult;
|
||||
|
||||
const searchProvider: HousekeepingSearchProvider = {
|
||||
@@ -87,7 +102,7 @@ const manifest: HousekeepingDomainManifest = {
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
descriptionKey: "pages.housekeeping.domains.people.description",
|
||||
iconId: "users",
|
||||
canonicalHref: "/ase-next/people",
|
||||
canonicalHref: "/ase/people",
|
||||
capability,
|
||||
routes: [],
|
||||
searchProviders: [searchProvider],
|
||||
@@ -96,19 +111,47 @@ const manifest: HousekeepingDomainManifest = {
|
||||
};
|
||||
|
||||
describe("housekeeping foundation contracts", () => {
|
||||
it("creates typed success and error results", () => {
|
||||
it("preserves correlation IDs for successful results", () => {
|
||||
expect(ok({ count: 2 }, "corr-1")).toEqual({
|
||||
ok: true,
|
||||
data: { count: 2 },
|
||||
correlationId: "corr-1",
|
||||
});
|
||||
expect(fail("CAPABILITY_DENIED", "corr-2")).toEqual({
|
||||
});
|
||||
|
||||
it.each([
|
||||
"UNAUTHENTICATED",
|
||||
"FORBIDDEN",
|
||||
"VALIDATION",
|
||||
"NOT_FOUND",
|
||||
"CONFLICT",
|
||||
"RATE_LIMITED",
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"TIMEOUT",
|
||||
"INTERNAL",
|
||||
] as const)("returns the approved %s error contract", (code) => {
|
||||
expect(
|
||||
fail(code, `errors.housekeeping.${code.toLowerCase()}`, "corr-2", {
|
||||
name: ["errors.validation.required"],
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "CAPABILITY_DENIED" },
|
||||
error: {
|
||||
code,
|
||||
messageKey: `errors.housekeeping.${code.toLowerCase()}`,
|
||||
fieldErrors: { name: ["errors.validation.required"] },
|
||||
},
|
||||
correlationId: "corr-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes unknown errors with the stable internal taxonomy", () => {
|
||||
expect(mapUnknownError(new Error("database password exposed"))).toEqual({
|
||||
code: "INTERNAL",
|
||||
messageKey: "errors.housekeeping.internal",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty capability requirements", () => {
|
||||
expect(() => anyCapability()).toThrow("capability requirement is empty");
|
||||
expect(() => allCapabilities()).toThrow("capability requirement is empty");
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { HousekeepingSearchProvider } from "./search";
|
||||
import type { HousekeepingWidgetDefinition } from "./widget";
|
||||
|
||||
export type HousekeepingSurface = "preview" | "canonical";
|
||||
export type CanonicalHousekeepingHref = `/ase${string}`;
|
||||
export type CanonicalHousekeepingHref = "/ase" | `/ase/${string}`;
|
||||
export interface HousekeepingRouteDefinition {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
|
||||
@@ -3,12 +3,25 @@ import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "./capability";
|
||||
import type { CanonicalHousekeepingHref } from "./domain";
|
||||
import type { HousekeepingResult } from "./result";
|
||||
|
||||
export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical";
|
||||
export type HousekeepingWorkItemFreshness = "fresh" | "stale";
|
||||
export type HousekeepingWorkItemPriority =
|
||||
| "low"
|
||||
| "normal"
|
||||
| "high"
|
||||
| "critical";
|
||||
export type HousekeepingInboxSourceAvailability = "available" | "unavailable";
|
||||
|
||||
export interface HousekeepingWorkItemAction {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
commandId: string;
|
||||
capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
export interface HousekeepingWorkItem {
|
||||
sourceId: string;
|
||||
itemId: string;
|
||||
@@ -16,11 +29,15 @@ export interface HousekeepingWorkItem {
|
||||
domain: HousekeepingDomainId;
|
||||
capability: CapabilityRequirement;
|
||||
severity: HousekeepingWorkItemSeverity;
|
||||
priority: HousekeepingWorkItemPriority;
|
||||
ageMs: number;
|
||||
state: string;
|
||||
occurredAt: string;
|
||||
titleKey: string;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
href: string;
|
||||
href: CanonicalHousekeepingHref;
|
||||
freshness: HousekeepingWorkItemFreshness;
|
||||
actions: readonly HousekeepingWorkItemAction[];
|
||||
}
|
||||
|
||||
export interface HousekeepingInboxSourceResult {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { createCorrelationId } from "../correlation";
|
||||
export {
|
||||
allCapabilities,
|
||||
anyCapability,
|
||||
@@ -17,20 +18,25 @@ export type {
|
||||
HousekeepingInboxSourceAvailability,
|
||||
HousekeepingInboxSourceResult,
|
||||
HousekeepingWorkItem,
|
||||
HousekeepingWorkItemAction,
|
||||
HousekeepingWorkItemFreshness,
|
||||
HousekeepingWorkItemPriority,
|
||||
HousekeepingWorkItemSeverity,
|
||||
} from "./inbox";
|
||||
export type { HousekeepingQuery } from "./query";
|
||||
export {
|
||||
fail,
|
||||
type HousekeepingError,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingResult,
|
||||
mapUnknownError,
|
||||
ok,
|
||||
} from "./result";
|
||||
export type {
|
||||
HousekeepingSearchInput,
|
||||
HousekeepingSearchProvider,
|
||||
HousekeepingSearchResult,
|
||||
HousekeepingSearchResultType,
|
||||
} from "./search";
|
||||
export type {
|
||||
HousekeepingWidgetDefinition,
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
export type HousekeepingErrorCode =
|
||||
| "VALIDATION_FAILED"
|
||||
| "AUTHENTICATION_REQUIRED"
|
||||
| "CAPABILITY_DENIED"
|
||||
| "UNAUTHENTICATED"
|
||||
| "FORBIDDEN"
|
||||
| "VALIDATION"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "RATE_LIMITED"
|
||||
| "DEPENDENCY_UNAVAILABLE"
|
||||
| "PARTIAL_RESULT"
|
||||
| "INTERNAL_ERROR";
|
||||
| "TIMEOUT"
|
||||
| "INTERNAL";
|
||||
|
||||
export interface HousekeepingError {
|
||||
code: HousekeepingErrorCode;
|
||||
messageKey: string;
|
||||
fieldErrors?: Readonly<Record<string, readonly string[]>>;
|
||||
}
|
||||
|
||||
export type HousekeepingResult<T> =
|
||||
| { ok: true; data: T; correlationId: string }
|
||||
| {
|
||||
ok: false;
|
||||
error: { code: HousekeepingErrorCode };
|
||||
correlationId: string;
|
||||
};
|
||||
| { ok: false; error: HousekeepingError; correlationId: string };
|
||||
|
||||
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
||||
return { ok: true, data, correlationId };
|
||||
@@ -22,7 +25,24 @@ export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
||||
|
||||
export function fail(
|
||||
code: HousekeepingErrorCode,
|
||||
messageKey: string,
|
||||
correlationId: string,
|
||||
fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
): HousekeepingResult<never> {
|
||||
return { ok: false, error: { code }, correlationId };
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code,
|
||||
messageKey,
|
||||
...(fieldErrors === undefined ? {} : { fieldErrors }),
|
||||
},
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUnknownError(_error: unknown): HousekeepingError {
|
||||
return {
|
||||
code: "INTERNAL",
|
||||
messageKey: "errors.housekeeping.internal",
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "./capability";
|
||||
import type { CanonicalHousekeepingHref } from "./domain";
|
||||
import type { HousekeepingResult } from "./result";
|
||||
|
||||
export interface HousekeepingSearchInput {
|
||||
@@ -10,11 +11,16 @@ export interface HousekeepingSearchInput {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type HousekeepingSearchResultType = "navigation" | "entity" | "command";
|
||||
|
||||
export interface HousekeepingSearchResult {
|
||||
id: string;
|
||||
domain: HousekeepingDomainId;
|
||||
type: HousekeepingSearchResultType;
|
||||
title: string;
|
||||
href: string;
|
||||
description?: string;
|
||||
href: CanonicalHousekeepingHref;
|
||||
capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
export interface HousekeepingSearchProvider {
|
||||
|
||||
@@ -14,5 +14,6 @@ export interface HousekeepingWidgetDefinition {
|
||||
kind: HousekeepingWidgetKind;
|
||||
load(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<HousekeepingResult<unknown>>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCorrelationId } from "./correlation";
|
||||
|
||||
describe("createCorrelationId", () => {
|
||||
it("creates UUID-backed identifiers within the audit storage limit", () => {
|
||||
const correlationId = createCorrelationId();
|
||||
|
||||
expect(correlationId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(correlationId.length).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
it("does not reuse an identifier for separate operations", () => {
|
||||
expect(createCorrelationId()).not.toBe(createCorrelationId());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function createCorrelationId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../migration/types";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type CapabilityRequirement,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingInboxSource,
|
||||
@@ -232,7 +233,7 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as `/ase${string}`,
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
{
|
||||
@@ -253,13 +254,13 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as `/ase${string}`,
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
{
|
||||
id: "staff",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as `/ase${string}`,
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
},
|
||||
],
|
||||
@@ -272,7 +273,7 @@ describe("housekeeping registry", () => {
|
||||
const route = {
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as `/ase${string}`,
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
@@ -285,7 +286,7 @@ describe("housekeeping registry", () => {
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
routes: [{ ...route, href: " " as `/ase${string}` }],
|
||||
routes: [{ ...route, href: " " as CanonicalHousekeepingHref }],
|
||||
},
|
||||
]),
|
||||
).toThrow("empty route href");
|
||||
@@ -484,7 +485,7 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/people/users" as `/ase${string}`,
|
||||
href: "/ase/people/users" as CanonicalHousekeepingHref,
|
||||
capability: { mode: "any", slugs: [] },
|
||||
},
|
||||
],
|
||||
@@ -497,7 +498,7 @@ describe("housekeeping registry", () => {
|
||||
const route = {
|
||||
id: "shared",
|
||||
labelKey: "pages.housekeeping.domains.people.title",
|
||||
href: "/ase/shared" as `/ase${string}`,
|
||||
href: "/ase/shared" as CanonicalHousekeepingHref,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
};
|
||||
|
||||
@@ -507,7 +508,10 @@ describe("housekeeping registry", () => {
|
||||
{
|
||||
...manifest("content"),
|
||||
routes: [
|
||||
{ ...route, href: "/ase/content/shared" as `/ase${string}` },
|
||||
{
|
||||
...route,
|
||||
href: "/ase/content/shared" as CanonicalHousekeepingHref,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
|
||||
Reference in new issue
Block a user