Data model¶
| Server | |
|---|---|
| Status | ✅ |
| Code | db/schema.rb (version 2026_10_27_000100), app/models/, app/services/track_file.rb, app/services/account_erasure.rb, app/jobs/{account_erasure,stale_device_cleanup,support_data_retention,trip_link_cleanup,unattached_blob_cleanup}_job.rb, config/recurring.yml |
Verified against code: 2026-10-26
Purpose¶
What the server stores about users, devices, trips and tracks, and what each column means. PostgreSQL with the pgcrypto extension. Positions are plain decimal latitude / longitude columns; PostGIS is not used. A track's points are not rows but one file per track in object storage (Track file, ADR 017). Most tables have UUID keys; the high-volume tables (track_events, track_segments) use bigint keys.
Relationships¶
users ─┬─< devices ─┬─< device_registrations
│ ├─< track_segments >─ tracks (nullable)
│ └─< tracks (nullable device)
├─< boats ──< trips (nullable boat)
├─< trips ─┬─< tracks ─┬── points_file (Active Storage: the track file)
│ │ ├─< track_events
│ │ └── track_map_lines (one)
│ ├─< trip_bridges (from_track, to_track)
│ ├─< trip_days
│ ├─< trip_links (public / crew)
│ ├── preview_image (Active Storage, while a link works)
│ └─< crew_memberships >─ users (the crew)
├─< passkeys, magic_tokens, connections
└─< beta_applicants, support_requests ──< replies (nullable user)
Deletion¶
| Deleted | Effect |
|---|---|
| User | Sailor bio, public profile images (avatar, header and their originals), picked profile trips, reserved handles, devices (with registrations), boats, trips (with their links and crew memberships), tracks (with their track files and events), segments, the user's own crew memberships on other trips, passkeys, magic tokens, connections, and the beta applications and support requests (with replies) linked by user_id are deleted (foreign keys cascade; has_many … dependent:). |
| Device | Its registrations and all its raw segments are deleted. Its tracks stay and lose the device link (device_id null). |
| Trip | Its tracks (with their track files, events and map lines), bridges, days, links, crew memberships and description are deleted; it disappears from the crew's logbooks. |
| Support request | Its replies are deleted (foreign key cascade). |
| Track | Points, events and the map line are deleted; segments stay and lose the link; bridges touching it are deleted. |
| Boat | Trips lose the boat. |
| Description editor | A trip's description_edited_by_id is set to null when that user's account is deleted; the description stays with its trip, shown as "Last edited |
Account erasure (AccountErasure)¶
Used by AccountErasureJob (account page and admin, auth.md, admin.md) and bin/rails "users:erase[EMAIL]". In one transaction:
user.destroy!with the chain above; the track files and preview images are purged from Active Storage after the commit.- Beta applications and support requests (with replies) whose email equals the account's email or pending new address, in any case: rows sent before the account existed have no
user_id. - One log line with the user id, the time and the counts of trips, tracks and devices; no email or name.
test/services/account_erasure_test.rb checks that afterwards no table with a user_id, email or unconfirmed_email column has a row of the user.
beta_applicants.user_id and support_requests.user_id are set when the row is saved and an account with that email exists, and on a beta invitation.
Cleanup jobs¶
Recurring jobs (config/recurring.yml, production):
| Job | When | Deletes |
|---|---|---|
StaleDeviceCleanupJob |
Daily 4:30 | Devices without a user (never paired) created more than 30 days ago, with their registrations |
SupportDataRetentionJob |
Daily 4:45 | Support requests (with replies) and beta applications created more than 12 months ago |
TripLinkCleanupJob |
Daily 5:00 | Public and crew links that ended (expired or revoked) more than 30 days ago (sharing.md) |
UnattachedBlobCleanupJob |
Daily 4:00 | Attachments whose record no longer exists (rows deleted in plain SQL or by a cascade), with their files; then uploaded files never attached, after a day |
Recording tables¶
tracks¶
One recording (SLTB) or one FIT file.
| Column | Meaning |
|---|---|
user_id, trip_id, device_id |
Owner; trip (nullable, always one of the owner's trips: validated); device (nullable, e.g. web import) |
uploaded_by_id |
Who uploaded the file in the web upload (the owner or the trip's skipper); null for watch tracks. Set to null when that account is deleted (on_delete: nullify). Lets the skipper delete their own uploads (sharing.md § Roles) |
source |
watch_sync (SLTB), direct_sync (API FIT upload), import (web upload), garmin_activity_api (unused) |
external_activity_id |
Web import: upload:<user_id>:<start unix>. Unique with source. |
trip_token |
Token the track arrived with |
device_name |
Model name at import, e.g. "fēnix 8 Pro 47mm" (default "Garmin Watch") |
sport |
Always sailing |
start_time, end_time |
Recording start (SLTB: track_id) and last kept point |
trim_start_at, trim_end_at |
Trim IN / OUT as timestamps; nullable; OUT must be after IN |
distance_nm, distance_source |
Distance and where it came from: device, computed, device_scaled |
engine_distance_nm, sailing_distance_nm, engine_hours, sailing_hours |
Split (ingestion.md) |
max_speed_knots, avg_speed_knots |
Speeds |
storm_warning |
A storm warning event exists |
start_name, end_name |
Departure and destination names |
start_name_inherited |
Start name came from the previous track or was generated |
start_name_manual, end_name_manual |
Typed by the user; never regenerated |
wind, wind_fetched_at |
JSON: the hourly model wind along the track as the wind endpoint returns it (times, cells with speed and direction), and when it was fetched; null until TrackWindJob ran (ingestion.md § Wind) |
state_notes |
JSON list [{"at": ISO, "note": "…"}]: the skipper's notes on the sail/engine changes from the track editor (at most 200 characters each); a note belongs to the change within 60 s of its time. Default []. |
sync_status |
SLTB only: receiving, complete, incomplete; null for FIT |
expected_segments |
SLTB: FINAL.seq + 1 |
device_totals |
JSON: the SLTB END record (reason saved / recovered, timer_s, distance_m, …) or {"reason":"fit","distance_m":…} |
processed_at, recompute_requested_at |
Last recompute; set when a trim asked for one (shows "recalculating" until processed_at is newer) |
is_private |
Default true; not used by the logbook |
Indexes: unique (device_id, start_time) where device_id is set; unique (source, external_activity_id) where set.
Track file¶
A track's points: one file per track in Active Storage (tracks.points_file, content type application/vnd.sailever.track, production bucket sailever-files), written on ingest and rewritten by every SLTB segment and every sail/engine edit (ADR 017, app/services/track_file.rb). Every source is on the 5 s grid (ingestion.md). The track editor reads the same file in the browser (app/javascript/track_view/track_file.js).
Format, gzip-compressed; after decompression little-endian:
| Part | Content |
|---|---|
| Header, 16 bytes | SVTP, u8 version 1, u8 interval 5 (s), u16 flags 0, u32 number of points n, u32 time of the first point (epoch s) |
i32[n] |
Seconds since the previous point (first: 0) |
i32[n] i32[n] |
Latitude and longitude in 1e-7°, as differences to the previous point (first: the value) |
u16[n] |
SOG in 0.01 kn (SLTB: derived by the server; FIT/GPX: from the file) |
u16[n] |
COG in 0.1° |
u16[n] |
Pressure in 0.1 hPa |
u8[n] |
Vessel state: 0 sail, 1 engine, 2 motorsail, 3 mooring, 4 anchor, 5 buoy, 6 shoreline, 7 adrift (vessel-state.md) |
u8[n] |
Engine on (1): state engine / motorsail, or the FIT engine field |
0xFFFF in a u16 column means no value. Points outside the trim stay in the file and are ignored by every calculation (Track#effective_points). Each read is cached per server process by the file's key; a changed file is a new file.
track_events¶
Derived, rebuilt with every recompute (ingestion.md).
| Column | Meaning |
|---|---|
kind |
vessel_state_change, engine_on, engine_off, storm_warning, anchor_drift_alert (unused) |
occurred_at, latitude, longitude, baro_pressure_hpa |
Where and when |
details |
JSON: from, to, location_name, island_name, feature_type, engine_on, drop_hpa, window_hours |
track_map_lines¶
Derived, rebuilt with every metrics computation (ingestion.md). Its own table, so lists that load tracks don't load their lines.
| Column | Meaning |
|---|---|
track_id |
Unique; deleted with the track |
points |
The simplified line, packed by MapLines::Codec: BER varints (version 1, number of lines, then per line: point count, engine of the first point, the engine switch indexes as steps, then latitude and longitude in 1e-5° as zigzag steps from the previous point). About 3 bytes per point. |
track_segments¶
Every uploaded SLTB segment. The raw payload is the archive of a watch track: the track can be rebuilt from it at any time.
| Column | Meaning |
|---|---|
device_id, user_id |
Uploader at upload time |
track_id |
Set by the ingest job; null before, after a discard, or when the track was deleted |
trip_token |
From the envelope (≤ 64) |
track_started_at, seq |
Identity with device_id (unique idx_segments_identity) |
final, format_version |
From the envelope (format_version always 1) |
payload, byte_size |
Raw SLTB bytes (header + body); emptied on discard |
point_count |
Decoded points |
device_model, app_version |
From the envelope |
status |
received, decoded, quarantined, discarded |
error, processed_at |
Quarantine reason; last processing |
Kept indefinitely (about 7 KB per day of sailing) until the device or the user is deleted.
Trip tables¶
trips¶
| Column | Meaning |
|---|---|
user_id, boat_id |
Owner; boat (nullable, always one of the owner's boats: validated) |
title |
Required. Generated as Sailing Trip <date> or from the upload dialog. |
trip_token |
Unique per user when set (idx_trips_user_token). Watch-generated (20 hex) or a UUID for web uploads. |
sailing_area |
Free text; the spelling the user already uses wins (case-insensitive) |
skipper |
The owner was skipper ("I was the skipper"); setting it clears a crew skipper (sharing.md § Roles) |
time_zone |
IANA zone for days; set from the first point |
description_edited_by_id, description_edited_at |
Who last changed the description text (trip.md) and when; the foreign key to users nulls when that account is deleted |
bridge_gaps |
Bridges between all tracks on / off |
total_distance_nm, engine_distance_nm, sailing_distance_nm, bridged_distance_nm, engine_hours, sailing_hours |
Sums (ingestion.md) |
import_notified_at |
When the new-trip email went out (emails.md) |
pending_uploads |
Files of a web upload still being imported |
map_line_card |
The card's map line, packed like track_map_lines.points, one line per track (ingestion.md). Null until the first recompute or the backfill. |
charter_company |
Ignored column (moved to boats) |
visibility |
Ignored column, dropped in the next migration: visibility levels are gone, trips are shared by link (sharing.md) |
trip_links¶
A link to a trip that works for 30 days (sharing.md).
| Column | Meaning |
|---|---|
trip_id |
Trip (cascade) |
kind |
public (the read-only page /t/<token>) or crew (joining at /crew/<token>) |
token |
22 base58 characters, unique; the only thing in the URL. Stored in clear so the owner can copy the link again. |
expires_at |
Creation + 30 days |
revoked_at |
Set by "Stop sharing" (public) or by a new crew link (crew) |
Indexes: token (unique), (trip_id, kind, expires_at), and index_trip_links_one_crew_link (unique trip_id where kind = 'crew' AND revoked_at IS NULL).
crew_memberships¶
trip_id (cascade), user_id (the crew member, cascade), role (crew or skipper, default crew, check constraint crew_memberships_role; sharing.md § Roles), created_at (joined). Unique (trip_id, user_id); at most one skipper per trip (index_crew_memberships_one_skipper, unique trip_id where role = 'skipper'); the owner can't be crew of their own trip.
trip_bridges¶
Derived (ingestion.md): from_track_id, to_track_id (unique pair), distance_nm, propulsion (unknown, engine, sail; always unknown today), duration_seconds (never set).
trip_days¶
Derived (ingestion.md), unique (trip_id, date).
| Column | Meaning |
|---|---|
date, day_number |
Local date; 1-based count of days with points |
started_at, ended_at |
First and last kept point of the day |
start_name, end_name |
Route of the day |
distance_nm, sailing_distance_nm, engine_distance_nm, bridged_distance_nm, sailing_hours, engine_hours |
Day totals |
night_distance_nm, night_hours |
Miles and underway hours in nautical night (ingestion.md); NULL until the day is rebuilt |
night_stay, night_stay_override |
anchor, buoy, mooring, shoreline, at_sea, unknown; the override is set by the user and survives rebuilds |
night_name, night_latitude, night_longitude |
Where the night was spent |
track_ids |
UUID array of tracks touching the day |
bounds |
[[south, west], [north, east]] |
profile_trips¶
user_id, trip_id (unique pair, both cascade), created_at: a trip the user picked for their public skipper profile. Removed when the user leaves the trip's crew.
profile_handle_reservations¶
handle (unique), user_id (cascade), released_at: a handle given up, reserved for its old owner until released_at (30 days, profile.md).
boats¶
name (required), boat_model, home_port, own_boat, charter_company (cleared for own boats), kind (sailing_yacht default, catamaran, motorboat), length_m (length overall, optional, 2–100, one decimal).
Device tables¶
devices¶
One installed watch app, unique (platform, device_uid). Registration and pairing: device-api.md.
| Column | Meaning |
|---|---|
platform |
garmin, apple_watch, wear_os |
device_uid |
Install identifier from the watch (≤ 128) |
device_model |
Raw model (Garmin part number, Build.MODEL) |
display_name, display_name_manual |
Name shown; typed by the user when manual |
app_version, last_registered_app_version |
Latest reported version |
user_id, paired_at |
Owner and pairing time; null until paired |
revoked_at |
Removed or unpaired |
token_hash, token_rotated_at |
SHA-256 of the device token (unique); when it was issued |
pairing_code_hash, poll_token_hash, pairing_expires_at |
Open pairing request |
last_seen_at |
Last registration or authenticated request |
A device is paired when user_id and paired_at are set and revoked_at is null.
device_registrations¶
One row per device and app version (unique (device_id, app_version)): platform, app_version, device_model, registered_at. Feeds the admin device statistics.
Other tables¶
| Table | Holds |
|---|---|
users |
Email (unique), unconfirmed_email, names, locale, webauthn_id, privacy_consent_version and privacy_consented_at (web consent, auth.md), terms_version and terms_accepted_at (auth.md), show_first_name_publicly (default false, sharing.md), admin (default false: may enter /admin, admin.md), public skipper profile: profile_handle (unique), profile_public, profile_indexable (both default false), profile_handle_changed_at, avatar_crop / header_crop ({x, y, w, h} fractions); attachments avatar, avatar_original, header_image, header_original (profile.md) |
passkeys, magic_tokens |
Sign-in (auth.md) |
connections |
Provider OAuth tokens (garmin, apple, android, huawei); unused |
action_text_rich_texts |
Rich text with uuid record_id: User#sailor_bio and Trip#description (name, HTML body). Unique per record and name; deleted with the record (has_rich_text). No attachments are ever stored. |
active_storage_* |
Track files (tracks.points_file), link preview images (trips.preview_image, sharing.md) and pending upload blobs |
support_requests, replies, beta_applicants, system_settings |
Admin and support (admin.md). support_requests and beta_applicants have a nullable user_id (cascade); beta_applicants.privacy_consented_at and terms_accepted_at are the times of the form's privacy and terms checkboxes |
deploys |
One row per server deploy for the admin changelog: version (the deployed commit, unique), deployed_at, entry_ids (ids from config/changelog.yml). No user data |
osm_places, osm_shores, osm_coastline, osm_imports |
OpenStreetMap places for the place names, no user data; built by each import, not in schema.rb or the backup (ingestion.md § OSM places) |
Archives¶
| Channel | Archive | Rebuild |
|---|---|---|
| SLTB | track_segments.payload |
Admin Reprocess per quarantined segment; the segment's time window in the track file is replaced |
Known deviations¶
- Deleting a device keeps its tracks but deletes their raw segments, so those tracks can no longer be rebuilt.