feat: Effect services + HttpApi implementation + kit entry/CLI/dev server

- RomConfig/RomCache on the kit's ConfigService/CacheStore; RomSync wires the
  pure domain into the kit SyncEngine (poll/watch/coalesce/fingerprint) and
  ProviderClient; EngineStatus assembly shared by REST + SSE
- makeApi: HttpApiBuilder groups over live service values (handler runtime
  shares the plugin runtime's singletons) + sseRoute status feed
- index.ts: definePluginKit entry (async-main boundary); headless-first (UI
  serve failure logged, engine continues)
- cli.ts on the kit dispatcher: scan/detect/preview offline, sync/uninstall
  online; set-password is gone with the standalone server
- dev.ts: auth-free loopback API server (:5885) — the dev:live proxy target;
  never bundled
- verified live host-less: raw config round-trip on disk, status/platforms/
  preview endpoints, soft-fail reconcile without a host
- CI: workspace install, per-package typecheck, publish from plugin/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:04:25 +02:00
parent d6cf5f3d36
commit 899028e20f
43 changed files with 811 additions and 1558 deletions
+2 -2
View File
@@ -3,9 +3,9 @@
// files are named after the No-Intro name with a documented character-substitution set; we walk a
// fallback ladder (exact → region-swap → bare title) and verify each candidate with one probe.
import type { ParsedTitle } from "../domain/titles.js";
import type { Platform } from "../domain/platforms.js";
import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Platform } from "../domain/platforms.js";
import type { ParsedTitle } from "../domain/titles.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://thumbnails.libretro.com";
+2 -2
View File
@@ -8,10 +8,10 @@
// the rest of the run instead of hammering the API.
import { createHash } from "node:crypto";
import type { ParsedTitle } from "../domain/titles.js";
import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Platform } from "../domain/platforms.js";
import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { ParsedTitle } from "../domain/titles.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://www.steamgriddb.com/api/v2";
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every
// command sees the exact services the runner runs. scan/detect/preview work offline;
// sync/uninstall talk to the host.
import {
type CliCommand,
ProviderClient,
runPluginCli,
} from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME } from "./index.js";
import { type RomServices, romLayer } from "./layer.js";
import { PROVIDER_ID, RomSync } from "./services/engine.js";
const print = (line: string) => Effect.sync(() => console.log(line));
const commands: Record<string, CliCommand<RomServices>> = {
scan: {
summary: "Scan configured ROM roots and list candidates",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const { report } = yield* sync.preview;
yield* print(
`${report.considered} candidate(s), ${report.included} would be included`,
);
for (const [platform, n] of Object.entries(report.perPlatform)) {
yield* print(` ${platform}: ${n}`);
}
}),
},
detect: {
summary: "Probe for installed emulators",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const detected = yield* sync.detect(true);
for (const d of detected) {
yield* print(
`${d.id.padEnd(14)} ${d.via.padEnd(8)} ${d.exePath ?? d.exeToken}${d.cores ? ` (${d.cores.length} cores)` : ""}`,
);
}
if (detected.length === 0) yield* print("no emulators found");
}),
},
preview: {
summary: "Dry-run: show the entries a sync would reconcile",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const { entries, report } = yield* sync.preview;
for (const e of entries) yield* print(`${e.external_id} ${e.title}`);
for (const s of report.skipped) {
yield* print(`SKIP ${s.external_id}: ${s.reason}`);
}
yield* print(
`${report.included} included, ${report.skipped.length} skipped`,
);
}),
},
sync: {
summary: "Reconcile the library provider now",
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const outcome = yield* sync.engine.sync("manual");
yield* print(
outcome._tag === "AlreadyRunning"
? "a sync is already running"
: `${outcome._tag}: ${outcome.report.included} entries`,
);
}),
},
uninstall: {
summary: "Remove every rom-manager entry from the host library",
run: () =>
Effect.gen(function* () {
const provider = yield* ProviderClient;
yield* provider.remove(PROVIDER_ID);
yield* print("provider entries removed");
}),
},
};
await runPluginCli({
def: {
name: PLUGIN_NAME,
version: pkg.version,
layer: romLayer,
main: Effect.void,
},
commands,
});
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bun
// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5885), no auth,
// no static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a
// running host when one is up (full sync path); otherwise runs host-less (scan, preview,
// detect, and config all work; reconcile fails softly in the engine's safeSync).
// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js).
import { connect, type Punktfunk } from "@punktfunk/host";
import {
hostClientFromFacade,
loggingLayer,
pluginInfoLayer,
} from "@punktfunk/plugin-kit";
import { Effect, Layer, ManagedRuntime } from "effect";
import { HttpRouter } from "effect/unstable/http";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME } from "./index.js";
import { romLayer } from "./layer.js";
import { makeApi } from "./services/api.js";
import { RomCache } from "./services/cache.js";
import { RomConfig } from "./services/config.js";
import { RomSync } from "./services/engine.js";
const PORT = Number(process.env.ROM_MANAGER_DEV_PORT ?? 5885);
const offlineFacade = (): Punktfunk =>
({
request: async (method: string, path: string) => {
throw new Error(`dev: no host running (tried ${method} ${path})`);
},
close: () => {},
}) as unknown as Punktfunk;
const pf = await connect().catch(() => {
console.log("dev: no punktfunk host reachable — running host-less");
return offlineFacade();
});
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }),
loggingLayer(PLUGIN_NAME),
);
const rt = ManagedRuntime.make(Layer.provideMerge(romLayer, base));
// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and
// must outlive this acquisition effect (they close on rt.dispose()).
const { handler, dispose } = await rt.runPromise(
Effect.gen(function* () {
const sync = yield* RomSync;
const config = yield* RomConfig;
const cache = yield* RomCache;
yield* sync.engine.start;
return HttpRouter.toWebHandler(makeApi({ sync, config, cache }));
}),
);
const server = Bun.serve({
hostname: "127.0.0.1",
port: PORT,
idleTimeout: 0, // SSE
fetch: (req) => handler(req),
});
console.log(
`rom-manager dev API on http://127.0.0.1:${server.port}/api/status`,
);
process.once("SIGINT", () => {
void dispose()
.then(() => rt.dispose())
.finally(() => process.exit(0));
});
+1 -1
View File
@@ -5,9 +5,9 @@
// stretch item.
import * as nodePath from "node:path";
import type { PrepStep } from "@punktfunk/plugin-kit/wire";
import type { DetectedEmulator } from "./emulators.js";
import { type Os, quoteFor } from "./quote.js";
import type { PrepStep } from "@punktfunk/plugin-kit/wire";
/** A command that always exits 0, for `prep.do`. */
const noop = (os: Os): string => (os === "windows" ? "cd ." : "true");
+4 -4
View File
@@ -5,14 +5,14 @@
// `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the
// cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules.
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
import type { Config, GameOverride } from "@rom-manager/contract";
import type { ArtVerdict } from "../art/provider.js";
import { killPrep } from "./close.js";
import { type DetectedEmulator, resolveEmulators } from "./emulators.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.js";
import { type Platform, resolvePlatforms } from "./platforms.js";
import type { Os } from "./quote.js";
import type { ArtVerdict } from "../art/provider.js";
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { killPrep } from "./close.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.js";
import type { RomCandidate } from "./scanner.js";
import {
dedupeKey,
+48
View File
@@ -0,0 +1,48 @@
// The plugin entry — the default export the runner discovers. Everything is Effect
// inside definePluginKit's async-main boundary (see @punktfunk/plugin-kit): migrate,
// start the sync engine, serve the console UI, park until interruption.
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { romLayer } from "./layer.js";
import { migrateLegacyState } from "./migrate.js";
import { makeApi } from "./services/api.js";
import { RomCache } from "./services/cache.js";
import { RomConfig } from "./services/config.js";
import { RomSync } from "./services/engine.js";
export const PLUGIN_NAME = "rom-manager";
export const UI_TITLE = "ROM Manager";
export const UI_ICON = "gamepad-2";
const plugin = definePluginKit({
name: PLUGIN_NAME,
version: pkg.version,
layer: romLayer,
main: Effect.gen(function* () {
yield* migrateLegacyState(PLUGIN_NAME);
const sync = yield* RomSync;
const config = yield* RomConfig;
const cache = yield* RomCache;
// Headless sync first — the library reconciles even if the UI can't come up.
yield* sync.engine.start;
yield* serveUi({
title: UI_TITLE,
icon: UI_ICON,
staticDir: new URL("./ui/", import.meta.url),
api: makeApi({ sync, config, cache }),
}).pipe(
Effect.catch((e) =>
Effect.logWarning(
`ui: could not serve the console surface (${e.cause}) — engine continues headless`,
),
),
);
yield* Effect.never;
}),
});
export default plugin;
+21
View File
@@ -0,0 +1,21 @@
// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the
// runner entry (index.ts), the CLI, and the dev server so they all run the same engine.
import type { HostClient } from "@punktfunk/plugin-kit";
import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit";
import { Layer } from "effect";
import { RomCache } from "./services/cache.js";
import { RomConfig } from "./services/config.js";
import { RomSync } from "./services/engine.js";
export type RomServices = RomConfig | RomCache | RomSync | ProviderClient;
export const romLayer: Layer.Layer<
RomServices,
never,
HostClient | PluginInfo
> = RomSync.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(RomConfig.layer, RomCache.layer, ProviderClient.layer),
),
);
+26
View File
@@ -0,0 +1,26 @@
// One-shot legacy-state migration (pre-0.2.1 → plugin-state), OUT of the hot load path.
// Copy (not move): the old dir may be unwritable, and leaving it is harmless. Guarded —
// only fills files the new dir does not already have — and best-effort.
import * as fs from "node:fs";
import * as path from "node:path";
import { pluginStateDir } from "@punktfunk/plugin-kit";
import { Effect } from "effect";
export const migrateLegacyState = (name: string): Effect.Effect<void> =>
Effect.sync(() => {
const dir = pluginStateDir(name);
// <config_dir>/plugin-state/<name> → <config_dir>/<name> was the pre-0.2.1 home.
const legacy = path.join(path.dirname(path.dirname(dir)), name);
try {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
for (const file of ["config.json", "cache.json"]) {
const dst = path.join(dir, file);
const src = path.join(legacy, file);
if (!fs.existsSync(dst) && fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
} catch {
// best-effort — a failed copy just means the plugin starts from defaults
}
});
+129
View File
@@ -0,0 +1,129 @@
// The HttpApi implementation + SSE route, composed as the Layer `serveUi` mounts.
// Built from live service VALUES (not layers) so the handler runtime shares the plugin
// runtime's singletons (one engine, one config service).
import {
type CacheStore,
type ConfigService,
httpApiEnv,
sseRoute,
} from "@punktfunk/plugin-kit";
import {
ConfigInvalid,
DetectFailed,
type EmulatorsPayload,
EVENTS_EVENT,
EVENTS_PATH,
type Platform,
type RomConfigSchema,
RomManagerApi,
resolveConfig,
ScanFailed,
SyncInProgress,
} from "@rom-manager/contract";
import { Effect, Layer } from "effect";
import type { HttpRouter } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { resolvePlatforms } from "../domain/platforms.js";
import type { CacheSchema } from "./cache.js";
import type { RomSyncService } from "./engine.js";
export interface ApiDeps {
readonly sync: RomSyncService;
readonly config: ConfigService<typeof RomConfigSchema>;
readonly cache: CacheStore<typeof CacheSchema>;
}
const emulatorsPayload = (
deps: ApiDeps,
force: boolean,
): Effect.Effect<EmulatorsPayload> =>
Effect.gen(function* () {
const detected = yield* deps.sync.detect(force);
const at = (yield* deps.cache.get).detect?.at ?? null;
return { detected, detectedAt: at };
});
export const makeApi = (
deps: ApiDeps,
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
const statusGroup = HttpApiBuilder.group(RomManagerApi, "status", (h) =>
h.handle("get", () => deps.sync.status),
);
const configGroup = HttpApiBuilder.group(RomManagerApi, "config", (h) =>
h
.handle("get", () =>
deps.config.loadRaw.pipe(
Effect.map((raw) => ({ raw: raw as unknown })),
Effect.orDie,
),
)
.handle("put", ({ payload }) =>
deps.config.saveRaw(payload).pipe(
Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })),
// Loops restart + resync in the background; the PUT answers immediately.
Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)),
Effect.map(() => ({ raw: payload as unknown })),
),
),
);
const libraryGroup = HttpApiBuilder.group(RomManagerApi, "library", (h) =>
h.handle("preview", () =>
deps.sync.preview.pipe(
Effect.mapError((e) => new ScanFailed({ message: String(e) })),
),
),
);
const catalogGroup = HttpApiBuilder.group(RomManagerApi, "catalog", (h) =>
h
.handle("platforms", () =>
deps.config.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
Effect.map(
(config) =>
[
...resolvePlatforms(config.platforms).values(),
] as Array<Platform>,
),
),
)
.handle("emulators", () => emulatorsPayload(deps, false))
.handle("detect", () =>
emulatorsPayload(deps, true).pipe(
Effect.mapError((e) => new DetectFailed({ message: String(e) })),
),
),
);
const syncGroup = HttpApiBuilder.group(RomManagerApi, "sync", (h) =>
h.handle("run", () =>
deps.sync.engine.sync("manual").pipe(
Effect.orDie, // a SyncError is a 500; the 409 stays typed below
Effect.flatMap((outcome) =>
outcome._tag === "AlreadyRunning"
? Effect.fail(new SyncInProgress())
: Effect.succeed(outcome.report),
),
),
),
);
return Layer.mergeAll(
HttpApiBuilder.layer(RomManagerApi).pipe(
Layer.provide(
Layer.mergeAll(
statusGroup,
configGroup,
libraryGroup,
catalogGroup,
syncGroup,
),
),
Layer.provide(httpApiEnv),
),
// EngineStatus is an identity codec (plain JSON shape) — default JSON encode.
sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }),
);
};
+46
View File
@@ -0,0 +1,46 @@
// The plugin's disposable derived state (cache.json), on the kit's CacheStore: art
// verdicts keyed by external_id, the last detection result, and the last-sync
// fingerprint. Corrupt/absent → empty; every mutation is write-through.
import {
type CacheStore,
makeCacheStore,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { Artwork } from "@punktfunk/plugin-kit/wire";
import { DetectedEmulator, LastSync, Os } from "@rom-manager/contract";
import { Context, Effect, Layer, Schema } from "effect";
export const ArtVerdictSchema = Schema.Struct({
art: Schema.NullOr(Artwork),
provider: Schema.String,
matcher: Schema.Number,
});
export const CacheSchema = Schema.Struct({
art: Schema.Record(Schema.String, ArtVerdictSchema).pipe(
Schema.withDecodingDefaultKey(Effect.succeed({}), {
encodingStrategy: "passthrough",
}),
),
detect: Schema.optionalKey(
Schema.Struct({
at: Schema.Number,
os: Os,
emulators: Schema.Array(DetectedEmulator),
}),
),
lastSync: Schema.optionalKey(LastSync),
});
export type Cache = typeof CacheSchema.Type;
export const emptyCache: Cache = { art: {} };
export class RomCache extends Context.Service<
RomCache,
CacheStore<typeof CacheSchema>
>()("rom-manager/RomCache") {
static readonly layer: Layer.Layer<RomCache, never, PluginInfo> =
Layer.effect(RomCache)(
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
);
}
+17
View File
@@ -0,0 +1,17 @@
// The plugin's config service — the kit's ConfigService over the contract schema.
// Raw round-trip by construction: the file on disk is always the authored shape.
import {
type ConfigService,
makeConfigService,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { RomConfigSchema } from "@rom-manager/contract";
import { Context, Layer } from "effect";
export class RomConfig extends Context.Service<
RomConfig,
ConfigService<typeof RomConfigSchema>
>()("rom-manager/RomConfig") {
static readonly layer: Layer.Layer<RomConfig, never, PluginInfo> =
Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema }));
}
+234
View File
@@ -0,0 +1,234 @@
// RomSync — the plugin's engine service: wires the pure domain (scan → detect → warm →
// computeDesired) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/
// fingerprint) and the ProviderClient reconcile. Also owns the EngineStatus assembly the
// API and the SSE feed share, and the side-effect-light preview the Library page uses.
import * as nodePath from "node:path";
import {
type LastSync,
makeSyncEngine,
ProviderClient,
type SyncEngine,
} from "@punktfunk/plugin-kit";
import type { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import {
type Config,
type DetectedEmulator,
type EngineStatus,
resolveConfig,
type SyncReport,
} from "@rom-manager/contract";
import { Context, Duration, Effect, Layer, type Scope, Stream } from "effect";
import {
type ArtVerdict,
selectArtProvider,
warmArt,
} from "../art/provider.js";
import {
detectAll,
realDetectEnv,
resolveEmulators,
} from "../domain/emulators.js";
import { resolvePlatforms } from "../domain/platforms.js";
import { currentOs } from "../domain/quote.js";
import { computeDesired, enumerateTitles } from "../domain/reconcile.js";
import { type RomCandidate, realScanFs, scanRoot } from "../domain/scanner.js";
import { RomCache } from "./cache.js";
import { RomConfig } from "./config.js";
export const PROVIDER_ID = "rom-manager";
export interface RomSyncService {
readonly engine: SyncEngine<SyncReport>;
/** Scan + cached detect + cached art → desired entries. NO warming, NO reconcile. */
readonly preview: Effect.Effect<
{ entries: ReadonlyArray<ProviderEntry>; report: SyncReport },
unknown
>;
/** Emulator detection (cached; `force` re-probes — the UI's Detect button). */
readonly detect: (
force: boolean,
) => Effect.Effect<ReadonlyArray<DetectedEmulator>>;
readonly status: Effect.Effect<EngineStatus>;
/** The SSE feed: a full EngineStatus on every engine transition. */
readonly statusChanges: Stream.Stream<EngineStatus>;
}
export class RomSync extends Context.Service<RomSync, RomSyncService>()(
"rom-manager/RomSync",
) {
// Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the
// engine's watch/poll loops live exactly as long as the plugin runtime.
static readonly layer: Layer.Layer<
RomSync,
never,
RomConfig | RomCache | ProviderClient
> = Layer.effect(RomSync)(make());
}
function make(): Effect.Effect<
RomSyncService,
never,
RomConfig | RomCache | ProviderClient | Scope.Scope
> {
return Effect.gen(function* () {
const cfg = yield* RomConfig;
const cache = yield* RomCache;
const provider = yield* ProviderClient;
const os = currentOs();
const detectEnv = realDetectEnv();
// Pure helpers collect log lines; callers flush them through Effect.log so they
// go through the runtime's journal-format logger (never console directly).
const flush = (lines: ReadonlyArray<string>) =>
Effect.forEach(lines, (l) => Effect.log(l), { discard: true });
const scanAll = (config: Config, lines: string[]): RomCandidate[] => {
const platforms = resolvePlatforms(config.platforms);
const out: RomCandidate[] = [];
for (const root of config.roots) {
const platform = platforms.get(root.platform);
if (!platform) {
lines.push(
`scan: root ${root.dir} has unknown platform "${root.platform}" — skipped`,
);
continue;
}
try {
out.push(
...scanRoot(realScanFs, root.dir, platform, [
...(root.excludes ?? []),
]),
);
} catch (e) {
lines.push(`scan: ${root.dir}: ${e}`);
}
}
return out;
};
const loadOrDefault = cfg.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
);
const detect = (force: boolean) =>
Effect.gen(function* () {
const config = yield* loadOrDefault;
const cached = (yield* cache.get).detect;
if (!force && cached && cached.os === os) {
return cached.emulators;
}
const defs = resolveEmulators(config.emulators);
const emulators = detectAll(detectEnv, defs.values());
yield* cache
.update((c) => ({
...c,
detect: { at: Date.now(), os, emulators },
}))
.pipe(Effect.ignore);
return emulators as ReadonlyArray<DetectedEmulator>;
});
/** Warm the art cache for everything the current scan will include (best-effort). */
const warm = (config: Config, scan: RomCandidate[]) =>
Effect.gen(function* () {
const lines: string[] = [];
const artProvider = selectArtProvider(config, (l) => lines.push(l));
if (artProvider) {
const { survivors } = enumerateTitles(config, scan);
const targets = survivors.map((s) => ({
externalId: s.externalId,
platform: s.platform,
parsed: s.parsed,
}));
// warmArt mutates a working copy; the store persists it atomically.
const working: { art: Record<string, ArtVerdict> } = {
art: { ...(yield* cache.get).art },
};
const n = yield* Effect.promise(() =>
warmArt(artProvider, targets, working, {
log: (l) => lines.push(l),
}),
);
if (n > 0) {
yield* cache
.update((c) => ({ ...c, art: working.art }))
.pipe(Effect.ignore);
lines.push(
`art: resolved ${n} title(s) via ${artProvider.id.split(":")[0]}`,
);
}
}
yield* flush(lines);
});
const computeWith = (withWarm: boolean) =>
Effect.gen(function* () {
const config = yield* cfg.load;
const lines: string[] = [];
const scan = scanAll(config, lines);
yield* flush(lines);
const detected = yield* detect(false);
if (withWarm) yield* warm(config, scan);
const art = (yield* cache.get).art;
const result = computeDesired({
config,
scan,
detected: [...detected],
art,
os,
});
return { entries: result.entries, report: result.report };
});
const engine = yield* makeSyncEngine<
SyncReport,
ReadonlyArray<ProviderEntry>,
never
>({
compute: () => computeWith(true),
apply: (entries) => provider.reconcile(PROVIDER_ID, entries),
lastSync: {
get: cache.get.pipe(Effect.map((c) => c.lastSync)),
set: (last: LastSync) =>
cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore),
},
settings: loadOrDefault.pipe(
Effect.map((config) => ({
pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)),
watch: config.sync.watch,
debounce: Duration.millis(config.sync.debounceMs),
watchDirs: config.roots.map((r) => r.dir),
})),
),
});
const status: Effect.Effect<EngineStatus> = Effect.gen(function* () {
const config = yield* loadOrDefault;
const engineStatus = yield* engine.status;
const c = yield* cache.get;
return {
rootsConfigured: config.roots.length,
os,
artProvider: selectArtProvider(config)?.id ?? null,
syncing: engineStatus.syncing,
lastSync: engineStatus.lastSync,
lastReport: engineStatus.lastReport,
detectedAt: c.detect?.at,
paths: {
dir: nodePath.dirname(cfg.path),
config: cfg.path,
cache: cache.path,
},
} satisfies EngineStatus;
});
return {
engine,
preview: computeWith(false),
detect,
status,
statusChanges: engine.changes.pipe(Stream.mapEffect(() => status)),
} satisfies RomSyncService;
});
}