Skip to content

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.