store: read a book off the sync: groups
A book is sync:* plus sync:<name>, and a client create joins its writer's own;
a move into or out of one is logged, so a sync token hears of it, bytes changed or none.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/db/migrations/007_sync_names.rb b/db/migrations/007_sync_names.rb
new file mode 100644
index 0000000..a2b8e3f
--- /dev/null
+++ b/db/migrations/007_sync_names.rb
@@ -0,0 +1,16 @@
+# A `sync:` name is unique among groups: a client's create joins its
+# writer's `sync:<name>` group (Store#put), which has to be exactly one
+# group (docs/plans/2026-09-12-per-user-books.md). Every other name may
+# still repeat, a label being the author's to reuse, so the index is
+# partial.
+#
+# GLOB rather than LIKE for 005's reason: its case sensitivity is its
+# own rather than a per-connection pragma's. `*` is GLOB's wildcard, so
+# the pattern is every name that starts `sync:`, `sync:*` itself among
+# them. `glob(X, Y)` is `Y GLOB X`, pattern first, as in 005.
+Sequel.migration do
+ change do
+ add_index :groups, :name, unique: true, name: :groups_sync_name_is_unique,
+ where: Sequel.function(:glob, "sync:*", :name)
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 764a5c4..b0d08ae 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -171,6 +171,13 @@ module ProTacts
# the label is written against the table by name.
GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
+ # 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.
+ SYNC_PREFIX = "sync:" #: String
+ EVERYONE = "#{SYNC_PREFIX}*" #: String
+
# Sequel's migrations, run on open. They ship with the code rather
# than with a deployment, so the path is relative to this file.
# `__dir__` is nil only for code with no file behind it, which a
@@ -349,6 +356,19 @@ module ProTacts
)
end
+ # The cards one user's client syncs: every member of `sync:*` and of
+ # `sync:<name>`, each once (docs/plans/2026-09-12-per-user-books.md).
+ #: (String name) -> Set[String]
+ def book(name)
+ Set.new(
+ group_members
+ .join(:groups, id: :group_id)
+ .where(Sequel[:groups][:name] => [EVERYONE, "#{SYNC_PREFIX}#{name}"])
+ .select(Sequel[:group_members][:card_id])
+ .map { it.fetch(:card_id).to_s },
+ )
+ 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
@@ -388,8 +408,14 @@ module ProTacts
# nothing that is not text gets this far; VCard's own raise, at the
# construction the caller makes, is the assertion under that, and
# the bind is the third line.
- #: (String id, VCard vcard) -> Contact
- def put(id, vcard)
+ #
+ # `sync_to` is the writing user's display name, for a client's
+ # write: a card it creates joins that user's `sync:` group, created
+ # on first use, or it would drop out of the collection it was
+ # written to (docs/plans/2026-09-12-per-user-books.md, "The wire").
+ # A rewrite of a card that exists joins nothing.
+ #: (String id, VCard vcard, ?sync_to: String?) -> Contact
+ def put(id, vcard, sync_to: nil)
# The birthday half of the split a write makes
# (docs/plans/2026-09-11-every-birthday-in-the-model.md). A card
# holds at most one BDAY, and one that reads as a birthday leaves
@@ -464,6 +490,14 @@ module ProTacts
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
.insert(id: contact.id, vcard: stored.to_s)
+ # After the card, which the membership names by foreign key, and
+ # before the log entry, which records the card composed with
+ # whatever the group lends. The put's entry is the card's arrival
+ # in the book, so the join writes none of its own.
+ if sync_to && own.nil?
+ group_members.insert(group_id: sync_group_id(sync_to), card_id: contact.id)
+ contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(contact.id))
+ end
write_birthday(contact.id, birthday)
record(contact.id, "put", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
reindex(contact.id, stored)
@@ -529,6 +563,11 @@ 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 first; the second is the caller's to hear.
+ raise if groups.where(id:).empty?
+
next
end
end
@@ -554,10 +593,21 @@ module ProTacts
# A group's name, or none — NULL being the one spelling of
# nameless (db/migrations/005_group_identity.rb), so a blank is
# stored as that rather than refused by the schema. A name is on no
- # card, so no member's bytes move and nothing is logged.
+ # card, so no member's bytes move, but a `sync:` name is what puts a
+ # card in a book: a rename into, out of, or between them moves every
+ # member between books and logs each one (#fan_out's `moved`).
#: (String id, name: String?) -> void
def rename_group(id, name:)
- groups.where(id:).update(name: name.to_s.strip.empty? ? nil : name)
+ name = nil if name.to_s.strip.empty?
+ @database.transaction do
+ was = groups.where(id:).sole.fetch(:name)&.to_s
+ if was != name && (sync_name?(was) || sync_name?(name))
+ members = member_ids([id])
+ fan_out(members, moved: members) { groups.where(id:).update(name:) }
+ else
+ groups.where(id:).update(name:)
+ end
+ end
end
# Replaces what a group lends, wholesale, at positions from zero.
@@ -593,22 +643,30 @@ module ProTacts
end
# A card joins a group, and starts serving what the group lends.
- # Joining twice is joining once.
+ # Joining twice is joining once. Joining a `sync:` group puts the
+ # card in a book, which is logged whether or not its bytes moved.
#: (String group_id, String card_id) -> void
def add_member(group_id, card_id)
@database.transaction do
- fan_out([card_id]) { group_members.insert_conflict.insert(group_id:, card_id:) }
+ joining = sync_group?(group_id) && !member?(group_id, card_id)
+ fan_out([card_id], moved: joining ? [card_id] : []) {
+ group_members.insert_conflict.insert(group_id:, card_id:)
+ }
end
end
# A card leaves a group, and stops serving what the group lends —
# the one lever the model has for "everyone but this member"
# (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Edits
- # propagate to the group").
+ # propagate to the group"). Leaving a `sync:` group takes the card
+ # out of a book, logged the way joining one is.
#: (String group_id, String card_id) -> void
def remove_member(group_id, card_id)
@database.transaction do
- fan_out([card_id]) { group_members.where(group_id:, card_id:).delete }
+ leaving = sync_group?(group_id) && member?(group_id, card_id)
+ fan_out([card_id], moved: leaving ? [card_id] : []) {
+ group_members.where(group_id:, card_id:).delete
+ }
end
end
@@ -725,14 +783,20 @@ module ProTacts
# writing, whose own entry already carries this composition
# (docs/plans/2026-09-09-group-edits-propagate.md, "The fan-out").
#
+ # `moved` is the exception: cards logged whatever their bytes did,
+ # because a `sync:` membership moves a card into or out of a book
+ # without touching what it serves, and the log is the only way a
+ # client's sync token hears of it
+ # (docs/plans/2026-09-12-per-user-books.md, "The change log").
+ #
# Card ids rather than groups, because the cards a write can move
# are not always members yet: a card joining a group is read before
# the row that makes it one exists.
#
# Inside whatever transaction the caller has open: Sequel joins one
# rather than nesting, which is what lets this run from #put.
- #: (Array[String] cards, ?except: String?) { () -> void } -> void
- def fan_out(cards, except: nil)
+ #: (Array[String] cards, ?except: String?, ?moved: Array[String]) { () -> void } -> void
+ def fan_out(cards, except: nil, moved: [])
ids = cards - [except].compact
before = ids.to_h {
[it, composed(it)] #: [String, Contact]
@@ -741,7 +805,7 @@ module ProTacts
ids.each do |id|
was = before.fetch(id)
now = composed(id)
- next if was.vcard.to_s == now.vcard.to_s
+ next if was.vcard.to_s == now.vcard.to_s && !moved.include?(id)
record(id, "group", now.etag, CardDiff.between(was.vcard, now.vcard))
end
@@ -781,6 +845,31 @@ module ProTacts
}
end
+ # The id of one user's `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 a user's first create.
+ #: (String name) -> String
+ def sync_group_id(name)
+ groups.where(name: "#{SYNC_PREFIX}#{name}").sole.fetch(:id).to_s
+ rescue Sequel::NoMatchingRow
+ create_group(name: "#{SYNC_PREFIX}#{name}")
+ end
+
+ #: (String? name) -> bool
+ def sync_name?(name)
+ name&.start_with?(SYNC_PREFIX) == true
+ end
+
+ #: (String group_id) -> bool
+ def sync_group?(group_id)
+ sync_name?(groups.where(id: group_id).sole.fetch(:name)&.to_s)
+ end
+
+ #: (String group_id, String card_id) -> bool
+ def member?(group_id, card_id)
+ !group_members.where(group_id:, card_id:).empty?
+ end
+
# The cards belonging to any of these groups, each once — the
# members a write to those groups can move.
#: (Array[String] group_ids) -> Array[String]
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 684d7fb..d86253f 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -217,6 +217,11 @@ module ProTacts
name = r.params["name"].to_s.strip
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).
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::GroupsIndex.call(groups: store.all_groups, notice: "Another group is already named #{name}.")
end
end
@@ -871,7 +876,13 @@ module ProTacts
group.members
end #: Array[String]
- store.edit_group(id, name: r.params["name"].to_s, lines: group_lines(card), members:)
+ begin
+ store.edit_group(id, name: r.params["name"].to_s, lines: group_lines(card), members:)
+ rescue Sequel::UniqueConstraintViolation
+ # A taken `sync:` name (db/migrations/007_sync_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
end
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index e8182d5..32b10bc 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -108,6 +108,9 @@ module Sequel
def first: () -> Sequel::row?
def all: () -> Array[Sequel::row]
def count: () -> Integer
+ # Whether any row matches, asked of the database rather than by
+ # fetching one.
+ def empty?: () -> bool
def columns: () -> Array[Symbol]
def single_value: () -> untyped
def select_map: (untyped columns) -> Array[untyped]
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 2b04e29..b112edd 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -92,6 +92,19 @@ 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
+ with_contacts({}) do |store|
+ store.create_group(name: "sync:Test User")
+
+ post "/groups", name: "sync:Test User"
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "Another group is already named sync:Test User."
+ assert_equal 1, store.all_groups.size
+ end
+ end
+
## The card
def test_the_card_shows_what_the_group_lends_and_to_whom
@@ -203,6 +216,21 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
+ def test_a_rename_to_a_taken_sync_name_is_refused_and_changes_nothing
+ with_contacts(BOOLES) do |store|
+ store.create_group(name: "sync:Test User")
+ id = household(store)
+
+ post "/groups/#{id}", version: store.group(id).version, name: "sync:Test User", 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_equal "Booles", store.group(id).name
+ assert_equal %w[george mary], store.group(id).members
+ end
+ end
+
# No list at all is a POST that says nothing about membership; a
# doctored id names no card and is dropped rather than a 500.
def test_membership_moves_only_for_a_list_of_real_cards
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 000cea6..042ce58 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1329,6 +1329,104 @@ class StoreTest < Minitest::Test
end
end
+ ## Books
+
+ YUKI = AIDEN.sub("FN:Aiden", "FN:Yuki").sub("UID:aiden", "UID:yuki") #: String
+
+ # A card in `sync:*` is everyone's, one in `sync:<name>` that user's
+ # alone, and one in any other group nobody's
+ # (docs/plans/2026-09-12-per-user-books.md).
+ def test_a_book_is_everyones_cards_and_the_users_own
+ with_store({"aiden" => AIDEN, "znorth" => ZED, "yuki" => YUKI}) do |store|
+ FixtureData.seed_group(store, name: "sync:*", members: ["aiden"])
+ 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")
+ end
+ end
+
+ # The put's own entry is the card's arrival in the book, so the join
+ # logs nothing beside it.
+ def test_a_client_create_joins_the_writers_book
+ with_store do |store|
+ store.put("aiden", vcard(AIDEN), sync_to: "Alpha Chen")
+ store.put("znorth", vcard(ZED), sync_to: "Alpha Chen")
+
+ assert_equal Set["aiden", "znorth"], store.book("Alpha Chen")
+ assert_equal ["sync:Alpha Chen"], store.all_groups.map(&:name)
+ assert_equal ["put"], store.changes_of("aiden").map(&:action)
+ end
+ end
+
+ def test_a_client_rewrite_joins_nothing
+ with_store({"aiden" => AIDEN}) do |store|
+ store.put("aiden", vcard(AIDEN), sync_to: "Alpha Chen")
+
+ assert_empty store.book("Alpha Chen")
+ end
+ end
+
+ # A `sync:` group can lend lines like any other, and the logged etag
+ # is the card as the book serves it.
+ def test_a_client_create_is_logged_as_its_book_serves_it
+ with_store do |store|
+ FixtureData.seed_group(store, name: "sync:Alpha Chen", lines: [HOUSEHOLD_NOTE])
+
+ contact = store.put("aiden", vcard(AIDEN), sync_to: "Alpha Chen")
+
+ assert_includes contact.vcard.to_s, HOUSEHOLD_NOTE
+ assert_equal store.contact("aiden").etag, store.changes_of("aiden").first.etag
+ end
+ end
+
+ # Joining or leaving moves no bytes when the group lends nothing, but
+ # it moves the card between books, which a sync token hears of only
+ # through the log. Joining twice is still joining once.
+ def test_joining_and_leaving_a_sync_group_is_logged
+ with_store({"aiden" => AIDEN}) do |store|
+ id = FixtureData.seed_group(store, name: "sync:Alpha Chen")
+
+ store.add_member(id, "aiden")
+ store.add_member(id, "aiden")
+ store.remove_member(id, "aiden")
+ store.remove_member(id, "aiden")
+
+ assert_equal %w[group group put], store.changes_of("aiden").map(&:action)
+ end
+ end
+
+ def test_a_rename_into_between_or_out_of_sync_logs_every_member
+ with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+ id = FixtureData.seed_group(store, name: "Household", members: %w[aiden znorth])
+
+ store.rename_group(id, name: "sync:Alpha Chen")
+ store.rename_group(id, name: "sync:Alpha Chen")
+ store.rename_group(id, name: "sync:Zoë Chen")
+ store.rename_group(id, name: "Household")
+
+ %w[aiden znorth].each do
+ assert_equal %w[group group group put], store.changes_of(it).map(&:action)
+ end
+ 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
+ with_store do |store|
+ 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")
+
+ assert_raises(Sequel::UniqueConstraintViolation) { store.create_group(name: "sync:Alpha Chen") }
+ assert_raises(Sequel::UniqueConstraintViolation) { store.rename_group(other, name: "sync:Alpha Chen") }
+ assert_equal "sync:Zoë Chen", store.group(other).name
+ end
+ end
+
## Subtracting what a group lends
# The member's own address, deliberately unlike the group's so a