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.

Covers land at games/covers/<imageId>.jpg, and Game.imageUrl stores the absolute public URL built by src/lib/publicUrl.ts. The base is the first of:

  1. PUBLIC_CDN_URL, with https:// added when it has no scheme (in production it comes from the Terraform CDN_ENDPOINT secret, which is a bare host);
  2. SPACES_URL (the bucket origin);
  3. https://<SPACES_BUCKET>.<SPACES_REGION>.digitaloceanspaces.com.

The service refuses to start if that doesn't give a valid https URL. Until 2026-10-27 the bare CDN host was used as-is, so every cover URL was stored without a scheme (relative to the app) and no cover loaded; the 2,011 production rows were fixed by prefixing https://. Because a sync only re-uploads when the cover id changes, a wrong base never self-corrects: fix stored rows with SQL.

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_URL—Required
IGDB_CLIENT_ID—Twitch application client id
IGDB_CLIENT_SECRET—Twitch application secret
SPACES_REGION / SPACES_ENDPOINT / SPACES_ACCESS_KEY / SPACES_SECRET_KEY / SPACES_BUCKET—Cover storage; all required. SPACES_ENDPOINT is the regional API endpoint (https://nyc3.digitaloceanspaces.com)
SPACES_URL—Bucket origin (https://<bucket>.nyc3.digitaloceanspaces.com); fallback base for cover URLs
PUBLIC_CDN_URL—CDN base for cover URLs; a bare host gets https://

The names now match the API and ogun: SPACES_ENDPOINT is the S3 API endpoint and SPACES_URL the bucket origin. (Before 2026-10-27 this service read SPACES_URL as its endpoint.)

Deployment ​

PropertyValue
Helm valuesinfra/helm/values/production/igdb-heartbeat.yaml (release playpalz-igdb-heartbeat)
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