Repository navigation
Move a draft card between boards - #20
Merged
Merged
Conversation
eirvandelden
marked this pull request as ready for review
November 2, 2026 15:23
eirvandelden
force-pushed
the
move-draft-card-between-boards
branch
from
November 3, 2026 08:35
935a800 to
7d5f73d
Compare
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er draft Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ruby/setup-ruby has no Ruby 3.4.8 build for ubuntu-26.04, which ubuntu-latest now resolves to, so every job that sets up Ruby failed before running anything. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
eirvandelden
force-pushed
the
move-draft-card-between-boards
branch
from
November 6, 2026 20:54
7d5f73d to
6fb0b02
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Etienne presses "Create card", then switches the card to another board. Production raises
ActiveRecord::RecordNotUnique: UNIQUE constraint failed: cards.board_id, cards.creator_idand the move fails.The cause: the production SQLite database has a partial unique index,
index_cards_on_board_creator_drafted, oncards(board_id, creator_id) WHERE status = 'drafted'. It allows one draft per user per board. A move fails when the user already has a draft on the target board. The index is in no migration and no commit. It is only in the uncommitteddb/schema_sqlite.rbdump of the live database. Upstreambasecamp/fizzyhas no such index.A draft card moves to any board the user can access, including a board where the user already has a draft. Behaviour matches upstream.
Implementation
Domain terms
status = 'drafted'(Card::Statuses). Existing term.Card#move_to(board), which changes the board of a card. Existing term.index_cards_on_board_creator_draftedoncards(board_id, creator_id) WHERE status = 'drafted'.The repository has no glossary file. The new term is not written to a glossary in this change (see Design decisions).
Diagrams
classDiagram class Card { +move_to(new_board) void } class Board class User { +draft_new_card_in(board) Card } class RemoveBoardCreatorDraftedIndexFromCards { +up() void } Card --> Board : belongs to Card --> User : created by RemoveBoardCreatorDraftedIndexFromCards ..> Card : drops drafted card index on cardsER: not affected. Columns and committed indexes do not change. Only the version line of both schema files changes.
Signatures
db/migrate/20261102120000_remove_board_creator_drafted_index_from_cards.rb— drops the drafted card index where it exists.No
down: the inheritedMigration#downdoes nothing, so a rollback succeeds and creates nothing. That matches the committed schema, which never had the index.Card,Cards::BoardsController,User::Accessor: not affected.Test class
RemoveBoardCreatorDraftedIndexFromCardsTest < ActiveSupport::TestCase(new filetest/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb):if ActiveRecord::Base.connection_db_config.adapter == "sqlite3"block that defines the three index-present tests. It reads the config and opens no connection before the parallel fork. It does not useFizzy.db_adapter.sqlite?, which reads onlyDATABASE_ADAPTERand is wrong in SaaS mode, where the connection is always MySQL.add_drafted_card_index—connection.add_index :cards, [ :board_id, :creator_id ], unique: true, where: "status = 'drafted'", name: "index_cards_on_board_creator_drafted".migrate_up— finds the migration by class name inconnection_pool.migration_context.migrations, fails the test when it is missing, and runsmigrate(:up)insideActiveRecord::Migration.suppress_messages. The lookup is lazy, so a missing migration fails only these tests, not the suite load.drafted_card_index?—connection.index_name_exists?(:cards, "index_cards_on_board_creator_drafted").kevin_drafts_on_writebook_and_private—users(:kevin).draft_new_card_in(boards(:writebook))andusers(:kevin).draft_new_card_in(boards(:private)), the production path.Departure: the migration carries no
#:comment, because the repository's rubocop (Layout/LeadingCommentSpace) rejects it and upstream style has none.Not checked: Fizzy has no Steep or rbs_rails setup; the
#:comments document the types only.Complexity
cards. No DDL.DROP INDEX. Cost does not depend on the row count ofcards.Card#move_toand itsafter_updateboard-change callbacks: unchanged. Writes tocardsget slightly cheaper without the index.Invariants
cardshas no index namedindex_cards_on_board_creator_drafted. Enforced by the migration.if_exists: true. On MySQL this also means no implicit commit inside the test transaction.db/schema.rbanddb/schema_sqlite.rb, as upstream.RecordNotUniqueoncards.board_id, cards.creator_id. Enforced by a characterization test.sqlite3, where DDL is transactional and each parallel worker has its own database file.2026_11_02_120000, equal to the new migration. Otherwisemaintain_test_schema!anddb:setupin CI raisePendingMigrationError.Alternatives
A. Minimal: one migration test file; the adapter guard sits inside the index-creating helper, so every test runs on both adapters. Rejected: on MySQL the removal and post-migration tests would pass without the index and prove nothing, and the characterization test, which expects
RecordNotUnique, would fail. So all index-present tests must be defined only on SQLite.B. Maximal: a shared
DraftedCardIndexTestHelpermodule intest/test_helpers, apartial_index_testclass macro, raw-SQL index creation, a request-level regression test with the index and seven tests across three files. Rejected: too much test machinery for a one-line migration; only one test class needs the helpers.C. Chosen: one migration test file with private helpers and an adapter-scoped block for the three index-present tests, plus two request-level tests in the existing controller test that run on both adapters. It keeps the characterization test from B and the small footprint from A.
Also rejected:
changewithcolumn:,unique:andwhere:. Its inverse isadd_index ... if_not_exists, and on MySQL that dropswhere:and creates a full unique index on rollback.changewithname:only raisesIrreversibleMigrationon rollback. An explicitdownthat recreates the index puts back a defect no commit ever introduced.Also rejected (intent Decisions): reworking
Card#move_toinstead of dropping the index.db/migrate/20261102120000_remove_board_creator_drafted_index_from_cards.rb— new migration, drops the drafted card index where it exists.db/schema.rb— version line only:2026_08_28_120000→2026_11_02_120000.db/schema_sqlite.rb— version line only, same change.test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb— new; the first migration test in the repository. The defaulttest/**/*_test.rbglob picks it up.test/controllers/cards/boards_controller_test.rb— two new request-level tests.Proof
test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rba draft moves to a board where its creator already has a draft once the index is gone(regression test, SQLite, fails before the migration exists) andtest/controllers/cards/boards_controller_test.rbupdate moves a draft to a board where its creator has another draft(both adapters).test/controllers/cards/boards_controller_test.rbupdate moves a draft to a board without a draft of its creator.test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rbdoes nothing when the drafted card index is absent(both adapters).test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rbremoves the drafted card index when present(SQLite).if_exists: true→test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rbdoes nothing when the drafted card index is absent(asserts the index list ofcardsdoes not change and noDROP INDEXquery runs, viaassert_no_queries_match(/DROP INDEX/i, include_schema: true)).test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rbthe drafted card index rejects moving a draft onto a board with another draft of its creator(assertsRecordNotUniquematchingcards.board_id, cards.creator_id).