Database Migrations¶
Den handles schema management automatically based on your document struct definitions. There are no hand-written SQL migration files. For framework-version upgrades (e.g. v0.18 → v0.20) see the Migration section instead.
How It Works¶
- Each app declares its document types by implementing
HasDocuments - At startup, the framework calls
den.Register()for every document type returned by each app - Den inspects the struct tags (
den:"index",den:"unique",den:"fts") and creates or updates the underlying collections and indexes automatically - Schema changes are applied idempotently — adding new fields or indexes is safe
Implementing HasDocuments¶
Important
Return pointers to zero-value instances of each document type. Den uses these to introspect the struct and set up the collection schema.
Document Definitions¶
Den derives the collection name from the struct name (lowercased, no pluralization). Override with CollectionName in DenSettings():
type Note struct {
document.Base
UserID string `json:"user_id"`
Title string `json:"title" den:"index"`
Content string `json:"content" den:"fts"`
Status string `json:"status" den:"index"`
}
This creates a note collection with indexes on title and status, and a full-text search index on content.
Schema Evolution¶
Den handles additive schema changes automatically:
- New fields — added to existing documents as they are saved
- New indexes — created on the next startup
- New FTS indexes — created on the next startup, backfilled with existing rows
Removing fields or indexes requires no special handling — unused indexes are simply ignored.
Data Migrations¶
Schema changes (new fields, new indexes) are handled automatically. But sometimes you need to seed initial data, transform existing data — rename a field, split a value, backfill a computed field. For these cases, apps implement the HasMigrations interface and the server applies them at boot via Den's migrate package.
Implementing HasMigrations¶
Return one or more burrow.NamedMigration values from Migrations(). The framework registers them under {app.Name()}/{version} (so two contribs can both ship "001_initial" without colliding) and runs migrate.Up once after every app's Configure() has completed.
import (
"github.com/oliverandrich/burrow"
"github.com/oliverandrich/den"
"github.com/oliverandrich/den/migrate"
"github.com/oliverandrich/den/where"
)
// Implements [burrow.HasMigrations].
func (a *App) Migrations() []burrow.NamedMigration {
return []burrow.NamedMigration{{
Version: "001_backfill_slug",
Migration: migrate.Migration{
Forward: func(ctx context.Context, tx *den.Tx) error {
notes, err := den.NewQuery[Note](tx,
where.Field("slug").Eq(""),
).All(ctx)
if err != nil {
return err
}
for _, note := range notes {
note.Slug = slugify(note.Title)
if err := den.Save(ctx, tx, note); err != nil {
return err
}
}
return nil
},
},
}}
}
Forward functions use Den primitives (den.Save, den.Delete, den.NewQuery) directly with the transaction. Your app's Repository holds a *den.DB for the normal request path, but Forward receives a *den.Tx — calling a.repo.Save(...) would write outside the migration's transaction. The Den helpers polymorphically accept either type via an internal Scope interface, so passing the *den.Tx straight in is the correct path. Conceptually it also fits: migrations are setup code, not application code, and operating at the framework-persistence layer keeps the transaction boundary explicit.
Seeding Initial Data¶
Seeding — inserting reference data, demo content, default lookup tables, an initial admin user — is just a forward-only migration. Same mechanism, no separate "seed" concept. The advantage over an ad-hoc seed script: the migration log records that the seed has run, so booting the app twice doesn't duplicate the rows.
func (a *App) Migrations() []burrow.NamedMigration {
return []burrow.NamedMigration{{
Version: "001_initial_polls",
Migration: migrate.Migration{
Forward: func(ctx context.Context, tx *den.Tx) error {
samples := []struct {
text string
choices []string
}{
{"What's your favourite Go web framework?", []string{"Burrow", "Gin", "Echo", "net/http alone"}},
{"How long have you been writing Go?", []string{"<1 year", "1–3 years", "3–5 years", "5+ years"}},
}
for _, s := range samples {
q := &Question{Text: s.text, PublishedAt: time.Now()}
if err := den.Save(ctx, tx, q); err != nil {
return err
}
for _, ct := range s.choices {
if err := den.Save(ctx, tx, &Choice{QuestionID: q.ID, Text: ct}); err != nil {
return err
}
}
}
return nil
},
},
}}
}
The example doesn't guard against duplicates (Exists() check, etc.) because the _den_migrations log already guarantees the version runs exactly once. If you ever change a seed migration's body without bumping the version, that change won't apply to apps that already ran the old version — in that case create a follow-up migration (002_extra_polls) rather than editing 001_initial_polls in place.
Re-seeding for local development: drop the database (rm data/app.db) and boot again. The fresh _den_migrations collection treats the seed as pending and re-runs it. There is no --seed-style command-line escape hatch — that's intentional; the version log is the single source of truth. If you need to keep some data (e.g. your dev user account) but re-run a specific migration, delete that row from _den_migrations manually and reboot.
Key Points¶
- Each migration runs atomically in a transaction — if it fails, nothing is applied
- Applied migrations are tracked in a
_den_migrationscollection. Re-runs skip already-applied versions, so booting twice is a no-op Forwardis required;Backwardis optional. WithoutBackward, the migration is forward-only —migrate.Down/DownOnereturn an error if asked to roll back- Migrations run after
den.Register()has created the schema AND after every app'sConfigure()has wired its repo, so document types and app state are both ready - Within an app, migrations run in the order returned from
Migrations(). The version string is the lexicographic key the framework registers against_den_migrations, so the001_,002_prefix convention keeps the slice order and the registry order aligned - A failed migration aborts boot: the server exits with a non-zero status and logs
migration {app}/{version} failed: {error}. Subsequent migrations are skipped — fix the cause, redeploy, and the failed version retries on the next boot
Running Migrations Manually¶
You don't need to call anything; the framework wires migrate.Registry from every HasMigrations app and runs Up during boot. If you need finer control (e.g. a custom migrate subcommand that calls Down), reach for Den's migrate package directly.
Migration Order¶
Documents are registered in app registration order (the order you pass apps to NewServer). All document schemas are set up before any app's Configure() method is called.
Tips¶
- Keep document structs focused — one struct per logical entity
- Use
den:"index"for fields you frequently query on - Use
den:"unique"for fields that must be unique across the collection - Use
den:"fts"for fields that need full-text search