// pkgs/bigfred/server/domain/user.go
type Role string // "driver" | "signalman" | "admin"
type User struct {
ID uint
Login string // unique
PINHash string // bcrypt/argon2id over the PIN
Role Role // primary, permanent role
CreatedAt time.Time
UpdatedAt time.Time
TempRoles []TemporaryRole `ref:"id" fk:"user_id"`
DCCPool []DCCAddressRange `ref:"id" fk:"user_id"`
}
// Admin can grant a role for a limited time. When ExpiresAt < now() the grant
// is ignored by AuthService; a cleanup job removes expired rows.
type TemporaryRole struct {
ID uint
UserID uint
Role Role
GrantedBy uint // admin user ID
GrantedAt time.Time
ExpiresAt time.Time
}
// A contiguous DCC address range allocated to a user by the admin.
// Several rows per user are allowed (e.g. 100..199 and 3001..3010).
type DCCAddressRange struct {
ID uint
UserID uint
FromAddr uint16 // inclusive
ToAddr uint16 // inclusive
}
// Temporary API key minted by a user for themselves. Plaintext value
// is shown to the user EXACTLY ONCE at creation time and never stored
// in the database. KeyHash holds an argon2id (or sha256-hmac) hash of
// the secret part. KeyPrefix is the public, human-readable prefix
// ("rb_abc12345…") used to look the row up quickly without scanning
// every hash.
type APIKey struct {
ID uint
UserID uint // owner; the key inherits this user's roles & pool
Name string // user-friendly label (e.g. "home assistant")
KeyPrefix string // first 12 chars of the plaintext, indexed unique
KeyHash string // hash of the rest of the plaintext
Scopes string // CSV of scopes: "loco.read,loco.drive,radio.send"
CreatedAt time.Time
ExpiresAt time.Time // enforced: ExpiresAt - CreatedAt ≤ 365 days
LastUsedAt *time.Time
RevokedAt *time.Time
}
// pkgs/bigfred/server/domain/vehicle.go
// VehicleKind is the closed catalogue of physical vehicle classes
// that show up on a modeling layout. The values steer UI icons and
// filtering ("show me only locomotives") but are NOT used for DCC
// addressing – every kind may carry an optional DCC address.
type VehicleKind string
const (
VehicleKindLoco VehicleKind = "loco" // "Lokomotywa"
VehicleKindEMU VehicleKind = "emu" // "EZT" – elektryczny/diesel zespół trakcyjny
VehicleKindDrivingWagon VehicleKind = "driving_wagon" // "Wagon sterowniczy"
VehicleKindTrolley VehicleKind = "trolley" // "Drezyna"
VehicleKindWagon VehicleKind = "wagon" // "Wagon" (pasywny)
)
type Vehicle struct {
ID uint
// DCCAddress is OPTIONAL (pointer) per goal 4:
// * non-nil – the vehicle is steerable, the address must fall
// inside the owner's DCC pool, and (DCCAddress,
// command_station) is unique on the track;
// * nil – the vehicle is a DUMMY: still listed in the
// catalogue, still attachable to a train, still
// visible on the layout roster, but the throttle
// never sends DCC against it. Typical for unpowered
// wagons and visual fillers.
DCCAddress *uint16
OwnerUserID uint // when DCCAddress is set, the address must lie inside the owner's DCC pool
Name string
Kind VehicleKind // closed catalogue (loco | emu | driving_wagon | trolley | wagon)
Number string // optional free-text inventory / road number (e.g. "ET22-123", "92510")
// Function inheritance (§3a.6, goal 16). Three states:
// (nil, nil) – stand-alone, vehicle owns rows in `dcc_functions` (vehicle_id set)
// (T, nil) – LINKED to template T; list is virtual (read `dcc_functions` WHERE template_id = T)
// (T, ts) – DETACHED, copy-on-write at `ts`; vehicle owns its rows; T kept for lineage
TemplateID *uint
FunctionsDetachedAt *time.Time
// Per-vehicle dead-man's switch catalogue (§7e.5). Function
// numbers are F0..F31. Defaults: Rp1=F2, emergency lights=F0,
// option=stop.
Rp1Function uint8 // horn / Rp1 output
EmergencyLightsFunction uint8 // Pc6 / emergency lights
DeadManSwitchOption DeadManSwitchOption // stop | stop_horn | stop_horn_emergency_lights
CreatedAt time.Time
UpdatedAt time.Time
}
type DeadManSwitchOption string
const (
DeadManSwitchStop DeadManSwitchOption = "stop"
DeadManSwitchStopHorn DeadManSwitchOption = "stop_horn"
DeadManSwitchStopHornEmergencyLights DeadManSwitchOption = "stop_horn_emergency_lights"
)
// Train (Polish: skład) – an ordered group of 1+ Vehicles addressed and
// driven as a single unit. See the Terminology table.
type Train struct {
ID uint
OwnerUserID uint
Name string
CreatedAt time.Time
UpdatedAt time.Time
Members []TrainMember `ref:"id" fk:"train_id"`
}
type TrainMember struct {
ID uint
TrainID uint
VehicleID uint
Position int // ordering inside the train
Reversed bool // vehicle coupled the other way around
SpeedMultiplier float64 // scales non-leading members at train.setSpeed fan-out (default 1.0; leading forced to 1.0)
ExcludeFromSpeed bool // when true, train.setSpeed skips this member entirely
StartDelayMs int // consist-start hold-off before first speed write (0 or 50–1000 ms, step 50)
AccelRampMs int // acceleration ramp duration (0 or 500–5000 ms, step 500); applied by dcc-bus
AccelRampMaxSteps int // max steps for acceleration ramp (1–10)
BrakeRampMs int // braking ramp duration (0 or 500–5000 ms, step 500); applied by dcc-bus
BrakeRampMaxSteps int // max steps for braking ramp (1–10)
}
// pkgs/bigfred/server/domain/lease.go
// A vehicle or train can be leased to another user for DRIVING ONLY.
// Edit rights (CV writes, rename, delete, change train composition)
// always stay with the owner.
type VehicleLease struct {
ID uint
VehicleID uint
FromUserID uint // owner
ToUserID uint // lessee
StartedAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time // nil = active
}
type TrainLease struct {
ID uint
TrainID uint
FromUserID uint
ToUserID uint
StartedAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time
}
// pkgs/bigfred/server/domain/interlocking.go
// A signal box / interlocking. At most one active session per interlocking.
type Interlocking struct {
ID uint
Name string
Location string // free-text description
CreatedAt time.Time
}
// Enforced by a unique index: UNIQUE(interlocking_id) WHERE ended_at IS NULL.
type InterlockingSession struct {
ID uint
InterlockingID uint
SignalmanUserID uint
StartedAt time.Time
EndedAt *time.Time
}
// pkgs/bigfred/server/domain/takeover.go
// Request issued by a signalman wanting driving authority over a driver's
// vehicle or train. The driver has 15 seconds to reject; if they do not,
// the request is granted automatically (§4.3).
type TakeoverTarget string // "vehicle" | "train"
type TakeoverState string // "pending" | "granted" | "rejected" | "cancelled" | "expired" | "released"
// TakeoverWindow is the driver's reject window; TakeoverLeaseDuration is
// how long the signalman holds the target once granted (the self-lease).
const (
TakeoverWindow = 15 * time.Second
TakeoverLeaseDuration = 5 * time.Minute
)
type TakeoverRequest struct {
ID uint
SignalmanUserID uint
DriverUserID uint
Target TakeoverTarget
TargetID uint // vehicle.id or train.id
RequestedAt time.Time
DecisionAt *time.Time
AutoGrantAt time.Time // RequestedAt + TakeoverWindow
// On grant, the service creates a VehicleLease / TrainLease
// (FromUserID = owner, ToUserID = signalman, ExpiresAt = grant +
// TakeoverLeaseDuration) and stores its id here so release can revoke
// it. The driver's throttle session for the target is ended and the
// target hidden from their throttle picker until release (§4.3).
GrantedLeaseID *uint
ReleasedAt *time.Time
State TakeoverState
}
// pkgs/bigfred/server/domain/radio.go
// Walkie-talkie messages between signalmen and drivers use a closed
// vocabulary so that translations and UI buttons stay deterministic.
//
// IMPORTANT: RadioMessage is NOT a SQLite-persisted entity. Radio is
// operational chatter, not an audit trail: messages live ONLY in Redis
// with a default 4-hour TTL and are gone afterwards (§4.4.4). This type
// is therefore a value object serialised into Redis streams, never a
// REL-mapped table row. (Radio Stop, by contrast, IS audited — see
// AuditSessionEmergencyExecuted / `system.radio_stop`.)
type RadioPhrase string
const (
RadioStoppedAtSignal RadioPhrase = "STOPPED_AT_SIGNAL_READY_TO_ENTER"
RadioEntryPermitted RadioPhrase = "ENTRY_PERMITTED"
RadioCancelRoute RadioPhrase = "CANCEL_ROUTE"
RadioRouteSet RadioPhrase = "ROUTE_SET"
RadioAck RadioPhrase = "ACK"
RadioStopImmediately RadioPhrase = "STOP_IMMEDIATELY" // walkie-talkie phrase only; see §4.6 for layout-wide Radio Stop
RadioReadyToDepart RadioPhrase = "READY_TO_DEPART"
RadioDepartureCleared RadioPhrase = "DEPARTURE_CLEARED"
)
// RadioMessage is a single walkie-talkie message. Stored ONLY in Redis
// (default TTL 4h, §4.4.4); ID is a Redis stream / ULID id, not a SQL
// primary key.
//
// Target — exactly one of ToUserID / ToInterlockingID is set.
// Context — exactly one of ContextVehicleID / ContextTrainID is set; a
// message is always ABOUT a specific vehicle or train so the
// chat line can render "({fromLogin}) {context name}: {phrase}"
// and the signalman can correlate it with the roster panel
// (§4.4.1, §6.3d).
type RadioMessage struct {
ID string // Redis stream id / ULID (NOT a SQL id)
LayoutID uint
FromUserID uint
FromLogin string // denormalized for chat rendering & replay
ToUserID *uint // nil if directed at an interlocking
ToInterlockingID *uint // nil if directed at a user
// Context: exactly one of the two is non-nil (vehicle XOR train).
ContextVehicleID *uint
ContextTrainID *uint
ContextName string // denormalized vehicle/train name for the chat line
Phrase RadioPhrase
Note string // optional free-text, capped (e.g. 80 chars)
SentAt time.Time
}
// pkgs/bigfred/server/domain/command_station.go
type CommandStationConnectionType string
const (
CommandStationConnLoconetSerial CommandStationConnectionType = "loconet_serial" // physical socket
CommandStationConnZ21 CommandStationConnectionType = "z21" // Z21 over network
CommandStationConnLoconetTCP CommandStationConnectionType = "loconet_tcp" // LocoNet over Network
)
// Connection describes how the backend reaches the command station for
// this command station. Different connection types use different fields; the
// struct is intentionally flat so it serialises trivially to JSON in
// REST responses.
type CommandStationConnection struct {
Type CommandStationConnectionType
Device string // loconet_serial: e.g. "/dev/ttyUSB0"
Baudrate int // loconet_serial: e.g. 57600
Address string // z21 / loconet_tcp: host or IP
Port uint16 // z21 / loconet_tcp: TCP/UDP port
}
// CommandStation (Polish: centralka) – a physical model railway command station plus its
// command-station endpoint. Editable only by admin.
type CommandStation struct {
ID uint
Name string // unique
Connection CommandStationConnection // stored as JSON column in SQLite
CreatedAt time.Time
UpdatedAt time.Time
}
// pkgs/bigfred/server/domain/layout.go
// Layout (Polish: makieta) – a modeling event / room. The user picks a
// layout on the login form (§7a.1) and the resulting drive session is
// pinned to it for its entire lifetime.
//
// Two flags steer the lifecycle of a Layout row:
//
// - IsSystem: true for the bootstrap row only. The system layout
// cannot be deleted, cannot be locked, its Name and IsSystem fields
// are immutable, and its set of attached command stations is a
// virtual view of `command_stations` (admin endpoints that try to
// mutate it return 422). The system row is seeded with
// Name = "default"; the UI renders it via the i18n key
// `layout:system_default_label` ("Domyślna (warsztat)" / "Default
// (workshop)").
//
// - Locked: false for every layout right after creation; an admin
// may toggle it on a non-system layout via POST/DELETE on
// /api/v1/layouts/{id}/lock. A locked layout is hidden from the
// unauthenticated login dropdown (`GET /api/v1/layouts/login`) so no
// new sessions can open in it; existing drive sessions in that
// layout keep running until they close on their own. The system
// layout cannot be locked (DB CHECK + service rule).
//
// A Layout has **one or more attached command stations** (see
// LayoutCommandStation below). The driver picks one of those at the
// throttle via `session.setCommandStation` (§4.5). There is no longer
// a single nullable CommandStationID column on Layout itself.
type Layout struct {
ID uint
Name string // unique; system row is Name = "default" (immutable)
IsSystem bool // true ONLY for the system-seeded row; immutable
Locked bool // admin-toggleable on non-system layouts; always false for IsSystem rows
// AdminPINHash is the argon2id-hashed **layout admin PIN** that
// gates the sudo elevation flow (§7a.7). Plaintext PINs never
// leave the request handler – the field is set on Create, rotated
// by `PUT /api/v1/layouts/{id} { adminPin }` (admin-only;
// sudo-elevated admins are explicitly forbidden) and persisted as
// a hash with a per-row salt. The system layout's row is seeded
// with a one-shot random PIN at bootstrap time and printed once
// to the server log – an admin is expected to rotate it
// immediately on first login. The column is **NEVER NULL**: no
// layout can exist without a PIN, otherwise the sudo flow would
// be unreachable. See §7a.7 for the lifecycle.
AdminPINHash string
CreatedBy uint // admin user that created it (0 for the system seed)
CreatedAt time.Time
UpdatedAt time.Time
Signalmen []LayoutSignalman `ref:"id" fk:"layout_id"`
Interlockings []LayoutInterlocking `ref:"id" fk:"layout_id"`
Vehicles []LayoutVehicle `ref:"id" fk:"layout_id"`
CommandStations []LayoutCommandStation `ref:"id" fk:"layout_id"` // EMPTY for IsSystem rows: their set is virtual
}
// IsDefault returns true for the bootstrap system layout. Callers must
// use this helper (or the IsSystem field) – never compare Name against
// the string literal, because the displayed name comes from i18n while
// the stored Name is a stable system marker.
func (p Layout) IsDefault() bool { return p.IsSystem }
// LayoutCommandStation pins a CommandStation to a non-system layout.
// Rows exist ONLY for layouts with IsSystem == false:
//
// - the system layout's "set of command stations" is virtual: any
// CommandStation row in the catalogue is implicitly attached,
// including ones added after the system layout was seeded.
// - inserting a row for a system layout is rejected with
// `default_layout_command_stations_immutable` (DB CHECK on
// `layout_id != <system_layout_id>` + service validation).
//
// Admin is the only writer; both adding and removing are audited
// (`layout.command_station_attached` / `layout.command_station_detached`).
// Deleting a CommandStation cascades: every LayoutCommandStation row
// pointing at it disappears, and any drive session currently pinned to
// that command station is gracefully detached (CommandStationID → nil;
// throttle re-gated until the user re-picks). See §3a.3 invariants.
type LayoutCommandStation struct {
ID uint
LayoutID uint
CommandStationID uint
AddedByUserID uint // admin user ID
AddedAt time.Time
}
// LayoutSignalman grants the signalman role to UserID, but ONLY while
// they are active in LayoutID. The grant is administered by an admin
// and may optionally carry an ExpiresAt (otherwise it is permanent
// inside the layout). See §7a.2 for how this changes effective roles.
type LayoutSignalman struct {
ID uint
LayoutID uint
UserID uint
GrantedBy uint // admin user ID
GrantedAt time.Time
ExpiresAt *time.Time // nil = permanent inside this layout
}
// LayoutInterlocking whitelists which interlockings are visible to
// drivers (and which may be occupied) within a specific layout. Both
// the admin and any signalman of the layout may add rows; only admin
// may remove them.
type LayoutInterlocking struct {
ID uint
LayoutID uint
InterlockingID uint
AddedByUserID uint
AddedAt time.Time
}
// LayoutVehicle pins a registered Vehicle to a layout's operating roster.
// A vehicle must be registered globally before it can be added; only the
// vehicle owner may add or remove their row. The dashboard lists these
// rows so every participant in the layout sees which locos are "on the
// floor" for this session. Distinct from leasing: roster membership
// is visibility/participation, not a transfer of driving authority.
type LayoutVehicle struct {
ID uint
LayoutID uint
VehicleID uint
AddedByUserID uint // must equal vehicle.OwnerUserID at insert time
AddedAt time.Time
}
// pkgs/bigfred/server/domain/sudo.go
// EffectiveRoles is the flat result of
// `AuthService.Effective(ctx, user, layoutID)`. Permanent role,
// admin-issued temporary grants, layout-scoped signalman grants
// (admin-issued OR self-granted via the engineer's-cap icon) and
// the sudo admin elevation collapse onto the same set. A sudo
// admin grants the SAME authority as a permanent admin everywhere
// (§7a.7).
type EffectiveRoles struct {
// unexported set; constructed by NewEffectiveRoles.
}
func NewEffectiveRoles(roles ...Role) EffectiveRoles
func (EffectiveRoles) Has(Role) bool
// SudoElevation is the short-lived, layout-scoped admin self-grant
// produced by the sudo flow (§7a.7). The signalman icon next to the
// padlock writes a permanent `LayoutSignalman` row instead, so this
// type is admin-only.
//
// Invariants (DB + service):
// - exactly one ACTIVE row per (UserID, LayoutID), enforced by a
// UNIQUE index. The "renew the timer" path is a single upsert
// that pushes ExpiresAt forward;
// - ExpiresAt - GrantedAt MUST equal the configured sudo TTL
// (default 2 minutes; bounds [1m, 10m] enforced by the server
// config – not by a per-row CHECK, because an operator may tune
// the TTL between deployments and existing rows must remain
// valid);
// - the row is created ONLY by `SudoService.Sudo` after a
// successful PIN verification against `Layout.AdminPINHash`.
// There is no admin-side "grant sudo to user X" path – sudo is
// always a self-grant.
type SudoElevation struct {
ID uint
UserID uint
LayoutID uint
GrantedAt time.Time
ExpiresAt time.Time
}
// pkgs/bigfred/server/domain/audit.go
// AuditAction is a closed vocabulary of audit event types. Adding a new
// audited event requires adding it here AND wiring AuditService.Log in
// the matching service. Keeping the vocabulary closed makes the audit
// surface trivially diff-reviewable.
type AuditAction string
const (
AuditVehicleCreated AuditAction = "vehicle.created"
AuditVehicleUpdated AuditAction = "vehicle.updated"
AuditVehicleDeleted AuditAction = "vehicle.deleted"
AuditVehicleLeased AuditAction = "vehicle.leased"
AuditVehicleLeaseRevoked AuditAction = "vehicle.lease_revoked"
AuditVehicleLeaseExpired AuditAction = "vehicle.lease_expired"
AuditTrainCreated AuditAction = "train.created"
AuditTrainUpdated AuditAction = "train.updated"
AuditTrainDeleted AuditAction = "train.deleted"
AuditTrainLeased AuditAction = "train.leased"
AuditTrainLeaseRevoked AuditAction = "train.lease_revoked"
AuditTrainLeaseExpired AuditAction = "train.lease_expired"
AuditCommandStationCreated AuditAction = "command_station.created"
AuditCommandStationUpdated AuditAction = "command_station.updated"
AuditCommandStationDeleted AuditAction = "command_station.deleted"
AuditLayoutCreated AuditAction = "layout.created"
AuditLayoutUpdated AuditAction = "layout.updated"
AuditLayoutDeleted AuditAction = "layout.deleted"
AuditLayoutLocked AuditAction = "layout.locked"
AuditLayoutUnlocked AuditAction = "layout.unlocked"
AuditLayoutCommandStationAttached AuditAction = "layout.command_station_attached"
AuditLayoutCommandStationDetached AuditAction = "layout.command_station_detached"
// Layout admin PIN was rotated through the layout settings
// page. Any effective admin (permanent or sudo) may rotate it.
// Metadata = {previous_hash_prefix} so the audit row never
// carries plaintext or full hash. See §7a.7.
AuditLayoutAdminPINChanged AuditAction = "layout.admin_pin_changed"
// Sudo elevation lifecycle (§7a.7). The actor is the user who
// typed the layout PIN; ObjectType = "layout", ObjectID =
// LayoutID, ObjectName = layout.Name at the time of the event.
// Metadata for `auth.sudo_granted` and `auth.sudo_expired` is
// `{ target, expiresAt }`; for `auth.sudo_revoked` it carries
// `{ target, reason: "user_action"|"logout"|"layout_deleted" }`.
AuditAuthSudoGranted AuditAction = "auth.sudo_granted"
AuditAuthSudoRevoked AuditAction = "auth.sudo_revoked"
AuditAuthSudoExpired AuditAction = "auth.sudo_expired"
// A failed PIN attempt that triggered the rate-limit soft lock.
// ObjectType = "layout"; Metadata = { target, attempts, lockedUntil }.
AuditAuthSudoLocked AuditAction = "auth.sudo_locked"
// Vehicle function definitions (registration / detach / re-attach).
// Runtime invocation (DCC F<n> ON/OFF) is NOT audited.
AuditVehicleFunctionsUpdated AuditAction = "vehicle.functions_updated"
AuditVehicleFunctionsDetached AuditAction = "vehicle.functions_detached"
AuditVehicleFunctionsAttached AuditAction = "vehicle.functions_attached"
AuditTemplateCreated AuditAction = "template.created"
AuditTemplateUpdated AuditAction = "template.updated"
AuditTemplateDeleted AuditAction = "template.deleted"
// Scripts (§3a.7). The audit row stores metadata only; the
// JavaScript source body is NEVER copied into Metadata so that
// deleting a script truly removes its source from the system.
AuditScriptCreated AuditAction = "script.created"
AuditScriptUpdated AuditAction = "script.updated"
AuditScriptDeleted AuditAction = "script.deleted"
AuditScriptAttached AuditAction = "script.attached"
AuditScriptDetached AuditAction = "script.detached"
// "Driver fell asleep" – the dead-man's switch fired and the user's
// emergency plan was executed (§4.5).
AuditSessionEmergencyExecuted AuditAction = "session.emergency_executed"
)
// AuditLogEntry is the canonical row of the audit log. All six fields
// the spec requires (§ goal 14) are first-class. Object name and actor
// login are DENORMALIZED at write time so that later renames or
// deletions cannot rewrite history.
type AuditLogEntry struct {
ID uint
Action AuditAction
ActorUserID uint // the user that triggered the action ("user ID")
ActorLogin string // user.login at the moment of the event ("user name")
OccurredAt time.Time // UTC, ms precision ("date")
ObjectType string // "vehicle" | "train" | "command_station" | "layout" | "session"
ObjectID uint // ("object ID")
ObjectName string // e.g. vehicle.name at write time ("object name")
// Optional structured details for richer UIs. The audit log stays
// readable without it; it is purely informational.
LayoutID *uint // where the action happened, if applicable
Metadata string // JSON-encoded; e.g. for lease: {to_user_id, to_login, expires_at}
}
// pkgs/bigfred/server/domain/function.go
// FunctionIcon is a CLOSED catalogue (67 values). The authoritative slug
// list with Polish labels lives in §3a.8 (08-function-icon-catalogue.md).
// The frontend ships one SVG per slug; Tygo re-generates the TS union.
type FunctionIcon string
const (
IconUnspecified FunctionIcon = "unspecified"
IconLight FunctionIcon = "light"
IconEngine FunctionIcon = "engine"
// …remaining members mirror §3a.8 exactly…
)
// DccFunction is one F0–F31 slot stored in the unified `dcc_functions`
// table. Exactly one of VehicleID or TemplateID is non-nil on every row
// (enforced by DB CHECK). Num is constrained 0..31.
//
// Template rows: TemplateID != nil, VehicleID == nil
// Vehicle rows: VehicleID != nil, TemplateID == nil
//
// For a LINKED vehicle (§3a.6) there are no vehicle rows yet; the
// effective list is read from rows WHERE template_id = vehicle.TemplateID.
// After detach, vehicle rows exist and template rows are unchanged.
type DccFunction struct {
ID uint
VehicleID *uint // set on vehicle-owned rows; NULL on template rows
TemplateID *uint // set on template-owned rows; NULL on vehicle rows
Num uint8 // 0..31 inclusive
Name string
Icon FunctionIcon
Position int // ordering inside the throttle UI grid
CreatedAt time.Time
UpdatedAt time.Time
}
// pkgs/bigfred/server/domain/template.go
// VehicleTemplate – a reusable definition of a function list for a
// class of vehicles. Owner (or admin) may edit; any user may use a
// template to seed a new vehicle (goal 16).
type VehicleTemplate struct {
ID uint
Name string // unique; user-facing
Description string
OwnerUserID uint
Version int // monotonic; bumped on every mutation of either
// the template itself or any DccFunction row with
// template_id = this template. Snapshots stored on
// Vehicle for diff detection.
CreatedAt time.Time
UpdatedAt time.Time
Functions []DccFunction `ref:"template_id" fk:"template_id"` // rows WHERE template_id = id
}
// pkgs/bigfred/server/domain/script.go
// ScriptRuntime names the embedded interpreter used to execute the
// script source. Today only Goja (pure-Go ECMAScript 5.1+) is wired
// up. The enum is kept open so future runtimes (e.g. a sandboxed
// Lua) can be added without an `omitempty`-style data migration.
type ScriptRuntime string
const ScriptRuntimeGoja ScriptRuntime = "goja" // github.com/dop251/goja
// Script – a piece of JavaScript source authored by a user and
// executed SERVER-SIDE inside a sandboxed Goja VM in the sibling
// scripts-executor process. Stored as plain text; the embedded
// runtime calls back through the server's services for every DSL
// operation (findFirstLoco, findByDCCAddr, setSpeed, funcOn/Off,
// sleep, …), so every action is authorized exactly like a manual
// throttle press.
//
// Ownership and edit rules:
// - OwnerUserID is the only user who can edit Source / Name / Icon
// / Runtime. The owner may, however, lease a vehicle that has
// this script attached – the lessee will see and may RUN the
// script but cannot view or modify its source.
// - Icon is reused from the function-icon catalogue (FunctionIcon)
// so the throttle UI can render scripts as additional buttons
// alongside F0..F31 without a second icon set.
type Script struct {
ID uint
OwnerUserID uint
Name string // user-facing; unique per owner
Description string
Source string // JavaScript source code; size capped (64 KiB)
Runtime ScriptRuntime // ScriptRuntimeGoja
Icon FunctionIcon // same closed catalogue as DccFunction.Icon
Version int // monotonic; bumped on every Source/metadata edit.
// Currently only used to invalidate the editor's
// optimistic cache; server-side execution always
// loads the latest source at run.start time.
DeadlineSec int // hard wall-clock cap for a single run (default 60,
// max 600). After this time the executor calls
// vm.Interrupt("timeout") regardless of state.
CreatedAt time.Time
UpdatedAt time.Time
Attachments []ScriptAttachment `ref:"id" fk:"script_id"`
}
// ScriptAttachment binds a Script to exactly one Vehicle XOR one
// Train. The attachment, not the Script itself, carries the
// per-throttle metadata (position on the button row).
//
// Invariants enforced by service + DB:
// - exactly one of VehicleID / TrainID is set (CHECK constraint);
// - a Script may be attached MULTIPLE times (e.g. the same "yard
// shunt" script can be wired to several locos), but a given
// (Script, Vehicle) or (Script, Train) pair is UNIQUE so the
// button does not show up twice on one throttle.
type ScriptAttachment struct {
ID uint
ScriptID uint
VehicleID *uint // exactly one of VehicleID / TrainID is set
TrainID *uint
Position int // sort order in the throttle UI
CreatedAt time.Time
}