Database
PostgreSQL requirements, Alembic migrations, connection settings, and backup and restore.
SAT keeps all state in one PostgreSQL database: users and credentials, companies, agents, goals, projects, tasks, runs with their transcripts, approvals, routines and the audit log. The schema is managed by Alembic, and the API applies migrations itself when it starts.
Requirements
- PostgreSQL 16. Any PostgreSQL 16 works, managed (for example Cloud SQL for PostgreSQL) or self-run; the
cli-e2eCI job usespostgres:16. - PostgreSQL only. Migration
002changes constraints on existing tables, which SQLite cannot do without Alembic batch mode, so the API fails to initialise on SQLite. The pytest suite uses an in-memory SQLite database but builds tables withBase.metadata.create_all, not migrations. - Driver.
psycopg2-binary(synchronous SQLAlchemy 2).asyncpgis installed but not used. - Row locks. Run claims use
SELECT ... FOR UPDATE SKIP LOCKED, and task numbering locks the company row. Both rely on PostgreSQL semantics.
Connection settings
| Setting | Value | Where |
|---|---|---|
| URL | DATABASE_URL, for example postgresql://sat_api:<password>@10.x.x.x:5432/sat_main | A secret in production |
| Pool | NullPool: a new connection per request, closed when the request ends | database/connection.py |
| Session | autocommit=False, autoflush=False; one session per request through get_db | database/connection.py |
| SQL logging | DB_ECHO=true | Off by default |
| Network | Recommended: private IP only, for example over Direct VPC egress with --vpc-egress private-ranges-only on Cloud Run | Your deployment |
NullPool means each API request opens and closes a TCP connection to PostgreSQL. With one API instance and modest traffic this is acceptable, even on a small database instance; see Scaling before raising traffic. The SSE endpoint closes its session as soon as membership is checked, so an open event stream does not hold a database connection.
If your database has no public IP, which is recommended, you cannot connect to it from a laptop. Use your provider's export and import for data access, or a temporary job inside the private network.
Migrations at startup
On startup the API's lifespan handler calls init_db() (database/connection.py):
- If the database has a
userstable but noalembic_versiontable (it was created by the oldcreate_allcode), it is stamped at revision001first, so only newer migrations run. alembic upgrade headruns in-process.- If anything fails, the error is logged as
Database initialization failed, the API keeps serving, and/healthanswers503with"database": "initialization failed"until a revision starts cleanly. A deploy that gates on/healththerefore refuses a release whose migrations fail.
Every instance migrates
init_db() runs on every instance start, with no lock. With one instance that is fine. With several instances starting together, they race on the same alembic upgrade. An instance that loses the race records the error and reports unhealthy until it restarts. Run migrations as a separate step before scaling out. See Scaling.
Revisions
Revisions are in apps/api/alembic/versions, numbered and linear.
| Revision | File | What it does |
|---|---|---|
001 | 001_baseline.py | Baseline of the original schema: users, api_keys, refresh_tokens, projects, agents, sessions, session_logs, agent_tasks, deployments, system_metrics, system_health_checks. |
002 | 002_agent_company.py | The agent-company model: creates companies, company_members, goals, tasks, task_comments, runs, approvals, routines, audit_events with their indexes. Adds company_id, reports_to_id, title, role, adapter, instructions, monthly_budget_cents, heartbeat_cron, avatar_seed to agents and makes agents.project_id nullable. Adds company_id and monthly_budget_cents to projects. |
003 | 003_password_reset_tokens.py | Creates password_reset_tokens (SHA-256 token_hash unique, user_id with ON DELETE CASCADE, expires_at, used_at, requested_ip). |
004 | 004_runner_leases_api_keys.py | Adds runs.runner_id and runs.heartbeat_at for run claims and leases, and a unique index ix_api_keys_key_hash on api_keys.key_hash for API key lookup. |
Each revision has a downgrade(). Downgrades drop tables and columns, and with them their data.
Check the current revision:
cd apps/api
DATABASE_URL=postgresql://... .venv/bin/alembic current
DATABASE_URL=postgresql://... .venv/bin/alembic historyWriting a migration
Change the models
Edit apps/api/database/models.py or apps/api/database/company_models.py. models.py imports company_models, so every table is registered on Base.metadata wherever models are loaded.
Generate the revision against a local PostgreSQL at head
cd apps/api
export DATABASE_URL=postgresql://sat@127.0.0.1:5433/sat_main
.venv/bin/alembic upgrade head
.venv/bin/alembic revision --autogenerate -m "short description"Rename the file and set revision to the next number (005) and down_revision to the previous one, to keep the numbered sequence.
Review it by hand
Autogenerate misses some changes (server defaults, renames, data moves). Keep the migration additive: new tables, nullable columns or columns with a server_default, new indexes. Rolling back to the previous image does not restore the schema, so the previous release must still work against the new schema. Split destructive changes (drops, renames, NOT NULL on existing data) into a later release, after the code that needs them is live.
Test it
.venv/bin/alembic upgrade head
.venv/bin/alembic downgrade -1 && .venv/bin/alembic upgrade head
cd ../.. && SAT_API_URL=http://localhost:8080 scripts/03-testing/cli-smoke.shThe cli-e2e CI job starts the API against a fresh postgres:16, so a broken migration fails CI. Then regenerate the API client if response shapes changed (Development setup).
Backups
SAT has no backup job of its own; use your database's backups. Recommended:
- Automated daily backups, kept for at least 7 days.
- Point-in-time recovery, so you can restore to just before a bad change.
- An on-demand backup before any migration that changes or removes data.
- A restore tested on a scratch instance from time to time.
On Cloud SQL, for example:
gcloud sql instances describe <instance> --project <project> \
--format 'yaml(settings.backupConfiguration)'
gcloud sql instances patch <instance> --project <project> \
--backup-start-time 03:00 --enable-point-in-time-recovery --retained-backups-count 14
gcloud sql backups create --instance <instance> --project <project> --description "before release <sha>"With a self-run PostgreSQL, pg_dump and pg_restore do the same job:
pg_dump --format custom --file sat_main-$(date +%Y%m%d).dump "$DATABASE_URL"
pg_restore --clean --if-exists --dbname "$DATABASE_URL" sat_main-20261005.dumpExport and restore
On Cloud SQL, logical exports go through a Cloud Storage bucket. The instance's service account needs write access on the bucket to export and read access to import.
# Export the database to a SQL file
gcloud sql export sql <instance> gs://<bucket>/sat_main-$(date +%Y%m%d).sql \
--database sat_main --project <project>
# Import a SQL file into an instance
gcloud sql import sql <instance> gs://<bucket>/sat_main-20261005.sql \
--database sat_main --user <database user> --project <project>Restore a whole instance from an automated backup (this overwrites all data on the target):
gcloud sql backups list --instance <instance> --project <project>
gcloud sql backups restore <backup-id> --restore-instance <instance> \
--backup-instance <instance> --project <project>After a restore, restart the API (deploy the current revision again, or on Cloud Run gcloud run services update <service> --update-env-vars RESTORED_AT=<timestamp>) so it reconnects and re-runs init_db(). Runs that were running at backup time will be failed by lease expiry on the next claim or scheduler sweep.
Data growth
There is no retention or cleanup job. These tables only grow:
| Table | Grows with | Notes |
|---|---|---|
audit_events | Every change in every company | Append-only. Indexed on company_id and created_at. |
runs | Every run | transcript is a JSON array in the row; long runs make large rows. |
refresh_tokens | Every sign-in | Expired tokens are deleted for a user when that user signs in again. |
password_reset_tokens | Every reset request | Used and expired rows are kept. |