8d72e1d27e
ci / rust (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
The ~80% of every plugin that was copy-pasted (rom-manager ↔ playnite), extracted as one Effect-v4 package: - runtime: definePluginKit — async-main boundary hiding a ManagedRuntime (two-effect-instances discipline), signal-driven interruption with a bounded shutdown grace - config: Schema-driven raw round-trip (defaults only in the Schema via withDecodingDefaultKey + encodingStrategy omit; file stays authored-shape), atomic writes, world-writable refusal, changes stream - cache-store: disposable derived state, corrupt→empty, write-through - reconcile: kit-owned provider wire schemas + typed client over the skew-safe untyped pf.request seam - sync-engine: generic poll/watch/debounce/single-flight-coalesce/ fingerprint-skip engine with a status PubSub (the SSE feed) - ui-server + sse: effect/unstable/httpapi behind servePluginUi with core-only env layers (validated by the phase-0 spikes); raw SSE route (httpapi has no event-stream media type) - cli: minimal command dispatcher reusing the plugin layer graph (deliberately not effect/unstable/cli — it needs platform packages) - react subpath: plugin router (path→hash→fallback deep-link restore + pf-ui:navigate bridge), ResultGate, sseAtom, resolvePluginBase - theme.css: the console's violet identity packaged for plugin UIs 18 bun tests incl. the two phase-0 spikes; publish workflow mirrors sdk-publish (tag plugin-kit-v*; 0.1.0 published manually). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
286 lines
8.6 KiB
TypeScript
286 lines
8.6 KiB
TypeScript
// The generic sync engine — the poll/watch/debounce/coalesce/fingerprint machinery that
|
|
// was ~duplicated between rom-manager and playnite, as one Effect service.
|
|
//
|
|
// Semantics are a faithful port of the original Engine guard:
|
|
// - single-flight: a sync while one runs records a pending trigger and returns
|
|
// AlreadyRunning; the running pass re-fires once ("coalesced") when it finishes
|
|
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged
|
|
// - interval poll + best-effort fs watchers (recursive where the OS supports it, top-dir
|
|
// fallback on Linux) with debounce; the poll is the real safety net on SMB/NFS
|
|
// - every transition publishes a SyncStatus (the UI's SSE feed)
|
|
// All loops live in a private scope that `reconfigure` closes and rebuilds, and the whole
|
|
// engine tears down with the Scope it was constructed in.
|
|
import { createHash } from "node:crypto";
|
|
import * as fs from "node:fs";
|
|
import {
|
|
Cause,
|
|
type Duration,
|
|
Effect,
|
|
Exit,
|
|
PubSub,
|
|
Queue,
|
|
Ref,
|
|
Scope,
|
|
Stream,
|
|
} from "effect";
|
|
import { SyncError } from "./errors.js";
|
|
|
|
export type SyncReason =
|
|
| "startup"
|
|
| "poll"
|
|
| "fs-change"
|
|
| "config-change"
|
|
| "manual"
|
|
| "coalesced";
|
|
|
|
export interface LastSync {
|
|
readonly fingerprint: string;
|
|
readonly count: number;
|
|
readonly at: number;
|
|
}
|
|
|
|
export type SyncOutcome<Report> =
|
|
| { readonly _tag: "Applied"; readonly report: Report; readonly count: number }
|
|
| { readonly _tag: "Unchanged"; readonly report: Report }
|
|
| { readonly _tag: "AlreadyRunning" };
|
|
|
|
export interface SyncStatus<Report> {
|
|
readonly syncing: boolean;
|
|
readonly lastReport?: Report;
|
|
readonly lastSync?: LastSync;
|
|
}
|
|
|
|
export interface SyncSettings {
|
|
readonly pollInterval: Duration.Duration;
|
|
readonly watch: boolean;
|
|
readonly debounce: Duration.Duration;
|
|
readonly watchDirs: ReadonlyArray<string>;
|
|
}
|
|
|
|
export interface SyncEngineOptions<
|
|
Report,
|
|
Entries extends ReadonlyArray<unknown>,
|
|
R,
|
|
> {
|
|
/** Produce the full desired state. Pure of host effects — `apply` does the write. */
|
|
readonly compute: (
|
|
reason: SyncReason,
|
|
) => Effect.Effect<
|
|
{ readonly entries: Entries; readonly report: Report },
|
|
unknown,
|
|
R
|
|
>;
|
|
/** Push the desired state to the host (usually `ProviderClient.reconcile`). */
|
|
readonly apply: (entries: Entries) => Effect.Effect<void, unknown, R>;
|
|
/** Override the content fingerprint (default: sha256 of the entries JSON). */
|
|
readonly fingerprint?: (entries: Entries) => string;
|
|
/** Durable fingerprint storage (usually the plugin's CacheStore). */
|
|
readonly lastSync: {
|
|
readonly get: Effect.Effect<LastSync | undefined, never, R>;
|
|
readonly set: (last: LastSync) => Effect.Effect<void, never, R>;
|
|
};
|
|
/** Re-read on every `reconfigure` — loops restart with fresh settings. */
|
|
readonly settings: Effect.Effect<SyncSettings, never, R>;
|
|
}
|
|
|
|
export interface SyncEngine<Report> {
|
|
readonly sync: (
|
|
reason: SyncReason,
|
|
) => Effect.Effect<SyncOutcome<Report>, SyncError>;
|
|
readonly status: Effect.Effect<SyncStatus<Report>>;
|
|
/** Emits on every sync start/finish — the UI's SSE feed. */
|
|
readonly changes: Stream.Stream<SyncStatus<Report>>;
|
|
/** Initial sync + start poll/watch loops (scoped to the construction Scope). */
|
|
readonly start: Effect.Effect<void>;
|
|
/** Restart loops with fresh settings, then sync("config-change"). */
|
|
readonly reconfigure: Effect.Effect<void>;
|
|
}
|
|
|
|
const defaultFingerprint = (entries: unknown): string =>
|
|
createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
|
|
/** Best-effort watcher set over `dirs`: recursive where supported, top-dir fallback. */
|
|
const openWatchers = (
|
|
dirs: ReadonlyArray<string>,
|
|
onEvent: () => void,
|
|
): fs.FSWatcher[] => {
|
|
const out: fs.FSWatcher[] = [];
|
|
for (const dir of dirs) {
|
|
try {
|
|
out.push(fs.watch(dir, { recursive: true }, onEvent));
|
|
} catch {
|
|
try {
|
|
out.push(fs.watch(dir, onEvent));
|
|
} catch {
|
|
// unwatchable (poll covers it)
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
export const makeSyncEngine = <
|
|
Report,
|
|
Entries extends ReadonlyArray<unknown>,
|
|
R,
|
|
>(
|
|
opts: SyncEngineOptions<Report, Entries, R>,
|
|
): Effect.Effect<SyncEngine<Report>, never, R | Scope.Scope> =>
|
|
Effect.gen(function* () {
|
|
const ctx = yield* Effect.context<R>();
|
|
const run = <A, E>(eff: Effect.Effect<A, E, R>): Effect.Effect<A, E> =>
|
|
Effect.provide(eff, ctx);
|
|
const fingerprint = opts.fingerprint ?? defaultFingerprint;
|
|
|
|
const flags = yield* Ref.make({ syncing: false, pending: false });
|
|
const lastReport = yield* Ref.make<Report | undefined>(undefined);
|
|
const hub = yield* PubSub.unbounded<SyncStatus<Report>>();
|
|
|
|
const status: Effect.Effect<SyncStatus<Report>> = Effect.gen(function* () {
|
|
const f = yield* Ref.get(flags);
|
|
return {
|
|
syncing: f.syncing,
|
|
lastReport: yield* Ref.get(lastReport),
|
|
lastSync: yield* run(opts.lastSync.get),
|
|
};
|
|
});
|
|
const publish = status.pipe(
|
|
Effect.flatMap((s) => PubSub.publish(hub, s)),
|
|
Effect.asVoid,
|
|
);
|
|
|
|
const doSync = (
|
|
reason: SyncReason,
|
|
): Effect.Effect<SyncOutcome<Report>, SyncError> =>
|
|
Effect.gen(function* () {
|
|
const { entries, report } = yield* run(opts.compute(reason)).pipe(
|
|
Effect.mapError((cause) => new SyncError({ reason, cause })),
|
|
);
|
|
yield* Ref.set(lastReport, report);
|
|
const fp = fingerprint(entries);
|
|
const prev = yield* run(opts.lastSync.get);
|
|
if (prev?.fingerprint === fp) {
|
|
yield* Effect.log(
|
|
`sync (${reason}): no changes (${entries.length} entries)`,
|
|
);
|
|
return { _tag: "Unchanged", report } as const;
|
|
}
|
|
yield* run(opts.apply(entries)).pipe(
|
|
Effect.mapError((cause) => new SyncError({ reason, cause })),
|
|
);
|
|
yield* run(
|
|
opts.lastSync.set({
|
|
fingerprint: fp,
|
|
count: entries.length,
|
|
at: Date.now(),
|
|
}),
|
|
);
|
|
yield* Effect.log(
|
|
`sync (${reason}): reconciled ${entries.length} entries`,
|
|
);
|
|
return { _tag: "Applied", report, count: entries.length } as const;
|
|
});
|
|
|
|
// Errors must never kill a loop — log and carry on (the original's catch).
|
|
const safeSync = (reason: SyncReason): Effect.Effect<void> =>
|
|
sync(reason).pipe(
|
|
Effect.catch((e: SyncError) =>
|
|
Effect.logWarning(`sync (${reason}) failed: ${e.cause}`),
|
|
),
|
|
Effect.asVoid,
|
|
);
|
|
|
|
const sync = (
|
|
reason: SyncReason,
|
|
): Effect.Effect<SyncOutcome<Report>, SyncError> =>
|
|
Ref.modify(flags, (f) =>
|
|
f.syncing
|
|
? ([false, { ...f, pending: true }] as const)
|
|
: ([true, { ...f, syncing: true }] as const),
|
|
).pipe(
|
|
Effect.flatMap((acquired) => {
|
|
if (!acquired) {
|
|
return Effect.succeed({ _tag: "AlreadyRunning" } as const);
|
|
}
|
|
return publish.pipe(
|
|
Effect.andThen(doSync(reason)),
|
|
Effect.ensuring(
|
|
Effect.gen(function* () {
|
|
const pending = yield* Ref.modify(
|
|
flags,
|
|
(f) =>
|
|
[f.pending, { syncing: false, pending: false }] as const,
|
|
);
|
|
yield* publish;
|
|
if (pending) {
|
|
yield* Effect.forkDetach(safeSync("coalesced"));
|
|
}
|
|
}),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
|
|
// ---------------------------------------------------------------- loops
|
|
const loopScope = yield* Ref.make<Scope.Closeable | undefined>(undefined);
|
|
|
|
const stopLoops = Effect.gen(function* () {
|
|
const prev = yield* Ref.getAndSet(loopScope, undefined);
|
|
if (prev) yield* Scope.close(prev, Exit.void);
|
|
});
|
|
|
|
const startLoops: Effect.Effect<void> = Effect.gen(function* () {
|
|
yield* stopLoops;
|
|
const scope = yield* Scope.make();
|
|
yield* Ref.set(loopScope, scope);
|
|
const settings = yield* run(opts.settings);
|
|
|
|
const pollLoop = Effect.forever(
|
|
Effect.sleep(settings.pollInterval).pipe(
|
|
Effect.andThen(safeSync("poll")),
|
|
),
|
|
);
|
|
yield* Effect.forkIn(pollLoop, scope);
|
|
|
|
if (settings.watch && settings.watchDirs.length > 0) {
|
|
const watchStream = Stream.callback<void>((queue) =>
|
|
Effect.acquireRelease(
|
|
Effect.sync(() =>
|
|
openWatchers(settings.watchDirs, () => {
|
|
Queue.offerUnsafe(queue, undefined);
|
|
}),
|
|
),
|
|
(watchers) =>
|
|
Effect.sync(() => {
|
|
for (const w of watchers) {
|
|
try {
|
|
w.close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
}
|
|
}),
|
|
),
|
|
);
|
|
const watchLoop = watchStream.pipe(
|
|
Stream.debounce(settings.debounce),
|
|
Stream.runForEach(() => safeSync("fs-change")),
|
|
);
|
|
yield* Effect.forkIn(watchLoop, scope);
|
|
}
|
|
});
|
|
|
|
// Loop teardown rides the construction Scope (plugin shutdown).
|
|
yield* Effect.addFinalizer(() => stopLoops);
|
|
|
|
return {
|
|
sync,
|
|
status,
|
|
changes: Stream.fromPubSub(hub),
|
|
start: safeSync("startup").pipe(Effect.andThen(startLoops)),
|
|
reconfigure: startLoops.pipe(
|
|
Effect.andThen(safeSync("config-change")),
|
|
),
|
|
} satisfies SyncEngine<Report>;
|
|
});
|