books: the table is named for the book, not for the name
`book_names` named the table after its one column, so the word for the
thing and the word for the table disagreed and every reader carried
both. A row is a book that goes by a name of its own, and the table is
`books` (010). SQLite carries a table's constraints over with it, so
009's two rules still hold and no row moves.
A word is only worth renaming if it then means one thing, so the cards
one client syncs are Store#book_cards rather than Store#book, the
reason vcard's octets are bytes. Web#book keeps its name: it is the
requester's book and no table is in scope there.
Now rather than later: nothing has dumped a books.yml yet, so this is a
migration and a pass over one class, where a deployment whose dump
history carried the old file would also need that file cleaned up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SB9PbfzjHsGjApxNNt7Qj5
diff --git a/AGENTS.md b/AGENTS.md
index 7ffccef..1e8e329 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,9 +21,9 @@ rake contacts:reid # Move contacts whose ids are not server-minted
# onto minted ones, rewriting each card's UID
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, groups, and book
- # names to data/dump and commit them to the
- # repository there (DUMP=path to move it)
+rake db:dump # Write the cards, birthdays, groups, and books
+ # to data/dump and commit them to the repository
+ # there (DUMP=path to move it)
rake dev # Dev server, seeded from test/fixtures/cards into a
# throwaway tmpdir on every start, reloading on change
# (needs fd and entr)
diff --git a/README.md b/README.md
index 10a768b..ef8b63f 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ the birthdays — the two things in it that cannot be rebuilt, the
birthdays because a partial date has no vCard 3.0 spelling — and an
index of parsed properties, which can: `rake index:rebuild` derives
that again from the stored cards alone. `rake db:dump` writes the
-cards, birthdays, groups, and book names out as plain files, into
+cards, birthdays, groups, and books out as plain files, into
`data/dump` unless `DUMP` names another directory, and commits them to
that directory's own repository, so a snapshot is something to revert
to (`docs/plans/2026-09-12-database-dump.md`,
diff --git a/db/migrations/010_books.rb b/db/migrations/010_books.rb
new file mode 100644
index 0000000..dbc6e40
--- /dev/null
+++ b/db/migrations/010_books.rb
@@ -0,0 +1,14 @@
+# The table is named for what a row is rather than for its one column.
+# A book is what a login syncs (Store#book_cards), and a row here is
+# one that goes by a name of its own; `book_names` named the table
+# after the `name` column, leaving the word for the thing and the word
+# for the table at odds (docs/plans/2026-09-21-books-not-book-names.md).
+#
+# A rename and nothing else. SQLite carries a table's own constraints
+# over with it, so 009's two rules — a login holds one row, a name is
+# one login's — still hold, and no row moves.
+Sequel.migration do
+ change do
+ rename_table :book_names, :books
+ end
+end
diff --git a/docs/plans/2026-09-19-book-names-in-the-dump.md b/docs/plans/2026-09-19-books-in-the-dump.md
similarity index 58%
rename from docs/plans/2026-09-19-book-names-in-the-dump.md
rename to docs/plans/2026-09-19-books-in-the-dump.md
index e94f3f5..220f80a 100644
--- a/docs/plans/2026-09-19-book-names-in-the-dump.md
+++ b/docs/plans/2026-09-19-books-in-the-dump.md
@@ -1,16 +1,19 @@
-# Book names in the dump
+# Books in the dump
-2026-09-19. `rake db:dump` writes `book_names.yml` beside the cards,
-the birthdays, and the groups: every login that has a book name, and
+2026-09-19. `rake db:dump` writes `books.yml` beside the cards, the
+birthdays, and the groups: every login whose book goes by a name, and
the name it goes by.
+Written as `book_names.yml` on the day, off the table's name then;
+`2026-09-21-books-not-book-names.md` renamed both.
+
## Why it belongs there
The dump holds what the store cannot rebuild
-(`2026-09-12-database-dump.md`), and a book name is exactly that. Nothing
-re-derives a `book_names` row — the migration says so in as many words
-(db/migrations/009_book_names.rb) — and it is not carried by any card, so
-it went out of a dump the way the birthdays would have.
+(`2026-09-12-database-dump.md`), and a book's name is exactly that.
+Nothing re-derives one of those rows — the migration says so in as many
+words (db/migrations/009_book_names.rb) — and no card carries it, so it
+went out of a dump the way the birthdays would have.
Losing one is not a cosmetic loss either. A login whose name is gone
goes back to syncing `sync:<login>`, and the `sync:<name>` group the dump
@@ -23,23 +26,23 @@ down.
| Path | Holds |
|---|---|
-| `book_names.yml` | The name each login's book goes by, keyed by login |
+| `books.yml` | The name each named book goes by, keyed by login |
One file rather than a directory, the birthdays' shape: a row is a login
and a string, and there is one per user of the deployment. Written every
dump, `--- {}` for a deployment that has named no book, so the file is
the answer rather than its absence being one.
-Logins are ordered in `Store#all_book_names` rather than by the task, the
+Logins are ordered in `Store#all_books` rather than by the task, the
birthdays' arrangement reversed. `Store#birthdays_by_id` is shared with
the listing reads, which do not care, so `tasks/db.rake` sorts what it
-writes; nothing but the dump reads all the book names, so the order the
-file needs lives with the read.
+writes; nothing but the dump reads all the books, so the order the file
+needs lives with the read.
## Still not read back
Nothing reads a dump back, this no more than the rest. A restore would
need an importer, and `Store#name_book` is the writer it would call —
which renames the `sync:` group as it goes, so an importer replaying
-`book_names.yml` after the groups would rename what it had just restored.
+`books.yml` after the groups would rename what it had just restored.
The order is the importer's problem to solve when one is written.
diff --git a/docs/plans/2026-09-21-books-not-book-names.md b/docs/plans/2026-09-21-books-not-book-names.md
new file mode 100644
index 0000000..f02231b
--- /dev/null
+++ b/docs/plans/2026-09-21-books-not-book-names.md
@@ -0,0 +1,45 @@
+# Books, not book names
+
+2026-09-21. The `book_names` table is `books`
+(db/migrations/010_books.rb), and the word `book` inside `Store` now
+names one thing.
+
+## The table was named for its column
+
+A book is what a login syncs. `book_names` named the table after the
+`name` column instead, so the table and the thing it held went by
+different words, and every reader had to hold both. A row here is a book
+that goes by a name of its own; a login with no row has a book all the
+same, which is the state the table exists to record a departure from.
+
+The rename is a rename and nothing else. SQLite carries a table's own
+constraints over with it, so 009's two rules — a login holds one row, a
+name is one login's — still hold, and no row moves.
+
+## What the word costs elsewhere
+
+`books` cannot mean a table of names while `book` means a set of cards.
+`Store#book(login)` answered with the card ids one client syncs, so it
+is `Store#book_cards(login)` now, for the same reason `vcard` names a
+`VCard` and a card's octets are `bytes` (AGENTS.md). `Web#book` keeps
+its name: it is the requester's book, and no table is in scope there.
+
+The pairs that stay, because each already said which half it meant:
+
+| Reads | Answers |
+|---|---|
+| `Store#books` | The table |
+| `Store#all_books` | Every named book, by login — `groups`/`all_groups`'s arrangement |
+| `Store#book_name(login)` | The name, or nil for a book that goes by its login |
+| `Store#name_book(login, name)` | Sets it, and renames the `sync:` group with it |
+| `Store#book_cards(login)` | The card ids that client syncs |
+
+`Snapshot#books` and the dump's `books.yml` follow the table
+(`2026-09-19-books-in-the-dump.md`).
+
+## Why now
+
+The feature is five days old (`2026-09-16-book-names.md`) and the dump
+was about to write the name into a file that version control would keep.
+A rename costs a migration and a pass over one class today; it costs a
+dump migration once a deployment's history carries the old file.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 9afa6ba..f5c0280 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -89,9 +89,10 @@ module ProTacts
# Everything the store cannot rebuild, read at one moment: the
# stored cards by id, the birthdays by card id, the groups, and the
- # book names by login. What `rake db:dump` writes (tasks/db.rake).
+ # books — the name each named one goes by, keyed by login. What
+ # `rake db:dump` writes (tasks/db.rake).
# @rbs skip
- Snapshot = Data.define(:cards, :birthdays, :groups, :book_names)
+ Snapshot = Data.define(:cards, :birthdays, :groups, :books)
# A group as the admin screens read one: its own row, the lines it
# lends in the order it lends them, and its members' card ids. The
@@ -268,7 +269,7 @@ module ProTacts
},
birthdays: birthdays_by_id,
groups: all_groups,
- book_names: all_book_names,
+ books: all_books,
)
end
end
@@ -383,8 +384,11 @@ module ProTacts
# 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.
+ # Named for the cards rather than for the book, because `books` is
+ # the table of the books that have names
+ # (docs/plans/2026-09-21-books-not-book-names.md).
#: (String login) -> Set[String]
- def book(login)
+ def book_cards(login)
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
@@ -393,7 +397,7 @@ module ProTacts
# the login itself.
#: (String login) -> String?
def book_name(login)
- book_names.where(login:).sole.fetch(:name).to_s
+ books.where(login:).sole.fetch(:name).to_s
rescue Sequel::NoMatchingRow
nil
end
@@ -413,8 +417,8 @@ module ProTacts
@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?
+ books.where(login:).delete
+ books.insert(login:, name:) unless name.nil?
rename_group(group.to_s, name: own_sync_name(login)) unless group.nil?
end
end
@@ -898,8 +902,8 @@ module ProTacts
end
#: () -> Sequel::Dataset
- def book_names
- @database[:book_names]
+ def books
+ @database[:books]
end
#: () -> Sequel::Dataset
@@ -1451,14 +1455,15 @@ module ProTacts
end
end
- # Every book name, keyed by login, for the dump — the one read that
- # wants them all (docs/plans/2026-09-19-book-names-in-the-dump.md).
- # Ordered here rather than by the caller, the birthdays' arrangement
- # reversed: nothing else reads this, so the order a dumped file
- # needs belongs with the read.
+ # Every named book, the name keyed by the login whose book it is,
+ # for the dump — the one read that wants them all
+ # (docs/plans/2026-09-19-books-in-the-dump.md). Ordered here
+ # rather than by the caller, the birthdays' arrangement reversed:
+ # nothing else reads this, so the order a dumped file needs belongs
+ # with the read.
#: () -> Hash[String, String]
- def all_book_names
- book_names.order(:login).all.to_h {
+ def all_books
+ books.order(:login).all.to_h {
[it.fetch(:login).to_s, it.fetch(:name).to_s] #: [String, String]
}
end
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 74fd8ba..1cd17b9 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -322,7 +322,7 @@ module ProTacts
#: () -> Set[String]
def book
- @book ||= store.book(@login)
+ @book ||= store.book_cards(@login)
end
# One member of the requester's book, or nil for a card outside it
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 7a4fb89..0b42bb8 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -50,9 +50,9 @@ module ProTacts
attr_reader cards: Hash[String, String]
attr_reader birthdays: Hash[String, Birthday]
attr_reader groups: Array[Group]
- attr_reader book_names: Hash[String, String]
+ attr_reader books: Hash[String, String]
- def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], book_names: Hash[String, String]) -> instance
+ def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], books: Hash[String, String]) -> instance
end
class GroupEdit
diff --git a/tasks/db.rake b/tasks/db.rake
index a4872e5..799ef0d 100644
--- a/tasks/db.rake
+++ b/tasks/db.rake
@@ -1,7 +1,7 @@
# A readable copy of what the store cannot rebuild: each stored card as
-# its bytes, the birthdays, the groups, and the book names, split the
-# way the store splits them (docs/plans/2026-09-12-database-dump.md,
-# docs/plans/2026-09-19-book-names-in-the-dump.md). cards/ and
+# its bytes, the birthdays, the groups, and the books, split the way
+# the store splits them (docs/plans/2026-09-12-database-dump.md,
+# docs/plans/2026-09-19-books-in-the-dump.md). cards/ and
# groups/ are made to hold exactly what the store holds, so a dump kept
# in version control shows deletions too; anything else in the
# directory is left alone.
@@ -24,7 +24,7 @@ directory cards.to_s
directory groups.to_s
namespace :db do
- desc "Dump the cards, birthdays, groups, and book names into #{dump} and commit them there (DUMP=path to move it)"
+ desc "Dump the cards, birthdays, groups, and books into #{dump} and commit them there (DUMP=path to move it)"
task dump: [cards.to_s, groups.to_s] do
require "yaml"
require "pro_tacts/store"
@@ -44,7 +44,7 @@ namespace :db do
["#{group.id}.yml", YAML.dump({"name" => group.name, "lines" => group.lines, "members" => group.members})]
})
- dump_file(dump / "book_names.yml", YAML.dump(snapshot.book_names))
+ dump_file(dump / "books.yml", YAML.dump(snapshot.books))
end
commit_dump(dump)
puts "dumped #{database} into #{dump}"
diff --git a/test/pro_tacts/import/test_finalize.rb b/test/pro_tacts/import/test_finalize.rb
index 25dcc67..c369054 100644
--- a/test/pro_tacts/import/test_finalize.rb
+++ b/test/pro_tacts/import/test_finalize.rb
@@ -128,7 +128,7 @@ class ImportFinalizeTest < Minitest::Test
# user's book, which a card in no `sync:` group is outside of.
def test_a_contact_whose_card_is_in_no_book_still_leaves_the_mac
with_landed_plan(edit: ->(yaml) { yaml.sub("- sync:*\n", "") }) do |plan, store, client|
- refute_includes store.book("test@example.com"), IDS.first
+ refute_includes store.book_cards("test@example.com"), IDS.first
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
result = Finalize.call(plan, client:, mac:)
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index a35b989..0f379e3 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1490,8 +1490,8 @@ class StoreTest < Minitest::Test
FixtureData.seed_group(store, name: "sync:Alpha Chen", members: %w[aiden znorth])
FixtureData.seed_group(store, name: "Household", members: ["yuki"])
- assert_equal Set["aiden", "znorth"], store.book("Alpha Chen")
- assert_equal Set["aiden"], store.book("Zoë Chen")
+ assert_equal Set["aiden", "znorth"], store.book_cards("Alpha Chen")
+ assert_equal Set["aiden"], store.book_cards("Zoë Chen")
end
end
@@ -1503,7 +1503,7 @@ class StoreTest < Minitest::Test
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["aiden"], store.book_cards("Alpha Chen")
end
end
@@ -1518,14 +1518,14 @@ class StoreTest < Minitest::Test
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 Set["aiden"], store.book_cards("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")
+ assert_equal Set["aiden"], store.book_cards("alpha@example.com")
end
end
@@ -1534,7 +1534,7 @@ class StoreTest < Minitest::Test
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")
+ assert_equal Set["aiden"], store.book_cards("alpha@example.com")
end
end
@@ -1557,7 +1557,7 @@ class StoreTest < Minitest::Test
store.put("aiden", vcard(AIDEN), client: true)
store.put("znorth", vcard(ZED), client: true)
- assert_equal Set["aiden", "znorth"], store.book("Zoë Chen")
+ assert_equal Set["aiden", "znorth"], store.book_cards("Zoë Chen")
assert_equal ["sync:*"], store.all_groups.map(&:name)
assert_equal ["put"], store.changes_of("aiden").map(&:action)
end
@@ -1567,7 +1567,7 @@ class StoreTest < Minitest::Test
with_store({"aiden" => AIDEN}) do |store|
store.put("aiden", vcard(AIDEN), client: true)
- assert_empty store.book("Alpha Chen")
+ assert_empty store.book_cards("Alpha Chen")
end
end
@@ -2137,7 +2137,7 @@ class StoreTest < Minitest::Test
assert_equal({"aiden" => AIDEN}, snapshot.cards)
assert_equal({"aiden" => ProTacts::Birthday.new(year: 1985, month: 4, day: 12)}, snapshot.birthdays)
assert_equal [["Booles", ["aiden"]]], snapshot.groups.map { [it.name, it.members] }
- assert_equal({"alpha@example.com" => "Alpha Chen"}, snapshot.book_names)
+ assert_equal({"alpha@example.com" => "Alpha Chen"}, snapshot.books)
end
end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index f79fead..9d96483 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1024,8 +1024,8 @@ class WebTest < Minitest::Test
put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
assert_equal 201, last_response.status
- assert_equal Set["new"], store.book("test@example.com")
- assert_equal Set["new"], store.book("zoë@example.com")
+ assert_equal Set["new"], store.book_cards("test@example.com")
+ assert_equal Set["new"], store.book_cards("zoë@example.com")
end
end
diff --git a/test/tasks/test_db.rb b/test/tasks/test_db.rb
index 432008f..49ebc81 100644
--- a/test/tasks/test_db.rb
+++ b/test/tasks/test_db.rb
@@ -30,7 +30,7 @@ class DbTasksTest < Minitest::Test
assert_equal({"aiden" => {"year" => 1985, "month" => 4}}, YAML.safe_load((root / "dump" / "birthdays.yml").read))
assert_equal({"name" => "Booles", "lines" => ["NOTE:a household"], "members" => ["aiden"]},
YAML.safe_load((root / "dump" / "groups" / "#{group}.yml").read))
- assert_equal({"alpha@example.com" => "Alpha Chen"}, YAML.safe_load((root / "dump" / "book_names.yml").read))
+ assert_equal({"alpha@example.com" => "Alpha Chen"}, YAML.safe_load((root / "dump" / "books.yml").read))
end
end
@@ -42,7 +42,7 @@ class DbTasksTest < Minitest::Test
dump(root)
- assert_equal({}, YAML.safe_load((root / "dump" / "book_names.yml").read))
+ assert_equal({}, YAML.safe_load((root / "dump" / "books.yml").read))
end
end