- TypeScript 55.9%
- Shell 31.7%
- JavaScript 12.4%
Owner call: the steamstore repo's deploy.sh is the origin tool and stays untouched; README deploy section aligned |
||
|---|---|---|
| .gitattributes | ||
| .gitignore | ||
| deploy.sh | ||
| gamevault.d.ts | ||
| mapping-report.controller.ts | ||
| mapping-report.plugin.module.ts | ||
| package-lock.json | ||
| package.json | ||
| postbuild.js | ||
| README.md | ||
| tsconfig.json | ||
Mapping Report — GameVault / LudoNexus Backend Plugin
A minimal, routes-only, read-only NestJS plugin for the
GameVault backend (built against v16.3.0). It adds a
single endpoint that returns the IDs of games which have zero REAL
provider-metadata mappings — i.e. games that no metadata provider (IGDB, Steam
Unfiltered, …) has successfully matched. "Real" is load-bearing: the
Steam Unfiltered provider ships a no-match
SENTINEL junction row (provider_data_id = "unmapped-<gameId>", priority -1)
instead of throwing, so a sentinel-only game has a junction row yet is not
mapped and must appear in this report. (recovered-<appid> rows are the
delisted-game recovery family — those are real mappings and do NOT appear.)
It exists to back the LudoNexus client's "Mapped Games" filter — the provider-backed upgrade tracked as backlog item 999.14. The client can call this endpoint to know exactly which library items are still orphaned and surface them for review, instead of guessing client-side.
What this plugin is NOT. It registers no metadata provider, patches no global prototypes, calls no external APIs, ships no configuration, and has no self-updater. It contributes exactly one guarded GET route and nothing else. Zero third-party runtime dependencies — every module it imports (
@nestjs/common,@nestjs/typeorm,typeorm,@nestjs/swagger) is provided by the backend at runtime, sodist/drops straight into/pluginswith no bundlednode_modules.
Endpoint contract
GET /api/plugins/mapping/unmapped
(The backend adds the global /api prefix; the controller itself declares
plugins/mapping + unmapped.)
Auth: requires the EDITOR role (@MinimumRole(Role.EDITOR)). The
backend's auth guards are registered globally as APP_GUARDs, so a valid bearer
token or API key plus the EDITOR role is all that's required — the plugin adds no
guard of its own and never skips them.
Response (200 OK), snake_case to match the backend API convention:
{
"unmapped_game_ids": [12, 87, 340], // number[] — ids of unmapped games
"count": 3, // number — unmapped_game_ids.length
"generated_at": "2026-07-03T12:34:56.789Z" // string — ISO-8601 timestamp
}
The query
SELECT g.id
FROM gamevault_game g
WHERE g.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1
FROM gamevault_game_provider_metadata_game_metadata j
JOIN game_metadata m ON m.id = j.game_metadata_id
WHERE j.gamevault_game_id = g.id
AND m.deleted_at IS NULL
AND (m.provider_data_id IS NULL
OR m.provider_data_id NOT LIKE 'unmapped-%')
);
Issued via repository.createQueryBuilder(...).getRawMany(). A game is unmapped
when it has no junction row to a live, non-sentinel metadata row. Bare
junction-existence is NOT enough — that was the original query, and it
classified sentinel-only games as mapped, blanking the client's Unmapped work
queue (falsified live against the dev mirror, 2026-07-04). The IS NULL arm
keeps a (malformed) null-provider_data_id row counting as mapped — SQL's
NULL NOT LIKE is NULL, which would silently flip such rows — matching the
client's Game.HasRealProviderMapping rule exactly: only the sentinel prefix
changes classification. Both soft-delete filters are explicit and
required. Postgres returns integer columns as strings, so ids are coerced
with Number().
Acceptance check (this plugin has no test harness — this is the executable
pin; run it against a mirror DB whenever the query changes): on the dev mirror's
spicy DB (24 games, two of them sentinel-only) the query returns exactly those
two ids [9, 19]; on the game DB (10 games, all real appids) it returns [].
Build
npm install
npm run build # tsc -> dist/, then postbuild.js rewrites the src/... requires
npm run build runs tsc then postbuild.js. tsc type-checks against the
ambient src/... stubs in gamevault.d.ts; postbuild.js rewrites the emitted
require("src/...") specifiers to the relative paths that resolve at runtime
inside the backend:
| compile-time specifier | rewritten to | resolves at runtime to |
|---|---|---|
src/globals |
../../globals |
dist/src/globals |
src/modules/games/... |
../games/... |
dist/src/modules/games/... |
src/modules/users/... |
../users/... |
dist/src/modules/users/... |
src/decorators/... |
../../decorators/... |
dist/src/decorators/... |
The compiled entrypoint is dist/mapping-report.plugin.module.js — its
.plugin.module.js suffix is what the backend's plugin loader globs for.
Deploy
Deploy to the WSL/docker mirror stack FIRST and verify boot before touching
production. The backend plugin loader is all-or-nothing: it import()s
every *.plugin.module.js under /plugins in one Promise.all, and one plugin
file that fails to load throws the whole batch — which would take down every
plugin, including the Steam Unfiltered metadata provider. A broken drop here
silently disables Steam metadata on the whole server. Prove it boots on the
mirror first.
On the mirror stack this plugin is bind-mounted per-plugin from:
$HOME/GameVault/official/ludonexus-plugin-mapping-report/ <- dist/* contents here
(the compose override mounts that dir read-only at
/plugins/ludonexus-plugin-mapping-report, shadowing the shared ./plugins dir
for this plugin — see the gotcha below). In-container it still sits one directory
deep under /plugins, which the rewritten ../ / ../../globals requires need.
deploy.sh automates the mirror deploy (build → staged-swap into official/ →
docker compose up -d --force-recreate --no-deps game spicy). Run it manually
from WSL:
./deploy.sh # build + hot-redeploy into the shared ./plugins, reload backends
./deploy.sh --fresh # additionally wipe the disposable 'spicy' database first
Or deploy by hand: copy the contents of dist/ into
.../plugins/ludonexus-plugin-mapping-report/ and restart the backend
containers so each re-copies /plugins on boot.
⚠️ Mount-shadow gotcha (cost a debugging round on 2026-07-04): if the compose file bind-mounts a per-plugin override — e.g.
~/GameVault/official/ludonexus-plugin-mapping-report → /plugins/ludonexus-plugin-mapping-report (ro)— that mount shadows the shared./pluginsdir for this plugin, anddeploy.sh's target is silently ignored: the backend keeps running the old build while the shared dir holds the new one. After ANY deploy, verify the code the container actually runs, not the host dir:docker exec <backend> grep -c 'NOT LIKE' /app/dist/src/modules/ludonexus-plugin-mapping-report/mapping-report.controller.js(must be ≥1 on the sentinel-aware build).deploy.shtargetsofficial/since 2026-07-04 for exactly this reason; any copy left under the shared./plugins/ludonexus-plugin-mapping-report/is shadowed dead weight — safe to delete, and worth deleting so a future mount change can't resurrect a stale build.
Client consumer
LudoNexus app — the "Mapped Games" filter provider-backed upgrade
(backlog 999.14). The client hits GET /api/plugins/mapping/unmapped to get
the authoritative set of unmapped game ids and drives the filter / review UI from
it, rather than inferring mapping state client-side.