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: errorand 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 |