Migrating to v0.22¶
v0.22 lands several breaking changes: the user model becomes generic so apps extend it with their own profile fields, data setup moves from Seedable to versioned migrations, navigation labels double as i18n keys, and several CLI flags either get smarter defaults or disappear because their value is now derivable. This guide walks the concrete before/after patterns for the upgrade from v0.21.x.
For the full list of changes, see the v0.22 changelog. For the rationale behind individual decisions, see the reference docs linked from each section.
Quick orientation¶
| What you used | What replaces it | Section |
|---|---|---|
auth.User (with Name / Bio) |
auth.User[P] with inline Profile P |
Generic auth.User |
Seedable.Seed(ctx) + --seed flag |
HasMigrations.Migrations(), auto-applied at boot |
HasMigrations |
NavItem.LabelKey / Choice.LabelKey |
NavItem.Label (Label = i18n key) |
Label-as-key |
--i18n-default-language / --i18n-supported-languages flags |
Auto-derived from registered TranslationFS files |
i18n flags removed |
cmd/burrow-tailwind standalone |
burrow tailwind subcommand |
burrow-tailwind shim removed |
--auth-webauthn-rp-id=localhost (hardcoded default) |
Derives from --base-url host automatically |
WebAuthn RP-ID auto-derived |
--auth-webauthn-rp-display-name="Web App" (default) |
Derives from new --app-name flag |
App name |
Generic auth.User¶
auth.User, auth.App, auth.Repository, auth.CurrentUser, and auth.MustCurrentUser are now generic over a Profile type parameter that holds app-specific extension fields (display name, bio, avatar, social links, …). The fixed Name and Bio fields are gone from User core — they were arbitrary picks of "what a user might want." Apps that don't extend the user parametrise with auth.EmptyProfile.
Mechanical migration (no profile extension):
// Server setup:
- auth.New(opts...)
+ auth.New[auth.EmptyProfile](opts...)
// Reading the user in handlers / middleware:
- user := auth.CurrentUser(r.Context())
+ user := auth.CurrentUser[auth.EmptyProfile](r.Context())
- user := auth.MustCurrentUser(r.Context())
+ user := auth.MustCurrentUser[auth.EmptyProfile](r.Context())
// Repository:
- repo := auth.NewRepository(db)
+ repo := auth.NewRepository[auth.EmptyProfile](db)
// Type references:
- var u *auth.User
+ var u *auth.User[auth.EmptyProfile]
Repository.CreateUser and CreateUserWithEmail no longer accept a name parameter; if your code depended on it, drop the argument and either set Username to the desired display string or define a Profile (next section). Repository.SearchUsers searches Username and Email only.
Adding a Profile:
// Define your profile struct (must be a plain struct, not a den.Document):
type Profile struct {
Name string `form:"name" verbose:"Display name"`
Bio string `form:"bio" verbose:"Bio" widget:"textarea"`
AvatarURL string `form:"avatar_url" verbose:"Avatar URL"`
}
// Register with auth:
auth.New[Profile](opts...)
// Read in handlers:
u := auth.CurrentUser[Profile](r.Context())
fmt.Println(u.Profile.Name)
The Profile is stored inline as a nested JSON object on the user document — no separate table, no join. Existing user records load fine because missing profile keys unmarshal to the zero value of P. den: tags on Profile fields (index, unique, fts) are honoured automatically via Den 0.14's nested-struct schema walk.
auth.Configure rejects Profile types that embed document.Base at startup with a clear error — Profile is serialised inline, not as a Den document.
See Extending the User for the full guide with admin form integration, search caveats, and the "when to use a separate document instead" trade-off.
HasMigrations¶
The Seedable interface and the --seed / SEED=true flag are gone. Apps now ship versioned, run-once migrations via HasMigrations, and the server applies them automatically at boot via Den's migrate package. Each migration runs exactly once across processes (tracked in the _den_migrations collection); re-booting is a no-op.
Before:
// Implements [burrow.Seedable]:
func (a *App) Seed(ctx context.Context) error {
// create initial questions; idempotency was the implementer's burden
return a.repo.CreateQuestion(ctx, &Question{Text: "Hello"})
}
Booting: myapp --seed to run once.
After:
import (
"github.com/oliverandrich/burrow"
"github.com/oliverandrich/den"
"github.com/oliverandrich/den/migrate"
)
// Implements [burrow.HasMigrations]:
func (a *App) Migrations() []burrow.NamedMigration {
return []burrow.NamedMigration{{
Version: "001_initial_questions",
Migration: migrate.Migration{
Forward: func(ctx context.Context, tx *den.Tx) error {
return den.Save(ctx, tx, &Question{Text: "Hello"})
},
},
}}
}
Booting: just myapp. The migration runs on the first boot, is skipped on every subsequent boot.
Forward receives a *den.Tx, so use Den primitives (den.Save(ctx, tx, doc), den.NewQuery[T](tx, ...)) directly. Don't call your repo from Forward — repos hold a *den.DB, the migration owns the transaction. Migrations are setup code, not application code.
Versions are namespaced by app name ({app.Name()}/{version}), so two contribs can both ship "001_initial" without colliding. Within an app, migrations run in the order returned from Migrations() — the 001_, 002_ prefix convention keeps slice order and lexicographic registry order aligned.
Re-seeding in dev: drop the database file (rm data/app.db) and boot again. No --seed-style escape hatch — the migration log is the single source of truth.
See Database Migrations for backward migrations, failure semantics, and the full pattern.
Label-as-key¶
NavItem.LabelKey and Choice.LabelKey are gone. The Label itself doubles as the i18n message ID — the framework pipes it through i18n.T at render time. On a translation miss, the raw Label string renders.
Before:
active.de.toml:
After:
active.de.toml:
forms.BoundField.Label and forms.Choice.Label follow the same convention. Templates can now do {{ .Label }} directly and get the locale-appropriate string — no {{ t .Label }} wrapping needed. The verbose:"..." struct tag value is the key.
Burrow's built-in contribs (admin, auth, jobs) migrated their TOMLs. Bare English keys ({{ t "Active" }}) replaced opaque structured keys ({{ t "admin-users-active" }}) for short single-word UI labels. Full sentences, prompts, confirmations, and plural variants still use structured keys. See i18n: Labels vs. Messages for the rule.
i18n flags removed¶
--i18n-default-language and --i18n-supported-languages are gone. The supported locale set is derived from the union of every registered HasTranslations app's active.<locale>.toml files. Drop an active.fr.toml into any contrib's translations/ directory and French becomes a routable locale automatically.
The framework's bundle tag is pinned to language.English. With Label-as-key, the literal you write in {{ t "..." }} is the source-language string — a German-first project writes German keys and ships only active.de.toml. No framework configuration required.
Migration: delete the flags from your .mise.toml, env vars, and config files. urfave/cli rejects them at boot with flag provided but not defined, so the failure is loud.
The NewBundle() and NewTestBundle(translationFSes...) signatures lost their leading args. If you constructed bundles by hand:
- bundle, err := i18n.NewBundle("en", []string{"en", "de"})
+ bundle, err := i18n.NewBundle()
- bundle, err := i18n.NewTestBundle("en", translationFS)
+ bundle, err := i18n.NewTestBundle(translationFS)
burrow-tailwind shim removed¶
The standalone cmd/burrow-tailwind binary — a deprecation shim in v0.21 that printed a notice and delegated to burrow tailwind — is gone. Replace the tool directive in go.mod and the invocations in .mise.toml / .air.toml:
- tool github.com/oliverandrich/burrow/cmd/burrow-tailwind
+ tool github.com/oliverandrich/burrow/cmd/burrow
The functionality (@source auto-discovery, watch mode, minify) is unchanged.
WebAuthn RP-ID auto-derived¶
--auth-webauthn-rp-id loses its "localhost" default. When unset, it derives from the host part of the resolved base URL. Local dev with --host=localhost --port=8080 still resolves to localhost (the port is stripped); deployed apps with --base-url=https://app.example.com now correctly use app.example.com without anyone setting the flag.
If you explicitly set --auth-webauthn-rp-id for registrable-suffix setups (e.g. example.com to share credentials across app.example.com + admin.example.com), nothing changes — your override still wins.
If you relied on the implicit "localhost" default in production without setting --base-url, the RP-ID is now derived from your host/port combination and may differ from what you registered with. Set --auth-webauthn-rp-id=localhost explicitly to keep the old behaviour, or set --base-url to the real production URL.
App name¶
New --app-name flag (APP_NAME env, server.app_name toml). Default: the binary basename (notes, myapp, …). Three consumers:
--auth-webauthn-rp-display-namelost its"Web App"default and falls back to--app-name. The browser passkey dialog now reads "Save passkey for Notes" instead of "Save passkey for Web App" — usually the improvement you wanted.--smtp-from=noreply@example.com(bare email) is decorated as"Notes <noreply@example.com>"automatically. Pre-formatted"Acme <noreply@example.com>"passes through unchanged.- A new
{{ appName }}template func returns the value for use in layout titles, email subjects, footer copy, etc.
No migration needed. Apps that explicitly set --auth-webauthn-rp-display-name keep working; the only behaviour shift is that apps which relied on the placeholder default get a more meaningful default automatically.
Smaller items¶
AppConfig.WithLocale— apps that want locale-scoped contexts in background work read it from*burrow.AppConfig(passed toConfigure). Unchanged API; mentioned because the i18n flag removal moved the boot-time wiring around.- Den 0.14 / 0.15 — Burrow now depends on Den 0.15.0. Highlights for app code:
den:tags on nested struct fields (Profile fields, sibling structs) are honoured with JSON-path indexes ($.profile.slug).den.Revertzeroes the doc before decoding the snapshot — burrow doesn't call it, only relevant if your app code does. Thestorage/s3Den backend was removed in 0.15; thefile://backend and theStorageinterface are unaffected. burrow newscaffold synced. Projects generated withburrow newpin Den 0.15.0 and no longer carry stalegoccy/go-json/oklog/ulid/v2indirect entries. No action needed for existing projects beyond ago mod tidy.
What's new and doesn't require migration¶
If you upgrade and nothing else, you also get:
formspackage: nested-struct subform support. Struct-typed form fields render as subforms automatically. Use it for the new auth Profile, or for any other "section of related fields" pattern.- Translation guard test.
TestTranslationsLoadInGoI18nwalks everyactive.*.tomlin the repo and loads it through a real go-i18n bundle so reserved-key collisions (id,hash, plural keys) surface at test time instead of at server boot. - Forms label auto-translation.
forms.BoundField.Labelandforms.Choice.Labelare auto-translated byextractFieldsvia the same Label-as-key convention. Templates can drop the{{ t .Label }}wrapping and just render{{ .Label }}.
Stuck?¶
If you hit a case this guide doesn't cover, please open an issue — we'll fold the resolution back into this page.