Files
punktfunk/plugin-kit/src/library/parsers/sqlite.ts
T
enricobuehler 8728d90e01 feat(plugin-kit): the library-plugin framework — parsers, __config, defineLibraryPlugin
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.

WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.

Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".

WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.

WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.

M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.

plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
2026-08-05 09:53:58 +02:00

69 lines
2.3 KiB
TypeScript

// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb.
//
// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that
// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst
// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while
// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch".
import { Database } from "bun:sqlite";
import { isFile } from "./fs.js";
export interface ReadOnlyDb {
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
readonly query: <T = Record<string, unknown>>(
sql: string,
...params: unknown[]
) => T[];
readonly close: () => void;
}
/**
* Open a launcher database read-only and immutably. `undefined` if the file is absent or not a
* database — the normal "this launcher isn't installed" case, not an error.
*
* Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you).
*/
export const openReadOnly = (file: string): ReadOnlyDb | undefined => {
if (!isFile(file)) return undefined;
let db: Database;
try {
// `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes
// this a pure read. It is safe here precisely because a scan is a point-in-time snapshot —
// if the launcher writes mid-scan we simply pick it up on the next sync.
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
} catch {
return undefined;
}
return {
query: <T = Record<string, unknown>>(sql: string, ...params: unknown[]) => {
try {
return db.query(sql).all(...(params as never[])) as T[];
} catch {
// A schema drift (a renamed column in a launcher upgrade) must degrade to "no
// titles from this source", never take the whole plugin down.
return [] as T[];
}
},
close: () => {
try {
db.close();
} catch {
/* already closed */
}
},
};
};
/** Open, use, and always close. Returns `undefined` when the database isn't there. */
export const withReadOnlyDb = <T>(
file: string,
use: (db: ReadOnlyDb) => T,
): T | undefined => {
const db = openReadOnly(file);
if (!db) return undefined;
try {
return use(db);
} finally {
db.close();
}
};