RomM Backend: FastAPI / SQLAlchemy
SkillDatabases & dataLets your agent build and modify the RomM Python backend, including API endpoints, models, migrations, and tests.
Use RomM Backend: FastAPI / SQLAlchemy in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add RomM Backend: FastAPI / SQLAlchemy and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the RomM Backend: FastAPI / SQLAlchemy skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
About this skill
Working on the RomM Python backend (backend/), a FastAPI app with SQLAlchemy 2.0, Alembic, RQ/Redis, and Socket.IO. Use when adding or changing API endpoints, handlers, ORM models, response schemas, metadata-provider adapters, background tasks, database migrations, or backend tests. Covers the laye
What this skill tells your AI
The instructions your AI receives, as published by rommapp/romm in .claude/skills/backend-development/SKILL.md and read by ahel’s review.
Python 3.14+, FastAPI, SQLAlchemy 2.0 (MariaDB default; MySQL/PostgreSQL supported), Alembic, Redis + RQ for jobs/cache/sessions, Socket.IO for real-time. Managed with uv.
Full reference: docs/BACKEND_ARCHITECTURE.md (directory map, ER diagram, every endpoint, auth flows). Read it before non-trivial changes.
Layered architecture: where code goes
endpoints/ FastAPI routers: request validation, response schemas, @protected_route scopes
endpoints/responses/ Pydantic response schemas (these shape the OpenAPI → frontend types)
endpoints/sockets/ Socket.IO event handlers
handler/ Business logic, decoupled from HTTP
├ auth/ HybridAuthBackend (session/basic/bearer/OIDC/client-token), scopes, CSRF/session middleware
├ database/ Per-entity CRUD handlers (db_rom_handler, db_user_handler, …), engine/session factory
├ metadata/ One handler per provider; normalizes + ranks by priority
└ filesystem/ ROM/asset/firmware file I/O, hashing, archive extraction
adapters/services/ Typed external API clients (igdb.py + igdb_types.py, screenscraper.py, …)
models/ SQLAlchemy ORM models (BaseModel adds created_at/updated_at)
tasks/ RQ jobs, scheduled/ (cron) and manual/ (on-demand); base classes in tasks.py
config/ Env-var loading (__init__.py) + YAML config manager (singleton)
decorators/ @begin_session (DB session), @protected_route (auth + scopes)
exceptions/ Custom exception hierarchy
utils/ logger/ Shared helpers, structured logging
alembic/ Migrations (env.py + versions/)
Endpoint → handler → (database | metadata | filesystem) → models/adapters. Endpoints stay thin: validate, enforce scopes, call handlers, serialize via a response schema. Don't put business logic or raw queries in endpoints.
Conventions
- Naming: Classes
PascalCase; functions/varssnake_case; constantsUPPER_SNAKE_CASE; private_prefixed. - DB sessions: decorate handler methods with
@begin_session; it injects and manages the SQLAlchemy session/transaction. Don't open sessions ad hoc. - Async: I/O-bound endpoints and tasks use
async/await. Per-requesthttpx2/aiohttpclients come from context vars (utils/context.py), not new clients per call. - Imports: stdlib → third-party → local; explicit (no wildcards);
TYPE_CHECKINGblocks to break circular imports. - Errors: raise the custom exceptions in
exceptions/(e.g.RomNotFoundInDatabaseException), not bareHTTPException, where a typed one exists. - Validation/SSRF: sanitize filenames/paths before filesystem use (
utils/); paths are rooted atLIBRARY_BASE_PATH/RESOURCES_BASE_PATH/ASSETS_BASE_PATHfrom config. - Engine-specific query SQL: reach for a portable SQLAlchemy expression first. If the engines need different SQL, build both with
DialectCase(postgresql=..., mysql=...)fromutils/sql_dialect.py(or add a helper or@compilesconstruct there) rather than branching onROMM_DB_DRIVERin a handler.@compiles(..., "mysql")alone misses MariaDB; use_compiles_on_mysql_family. Pin each spelling in tests by compiling forMARIADB_DIALECT/POSTGRESQL_DIALECTfromtests/sql_dialects.py.
Auth & scopes
- Roles:
VIEWER(read),EDITOR(+write roms/platforms/assets),ADMIN(+users/tasks/logs). Defined onmodels/user.py; scope tiers inhandler/auth/constants.py. - Granular scopes:
me.read/write,roms.read/write,platforms.*,assets.*,devices.*,firmware.*,collections.*,users.*,tasks.run,logs.read. - Protect routes with
@protected_route(router.<method>, "<path>", [Scope.X]). The frontend mirrors these scopes; keep them aligned.
Adding things
- Endpoint: add the route in the right
endpoints/*router, a response schema inendpoints/responses/, enforce scopes, delegate to a handler. If the response shape changes, the frontend must regenerate types (below). - Model / schema change: edit
models/, then create a migration (below). Update the matching response schema so OpenAPI stays accurate. - Metadata provider: add a typed client in
adapters/services/<name>.py(+<name>_types.py) and ahandler/metadata/<name>_handler.pythat normalizes into the common shape and slots into the priority order. - Background job: subclass
Task/PeriodicTaskintasks/scheduled/ortasks/manual/; register scheduled jobs instartup.py. - Telling a user something happened:
notify()/notify_admins()inhandler/notification_handler.py, from a request or a worker. ANotificationKindis translated client-side fromdata(add its describer and locale keys); for anything else pass a free-form kind withtitle/body/link.
Database migrations (Alembic)
Migrations must work on MariaDB, MySQL, and PostgreSQL (CI runs alembic upgrade head on Postgres and MariaDB; see .github/workflows/migrations.yml). Use batch mode / DB-specific SQL where needed; mirror existing migrations in alembic/versions/.
cd backend
uv run alembic revision --autogenerate -m "short description" # generate, then HAND-REVIEW the file
uv run alembic upgrade head # apply
uv run alembic downgrade -1 # verify the downgrade works
Always review autogenerated migrations: they miss server-default/enum/index nuances and cross-dialect differences. The virtual_collections DB view is excluded from migrations.
Migration hygiene (recurring review fixes)
- Numbering collides on rebase. Two open branches both pick the next number. When you rebase onto
masterand find your0102_*already taken, rename the file, updaterevision, and re-chaindown_revisiononto the migration that actually precedes it now. Then runalembic upgrade headon a fresh DB to confirm the chain is linear. - Use the builtin idempotency flags, not manual introspection.
op.create_table(..., if_not_exists=True)andcreate_index(..., if_not_exists=True)overinspect(conn).get_table_names()guards wrapping the whole block. Reserveinspect()for what the flags can't express. - A released revision must survive a partial run. MySQL/MariaDB auto-commit each DDL statement while alembic only stamps on success, so a revision that dies partway leaves its earlier statements behind and the next start replays it from the top. Guard every step, and filter raw
op.execute("ALTER TABLE ...")strings againstutils.database.column_nameswhere the flags cannot reach.tests/test_migrations.pypins the replays. - Edit an unreleased migration in place. If the migration hasn't shipped in a tag yet, change it rather than stacking a fixup migration on top. Only released migrations are immutable.
- Don't ship a data backfill you can avoid. A rewrite-every-row migration to normalize values the parser now handles is a maintenance liability. Prefer fixing the parser and letting the next scan converge, unless stale rows are actually user-visible and unrecoverable.
- A new
romscolumn goes throughutils/roms_columns.py. Declare it in the catalog there and have the revision callensure_roms_columns(); the first revision that runs on a database adds every catalog column the table lacks in oneALTER TABLE.romscarries a FULLTEXT index and JSON blobs per provider, so each separateALTER TABLE romscopies the whole table, minutes per copy on a scraped library. The revision's downgrade drops its own column withif_exists=True;0108's downgrade callsdrop_roms_columns()and removes whatever of the catalog a chain that stopped short still carries. - Prefer a generated column plus an index over a join when a field exists only to sort or filter (see
generated_first_release_date), and say so in the model's__table_args__rather than in prose. - Pass
nullable=on every database-filled column. Amapped_column(server_default=FetchedValue())left to infer it fromMapped[...]is one autogenerate never compares, so declare it to match the DDL (MariaDB forces generated columns nullable).tests/test_migrations.pyenforces it.
OpenAPI → frontend types
FastAPI serves the schema at GET /openapi.json. The frontend regenerates its TypeScript types from it:
# backend running on :3000, then in frontend/
npm run generate # writes src/__generated__/ via openapi-typescript-codegen
Any change to a response schema or route signature should be followed by npm run generate + a frontend typecheck.
Run, test, lint
cd backend
uv run python3 main.py # run (migrations auto-apply on startup)
uv run pytest <path/file> # tests - affected files only, NEVER the whole suite
uv run pytest -n auto <dir> # same, in parallel; use for directories, not single files
- Tests: pytest + pytest-asyncio, isolated per
pytest-xdistworker (per-worker DBs);fakeredis;pytest-recordingVCR cassettes mock external APIs; Hypothesis for property tests. Mirror thebackend/<area>/layout underbackend/tests/. First-time test DB setup:docker exec -i romm-db-dev mariadb -uroot -p<pw> < backend/romm_test/setup.sql. - Lint / format run through Trunk (ruff, black, isort, bandit):
trunk fmt && trunk check. CI enforces Trunk on every PR. Never bypass with--no-verify. - Type-check with mypy, outside Trunk so it sees the project's packages:
uv run mypy --config-file ../.trunk/configs/mypy.ini .frombackend/. CI'smypy.ymlrequires zero errors. The config isstrict = Trueminus the flags it lists as not yet clean: every function outsidetests/needs full annotations, every# type: ignorenames its error code, and a name is imported from the module that defines it, not one that merely imports it. - Persist test rows with the
make_*factories intests/factories.py(ROMs, saves, states, screenshots, firmware, client tokens), passing only the columns the test cares about. Ruff'sTID251bans calling the handlers'add_*directly in tests. - New/changed logic needs a test; new endpoints need endpoint tests.
Signals
- GitHub stars
- 13k
- Forks
- 747
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
backend-development-rommapp- Source
- github.com/rommapp/romm
Related picks
Skill · wshobson
The pick for Pythonpython-pro
Skill · jeffallan
The pick for Pythonfastapi
Skill · fastapi
The pick for FastAPIpython-fastapi-ops
Skill · 0xdarkmatter
The pick for FastAPIsupabase
Skill · supabase
More in Databases & dataconnect
Skill · composiohq
More in Databases & data