feat(housekeeping): integrate studio operations
This commit is contained in:
1 parent
020c06f172
commit
2e95a106e0
36 files changed
+2296
-60
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import {
|
||||
createInMemoryStudioOperationRepository,
|
||||
createStudioOperationQuery,
|
||||
STUDIO_OPERATION_KINDS,
|
||||
} from "./studio";
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Studio operation query", () => {
|
||||
it("defines the exact ten operation kinds", () => {
|
||||
expect(STUDIO_OPERATION_KINDS).toEqual([
|
||||
"badge",
|
||||
"clone",
|
||||
"clothing",
|
||||
"effect",
|
||||
"furni",
|
||||
"maintenance",
|
||||
"pet",
|
||||
"repair-icons",
|
||||
"sync",
|
||||
"upload",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed before invoking an unauthorized adapter", async () => {
|
||||
const load = vi.fn();
|
||||
const result = await createStudioOperationQuery({ load }).run(context([]), {
|
||||
routeId: "hotel.studio.furni",
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes filters and preserves truthful external-source outages", async () => {
|
||||
const load = vi.fn(async (input) => ({
|
||||
kind: "studio" as const,
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: ["external-sources"] as const,
|
||||
input,
|
||||
}));
|
||||
const result = await createStudioOperationQuery({ load }).run(
|
||||
context([PERMS.ASSETS_IMPORT]),
|
||||
{
|
||||
routeId: "hotel.studio.clone",
|
||||
list: { search: " sofa ", pageSize: 999, offset: 999_999 },
|
||||
},
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "hotel.studio.clone",
|
||||
operationKind: "clone",
|
||||
list: { search: "sofa", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { partialDependencies: ["external-sources"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the audit route across all operation kinds without inventing a command kind", async () => {
|
||||
const load = vi.fn(async () => ({
|
||||
kind: "studio" as const,
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [] as const,
|
||||
}));
|
||||
const result = await createStudioOperationQuery({ load }).run(
|
||||
context([PERMS.ASSETS_IMPORT]),
|
||||
{ routeId: "hotel.studio.audit" },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ operationKind: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Studio operation repository", () => {
|
||||
it("enforces monotonic progress and terminal event ordering", async () => {
|
||||
const repository = createInMemoryStudioOperationRepository();
|
||||
await repository.persistIntent({
|
||||
operationId: "op-1",
|
||||
kind: "furni",
|
||||
actorId: 42,
|
||||
correlationId: "corr-1",
|
||||
createdAt: "2026-08-30T10:00:00.000Z",
|
||||
});
|
||||
await repository.appendEvent({
|
||||
operationId: "op-1",
|
||||
kind: "furni",
|
||||
phase: "running",
|
||||
completed: 2,
|
||||
total: 4,
|
||||
messageKey: "pages.housekeeping.studio.running",
|
||||
correlationId: "corr-1",
|
||||
});
|
||||
await expect(
|
||||
repository.appendEvent({
|
||||
operationId: "op-1",
|
||||
kind: "furni",
|
||||
phase: "running",
|
||||
completed: 1,
|
||||
total: 4,
|
||||
messageKey: "pages.housekeeping.studio.running",
|
||||
correlationId: "corr-1",
|
||||
}),
|
||||
).rejects.toThrow("non-monotonic Studio progress");
|
||||
expect(await repository.get("op-1")).toMatchObject({
|
||||
operationId: "op-1",
|
||||
completed: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
type HotelStudioRouteId,
|
||||
hotelRouteById,
|
||||
hotelStudioOperationKind,
|
||||
} from "../routes";
|
||||
|
||||
export const STUDIO_OPERATION_KINDS = [
|
||||
"badge",
|
||||
"clone",
|
||||
"clothing",
|
||||
"effect",
|
||||
"furni",
|
||||
"maintenance",
|
||||
"pet",
|
||||
"repair-icons",
|
||||
"sync",
|
||||
"upload",
|
||||
] as const;
|
||||
export type StudioOperationKind = (typeof STUDIO_OPERATION_KINDS)[number];
|
||||
export type StudioOperationPhase =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "partial";
|
||||
|
||||
export interface StudioOperationEvent {
|
||||
readonly operationId: string;
|
||||
readonly kind: StudioOperationKind;
|
||||
readonly phase: StudioOperationPhase;
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly messageKey: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface StudioOperationIntent {
|
||||
readonly operationId: string;
|
||||
readonly kind: StudioOperationKind;
|
||||
readonly actorId: number;
|
||||
readonly correlationId: string;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface StudioOperationRecord extends StudioOperationEvent {
|
||||
readonly actorId: number;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly events?: readonly StudioOperationEvent[];
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface StudioOperationListInput {
|
||||
readonly kind?: StudioOperationKind;
|
||||
readonly search?: string;
|
||||
readonly pageSize: number;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
export interface StudioOperationListResult {
|
||||
readonly items: readonly StudioOperationRecord[];
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
export interface StudioOperationRepository {
|
||||
persistIntent(intent: StudioOperationIntent): Promise<void>;
|
||||
appendEvent(
|
||||
event: StudioOperationEvent,
|
||||
output?: Readonly<Record<string, unknown>>,
|
||||
): Promise<void>;
|
||||
get(operationId: string): Promise<StudioOperationRecord | null>;
|
||||
list(input: StudioOperationListInput): Promise<StudioOperationListResult>;
|
||||
}
|
||||
|
||||
const TERMINAL_PHASES = new Set<StudioOperationPhase>([
|
||||
"completed",
|
||||
"failed",
|
||||
"partial",
|
||||
]);
|
||||
|
||||
export function createInMemoryStudioOperationRepository(
|
||||
now: () => string = () => new Date().toISOString(),
|
||||
): StudioOperationRepository {
|
||||
const records = new Map<string, StudioOperationRecord>();
|
||||
|
||||
return {
|
||||
async persistIntent(intent) {
|
||||
if (records.has(intent.operationId)) {
|
||||
throw new Error(`duplicate Studio operation: ${intent.operationId}`);
|
||||
}
|
||||
const queued: StudioOperationEvent = {
|
||||
operationId: intent.operationId,
|
||||
kind: intent.kind,
|
||||
phase: "queued",
|
||||
completed: 0,
|
||||
total: null,
|
||||
messageKey: "pages.housekeeping.studio.queued",
|
||||
correlationId: intent.correlationId,
|
||||
};
|
||||
records.set(intent.operationId, {
|
||||
...queued,
|
||||
actorId: intent.actorId,
|
||||
createdAt: intent.createdAt,
|
||||
updatedAt: intent.createdAt,
|
||||
events: [queued],
|
||||
});
|
||||
},
|
||||
async appendEvent(event, output) {
|
||||
const previous = records.get(event.operationId);
|
||||
if (!previous)
|
||||
throw new Error(`unknown Studio operation: ${event.operationId}`);
|
||||
if (
|
||||
previous.kind !== event.kind ||
|
||||
previous.correlationId !== event.correlationId
|
||||
) {
|
||||
throw new Error("Studio operation identity mismatch");
|
||||
}
|
||||
if (TERMINAL_PHASES.has(previous.phase)) {
|
||||
throw new Error("Studio operation already terminal");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(event.completed) ||
|
||||
event.completed < previous.completed
|
||||
) {
|
||||
throw new Error("non-monotonic Studio progress");
|
||||
}
|
||||
if (
|
||||
event.total !== null &&
|
||||
(!Number.isSafeInteger(event.total) ||
|
||||
event.total < event.completed ||
|
||||
(previous.total !== null && event.total !== previous.total))
|
||||
) {
|
||||
throw new Error("invalid Studio operation total");
|
||||
}
|
||||
const events = [...(previous.events ?? []), event];
|
||||
records.set(event.operationId, {
|
||||
...previous,
|
||||
...event,
|
||||
updatedAt: now(),
|
||||
events,
|
||||
...(output ? { output: Object.freeze({ ...output }) } : {}),
|
||||
});
|
||||
},
|
||||
async get(operationId) {
|
||||
return records.get(operationId) ?? null;
|
||||
},
|
||||
async list(input) {
|
||||
const term = String(input.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const matches = [...records.values()]
|
||||
.filter((record) => !input.kind || record.kind === input.kind)
|
||||
.filter(
|
||||
(record) =>
|
||||
!term ||
|
||||
record.operationId.toLowerCase().includes(term) ||
|
||||
record.correlationId.toLowerCase().includes(term) ||
|
||||
record.kind.includes(term),
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
return {
|
||||
items: matches.slice(input.offset, input.offset + input.pageSize),
|
||||
total: matches.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const globalStudio = globalThis as unknown as {
|
||||
housekeepingStudioOperations?: StudioOperationRepository;
|
||||
};
|
||||
const memoryStudioOperations =
|
||||
globalStudio.housekeepingStudioOperations ??
|
||||
createInMemoryStudioOperationRepository();
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalStudio.housekeepingStudioOperations = memoryStudioOperations;
|
||||
}
|
||||
|
||||
function auditOutcome(phase: StudioOperationPhase) {
|
||||
if (phase === "completed") return "success" as const;
|
||||
if (phase === "partial") return "partial" as const;
|
||||
if (phase === "failed") return "failure" as const;
|
||||
return "intent" as const;
|
||||
}
|
||||
|
||||
export const studioOperationRepository: StudioOperationRepository = {
|
||||
async persistIntent(intent) {
|
||||
const { logAudit } = await import("@/lib/services/audit");
|
||||
await logAudit({
|
||||
userId: intent.actorId,
|
||||
action: "hotel_studio_operation_queued",
|
||||
target: "StudioOperation",
|
||||
after: { ...intent, phase: "queued", completed: 0, total: null },
|
||||
correlationId: intent.correlationId,
|
||||
outcome: "intent",
|
||||
domain: "hotel",
|
||||
});
|
||||
await memoryStudioOperations.persistIntent(intent);
|
||||
},
|
||||
async appendEvent(event, output) {
|
||||
const actorId = (await memoryStudioOperations.get(event.operationId))
|
||||
?.actorId;
|
||||
if (!actorId) throw new Error(`unknown Studio actor: ${event.operationId}`);
|
||||
const { logAudit } = await import("@/lib/services/audit");
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
action: `hotel_studio_operation_${event.phase}`,
|
||||
target: "StudioOperation",
|
||||
after: { ...event, ...(output ? { output } : {}) },
|
||||
correlationId: event.correlationId,
|
||||
outcome: auditOutcome(event.phase),
|
||||
domain: "hotel",
|
||||
});
|
||||
await memoryStudioOperations.appendEvent(event, output);
|
||||
},
|
||||
get: (operationId) => memoryStudioOperations.get(operationId),
|
||||
list: (input) => memoryStudioOperations.list(input),
|
||||
};
|
||||
|
||||
export interface StudioQueryInput {
|
||||
readonly routeId: HotelStudioRouteId;
|
||||
readonly list?: Readonly<{
|
||||
search?: string;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedStudioQueryInput {
|
||||
readonly routeId: HotelStudioRouteId;
|
||||
readonly operationKind: StudioOperationKind | null;
|
||||
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
|
||||
}
|
||||
|
||||
export interface StudioQueryData {
|
||||
readonly kind: "studio";
|
||||
readonly items: readonly StudioOperationRecord[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
|
||||
export interface StudioQueryAdapters {
|
||||
load(input: NormalizedStudioQueryInput): Promise<StudioQueryData>;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput {
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
operationKind: hotelStudioOperationKind(input.routeId),
|
||||
list: {
|
||||
search: String(input.list?.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const STUDIO_DEPENDENCIES = new Set([
|
||||
"database",
|
||||
"external-sources",
|
||||
"filesystem",
|
||||
"rcon",
|
||||
]);
|
||||
|
||||
function validData(data: StudioQueryData): boolean {
|
||||
return (
|
||||
data.kind === "studio" &&
|
||||
Array.isArray(data.items) &&
|
||||
Number.isSafeInteger(data.total) &&
|
||||
data.total >= 0 &&
|
||||
data.partialDependencies.every((item) => STUDIO_DEPENDENCIES.has(item))
|
||||
);
|
||||
}
|
||||
|
||||
export function createStudioOperationQuery(
|
||||
adapters: StudioQueryAdapters,
|
||||
): HousekeepingQuery<StudioQueryInput, StudioQueryData> {
|
||||
return {
|
||||
id: "hotel.studio.query",
|
||||
owner: "hotel",
|
||||
capability: hotelRouteById("hotel.studio.furni").capability,
|
||||
async run(context, rawInput) {
|
||||
const authorization = authorizeHousekeeping(
|
||||
context,
|
||||
hotelRouteById(rawInput.routeId).capability,
|
||||
);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const data = await adapters.load(normalizeInput(rawInput));
|
||||
if (!validData(data)) throw new Error("invalid Studio query data");
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const studioOperationQuery = createStudioOperationQuery({
|
||||
async load(input) {
|
||||
const result = await studioOperationRepository.list({
|
||||
kind: input.operationKind ?? undefined,
|
||||
...input.list,
|
||||
});
|
||||
const partialDependencies: string[] = [];
|
||||
if (input.operationKind === "clone" || input.operationKind === "sync") {
|
||||
try {
|
||||
const { listSources } = await import("@/lib/services/clone-sources");
|
||||
if ((await listSources()).length === 0) {
|
||||
partialDependencies.push("external-sources");
|
||||
}
|
||||
} catch {
|
||||
partialDependencies.push("external-sources");
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "studio",
|
||||
items: result.items,
|
||||
total: result.total,
|
||||
partialDependencies,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user