The project uses GORM (gorm.io/gorm) with PostgreSQL in production and SQLite for tests.
All models are in pkg/models/ and embed gorm.Model (provides ID, CreatedAt, UpdatedAt, DeletedAt):
| Model | Table | Purpose |
|---|---|---|
Quickstart |
quickstarts |
Learning resource content (JSON blob) |
HelpTopic |
help_topics |
Help panel content |
Tag |
tags |
Tag categories (many-to-many with quickstarts and help topics) |
FavoriteQuickstart |
favorite_quickstarts |
User favorites (by account ID + quickstart name) |
QuickstartProgress |
quickstart_progresses |
User progress tracking |
Tags use many-to-many associations:
type Tag struct {
gorm.Model
Type TagType
Value string
Quickstarts []Quickstart `gorm:"many2many:quickstart_tags"`
HelpTopics []HelpTopic `gorm:"many2many:help_topic_tags"`
}pkg/database/db.go handles connection setup:
- PostgreSQL in production (via Clowder config)
- SQLite for tests (
cfg.Test = true) - Creates tables if they don't exist
- Enables
fuzzystrmatchextension on PostgreSQL
The global DB variable holds the connection. In seeding functions, always use the transaction handle (tx) instead of DB.
Content seeding (pkg/database/db_seed.go) runs during migration:
SeedTags()— entry point, wraps everything in a transactionclearOldContent(tx)— hard-deletes all quickstarts, help topics, tags, favoritesseedDefaultTags(tx)— creates default tag entries per tag type- For each YAML template in
docs/:seedQuickstart(tx, ...)orseedHelpTopic(tx, ...)- Create/find tags and associate with content
seedFavorites(tx, ...)— restore previously saved favorites
Seeding uses a PostgreSQL advisory lock to serialize concurrent pod startups:
const seedAdvisoryLockID = 42
tx.Exec("SELECT pg_advisory_xact_lock(?)", seedAdvisoryLockID)The lock is transaction-scoped and auto-releases on commit/rollback. Skipped on non-PostgreSQL (SQLite tests).
Quickstart metadata files follow this structure:
docs/quickstarts/<name>/metadata.yaml
docs/quickstarts/<name>/<name>.yaml (content)
The findTags() function scans docs/ for metadata.yaml files and parses them into templates.
Schema migrations use GORM's AutoMigrate:
DB.AutoMigrate(
&models.Quickstart{},
&models.QuickstartProgress{},
&models.Tag{},
&models.HelpTopic{},
&models.FavoriteQuickstart{},
)This runs in the quickstarts-migrate binary before each pod starts.
Services use GORM's query builder:
// Basic query with preloading
db.Preload("Tags").Find(&quickstarts)
// Filtering with conditions
db.Where("name = ?", name).First(&quickstart)
// Pagination
db.Limit(limit).Offset(offset).Find(&quickstarts)
// Association queries for tags
db.Model(&tag).Association("Quickstarts").Find(&quickstarts)Models have DeletedAt (soft delete), but seeding uses Unscoped().Delete() (hard delete) to fully remove old content before re-inserting.
All database operations inside transactions must check errors and return them to trigger rollback:
if err := tx.Create(&record).Error; err != nil {
return err // triggers transaction rollback
}Prefer returning errors over logging and continuing — partial commits inside transactions create inconsistent state.