dolt_ignore'd tables with live dirty content have no loss-free recovery path when a pending ignored-source migration touches them (embedded and server mode)
bd version: 1.2.2, built from current main @ 7505e173f2 (stock, go build -tags gms_pure_go ./cmd/bd); also reproduces identically on our fork (74ea9a093f)
Storage mode: reproduced in embedded mode; the code path is shared with server mode (trace below)
OS: Ubuntu 26.04 LTS (Linux 7.0.12-1-pve), x86_64
Dolt CLI used for fixture surgery: 2.1.6 (the defect is in bd's migration logic, not Dolt itself)
Summary
MigrateUp's ignored-source dirty-table guard (internal/storage/schema/schema.go ~L672-687) intentionally returns a plain, untyped fmt.Errorf instead of the typed *DirtyTablesError the main-source guard uses. That is a deliberate choice per its own comment — the check fires mid-pass, after main-source migrations have applied, so a lenient caller skipping it could checkpoint a half-applied migration pass.
The consequence: none of the three call sites that catch the dirty-table refusal leniently for working-set-reconcile commands (bd dolt commit / bd vc commit) match this error, because all three type-assert specifically for *schema.DirtyTablesError:
internal/storage/embeddeddolt/store.go(embeddedopenWorkingSetReconcile)internal/storage/schema/lock.go(MigrateUpWithLock's fresh-bootstrap-heal path)- (our fork also extends the main-source leniency to server mode, with the same blind spot)
Server-mode initSchema calls schema.MigrateUpWithLock, which calls the exact same schema.MigrateUp embedded mode calls directly — the guard, and the gap, are identical in both modes.
Result: if a dolt_ignore'd clone-local table (wisps, wisp_comments, ...) is genuinely dirty with live data at the moment its local ignored_schema_migrations cursor falls behind a pending ignored-source migration touching that table, every open of the database fails with the same refusal, including the one command whose job is to clear it (bd dolt commit). The "obvious" manual fallback does not help either:
CALL DOLT_COMMIT('-Am', '...')
-- error on line 1 for query CALL DOLT_COMMIT('-Am', ...): nothing to commit-Am's implicit add-all respects dolt_ignore even for an already-tracked table, so it silently skips the very tables that need committing. The recovery most operators would find is CALL DOLT_CHECKOUT('--', <table>), which discards the live uncommitted rows — acceptable only when the table happens to be schema-only dirty, never when it holds real wisp/comment data.
Minimal repro (embedded mode; scratch-repo; commands as executed against the stock build)
bd init --prefix=repro2 --non-interactive
cd .beads/embeddeddolt/repro2
# 1. Simulate a clone where the wisp tables are already tracked (the historical
# state of real clones, from before DOLT_ADD started no-op'ing on ignored tables).
dolt sql -q "CALL DOLT_ADD('-f','wisps'); CALL DOLT_ADD('-f','wisp_comments');
CALL DOLT_COMMIT('-m','baseline')"
# 2. Live wisp data (what normal bd operation produces).
dolt sql -q "INSERT INTO wisps (id, title) VALUES ('repro2-wisp-001','w');
INSERT INTO wisp_comments (id, issue_id, author, text)
VALUES (UUID(),'repro2-wisp-001','agent','live comment')"
# 3. Rewind the LOCAL ignored cursor below the latest ignored migration that
# touches wisp_comments (0024_widen_wisp_comments_text) — the exact situation
# that migration's own header describes (main cursor current, ignored behind).
dolt sql -q "DELETE FROM ignored_schema_migrations WHERE version >= 24"
cd ../../..
bd dolt commit -m "reconcile"Expected vs actual
- Expected:
bd dolt commit(per its own purpose, and per the precedent of the main-source guard's leniency) commits the working set and unblocksbd migrate, without data loss. - Actual (verbatim, deterministic on every retry):
Error: failed to open database: embeddeddolt: init schema: embeddeddolt: migrate: pending ignored schema migrations alter pre-existing dirty tables: wisp_commentsA loss-free recovery exists but bd doesn't offer it
Explicit staging bypasses dolt_ignore where the implicit -Am add-all doesn't:
CALL DOLT_ADD('-f', 'wisp_comments');
CALL DOLT_ADD('-f', 'wisps');
CALL DOLT_COMMIT('-m', 'reconcile ignored-tables working set');Verified end-to-end (with our build of the same guard code): bd dolt commit and bd migrate then succeed, the ignored cursor advances, and all live rows survive (row counts and content unchanged). By contrast, the DOLT_CHECKOUT route verifiably dropped an uncommitted wisp_comments row in the same scenario.
Suggested direction
Not necessarily "make the guard lenient like the main-source one" — the comment in schema.go gives a real reason not to skip it outright. But the recovery path could be narrower and still safe: the guard fires before ignoredSource.migrate runs, so nothing is half-applied yet at that point. Committing the pre-existing dirty state via DOLT_ADD -f + DOLT_COMMIT is exactly as safe as the existing *DirtyTablesError leniency, scoped to the tables the guard itself names. Options, in order of invasiveness:
- Give the ignored-guard a typed error (e.g.
IgnoredDirtyTablesError, symmetric withDirtyTablesError) and let the existing lenient catch-sites match it — same warn-and-continue shape already used for the main-source guard. - At minimum, document the
DOLT_ADD -f+DOLT_COMMITrecovery in the refusal message (which today only namesbd dolt commit— itself refused) so operators don't reach for the destructiveDOLT_CHECKOUTby default.
Happy to open a PR for either shape if wanted.
Source: gastownhall/beads