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:
@@ -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;
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}) {}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user