Inspecting the Databases
Every app keeps its state in a SQLite file. This page is about finding those files, reading them, and pointing a client at them without fighting the running app.
Usually you want the grading state, not the database
If the question is "what will the task grader see?", skip SQLite entirely.
open_apps.state.get_current_state(url) fetches every app's /<app>_all
endpoint from a running server and returns one dict keyed by app name -- the
exact snapshot tasks diff against to score a run:
from open_apps.state import get_current_state
state = get_current_state("http://localhost:5001") # the port the launcher printed
state["openbanking"]["accounts"]
Or one app at a time: curl -s localhost:5001/openbanking_all | jq. Reach for
the database itself only when you need to edit rows, query across tables, or
look at a run after the server has stopped.
Two layers: the config seeds, the database serves
It is worth being precise about this, because the two disagree the moment anything writes to the database:
- Hydra config is the seed.
config/apps/openbanking/content/*.yamlholds the accounts and ledger rows. It is read exactly once, byset_environment, which inserts it into the tables. - SQLite is what the app serves. Every page render, and the
/openbanking_allreward state, reads out of theaccountandtransactiontables. Nothing is cached between requests.
So a balance shown on screen is the database's answer, not the config's. Edit a row in the database and the next page load reflects it; edit the config while the app is running and nothing happens until the app re-seeds.
TestBalancesComeFromSqlite in tests/test_openbanking.py pins this down — it
writes to the file through a separate connection and asserts the change appears
in the next request.
To change the seed rather than the live state, edit the content YAML (or use
openbanking-gen-txns, which appends ledger rows to the content files) and
restart, or re-seed in place — see Resetting below.
Where the files are
config/mode/local.yaml puts each run in its own timestamped directory:
logs_dir: ${hydra:runtime.cwd}/log_outputs/${now:%Y-%m-%d_%H-%M-%S}-${oc.env:USER}
databases_dir: ${logs_dir}/databases
So a run's databases land in
log_outputs/<timestamp>-<user>/databases/. The launcher prints the directory
on startup (logging to: ...), and the resolved config is written to
log_outputs/<timestamp>-<user>/config.yaml if you want to read the path back
out.
Nothing is shared between runs — each launch gets a fresh directory and re-seeds from scratch. To grab the most recent one:
DB=$(ls -td log_outputs/*/databases | head -1)/openbanking.db
| App | File |
|---|---|
| OpenBanking | openbanking.db |
| Calendar | calendar.db |
| Messenger | messenger.db |
| Todo | todo.db |
| Maps | maps.db |
| Code editor | codeeditor/ (a directory, not a database) |
| Onlineshop | onlineshop/ (a directory; only present when onlineshop is enabled) |
Each .db sits next to a -wal and a -shm file while the app is running.
That matters if you copy a database somewhere to poke at it: copy all three, or
you get a snapshot missing every write still sitting in the write-ahead log.
Copying just the .db of a stopped app is fine — the WAL is checkpointed back
into it on a clean shutdown.
Two schema quirks
- A credit card's
available_balanceis0.00by design. The figures that mean something on a card arepresent_balance,statement_balance, andavailable_credit— which is why the card summary renders a different set of fields from a deposit account. transactionis a SQL keyword, so it has to be quoted in every query:SELECT ... FROM "transaction".- In
transaction,amountis signed (negative is money out),positionis ledger order with 0 newest, and aNULLbalancemeans the posting is still pending.
Reading it from the command line
sqlite3 ships with macOS and most Linux distributions. Use -readonly — you
do not want to take a write lock on a database an app is serving from.
DB=$(ls -td log_outputs/*/databases | head -1)/openbanking.db
sqlite3 -readonly "$DB" ".tables"
sqlite3 -readonly "$DB" ".schema"
Balances across every account:
sqlite3 -readonly -box "$DB" \
"SELECT id, name, kind, available_balance, present_balance, statement_balance
FROM account ORDER BY id;"
One account's ledger, in the order the page shows it:
sqlite3 -readonly -box "$DB" \
'SELECT position, date, description, type, amount, balance
FROM "transaction" WHERE account_id = 0 ORDER BY position LIMIT 10;'
Connecting a client
Any SQLite client works — the file is a plain database with no extensions.
Point it at the run directory, not the repo root. The path is
log_outputs/<timestamp>-<user>/databases/openbanking.db, and it changes every
launch, so a saved connection goes stale as soon as you restart. If you are
iterating, symlink a stable path once and re-point it:
ln -sfn "$(ls -td log_outputs/*/databases | head -1)" ./current-dbs
then connect to ./current-dbs/openbanking.db.
Open read-only if the client offers it. The app holds the database open in WAL mode. Reads never conflict, but a client that starts an interactive write transaction and then sits on it will block the app's writes.
Any GUI that takes a file path works (DB Browser for SQLite, TablePlus,
DBeaver, the VS Code SQLite extensions); tick its read-only option. For
browsing in a browser without writing SQL, uvx datasette <path> runs Datasette
without adding a dependency -- pick a port other than the app's.
Do not use Python's sqlite3 module in the app's process
This one costs an afternoon if you hit it cold. fastlite talks to SQLite through
apsw, which links its own copy of the SQLite library; the stdlib sqlite3
module links the system one. Two different SQLite builds in one process, both
holding the same WAL database open, do not share the WAL index coherently — the
apsw side keeps serving a stale snapshot and gives no error. Symptom: your first
write shows up on the page, the second silently does not.
It only bites in-process. From a separate process — the sqlite3 CLI, a GUI
client, a standalone script — sqlite3 is completely fine, and every write is
picked up on the next request.
Inside the app's process (a test, a debugger session, an MCP handler), use
apsw instead, and drain every cursor, because apsw cursors are lazy:
import apsw
con = apsw.Connection(app.config.openbanking.database_path)
rows = list(con.execute("SELECT id, available_balance FROM account"))
list(con.execute("UPDATE account SET available_balance = 12345.67 WHERE id = 0"))
con.close()
Or just go through fastlite's own tables, which is what the app does:
from open_apps.apps.openbanking_app import main as ob
ob.accounts() # every account row
ob.txns_for(0) # one account's ledger, in display order
Resetting
To put every app back to its configured initial state without restarting, call
open_apps.apps.start_page.main.reset_all_apps(config.apps), or
AppServer.reset() if you are driving the environment over MCP. It drops each
app's tables and re-runs set_environment, so anything you edited by hand goes
away and the config seed comes back. There is no HTTP route for this.