groups: compose shared attributes into members' cards
The read half of shared group attributes: migration 004 adds the
groups tables and widens the change log to a fourth action for the
fan-out entries the write half will record, and Contact composes
stored + inherited + birthday at every read. Nothing writes the group
tables yet, so FixtureData seeds a household in Ruby, its members
chosen outside the recorded exchange's hrefs.
Assisted-by: GLM-5.3 via pi
diff --git a/db/migrations/004_groups.rb b/db/migrations/004_groups.rb
new file mode 100644
index 0000000..c4946da
--- /dev/null
+++ b/db/migrations/004_groups.rb
@@ -0,0 +1,73 @@
+# Groups: shared attributes composed into their members' cards at read
+# (docs/plans/2026-08-24-vcard-storage-and-groups.md). A group is
+# server-side state — membership is never exposed to the client — and
+# the properties it holds are verbatim content lines rather than parsed
+# structure, for the same reason the cards are stored as bytes: what
+# the group's author wrote is what a member's card carries, and a
+# property this server does not model rides through composition
+# untouched (RFC 6352 section 6.3.2.2).
+#
+# No timestamps on these tables, the birthdays' own reason: the rows
+# are current state, replaced wholesale by whatever authoring last
+# wrote them, and the change log — whose fan-out entries carry the
+# 'group' action below — is the history of a group's edits.
+#
+# `text: true` on every string column because these are STRICT tables,
+# which refuse Sequel's plain String. A local rather than a constant,
+# for the reason 001 records.
+now = "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
+
+Sequel.migration do
+ change do
+ # A group's own row: an id and the name the admin surfaces show.
+ # The name is for the author; a member's card never carries it.
+ create_table(:groups, strict: true) do
+ String :id, text: true, primary_key: true, null: false
+ String :name, text: true, null: false
+ end
+
+ # What a group contributes, as the content lines to compose into a
+ # member's card. `position` is the line's place in the group, which
+ # fixes the order the composition serves them in.
+ create_table(:group_properties, strict: true) do
+ foreign_key :group_id, :groups, type: String, text: true, null: false, on_delete: :cascade
+ Integer :position, null: false
+ String :line, text: true, null: false
+ primary_key [:group_id, :position]
+ end
+
+ # Membership. Deleting a card drops the membership — the group
+ # outlives the member — and deleting a group drops everything it
+ # had, members included.
+ create_table(:group_members, strict: true) do
+ foreign_key :group_id, :groups, type: String, text: true, null: false, on_delete: :cascade
+ foreign_key :card_id, :cards, type: String, text: true, null: false, on_delete: :cascade
+ primary_key [:group_id, :card_id]
+ end
+
+ # The change log's action grows its fourth value: group, the
+ # fan-out entry a group edit writes for each member whose served
+ # card the edit moved. Nothing writes it yet — that is the write
+ # half's task — but the constraint widens here, with the tables the
+ # action will name, because a schema and the actions it admits are
+ # one decision. The rebuild is 003's own move: SQLite cannot alter
+ # a CHECK constraint, so the table is created again beside the old
+ # one, rows copied with their sequences (every client's sync token
+ # state, copied rather than regenerated so no token skips a
+ # change), old dropped, new renamed. Nothing references this table,
+ # so the drop takes nothing with it.
+ create_table(:changes_new, strict: true) do
+ primary_key :sequence, type: Integer
+ String :card_id, text: true, null: false
+ String :action, text: true, null: false
+ String :etag, text: true
+ String :created_at, text: true, null: false, default: Sequel.lit(now)
+ constraint(:action_is_known, action: %w[put delete edit group])
+ end
+
+ run "INSERT INTO changes_new (sequence, card_id, action, etag, created_at) " \
+ "SELECT sequence, card_id, action, etag, created_at FROM changes"
+ drop_table(:changes)
+ rename_table(:changes_new, :changes)
+ end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index c4de3f1..4a9415d 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -25,7 +25,10 @@ module ProTacts
# card, never both, so the two facts are independent and #vcard
# composes the served card from them. An unmodeled BDAY spelling is
# the card's own — #properties shows it, and #birthday reads nil over
- # it.
+ # it. The lines a contact inherits from its groups sit beside the
+ # birthday the same way: no stored card carries them, so they are
+ # store facts composed in at read
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md).
#
# The etag hashes the card that goes out, so it changes exactly when
# what the client downloads changes. It is derived here and stored
@@ -49,6 +52,7 @@ module ProTacts
# @rbs @id: String
# @rbs @stored: VCard
# @rbs @birthday: Birthday?
+ # @rbs @inherited: Array[String]
# @rbs @vcard: VCard
# @rbs @etag: String
@@ -87,20 +91,22 @@ module ProTacts
:line,
)
- # A contact from its id, its stored card, and its birthday. The
- # only way to make one: an etag that came from anywhere but the
- # card in hand is an etag that can be wrong, and a birthday from
+ # A contact from its id, its stored card, its birthday, and the
+ # content lines it inherits from its groups. The only way to make
+ # one: an etag that came from anywhere but the card in hand is an
+ # etag that can be wrong, and a birthday or an inheritance from
# anywhere but the store is wrong the same way — which is why
- # `birthday:` is required and carries no default, since an
- # optional nil would let a caller quietly get no birthday off a
- # card that has one. Named `stored:` rather than `vcard:` because
- # what #vcard returns is composed, and an argument that is not
- # what the reader hands back is a trap.
- #: (id: String, stored: VCard, birthday: Birthday?) -> Contact
- def self.for(id:, stored:, birthday:)
+ # `birthday:` and `inherited:` are required and carry no defaults,
+ # since an optional nil would let a caller quietly get no birthday
+ # off a card that has one, and an omitted inheritance would serve a
+ # member its group's lines no longer reach. Named `stored:` rather
+ # than `vcard:` because what #vcard returns is composed, and an
+ # argument that is not what the reader hands back is a trap.
+ #: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[String]) -> Contact
+ def self.for(id:, stored:, birthday:, inherited:)
raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
- new(id, stored, birthday)
+ new(id, stored, birthday, inherited)
end
#: (VCard vcard) -> String
@@ -108,11 +114,12 @@ module ProTacts
%("#{Digest::SHA256.hexdigest(vcard.to_s)}")
end
- #: (String id, VCard stored, Birthday? birthday) -> void
- def initialize(id, stored, birthday)
+ #: (String id, VCard stored, Birthday? birthday, Array[String] inherited) -> void
+ def initialize(id, stored, birthday, inherited)
@id = id
@stored = stored
@birthday = birthday
+ @inherited = inherited
end
attr_reader :id
@@ -130,20 +137,24 @@ module ProTacts
# #properties' job.
attr_reader :birthday
- # The card to serve: the stored one with the birthday composed back
- # in, immediately before END:VCARD — the same insert over the same
- # two inputs Store composed at each read path before, so the bytes
- # a client downloads do not move. A birthday with no wire form, or
- # none at all, serves the stored card exactly as it is. Composed on
- # first ask and memoized, and the etag's derivation deferred with
- # it: a caller that reads neither — and the listing paths read
- # neither — pays for no composition of its own.
+ # The card to serve: the stored one with the inherited lines and
+ # the birthday composed back in, in that order, each immediately
+ # before END:VCARD — stored + inherited + birthday, the card a
+ # client downloads. A birthday with no wire form, or none at all,
+ # and an empty inheritance leave their halves as they lie, and an
+ # empty inheritance is #insert's own no-op — the same card object —
+ # so a contact in no group serves exactly the bytes it did before
+ # groups existed, etag included. Composed on first ask and
+ # memoized, and the etag's derivation deferred with it: a caller
+ # that reads neither — and the listing paths read neither — pays
+ # for no composition of its own.
#: () -> VCard
def vcard
return @vcard if defined?(@vcard)
+ card = @stored.insert(@inherited)
line = @birthday && @birthday.to_line
- @vcard = line ? @stored.insert([line]) : @stored
+ @vcard = line ? card.insert([line]) : card
end
# The etag over #vcard's bytes, derived here on first ask and
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 2d03fe0..0ffe85f 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -24,15 +24,18 @@ module ProTacts
# change-log entry have to land or fail together. The birthdays are
# the third thing that cannot be rebuilt — a partial date has no
# vCard 3.0 spelling, so it lives beside its card rather than in it
- # (docs/plans/2026-08-31-partial-birthdays.md). Everything else is
- # an index derived from the cards, and #rebuild_index will make it
- # again from nothing.
+ # (docs/plans/2026-08-31-partial-birthdays.md). The groups are the
+ # fourth — membership and the lines a group contributes to its
+ # members' served cards, facts no stored card carries and so nothing
+ # can re-derive. Everything else is an index derived from the cards,
+ # and #rebuild_index will make it again from nothing.
#
# Every Contact this store hands out is composed, never the stored
# card alone: a birthday is subtracted out of a card on the way in,
- # and Contact composes it back in on read, so the vcard and the etag
- # a caller sees — and the etag the change log records — describe the
- # card a client downloads, not the bytes on disk.
+ # and Contact composes it back in on read, with the lines a contact
+ # inherits from its groups composed in beside it — so the vcard and
+ # the etag a caller sees, and the etag the change log records,
+ # describe the card a client downloads, not the bytes on disk.
#
# Sequel's transactions join one already open rather than failing on
# SQLite's lack of nesting, which is what lets the group fan-out this
@@ -125,7 +128,11 @@ module ProTacts
#: () -> Array[Contact]
def contacts
birthdays = birthdays_by_id
- cards.order(:id).map { contact_from(it, birthdays[it.fetch(:id).to_s]) }
+ inherited = inherited_by_id
+ cards.order(:id).map {
+ id = it.fetch(:id).to_s
+ contact_from(it, birthdays[id], inherited.fetch(id, []))
+ }
end
# Every contact paired with its card's updated_at, newest first — the
@@ -135,9 +142,11 @@ module ProTacts
#: () -> Array[RecentContact]
def contacts_by_recency
birthdays = birthdays_by_id
+ inherited = inherited_by_id
cards.order(Sequel.desc(:updated_at)).map {
+ id = it.fetch(:id).to_s
RecentContact.new(
- contact: contact_from(it, birthdays[it.fetch(:id).to_s]),
+ contact: contact_from(it, birthdays[id], inherited.fetch(id, [])),
updated_at: it.fetch(:updated_at).to_s,
)
}
@@ -178,9 +187,10 @@ module ProTacts
# this reads the table rather than scanning cards for BDAY.
#: (Integer limit, ?today: Date) -> Array[UpcomingBirthday]
def upcoming_birthdays(limit, today: Date.today)
+ inherited = inherited_by_id
birthdays
.join(:cards, id: :card_id)
- .map { upcoming_from(it, today) }
+ .map { upcoming_from(it, today, inherited.fetch(it.fetch(:card_id).to_s, [])) }
.compact
.sort_by { [it.occurs_on, it.contact.id] }
.first(limit)
@@ -193,7 +203,7 @@ module ProTacts
# nil.
#: (String id) -> Contact?
def contact(id)
- contact_from(cards.where(id:).sole, birthday_of(id))
+ contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
rescue Sequel::NoMatchingRow
nil
end
@@ -272,10 +282,15 @@ module ProTacts
[nil, card]
end
- # The Contact this returns is the composed one, and the logged
- # etag is its hash, so a client's token describes the card it
- # downloads.
- contact = Contact.for(id:, stored:, birthday:)
+ # The Contact this returns is the composed one — what the contact
+ # inherits included, read off membership as it stands at this
+ # write — and the logged etag is its hash, so a client's token
+ # describes the card it downloads. The submitted card is stored
+ # as it arrived: subtracting a group's lines back out of a
+ # member's submission is the write half's task
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md), so until it
+ # lands a member's rewrite carries the inherited lines within it.
+ contact = Contact.for(id:, stored:, birthday:, inherited: inherited_of(id))
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
@@ -303,7 +318,7 @@ module ProTacts
#: (String id, String vcard, birthday: Birthday?) -> Contact
def rewrite(id, vcard, birthday:)
card = VCard.new(vcard)
- contact = Contact.for(id:, stored: card, birthday:)
+ contact = Contact.for(id:, stored: card, birthday:, inherited: inherited_of(id))
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
@@ -376,6 +391,11 @@ module ProTacts
@database[:birthdays]
end
+ #: () -> Sequel::Dataset
+ def group_members
+ @database[:group_members]
+ end
+
#: (String card_id, String action, String? etag) -> void
def record(card_id, action, etag)
change_log.insert(card_id:, action:, etag:)
@@ -501,14 +521,15 @@ module ProTacts
# The birthday travels beside the card rather than inside it, and
# Contact composes the served one — its vcard, its etag, everything
- # a caller reads — so both describe what a client downloads rather
- # than the bytes on disk.
- #: (Hash[Symbol, untyped] row, Birthday? birthday) -> Contact
- def contact_from(row, birthday)
+ # a caller reads — with the inherited lines beside it, so all
+ # describe what a client downloads rather than the bytes on disk.
+ #: (Hash[Symbol, untyped] row, Birthday? birthday, Array[String] inherited) -> Contact
+ def contact_from(row, birthday, inherited)
Contact.for(
id: row.fetch(:id).to_s,
stored: VCard.new(row.fetch(:vcard).to_s),
birthday:,
+ inherited:,
)
end
@@ -548,6 +569,42 @@ module ProTacts
row && birthday_from(row)
end
+ # The content lines a contact inherits — every property of every
+ # group it belongs to, nothing of a group that holds nothing, and
+ # [] for a contact in no group at all. Ordered by group id and then
+ # position, so the composed card is the same bytes every read.
+ #: (String id) -> Array[String]
+ def inherited_of(id)
+ inherited_rows.where(card_id: id).map { it.fetch(:line).to_s }
+ end
+
+ # Every contact's inheritance in one pass, keyed by card, for the
+ # listing reads that compose a whole collection — the same shape as
+ # birthdays_by_id, for the same reason. Only a contact with
+ # something to inherit appears; the absent key reads as [] at the
+ # caller, which is what it means.
+ #: () -> Hash[String, Array[String]]
+ def inherited_by_id
+ inherited_rows.all
+ .group_by { it.fetch(:card_id).to_s }
+ .transform_values { |rows| rows.map { it.fetch(:line).to_s } }
+ end
+
+ # The join both inherited reads walk: a membership to the property
+ # it inherits, qualified and ordered so the composition neither
+ # depends on what SQLite feels like returning nor trips over the
+ # group_id the two tables share.
+ #: () -> Sequel::Dataset
+ def inherited_rows
+ group_members
+ .join(:group_properties, group_id: :group_id)
+ .order(
+ Sequel[:group_members][:card_id],
+ Sequel[:group_properties][:group_id],
+ Sequel[:group_properties][:position],
+ )
+ end
+
# A birthday row read as the model. The shape was validated on the
# way in, so a row that no longer parses is corruption to raise on
# rather than quietly drop.
@@ -559,15 +616,15 @@ module ProTacts
# One joined birthday-and-card row as an UpcomingBirthday, or nil
# for a shape that lands on no calendar day (see
# #upcoming_birthdays).
- #: (Hash[Symbol, untyped] row, Date today) -> UpcomingBirthday?
- def upcoming_from(row, today)
+ #: (Hash[Symbol, untyped] row, Date today, Array[String] inherited) -> UpcomingBirthday?
+ def upcoming_from(row, today, inherited)
birthday = birthday_from(row)
month, day = birthday.month, birthday.day
return if month.nil? || day.nil?
candidate = date_in(today.year, month, day)
UpcomingBirthday.new(
- contact: contact_from(row, birthday),
+ contact: contact_from(row, birthday, inherited),
occurs_on: candidate < today ? date_in(today.year + 1, month, day) : candidate,
)
end
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 7d7edad..4ec5f24 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -37,6 +37,37 @@ module FixtureData
CARDS.glob("*.vcf").sort.to_h { [it.basename(".vcf").to_s, it.read] }
end
+ # What the household shares: an address and a home number, the
+ # shape the groups work tests against
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md). Deliberately
+ # not the address any seed card already carries, so a test can tell
+ # a composed line from a stored one by its bytes.
+ HOUSEHOLD = [
+ "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
+ "TEL;TYPE=home:+44 20 5555 0100",
+ ].freeze #: Array[String]
+
+ # The groups the fixture book carries: one household, two members.
+ # Seeded in Ruby rather than a fixture file because the cards are the
+ # only evidence-shaped fixtures — a group is rows across three
+ # tables with no file format of its own — and the members are chosen
+ # to sit outside the recorded macOS exchange's hrefs, so the replay
+ # keeps serving that session the bytes it saw. Authoring groups is
+ # the admin UI's task; until it arrives nothing public writes these
+ # tables, so the rows land the way backdate's do, through the
+ # store's own database.
+ #: (ProTacts::Store store) -> void
+ def self.seed_groups(store)
+ database = store.instance_variable_get(:@database)
+ database[:groups].insert(id: "household", name: "Household")
+ HOUSEHOLD.each.with_index do |line, position|
+ database[:group_properties].insert(group_id: "household", position:, line:)
+ end
+ ["bday-complete", "bday-basic-noyear"].each do |id|
+ database[:group_members].insert(group_id: "household", card_id: id)
+ end
+ end
+
# Builds the database and returns a store still open on it, for the
# caller to hand to the app the way config.ru does.
def self.install(directory)
@@ -49,6 +80,7 @@ module FixtureData
store.put(id, card)
end.keys
backdate(store, ids)
+ seed_groups(store)
store
end
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 79d5eb1..5913f18 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -26,7 +26,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"78822ddd3e94790ad8132d452c65b9a9214b695f5db650b1882081696f04b080"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -35,7 +35,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-complete.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+ <d:getetag>"ea55ff3f6cf660799cbc5b25f86d9e219e7c5b57aecb2a86f4c0af8687805861"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 875ad9c..c8d226e 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -16,7 +16,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"78822ddd3e94790ad8132d452c65b9a9214b695f5db650b1882081696f04b080"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -25,7 +25,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-complete.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+ <d:getetag>"ea55ff3f6cf660799cbc5b25f86d9e219e7c5b57aecb2a86f4c0af8687805861"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 2d8839b..36affd2 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -51,7 +51,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"78822ddd3e94790ad8132d452c65b9a9214b695f5db650b1882081696f04b080"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -60,7 +60,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-complete.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+ <d:getetag>"ea55ff3f6cf660799cbc5b25f86d9e219e7c5b57aecb2a86f4c0af8687805861"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index b1ca2ca..6d74793 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -9,10 +9,11 @@ class FormatTest < Minitest::Test
"UID:ada\r\nEND:VCARD\r\n"
# The production shapes: a modeled birthday passes a BDAY-free card
- # beside a Birthday, and a fallback case passes a card carrying an
- # unmodeled BDAY beside nil (see Contact).
- def contact(vcard = CARD, id: "aiden", birthday: nil)
- ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
+ # beside a Birthday, a fallback case passes a card carrying an
+ # unmodeled BDAY beside nil, and inheritance defaults to none (see
+ # Contact).
+ def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
+ ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
end
# Initials come from the structured N property (given + family),
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index b888bac..469359e 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -20,11 +20,12 @@ class ContactTest < Minitest::Test
"NOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
# The production shapes (see the class comment): a modeled birthday
- # passes a BDAY-free card beside a Birthday, and a fallback case
+ # passes a BDAY-free card beside a Birthday, a fallback case
# passes a card carrying an unmodeled BDAY beside nil — no stored
- # card ever carries a BDAY the model recomposes.
- def contact(vcard = CARD, id: "aiden", birthday: nil)
- ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
+ # card ever carries a BDAY the model recomposes — and inheritance
+ # defaults to none, the shape of every contact in no group.
+ def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
+ ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
end
# The decode's refusal reports (see #photo); the transport this pins
@@ -264,6 +265,51 @@ class ContactTest < Minitest::Test
assert_nil contact(unmodeled).birthday
end
+ ## Inherited lines
+
+ # The group's lines land between the stored card and the composed
+ # birthday — stored + inherited + birthday — so the card a client
+ # downloads carries everything in one fixed order.
+ def test_the_served_card_composes_inherited_lines_before_the_birthday
+ birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+ composed = STRUCTURED.sub(
+ "END:VCARD\r\n",
+ "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom\r\n" \
+ "TEL;TYPE=home:+44 20 5555 0100\r\n" \
+ "BDAY:1985-12-10\r\nEND:VCARD\r\n",
+ )
+
+ assert_equal(
+ composed,
+ contact(
+ STRUCTURED,
+ birthday:,
+ inherited: [
+ "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
+ "TEL;TYPE=home:+44 20 5555 0100",
+ ],
+ ).vcard.to_s,
+ )
+ end
+
+ # No inheritance composes the same bytes and the same etag as before
+ # groups existed: an empty inheritance is insert's own no-op, so a
+ # contact in no group serves its stored card untouched.
+ def test_a_contact_in_no_group_serves_its_stored_bytes_and_etag
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+ assert_equal card, contact(card, inherited: []).vcard.to_s
+ assert_equal ProTacts::Contact.etag_for(ProTacts::VCard.new(card)), contact(card).etag
+ end
+
+ # Inherited lines are card contents like any other, so the accessors
+ # read them: a group's number is a phone on the member's card.
+ def test_the_accessors_read_inherited_lines
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+ assert_equal "+44 20 5555 0100", contact(card, inherited: ["TEL;TYPE=home:+44 20 5555 0100"]).phones.first&.value
+ end
+
def test_unescapes_notes
assert_equal "Countess, mathematician.", contact(STRUCTURED).notes
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 87881db..89667fa 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -936,6 +936,127 @@ class StoreTest < Minitest::Test
end
end
+ ## Groups
+
+ HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+
+ # There is no write path for the group tables yet — authoring is the
+ # admin UI's task — so the tests arrange them the way the fixture
+ # seeder does, straight through the store's own database.
+ def add_group(store, id:, members:, lines:)
+ db = database(store)
+ db[:groups].insert(id:, name: id.capitalize)
+ lines.each.with_index { |line, position| db[:group_properties].insert(group_id: id, position:, line:) }
+ members.each { |card_id| db[:group_members].insert(group_id: id, card_id:) }
+ end
+
+ # A member's served card is the stored one with the group's lines
+ # composed in, and the birthday composed after them — every read,
+ # single or listed, hands out the same composed card.
+ def test_a_member_serves_the_group_lines_composed_in
+ born = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-12-10\r\n")
+ composed = AIDEN.sub(
+ "END:VCARD\r\n",
+ "#{HOUSEHOLD_ADDRESS}\r\nTEL;TYPE=home:+44 20 5555 0100\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n",
+ )
+
+ with_store({"aiden" => born, "znorth" => ZED}) do |store|
+ add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, "TEL;TYPE=home:+44 20 5555 0100"])
+
+ assert_equal composed, store.contact("aiden").vcard.to_s
+ assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
+ assert_equal composed, store.contacts_by_recency.find { it.contact.id == "aiden" }.contact.vcard.to_s
+ end
+ end
+
+ # A contact in no group serves the stored bytes with the etag it
+ # always had — the byte-identity the whole fixture replay stands on.
+ def test_a_contact_in_no_group_serves_unchanged_bytes_and_etag
+ with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+ before = store.contact("aiden")
+ add_group(store, id: "household", members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
+
+ after = store.contact("aiden")
+ assert_equal AIDEN, after.vcard.to_s
+ assert_equal before.etag, after.etag
+ end
+ end
+
+ # Membership is the only lever: a contact that leaves the group
+ # serves its own bytes again, at the etag it had before any of the
+ # group's lines reached it.
+ def test_leaving_a_group_restores_the_stored_bytes
+ with_store({"aiden" => AIDEN}) do |store|
+ before = store.contact("aiden").etag
+ add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ refute_equal before, store.contact("aiden").etag
+
+ database(store)[:group_members].where(card_id: "aiden").delete
+ assert_equal AIDEN, store.contact("aiden").vcard.to_s
+ assert_equal before, store.contact("aiden").etag
+ end
+ end
+
+ # Two groups' lines compose in group-id order, then position — the
+ # order is a fact of the schema, not of whichever join SQLite
+ # returns first, so the composed bytes never move between reads.
+ def test_two_groups_compose_in_a_fixed_order
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, id: "zedly", members: ["aiden"], lines: ["TEL;TYPE=home:+44 20 5555 0100"])
+ add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+ composed = AIDEN.sub(
+ "END:VCARD\r\n",
+ "#{HOUSEHOLD_ADDRESS}\r\nTEL;TYPE=home:+44 20 5555 0100\r\nEND:VCARD\r\n",
+ )
+ assert_equal composed, store.contact("aiden").vcard.to_s
+ end
+ end
+
+ # The index projects the stored cards, and no stored card carries an
+ # inherited line: the group's address is in the served card and in
+ # no index row, and rebuilding the index leaves it that way.
+ def test_the_index_holds_nothing_the_group_composes_in
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+ assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+ assert_includes indexed_names(store, "aiden"), "FN"
+ refute_includes indexed_names(store, "aiden"), "ADR"
+
+ store.rebuild_index
+ refute_includes indexed_names(store, "aiden"), "ADR"
+ end
+ end
+
+ # A PUT and an editor's save both hand back the composed contact —
+ # the etag each records in the change log is the composed card's, so
+ # a client's token describes what there is to download. The first
+ # entry is with_store's own seed put, logged before the group existed.
+ def test_writes_log_the_composed_etag_for_a_member
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+ put = store.put("aiden", AIDEN)
+ edit = store.rewrite("aiden", AIDEN, birthday: nil)
+ logged = database(store)[:changes].where(card_id: "aiden").order(:sequence).all
+
+ assert_equal [put.etag, edit.etag], logged.last(2).map { it[:etag] }
+ assert_includes put.vcard.to_s, HOUSEHOLD_ADDRESS
+ end
+ end
+
+ # The fourth action value exists for the fan-out a group edit will
+ # write; nothing writes it yet, and this pins that the log can hold
+ # one when it does.
+ def test_the_change_log_admits_the_group_action
+ with_store({"aiden" => AIDEN}) do |store|
+ database(store)[:changes].insert(card_id: "aiden", action: "group", etag: "\"x\"")
+
+ assert_equal "group", store.changes.last.action
+ end
+ end
+
## The migration
# A database at the old action vocabulary — put and delete only — is