igdb-heartbeat — Game Catalog Sync
@playpals/igdb-heartbeat · port 4006 · apps/igdb-heartbeat
Keeps the Game table in sync with IGDB. Users tag posts with games and pick favourite games during onboarding; this service is where that catalog comes from.
Running it
pnpm --filter @playpals/igdb-heartbeat dev
curl http://localhost:4006/health # {"status":"ok","service":"igdb-heartbeat"}You need Twitch developer credentials — IGDB authenticates through Twitch OAuth.
Schedule
apps/igdb-heartbeat/src/jobs/sync.ts:
| When | What |
|---|---|
| On startup | seedIfEmpty() — if Game is empty, sync the top 2000 games |
Daily 03:00 UTC (0 3 * * *) | Refresh the top 1000 games |
The startup seed is a no-op once the table is populated, so restarts are cheap.
What it fetches
fields id, name, summary, cover.image_id, rating, first_release_date,
genres.name, platforms.abbreviation, platforms.name;
where rating_count > 30 & version_parent = null & cover != null;
sort rating desc;
limit 500;
offset <n>;The filter is doing real work: rating_count > 30 excludes obscure entries, version_parent = null excludes regional re-releases and special editions, and cover != null keeps the UI from rendering placeholder tiles. Pages of 500, sorted by rating.
Cover images are mirrored, not hot-linked
Covers are downloaded from IGDB and re-uploaded to DigitalOcean Spaces, served from the CDN. The stored igdbCoverImageId is compared on each sync so an unchanged cover is never re-uploaded:
const coverChanged = incomingCoverId && incomingCoverId !== existing?.igdbCoverImageId;
if (coverChanged) imageUrl = await uploadCoverFromUrl(incomingCoverId);A failed upload logs a warning and keeps the existing URL rather than blanking the record.
Token handling
IGDB uses Twitch client-credentials OAuth. The token is cached in memory and refreshed 60 seconds before expiry:
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken;Configuration
| Variable | Default | Notes |
|---|---|---|
PORT | 4006 | |
DATABASE_URL | — | Required |
IGDB_CLIENT_ID | — | Twitch application client id |
IGDB_CLIENT_SECRET | — | Twitch application secret |
SPACES_REGION / SPACES_URL / SPACES_ACCESS_KEY / SPACES_SECRET_KEY / SPACES_BUCKET | — | Cover storage |
PUBLIC_CDN_URL | — | CDN base for cover URLs |
Note this service reads SPACES_URL as its endpoint, where ogun reads SPACES_ENDPOINT. Set both to be safe.
Deployment
| Property | Value |
|---|---|
| Manifest | infra/k8s/igdb-heartbeat-deployment.yaml |
| Replicas | 1 — cron, do not scale |
| Probes | Liveness and readiness on /health |
| Ingress | None |
./scripts/build-igdb-heartbeat.sh [tag]Operating
kubectl logs -l app=playpalz-igdb-heartbeat --tail=100
# Did the daily sync run?
kubectl logs -l app=playpalz-igdb-heartbeat --since=24h | grep "daily sync"To force a full re-seed, empty the Game table and restart the pod — seedIfEmpty() will pull 2000 games. Consider what that does to any UserGame foreign keys first.
Known issues
- No manual trigger. There is no admin endpoint to force a sync; the only lever is a restart with an empty table. A
POST /admin/syncguarded by a token would help. - No failure alerting. A sync failure logs an error and waits 24 hours for the next attempt.
- Rating changes do not trigger re-sort. Only the top 1000 by rating are refreshed daily, so a game that climbs into the top 1000 is not picked up until a wider sync runs.
