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.
FKondeleteConsequence
drone.user_id, mission.user_id, mission_point.user_id, mission_point.mission_id, asset.user_id, asset.drone_id, mission_execution.user_id, mission_execution.drone_id, geofence.user_id, geofence_point.*, calendar_event.user_id, calendar_event_occurrence.event_id, user_drone_access.*, isaac_sim_instances.user_id, isaac_sim_usage_tracking.*, subscription.user_id, payment.user_idCASCADEDeleting the parent removes children automatically.
mission_execution.mission_id, asset.execution_id, mission_execution.log_asset_id, calendar_event.drone_id, calendar_event.mission_id, calendar_event.execution_id, calendar_event_occurrence.execution_id, isaac_sim_instances.drone_id, payment.subscription_idSET NULLLink is nulled; the child survives (e.g. a manual flight has mission_id = NULL).
drone.mission_idnone (default RESTRICT)mission_service.delete() must manually null drone.mission_id and delete mission_point rows before deleting a mission, or the delete raises.
subscription.user_id, payment.user_id, payment.subscription_idCASCADE / CASCADE / SET NULL in migration h3c4d5e6f7g8Model-vs-migration divergence: the ORM models omit ondelete, so db.create_all() (dev) would make these RESTRICT, but the production migration builds them as CASCADE/CASCADE/SET NULL (rows above). In production, deleting a user cascade-deletes their subscription and payments — consistent with the rest of the user tree.
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

usersrc/models/user.py

ColumnTypeConstraints
idIntegerPK
usernameVARCHAR(80)UNIQUE, nullable (was NOT NULL in the initial migration, relaxed in the model)
passwordVARCHAR(255)NOT NULL — bcrypt hash
ipVARCHAR(100)UNIQUE, nullable — the user’s WireGuard VPN IP
is_activeBooleanNOT NULL, default false — gates login/service; also used to soft-ban
levelENUM userlevelnullable, default NULLADMIN / CUSTOMER
activation_tokenVARCHAR(10)UNIQUE, nullable
emailVARCHAR(100)UNIQUE, NOT NULL
report_extra_emailsTEXTnullable — comma-separated extra report recipients
get_report_recipients() returns [email] + parsed extras, de-duplicated with the primary email kept first. See Executions & Reports.

dronesrc/models/drone.py

ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL, indexed (ix_drone_user_id)
macVARCHAR(100)UNIQUE, NOT NULL — deprecated but still required (see gotcha)
nameVARCHAR(100)NOT NULL
ipVARCHAR(100)UNIQUE, NOT NULL, indexed (ix_drone_ip) — VPN IP (physical) or container host (SITL)
portIntegerNOT NULL — 9090 for physical, 9090+n for SITL
mission_idIntegerFK → mission.id, no ondelete, nullable — last-uploaded mission
typeENUM dronetypephysical / sitlDB-nullable (see gotcha)
vehicle_typeENUM dronevehicletyperover / copter, default rover, column NOT NULL in migrations
video_room_idIntegerUNIQUE — Janus room number
video_room_passwordVARCHAR(100)
video_room_tokenVARCHAR(300)
activation_tokenVARCHAR(10)UNIQUE, nullable

user_drone_accesssrc/models/user_drone_access.py

Join table for sharing a drone with additional users.
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL
drone_idIntegerFK → drone.id CASCADE, NOT NULL
Unique constraint uq_user_drone_access(user_id, drone_id).

Missions & waypoints

missionsrc/models/mission.py

Route-template header. Unique constraint uq_user_name(user_id, name).
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL
nameVARCHAR(255)NOT NULL

mission_pointsrc/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.
ColumnTypeConstraints
idIntegerPK
mission_idIntegerFK → mission.id CASCADE, NOT NULL
user_idIntegerFK → user.id CASCADE, NOT NULL
lat / lng / altitudeFloatNOT NULL
typeENUM mission_point_typesNOT NULL — base / fly / safepoint
labelVARCHAR(255)nullable
sequenceIntegerNOT NULL — ordering key
frameIntegerdefault 3 (GLOBAL_RELATIVE_ALT)
commandIntegerdefault 16 (WAYPOINT); 22=TAKEOFF, 21=LAND, 20=RTL
is_currentBooleandefault false
autocontinueBooleandefault true
param1param4Floatdefault 0.0
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_executionsrc/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.
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL, idx
drone_idIntegerFK → drone.id CASCADE, NOT NULL, idx
mission_idIntegerFK → mission.id SET NULL, nullable, idx (NULL = manual)
started_atDateTime(tz)NOT NULL, idx
ended_atDateTime(tz)nullable
duration_secondsIntegernullable
statusENUM executionstatusNOT NULL, default in_progress, idx
report_sent_atDateTime(tz)nullable
report_pendingBooleanNOT NULL, default false
archive_s3_keyVARCHAR(500)nullable — ZIP of all assets
archive_generated_atDateTime(tz)nullable
log_asset_idIntegerFK → asset.id SET NULL, nullable
notesTEXTnullable
createdDateTime(tz)NOT NULL, default now(utc)
modifiedDateTime(tz)NOT NULL, default/onupdate now(utc)
GroupColumns
Flight statslog_flight_time_seconds (Int), total_distance_meters, max_altitude_meters, avg_speed_ms, max_speed_ms (Float), log_analyzed_at (DateTime tz)
Batterybattery_start_voltage, battery_min_voltage, battery_end_voltage, battery_energy_wh, battery_remaining_pct (Float)
GPSgps_avg_satellites (Float), gps_min_satellites (Int), gps_avg_hdop (Float), gps_fix_type (Int), gps_fix_type_name (VARCHAR 20)
Efficiencyhover_throttle_pct (Float)
Vibrationvibe_x/y/z_avg, vibe_x/y/z_max (Float), clip_0, clip_1, clip_2 (Int)
Errorserror_count (Int, default 0), error_events (JSON)
Modesmode_change_count (Int, default 0), failsafe_count (Int, default 0), mode_changes (JSON)
Motorsmotor_balance_avg, motor_balance_max, motor_balance_min (JSON arrays), motor_count (Int, default 0)
These metric columns were added across migrations d89dbd0532c0, 55cc964f0c63, n9i0j1k2l3m4, o0j1k2l3m4n5, and p1k2l3m4n5o6. See Executions, Log Analysis & Reports.

assetsrc/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.
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL, idx (ix_asset_user_id)
drone_idIntegerFK → drone.id CASCADE, NOT NULL, idx (ix_asset_drone_id)
execution_idIntegerFK → mission_execution.id SET NULL, nullable, idx (ix_asset_execution_id)
file_nameVARCHAR(255)NOT NULL
s3_keyVARCHAR(1024)nullable
checksumVARCHAR(64)nullable, idx (ix_asset_checksum)
mime_typeVARCHAR(255)nullable
content_lengthIntegernullable, default 0
asset_typeENUM assettypevideo / image / logs
statusENUM assetstatusdefault 'pending' — see gotcha
upload_idVARCHAR(255)nullable — multipart upload id
createdDateTime(tz)default now
modifiedDateTime(naive)default/onupdate now — timezone inconsistency
See S3 Assets, HLS Video & Execution Archives.

Geofences

geofencesrc/models/geofence.py

Unique constraint uq_geofence_user_name(user_id, name). geofence_points cascade delete-orphan at the ORM level.
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL, idx (ix_geofence_user_id)
nameVARCHAR(255)NOT NULL
typeENUM geofence_typesNOT NULL — polygon / circle
fence_typeENUM fence_typesNOT NULL, default exclusioninclusion / exclusion
enabledBooleanNOT NULL, default true

geofence_pointsrc/models/geofence_point.py

A fence vertex (polygon) or center (circle), stored as a MAVLink fence item.
ColumnTypeConstraints
idIntegerPK
geofence_idIntegerFK → geofence.id CASCADE, NOT NULL, idx (ix_geofence_point_geofence_id)
user_idIntegerFK → user.id CASCADE, NOT NULL, idx (ix_geofence_point_user_id)
lat / lngFloatNOT NULL
sequenceIntegerNOT NULL
frameIntegerdefault 3
commandIntegerNOT NULL — MAVLink fence cmd (below)
param1param4Floatdefault 0.0
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).
CommandConstantMeaningparam1
5001POLYGON_VERTEX_INCLUSIONstay inside polygonvertex count
5002POLYGON_VERTEX_EXCLUSIONstay outside polygonvertex count
5003CIRCLE_INCLUSIONstay inside circleradius (m)
5004CIRCLE_EXCLUSIONstay outside circleradius (m)
See Missions & Geofences API.

Calendar & scheduling

calendar_eventsrc/models/calendar_event.py

A scheduled (optionally recurring, RRULE) mission. Unique constraint uq_user_event_title_time(user_id, title, scheduled_time).
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL, idx
titleVARCHAR(255)NOT NULL
descriptionTEXTnullable
scheduled_timeDateTime(tz)NOT NULL, idx
statusENUM event_statusNOT NULL, default scheduled, idx
drone_idIntegerFK → drone.id SET NULL, nullable, idx
mission_idIntegerFK → mission.id SET NULL, nullable
execution_idIntegerFK → mission_execution.id SET NULL, nullable
recurrence_ruleVARCHAR(255)nullable — iCal RRULE
created_at / updated_atDateTime(tz)NOT NULL, default/onupdate now(utc)

calendar_event_occurrencesrc/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).
ColumnTypeConstraints
idIntegerPK
event_idIntegerFK → calendar_event.id CASCADE, NOT NULL, idx
occurrence_timeDateTime(tz)NOT NULL, idx
statusENUM event_statusNOT NULL, default scheduled
execution_idIntegerFK → mission_execution.id SET NULL, nullable, idx
created_at / updated_atDateTime(tz)NOT NULL, default/onupdate now(utc)
See Users, IP Allocation, Calendar & Isaac Sim.

Billing

subscriptionsrc/models/subscription.py

One row per user (user_id UNIQUE). Pricing model is €120 / vehicle / year.
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id (CASCADE in migration h3c4d5e6f7g8; model omits ondelete, so create_all would yield RESTRICT), NOT NULL, UNIQUE
stripe_customer_idVARCHAR(255)UNIQUE, nullable
stripe_subscription_idVARCHAR(255)UNIQUE, nullable
statusENUM subscription_statusNOT NULL, default incomplete
vehicle_countIntegerNOT NULL, default 0
current_period_start / current_period_endDateTime (naive)nullable
created_at / updated_atDateTime(tz)NOT NULL
canceled_atDateTime(tz)nullable

paymentsrc/models/subscription.py

Invoice/receipt history. amount is stored in cents.
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK → user.id (CASCADE in migration h3c4d5e6f7g8; model omits ondelete, so create_all would yield RESTRICT), NOT NULL
subscription_idIntegerFK → subscription.id (SET NULL in migration h3c4d5e6f7g8; model omits ondelete, so create_all would yield RESTRICT), nullable
stripe_payment_intent_idVARCHAR(255)UNIQUE, nullable
stripe_invoice_idVARCHAR(255)UNIQUE, nullable
amountIntegerNOT NULL — cents (€120.00 → 12000)
currencyVARCHAR(3)NOT NULL, default eur
statusVARCHAR(50)NOT NULL — succeeded / failed / pending
descriptionVARCHAR(500)nullable
vehicle_countIntegerNOT NULL, default 1
created_atDateTime(tz)NOT NULL
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.
TypeLabels stored in PostgreSQLUsed by
userlevelADMIN, CUSTOMER (names)user.level
dronetypephysical, sitldrone.type
dronevehicletyperover, copterdrone.vehicle_type
mission_point_typesbase, fly, safepointmission_point.type
geofence_typespolygon, circlegeofence.type
fence_typesinclusion, exclusiongeofence.fence_type
executionstatusin_progress, completed, aborted, errormission_execution.status
assettypevideo, image, logsasset.asset_type
assetstatuspending, ready, errorasset.status
event_statusscheduled, completed, cancelled (values)calendar_event.status, calendar_event_occurrence.status
subscription_statusactive, canceled, past_due, incomplete, trialing, unpaid (values)subscription.status
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().
ColumnTypeNotes
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL
drone_idIntegerFK → drone.id SET NULL, nullable
instance_idVARCHAR(50)UNIQUE, NOT NULL — EC2 instance id
instance_typeVARCHAR(20)default g6e.4xlarge
public_ip / private_ip / availability_zoneVARCHAR(50)nullable
is_spotBooleandefault true
stateVARCHAR(20)NOT NULL, default launching
launch_time / stop_time / auto_shutdown_timeDateTimenullable
session_idVARCHAR(100)nullable
total_runtime_hours / estimated_costNUMERIC(10,2)default 0
ebs_snapshot_idVARCHAR(50)nullable
created_at / updated_atDateTimedefault CURRENT_TIMESTAMP
Historical per-session usage. Indexes on user_id, instance_id, session_start DESC.
ColumnTypeNotes
idIntegerPK
user_idIntegerFK → user.id CASCADE, NOT NULL
instance_idIntegerFK → isaac_sim_instances.id CASCADE, NOT NULL
session_startDateTimeNOT NULL
session_endDateTimenullable
duration_minutesIntegernullable
costNUMERIC(10,2)nullable
created_atDateTimedefault CURRENT_TIMESTAMP
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).
ColumnTypeNotes
idIntegerPK
monthVARCHAR(7)UNIQUE, NOT NULL — YYYY-MM
total_spent / total_hoursNUMERIC(10,2)default 0
session_countIntegerdefault 0
budget_limitNUMERIC(10,2)default 320.00 (USD)
budget_exceededBooleandefault false — set by trigger
budget_percentageNUMERIC(5,2)default 0 — set by trigger
updated_atDateTimedefault CURRENT_TIMESTAMP
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.
ColumnTypeNotes
idIntegerPK
config_keyVARCHAR(100)UNIQUE, NOT NULL
config_valueTEXTNOT NULL
descriptionTEXTnullable
updated_atDateTimedefault CURRENT_TIMESTAMP
Views (read-only, defined in the same migration):
ViewPurpose
active_isaac_instancesInstances in launching/running with computed runtime_hours and minutes_until_shutdown, joined to user/drone.
isaac_monthly_usage_summaryPer-user monthly session count, hours and cost from isaac_sim_usage_tracking.
isaac_budget_statusLast 12 months of budget with a healthy/warning/critical/exceeded status band.
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 (i4d5e6f7g8h9r3m4n5o6p7q8) — and the interleaving is non-obvious (e.g. 55cc964f0c63.down = m8h9i0j1k2l3), so always trust down_revision, not the filename ordering.
# Show the current head and full history
docker exec -it skyhub_gateway_service flask db heads      # -> r3m4n5o6p7q8
docker exec -it skyhub_gateway_service flask db history

# Apply / create migrations
docker exec -it skyhub_gateway_service flask db upgrade
docker exec -it skyhub_gateway_service flask db migrate -m "<message>"
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.