Files
punktfunk/plugin-kit/src/sync-engine.ts
T
enricobuehler 10a0ef3283 style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.

Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.

Two deliberate differences from the plugin repos' copy:

  * no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
    the config; plugin-kit is a directory inside this one, and biome errors with
    "couldn't find an ignore file". The `files.includes` exclusions cover it.
  * `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.

`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.

Everything here is formatter/import churn except two real findings, both fixed:

  * `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
    never used;
  * test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
    `expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
    `reg` been undefined the `.ui` access would throw a TypeError instead of
    failing the test readably. Now asserted to the type system too.

Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.

Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
2026-08-08 02:19:06 +02:00

287 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 {
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>;
});