Engineering rules
July 27, 2026 · View on GitHub
Constraints that are not obvious from reading the code, and cost real incidents when broken. Read before making changes.
CONTRIBUTING.md covers process — how to open a PR, how to run the suite. This file covers the things the codebase itself will not tell you.
Stack
SvelteKit 2 + Svelte 5 (runes) · @sveltejs/adapter-node · PostgreSQL via raw
pg (no ORM) · Redis via node-redis with in-memory fallback · jose for
session JWTs · PM2 cluster in Docker.
New components use runes ($state, $props, $derived). Some older
components still use Svelte 4 syntax (export let) — migrate them when you
touch them.
Server-only modules end in .server.js or live under src/lib/server/.
Server-side performance rules
These exist because v1.3 fixed a 9-second cold-start hang caused by breaking them. See V1.3_FINDINGS.md.
-
No outbound HTTP on the root layout.
src/routes/+layout.server.jsruns on every route, including the unauthenticated/login. Anything awaited there blocks the first byte of every page. Use config checks and background-refreshed values instead. -
Every outbound call gets a hard timeout.
AbortSignal.timeout(...)on everyfetch, no exceptions. Nothing on a render path may block unbounded.AbortSignal.timeout()throwsTimeoutError, notAbortError. Check accordingly.isTimeoutOrNetworkError()insrc/lib/utils.jsgets this right; use it rather than writing the check again.
-
Never swallow an error silently. A
catchthat returns[],null, orfalsewithout logging destroys the ability to diagnose anything. Log the status code. Distinguish 403 from timeout from empty. -
Nothing expensive on the request path at boot. Cache warming, migrations, and pool construction happen at startup, out of band.
-
Slow data streams. Return unawaited promises from
loadand render skeletons, rather than blocking SSR.- ⚠️ Streaming needs CSP
mode: "nonce"insvelte.config.js. Under"hash", streamed inline<script>chunks are emitted after headers flush, get blocked, and the sections never resolve. Note that while'unsafe-inline'stays inscript-src, SvelteKit emits neither hashes nor nonces, so the mode is inert — but it must be correct before'unsafe-inline'is removed.
- ⚠️ Streaming needs CSP
-
Never strip
consolefrom the server bundle.drop_consoleinvite.config.jsapplies to the SSR build too. Operators of a self-hosted app debug from container logs; silently removing every log statement is how integrations fail invisibly. -
Prefer internal service hostnames over public URLs for server-side calls. A public URL hairpins out through DNS + TLS + the reverse proxy and back.
Cached credentials need an expiry
Any credential held in module scope must track when it dies and renew ahead of that, and must be discarded on any response that suggests it is stale — not just the status code the provider's documentation promises.
The ROMM client learned this the expensive way: it cached a bearer token with no
expiry and cleared it only on an exact 401. RomM answers a request carrying an
expired JWT with 500, so the token was never discarded and each worker
served errors until the container was restarted. See
src/lib/romm.server.js and tests/integration/romm-token-lifecycle.test.js.
Module state is also per PM2 worker. A cache, token, or circuit breaker held in a module is not shared, so N workers means N copies with independent lifetimes. Anything that must be shared goes through Redis.
Database & migrations
Migrations live in migrations/, run via scripts/database/db-manager.js,
tracked by filename in ggr_migrations, ordered lexicographically.
npm run db:migrate
npm run db:status
Known limitations — do not rely on these working:
ggr_schema_versionandggr_migration_lockare created by001_initial_schema.sqlbut are never read by any code. There is no version-delta upgrade path, and no locking — concurrent migration runs are not serialised.rollback_sqlis stored but never executed.verifySchemaIntegrity()indb-manager.jsdoes not throw. A schema mismatch logs to stderr and the app boots anyway, on the grounds that an instance which refuses to start is harder to repair than one running degraded. Check container logs after upgrading.
New migrations: NNN_description.sql, next number in sequence. Never edit a
migration that has shipped.
Migrations create the schema and the system roles and permissions, but no domain data. A freshly migrated database has no users and no games; see ../setup/TESTING.md for seeding one.
Environment variables
.env.example is the source of truth for what exists. When adding a variable,
update all of:
.env.example · docker-compose.yml · docs/CONFIGURATION.md · the relevant
setup guide in docs/setup/
Prefer reading env through $env/dynamic/private. Note that
src/lib/auth.server.js reads at module top-level, so those values freeze at
first import.
Docker Compose reads ./.env for ${VAR:-default} interpolation, whether
or not a compose file asks it to. This is why docker-compose.test.yml is run
with --env-file /dev/null: without it, the disposable test stack silently
inherited the production AUTH_METHOD, SESSION_SECRET and ROMM_SERVER_URL.
Commit discipline
One logical change per commit. This is the rule that matters most here, because the repo has a history of violating it:
687aef7 Release v1.2.5: Global content filtering and critical bug fixes
bf494c2 Release v1.2.3: Critical security fixes and granular permissions
b6b29a7 Complete UI improvements from previous session
Commits like these make it impossible to bisect a regression, revert one bad change without losing four good ones, or review anything meaningfully.
- One task, one commit. A bug fix and the refactor that enabled it are two commits.
- Never bundle unrelated fixes under a release heading. "Release vX.Y.Z" is a tag, not a commit message. The only thing a release commit should contain is the version bump and changelog entry.
- Formatting-only changes (
npm run format) go in their own commit, never mixed with logic. - If a change touches multiple workstreams, split it — even when the work happened in one sitting.
Format: Conventional Commits.
feat: add OIDC discovery document support
fix: add timeout to ROMM authentication request
docs: rewrite OIDC setup guide for generic providers
refactor: consolidate duplicate ROMM fetch implementations
Types: feat, fix, docs, style, refactor, test, chore.
Branches: feature/, fix/, docs/, refactor/, test/ + description.
Before committing
npm run lint # prettier --check .
npm run check # svelte-check
npm run test:unit # vitest, jsdom project
npm run test:integration # vitest, node project
Run npm run format if lint fails — but commit the formatting separately from
the logic change.
Changelog
CHANGELOG.md follows Keep a Changelog structure with emoji section headers
in practice:
## [1.3.0] - 2026-07-25
### ✨ New Features
### 🐛 Bug Fixes
### 🔧 Technical Changes
### 📚 Documentation
docs/guides/RELEASE_GUIDE.mddocuments plain### Added / Changed / Fixedheaders. The file itself uses emoji. The docs are out of date; follow the file.
The ## [X.Y.Z] heading is load-bearing — .github/workflows/release.yml
extracts release notes from it with awk, and scripts/create-release.sh gates
on grep -q "^## \[$version\]". Do not reformat it.
Keep an empty ## [Unreleased] section at the top.
Documentation
docs/ is organised as setup/, guides/, dev-notes/, plus top-level
ARCHITECTURE.md, API.md, CONFIGURATION.md. The index lives in the root
README.md, which is the only documentation file at the repository root.
Docs must describe shipped behavior. docs/setup/OIDC_SETUP.md previously
documented eight OIDC_* variables that no code read, which produced two
user-facing bug reports (#4, #7). If a feature is planned but not implemented,
say so explicitly.
Planning records
Multi-phase work is tracked in tmp/ — one document per workstream, with
evidence and a task checklist. tmp/ is not tracked in git, so these records
are local to whoever did the work.
When a workstream finishes, the durable findings move into docs/dev-notes/ and
the working record stays behind. V1.3_FINDINGS.md is the
worked example, distilled from six tmp/1.3-*.md records.