Skip to content

Seeding

How to populate the database with initial placeholder data that runs automatically on startup.


How it works

Seeds run once in main.go after the database is connected and migrations are complete:

// main.go
db.Init()
db.RunMigrationMain()
storage.Init()
seed.Run(db.GetDB())  // ← runs here, after tables exist

seed.Run in pkg/seed/00-seed.go calls every domain's seed function in order:

func Run(db *gorm.DB) {
    seedCaches(db)
    seedStreamers(db)
    seedMedia(db)
    seedRoles(db)
    seedOrganization(db)
    seedWorkspaces(db)
    // ...
}

Every seed function is idempotent — running it multiple times produces the same result. Records are only inserted if they don't already exist.


Adding seeds for your domain

Two files — a new seed file in pkg/seed/ and one line added to Run().

1. Create pkg/seed/your-domain.go

package seed

import (
    "gorm.io/gorm"
    yourdomain "mashup.castis.io/playtelly/CoreAPI/domain/your-domain"
)

func seedYourDomain(db *gorm.DB) {
    records := []yourdomain.YourModel{
        {
            Name:   "Default Record",
            Status: "active",
        },
    }

    for _, r := range records {
        upsert(db, "your_models", &r, "name") // conflict on "name" column
    }
}

2. Add the call to pkg/seed/00-seed.go

func Run(db *gorm.DB) {
    // existing seeds...
    seedYourDomain(db) // ← add here
}

That's it. Restart CoreAPI and your records are inserted on startup.


Handling duplicates — upsert

Seeds currently use the upsert helper defined in 00-seed.go:

func upsert(database *gorm.DB, table string, value interface{}, conflictCols ...string) {
    cols := make([]clause.Column, len(conflictCols))
    for i, c := range conflictCols {
        cols[i] = clause.Column{Name: c}
    }
    database.Clauses(clause.OnConflict{
        Columns:   cols,
        DoNothing: true,
    }).Create(value)
}

// usage:
upsert(db, "roles", &role, "id")
upsert(db, "streamers", &streamer, "name")

Behavior to be aware of:

  • If the record doesn't exist — it is inserted
  • If the record already exists (conflict on the specified column) — it is skipped silently, no update
  • If the insert fails for another reason — it logs [seed] table_name: error and continues

This means seed data is append-only at runtime. If you change a seeded record's fields, the change won't apply to an existing database — only fresh ones. For dev resets, run make base with -v to wipe volumes and reseed from scratch.


Shared helpers in 00-seed.go

Two things defined there that all seed files can use:

const OrgID = "373022138539442184" // shared org ID used across seed files

func ptr[T any](v T) *T { return &v } // generic pointer helper for nullable fields

Use ptr() when your model has pointer fields:

yourdomain.YourModel{
    Placement: ptr(1),       // *int
    StartDate: ptr(someTime), // *time.Time
}

Tips

Use env vars for environment-specific values — don't hardcode bucket names or URLs:

bucket := os.Getenv("MINIO_BUCKET")

Use fixed IDs for reference data — roles, organizations, and records that other seeds depend on should have predictable IDs so cross-references work regardless of insertion order.

Order matters in Run() — if your seed depends on another domain's records existing first (e.g. you reference OrgID), make sure that domain's seed runs before yours in Run().


Seed vs Migration

Migration Seed
Purpose Create/update table schema Insert initial data
File models.go + RegisterModels in main.go pkg/seed/your-domain.go
Runs Every startup Every startup (idempotent)
What it creates Columns, indexes, constraints Rows