The Gateway persists all of its state in a single PostgreSQL database, accessed through Flask-SQLAlchemy. Every model inherits db.Model from the global db instance defined in src/connector/db_connection.py, and the schema is versioned by Alembic migrations under migrations/versions/. A second, parallel set of isaac_sim_* objects has no ORM model (so db.create_all() never creates them) and is defined entirely inside one migration — those are covered on their own page. This page is the map: the entity-relationship overview, a per-table column inventory, the foreign-key cascade matrix, and the handful of schema gotchas a future editor must not “fix” without understanding them.
This page describes structure. For how the schema is created and evolved (the DBConnector, the postgresql:// URI, the Alembic chain, and the APP_ENVIRONMENT=dev db.create_all() shortcut) see Migrations, DB Connection & Dev Mode. For the ORM→MAVLink translation of waypoints and fences see Mission & Geofence MAVLink Format. For the raw-SQL Isaac Sim tables see Isaac Sim Tables (Raw SQL, no ORM).

Entity-relationship overview

user is the root of nearly every ownership chain. Almost everything a user creates — drones, missions, waypoints, flights, media, geofences, calendar events — carries a user_id foreign key that cascades on user delete. The one deliberate exception is billing (subscription, payment), which uses the default RESTRICT so a user with financial history cannot be silently deleted. Note the two relationships between MISSION_EXECUTION and ASSET: an execution captures many assets (asset.execution_id), and an execution optionally points to one log asset (mission_execution.log_asset_id, the ArduPilot .bin).

Table inventory

Thirteen ORM-backed tables plus four raw-SQL Isaac Sim tables.
src/models/__init__.py is empty. Models register on db.metadata only as a side effect of being imported — the services (mission_service, drone_service, asset_service, …) import their models, and app.py imports all services. A new model that no loaded module imports will be invisible to both Alembic autogenerate and db.create_all(). Always import a new model somewhere in the startup path.

Column reference

user — src/models/user.pyget_report_recipients() returns the user’s email first, then the deduplicated extras — used by the report mailer.drone — src/models/drone.pyThe video_room_* fields are pushed to the drone over rosbridge — see Janus Video Rooms. SITL lifecycle populates ip/port — see SITL Drone Lifecycle.user_drone_access — src/models/user_drone_access.pyUnique constraint uq_user_drone_access(user_id, drone_id). This is the share-grant table; the User VPN writes iptables rules from it so only owning/granted users can reach a drone — see VPN IP Authentication & Jumphost Routing.
mission — src/models/mission.pyUnique constraint uq_user_name(user_id, name). One-to-many mission_points.mission_point — src/models/mission_point.pyEach row carries both a semantic type (used by the UI) and the full MAVLink command set. to_mavlink_waypoint() maps lat→x_lat, lng→y_long, altitude→z_alt. The upload-to-drone semantics (including the auto-prepended TAKEOFF) live in Mission & Geofence MAVLink Format and DroneControlService.
mission_execution — src/models/mission_execution.pyThe flight record. Created (status in_progress) when a drone arms/flies; mission_id is NULL for manual flights. After landing, LogAnalysisService parses the ArduPilot .bin (referenced by log_asset_id) and back-fills ~40 metric columns.Indexed on user_id, drone_id, mission_id, started_at, status. The metric semantics and reporting flow are documented in Executions, Log Analysis & Reports.asset — src/models/asset.pyAsset storage/HLS/archive mechanics are in S3 Assets, HLS Video & Execution Archives.
geofence — src/models/geofence.pyUnique constraint uq_geofence_user_name(user_id, name). Points cascade via ORM delete-orphan.geofence_point — src/models/geofence_point.pycommand encodes the fence type: 5001 POLY_INCLUSION, 5002 POLY_EXCLUSION, 5003 CIRCLE_INCLUSION, 5004 CIRCLE_EXCLUSION. param1 is the polygon vertex count or the circle radius (m). to_mavlink_fence_item() forces z_alt = 0.0 (2D fences). Full mapping in Mission & Geofence MAVLink Format.
calendar_event — src/models/calendar_event.pyUnique constraint uq_user_event_title_time(user_id, title, scheduled_time).calendar_event_occurrence — src/models/calendar_event_occurrence.pyA sparse exceptions table: a recurring event’s occurrences are virtual and default to scheduled; only occurrences whose status deviates get a row here.Unique constraint uq_event_occurrence_time(event_id, occurrence_time). Calendar behavior is documented in Users, IP Allocation, Calendar & Isaac Sim.
subscription — src/models/subscription.py (pricing: €120/vehicle/year)payment — src/models/subscription.pyBilling logic and vehicle-limit enforcement live in Stripe Billing & Vehicle Limits.

Cascade rules

Ownership tables cascade from user; report/asset links soften to SET NULL so history survives the deletion of the target; billing intentionally does not cascade.
drone.mission_id has no ON DELETE rule — deleting a mission that any drone still references would RESTRICT. mission_service.delete() (src/service/mission_service.py:83) compensates: it runs Drone.query.filter(Drone.mission_id == mission_id).update({"mission_id": None}) and deletes the mission’s points before deleting the Mission. Any refactor that changes cascade behavior must preserve this manual cleanup (or add ON DELETE SET NULL).

Schema gotchas

These are load-bearing quirks. Preserve them across refactors unless you deliberately migrate the data.
Drone.type is declared db.Column(Enum(DroneType, nullable=False)) (src/models/drone.py:50). The nullable=False is passed to the Enum type, not the Column — so at the DB level these columns are actually nullable. The initial migration confirms it: drone.type is created nullable=True (migrations/versions/cd0db35fec77_initial_migration.py:44). The same pattern affects Drone.vehicle_type and Asset.asset_type. Do not assume NOT NULL on these columns.
Enum columns are backed by global PostgreSQL types: dronetype, dronevehicletype, mission_point_types, geofence_types, fence_types, executionstatus, eventstatus, subscriptionstatus, assettype, assetstatus, userlevel.EventStatus and SubscriptionStatus are declared with values_callable=lambda x: [e.value for e in x], so PostgreSQL stores the lowercase value ("scheduled"). The others (DroneType, ExecutionStatus, AssetType, …) store the member name. This matters when you write raw SQL or add new members — check the declaration before assuming what string is on disk.
Asset.status is an Enum(AssetStatus) column but its default is AssetStatus.pending.value — the string "pending" — not the enum member (src/models/asset.py:52). It works only because the member name equals its value. Every other enum default in the codebase passes the member itself; keep the distinction in mind when copying the pattern.
Most audit timestamps are timezone-aware (DateTime(timezone=True) + datetime.now(timezone.utc)): mission_execution, calendar_event, subscription.created_at/updated_at. But several are naive:
  • asset.modified — plain db.DateTime with datetime.now (while asset.created is tz-aware).
  • subscription.current_period_start / current_period_end — naive db.DateTime.
Comparing or serializing these against tz-aware values can surprise you. Normalize explicitly.
subscription.user_id, payment.user_id, and payment.subscription_id have no ondelete, so they default to RESTRICT. Because most user-owned tables CASCADE, a naive DELETE FROM user succeeds for drones/missions/assets but fails if the user has a subscription or payment. Delete billing rows first, or add explicit cascade rules if that is the intent.
Drone.mac is annotated TODO:FIXME: DEPRECATE! - this is no longer used (src/models/drone.py:34) yet remains unique=True, nullable=False. Every drone insert must still supply a unique mac value — SITL creation, for example, sets ip == mac == container_name.
The isaac_sim_instances, isaac_sim_usage_tracking, isaac_sim_budget, and isaac_sim_config tables (built with op.create_table in migrations/versions/e85b13d42b08_add_isaac_sim_tables.py) — plus 3 views, 2 PL/pgSQL functions, and their triggers (raw op.execute SQL in the same migration) — have no ORM model. They are read/written with a raw psycopg2 cursor in src/service/instance_scheduler_service.py, bypassing the ORM entirely. The dev-only db.create_all() shortcut will not create them, so a dev database is incomplete for Isaac Sim. Full details on Isaac Sim Tables (Raw SQL, no ORM).
The Alembic chain is linear to a single head (r3m4n5o6p7q8), but revision IDs interleave random hashes (cd0db35fec77) with hand-authored sequential letters (i4d5e6f7g8h9 … r3m4n5o6p7q8), and down_revision values are not always alphabetical — trust down_revision, not filenames. The migration mechanics are on Migrations, DB Connection & Dev Mode.

Where to go next

Migrations & Connection

DBConnector, the postgresql:// URI, the Alembic chain, and the dev-mode create_all() shortcut.

Mission & Geofence MAVLink Format

How MissionPoint / GeofencePoint map to MAVLink waypoints and fence items.

Isaac Sim Tables (Raw SQL)

The four raw-SQL tables, views, functions and triggers with no ORM model.

Full Database Schema Reference

The platform-wide, cross-repo schema reference.