Migrations
CoreAPI uses GORM's AutoMigrate to create and update database tables on startup. There are two migration paths depending on which database your tables live in.
Which path do I use?
| Database | Tables | How to migrate |
|---|---|---|
coreapi |
media, streamers, channels, devices... | RegisterModels + RunMigrationMain |
johorzoo |
orders, customers, reports... | SQL dump files in docker/postgres/ |
If you're building a new CoreAPI domain, you use RegisterModels. You never touch the johorzoo SQL files unless you're working on the ticketing system.
How it works — CoreAPI tables
On every startup, main.go runs this sequence:
db.RegisterModels(
&mediamodels.Media{},
&mediamodels.UploadToken{},
&distributionmodels.Streamer{},
)
db.Init() // connects to DB only
db.RunMigrationMain() // runs self-heal + AutoMigrate for every registered model
RegisterModels must be called before db.Init(). Order matters.
What you touch
Two files — your domain's models.go and main.go.
1. domain/your-domain/models.go
Define your struct with GORM tags. Always implement TableName().
package yourdomain
import "gorm.io/gorm"
type YourModel struct {
gorm.Model // adds ID (uint), CreatedAt, UpdatedAt, DeletedAt (soft delete)
Name string `gorm:"type:text;not null"`
Status string `gorm:"type:varchar(20);default:'active';index"`
// pointer = nullable column
Description *string `gorm:"type:text"`
Width *int `gorm:"type:int"`
}
func (YourModel) TableName() string { return "your_models" }
2. main.go
Register your models before db.Init():
db.RegisterModels(
&existingmodels.ExistingModel{},
&yourdomainmodels.YourModel{}, // ← add here
)
db.Init()
db.RunMigrationMain()
Import your domain root package with an alias:
import (
yourdomainmodels "mashup.castis.io/playtelly/CoreAPI/domain/your-domain"
)
gorm.Model vs manual Base vs UUID
// Option A — gorm.Model (recommended for most new domains)
// Gives you: ID (uint autoincrement), CreatedAt, UpdatedAt, DeletedAt (soft delete)
type Media struct {
gorm.Model
Title string `gorm:"type:text;not null"`
}
// Option B — manual Base struct (used in distribution, playback)
// Same as gorm.Model but defined explicitly — use when you want control
type Streamer struct {
Base // ID uint, CreatedAt, UpdatedAt, DeletedAt
Name string `gorm:"type:varchar(100);not null"`
}
// Option C — UUID primary key (used in platform models)
type Organization struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
CreatedAt time.Time
UpdatedAt time.Time
Name string `gorm:"type:varchar(255);not null"`
}
Nullable vs non-nullable
Use a pointer for nullable columns, a value type for non-nullable:
// Non-nullable — zero value stored if empty ("", 0, false)
Name string `gorm:"type:text;not null"`
Status string `gorm:"type:varchar(20);default:'active'"`
// Nullable — NULL stored if not set
Description *string `gorm:"type:text"`
StartDate *time.Time `gorm:"type:date"`
Width *int `gorm:"type:int"`
GORM tag reference
// Column types
`gorm:"type:text"`
`gorm:"type:varchar(255)"`
`gorm:"type:jsonb;default:'{}'"`
`gorm:"type:uuid;default:gen_random_uuid()"`
`gorm:"type:timestamptz"`
// Constraints
`gorm:"not null"`
`gorm:"default:'active'"`
`gorm:"default:true"`
`gorm:"check:status IN ('active','inactive')"`
// Indexes
`gorm:"index"`
`gorm:"uniqueIndex"`
`gorm:"uniqueIndex:idx_name"` // named composite unique index
What AutoMigrate does and doesn't do
Does: create tables that don't exist, add new columns, add new indexes.
Does not: drop columns you remove from the struct, rename columns, change column types.
For destructive changes (drop, rename), write a manual SQL migration and run it against the container directly — or, for a fast-iterating/disposable environment, see the hard-reset option below instead of hand SQL.
Hard-resetting a domain's tables
For an environment where the schema is still churning heavily and
existing rows aren't worth preserving across a deploy (e.g. staging,
pre-launch) — pkg/db has:
func HardResetTables(tables ...string) error
Drops the given tables (CASCADE, so anything FK'd to them goes too) if
they exist. Call it before RunMigrationMain() — the next
AutoMigrate then recreates each table fresh from whatever the Go
struct currently says, rather than trying (and sometimes failing) to
ALTER an existing one into the new shape.
Gate every call behind an env var that defaults off — this is destructive by design. The one live example (media domain):
// main.go, after db.Init(), before db.RunMigrationMain()
if os.Getenv("MEDIA_HARD_RESET") == "true" {
db.HardResetTables(
"media_slides", "media_playlist_items", "media_playlists",
"transcoding_profile_versions", "transcoding_profiles",
"upload_tokens", "media",
)
}
Scoped to exactly the table names passed in — no other domain is
touched. Follow the reset with your domain's own seed.Run call so the
now-empty tables get fresh seed data back immediately; see
pkg/seed for the seeding side of this, including
a caveat about its separate (unconditional, not gated) prune step.
Danger
Never set the gating env var to true anywhere the affected tables'
data needs to survive a deploy. This is a full wipe of every listed
table, every time the flag is on — there's no partial/selective mode.
Why migrations feel instant on a warm restart
RunMigrationMain() computes a checksum of every registered model's field
shape (name, type, and every raw GORM tag — not null, default,
check:, index, uniqueIndex, etc.) and compares it against one saved
in the schema_fingerprint table. If they match, the entire self-heal +
AutoMigrate pass is skipped outright — no information_schema queries
at all beyond the fingerprint check itself.
This matters a lot once you're not migrating against a local Postgres:
against a network-remote DB, self-heal + AutoMigrate together were
measured at ~85-111s per boot (see 2026-08-25 changelog
entry), because both run one round trip per
model, per field. A warm restart with no schema change now measures
~1.5s instead.
This means: if you change a model, the fingerprint changes automatically
— you never need to do anything to "invalidate" it. It's derived
entirely from the current Go struct definitions, so any real change
(new field, changed type, changed check:/default:/index tag) is
picked up the next boot and runs the full migration + self-heal pass as
normal. Verified directly for both a check: constraint edit and a new
field addition — both correctly triggered a full run, not a skip.
The check fails open: any error computing or reading the fingerprint (including the very first boot ever, no stored row yet) falls through to running the full migration — it never silently skips on uncertainty.
Set FORCE_SCHEMA_CHECK=true to bypass the fingerprint check and always
run the full migration, regardless of whether it matches — useful if
you don't trust the current fingerprint (e.g. someone altered the DB by
hand outside of CoreAPI).
RunMigrations() no longer exists
It used to be a second, separate migration path with its own
hardcoded model list in pkg/db/migrate.go, running before
RunMigrationMain(). Both functions ran identical self-heal +
AutoMigrate logic against the same connection, so the split was
pure duplication — every model now goes through RegisterModels in
main.go and migrates via RunMigrationMain() only. If you're
reading old code or an old PR that references RunMigrations(),
it predates this consolidation.