store: hold every group name to one group
Only the sync: names were spoken for; the rest could all collide,
and two groups named Booles put two identical tags side by side.
The index takes every name now, exact bytes, as 007 compared;
the dialog that creates by name can no longer make a pair.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/db/migrations/008_group_names.rb b/db/migrations/008_group_names.rb
new file mode 100644
index 0000000..ef1764a
--- /dev/null
+++ b/db/migrations/008_group_names.rb
@@ -0,0 +1,35 @@
+# A name is one group's. 007 made that true of the `sync:` names alone,
+# because a client's create had to find exactly one group to join, and
+# left every other name repeatable as the author's to reuse. The rest
+# follow it now that a contact's groups dialog creates a group by name
+# (Web#apply_groups): two groups called Booles are two tags a member
+# cannot tell apart, and the save that made the second one meant the
+# first.
+#
+# Exact bytes, the comparison 007 made. SQLite's NOCASE folds ASCII
+# alone, so a folded index would refuse "booles" beside "Booles" and
+# admit "ZOË" beside "Zoë" — a rule no screen could state. The dialog
+# offers to create a name only when no group's lowercased label is it
+# (Admin::GroupDialog), so a case variant is a hand-made POST rather
+# than something the admin makes.
+#
+# Nameless groups are untouched, a unique index admitting any number of
+# NULLs: 005 made the column nullable so that nameless is one spelling
+# and a group without a name is displayed by its id.
+#
+# 007's index is dropped rather than left beside this one — the `sync:`
+# names are a subset of every name, and the same rule held twice is the
+# one that goes stale. `up` and `down` rather than `change`, because
+# Sequel's reverser knows add_index and not drop_index.
+Sequel.migration do
+ up do
+ drop_index :groups, :name, name: :groups_sync_name_is_unique
+ add_index :groups, :name, unique: true, name: :groups_name_is_unique
+ end
+
+ down do
+ drop_index :groups, :name, name: :groups_name_is_unique
+ add_index :groups, :name, unique: true, name: :groups_sync_name_is_unique,
+ where: Sequel.function(:glob, "sync:*", :name)
+ end
+end
diff --git a/docs/plans/2026-09-15-group-names-are-unique.md b/docs/plans/2026-09-15-group-names-are-unique.md
new file mode 100644
index 0000000..122a8c2
--- /dev/null
+++ b/docs/plans/2026-09-15-group-names-are-unique.md
@@ -0,0 +1,22 @@
+# Group names are unique
+
+2026-09-15. This changes one rule in
+`2026-09-12-per-user-books.md`, "Selection rides on groups": every
+group's name is its own, not the `sync:` names alone.
+
+The groups dialog on a contact's card creates a group by the name typed
+into its filter, so a name is something a person writes rather than
+something they pick. Two groups called Booles put two identical tags on
+a card, and neither the tag nor the save that made the second one says
+which group is which. The refusal is the database's
+(`db/migrations/008_group_names.rb`), so it holds for the group
+editor's rename and the create route as well as for the dialog.
+
+Exact bytes rather than a case fold. SQLite's NOCASE folds ASCII alone,
+so a folded index would refuse "booles" beside "Booles" and admit "ZOË"
+beside "Zoë". The dialog offers to create only a name that no group's
+lowercased label already spells, so a case variant takes a hand-made
+POST.
+
+Nameless groups are unaffected: a unique index admits any number of
+NULLs, and a group with no name is displayed by its id.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 6591eee..d936e6a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -173,8 +173,8 @@ module ProTacts
# The group names that choose what a client syncs: `sync:*` for
# everyone, `sync:<display name>` for one user
- # (docs/plans/2026-09-12-per-user-books.md). Unique among groups
- # (db/migrations/007_sync_names.rb), where other names may repeat.
+ # (docs/plans/2026-09-12-per-user-books.md). Unique like every
+ # group's name (db/migrations/008_group_names.rb).
SYNC_PREFIX = "sync:" #: String
EVERYONE = "#{SYNC_PREFIX}*" #: String
@@ -563,8 +563,8 @@ module ProTacts
groups.insert(id:, name:)
return id
rescue Sequel::UniqueConstraintViolation
- # The id collided, or a `sync:` name did
- # (db/migrations/007_sync_names.rb). Drawing again only helps
+ # The id collided, or the name did
+ # (db/migrations/008_group_names.rb). Drawing again only helps
# the first; the second is the caller's to hear.
raise if groups.where(id:).empty?
@@ -672,8 +672,8 @@ module ProTacts
# A card's side of #edit_group: one transaction, leavers first and
# joiners last for that method's reason. A name creates a group to
- # join inside the same transaction, so a taken `sync:` name
- # (#create_group) moves nothing.
+ # join inside the same transaction, so a taken name (#create_group)
+ # moves nothing.
#: (String card_id, join: Array[String], leave: Array[String], ?create: String?) -> void
def regroup(card_id, join:, leave:, create: nil)
@database.transaction do
@@ -859,8 +859,8 @@ module ProTacts
end
# The id of the `sync:*` group, created on first use. `sole` because
- # a `sync:` name is unique (db/migrations/007_sync_names.rb), and no
- # row is the ordinary answer for the first create.
+ # a group's name is unique (db/migrations/008_group_names.rb), and
+ # no row is the ordinary answer for the first create.
#: () -> String
def everyone_group_id
groups.where(name: EVERYONE).sole.fetch(:id).to_s
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index f3913a6..1066d81 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -194,8 +194,8 @@ module ProTacts
begin
store.regroup(id, join: checked - was, leave: was - checked, create: (name unless name.empty?))
rescue Sequel::UniqueConstraintViolation
- # A taken `sync:` name (db/migrations/007_sync_names.rb), refused
- # with the rest of the save in regroup's transaction.
+ # A taken name (db/migrations/008_group_names.rb), refused with
+ # the rest of the save in regroup's transaction.
return contact_screen(contact, notice: "Another group is already named #{name}; nothing was saved.")
end
r.redirect "/contacts/#{id}", 303
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 122b7ef..06880f9 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -23,8 +23,7 @@ module ProTacts
id = store.create_group(name: name.empty? ? nil : name)
r.redirect "/groups/#{id}/edit", 303
rescue Sequel::UniqueConstraintViolation
- # A taken `sync:` name, the one kind that must be unique
- # (db/migrations/007_sync_names.rb).
+ # A taken name (db/migrations/008_group_names.rb).
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsIndex.call(groups: store.all_groups, notice: "Another group is already named #{name}.")
end
@@ -80,8 +79,8 @@ module ProTacts
begin
store.edit_group(id, name: r.params["name"].to_s, lines: Admin::CardForm.group_lines(group, r.params), members:)
rescue Sequel::UniqueConstraintViolation
- # A taken `sync:` name (db/migrations/007_sync_names.rb). The save
- # is one transaction, so nothing of it landed.
+ # A taken name (db/migrations/008_group_names.rb). The save is
+ # one transaction, so nothing of it landed.
return group_edit_screen(group, notice: "Another group is already named #{r.params['name']}; nothing was saved.")
end
r.redirect "/groups/#{id}", 303
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 81d4f71..19c8273 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -445,17 +445,17 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # A `sync:` name is the one kind that must be unique
- # (db/migrations/007_sync_names.rb), and the refusal is the whole save.
- def test_a_new_group_with_a_taken_sync_name_saves_nothing
+ # A name is one group's (db/migrations/008_group_names.rb), and the
+ # refusal is the whole save.
+ def test_a_new_group_under_a_taken_name_saves_nothing
with_contacts({"ada" => ADA}) do |store|
booles = FixtureData.seed_group(store, name: "Booles")
- FixtureData.seed_group(store, name: "sync:alpha")
+ FixtureData.seed_group(store, name: "Clarks")
- post "/contacts/ada/groups", groups: [booles], new: "sync:alpha"
+ post "/contacts/ada/groups", groups: [booles], new: "Clarks"
assert_equal 200, last_response.status
- assert_includes last_response.body, "Another group is already named sync:alpha; nothing was saved."
+ assert_includes last_response.body, "Another group is already named Clarks; nothing was saved."
assert_empty store.groups_of("ada")
assert_equal 2, store.all_groups.length
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index d8ed38b..0ac91f0 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -92,15 +92,15 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
- # Only a `sync:` name has to be unique (db/migrations/007_sync_names.rb).
- def test_a_create_under_a_taken_sync_name_is_refused
+ # A name is one group's (db/migrations/008_group_names.rb).
+ def test_a_create_under_a_taken_name_is_refused
with_contacts({}) do |store|
- store.create_group(name: "sync:Test User")
+ store.create_group(name: "Booles")
- post "/groups", name: "sync:Test User"
+ post "/groups", name: "Booles"
assert_equal 200, last_response.status
- assert_includes last_response.body, "Another group is already named sync:Test User."
+ assert_includes last_response.body, "Another group is already named Booles."
assert_equal 1, store.all_groups.size
end
end
@@ -227,16 +227,16 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
- def test_a_rename_to_a_taken_sync_name_is_refused_and_changes_nothing
+ def test_a_rename_to_a_taken_name_is_refused_and_changes_nothing
with_contacts(BOOLES) do |store|
- store.create_group(name: "sync:Test User")
+ store.create_group(name: "Clarks")
id = household(store)
- post "/groups/#{id}", version: store.group(id).version, name: "sync:Test User", note: "Gate code 1854.",
+ post "/groups/#{id}", version: store.group(id).version, name: "Clarks", note: "Gate code 1854.",
members: ["", "ada"]
assert_equal 200, last_response.status
- assert_includes last_response.body, "Another group is already named sync:Test User; nothing was saved."
+ assert_includes last_response.body, "Another group is already named Clarks; nothing was saved."
assert_equal "Booles", store.group(id).name
assert_equal %w[george mary], store.group(id).members
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index ea4f719..78f647a 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1446,18 +1446,24 @@ class StoreTest < Minitest::Test
end
end
- # A client's create has to join exactly one group, so a `sync:` name
- # is unique (db/migrations/007_sync_names.rb); any other may repeat.
- def test_a_sync_name_is_unique_and_others_are_not
+ # A name is one group's (db/migrations/008_group_names.rb). Nameless
+ # groups are the exception, and a group keeping the name it has is no
+ # collision with itself — every group edit rewrites the name it found
+ # (#edit_group).
+ def test_a_name_is_one_groups_and_nameless_groups_are_any_number
with_store do |store|
+ household = store.create_group(name: "Household")
store.create_group(name: "sync:Alpha Chen")
- store.create_group(name: "Household")
- store.create_group(name: "Household")
- other = store.create_group(name: "sync:Zoë Chen")
+ store.create_group
+ other = store.create_group
- assert_raises(Sequel::UniqueConstraintViolation) { store.create_group(name: "sync:Alpha Chen") }
+ assert_raises(Sequel::UniqueConstraintViolation) { store.create_group(name: "Household") }
assert_raises(Sequel::UniqueConstraintViolation) { store.rename_group(other, name: "sync:Alpha Chen") }
- assert_equal "sync:Zoë Chen", store.group(other).name
+ assert_nil store.group(other).name
+
+ store.rename_group(household, name: "Household")
+
+ assert_equal "Household", store.group(household).name
end
end