Skip to content

feat: Allow dropping empty tables during auto-migration#4593

Open
Ludv1gL wants to merge 1 commit intoclockworklabs:masterfrom
Ludv1gL:feat/allow-drop-empty-tables
Open

feat: Allow dropping empty tables during auto-migration#4593
Ludv1gL wants to merge 1 commit intoclockworklabs:masterfrom
Ludv1gL:feat/allow-drop-empty-tables

Conversation

@Ludv1gL
Copy link
Contributor

@Ludv1gL Ludv1gL commented Mar 9, 2026

Summary

  • Removing a table from the schema previously always triggered AutoMigrateError::RemoveTable, forcing --delete-data which nukes the entire database
  • Now, empty tables can be dropped seamlessly during spacetime publish
  • Non-empty tables fail with an actionable error guiding the user to clear rows first

Changes

  • auto_migrate.rs: Replace hard RemoveTable error with AutoMigrateStep::RemoveTable. Compute removed_tables set (same pattern as new_tables) and pass to auto_migrate_indexes/auto_migrate_sequences/auto_migrate_constraints to skip sub-object diffs for removed tables (cascade handled by drop_table)
  • update.rs: Execute RemoveTable step — O(1) emptiness check via table_row_count_mut, then drop_table. Fails with clear message if table has data
  • formatter.rs / termcolor_formatter.rs: Add format_remove_table to MigrationFormatter trait + implementation
  • publish.rs (smoketests): Update existing test — removing empty table now succeeds without flags

Safety

  • Transaction safety: Emptiness check and drop run in the same MutTx — no window for concurrent inserts
  • Cascade: drop_table already handles removing all indexes, constraints, and sequences for the table
  • Sub-object filtering: Indexes/constraints/sequences belonging to removed tables are filtered from their respective diffs, preventing orphan RemoveIndex/RemoveConstraint/RemoveSequence steps
  • Rollback: If the emptiness check fails, the entire migration aborts before any changes are applied

Example error output (non-empty table)

Cannot remove table `MyTable`: table contains data. Clear the table's rows (e.g. via a reducer) before removing it from your schema.

Test plan

  • cargo check -p spacetimedb-schema -p spacetimedb-core passes
  • All 14 auto_migrate schema tests pass (2 new: remove_table_produces_step, remove_table_does_not_produce_orphan_sub_object_steps)
  • All 3 update execution tests pass (2 new: remove_empty_table_succeeds, remove_nonempty_table_fails)
  • Updated existing auto_migration_errors test (no longer expects RemoveTable error)
  • Updated smoke test: cli_can_publish_remove_empty_table expects success

🤖 Generated with Claude Code


let mut tx = begin_mut_tx(&stdb);
let plan = ponder_migrate(&old, &new)?;
let _ = update_database(&stdb, &mut tx, auth_ctx, plan, &TestLogger)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check for UpdateResult::Success here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now asserting RequiresClientDisconnect (since RemoveTable now emits DisconnectAllUsers).

let plan = ponder_migrate(&old, &new)?;
let _ = update_database(&stdb, &mut tx, auth_ctx, plan, &TestLogger)?;

assert!(stdb.table_id_from_name_mut(&tx, "droppable")?.is_none());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check pending_schema_changes here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — asserting exactly 1 pending schema change (TableRemoved) from drop_table.

assert!(
err.to_string().contains("table contains data"),
"error should mention that the table contains data, got: {err}"
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check that pending_schema_changes is empty here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — asserting pending_schema_changes has len 1 (the TableRemoved entry from drop_table).

let indexes_ok = auto_migrate_indexes(&mut plan, &new_tables);
let sequences_ok = auto_migrate_sequences(&mut plan, &new_tables);
let constraints_ok = auto_migrate_constraints(&mut plan, &new_tables);
// Filter out sub-objects of added/removed tables — they're handled by AddTable/RemoveTable.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Filter out sub-objects of added/removed tables — they're handled by AddTable/RemoveTable.
// Filter out sub-objects of added/removed tables — they're handled by `AddTable`/`RemoveTable`.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — backticked both AddTable and RemoveTable.

Comment on lines 946 to 949
if removed_tables.contains(&plan.old.stored_in_table_def(&old.name).unwrap().name) {
return Ok(()); // handled by RemoveTable
}
plan.steps.push(AutoMigrateStep::RemoveIndex(old.key()));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make this align with the code for Diff::Add and add a closure that takes the hash set as a parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched to negated-if style (no early return) to align with the Diff::Add pattern. Applied to all three: indexes, sequences, and constraints.

Comment on lines +986 to +988
if removed_tables.contains(&plan.old.stored_in_table_def(&old.name).unwrap().name) {
return Ok(()); // handled by RemoveTable
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also use a closure here and style without the early return as in the Add case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines 1024 to 1027
if removed_tables.contains(&plan.old.stored_in_table_def(&old.name).unwrap().name) {
return Ok(()); // handled by RemoveTable
}
plan.steps.push(AutoMigrateStep::RemoveConstraint(old.key()));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto re closure and early return

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +2435 to +2438
assert!(
plan.steps.iter().any(|s| matches!(s, AutoMigrateStep::RemoveTable(name) if &name[..] == "Drop")),
"plan should contain a RemoveTable step for 'Drop'"
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to see an explicit list here for this test to also capture the disconnect all clients step.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — now using assert_eq\!(plan.steps, vec\![RemoveTable(&drop_table), DisconnectAllUsers]) for explicit step verification.

Comment on lines +2454 to +2461
assert!(
!plan.steps.iter().any(|s| matches!(s, AutoMigrateStep::RemoveIndex(_))),
"plan should not contain orphan RemoveIndex steps for a removed table"
);
assert!(
!plan.steps.iter().any(|s| matches!(s, AutoMigrateStep::RemoveConstraint(_))),
"plan should not contain orphan RemoveConstraint steps for a removed table"
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to see an explicit list here too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — same explicit assert_eq\! on the full steps vec.

false,
true,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this test changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was originally cli_cannot_publish_breaking_change_without_flag — it tested that removing a table fails during publish. Now that empty table removal succeeds via auto-migration, the test was updated to cli_can_publish_remove_empty_table and expects success. The smoke test module starts with empty tables (fresh publish), so the removal succeeds as expected.

@Centril Centril self-assigned this Mar 10, 2026
Previously, removing a table from the schema always required a manual
migration (--delete-data), which nukes the entire database. Now, empty
tables can be dropped seamlessly during publish.

- Add RemoveTable variant to AutoMigrateStep (schema layer)
- Remove AutoMigrateError::RemoveTable (no longer an error)
- Filter sub-object diffs (indexes, constraints, sequences) for removed
  tables so drop_table handles cascade
- Validate emptiness at execution time via table_row_count_mut (O(1))
- Non-empty tables fail with actionable error: "Clear the table's rows
  (e.g. via a reducer) before removing it from your schema"
- Add format_remove_table to MigrationFormatter trait + termcolor impl
- Update smoke test: removing empty table now succeeds without flags

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Ludv1gL Ludv1gL force-pushed the feat/allow-drop-empty-tables branch from e35fcdd to e45d973 Compare March 10, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants