Database Developer Guide
This page describes the data layer of the AutoBangumi backend: the engine, the Database facade, the repositories, the session rules and the schema migrations. The code is in backend/src/module/database/.
Overview
- The database is the SQLite file
data/data.db. The ORM is SQLModel (Pydantic + SQLAlchemy). - The whole application uses only the async engine
sqlite+aiosqlite. Each connection setsPRAGMA journal_mode=WAL,busy_timeout=5000andforeign_keys=ON. Thus, more than one connection can write at the same time, and a write that collides waits instead of failing at once withdatabase is locked. - Foreign key constraints apply. Before you delete a row that other rows refer to, handle those rows first. For example,
BangumiDatabase.delete_alldeletes the torrent records first.
module/database/
├── __init__.py # Database, get_db (FastAPI dependency)
├── engine.py # async_engine, async_session_factory, connection PRAGMAs
├── combine.py # Database: one session plus all repositories
├── migrations.py # the MIGRATIONS table and the migration runner
├── bangumi.py, rss.py, torrent.py, movie.py, user.py, auth.py,
├── inbox.py, llm_credential.py, rename_operation.py, aria2.py,
└── plugin_kv.py, passkey.py # one repository for each tableDatabase and sessions
Database is an async context manager. It creates one AsyncSession and attaches the repositories to itself. When the async with block ends, it closes the session.
from module.database import Database
async with Database() as db:
rules = await db.bangumi.search_all()
feeds = await db.rss.search_all()| Attribute | Repository | Table |
|---|---|---|
db.bangumi | BangumiDatabase | bangumi |
db.rss | RSSDatabase | rssitem |
db.torrent | TorrentDatabase | torrent |
db.movie | MovieDatabase | movie |
db.user | UserDatabase | user |
db.auth | AuthDatabase | auth_session, api_token |
db.inbox | InboxDatabase | notification center |
db.llm_credential | LLMCredentialDatabase | LLM subscription credentials |
db.rename_operation | RenameOperationDatabase | rename_operation (rename conflicts and progress) |
db.aria2 | Aria2GidDatabase | aria2_gid |
db.plugin_kv | PluginKVDatabase | plugin_kv (the ctx.kv of plugins) |
PasskeyDatabase is not attached to Database. When you need it, make it with PasskeyDatabase(db.session).
Database also forwards some session methods: add (synchronous, it only queues the object), commit, rollback, refresh and close. On SQLite, begin_write() runs BEGIN IMMEDIATE. Use it when you read and then write and an invariant must stay true. Call it before the first access in the session.
Session rules
Use one session for each operation. Do not keep a session or a Database on an object that lives longer than one request or one loop pass. AppContext holds no session.
Routes use dependency injection:
pythonfrom fastapi import Depends from module.database import Database, get_db @router.get("/example") async def example(db: Database = Depends(get_db)): return await db.bangumi.search_all()Scheduled loops and services open their own session for each run:
async with Database() as db:.Services get their dependencies in the constructor:
RSSEngine(db),TorrentManager(db). A service usesself.db.<repository>. A caller that needs only one repository usesdb.<repository>directly.The session factory sets
expire_on_commit=False. After a commit, you can still read the attributes of an object, but the object is not attached to a new session. To change it, query it again in the new session.
Repositories
A repository takes an AsyncSession. All of its methods are async def, and most of them commit inside the method. When you add a query:
- Put it in the repository of its table. Do not write SQL in a route or a service.
- For many rows, get them in one query with
in_. Do not query row by row in a loop. - Soft delete:
bangumiandmoviehave adeletedfield. To "disable" a rule,disable_rulesetsdeletedtoTrue. Onlydelete_oneremoves the row. When you query active rules, filter ondeleted.
More than one downloader
4.0 supports more than one downloader instance (the plugins.instances config). bangumi, movie, rssitem and torrent all have a downloader_id column:
- On rules and feeds, an empty value means "use the default instance" (
plugins.slots.downloader). - On a torrent record, it is the instance that has the torrent. Torrents that came from 3.3 have
default.
Schema migrations
The migrations are in the MIGRATIONS tuple in migrations.py. The runner works from this table. The schema_version table in the database records the applied version.
Migration(
26,
"add downloader_id to bangumi, movie, rssitem and torrent",
(...SQL statements...),
already_applied=..., # guard: is this migration already in effect?
guarded_statements=(...), # optional: one guard for each statement
)At startup, AB first runs SQLModel.metadata.create_all, then run_migrations():
- It reads
schema_versionand skips each migration whose version is not larger. - For each pending migration, it inspects the database again and calls the guard. If the migration is already in effect (for example,
create_allmade a new database from the models), it only records the version. - Otherwise, it runs the statements in one SAVEPOINT. If a statement fails, the migration rolls back and the error goes up. The whole migration transaction rolls back and AB does not start. AB never runs on a schema that is only half migrated.
- If a migration ran, at the end it fills
NULLvalues in existing rows with the model defaults (fill_null_with_defaults_conn). It skips primary keys,default_factoryfields and optional fields whose default isNone.
Add a migration
- Change the model in
module/models/first. A new database gets the new structure fromcreate_all. - Append a
Migrationto the end ofMIGRATIONS. Its version is the previous version plus 1.CURRENT_SCHEMA_VERSIONcomes from the list. Do not edit it manually. - Give an
already_appliedguard:column_exists(table, column),table_exists(table)orindex_exists(table, index). To combine conditions, useall_checks(...). If one migration changes many tables and each table can be in a different state, useguarded_statementsto give each statement its own guard. - Add a test for the migration in
backend/src/test/: start from the old schema, run the migration and check the result.
Migration(
27,
"add note to bangumi",
("ALTER TABLE bangumi ADD COLUMN note TEXT DEFAULT ''",),
column_exists("bangumi", "note"),
)Tests
backend/src/test/conftest.py provides an in-memory database:
db_engine:sqlite+aiosqlite://withStaticPool. Each test gets a new one, with tables made from the models.db_session: anAsyncSessionbound todb_engine.
To test a repository directly, use Database(engine=db_engine), or make one repository from db_session:
from module.database import Database
from module.database.bangumi import BangumiDatabase
from test.factories import make_bangumi
async def test_add_bangumi(db_engine):
async with Database(engine=db_engine) as db:
assert await db.bangumi.add(make_bangumi())
async def test_search(db_session):
repo = BangumiDatabase(db_session)
assert await repo.search_all() == []The backend pytest config sets asyncio_mode = "auto", so async test functions need no extra decorator. To run: cd backend && uv run pytest src/test/test_xxx.py -v.