refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@rom-manager/contract",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "The single source of truth shared by the plugin server and the UI: config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "^4.0.0-beta.99",
|
||||
"@punktfunk/plugin-kit": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"effect": "4.0.0-beta.99",
|
||||
"@punktfunk/plugin-kit": "^0.1.1",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/bun": "^1.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
|
||||
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
|
||||
import { Schema } from "effect";
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
HttpApiSchema,
|
||||
} from "effect/unstable/httpapi";
|
||||
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||
import { DetectedEmulator, EngineStatus, Platform, SyncReport } from "./domain.js";
|
||||
import { ConfigInvalid, DetectFailed, ScanFailed, SyncInProgress } from "./errors.js";
|
||||
|
||||
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
|
||||
* The UI resolves it locally by decoding through the SHARED RomConfigSchema. */
|
||||
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
|
||||
export type ConfigPayload = typeof ConfigPayload.Type;
|
||||
|
||||
/** GET /api/preview: the dry-run desired state (cached art only — no warming). */
|
||||
export const PreviewResult = Schema.Struct({
|
||||
entries: Schema.Array(ProviderEntry),
|
||||
report: SyncReport,
|
||||
});
|
||||
export type PreviewResult = typeof PreviewResult.Type;
|
||||
|
||||
export const EmulatorsPayload = Schema.Struct({
|
||||
detected: Schema.Array(DetectedEmulator),
|
||||
detectedAt: Schema.NullOr(Schema.Number),
|
||||
});
|
||||
export type EmulatorsPayload = typeof EmulatorsPayload.Type;
|
||||
|
||||
export const RomManagerApi = HttpApi.make("rom-manager").add(
|
||||
HttpApiGroup.make("status").add(
|
||||
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
|
||||
),
|
||||
).add(
|
||||
HttpApiGroup.make("config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("put", "/api/config", {
|
||||
payload: Schema.Unknown,
|
||||
success: ConfigPayload,
|
||||
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
),
|
||||
).add(
|
||||
HttpApiGroup.make("library").add(
|
||||
HttpApiEndpoint.get("preview", "/api/preview", {
|
||||
success: PreviewResult,
|
||||
error: ScanFailed.pipe(HttpApiSchema.status(500)),
|
||||
}),
|
||||
),
|
||||
).add(
|
||||
HttpApiGroup.make("catalog")
|
||||
.add(
|
||||
HttpApiEndpoint.get("platforms", "/api/platforms", {
|
||||
success: Schema.Array(Platform),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("emulators", "/api/emulators", {
|
||||
success: EmulatorsPayload,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("detect", "/api/detect", {
|
||||
success: EmulatorsPayload,
|
||||
error: DetectFailed.pipe(HttpApiSchema.status(500)),
|
||||
}),
|
||||
),
|
||||
).add(
|
||||
HttpApiGroup.make("sync").add(
|
||||
HttpApiEndpoint.post("run", "/api/sync", {
|
||||
success: SyncReport,
|
||||
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/** The SSE status feed — outside the HttpApi (no event-stream media type in httpapi).
|
||||
* Frames: `event: status`, data = EngineStatus JSON. */
|
||||
export const EVENTS_PATH = "/api/events";
|
||||
export const EVENTS_EVENT = "status";
|
||||
@@ -0,0 +1,105 @@
|
||||
// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
|
||||
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
|
||||
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
|
||||
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
|
||||
//
|
||||
// Dropped vs 0.2.x: `ui.*` (the standalone password server is gone — console surface +
|
||||
// CLI only) and `devEntry` (dev flag out of the prod shape; use `preview` instead).
|
||||
// Stale keys in existing files are tolerated on decode and dropped by the next save.
|
||||
import { Effect, Schema } from "effect";
|
||||
import { EmulatorDef, Platform } from "./domain.js";
|
||||
|
||||
const withDefault = <S extends Schema.Top>(
|
||||
schema: S,
|
||||
value: S["Encoded"],
|
||||
) =>
|
||||
schema.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(value), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
);
|
||||
|
||||
/** A ROM root: a directory paired with the platform its files belong to (design §5). */
|
||||
export const RomRoot = Schema.Struct({
|
||||
dir: Schema.String,
|
||||
platform: Schema.String,
|
||||
excludes: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
});
|
||||
export type RomRoot = typeof RomRoot.Type;
|
||||
|
||||
/** A per-platform launch override — replaces the platform's built-in default. */
|
||||
export const PlatformLaunch = Schema.Struct({
|
||||
emulator: Schema.String,
|
||||
core: Schema.optionalKey(Schema.String),
|
||||
extraArgs: Schema.optionalKey(Schema.String),
|
||||
});
|
||||
export type PlatformLaunch = typeof PlatformLaunch.Type;
|
||||
|
||||
/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */
|
||||
export const GameOverride = Schema.Struct({
|
||||
exclude: Schema.optionalKey(Schema.Boolean),
|
||||
emulator: Schema.optionalKey(Schema.String),
|
||||
core: Schema.optionalKey(Schema.String),
|
||||
extraArgs: Schema.optionalKey(Schema.String),
|
||||
title: Schema.optionalKey(Schema.String),
|
||||
art: Schema.optionalKey(Schema.String),
|
||||
});
|
||||
export type GameOverride = typeof GameOverride.Type;
|
||||
|
||||
export const ArtProviderChoice = Schema.Literals([
|
||||
"auto",
|
||||
"steamgriddb",
|
||||
"libretro",
|
||||
]);
|
||||
export type ArtProviderChoice = typeof ArtProviderChoice.Type;
|
||||
|
||||
export const SyncOptions = Schema.Struct({
|
||||
/** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is primary). */
|
||||
pollMinutes: withDefault(Schema.Number, 15),
|
||||
watch: withDefault(Schema.Boolean, true),
|
||||
debounceMs: withDefault(Schema.Number, 2000),
|
||||
/** Platform ids to region-dedupe ("one entry per title", design §5). */
|
||||
dedupeRegions: withDefault(Schema.Array(Schema.String), []),
|
||||
regionPriority: withDefault(Schema.Array(Schema.String), [
|
||||
"USA",
|
||||
"World",
|
||||
"Europe",
|
||||
"Japan",
|
||||
]),
|
||||
warnEntries: withDefault(Schema.Number, 2000),
|
||||
maxEntries: withDefault(Schema.Number, 5000),
|
||||
closeOnEnd: withDefault(Schema.Boolean, false),
|
||||
});
|
||||
export type SyncOptions = typeof SyncOptions.Type;
|
||||
|
||||
export const ArtOptions = Schema.Struct({
|
||||
enabled: withDefault(Schema.Boolean, true),
|
||||
provider: withDefault(ArtProviderChoice, "auto"),
|
||||
steamGridDbKey: Schema.optionalKey(Schema.String),
|
||||
});
|
||||
export type ArtOptions = typeof ArtOptions.Type;
|
||||
|
||||
export const RomConfigSchema = Schema.Struct({
|
||||
roots: withDefault(Schema.Array(RomRoot), []),
|
||||
platformLaunch: withDefault(Schema.Record(Schema.String, PlatformLaunch), {}),
|
||||
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
|
||||
/** Operator-added / overriding platform defs (merged over built-ins by id). */
|
||||
platforms: Schema.optionalKey(Schema.Array(Platform)),
|
||||
/** Operator-added / overriding emulator defs (merged over built-ins by id). */
|
||||
emulators: Schema.optionalKey(Schema.Array(EmulatorDef)),
|
||||
sync: withDefault(SyncOptions, {}),
|
||||
art: withDefault(ArtOptions, {}),
|
||||
});
|
||||
|
||||
/** The fully-resolved config the engine consumes. */
|
||||
export type Config = typeof RomConfigSchema.Type;
|
||||
/** The on-disk config as authored (all optional). */
|
||||
export type RawConfig = typeof RomConfigSchema.Encoded;
|
||||
|
||||
/**
|
||||
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
|
||||
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
|
||||
* ConfigService (Effect + typed errors) instead.
|
||||
*/
|
||||
export const resolveConfig = (raw: unknown): Config =>
|
||||
Schema.decodeUnknownSync(RomConfigSchema)(raw);
|
||||
@@ -0,0 +1,108 @@
|
||||
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
|
||||
// the derived types keep the original domain names so the pure core reads unchanged.
|
||||
import { Schema } from "effect";
|
||||
|
||||
export const Os = Schema.Literals(["linux", "windows", "mac"]);
|
||||
export type Os = typeof Os.Type;
|
||||
|
||||
/** Default emulator + core for a platform (operator-overridable per platform / per game). */
|
||||
export const DefaultLaunch = Schema.Struct({
|
||||
emulator: Schema.String,
|
||||
core: Schema.optionalKey(Schema.String),
|
||||
extraArgs: Schema.optionalKey(Schema.String),
|
||||
});
|
||||
export type DefaultLaunch = typeof DefaultLaunch.Type;
|
||||
|
||||
/** A console platform: extension set, art key, default launch (design §5). */
|
||||
export const Platform = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
extensions: Schema.Array(Schema.String),
|
||||
libretroSystem: Schema.optionalKey(Schema.String),
|
||||
defaultLaunch: DefaultLaunch,
|
||||
disc: Schema.optionalKey(Schema.Boolean),
|
||||
});
|
||||
export type Platform = typeof Platform.Type;
|
||||
|
||||
/** An emulator definition: per-OS detection, launch template, archive support. */
|
||||
export const EmulatorDef = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
detect: Schema.Struct({
|
||||
linux: Schema.Array(Schema.String),
|
||||
windows: Schema.Array(Schema.String),
|
||||
}),
|
||||
coresDir: Schema.optionalKey(
|
||||
Schema.Struct({
|
||||
linux: Schema.Array(Schema.String),
|
||||
windows: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
template: Schema.String,
|
||||
supportsArchives: Schema.Boolean,
|
||||
contested: Schema.optionalKey(Schema.Boolean),
|
||||
});
|
||||
export type EmulatorDef = typeof EmulatorDef.Type;
|
||||
|
||||
/** A detected emulator install (UI's Emulators page + launch resolution). */
|
||||
export const DetectedEmulator = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
template: Schema.String,
|
||||
supportsArchives: Schema.Boolean,
|
||||
contested: Schema.optionalKey(Schema.Boolean),
|
||||
exeToken: Schema.String,
|
||||
exePath: Schema.optionalKey(Schema.String),
|
||||
appId: Schema.optionalKey(Schema.String),
|
||||
via: Schema.Literals(["path", "file", "flatpak"]),
|
||||
coresDir: Schema.optionalKey(Schema.String),
|
||||
cores: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
});
|
||||
export type DetectedEmulator = typeof DetectedEmulator.Type;
|
||||
|
||||
/** A candidate the compute rejected, with a human reason (Library page, CLI). */
|
||||
export const Skipped = Schema.Struct({
|
||||
external_id: Schema.String,
|
||||
title: Schema.String,
|
||||
reason: Schema.String,
|
||||
});
|
||||
export type Skipped = typeof Skipped.Type;
|
||||
|
||||
/** A summary of one desired-state compute (Overview + Library pages). */
|
||||
export const SyncReport = Schema.Struct({
|
||||
considered: Schema.Number,
|
||||
included: Schema.Number,
|
||||
skipped: Schema.Array(Skipped),
|
||||
excluded: Schema.Array(
|
||||
Schema.Struct({ external_id: Schema.String, title: Schema.String }),
|
||||
),
|
||||
warnings: Schema.Array(Schema.String),
|
||||
truncated: Schema.Number,
|
||||
perPlatform: Schema.Record(Schema.String, Schema.Number),
|
||||
overWarn: Schema.Boolean,
|
||||
});
|
||||
export type SyncReport = typeof SyncReport.Type;
|
||||
|
||||
export const LastSync = Schema.Struct({
|
||||
fingerprint: Schema.String,
|
||||
count: Schema.Number,
|
||||
at: Schema.Number,
|
||||
});
|
||||
export type LastSync = typeof LastSync.Type;
|
||||
|
||||
/** The engine status the Overview page renders and the SSE feed streams. */
|
||||
export const EngineStatus = Schema.Struct({
|
||||
rootsConfigured: Schema.Number,
|
||||
os: Os,
|
||||
artProvider: Schema.NullOr(Schema.String),
|
||||
syncing: Schema.Boolean,
|
||||
lastSync: Schema.optionalKey(LastSync),
|
||||
lastReport: Schema.optionalKey(SyncReport),
|
||||
detectedAt: Schema.optionalKey(Schema.Number),
|
||||
paths: Schema.Struct({
|
||||
dir: Schema.String,
|
||||
config: Schema.String,
|
||||
cache: Schema.String,
|
||||
}),
|
||||
});
|
||||
export type EngineStatus = typeof EngineStatus.Type;
|
||||
@@ -0,0 +1,27 @@
|
||||
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
|
||||
// HttpApiSchema.status in api.ts.
|
||||
import { Schema } from "effect";
|
||||
|
||||
/** PUT /api/config body failed schema validation. → 400 */
|
||||
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
|
||||
"ConfigInvalid",
|
||||
{ issues: Schema.String },
|
||||
) {}
|
||||
|
||||
/** A sync pass is already running (the trigger was coalesced). → 409 */
|
||||
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
|
||||
"SyncInProgress",
|
||||
{},
|
||||
) {}
|
||||
|
||||
/** Scan/compute failed. → 500 */
|
||||
export class ScanFailed extends Schema.TaggedErrorClass<ScanFailed>()(
|
||||
"ScanFailed",
|
||||
{ message: Schema.String },
|
||||
) {}
|
||||
|
||||
/** Emulator detection failed. → 500 */
|
||||
export class DetectFailed extends Schema.TaggedErrorClass<DetectFailed>()(
|
||||
"DetectFailed",
|
||||
{ message: Schema.String },
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
// @rom-manager/contract — the shared source of truth (config schema, domain DTOs,
|
||||
// HttpApi contract, API errors). Bundled into the plugin, aliased into the UI.
|
||||
export * from "./api.js";
|
||||
export * from "./config.js";
|
||||
export * from "./domain.js";
|
||||
export * from "./errors.js";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user