fix: backfill empty custom_types and custom_statuses tables#3012
Conversation
Migration 015 creates and populates custom_types and custom_statuses tables from config values, but only when the tables do not already exist. Databases where the tables were created by schema initialization (before 015 ran) have empty tables because 015 saw the table and returned early without populating. The empty tables shadow the config fallback in ResolveCustomTypesInTx and ResolveCustomStatusesDetailedInTx. These functions query the normalized table first and return empty results (no custom types/ statuses) instead of falling back to the config table. This causes "invalid issue type" validation errors for any custom type. Add migration 016 to detect and backfill empty tables from config. Bump currentSchemaVersion to 12 so the migration runs automatically on the next bd invocation for affected databases. Migration 015 is intentionally left unchanged — it has already run on existing databases, and modifying it would create two possible states for databases at the same migration level. Fixes gastownhall#2984 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hilmes
left a comment
There was a problem hiding this comment.
Review: fix: backfill empty custom_types and custom_statuses tables
Verdict: Correct fix for a schema initialization sequencing bug. Migration 016 properly backfills empty tables that were created but not populated by migration 015. Design is sound, idempotent, and handles edge cases well.
+97 −1 across 3 files (new migration 016, registration, schema version bump). Fixes #2984, #1631.
Root Problem: Empty Table Shadows Config Fallback ✅
Scenario:
- Database is created before migration 015 exists
- Schema initialization runs, creates
custom_typesandcustom_statusestables (empty) - Migration 015 runs, checks
if table existsand skips population because tables already exist - Result: Empty tables that shadow the config fallback in
ResolveCustomTypesInTx - User gets "invalid issue type" error for custom types like "agent" that are in config
Why this happens:
- Schema initialization happens once at startup (creates table structure)
- Migration 015 was designed to create AND populate tables together
- But if the table already exists (from earlier schema init), 015 skips population
- Resolution functions have fallback to config, but only if table is empty/missing
- Empty table breaks the fallback logic
Impact: Custom types/statuses become inaccessible after migration 015 runs on databases that pre-created the tables.
Solution: Migration 016 Backfill ✅
Design:
- New migration 016:
BackfillCustomTablespopulates empty tables from config - Schema version bumped from 11 → 12, so migration runs automatically
- Idempotent: if table already has data, skips backfill
Idempotency:
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM custom_types").Scan(&count); err != nil {
return err
}
if count > 0 {
return nil // Already populated
}Safe to run multiple times. If data exists, exits early. ✓
Config fallback:
var value string
err = db.QueryRow("SELECT `value` FROM config WHERE `key` = 'types.custom'").Scan(&value)
if err != nil || value == "" {
return nil // No config to backfill from
}If config is missing/empty, gracefully skips. ✓
Parsing reuses existing logic:
for _, name := range parseTypesValue(value) {
// ...insert into table
}Uses the same parseTypesValue helper from migration 015 (handles JSON array + comma-separated). ✓
INSERT IGNORE:
_, err = db.Exec("INSERT IGNORE INTO custom_types (name) VALUES (?)", name)Duplicate values ignored (safe if config has duplicates). ✓
Correctness: Edge Cases ✅
Case 1: Empty table + no config
- Query returns empty string
- Early return with nil
- ✓ Correct
Case 2: Empty table + JSON array config
types.custom = '["agent","gate","convoy"]'
parseTypesValueparses JSON- Inserts 3 rows
- ✓ Correct
Case 3: Empty table + comma-separated config
types.custom = 'agent, gate, convoy'
parseTypesValueparses CSV with trim- Inserts 3 rows
- ✓ Correct
Case 4: Already-populated table
- COUNT(*) returns > 0
- Early return with nil (idempotent)
- ✓ Correct
Case 5: Table doesn't exist
tableExistsreturns false- Early return with nil
- ✓ Correct
Case 6: Config query fails
- Error returned or treated as missing
- ✓ Correct
Case 7: Status backfill with unspecified category
_, err = db.Exec("INSERT IGNORE INTO custom_statuses (name, category) VALUES (?, 'unspecified')", name)- Status config doesn't include categories
- Defaults to 'unspecified'
- Matches migration 015 behavior
- ✓ Correct
Design: Preserving Migration 015 ✅
PR notes: "Migration 015 is intentionally left unchanged."
This is the right call:
- 015 has already run on existing databases
- Modifying it would create multiple states for databases at migration level 15
- Better to have 015 create+populate, 016 handle the backfill case
- Clean separation of concerns
Performance ✅
Backfill path:
- One COUNT query per table
- One SELECT from config per table
- If empty: N inserts (N = custom types/statuses, typically 5-20)
- INSERT IGNORE is O(1) per row (primary key lookup)
Impact: Minimal. Migration runs once, backfill is rare case (only on databases with pre-created empty tables).
Style: Consistent with Migration 015 ✅
- Uses same helper functions (
tableExists,parseTypesValue) - Same error handling pattern (early return on missing config)
- Same INSERT IGNORE pattern
- Comments explain non-obvious choices (why "unspecified" for statuses)
- Code is readable and maintainable
Good design: two separate functions for clarity, shared pattern for consistency.
Testing: Adequate ✅
Test plan:
- ✅ Unit tests pass (go test -short ./internal/storage/dolt/migrations/...)
- ✅ Compiles cleanly (go build ./cmd/bd)
- ✅ Manual verification: ran on database with empty
custom_typestable and 11 custom types in config - ✅ Migration populated table correctly
- ✅
bd create --type=agentsucceeded after migration
Manual test is thorough — validates the full E2E flow.
🟡 Error Handling: Query Failures Are Lenient
var value string
err = db.QueryRow("SELECT `value` FROM config WHERE `key` = 'types.custom'").Scan(&value)
if err != nil || value == "" {
return nil // No config to backfill from
}If the query fails with a real error (corruption, disk full), it returns nil silently. This is intentional (matches migration 015 pattern) but worth noting:
sql.ErrNoRows→ treated as "no config" (correct)- Other errors → also treated as "no config" (acceptable, migration is lenient)
Matches migration 015 philosophy ("migrations are lenient so existing databases aren't blocked"). Acceptable but could be more explicit.
Not blocking — consistent with existing migration patterns.
🟡 Status Category Defaulting
_, err = db.Exec("INSERT IGNORE INTO custom_statuses (name, category) VALUES (?, 'unspecified')", name)Status config doesn't include category information, so migration defaults all to 'unspecified'. Comment explains this.
If someone manually created statuses with specific categories via SQL, this backfill won't preserve them. But acceptable because:
- Statuses are typically bulk-created from config
- If table is already populated, idempotent check (
count > 0) skips entirely - Comment documents the behavior
Summary
| Component | Verdict | Notes |
|---|---|---|
| Root cause analysis | ✅ | Migration 015 skip behavior on existing tables identified correctly |
| Backfill design | ✅ | Idempotent, reads from config, graceful on missing/empty |
| Reuse of existing helpers | ✅ | tableExists, parseTypesValue properly used |
| Edge cases | ✅ | Empty table, no config, already populated, table missing all handled |
| Status category defaulting | ✅ | Documented, config has no categories, 'unspecified' is correct |
| Idempotency | ✅ | COUNT(*) prevents re-inserting existing data |
| Error handling on config read | 🟡 | Follows migration 015 pattern (lenient), could be more explicit |
| Performance | ✅ | Minimal impact, one-time backfill |
| Test coverage | ✅ | Unit tests + manual E2E verification on real database |
| Design rationale | ✅ | Preserves migration 015, clean separation, well-documented |
LGTM. Correct fix for a sequencing bug where empty tables created by schema init shadow the config fallback. Migration 016 properly backfills these tables from config, with idempotent design and graceful handling of edge cases.
Two yellow flags are both acceptable: error handling when reading config is lenient (matches migration 015 pattern), and status categories default to 'unspecified' (config doesn't include them). The fix restores functionality to databases that hit the "empty table shadows config" state.
Ship it.
Use ParseCustomStatusConfig (same as migration 015) instead of parseTypesValue so that status categories like "reviewing:active" are preserved during backfill. Without this, all backfilled statuses get category 'unspecified' regardless of config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Migration 015 populates
custom_typesandcustom_statusestables fromconfig, but only when the tables don't exist. Databases where the tables
were created by schema initialization (before 015 ran) have empty tables
that shadow the config fallback in
ResolveCustomTypesInTx, causing"invalid issue type" validation errors for all custom types.
Adds migration 016 to backfill empty tables from config values, and bumps
currentSchemaVersionto 12 so the migration runs automatically on thenext
bdinvocation.Contributes to fixing #2984
Changes
016_backfill_custom_tables.go— populates emptycustom_typesandcustom_statusesfrom config, idempotentmigrations.gocurrentSchemaVersionfrom 11 to 12 inschema.goTesting
go test -short ./internal/storage/dolt/migrations/...passesgo build ./cmd/bdcompiles cleanlycustom_typestable andtypes.customconfig containing 11 custom types — migration populatedthe table and
bd create --type=agentsucceeded afterwardDesign notes
Migration 015 is intentionally left unchanged — it has already run on
existing databases, and modifying it would create two possible states
for databases at the same migration level.