feat(housekeeping): unify complete rebuild in gated preview
CI / check (pull_request) Failing after 10s
CI / deploy (pull_request) Skipped
CI / release (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-31 20:37:51 +02:00
1 parent 4ad007d241
commit 43470ebf82
402 files changed
+78313 -1663

No files matched your search

@@ -0,0 +1,4 @@
export const HOTEL_ASSET_QUERY_OWNERSHIP = Object.freeze({
badges: "economy.rewards.badges",
sounds: "economy.rewards.sounds",
} as const);
@@ -0,0 +1,232 @@
import "server-only";
import type { SQL } from "drizzle-orm";
import { type HotelPrimaryRouteId, hotelRouteGroup } from "../routes";
import type {
HotelQueryData,
HotelQueryItem,
NormalizedHotelQueryInput,
} from "./rooms";
type RawRow = Readonly<Record<string, unknown>>;
interface QueryDefinition {
readonly statement: string;
readonly href: string;
readonly appendId?: boolean;
readonly parameterAlias?: "id" | "room_id";
readonly fallbackStatement?: string;
readonly partialDependency?: string;
}
export const HOTEL_QUERY_DEFINITIONS = {
"hotel.rooms": {
statement:
"SELECT id, name AS title, CONCAT(COALESCE(NULLIF(owner_name, ''), 'unknown'), ' · ', users, '/', users_max) AS description, state AS status, NULL AS updated_at FROM rooms ORDER BY id DESC",
href: "/ase-next/hotel/rooms/",
appendId: true,
},
"hotel.room-detail": {
statement:
"SELECT id, name AS title, CONCAT(COALESCE(NULLIF(owner_name, ''), 'unknown'), ' · score ', score, ' · category ', category) AS description, state AS status, NULL AS updated_at FROM rooms ORDER BY id DESC",
href: "/ase-next/hotel/rooms/",
appendId: true,
},
"hotel.room-furni": {
statement:
"SELECT i.id, COALESCE(NULLIF(ib.public_name, ''), NULLIF(ib.item_name, ''), CONCAT('#', i.item_id)) AS title, CONCAT('owner-', i.user_id, ' @ ', i.x, ',', i.y, ',', i.z) AS description, CONCAT('rot-', i.rot) AS status, NULL AS updated_at, i.room_id FROM items i LEFT JOIN items_base ib ON ib.id = i.item_id ORDER BY i.id DESC",
href: "/ase-next/hotel/rooms",
parameterAlias: "room_id",
},
"hotel.navigation": {
statement:
"SELECT CONCAT('flat-', id) AS id, caption AS title, CONCAT('min-rank ', min_rank, ' · max-users ', max_user_count) AS description, CASE WHEN public = '1' THEN 'public' ELSE 'private' END AS status, NULL AS updated_at FROM navigator_flatcats UNION ALL SELECT CONCAT('public-', np.public_cat_id, '-', np.room_id) AS id, COALESCE(NULLIF(r.name, ''), CONCAT('Room #', np.room_id)) AS title, CONCAT('category-', np.public_cat_id) AS description, CASE WHEN np.visible = '1' THEN 'visible' ELSE 'hidden' END AS status, NULL AS updated_at FROM navigator_publics np LEFT JOIN rooms r ON r.id = np.room_id ORDER BY title ASC, id ASC",
fallbackStatement:
"SELECT CONCAT('flat-', id) AS id, caption AS title, CONCAT('min-rank ', min_rank, ' · max-users ', max_user_count) AS description, CASE WHEN public = '1' THEN 'public' ELSE 'private' END AS status, NULL AS updated_at FROM navigator_flatcats ORDER BY title ASC, id ASC",
partialDependency: "navigator-publics",
href: "/ase-next/hotel/navigation",
},
"hotel.radio.overview": {
statement:
"SELECT CONCAT('application-', id) AS id, real_name AS title, motivation AS description, status, created_at AS updated_at FROM radio_applications UNION ALL SELECT CONCAT('schedule-', id) AS id, COALESCE(NULLIF(show_name, ''), day) AS title, CONCAT(day, ' ', start_time, '-', end_time) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, created_at AS updated_at FROM radio_schedules UNION ALL SELECT CONCAT('shout-', id) AS id, CONCAT('User #', user_id) AS title, message AS description, 'shout' AS status, created_at AS updated_at FROM radio_shouts ORDER BY updated_at DESC, id DESC",
href: "/ase-next/hotel/radio/overview",
},
"hotel.radio.settings": {
statement:
"SELECT id, `key` AS title, CASE WHEN LOWER(`key`) REGEXP '(key|secret|password|token|webhook|custom_js)' THEN '[redacted]' ELSE LEFT(value, 512) END AS description, 'configured' AS status, NULL AS updated_at FROM website_settings WHERE `key` LIKE 'radio_%' OR `key` LIKE 'auto_dj_%' ORDER BY `key` ASC, id ASC",
href: "/ase-next/hotel/radio/settings",
},
"hotel.radio.moderation": {
statement:
"SELECT id, CONCAT('User #', user_id) AS title, message AS description, 'shout' AS status, created_at AS updated_at FROM radio_shouts ORDER BY created_at DESC, id DESC",
href: "/ase-next/hotel/radio/moderation",
},
"hotel.radio.autodj": {
statement:
"SELECT id, title, CONCAT(COALESCE(artist, ''), CASE WHEN album IS NULL OR album = '' THEN '' ELSE CONCAT(' · ', album) END) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_auto_dj_playlist ORDER BY sort_order ASC, title ASC, id ASC",
href: "/ase-next/hotel/radio/autodj",
},
"hotel.radio.history": {
statement:
"SELECT id, COALESCE(NULLIF(show_name, ''), CONCAT('DJ #', user_id)) AS title, CONCAT(listeners_count, ' listeners') AS description, CASE WHEN ended_at IS NULL THEN 'live' ELSE 'completed' END AS status, started_at AS updated_at FROM radio_history ORDER BY started_at DESC, id DESC",
href: "/ase-next/hotel/radio/history",
},
"hotel.radio.points": {
statement:
"SELECT id, `key` AS title, value AS description, 'configured' AS status, NULL AS updated_at FROM website_settings WHERE `key` LIKE 'radio_points_%' ORDER BY `key` ASC, id ASC",
href: "/ase-next/hotel/radio/points",
},
"hotel.radio.ranks": {
statement:
"SELECT id, name AS title, CONCAT(COALESCE(description, ''), CASE WHEN badge_code IS NULL OR badge_code = '' THEN '' ELSE CONCAT(' · ', badge_code) END) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_ranks ORDER BY name ASC, id ASC",
href: "/ase-next/hotel/radio/ranks",
},
"hotel.radio.api-keys": {
statement:
"SELECT id, name AS title, CONCAT('****', RIGHT(`key`, 4), CASE WHEN allowed_ips IS NULL OR allowed_ips = '' THEN '' ELSE CONCAT(' · ', allowed_ips) END) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_api_keys ORDER BY id DESC",
href: "/ase-next/hotel/radio/api-keys",
},
"hotel.radio.banners": {
statement:
"SELECT id, COALESCE(NULLIF(title, ''), image_path) AS title, image_path AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_banners ORDER BY sort_order ASC, id DESC",
href: "/ase-next/hotel/radio/banners",
},
"hotel.radio.embed": {
statement:
"SELECT id, `key` AS title, value AS description, 'embed' AS status, NULL AS updated_at FROM website_settings WHERE `key` IN ('radio_name', 'radio_stream_url', 'radio_logo_url') ORDER BY `key` ASC, id ASC",
href: "/ase-next/hotel/radio/embed",
},
} as const satisfies Partial<Record<HotelPrimaryRouteId, QueryDefinition>>;
function rows(result: unknown): readonly RawRow[] {
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid Hotel query result");
}
return result[0] as readonly RawRow[];
}
function value(input: unknown, fallback = ""): string {
return typeof input === "string"
? input
: input == null
? fallback
: String(input);
}
function date(valueToParse: unknown): string | null {
if (valueToParse == null) return null;
const parsed =
valueToParse instanceof Date
? valueToParse
: new Date(String(valueToParse));
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function statementParts(statement: string) {
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
return match
? { selection: match[1], ordering: match[2] }
: { selection: statement, ordering: "" };
}
function totalFromRows(rawRows: readonly RawRow[]): number {
const total = Number(rawRows[0]?.total ?? 0);
if (!Number.isSafeInteger(total) || total < 0) {
throw new Error("invalid Hotel query total");
}
return total;
}
function mapRows(
definition: QueryDefinition,
rawRows: readonly RawRow[],
): readonly HotelQueryItem[] {
return rawRows.map((row) => {
const id = value(row.id);
if (!id) throw new Error("invalid Hotel identifier");
return {
id,
title: value(row.title, "Untitled hotel entry"),
description: value(row.description) || undefined,
status: value(row.status) || undefined,
updatedAt: date(row.updated_at),
href: definition.appendId ? definition.href + id : definition.href,
};
});
}
export async function loadHotelDatabaseQuery(
input: NormalizedHotelQueryInput,
): Promise<HotelQueryData> {
const { db } = await import("@/lib/db");
return loadHotelDatabaseQueryWith(input, (query) => db.execute(query));
}
export async function loadHotelDatabaseQueryWith(
input: NormalizedHotelQueryInput,
execute: (query: SQL) => Promise<unknown>,
): Promise<HotelQueryData> {
const definition = (
HOTEL_QUERY_DEFINITIONS as Partial<
Record<HotelPrimaryRouteId, QueryDefinition>
>
)[input.routeId];
if (!definition) throw new Error("missing Hotel query adapter");
const resolvedDefinition: QueryDefinition = definition;
const { sql } = await import("drizzle-orm");
async function run(
statement: string,
partialDependencies: readonly string[],
): Promise<HotelQueryData> {
const { selection, ordering } = statementParts(statement);
const filters: SQL[] = [];
if (input.list.search) {
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
filters.push(
sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`,
);
}
if (input.params.id) {
const alias = resolvedDefinition.parameterAlias ?? "id";
filters.push(sql`CAST(${sql.raw(alias)} AS CHAR) = ${input.params.id}`);
}
const filtered = filters.length
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
: sql.raw(selection);
const total = totalFromRows(
rows(
await execute(
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS hotel_rows`,
),
),
);
const items = mapRows(
resolvedDefinition,
rows(
await execute(
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
),
),
);
return {
kind: hotelRouteGroup(input.routeId),
items,
total,
partialDependencies,
};
}
try {
return await run(resolvedDefinition.statement, []);
} catch (error) {
if (
!resolvedDefinition.fallbackStatement ||
!resolvedDefinition.partialDependency
) {
throw error;
}
return run(resolvedDefinition.fallbackStatement, [
resolvedDefinition.partialDependency,
]);
}
}
@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { HOTEL_PRIMARY_ROUTE_IDS } from "../routes";
import { loadHotelDatabaseQueryWith } from "./hotel-production";
import {
createHotelQuery,
type HotelQueryData,
type NormalizedHotelQueryInput,
} from "./rooms";
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("Hotel query adapters", () => {
it("fails closed before invoking an unauthorized adapter", async () => {
const load = vi.fn();
const result = await createHotelQuery({ load }).run(context([]), {
routeId: "hotel.rooms",
});
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(load).not.toHaveBeenCalled();
});
it("normalizes bounded pagination and preserves BIGINT identifiers", async () => {
const load = vi.fn(
async (): Promise<HotelQueryData> => ({
kind: "radio",
items: [
{
id: "18446744073709551615",
title: "Radio history",
href: "/ase-next/hotel/radio/history",
},
],
total: 501,
partialDependencies: [],
}),
);
const result = await createHotelQuery({ load }).run(
context([PERMS.RADIO_VIEW]),
{
routeId: "hotel.radio.history",
list: { search: " show ", pageSize: 999, offset: 999_999 },
},
);
expect(load).toHaveBeenCalledWith({
routeId: "hotel.radio.history",
params: {},
list: { search: "show", pageSize: 100, offset: 100_000 },
});
expect(result).toMatchObject({
ok: true,
data: { items: [{ id: "18446744073709551615" }], total: 501 },
});
});
it("reports only truthful unavailable radio and RCON dependencies", async () => {
const query = createHotelQuery({
load: async (input: NormalizedHotelQueryInput) => ({
kind: input.routeId.startsWith("hotel.radio.") ? "radio" : "rooms",
items: [],
total: 0,
partialDependencies: input.routeId.startsWith("hotel.radio.")
? ["radio"]
: ["rcon"],
}),
});
expect(
await query.run(context([PERMS.RADIO_VIEW]), {
routeId: "hotel.radio.monitoring",
}),
).toMatchObject({ ok: true, data: { partialDependencies: ["radio"] } });
expect(
await query.run(context([PERMS.ROOMS_VIEW]), {
routeId: "hotel.room-detail",
}),
).toMatchObject({ ok: true, data: { partialDependencies: ["rcon"] } });
});
it("rejects fabricated dependencies and unsafe canonical hrefs", async () => {
const query = createHotelQuery({
load: async () => ({
kind: "rooms",
items: [{ id: "1", title: "Unsafe", href: "/ase-next/hotel/%2e%2e/system" }],
total: 1,
partialDependencies: ["imaginary"],
}),
});
expect(
await query.run(context([PERMS.ROOMS_VIEW]), { routeId: "hotel.rooms" }),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("has an authorized adapter path for every primary Hotel route", async () => {
const load = vi.fn(async (input: NormalizedHotelQueryInput) => ({
kind: input.routeId.startsWith("hotel.radio.")
? ("radio" as const)
: input.routeId === "hotel.navigation"
? ("navigation" as const)
: ("rooms" as const),
items: [],
total: 0,
partialDependencies: [],
}));
const query = createHotelQuery({ load });
for (const routeId of HOTEL_PRIMARY_ROUTE_IDS) {
const result = await query.run(context(Object.values(PERMS)), {
routeId,
});
expect(result.ok, routeId).toBe(true);
}
expect(load).toHaveBeenCalledTimes(HOTEL_PRIMARY_ROUTE_IDS.length);
});
it("falls back to navigator categories and reports missing public rooms", async () => {
const execute = vi
.fn()
.mockRejectedValueOnce(new Error("navigator_publics unavailable"))
.mockResolvedValueOnce([[{ total: 1 }]])
.mockResolvedValueOnce([
[
{
id: "flat-1",
title: "Public rooms",
status: "public",
updated_at: null,
},
],
]);
const result = await loadHotelDatabaseQueryWith(
{
routeId: "hotel.navigation",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
},
execute,
);
expect(result).toMatchObject({
total: 1,
partialDependencies: ["navigator-publics"],
items: [{ id: "flat-1", href: "/ase-next/hotel/navigation" }],
});
});
it("preserves masked API-key output from production rows", async () => {
const execute = vi
.fn()
.mockResolvedValueOnce([[{ total: 1 }]])
.mockResolvedValueOnce([
[
{
id: 9n,
title: "Bridge",
description: "****cafe",
status: "active",
updated_at: null,
},
],
]);
const result = await loadHotelDatabaseQueryWith(
{
routeId: "hotel.radio.api-keys",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
},
execute,
);
expect(result.items).toEqual([
expect.objectContaining({ id: "9", description: "****cafe" }),
]);
expect(JSON.stringify(result)).not.toContain("secret-api-key");
});
});
@@ -0,0 +1,42 @@
import type { HotelQueryData, NormalizedHotelQueryInput } from "./rooms";
export async function loadHotelRadioQuery(
input: NormalizedHotelQueryInput,
): Promise<HotelQueryData> {
if (input.routeId !== "hotel.radio.monitoring") {
const { loadHotelDatabaseQuery } = await import("./hotel-production");
return loadHotelDatabaseQuery(input);
}
const { fetchListeners, fetchNowPlaying } = await import(
"@/lib/services/radio"
);
const [nowPlaying, listeners] = await Promise.all([
fetchNowPlaying(),
fetchListeners(),
]);
const items: Array<HotelQueryData["items"][number]> = [];
if (nowPlaying) {
items.push({
id: "now-playing",
title: nowPlaying.title,
description: nowPlaying.artist ?? undefined,
status: "playing",
href: "/ase-next/hotel/radio/monitoring",
});
}
if (listeners !== null) {
items.push({
id: "listeners",
title: `${listeners} listeners`,
status: "live",
href: "/ase-next/hotel/radio/monitoring",
});
}
return {
kind: "radio",
items,
total: items.length,
partialDependencies:
nowPlaying === null && listeners === null ? ["radio"] : [],
};
}
@@ -0,0 +1,158 @@
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
import {
type HotelPrimaryRouteId,
type HotelRouteGroup,
hotelRouteById,
hotelRouteGroup,
} from "../routes";
export interface HotelQueryInput {
readonly routeId: HotelPrimaryRouteId;
readonly params?: Readonly<Record<string, string>>;
readonly list?: Readonly<{
search?: string;
pageSize?: number;
offset?: number;
}>;
}
export interface HotelQueryItem {
readonly id: string;
readonly title: string;
readonly status?: string;
readonly href?: string;
readonly description?: string;
readonly updatedAt?: string | null;
}
export interface HotelQueryData {
readonly kind: HotelRouteGroup;
readonly items: readonly HotelQueryItem[];
readonly total: number;
readonly partialDependencies: readonly string[];
}
export interface NormalizedHotelQueryInput {
readonly routeId: HotelPrimaryRouteId;
readonly params: Readonly<Record<string, string>>;
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
}
export interface HotelQueryAdapters {
load(input: NormalizedHotelQueryInput): Promise<HotelQueryData>;
}
const ALLOWED_PARTIAL_DEPENDENCIES = Object.freeze({
"hotel.room-detail": new Set(["rcon"]),
"hotel.room-furni": new Set(["rcon"]),
"hotel.navigation": new Set(["navigator-publics"]),
"hotel.radio.monitoring": new Set(["radio"]),
} as Partial<Record<HotelPrimaryRouteId, ReadonlySet<string>>>);
function boundedInteger(
value: unknown,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function normalizeInput(input: HotelQueryInput): NormalizedHotelQueryInput {
return {
routeId: input.routeId,
params: Object.fromEntries(
Object.entries(input.params ?? {}).map(([key, value]) => [
key.normalize("NFC").trim().slice(0, 128),
value.normalize("NFC").trim().slice(0, 128),
]),
),
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),
},
};
}
function isValidData(
data: HotelQueryData,
input: NormalizedHotelQueryInput,
): boolean {
if (
data.kind !== hotelRouteGroup(input.routeId) ||
!Array.isArray(data.items) ||
!Number.isSafeInteger(data.total) ||
data.total < 0 ||
!Array.isArray(data.partialDependencies)
) {
return false;
}
const allowed = ALLOWED_PARTIAL_DEPENDENCIES[input.routeId] ?? new Set();
if (data.partialDependencies.some((dependency) => !allowed.has(dependency))) {
return false;
}
return data.items.every(
(item) =>
typeof item.id === "string" &&
item.id.length > 0 &&
typeof item.title === "string" &&
item.title.length > 0 &&
(item.href === undefined || isSafeHousekeepingHref(item.href)),
);
}
export function createHotelQuery(
adapters: HotelQueryAdapters,
): HousekeepingQuery<HotelQueryInput, HotelQueryData> {
return {
id: "hotel.query",
owner: "hotel",
capability: hotelRouteById("hotel.rooms").capability,
async run(context, rawInput) {
const route = hotelRouteById(rawInput.routeId);
const authorization = authorizeHousekeeping(context, route.capability);
if (!authorization.ok) return authorization;
const input = normalizeInput(rawInput);
try {
const data = await adapters.load(input);
if (!isValidData(data, input)) throw new Error("invalid Hotel query");
return ok(data, authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export async function loadHotelRoomsQuery(
input: NormalizedHotelQueryInput,
): Promise<HotelQueryData> {
const { loadHotelDatabaseQuery } = await import("./hotel-production");
return loadHotelDatabaseQuery(input);
}
export const hotelQuery = createHotelQuery({
async load(input) {
if (input.routeId.startsWith("hotel.radio.")) {
const { loadHotelRadioQuery } = await import("./radio");
return loadHotelRadioQuery(input);
}
return loadHotelRoomsQuery(input);
},
});
@@ -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,
};
},
});