books: name a login's book with a rake task
The group read sync:alpha@example.com, a login where a name should be;
rake book:name picks the name and renames the group, members logged for free.
Now that a deployment chooses it, the book no longer folds case —
sync:<name> matches as spelled, the unique index's own base.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/AGENTS.md b/AGENTS.md
index 21adb2f..169d735 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,8 @@ rake fixtures # Re-record response fixtures from current behavior
rake fixtures:extract # A logged exchange's request as a fixture step
# (EXCHANGE=id STEP=test/fixtures/<recording>/NN-name)
rake index:rebuild # Derive the parsed index again from the stored cards
+rake book:name # Name a login's book and rename its sync group
+ # (LOGIN=login NAME=name; no NAME clears it)
rake db:dump # Write the cards, birthdays, and groups to data/dump
# (DUMP=path to move it)
rake dev # Dev server, seeded from test/fixtures/cards into a
diff --git a/README.md b/README.md
index e86d64e..d4d18d8 100644
--- a/README.md
+++ b/README.md
@@ -70,11 +70,14 @@ Funnel traffic, which is public, and traffic from tagged devices.
Each user syncs a book of their own rather than every card: the members
of the group `sync:*`, which everyone gets, and of `sync:<login>`,
-matched in any case. A card a client creates joins `sync:*`, so everyone
+matched exactly. `rake book:name LOGIN=... NAME=...` gives a login's
+book a name to use in place of the login, renaming its group to
+`sync:<name>`. A card a client creates joins `sync:*`, so everyone
gets it until someone takes it out. See
`docs/plans/2026-09-12-per-user-books.md`,
-`docs/plans/2026-09-15-client-creates-join-everyone.md`, and
-`docs/plans/2026-09-15-identity-from-one-header.md`.
+`docs/plans/2026-09-15-client-creates-join-everyone.md`,
+`docs/plans/2026-09-15-identity-from-one-header.md`, and
+`docs/plans/2026-09-16-book-names.md`.
DAV exchanges that go wrong — a status of 400 or more other than the 401
above, a crash, or a request that reported to Sentry — are written whole
diff --git a/db/migrations/009_book_names.rb b/db/migrations/009_book_names.rb
new file mode 100644
index 0000000..a236206
--- /dev/null
+++ b/db/migrations/009_book_names.rb
@@ -0,0 +1,18 @@
+# A login's book can go by a name other than the login, so the group
+# choosing it reads `sync:Alpha Chen` rather than
+# `sync:alpha@example.com` (docs/plans/2026-09-16-book-names.md).
+#
+# Both columns are exact bytes, the comparison Store#book makes, and a
+# name is one login's for 008's reason: two logins sharing it would
+# share a book.
+#
+# Like the groups, nothing can re-derive a row here. No timestamps, the
+# birthdays' reason: a row is current state, replaced wholesale.
+Sequel.migration do
+ change do
+ create_table(:book_names, strict: true) do
+ String :login, text: true, null: false, primary_key: true
+ String :name, text: true, null: false, unique: true
+ end
+ end
+end
diff --git a/docs/plans/2026-09-16-book-names.md b/docs/plans/2026-09-16-book-names.md
new file mode 100644
index 0000000..3b6e4be
--- /dev/null
+++ b/docs/plans/2026-09-16-book-names.md
@@ -0,0 +1,38 @@
+# Book names
+
+2026-09-16. A login's book can go by a name other than the login, so the
+group choosing it reads `sync:Alpha Chen` rather than
+`sync:alpha@example.com`. This is the setting
+`2026-09-15-identity-from-one-header.md` left for next, under "One value".
+
+## Where the name lives
+
+In the database, in `book_names` (db/migrations/009_book_names.rb), one
+row per login that has a name. A login without a row goes by itself.
+There is no screen for it: `rake book:name LOGIN=... NAME=...` sets one,
+and the same task with no `NAME` goes back to the login. Naming a book is
+done once per user per deployment, and an environment variable holding a
+map of logins would be a second place a group name is spelled.
+
+Names are unique, since two logins sharing a name would share a book,
+and `*` is refused as everyone's.
+
+## The group moves with the name
+
+`Store#name_book` renames the login's `sync:` group, if it has one, in
+the same transaction as the row, through `Store#rename_group`. The book
+keeps its cards, and every member is logged as moved, so a client syncing
+it refetches rather than missing anything.
+
+Renaming the group from the groups screen does not update the row. The
+book then names a group that is not there, the same as a login whose
+group was renamed before this.
+
+## Matched as spelled
+
+`Store#book` used to match `sync:<login>` in any case, because the name
+was a display name a user could recase at will. With a name that is the
+deployment's to choose, a login whose case reads badly gets a name
+instead, so the group name and the login are both compared as exact
+bytes, the comparison the unique index on group names already makes
+(`2026-09-15-group-names-are-unique.md`).
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d943c5d..b8a696f 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -357,19 +357,46 @@ module ProTacts
end
# The cards one user's client syncs: every member of `sync:*` and of
- # `sync:<login>`, each once (docs/plans/2026-09-12-per-user-books.md).
- # The login matches in any case, folded here in Ruby because SQLite's
- # lower() folds ASCII alone; the prefix only as #sync_name? reads it,
- # since no other group's moves are logged.
+ # `sync:<name>`, each once (docs/plans/2026-09-12-per-user-books.md),
+ # where the name is the login's book name or the login itself
+ # (docs/plans/2026-09-16-book-names.md). Matched as spelled: a login
+ # whose case reads badly gets a name rather than a folded match.
#: (String login) -> Set[String]
def book(login)
- own = "#{SYNC_PREFIX}#{login}"
- ids = groups.where(Sequel.function(:glob, "#{SYNC_PREFIX}*", :name)).all
- .select { |group| (label = group.fetch(:name).to_s) == EVERYONE || label.casecmp?(own) }
- .map { it.fetch(:id) }
+ ids = groups.where(name: [EVERYONE, own_sync_name(login)]).select_map(:id)
Set.new(group_members.where(group_id: ids).select_map(:card_id).map(&:to_s))
end
+ # The name a login's book goes by in place of the login, or nil for
+ # the login itself.
+ #: (String login) -> String?
+ def book_name(login)
+ book_names.where(login:).sole.fetch(:name).to_s
+ rescue Sequel::NoMatchingRow
+ nil
+ end
+
+ # Sets the name a login's book goes by, or with nil or a blank goes
+ # back to the login. The login's `sync:` group, if it has one, is
+ # renamed in the same transaction, so the book keeps its cards and
+ # #rename_group logs every member for the clients syncing it. A name
+ # another login's book goes by raises on the unique index, and `*`
+ # is refused before the transaction, #put's reason for building a
+ # Contact outside one.
+ #: (String login, String? name) -> void
+ def name_book(login, name)
+ name = name.to_s.strip
+ name = nil if name.empty?
+ raise ArgumentError, "#{EVERYONE} is everyone's book, not #{login}'s" if name == "*"
+
+ @database.transaction do
+ group = groups.where(name: own_sync_name(login)).select_map(:id).first
+ book_names.where(login:).delete
+ book_names.insert(login:, name:) unless name.nil?
+ rename_group(group.to_s, name: own_sync_name(login)) unless group.nil?
+ end
+ end
+
# The id of the card whose UID property holds this value, if one
# does — the read behind the no-uid-conflict precondition (RFC 6352
# section 6.3.2.1). It reads the index, so a card whose UID line
@@ -758,6 +785,11 @@ module ProTacts
@database[:group_members]
end
+ #: () -> Sequel::Dataset
+ def book_names
+ @database[:book_names]
+ end
+
#: () -> Sequel::Dataset
def group_properties
@database[:group_properties]
@@ -868,6 +900,12 @@ module ProTacts
create_group(name: EVERYONE)
end
+ # The group name that puts cards in this login's book alone.
+ #: (String login) -> String
+ def own_sync_name(login)
+ "#{SYNC_PREFIX}#{book_name(login) || login}"
+ end
+
#: (String? name) -> bool
def sync_name?(name)
name&.start_with?(SYNC_PREFIX) == true
diff --git a/tasks/book.rake b/tasks/book.rake
new file mode 100644
index 0000000..113bae2
--- /dev/null
+++ b/tasks/book.rake
@@ -0,0 +1,16 @@
+# The name a login's book goes by, in place of the login
+# (docs/plans/2026-09-16-book-names.md).
+
+namespace :book do
+ desc "Name LOGIN's book NAME, renaming its sync group (no NAME goes back to the login)"
+ task :name do
+ require "pro_tacts"
+ require "pro_tacts/store"
+
+ login = ENV.fetch("LOGIN")
+ ProTacts::Store.connect(ProTacts.config.database_path) do |store|
+ store.name_book(login, ENV.fetch("NAME", nil))
+ puts "#{login} syncs #{ProTacts::Store::SYNC_PREFIX}#{store.book_name(login) || login}"
+ end
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 78f647a..baa3290 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1367,17 +1367,58 @@ class StoreTest < Minitest::Test
end
end
- # The display name in any case, beyond ASCII too; the prefix only as
- # spelled, since a group that is no `sync:` group puts nothing in a
- # book.
- def test_a_book_matches_the_display_name_in_any_case
+ # A case variant, or the prefix spelled another way, is some other
+ # group.
+ def test_a_book_matches_its_group_name_exactly
with_store({"aiden" => AIDEN, "znorth" => ZED, "yuki" => YUKI}) do |store|
- FixtureData.seed_group(store, name: "sync:alpha chen", members: ["aiden"])
- FixtureData.seed_group(store, name: "SYNC:Alpha Chen", members: ["znorth"])
- FixtureData.seed_group(store, name: "sync:zoë chen", members: ["yuki"])
+ FixtureData.seed_group(store, name: "sync:Alpha Chen", members: ["aiden"])
+ FixtureData.seed_group(store, name: "sync:alpha chen", members: ["znorth"])
+ FixtureData.seed_group(store, name: "SYNC:Alpha Chen", members: ["yuki"])
assert_equal Set["aiden"], store.book("Alpha Chen")
- assert_equal Set["yuki"], store.book("ZOË CHEN")
+ end
+ end
+
+ # A named book is the named group's, and the login's own group moves
+ # to the name with its members logged, so a client hears of nothing
+ # but the rename (docs/plans/2026-09-16-book-names.md).
+ def test_a_named_book_takes_its_group_along
+ with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+ id = FixtureData.seed_group(store, name: "sync:alpha@example.com", members: ["aiden"])
+
+ store.name_book("alpha@example.com", "Alpha Chen")
+
+ assert_equal "sync:Alpha Chen", store.group(id).name
+ assert_equal "Alpha Chen", store.book_name("alpha@example.com")
+ assert_equal Set["aiden"], store.book("alpha@example.com")
+ assert_equal %w[group put], store.changes_of("aiden").map(&:action)
+
+ store.name_book("alpha@example.com", " ")
+
+ assert_equal "sync:alpha@example.com", store.group(id).name
+ assert_nil store.book_name("alpha@example.com")
+ assert_equal Set["aiden"], store.book("alpha@example.com")
+ end
+ end
+
+ def test_a_book_is_named_before_its_group_exists
+ with_store({"aiden" => AIDEN}) do |store|
+ store.name_book("alpha@example.com", "Alpha Chen")
+ FixtureData.seed_group(store, name: "sync:Alpha Chen", members: ["aiden"])
+
+ assert_equal Set["aiden"], store.book("alpha@example.com")
+ end
+ end
+
+ # Another login's name would share a book, and `*` is everyone's.
+ def test_a_book_name_is_one_logins
+ with_store do |store|
+ store.name_book("alpha@example.com", "Alpha Chen")
+ store.name_book("alpha@example.com", "Alpha Chen")
+
+ assert_raises(Sequel::UniqueConstraintViolation) { store.name_book("zoe@example.com", "Alpha Chen") }
+ assert_raises(ArgumentError) { store.name_book("zoe@example.com", "*") }
+ assert_nil store.book_name("zoe@example.com")
end
end
diff --git a/test/tasks/test_book.rb b/test/tasks/test_book.rb
new file mode 100644
index 0000000..4f7cb9c
--- /dev/null
+++ b/test/tasks/test_book.rb
@@ -0,0 +1,43 @@
+require_relative "../test_helper"
+
+require "pathname"
+require "rake"
+require "tmpdir"
+
+require "pro_tacts/store"
+
+class BookTasksTest < Minitest::Test
+ def test_naming_a_book_renames_its_group
+ Dir.mktmpdir do |root|
+ root = Pathname.new(root)
+ ProTacts::Store.connect(root / "contacts.db") do |store|
+ id = store.create_group(name: "sync:alpha@example.com")
+
+ out, = run_task(root, "LOGIN" => "alpha@example.com", "NAME" => "Alpha Chen")
+
+ assert_equal "alpha@example.com syncs sync:Alpha Chen\n", out
+ assert_equal "sync:Alpha Chen", store.group(id).name
+ end
+ end
+ end
+
+ private
+
+ # Runs book:name in a Rake application of its own, with root as the
+ # data directory and env set for the run alone.
+ def run_task(root, env)
+ config = ProTacts.config
+ application = Rake.application
+ overridden = env.keys.to_h { [it, ENV.fetch(it, nil)] }
+
+ ProTacts.config = ProTacts::Config.new("PRO_TACTS_DATA_DIR" => root.to_s)
+ ENV.update(env)
+ Rake.application = Rake::Application.new
+ load (Pathname.new(__dir__).parent.parent / "tasks" / "book.rake").to_s
+ capture_io { Rake.application["book:name"].invoke }
+ ensure
+ ProTacts.config = config
+ Rake.application = application
+ ENV.update(overridden)
+ end
+end