This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Self-hosting fork of the archived christianselig/apollo-backend. The original backend was Apollo's production push-notification + watcher service, shut down June 30, 2023 after Reddit's API pricing changes. This fork is being adapted for single-tenant self-hosting against sideloaded Apollo builds (e.g. via JeffreyCA's Apollo-ImprovedCustomApi tweak). Christian-specific integrations (App Store IAP, Live Activities, Bugsnag, SMTP2GO, Render) have been stripped.
make build— compile the singleapollobinary (./cmd/apollo).make test— runsgo test -race -timeout 1s ./.... Tests that need Postgres skip themselves ifDATABASE_URLis unset;make test-setupruns the migrations againstDATABASE_URLto prepare a local DB.make lint— runsgolangci-lintwith the linters listed in.golangci.yml(notablyparalleltest,errcheck,sqlclosecheck,rowserrcheck,gochecknoinits).- Single test:
go test -race ./internal/repository -run TestPostgresWatcher_Create. - Migrations use
golang-migrate; files live inmigrations/.docs/schema.sqlis the consolidated schema CI loads instead of stepping through migrations.
A single binary, three cobra subcommands. Each is deployed as a separate container (see docker-compose.yml):
apollo api— Gorilla mux HTTP server (default port 4000,$PORToverrides). Routes ininternal/api/api.go. Handles device/account registration from the iOS app and watcher CRUD.apollo scheduler— single-instance ticker. Every 5s it runs SQL of the formUPDATE ... SET next_check_at = $next WHERE id IN (SELECT id ... WHERE next_check_at < $now FOR UPDATE SKIP LOCKED LIMIT N) RETURNING id, then publishes the returned IDs onto an rmq queue. This atomic claim-and-reschedule is the core scheduling primitive — don't replace it with a SELECT-then-UPDATE.apollo worker --queue <name> --consumers <n>— pulls jobs from one rmq queue and processes them. Queues:notifications,stuck-notifications,subreddits,trending,users. Each has aNew<Queue>Workerconstructor wired ininternal/cmd/worker.go.
Side channels: every process exposes pprof on localhost:6060; the scheduler also serves :8080 for health.
Configured via separate env vars (REDIS_QUEUE_URL, REDIS_LOCKS_URL) and built via cmdutil.NewRedisQueueClient / NewRedisLocksClient:
- Queue Redis — backs
github.com/adjust/rmq/v5.noeviction. - Locks Redis — holds short-lived
SET key NX EXkeys keyed likelocks:accounts:<reddit_account_id>. The scheduler loads a Lua script once (evalScriptininternal/cmd/scheduler.go) that takes a batch of candidate IDs and returns only those it successfully acquired the lock for. This is what prevents a job from being processed twice when checks overlap (NotificationCheckTimeoutis the lock TTL).
Postgres is reached through PgBouncer in transaction mode, so cmdutil.NewDatabasePool forces pgx.QueryExecModeSimpleProtocol — don't switch to the default extended protocol or prepared-statement caching will break under PgBouncer.
internal/domain/— pure types andRepositoryinterfaces. No DB code here. Domain-level constants likeNotificationCheckInterval,SubredditCheckInterval,StaleTokenThresholdlive here and govern scheduler cadence.internal/repository/— Postgres implementations of the domain interfaces, one file per aggregate (postgres_account.go, etc.). Usepgxpool.Pooldirectly; theConnectioninterface inconnection.goexists so methods can accept either a pool or a transaction.internal/api/— HTTP handlers, one file per resource.api.gowires repositories, the Reddit client, and the APNs token into the handler struct and registers all routes.internal/worker/— one file per queue. Each worker constructs its ownreddit.Clientand APNstoken.Tokenfrom env vars at startup (they aren't shared with the API process).worker.goonly defines theWorkerinterface andNewWorkerFn.internal/reddit/— Reddit OAuth + API client. Tracks rate-limit headers (x-ratelimit-*) and backs off;RequestRemainingBuffer = 50is the soft floor it keeps in reserve. Errors are mapped viadefaultErrorMap(401/403 →ErrOauthRevoked, 429 →ErrTooManyRequests).internal/cmd/— cobra command definitions; this is where the wiring (DB pool sizes, consumer counts, queue names) lives.internal/cmdutil/— process-startup helpers (logger, statsd, Redis, Postgres pool, rmq connection). All processes go through these so connection tuning is centralized.internal/testhelper/—NewTestPgxConnfor repository tests; skips whenDATABASE_URLis empty.
- Repository tests use the
_testpackage (enforced bytestpackagelinter) and must callt.Parallel()(enforced byparalleltest). gochecknoinitsis on — don't addinit()functions.- Observability is opt-in: every process builds a
zap.Logger, a statsd client (statsd.ClientInterface— aNoOpClientwhenSTATSD_URLis unset), and an OpenTelemetry tracer via Honeycomb's launcher (also no-op without env vars). New code paths in the request/job hot path should still emit a statsd metric — it's free when disabled. - Worker consumer counts are sized off
--consumers; the DB pool getsconsumers/16, locks Redisconsumers/4, queue Redisconsumers/16. Keep those ratios in mind if you change pool tuning.
- One deployment serves one sideloaded Apollo build. The APNs topic is configured per-deployment via
APPLE_APNS_TOPIC(the build's bundle ID);cmdutil.APNSTopic()reads it and panics on startup if unset. - Reddit OAuth credentials are per-account, not per-deployment. Each row in
accountscarries its ownreddit_client_id/reddit_client_secret/reddit_user_agent/reddit_redirect_uri. The tweak sends these at registration time. The process-levelreddit.Clientcarries no credentials;AuthenticatedClientholds them andRefreshTokensuses Basic auth with the per-account client_id/secret. - Registration endpoints (
POST /v1/device,POST /v1/device/{apns}/account,POST /v1/device/{apns}/accounts) are gated byREGISTRATION_SECRETwhen set — clients must send it asX-Registration-Token. When unset, registration is open (intended for local dev or private-network instances). - The iOS-app-facing registration payload is the explicit
accountRegistrationRequeststruct ininternal/api/accounts.go, notdomain.Accountdirectly — counters and DB IDs are not user-controlled.