db: the dump writes the book names
The dump holds what the store cannot rebuild, and a book name is that:
nothing re-derives a book_names row, and no card carries one. A login
whose name is lost goes back to syncing sync:<login>, leaving the
sync:<name> group the dump does hold pointing at nobody's book.
book_names.yml sits beside birthdays.yml, keyed by login, written every
dump so a deployment that has named no book says so rather than leaving
it to a missing file.
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 7b7db2f..7ffccef 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, and groups to data/dump
- # and commit them to the repository there
- # (DUMP=path to move 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 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 e27bdcd..10a768b 100644
--- a/README.md
+++ b/README.md
@@ -38,10 +38,10 @@ 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, and groups 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`,
+cards, birthdays, groups, and book names 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`,
`docs/plans/2026-09-20-the-dump-commits.md`).
A group holds attributes its members' cards carry: an address or a note
diff --git a/docs/plans/2026-09-19-book-names-in-the-dump.md b/docs/plans/2026-09-19-book-names-in-the-dump.md
new file mode 100644
index 0000000..e94f3f5
--- /dev/null
+++ b/docs/plans/2026-09-19-book-names-in-the-dump.md
@@ -0,0 +1,45 @@
+# Book names 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
+the name it goes by.
+
+## 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.
+
+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
+does hold is then a group nobody's book points at
+(`2026-09-16-book-names.md`, "The group moves with the name"). The row
+is what ties a login to its group, and it was the part not written
+down.
+
+## What it looks like
+
+| Path | Holds |
+|---|---|
+| `book_names.yml` | The name each login's 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
+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.
+
+## 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.
+The order is the importer's problem to solve when one is written.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 5257991..9afa6ba 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -88,10 +88,10 @@ module ProTacts
GroupEdit = Data.define(:group_id, :position, :line)
# Everything the store cannot rebuild, read at one moment: the
- # stored cards by id, the birthdays by card id, and the groups.
- # What `rake db:dump` writes (tasks/db.rake).
+ # 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).
# @rbs skip
- Snapshot = Data.define(:cards, :birthdays, :groups)
+ Snapshot = Data.define(:cards, :birthdays, :groups, :book_names)
# 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,6 +268,7 @@ module ProTacts
},
birthdays: birthdays_by_id,
groups: all_groups,
+ book_names: all_book_names,
)
end
end
@@ -1450,6 +1451,18 @@ 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.
+ #: () -> Hash[String, String]
+ def all_book_names
+ book_names.order(:login).all.to_h {
+ [it.fetch(:login).to_s, it.fetch(:name).to_s] #: [String, String]
+ }
+ end
+
# Every birthday, keyed by card, for the listing reads that compose
# a whole collection in one pass.
#: () -> Hash[String, Birthday]
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index a84ff25..7a4fb89 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -50,8 +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]
- def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group]) -> instance
+ def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], book_names: Hash[String, String]) -> instance
end
class GroupEdit
diff --git a/tasks/console.rake b/tasks/console.rake
index da377cc..cc02874 100644
--- a/tasks/console.rake
+++ b/tasks/console.rake
@@ -6,7 +6,7 @@
#
# Before the prompt, the dump is taken, and taking it is committing it
# (tasks/db.rake), so a session starts from a recorded snapshot of the
-# three things the store cannot rebuild and a mistake at the prompt is
+# four things the store cannot rebuild and a mistake at the prompt is
# a revert in the dump's repository rather than a restore from backup.
# db:dump raising is what keeps the console from opening on a session
# its safety net never armed.
diff --git a/tasks/db.rake b/tasks/db.rake
index c55a15e..a4872e5 100644
--- a/tasks/db.rake
+++ b/tasks/db.rake
@@ -1,6 +1,7 @@
# A readable copy of what the store cannot rebuild: each stored card as
-# its bytes, the birthdays, and the groups, split the way the store
-# splits them (docs/plans/2026-09-12-database-dump.md). cards/ and
+# 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
# 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.
@@ -23,7 +24,7 @@ directory cards.to_s
directory groups.to_s
namespace :db do
- desc "Dump the cards, birthdays, and groups into #{dump} and commit them there (DUMP=path to move it)"
+ desc "Dump the cards, birthdays, groups, and book names 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"
@@ -42,6 +43,8 @@ namespace :db do
dump_directory(groups, snapshot.groups.to_h { |group|
["#{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))
end
commit_dump(dump)
puts "dumped #{database} into #{dump}"
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index ebd5a71..a35b989 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -2130,12 +2130,14 @@ class StoreTest < Minitest::Test
with_store({"aiden" => AIDEN_BORN}) do |store|
group = store.create_group(name: "Booles")
store.add_member(group, "aiden")
+ store.name_book("alpha@example.com", "Alpha Chen")
snapshot = store.snapshot
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)
end
end
diff --git a/test/tasks/test_db.rb b/test/tasks/test_db.rb
index 4addc84..432008f 100644
--- a/test/tasks/test_db.rb
+++ b/test/tasks/test_db.rb
@@ -12,15 +12,17 @@ require "pro_tacts/vcard"
class DbTasksTest < Minitest::Test
AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
- # The stored card as its bytes, and the two things beside it that no
- # card carries: the birthday, in a shape no client is sent, and the
- # group with what it lends and who it lends to.
+ # The stored card as its bytes, and the three things beside it that no
+ # card carries: the birthday, in a shape no client is sent, the group
+ # with what it lends and who it lends to, and the name a login's book
+ # goes by.
def test_the_dump_writes_what_the_store_cannot_rebuild
with_store do |store, root|
store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")))
group = store.create_group(name: "Booles")
store.set_group_lines(group, ["NOTE:a household"])
store.add_member(group, "aiden")
+ store.name_book("alpha@example.com", "Alpha Chen")
dump(root)
@@ -28,6 +30,19 @@ 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))
+ end
+ end
+
+ # A deployment that has named no book still gets the file, so the
+ # dump says there are none rather than leaving it to a missing file.
+ def test_a_store_that_has_named_no_book_dumps_the_file_anyway
+ with_store do |store, root|
+ store.put("aiden", vcard(AIDEN))
+
+ dump(root)
+
+ assert_equal({}, YAML.safe_load((root / "dump" / "book_names.yml").read))
end
end