The Gateway persists all of its state in a single PostgreSQL database, accessed through Flask-SQLAlchemy. Twelve tables are defined as ORM models under src/models/; a parallel set of Isaac Sim objects (4 tables, 3 views, 2 functions, 3 triggers) exists only as raw SQL in one migration and has no ORM model. Schema is managed by Alembic (flask db upgrade) in production, with an APP_ENVIRONMENT=dev shortcut that calls db.create_all(). This page is the consolidated, column-level reference. For narrative context see:

Schema Overview

Table inventory, ownership, and per-table cascade rules explained in prose.

Migrations & Connection

DBConnector, the Alembic chain, pool settings, and dev-mode create_all().

Mission & Geofence Format

How MissionPoint/GeofencePoint map to MAVLink waypoint/fence items.

Isaac Sim Raw SQL

The raw-SQL Isaac Sim tables, views, functions and triggers in depth.
Column facts on this page come from the ORM models in src/models/ and are cross-checked against the Alembic migrations in migrations/versions/. Where the two diverge (e.g. a misplaced nullable=False), that divergence is flagged as a gotcha. Migration head at time of writing: r3m4n5o6p7q8.

Core ER diagram

Everything is owned by a user. Most child tables CASCADE on user delete; the exceptions (drone.mission_id, subscription, payment) are called out under Cascade & ownership.

Cascade & ownership model

Foreign-key ondelete behavior is not uniform — this is the single most important thing to internalize before writing a migration or a delete path.
drone.mission_id has no ON DELETE rule. Any code that deletes a mission must first run the equivalent of Drone.filter(mission_id == X).update(mission_id=None) and delete its mission_point rows, exactly as mission_service.delete() does. Adding ON DELETE SET NULL in a migration would let you drop that manual cleanup.

Identity & fleet

user — src/models/user.py

get_report_recipients() returns [email] + parsed extras, de-duplicated with the primary email kept first. See Executions & Reports.

drone — src/models/drone.py

user_drone_access — src/models/user_drone_access.py

Join table for sharing a drone with additional users. Unique constraint uq_user_drone_access(user_id, drone_id).

Missions & waypoints

mission — src/models/mission.py

Route-template header. Unique constraint uq_user_name(user_id, name).

mission_point — src/models/mission_point.py

An ordered waypoint. Carries both a semantic type (base/fly/safepoint) and the full MAVLink waypoint payload. Rows are read ordered by sequence. to_mavlink_waypoint() emits x_lat=lat, y_long=lng, z_alt=altitude plus frame/command/is_current/autocontinue/param1-4. DroneControlService.push_mission() (src/service/drone_control_service.py:784) loads points by sequence, converts each, and prepends a synthetic TAKEOFF (cmd 22) if the first command isn’t already 22, then pushes over rosbridge and verifies wp_transfered == len.
The CLAUDE.md “Mission Format” description (speed command 178, auto-generated reversed return path, RTL command 20 at the end) is not implemented in push_mission(). The backend uploads the stored waypoints as-is plus the TAKEOFF prepend. See Mission & Geofence MAVLink Format.

Flights & media

mission_execution — src/models/mission_execution.py

The realized-flight record. An arm/flight creates a row (status = in_progress, mission_id = NULL for a manual flight); after landing, LogAnalysisService parses the ArduPilot .bin (referenced by log_asset_id) and back-fills the ~40 metric columns. Indexed on user_id, drone_id, mission_id, started_at, status.
These metric columns were added across migrations d89dbd0532c0, 55cc964f0c63, n9i0j1k2l3m4, o0j1k2l3m4n5, and p1k2l3m4n5o6. See Executions, Log Analysis & Reports.

asset — src/models/asset.py

S3-backed media (video / image / logs). Uploaded during a flight, an asset carries execution_id; uploaded outside a tracked flight, it’s NULL. See S3 Assets, HLS Video & Execution Archives.

Geofences

geofence — src/models/geofence.py

Unique constraint uq_geofence_user_name(user_id, name). geofence_points cascade delete-orphan at the ORM level.

geofence_point — src/models/geofence_point.py

A fence vertex (polygon) or center (circle), stored as a MAVLink fence item. command is a MAVLink fence command; param1 is the polygon vertex count or the circle radius in meters. to_mavlink_fence_item() forces z_alt = 0.0 (fences are 2D). See Missions & Geofences API.

Calendar & scheduling

calendar_event — src/models/calendar_event.py

A scheduled (optionally recurring, RRULE) mission. Unique constraint uq_user_event_title_time(user_id, title, scheduled_time).

calendar_event_occurrence — src/models/calendar_event_occurrence.py

A sparse exceptions table: recurring occurrences default to scheduled, and only occurrences whose status deviates are persisted. Unique constraint uq_event_occurrence_time(event_id, occurrence_time). See Users, IP Allocation, Calendar & Isaac Sim.

Billing

subscription — src/models/subscription.py

One row per user (user_id UNIQUE). Pricing model is €120 / vehicle / year.

payment — src/models/subscription.py

Invoice/receipt history. amount is stored in cents. See Stripe Billing & Vehicle Limits.

Enum types

All enums are global PostgreSQL types. Two of them (event_status, subscription_status — the production type names created by migrations i4d5e6f7g8h9 and h3c4d5e6f7g8; db.create_all() would instead name them eventstatus/subscriptionstatus) declare values_callable so PostgreSQL stores the lowercase value; the rest store the enum name. For most enums the member name equals its value, so it makes no difference — except userlevel, whose type labels are the uppercase names ADMIN/CUSTOMER while the Python .value is lowercase admin/customer.
Dropping or recreating a table that uses one of these enums must manage the type lifecycle separately — a global PostgreSQL enum is not dropped with the table that references it.

Isaac Sim tables (raw SQL, no ORM)

Migration migrations/versions/e85b13d42b08_add_isaac_sim_tables.py creates 4 tables + 3 views + 2 PL/pgSQL functions + 3 triggers entirely as raw SQL. There is no SQLAlchemy model for any of it; src/service/instance_scheduler_service.py reads and writes these tables with a raw psycopg2 cursor (self.db.cursor(), commit(), rollback()), bypassing the ORM/session completely.
EC2 instance records. Indexes on user_id, drone_id, state, auto_shutdown_time, session_id, and created_at DESC. A trigger_update_isaac_instances_updated_at bumps updated_at via update_updated_at_column().
Historical per-session usage. Indexes on user_id, instance_id, session_start DESC.
Monthly rollup, one row per month (YYYY-MM). trigger_update_isaac_budget_percentage runs update_isaac_budget_percentage() BEFORE UPDATE, computing budget_percentage = total_spent / budget_limit * 100 and setting budget_exceeded = (percentage >= 100).
Key/value settings, seeded with 11 default rows (ami_id, security_group_id, subnet_id, instance_type=g6e.4xlarge, auto_shutdown_hours=2, max_spot_price=1.50, use_spot_instances=true, monthly_budget_limit=320, max_concurrent_instances=3, user_daily_limit_hours=4, user_monthly_limit_hours=20). A trigger_update_isaac_config_updated_at bumps updated_at.
Views (read-only, defined in the same migration):
db.create_all() (dev mode) creates only the ORM tables — none of the Isaac Sim tables, views, functions, or triggers. A dev database bootstrapped with create_all() is incomplete for Isaac Sim, and any ORM refactor of this subsystem must preserve/recreate the raw-SQL objects. See Isaac Sim Tables (Raw SQL).

Migration chain

Migrations live in migrations/versions/ and form a single linear chain from the root cd0db35fec77 (down_revision = None) to the current head r3m4n5o6p7q8. Revision IDs mix random hashes (cd0db35fec77, d741a9eb5162) with hand-authored sequential letters (i4d5e6f7g8h9 … r3m4n5o6p7q8) — and the interleaving is non-obvious (e.g. 55cc964f0c63.down = m8h9i0j1k2l3), so always trust down_revision, not the filename ordering.
migrations/env.py pulls target metadata from the live Flask app (current_app.extensions['migrate'].db) and suppresses empty autogenerate diffs. src/models/__init__.py is empty — models register on db.metadata only as a side effect of services importing them. A new model not imported by any loaded module is invisible to both flask db migrate autogenerate and create_all(). Connection settings (DB_USERNAME/PASSWORD/IP/NAME, pool config) live in Migrations, DB Connection & Dev Mode and Gateway Environment Variables.

Gotchas a future editor must preserve

In src/models/drone.py, type = db.Column(Enum(DroneType, nullable=False)) passes nullable=False to the Enum type, not the Column. The initial migration therefore created drone.type as nullable=True. vehicle_type has the same misplaced kwarg, but migration e63c68f1d55f added its column as NOT NULL — so in production vehicle_type is NOT NULL while type is nullable, and db.create_all() would make both nullable. Don’t assume NOT NULL on either without checking the migration.
The column is annotated TODO:FIXME: DEPRECATE! yet remains NOT NULL, UNIQUE. Every drone insert must supply a unique mac value. For SITL drones the service sets ip == mac == container_name.
In src/models/subscription.py, subscription.user_id, payment.user_id, and payment.subscription_id omit ondelete, so db.create_all() (dev) would make them RESTRICT. But production migration h3c4d5e6f7g8 builds them as CASCADE, CASCADE, and SET NULL respectively — so deleting a user in production cascade-deletes their subscription and payments, consistent with the rest of the user tree. Don’t assume RESTRICT here without checking the migration. Separately, drone.mission_id has no ondelete in either the model or the migration and genuinely needs manual cleanup on mission delete.
mission_execution, calendar_event(_occurrence), subscription.created_at/updated_at, and asset.created use tz-aware DateTime(timezone=True) with datetime.now(timezone.utc). But asset.modified, subscription.current_period_start/end, and the Isaac Sim DateTime columns are naive. Comparisons that mix naive and aware timestamps will raise or silently misbehave.
status = db.Column(Enum(AssetStatus), ... default=AssetStatus.pending.value) uses the string 'pending' rather than the member AssetStatus.pending. It works only because the member name equals its value; it’s inconsistent with every other enum default in the codebase.

HTTP & Socket.IO API

The endpoints that read and write these tables.

Environment Variables

DB_*, APP_ENVIRONMENT, and the rest of the config surface.

Redis & MAVLink Map

The non-SQL channels and the MAVLink port map.