Make the split two functions instead of Arrival
Arrival was a class with one call, three return values and no state
worth keeping, so it goes. Each half moves to where its vocabulary
already lives:
- BirthdayLine.split, beside BirthdayLine.read, with its three reports
as private class methods.
- Lent.subtract, on Lent promoted to a top-level Data class in lent.rb
and reopened the way CardDiff is. GroupEdit comes with it as
Lent::Edit, and the classify and match helpers become private class
methods.
Store#put is back to main's shape with those two calls in place of the
private methods. test_arrival.rb splits into test_birthday_line.rb and
test_lent.rb.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
diff --git a/AGENTS.md b/AGENTS.md
index 9b59c3e..28c8c4a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,10 +80,11 @@ lib/pro_tacts/
│ # group change log, and the derived index
├── contact.rb # the one model of a contact: id, vCard, etag,
│ # and the structured accessors over the card
-├── arrival.rb # A submitted card split back into what the store
-│ # keeps: the card, the birthday, the group edits
├── birthday.rb # A partial date, the one fact no card can hold
-├── birthday_line.rb # What a submitted BDAY line is, for Arrival
+├── birthday_line.rb # What a submitted BDAY line is, and the split
+│ # of a card's birthday from it, for Store#put
+├── lent.rb # A line a group lends a card, and the subtraction
+│ # that takes it back out of a submitted one
├── card_diff.rb # The lines a write added and removed
├── sync_token.rb # The token a syncing client carries: minted
│ # at a ctag for a book, and read back
diff --git a/lib/pro_tacts/birthday_line.rb b/lib/pro_tacts/birthday_line.rb
index 0e1b053..9aa95c9 100644
--- a/lib/pro_tacts/birthday_line.rb
+++ b/lib/pro_tacts/birthday_line.rb
@@ -1,11 +1,14 @@
+require "sentry-ruby"
+
require "pro_tacts/birthday"
+require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
module ProTacts
- # What one BDAY line in a submitted card is, for Arrival to act on
- # (docs/plans/2026-09-11-every-birthday-in-the-model.md). Match with
- # case/when, not case/in: Steep does not check the bodies of case/in
- # branches, and these branches are the ones worth checking.
+ # What one BDAY line in a submitted card is, and the split Store#put
+ # makes on it (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+ # Match with case/when, not case/in: Steep does not check the bodies
+ # of case/in branches, and these branches are the ones worth checking.
#
# The variants are signed in sig/pro_tacts/birthday_line.rbs, for
# Birthday's reason.
@@ -37,5 +40,77 @@ module ProTacts
birthday = Birthday.from_property(property)
birthday ? Modeled.new(birthday:) : Unrecognized.new
end
+
+ # The birthday half of the split a write makes: the model's new
+ # birthday, and the card to store beside it. A card holds at most
+ # one BDAY, and one that reads as a birthday leaves the card for the
+ # model. Any other stays in the card byte for byte, reported, with
+ # the model emptied so nothing composes a second BDAY beside it
+ # (RFC 6352 section 6.3.2.2).
+ #
+ # No BDAY is a client that was sent the birthday removing it, or
+ # one that was never sent it and so cannot have. A BDAY line left
+ # in the stored card goes with the write, which is worth saying.
+ #
+ # case/when for the module's reason, which is what the last arm is
+ # for.
+ #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
+ def self.split(vcard, existing, stored_before)
+ bdays, rest = vcard.extract("BDAY")
+ if bdays.empty?
+ report_lost(stored_before)
+ return [existing && !existing.served? ? existing : nil, vcard]
+ end
+
+ if bdays.length > 1
+ report_many(bdays.length)
+ return [nil, vcard]
+ end
+
+ line = read(bdays.fetch(0))
+ case line
+ when Modeled
+ [line.birthday, rest]
+ when Unrecognized
+ report_unrecognized
+ [nil, vcard]
+ when Unreadable
+ [nil, vcard]
+ else
+ raise "no arm for #{line.class}"
+ end
+ end
+
+ # The arrival report: a BDAY the model does not take is unexpected
+ # input, and storing it verbatim would be the last anyone heard of
+ # it. The message carries no card content
+ # (the line config.ru draws for Sentry); the value stays on the
+ # machine, where the admin view shows it raw.
+ #: () -> void
+ def self.report_unrecognized
+ Sentry.capture_message("a submitted card carried a BDAY line the model does not take", level: :warning)
+ end
+
+ # A contact has one birthday, so a card with several cannot say
+ # which it means, whatever each line holds.
+ #: (Integer count) -> void
+ def self.report_many(count)
+ Sentry.capture_message("a submitted card carried #{count} BDAY lines", level: :warning)
+ end
+
+ # The loss report, the write's half of the arrival one: a BDAY line
+ # still in the stored card is one the model did not take, and a
+ # write without it is about to drop it with nobody told.
+ #: (VCard? stored_before) -> void
+ def self.report_lost(stored_before)
+ return if stored_before.nil?
+
+ lines, = stored_before.extract("BDAY")
+ return if lines.empty?
+
+ Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
+ end
+
+ private_class_method :report_unrecognized, :report_many, :report_lost
end
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f8396c..bc7f271 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -342,7 +342,7 @@ module ProTacts
# Matched by the line's bytes, which is the same blindness the
# editor's digests have (VCard::Parser::Line#digest): a stored
# line whose bytes are a group's line reads as the group's here.
- # No write makes that state — Arrival#subtract_inherited takes the
+ # No write makes that state — Lent.subtract takes the
# group's copy back out of a submission — but a member whose own
# card spells the line exactly as its group does still has one, and
# naming the group over both copies says something true about the
diff --git a/lib/pro_tacts/arrival.rb b/lib/pro_tacts/lent.rb
similarity index 63%
rename from lib/pro_tacts/arrival.rb
rename to lib/pro_tacts/lent.rb
index 1ad7887..c0c37dc 100644
--- a/lib/pro_tacts/arrival.rb
+++ b/lib/pro_tacts/lent.rb
@@ -1,47 +1,29 @@
require "sentry-ruby"
-require "pro_tacts/birthday"
-require "pro_tacts/birthday_line"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
module ProTacts
- # A card as it arrives at Store#put, split back into what the store
- # keeps: the card to store, the birthday to hold beside it, and what
- # the member's edits ask of the groups lending it lines. The inverse
- # of what Contact composes — served is stored plus inherited plus
- # birthday, so stored is submitted minus both
- # (docs/plans/2026-08-31-partial-birthdays.md,
- # docs/plans/2026-08-24-vcard-storage-and-groups.md).
+ # One line a group lends one card, with the row it is lent from —
+ # the write path's reading of what Store#inherited_of answers as the
+ # model's. A member's edit has to reach the group_properties row the
+ # line came from, which takes the position as well as the group.
#
- # Nothing here reads or writes the database. The store hands over
- # what stood before the write — the card, the birthday, the lines
- # lent — and applies what comes back inside its own transaction. The
- # reports go to Sentry from here because this is the arrival
- # (AGENTS.md, "The one exception is the arrival reports").
- #
- # The signature of the two Data classes is in
- # sig/pro_tacts/arrival.rbs, for the reason Store's are in its own.
- class Arrival
- # @rbs @stored: VCard
- # @rbs @birthday: Birthday?
- # @rbs @edits: Array[GroupEdit]
-
- # One line a group lends one card, with the row it is lent from —
- # the write path's reading of what Store#inherited_of answers as
- # the model's. A member's edit has to reach the group_properties
- # row the line came from, which takes the position as well as the
- # group.
- # @rbs skip
- Lent = Data.define(:group_id, :position, :line)
-
+ # The signature lives in sig/pro_tacts/lent.rbs, this being a Data
+ # class.
+ # @rbs skip
+ Lent = Data.define(:group_id, :position, :line)
+
+ # Reopened rather than defined in the block above, for the reason
+ # VCard::Parser::Property is.
+ class Lent
# What one classified line asks of the group it came from: the row
# to write, and the line that replaces it — or nil to remove the
# row, which is what a member's deletion of a shared line means
# (docs/plans/2026-09-09-group-edits-propagate.md, "The open
# question, decided").
# @rbs skip
- GroupEdit = Data.define(:group_id, :position, :line)
+ Edit = Data.define(:group_id, :position, :line)
# The property names whose value is structured rather than free
# text (RFC 2426 section 3.2.1), among the two a group may lend:
@@ -59,107 +41,14 @@ module ProTacts
# group cannot hold").
SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
- # `stored_before` is the card this write replaces, nil for one
- # being created: the birthday split asks it about a BDAY the write
- # is dropping, and the subtraction strikes its lines from the pool.
- #: (VCard vcard, birthday_before: Birthday?, stored_before: VCard?, lent: Array[Lent]) -> void
- def initialize(vcard, birthday_before:, stored_before:, lent:)
- @birthday, rest = split_birthday(vcard, birthday_before, stored_before)
- @stored, @edits = subtract_inherited(rest, lent, stored_before)
- end
-
- # The card to store: the submission minus its birthday and minus
- # what its groups lend it.
- attr_reader :stored
-
- # The model's new birthday, or nil to empty the model.
- attr_reader :birthday
-
- # The group rows the member's edits rewrite or remove, empty where
- # every lent line came back untouched.
- attr_reader :edits
-
- private
-
- # The birthday half of the split
- # (docs/plans/2026-09-11-every-birthday-in-the-model.md): the
- # model's new birthday, and the card to store beside it. A card
- # holds at most one BDAY, and one that reads as a birthday leaves
- # the card for the model. Any other stays in the card byte for
- # byte, reported, with the model emptied so nothing composes a
- # second BDAY beside it (RFC 6352 section 6.3.2.2).
- #
- # No BDAY is a client that was sent the birthday removing it, or
- # one that was never sent it and so cannot have. A BDAY line left
- # in the stored card goes with the write, which is worth saying.
- #
- # case/when rather than case/in, BirthdayLine's own rule: Steep
- # does not check the bodies of case/in branches, and these are the
- # ones worth checking — which is what the last arm is for.
- #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
- def split_birthday(vcard, existing, stored_before)
- bdays, rest = vcard.extract("BDAY")
- if bdays.empty?
- report_lost_bday_lines(stored_before)
- return [existing && !existing.served? ? existing : nil, vcard]
- end
-
- if bdays.length > 1
- report_many_bday_lines(bdays.length)
- return [nil, vcard]
- end
-
- line = BirthdayLine.read(bdays.fetch(0))
- case line
- when BirthdayLine::Modeled
- [line.birthday, rest]
- when BirthdayLine::Unrecognized
- report_unrecognized_bday_line
- [nil, vcard]
- when BirthdayLine::Unreadable
- [nil, vcard]
- else
- raise "no arm for #{line.class}"
- end
- end
-
- # The arrival report: a BDAY the model does not take is unexpected
- # input, and storing it verbatim would be the last anyone heard of
- # it. The message carries no card content
- # (the line config.ru draws for Sentry); the value stays on the
- # machine, where the admin view shows it raw.
- #: () -> void
- def report_unrecognized_bday_line
- Sentry.capture_message("a submitted card carried a BDAY line the model does not take", level: :warning)
- end
-
- # A contact has one birthday, so a card with several cannot say
- # which it means, whatever each line holds.
- #: (Integer count) -> void
- def report_many_bday_lines(count)
- Sentry.capture_message("a submitted card carried #{count} BDAY lines", level: :warning)
- end
-
- # The loss report, the write's half of the arrival one: a BDAY line
- # still in the stored card is one the model did not take, and a
- # write without it is about to drop it with nobody told.
- #: (VCard? stored_before) -> void
- def report_lost_bday_lines(stored_before)
- return if stored_before.nil?
-
- lines, = stored_before.extract("BDAY")
- return if lines.empty?
-
- Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
- end
-
# The submitted card with the lines its groups lend it taken back
- # out: served is stored plus inherited, so stored is submitted
+ # out, and the edits the lines that came back changed ask of those
+ # groups: served is stored plus inherited, so stored is submitted
# minus inherited and a client that PUTs back what it downloaded
# stores what it started with
# (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Groups
# compose into cards"). Which of four shapes a lent line came back
- # as, and what each asks of its group, is #classify — argued in
+ # as, and what each asks of its group, is .classify — argued in
# that plan's "Classifying what came back" and in
# docs/plans/2026-09-09-group-edits-propagate.md.
#
@@ -177,8 +66,8 @@ module ProTacts
# this leaves keep their positions, so a submission that was the
# served card round-trips to the bytes it was composed from and the
# PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
- #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[GroupEdit]]
- def subtract_inherited(vcard, lent, stored_before)
+ #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[Edit]]
+ def self.subtract(vcard, lent, stored_before)
return [vcard, []] if lent.empty?
unaccounted = unaccounted_lines(vcard, stored_before)
@@ -201,19 +90,19 @@ module ProTacts
match
}
- edits = [] #: Array[GroupEdit]
+ edits = [] #: Array[Edit]
ambiguous = 0
unshareable = 0
moved.each do |row|
case classify(row, unaccounted, taken)
- in GroupEdit => edit then edits << edit
+ in Edit => edit then edits << edit
in :ambiguous then ambiguous += 1
in :unshareable then unshareable += 1
end
end
- report_ambiguous_inherited_lines(ambiguous)
- report_unshareable_lines(unshareable)
+ report_ambiguous(ambiguous)
+ report_unshareable(unshareable)
[taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
end
@@ -221,31 +110,31 @@ module ProTacts
# edit the one candidate of its name is, or the deletion no
# candidate at all is. The two refusals ask nothing — more than one
# candidate cannot be attributed, and a candidate the group could
- # not hold must not be tried (#shareable?) — and both leave the
+ # not hold must not be tried (.shareable?) — and both leave the
# line in the member's own card, where an edit at least is not
# lost.
#
# An edit's line is struck from the pool and carried by the group
# from here on, so `taken` grows the way it does for an untouched
# one.
- #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (GroupEdit | :ambiguous | :unshareable)
- def classify(row, unaccounted, taken)
+ #: (Lent row, Array[VCard::Parser::Line] unaccounted, Array[VCard::Parser::Line] taken) -> (Edit | :ambiguous | :unshareable)
+ def self.classify(row, unaccounted, taken)
candidates = unaccounted.select { it.names?(property_name(row.line)) }
- return GroupEdit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
+ return Edit.new(group_id: row.group_id, position: row.position, line: nil) if candidates.empty?
return :ambiguous unless candidates.length == 1
edited = candidates.fetch(0)
return :unshareable unless shareable?(edited)
taken << strike(unaccounted, edited)
- GroupEdit.new(group_id: row.group_id, position: row.position, line: edited.content)
+ Edit.new(group_id: row.group_id, position: row.position, line: edited.content)
end
# The line out of the pool, so no second lent line is attributed
# it. `index` rather than `delete`, which would take every copy of
# a line a card carries twice.
#: (Array[VCard::Parser::Line] unaccounted, VCard::Parser::Line line) -> VCard::Parser::Line
- def strike(unaccounted, line)
+ def self.strike(unaccounted, line)
unaccounted.delete_at(
unaccounted.index(line) #: Integer
)
@@ -257,7 +146,7 @@ module ProTacts
# the CHECK's two clauses, read off the parse rather than matched
# as bytes. A line that would not read is not one to share.
#: (VCard::Parser::Line line) -> bool
- def shareable?(line)
+ def self.shareable?(line)
property = line.property
return false if property.nil?
@@ -267,7 +156,7 @@ module ProTacts
# A lent line as the parser reads it: one logical line, the group
# schema admitting no other shape (db/migrations/004_groups.rb).
#: (String line) -> VCard::Parser::Line
- def parsed_line(line)
+ def self.parsed_line(line)
VCard.new(line).lines.fetch(0)
end
@@ -287,7 +176,7 @@ module ProTacts
# A line that will not read has no value to compare and falls back
# to its bytes, which still recognize the line nobody touched.
#: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
- def unedited?(line, lent, pool)
+ def self.unedited?(line, lent, pool)
value = value_of(lent)
return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
@@ -306,7 +195,7 @@ module ProTacts
# the line's types and `ADR;TYPE=dom` coming back as `item1.ADR`
# with `item1.X-ABLabel:dom` is unedited.
#: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
- def kept_types?(line, lent, pool)
+ def self.kept_types?(line, lent, pool)
label = label_of(line, pool)&.property
types = types_of(line)
types = (types + [label.text.downcase]).uniq.sort if label
@@ -314,10 +203,10 @@ module ProTacts
end
# The `X-ABLabel` sharing a property group with `line`, the other
- # half of the pair #kept_types? reads as one. Nil for an ungrouped
+ # half of the pair .kept_types? reads as one. Nil for an ungrouped
# line, or a group the pool holds no label for.
#: (VCard::Parser::Line line, Array[VCard::Parser::Line] pool) -> VCard::Parser::Line?
- def label_of(line, pool)
+ def self.label_of(line, pool)
group = line.property&.group
return if group.nil?
@@ -332,7 +221,7 @@ module ProTacts
# 3.2.1) and a NOTE as its unescaped text (section 2.4.2). Nil for
# a line that would not read, which has no value at all.
#: (VCard::Parser::Line line) -> Array[String]?
- def value_of(line)
+ def self.value_of(line)
property = line.property
return nil if property.nil?
@@ -345,7 +234,7 @@ module ProTacts
# way). Empty for a line that would not read, which has no
# parameters to compare.
#: (VCard::Parser::Line line) -> Array[String]
- def types_of(line)
+ def self.types_of(line)
property = line.property
return [] if property.nil?
@@ -360,7 +249,7 @@ module ProTacts
# attributed from. Everything for a card being created, which has
# no stored lines to explain anything.
#: (VCard vcard, VCard? stored_before) -> Array[VCard::Parser::Line]
- def unaccounted_lines(vcard, stored_before)
+ def self.unaccounted_lines(vcard, stored_before)
stored = stored_before ? stored_before.lines.map { it.verbatim.chomp } : [] #: Array[String]
vcard.lines.reject { |line|
index = stored.index(line.verbatim.chomp)
@@ -375,7 +264,7 @@ module ProTacts
# group's, which carry no `item1.` prefix to strip — the schema
# refuses one (db/migrations/004_groups.rb).
#: (String line) -> String
- def property_name(line)
+ def self.property_name(line)
line[/\A[^;:]*/].to_s
end
@@ -384,9 +273,9 @@ module ProTacts
# attribute — two lines of that name arrived that the member's card
# does not explain, and calling either the edit would be a guess.
# The card is stored as it arrived and the line is news, the same
- # bargain report_unrecognized_bday_line makes.
+ # bargain BirthdayLine.report_unrecognized makes.
#: (Integer count) -> void
- def report_ambiguous_inherited_lines(count)
+ def self.report_ambiguous(count)
return if count.zero?
Sentry.capture_message(
@@ -400,11 +289,11 @@ module ProTacts
# keeps what it lent. The known cause is an edit that lands in a
# type Contacts cannot model — a relabel to one, or a new value
# under one — which comes back as a property group with an
- # `X-ABLabel` beside it (#kept_types?), and the news is worth
+ # `X-ABLabel` beside it (.kept_types?), and the news is worth
# having because that member now serves the line twice — its own
# copy and the group's — until someone reconciles them.
#: (Integer count) -> void
- def report_unshareable_lines(count)
+ def self.report_unshareable(count)
return if count.zero?
Sentry.capture_message(
@@ -412,5 +301,9 @@ module ProTacts
level: :warning,
)
end
+
+ private_class_method :classify, :strike, :shareable?, :parsed_line, :unedited?, :kept_types?, :label_of,
+ :value_of, :types_of, :unaccounted_lines, :property_name, :report_ambiguous,
+ :report_unshareable
end
end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 5d955c6..ca2916a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -3,11 +3,12 @@ require "json"
require "pathname"
require "sequel"
-require "pro_tacts/arrival"
require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
require "pro_tacts/card_diff"
require "pro_tacts/change_id"
require "pro_tacts/contact"
+require "pro_tacts/lent"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -32,10 +33,11 @@ module ProTacts
#
# Every Contact this store hands out is composed, never the stored
# card alone: a birthday and the lines a contact inherits from its
- # groups are both subtracted out of a card on the way in (Arrival),
- # and Contact composes them 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.
+ # groups are both subtracted out of a card on the way in
+ # (BirthdayLine.split, Lent.subtract), and Contact composes them 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.
#
# The signature lives in sig/pro_tacts/store.rbs, for the Change Data
# class the inline syntax cannot read.
@@ -438,13 +440,13 @@ module ProTacts
# transaction, because the log entry cannot be rebuilt from anything.
#
# What is stored is the card minus its birthday and minus what its
- # groups lend it, both composed back in on read; Arrival makes that
- # split and this applies it.
+ # groups lend it, both composed back in on read
+ # (BirthdayLine.split, Lent.subtract).
#
# A card rather than its bytes, so the reading a caller already
- # made is the one Arrival splits: a PUT has asked whether the bytes
- # are a card at all and whose UID they carry before it gets here
- # (Web#write_card).
+ # made is the one BirthdayLine.split decides from: a PUT has asked
+ # whether the bytes are a card at all and whose UID they carry
+ # before it gets here (Web#write_card).
#
# The strings are UTF-8 by contract and the bind is the third line
# holding them to it, under Web#write_card's relabel-and-judge and
@@ -458,23 +460,28 @@ module ProTacts
#: (String id, VCard vcard, ?client: bool) -> Contact
def put(id, vcard, client: false)
existing = birthday_of(id)
- # Read once for the two that want it: the arrival's split, and
- # the card this write replaces.
+ # Read once for the three that want it: the birthday split, the
+ # subtraction after it, and the card this write replaces.
stored_before = stored_card(id)
- lent = lent_of(id)
- arrival = Arrival.new(vcard, birthday_before: existing, stored_before:, lent:)
- stored, birthday, edits = arrival.stored, arrival.birthday, arrival.edits
-
- # The Contact this returns is the composed one — the lent lines put
+ birthday, stored = BirthdayLine.split(vcard, existing, stored_before)
+
+ # The other half of the split, the same shape as the birthday's:
+ # what the groups lend comes back out of the submission before it
+ # is stored, so a write cannot materialize a group's lines into
+ # the member's own card.
+ #
+ # The Contact this returns is the composed one — those lines put
# back, 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.
+ lent = lent_of(id)
inherited = inheritance(lent)
# Composed from the parts already read here rather than through
# #contact, which would read all three again. Membership cannot
# move during a put, so the inheritance either side of it is the
# one read above.
replaced = stored_before && Contact.new(id:, stored: stored_before, birthday: existing, inherited:)
+ stored, edits = Lent.subtract(stored, lent, stored_before)
# Built before the transaction as well as inside it, because its
# constructor is where an id that could not be served is refused
# and a refusal from inside a transaction reaches the caller
@@ -1029,7 +1036,7 @@ module ProTacts
# what it lent. A removal leaves a gap in the positions, which
# costs nothing — position orders a group's lines and is not
# counted.
- #: (Array[Arrival::GroupEdit] edits) -> void
+ #: (Array[Lent::Edit] edits) -> void
def apply_group_edits(edits)
edits.each do |edit|
row = group_properties.where(group_id: edit.group_id, position: edit.position)
@@ -1230,8 +1237,8 @@ module ProTacts
# The same read as it stands for a write: the rows a member's edit
# has to reach back to, before #inheritance drops them to what the
- # model wants (see Arrival::Lent).
- #: (String id) -> Array[Arrival::Lent]
+ # model wants (see Lent).
+ #: (String id) -> Array[Lent]
def lent_of(id)
inherited_rows.where(card_id: id).map { lent_from(it) }
end
@@ -1253,7 +1260,7 @@ module ProTacts
# What a model asks of a group's rows: the group lending each,
# whole, and the line itself. Which group_properties row lent it is
# the write path's business and stops here (Contact#group_of).
- #: (Array[Arrival::Lent] lent, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
+ #: (Array[Lent] lent, ?Hash[String, Group] lenders) -> Array[Contact::Inherited]
def inheritance(lent, lenders = groups_by_id(lent.map(&:group_id)))
lent.map { Contact::Inherited.new(group: lenders.fetch(it.group_id), line: it.line) }
end
@@ -1301,9 +1308,9 @@ module ProTacts
)
end
- #: (Hash[Symbol, untyped] row) -> Arrival::Lent
+ #: (Hash[Symbol, untyped] row) -> Lent
def lent_from(row)
- Arrival::Lent.new(
+ Lent.new(
group_id: row.fetch(:group_id).to_s,
position: row.fetch(:position).to_i,
line: row.fetch(:line).to_s,
diff --git a/sig/pro_tacts/arrival.rbs b/sig/pro_tacts/arrival.rbs
deleted file mode 100644
index de9d155..0000000
--- a/sig/pro_tacts/arrival.rbs
+++ /dev/null
@@ -1,22 +0,0 @@
-# Lent and GroupEdit are Data classes, whose members the inline parser
-# cannot read (see sig/pro_tacts/contact.rbs). Arrival itself is an
-# ordinary class and its types are inline in arrival.rb.
-module ProTacts
- class Arrival
- class Lent
- attr_reader group_id: String
- attr_reader position: Integer
- attr_reader line: String
-
- def self.new: (group_id: String, position: Integer, line: String) -> instance
- end
-
- class GroupEdit
- attr_reader group_id: String
- attr_reader position: Integer
- attr_reader line: String?
-
- def self.new: (group_id: String, position: Integer, line: String?) -> instance
- end
- end
-end
diff --git a/sig/pro_tacts/lent.rbs b/sig/pro_tacts/lent.rbs
new file mode 100644
index 0000000..59ae447
--- /dev/null
+++ b/sig/pro_tacts/lent.rbs
@@ -0,0 +1,21 @@
+# Lent and Lent::Edit are Data classes, whose members the inline parser
+# cannot read (see sig/pro_tacts/contact.rbs). Both are marked
+# `@rbs skip` in lent.rb and signed here instead; the methods reopened
+# onto Lent carry their own inline types.
+module ProTacts
+ class Lent
+ attr_reader group_id: String
+ attr_reader position: Integer
+ attr_reader line: String
+
+ def self.new: (group_id: String, position: Integer, line: String) -> instance
+
+ class Edit
+ attr_reader group_id: String
+ attr_reader position: Integer
+ attr_reader line: String?
+
+ def self.new: (group_id: String, position: Integer, line: String?) -> instance
+ end
+ end
+end
diff --git a/test/pro_tacts/test_arrival.rb b/test/pro_tacts/test_arrival.rb
deleted file mode 100644
index 962a683..0000000
--- a/test/pro_tacts/test_arrival.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-require_relative "../test_helper"
-
-require "pro_tacts/arrival"
-require "pro_tacts/birthday"
-require "pro_tacts/vcard"
-
-# The split on its own, with no database behind it: what Store#put
-# applies is decided here from the card and what stood before it, so
-# each branch can be read off its inputs. test_store.rb holds the same
-# rules through a write, where the group rows and the served card are
-# what is checked.
-class ArrivalTest < Minitest::Test
- include Sentry::TestHelper
- include SentryMessages
-
- AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
- HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
- EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
- RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
- OWN_ADDRESS = "ADR:;;1 Long Road;;;;" #: String
- LENT = ProTacts::Arrival::Lent.new(group_id: "kkkk", position: 0, line: HOUSEHOLD_ADDRESS)
-
- def setup
- setup_sentry
- end
-
- def teardown
- teardown_sentry_test
- end
-
- def vcard(bytes) = ProTacts::VCard.new(bytes)
-
- def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
-
- def arrive(bytes, birthday_before: nil, stored_before: nil, lent: [])
- ProTacts::Arrival.new(
- vcard(bytes),
- birthday_before:,
- stored_before: stored_before && vcard(stored_before),
- lent:,
- )
- end
-
- ## The birthday
-
- def test_a_modeled_birthday_leaves_the_card_for_the_model
- arrival = arrive(with_lines("BDAY:1985-04-12"))
-
- assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), arrival.birthday
- assert_equal AIDEN, arrival.stored.to_s
- assert_empty sentry_messages
- end
-
- def test_a_card_without_a_bday_keeps_a_birthday_no_client_was_sent
- month_only = ProTacts::Birthday.new(month: 4)
-
- assert_equal month_only, arrive(AIDEN, birthday_before: month_only).birthday
- end
-
- def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
- assert_nil arrive(AIDEN, birthday_before: ProTacts::Birthday.new(year: 1985, month: 4, day: 12)).birthday
- end
-
- def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
- submitted = with_lines("BDAY:1985-13")
- arrival = arrive(submitted)
-
- assert_nil arrival.birthday
- assert_equal submitted, arrival.stored.to_s
- assert_equal ["a submitted card carried a BDAY line the model does not take"], sentry_messages
- end
-
- def test_several_bday_lines_stay_in_the_card_and_are_reported
- submitted = with_lines("BDAY:1985-04-12", "BDAY:1986-05-13")
- arrival = arrive(submitted)
-
- assert_nil arrival.birthday
- assert_equal submitted, arrival.stored.to_s
- assert_equal ["a submitted card carried 2 BDAY lines"], sentry_messages
- end
-
- def test_a_write_dropping_a_bday_the_model_did_not_take_is_reported
- arrive(AIDEN, stored_before: with_lines("BDAY:1985-13"))
-
- assert_equal ["a write dropped 1 BDAY line(s) the model did not take"], sentry_messages
- end
-
- ## The lent lines
-
- def test_a_card_with_nothing_lent_is_stored_as_it_arrived
- submitted = with_lines(OWN_ADDRESS)
- arrival = arrive(submitted)
-
- assert_equal submitted, arrival.stored.to_s
- assert_empty arrival.edits
- end
-
- def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
- arrival = arrive(with_lines(HOUSEHOLD_ADDRESS), stored_before: AIDEN, lent: [LENT])
-
- assert_equal AIDEN, arrival.stored.to_s
- assert_empty arrival.edits
- assert_empty sentry_messages
- end
-
- def test_a_reserialized_lent_line_is_still_untouched
- arrival = arrive(with_lines(RESERIALIZED_ADDRESS), stored_before: AIDEN, lent: [LENT])
-
- assert_equal AIDEN, arrival.stored.to_s
- assert_empty arrival.edits
- end
-
- def test_a_members_own_line_keeps_its_place_beside_a_lent_one
- own = AIDEN.sub("FN:Aiden\r\n", "#{OWN_ADDRESS}\r\nFN:Aiden\r\n")
- submitted = own.sub("END:VCARD\r\n", "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n")
-
- assert_equal own, arrive(submitted, stored_before: own, lent: [LENT]).stored.to_s
- end
-
- def test_an_edited_lent_line_is_an_edit_of_its_row
- arrival = arrive(with_lines(EDITED_ADDRESS), stored_before: AIDEN, lent: [LENT])
-
- assert_equal AIDEN, arrival.stored.to_s
- assert_equal [ProTacts::Arrival::GroupEdit.new(group_id: "kkkk", position: 0, line: EDITED_ADDRESS)], arrival.edits
- end
-
- def test_a_deleted_lent_line_is_a_removal_of_its_row
- arrival = arrive(AIDEN, stored_before: AIDEN, lent: [LENT])
-
- assert_equal AIDEN, arrival.stored.to_s
- assert_equal [ProTacts::Arrival::GroupEdit.new(group_id: "kkkk", position: 0, line: nil)], arrival.edits
- end
-
- def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
- submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
- arrival = arrive(submitted, stored_before: AIDEN, lent: [LENT])
-
- assert_equal submitted, arrival.stored.to_s
- assert_empty arrival.edits
- assert_equal 1, sentry_messages.length
- end
-
- def test_an_edit_no_group_may_hold_stays_in_the_card_and_is_reported
- submitted = with_lines(HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "item1.ADR:"), "item1.X-ABLabel:dom")
- arrival = arrive(submitted, stored_before: AIDEN, lent: [LENT])
-
- assert_equal submitted, arrival.stored.to_s
- assert_empty arrival.edits
- assert_equal 1, sentry_messages.length
- end
-
- def test_the_birthday_and_the_lent_lines_leave_together
- arrival = arrive(with_lines(HOUSEHOLD_ADDRESS, "BDAY:1985-04-12"), stored_before: AIDEN, lent: [LENT])
-
- assert_equal AIDEN, arrival.stored.to_s
- assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), arrival.birthday
- assert_empty arrival.edits
- end
-end
diff --git a/test/pro_tacts/test_birthday_line.rb b/test/pro_tacts/test_birthday_line.rb
new file mode 100644
index 0000000..b668951
--- /dev/null
+++ b/test/pro_tacts/test_birthday_line.rb
@@ -0,0 +1,74 @@
+require_relative "../test_helper"
+
+require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
+require "pro_tacts/vcard"
+
+# The birthday half of a write on its own, with no store behind it.
+# test_store.rb holds the same rules through Store#put, where the
+# birthdays row and the served card are what is checked.
+class BirthdayLineTest < Minitest::Test
+ include Sentry::TestHelper
+ include SentryMessages
+
+ AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+ APRIL_12 = ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
+
+ def setup
+ setup_sentry
+ end
+
+ def teardown
+ teardown_sentry_test
+ end
+
+ def vcard(bytes) = ProTacts::VCard.new(bytes)
+
+ def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
+
+ def split(bytes, existing: nil, stored_before: nil)
+ ProTacts::BirthdayLine.split(vcard(bytes), existing, stored_before && vcard(stored_before))
+ end
+
+ def test_a_modeled_birthday_leaves_the_card_for_the_model
+ birthday, rest = split(with_lines("BDAY:1985-04-12"))
+
+ assert_equal APRIL_12, birthday
+ assert_equal AIDEN, rest.to_s
+ assert_empty sentry_messages
+ end
+
+ def test_a_card_without_a_bday_keeps_a_birthday_no_client_was_sent
+ month_only = ProTacts::Birthday.new(month: 4)
+
+ assert_equal month_only, split(AIDEN, existing: month_only).first
+ end
+
+ def test_a_card_without_a_bday_drops_a_birthday_the_client_was_sent
+ assert_nil split(AIDEN, existing: APRIL_12).first
+ end
+
+ def test_an_unrecognized_bday_stays_in_the_card_and_is_reported
+ submitted = with_lines("BDAY:1985-13")
+ birthday, rest = split(submitted)
+
+ assert_nil birthday
+ assert_equal submitted, rest.to_s
+ assert_equal ["a submitted card carried a BDAY line the model does not take"], sentry_messages
+ end
+
+ def test_several_bday_lines_stay_in_the_card_and_are_reported
+ submitted = with_lines("BDAY:1985-04-12", "BDAY:1986-05-13")
+ birthday, rest = split(submitted)
+
+ assert_nil birthday
+ assert_equal submitted, rest.to_s
+ assert_equal ["a submitted card carried 2 BDAY lines"], sentry_messages
+ end
+
+ def test_a_write_dropping_a_bday_the_model_did_not_take_is_reported
+ split(AIDEN, stored_before: with_lines("BDAY:1985-13"))
+
+ assert_equal ["a write dropped 1 BDAY line(s) the model did not take"], sentry_messages
+ end
+end
diff --git a/test/pro_tacts/test_lent.rb b/test/pro_tacts/test_lent.rb
new file mode 100644
index 0000000..475a061
--- /dev/null
+++ b/test/pro_tacts/test_lent.rb
@@ -0,0 +1,86 @@
+require_relative "../test_helper"
+
+require "pro_tacts/lent"
+require "pro_tacts/vcard"
+
+# The subtraction on its own, with no store behind it: what the lines
+# a member's groups lend come back as, read off the card and what
+# stood before it. test_store.rb holds the same rules through
+# Store#put, where the group rows and the served card are what is
+# checked.
+class LentTest < Minitest::Test
+ include Sentry::TestHelper
+ include SentryMessages
+
+ AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+ HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+ EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
+ RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
+ OWN_ADDRESS = "ADR:;;1 Long Road;;;;" #: String
+ HOUSEHOLD = ProTacts::Lent.new(group_id: "kkkk", position: 0, line: HOUSEHOLD_ADDRESS)
+
+ def setup
+ setup_sentry
+ end
+
+ def teardown
+ teardown_sentry_test
+ end
+
+ def vcard(bytes) = ProTacts::VCard.new(bytes)
+
+ def with_lines(*lines) = AIDEN.sub("END:VCARD\r\n", lines.map { "#{it}\r\n" }.join + "END:VCARD\r\n")
+
+ def subtract(bytes, stored_before: AIDEN, lent: [HOUSEHOLD])
+ stored, edits = ProTacts::Lent.subtract(vcard(bytes), lent, stored_before && vcard(stored_before))
+ [stored.to_s, edits]
+ end
+
+ def test_a_card_with_nothing_lent_is_stored_as_it_arrived
+ submitted = with_lines(OWN_ADDRESS)
+
+ assert_equal [submitted, []], subtract(submitted, stored_before: nil, lent: [])
+ end
+
+ def test_an_untouched_lent_line_is_subtracted_and_asks_nothing
+ assert_equal [AIDEN, []], subtract(with_lines(HOUSEHOLD_ADDRESS))
+ assert_empty sentry_messages
+ end
+
+ def test_a_reserialized_lent_line_is_still_untouched
+ assert_equal [AIDEN, []], subtract(with_lines(RESERIALIZED_ADDRESS))
+ end
+
+ def test_a_members_own_line_keeps_its_place_beside_a_lent_one
+ own = AIDEN.sub("FN:Aiden\r\n", "#{OWN_ADDRESS}\r\nFN:Aiden\r\n")
+ submitted = own.sub("END:VCARD\r\n", "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n")
+
+ assert_equal [own, []], subtract(submitted, stored_before: own)
+ end
+
+ def test_an_edited_lent_line_is_an_edit_of_its_row
+ edit = ProTacts::Lent::Edit.new(group_id: "kkkk", position: 0, line: EDITED_ADDRESS)
+
+ assert_equal [AIDEN, [edit]], subtract(with_lines(EDITED_ADDRESS))
+ end
+
+ def test_a_deleted_lent_line_is_a_removal_of_its_row
+ removal = ProTacts::Lent::Edit.new(group_id: "kkkk", position: 0, line: nil)
+
+ assert_equal [AIDEN, [removal]], subtract(AIDEN)
+ end
+
+ def test_two_candidates_for_one_lent_line_stay_in_the_card_and_are_reported
+ submitted = with_lines(EDITED_ADDRESS, OWN_ADDRESS)
+
+ assert_equal [submitted, []], subtract(submitted)
+ assert_equal 1, sentry_messages.length
+ end
+
+ def test_an_edit_no_group_may_hold_stays_in_the_card_and_is_reported
+ submitted = with_lines(HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "item1.ADR:"), "item1.X-ABLabel:dom")
+
+ assert_equal [submitted, []], subtract(submitted)
+ assert_equal 1, sentry_messages.length
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 1f2810d..d3b5324 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -948,7 +948,7 @@ class StoreTest < Minitest::Test
# component out of range (month 13, day 32), a spelling its grammar
# does not take (undashed, unpadded — see Birthday's patterns), and a
# BDAY the card hangs off a property group. Why it reports rather
- # than stores quietly is Arrival#report_unrecognized_bday_line.
+ # than stores quietly is BirthdayLine.report_unrecognized.
def test_an_unrecognized_bday_arriving_is_reported
["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4", "item1.BDAY:1985-04-12"].each do |line|
with_store({}) do |store|
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 535a90b..b1a743e 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -392,7 +392,7 @@ class WebTest < Minitest::Test
# back is subtracted before storage and composed again on read, so
# the resource serves the submitted bytes exactly and the tag is
# honest — even though what was stored is fewer bytes than arrived
- # (Arrival#subtract_inherited).
+ # (Lent.subtract).
def test_a_members_put_of_its_served_card_keeps_the_strong_etag
address = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"