feat(ui): Effect-native data layer + zero-host mock transport

AtomHttpApi.Service derives the typed client straight from the shared
RomManagerApi contract — no hand-mirrored types, no react-query. Queries
carry reactivity keys; mutations pass them per-call (constants exported
beside each mutation). liveStatusAtom merges the kit sseAtom feed over
GET /api/status; statusEventsAtom folds frames into the activity feed.
useConfigDraft keeps a raw-config working copy (defaults never baked in)
with dirty tracking and toast-reporting save.

Mock mode is a full in-memory RomManagerApi as an HttpClient layer plus
a scenario ticker (healthy/firstRun/syncing/errors), registered through
a fixture-free seam so production chunks stay clean — mocks/http.ts is
only imported behind import.meta.env.DEV and by Storybook. Scenario and
mode atoms are keepAlive: the transport reads them outside the reactive
graph and registry-seeded values must survive pages that never subscribe
them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:41:36 +02:00
parent 8856df7e8d
commit bcff17a718
8 changed files with 921 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
// mutation constants below document which keys each mutation must pass at call time
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
import { sseAtom } from "@punktfunk/plugin-kit/react";
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@rom-manager/contract";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { installedMockStatusStream } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
import { Api, prefix } from "./client";
// ── Queries ────────────────────────────────────────────────────────────────────
export const statusAtom = Api.query("status", "get", {
reactivityKeys: ["status"],
});
export const configAtom = Api.query("config", "get", {
reactivityKeys: ["config"],
});
export const previewAtom = Api.query("library", "preview", {
reactivityKeys: ["preview"],
});
export const platformsAtom = Api.query("catalog", "platforms", {
reactivityKeys: ["catalog"],
timeToLive: "5 minutes",
});
export const emulatorsAtom = Api.query("catalog", "emulators", {
reactivityKeys: ["emulators"],
});
// ── Mutations + the keys they invalidate ───────────────────────────────────────
export const saveConfig = Api.mutation("config", "put");
/** A config save changes the desired state everywhere derived from it. */
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
export const runSync = Api.mutation("sync", "run");
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
export const runDetect = Api.mutation("catalog", "detect");
export const RUN_DETECT_KEYS = ["emulators", "status"] as const;
// ── Live status (SSE with polling fallback) ────────────────────────────────────
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
const liveStatusStream = sseAtom({
url: `${prefix}${EVENTS_PATH}`,
event: EVENTS_EVENT,
schema: EngineStatus,
});
/**
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
* mock mode. `undefined` until the first frame arrives.
*/
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
(get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockStatusStream();
return mock === undefined ? undefined : get(mock);
}
return get(liveStatusStream);
},
);
/**
* What pages actually render: the freshest engine status — the latest SSE frame once
* one has arrived, the plain GET /api/status result before that (and as error/skeleton
* source, since the SSE stream never fails).
*/
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
Atom.make((get) => {
const frame = get(statusStreamAtom);
if (frame !== undefined) {
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
frame,
);
}
return get(statusAtom);
});
// ── Status transitions → activity feed ─────────────────────────────────────────
export type StatusEvent = {
readonly id: string;
readonly at: number;
readonly text: string;
readonly level: "info" | "warn" | "error";
};
const EVENT_LIMIT = 50;
/**
* Folds the status frame stream into a bounded activity list for the Overview feed —
* client-side only, no history endpoint: connect, sync start/finish (with the report's
* count), report warnings, detection runs.
*/
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
Atom.make((get) => {
let prev: EngineStatus | undefined;
let items: ReadonlyArray<StatusEvent> = [];
let seq = 0;
get.subscribe(
statusStreamAtom,
(frame) => {
if (frame === undefined) return;
const next: Array<StatusEvent> = [];
const push = (text: string, level: StatusEvent["level"] = "info") =>
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
if (prev === undefined) {
const n = frame.rootsConfigured;
push(`Engine connected — ${n} ROM ${n === 1 ? "source" : "sources"}`);
if (frame.syncing) push("Sync running");
} else {
if (!prev.syncing && frame.syncing) push("Sync started");
if (prev.syncing && !frame.syncing) {
const report = frame.lastReport;
if (report === undefined) {
push("Sync finished");
} else {
push(
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
);
for (const warning of report.warnings) push(warning, "warn");
if (report.overWarn) {
push(
`Library is over the warning threshold (${report.included} entries)`,
"warn",
);
}
}
}
if (
frame.detectedAt !== undefined &&
frame.detectedAt !== prev.detectedAt
) {
push("Emulator detection finished");
}
}
prev = frame;
if (next.length > 0) {
items = [...items, ...next].slice(-EVENT_LIMIT);
get.setSelf(items);
}
},
{ immediate: true },
);
return items;
});
+42
View File
@@ -0,0 +1,42 @@
// The one place the UI talks HTTP: a typed client + atom factory derived from the
// SHARED HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in
// pages — endpoints, payloads, and errors all flow from @rom-manager/contract.
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
import { RomManagerApi } from "@rom-manager/contract";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
} from "effect/unstable/http";
import { AtomHttpApi } from "effect/unstable/reactivity";
import { installedMockHttpLayer } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
/**
* "/plugin-ui/<id>" behind the console proxy, "" in dev — Vite serves at "/", so
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
*/
export const prefix = resolvePluginBase();
export class Api extends AtomHttpApi.Service<Api>()("RomApi", {
api: RomManagerApi,
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
// import); if it is not installed we fall through to fetch — a loud, honest failure.
httpClient: (get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockHttpLayer(get);
if (mock !== undefined) return mock;
}
return FetchHttpClient.layer;
},
// prependUrl("") breaks URL joining — only install the transform when proxied.
...(prefix !== ""
? {
transformClient: HttpClient.mapRequest(
HttpClientRequest.prependUrl(prefix),
),
}
: {}),
}) {}
+101
View File
@@ -0,0 +1,101 @@
// The config draft: a local raw-config working copy seeded from GET /api/config.
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
// decodes the draft through the SHARED RomConfigSchema so controls can display
// effective values (e.g. pollMinutes 15 when the file omits it).
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import {
type Config,
type RawConfig,
resolveConfig,
} from "@rom-manager/contract";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState } from "react";
import { errorText } from "@/lib/errors";
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
export type ConfigDraft = {
/** False until GET /api/config has succeeded once (render a skeleton). */
readonly loaded: boolean;
/** The working copy of the authored (raw) config. `{}` until loaded. */
readonly draft: RawConfig;
/** The draft decoded through RomConfigSchema (defaults filled), if valid. */
readonly resolved: Config | undefined;
readonly dirty: boolean;
readonly saving: boolean;
readonly patch: (patch: Partial<RawConfig>) => void;
readonly save: () => void;
readonly discard: () => void;
};
type DraftState = {
readonly baseline: RawConfig;
readonly draft: RawConfig;
};
export const useConfigDraft = (): ConfigDraft => {
const result = useAtomValue(configAtom);
const saveResult = useAtomValue(saveConfig);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
const [state, setState] = useState<DraftState | null>(null);
// Seed once from the first successful load; later refetches (our own saves) must
// not clobber in-progress edits.
useEffect(() => {
if (state === null && AsyncResult.isSuccess(result)) {
const raw = result.value.raw as RawConfig;
setState({ baseline: raw, draft: raw });
}
}, [result, state]);
const draft = state?.draft ?? {};
const dirty =
state !== null &&
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
const resolved = useMemo(() => {
try {
return resolveConfig(draft);
} catch {
return undefined;
}
}, [draft]);
const patch = useCallback((p: Partial<RawConfig>) => {
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
}, []);
const discard = useCallback(() => {
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
}, []);
const save = useCallback(() => {
if (state === null) return;
const raw = state.draft;
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
(exit) => {
if (Exit.isSuccess(exit)) {
setState({ baseline: raw, draft: raw });
toast.success("Configuration saved");
} else {
toast.error("Save failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
},
);
}, [state, write]);
return {
loaded: state !== null,
draft,
resolved,
dirty,
saving: AsyncResult.isWaiting(saveResult),
patch,
save,
discard,
};
};