Skip to content

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

bash
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:

WhenWhat
On startupseedIfEmpty() — 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:

ts
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:

ts
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken;

Configuration

VariableDefaultNotes
PORT4006
DATABASE_URLRequired
IGDB_CLIENT_IDTwitch application client id
IGDB_CLIENT_SECRETTwitch application secret
SPACES_REGION / SPACES_URL / SPACES_ACCESS_KEY / SPACES_SECRET_KEY / SPACES_BUCKETCover storage
PUBLIC_CDN_URLCDN base for cover URLs

Note this service reads SPACES_URL as its endpoint, where ogun reads SPACES_ENDPOINT. Set both to be safe.

Deployment

PropertyValue
Manifestinfra/k8s/igdb-heartbeat-deployment.yaml
Replicas1 — cron, do not scale
ProbesLiveness and readiness on /health
IngressNone
bash
./scripts/build-igdb-heartbeat.sh [tag]

Operating

bash
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/sync guarded 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.

Internal documentation — PlayPalz platform