lib: give the names and types some comments were standing in for
`own` in the store meant the card as it stood before a write, which
is not what `Contact#own` means and not what `#save_edit` calls the
same thing; `#composed` was `#contact` without the nil; the ADR
components were spelled out in three files; the sync token was built
in one place and re-read by a regex in another; and the change log's
four actions were bare strings whose only list was a CHECK
constraint.
- `own` is `stored_before`, and the composed contact beside it is
`replaced`.
- `Store#composed` is `Store#contact!`.
- `Store#rewrite` is `Store#save_edit`, which frees "rewrite" to mean
what it means everywhere else in the file.
- `Contact::ADDRESS_COMPONENTS` is the one list; CardForm and the
importer read it.
- `ProTacts::SyncToken` mints and reads the token, and `Web` asks it.
- `Store::Action` names the four the schema admits, and `#record`
takes its columns by name.
No behavior moves: the fixtures and the replays are the check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWFQaLD58uLFedh3Dvfb2C
diff --git a/AGENTS.md b/AGENTS.md
index 1e8e329..09eff06 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,8 @@ lib/pro_tacts/
├── birthday.rb # A partial date, the one fact no card can hold
├── birthday_line.rb # What a submitted BDAY line is, for Store#put
├── 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
├── vcard.rb # vCard 3.0 escaping, and what a card is
├── vcard/parser.rb # One card's bytes into lines
├── proxy_auth.rb # The login, off the header the proxy writes
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 1d95117..398923d 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -1,4 +1,5 @@
require "pro_tacts/birthday"
+require "pro_tacts/contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -13,21 +14,6 @@ module ProTacts
#
# Not a view, unlike its neighbours, so the Steepfile checks it.
module CardForm
- # ADR's editable components: field name to position in the value
- # (RFC 2426 section 3.2.1, minus the leading po box at position 0
- # — no screen shows one, and the save preserves its bytes rather
- # than letting a form field near them). In the value's own order,
- # which is the order a rebuilt line's components join in.
- ADDRESS_COMPONENTS = {
- "extended" => 1,
- "street" => 2,
- "locality" => 3,
- "region" => 4,
- "postal_code" => 5,
- "country" => 6,
- }.freeze #: Hash[String, Integer]
- private_constant :ADDRESS_COMPONENTS
-
# A created contact's card: the envelope RFC 2426 section 4
# requires — BEGIN, VERSION, and the N and FN that section makes
# mandatory — plus the UID this server's id model is (see
@@ -189,7 +175,7 @@ module ProTacts
#: (Contact::Address address, untyped submitted) -> bool
def self.address_unchanged?(address, submitted)
- ADDRESS_COMPONENTS.keys.all? { |name|
+ Contact::ADDRESS_COMPONENTS.all? { |name|
submitted[name].to_s.strip == address.public_send(name).to_s
}
end
@@ -216,7 +202,8 @@ module ProTacts
components = VCard.split_raw_components(property.value)
end
components << "" while components.length < 7
- ADDRESS_COMPONENTS.each do |name, position|
+ Contact::ADDRESS_COMPONENTS.each_with_index do |name, index|
+ position = index + 1
value = submitted[name].to_s.strip
current = address && address.public_send(name).to_s
components[position] = value == current ? components.fetch(position) : VCard.escape(value)
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index b0d79f7..4a0839c 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -55,13 +55,12 @@ module ProTacts
private_constant :FORM
# The address row's component fields, stacked in the value
- # column in the order an address form reads — RFC 2426 section
- # 3.2.1's own order minus the po box, which no screen shows and
- # the save splices around. Field name to placeholder: a
- # component is not an attribute and earns no type column of its
- # own, so the placeholder is the label and the conventional
- # order carries it. Public because a group's address rows are
- # these same rows (Admin::GroupsEdit).
+ # column in the order an address form reads, which is not the
+ # order the value spells them in (Contact::ADDRESS_COMPONENTS).
+ # Field name to placeholder: a component is not an attribute and
+ # earns no type column of its own, so the placeholder is the
+ # label and the conventional order carries it. Public because a
+ # group's address rows are these same rows (Admin::GroupsEdit).
ADDRESS_FIELDS = [
["street", "street"],
["extended", "street 2"],
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 026eaad..9e6d283 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -93,6 +93,14 @@ module ProTacts
:line,
)
+ # An ADR's editable components, in the order the value spells them
+ # (RFC 2426 section 3.2.1). The post office box heads that value
+ # and is not among them — no screen shows one, and a save
+ # preserves its bytes rather than letting a form field near them —
+ # so a component's position in the value is one past its index
+ # here.
+ ADDRESS_COMPONENTS = %w[extended street locality region postal_code country].freeze #: Array[String]
+
#: (VCard vcard) -> String
def self.etag_for(vcard)
%("#{Digest::SHA256.hexdigest(vcard.to_s)}")
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 45409ce..b4fac74 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -28,11 +28,6 @@ module ProTacts
# so the two are read together and edited in one place.
SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
- # An address's parts, as the editor's own add row names them
- # (Admin::CardForm's ADDRESS_COMPONENTS, which is private to it).
- # No post office box: no screen shows one either.
- ADDRESS_KEYS = %w[extended street locality region postal_code country].freeze #: Array[String]
-
# A phone's parts: a number, and the label the source card's
# X-ABLabel gave the row, left out rather than written empty
# (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
@@ -69,7 +64,7 @@ module ProTacts
invalid.("phones must be a list, each with a quoted number and an optional label") unless phones.is_a?(Array) && phones.all? { phone?(it) }
invalid.("emails must be a list of addresses") unless texts?(emails)
addresses.is_a?(Array) && addresses.all? { address?(it) } or
- invalid.("addresses must be a list, each with any of #{ADDRESS_KEYS.join(", ")}")
+ invalid.("addresses must be a list, each with any of #{Contact::ADDRESS_COMPONENTS.join(", ")}")
invalid.("groups must be a list of names") unless texts?(groups) && groups.none? { it.strip.empty? }
invalid.("note must be text, or blank") unless note.nil? || note.is_a?(String)
invalid.("photo must be true or false") unless [true, false].include?(photo)
@@ -89,7 +84,7 @@ module ProTacts
#: (untyped value) -> bool
def self.address?(value)
- value.is_a?(Hash) && (value.keys - ADDRESS_KEYS).empty? &&
+ value.is_a?(Hash) && (value.keys - Contact::ADDRESS_COMPONENTS).empty? &&
value.values.all?(String) && value.values.any? { !it.strip.empty? }
end
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index a7b2485..23c5906 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -3,6 +3,7 @@ require "open3"
require "pathname"
require "pro_tacts/change_id"
+require "pro_tacts/contact"
require "pro_tacts/import/card"
require "pro_tacts/import/plan"
require "pro_tacts/vcard/parser"
@@ -206,7 +207,7 @@ module ProTacts
# The post office box leads the value and has no field; a card
# carrying one does not get this far (see ADR's value check).
_po_box, *parts = property.components
- Card::ADDRESS_KEYS.zip(parts).to_h { |key, value|
+ Contact::ADDRESS_COMPONENTS.zip(parts).to_h { |key, value|
[key, value.to_s] #: [String, String]
}.reject { |_key, value| value.empty? }
end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 1aa9511..0c911d8 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -45,6 +45,17 @@ module ProTacts
# @rbs skip
Change = Data.define(:sequence, :card_id, :action, :etag, :diff, :created_at)
+ # What an entry says happened. The four the schema admits and no
+ # fifth — the CHECK constraint's own list
+ # (db/migrations/006_change_diffs.rb), which is what refuses one
+ # this does not name.
+ module Action
+ PUT = "put" #: String
+ EDIT = "edit" #: String
+ DELETE = "delete" #: String
+ GROUP = "group" #: String
+ end
+
# A contact paired with when its card last changed. Contact itself
# carries no timestamp — it is derived from the card alone, see its
# own comment — so a surface that sorts or displays recency (the
@@ -438,21 +449,18 @@ module ProTacts
# `client` marks a client's write: a card it creates joins `sync:*`,
# created on first use
# (docs/plans/2026-09-15-client-creates-join-everyone.md). A
- # rewrite of a card that exists joins nothing.
+ # put over a card that exists joins nothing.
#: (String id, VCard vcard, ?client: bool) -> Contact
def put(id, vcard, client: false)
existing = birthday_of(id)
- # The card as it stands before this write, read once for the
- # three things that want it: the birthday split, which reports
- # what a rewrite drops; the subtraction after it, which accounts
- # for the member's own lines before attributing any to a group;
- # and the card the change log records this write as replacing.
- own = stored_card(id)
- birthday, stored = split_birthday(vcard, existing, own)
+ # 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)
+ birthday, stored = split_birthday(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 rewrite cannot materialize a group's lines into
+ # 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
@@ -461,17 +469,17 @@ module ProTacts
# it downloads.
lent = lent_of(id)
inherited = inheritance(lent)
- # The card this write replaces, 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.
- before = own && Contact.new(id:, stored: own, birthday: existing, inherited:)
- stored, edits = subtract_inherited(stored, lent, own)
+ # 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 = subtract_inherited(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
# wearing Sequel's own error class. Composition is lazy, so the
- # one that gets replaced below cost nothing to make.
+ # one the fan-out throws away below cost nothing to make.
contact = Contact.new(id:, stored:, birthday:, inherited:)
@database.transaction do
# The groups move first, so that everything composed below is
@@ -488,19 +496,23 @@ module ProTacts
# 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 client && own.nil?
+ if client && stored_before.nil?
group_members.insert(group_id: everyone_group_id, 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))
+ record(
+ contact.id,
+ action: Action::PUT,
+ etag: contact.etag,
+ diff: CardDiff.between(replaced&.vcard, contact.vcard),
+ )
reindex(contact.id, stored)
contact
end
end
- # The store's own editor changed a contact, against #put's "a
- # client submitted a card" — why the two paths cannot be one is
+ # Why this and #put cannot be one path is
# docs/plans/2026-09-05-web-card-editor.md, "Two write paths
# through the store". The card input is the stored one by
# definition, and `birthday:` is the model's whole new state, an
@@ -510,13 +522,18 @@ module ProTacts
# make this save, and re-reading its bytes here would walk them
# again to reach what the caller already had.
#: (String id, VCard vcard, birthday: Birthday?) -> Contact
- def rewrite(id, vcard, birthday:)
+ def save_edit(id, vcard, birthday:)
before = contact(id)
contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
@database.transaction do
upsert_card(contact.id, vcard)
write_birthday(contact.id, birthday)
- record(contact.id, "edit", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
+ record(
+ contact.id,
+ action: Action::EDIT,
+ etag: contact.etag,
+ diff: CardDiff.between(before&.vcard, contact.vcard),
+ )
reindex(contact.id, vcard)
end
contact
@@ -531,7 +548,7 @@ module ProTacts
@database.transaction do
before = contact(id)
deleted = cards.where(id:).delete.positive?
- record(id, "delete", nil, CardDiff.between(before&.vcard, nil)) if deleted
+ record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before&.vcard, nil)) if deleted
deleted
end
end
@@ -585,8 +602,13 @@ module ProTacts
# parameters follow their properties away on the cascade.
card_properties.where(card_id: id).delete
after = contact_from(cards.where(id: new_id).sole, birthday_of(new_id), inherited_of(new_id))
- record(id, "delete", nil, CardDiff.between(before.vcard, nil))
- record(new_id, "put", after.etag, CardDiff.between(before.vcard, after.vcard))
+ record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before.vcard, nil))
+ record(
+ new_id,
+ action: Action::PUT,
+ etag: after.etag,
+ diff: CardDiff.between(before.vcard, after.vcard),
+ )
reindex(new_id, stored)
end
return new_id
@@ -864,8 +886,8 @@ module ProTacts
# off a `before` and an `after`: only the caller knows which two
# cards its write was between, and a delete's `after` is nothing at
# all.
- #: (String card_id, String action, String? etag, CardDiff diff) -> void
- def record(card_id, action, etag, diff)
+ #: (String card_id, action: String, etag: String?, diff: CardDiff) -> void
+ def record(card_id, action:, etag:, diff:)
change_log.insert(card_id:, action:, etag:, diff: diff.to_json)
end
@@ -888,15 +910,15 @@ module ProTacts
def fan_out(cards, except: nil, moved: [])
ids = cards - [except].compact
before = ids.to_h {
- [it, composed(it)] #: [String, Contact]
+ [it, contact!(it)] #: [String, Contact]
}
yield
ids.each do |id|
was = before.fetch(id)
- now = composed(id)
+ now = contact!(id)
next if was.vcard.to_s == now.vcard.to_s && !moved.include?(id)
- record(id, "group", now.etag, CardDiff.between(was.vcard, now.vcard))
+ record(id, action: Action::GROUP, etag: now.etag, diff: CardDiff.between(was.vcard, now.vcard))
end
end
@@ -976,12 +998,11 @@ module ProTacts
.map { it.fetch(:card_id).to_s }
end
- # A member's card as it composes right now. #contact's read without
- # the rescue it makes: a group_members row names a card by foreign
- # key, so no row here is the corruption `sole` exists to raise on
- # rather than the ordinary miss an href off the wire is.
+ # A group_members row names a card by foreign key, so a missing
+ # row here is the corruption `sole` exists to raise on rather than
+ # the ordinary miss an href off the wire is.
#: (String id) -> Contact
- def composed(id)
+ def contact!(id)
contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
end
@@ -1032,16 +1053,16 @@ module ProTacts
#
# 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 rewrite, which is worth saying.
+ # 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? own) -> [Birthday?, VCard]
- def split_birthday(vcard, existing, own)
+ #: (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(own)
+ report_lost_bday_lines(stored_before)
return [existing && !existing.served? ? existing : nil, vcard]
end
@@ -1114,11 +1135,11 @@ 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? own) -> [VCard, Array[GroupEdit]]
- def subtract_inherited(vcard, lent, own)
+ #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[GroupEdit]]
+ def subtract_inherited(vcard, lent, stored_before)
return [vcard, []] if lent.empty?
- unaccounted = unaccounted_lines(vcard, own)
+ unaccounted = unaccounted_lines(vcard, stored_before)
taken = [] #: Array[VCard::Parser::Line]
moved = lent.reject { |row|
@@ -1289,16 +1310,16 @@ module ProTacts
property.types.sort
end
- # The submission's lines that the member's own stored card does not
- # already explain — one struck per stored line of the same bytes,
+ # The submission's lines the card as it stood before this write
+ # does not already explain — one struck per stored line of the same bytes,
# so a card that stores one of something and submits two leaves one
# over. What is left is what the groups lent plus whatever the
# client wrote beside it, which is the pool a lent line is
# attributed from. Everything for a card being created, which has
# no stored lines to explain anything.
- #: (VCard vcard, VCard? own) -> Array[VCard::Parser::Line]
- def unaccounted_lines(vcard, own)
- stored = own ? own.lines.map { it.verbatim.chomp } : [] #: Array[String]
+ #: (VCard vcard, VCard? stored_before) -> Array[VCard::Parser::Line]
+ def 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)
stored.delete_at(index) if index
@@ -1350,17 +1371,17 @@ module ProTacts
)
end
- # The loss report, the rewrite's half of the arrival one: a BDAY
- # line still in the stored card is one the model did not take, and a
- # rewrite without it is about to drop it with nobody told.
- #: (VCard? own) -> void
- def report_lost_bday_lines(own)
- return if own.nil?
+ # 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, = own.extract("BDAY")
+ lines, = stored_before.extract("BDAY")
return if lines.empty?
- Sentry.capture_message("a rewrite dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
+ Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
end
# The birthday travels beside the card rather than inside it, and
diff --git a/lib/pro_tacts/sync_token.rb b/lib/pro_tacts/sync_token.rb
new file mode 100644
index 0000000..d321f94
--- /dev/null
+++ b/lib/pro_tacts/sync_token.rb
@@ -0,0 +1,36 @@
+require "digest"
+
+module ProTacts
+ # A sync token as this server spells one: the ctag it was minted at
+ # and the book it was minted for, in a URI. Opaque to the client
+ # (RFC 6578 section 3) and the URI form is conventional, but not
+ # opaque to this server, which has to tell its own token from one
+ # another book minted — the one a rename leaves a user holding
+ # included (docs/plans/2026-09-12-per-user-books.md, "The wire").
+ # Built on the ctag so that a client polling either one sees changes
+ # at the same points.
+ module SyncToken
+ PREFIX = "http://pro-tacts/sync/" #: String
+
+ # The book half: the first 16 hex digits of the SHA-256 of the
+ # requester's login.
+ #: (String login) -> String
+ def self.book(login)
+ Digest::SHA256.hexdigest(login)[0, 16].to_s
+ end
+
+ #: (String ctag, String book) -> String
+ def self.mint(ctag, book)
+ "#{PREFIX}#{ctag}/#{book}"
+ end
+
+ # The sequence a token carries, or nil for one this book did not
+ # mint — another book's, a rename's leftover, or a string that is
+ # no token of ours at all. Whether the sequence is one the change
+ # log can still answer from is the caller's to judge.
+ #: (String token, String book) -> Integer?
+ def self.read(token, book)
+ token[%r{\A#{Regexp.escape(PREFIX)}(\d+)/#{Regexp.escape(book)}\z}, 1]&.to_i
+ end
+ end
+end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index c3e1a13..36ede4b 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -182,7 +182,7 @@ module ProTacts
return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
end
- store.rewrite(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
+ store.save_edit(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
r.redirect "/contacts/#{id}", 303
end
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index ff18c45..8af8cb8 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -1,7 +1,7 @@
-require "digest"
require "nokogiri"
require "pro_tacts/dav_xml"
+require "pro_tacts/sync_token"
module ProTacts
# The CardDAV half of the router: service discovery and the one
@@ -163,7 +163,7 @@ module ProTacts
# client back through multiget, so no address-data here.
#
# The token carries the change log's sequence and whose book
- # it was issued for (see #sync_token), and the delta is the
+ # it was issued for (SyncToken), and the delta is the
# log after the sequence, answered from the requester's book
# as it is now: a card that left it answers as removed
# (section 3.5.2), and so does one that never was in it,
@@ -190,9 +190,8 @@ module ProTacts
etag_response(d, it)
end
end
- elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
- sequence.to_i <= store.latest_sequence
- net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
+ elsif (sequence = SyncToken.read(token, book_digest)) && sequence <= store.latest_sequence
+ net = store.changes(after: sequence).map { it.card_id }.uniq
multistatus("card", sync_token:) do |d|
net.each do |id|
contact = contacts.find { it.id == id }
@@ -346,22 +345,16 @@ module ProTacts
@ctag ||= store.ctag
end
- # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
- # form is conventional. Built on the ctag so a client polling either
- # one sees changes at the same points, and on the requester's login
- # so that a token is refused by any other book, the one a rename
- # leaves a user with included (docs/plans/2026-09-12-per-user-books.md,
- # "The wire").
+ # This request's token, minted at the ctag read above so that the
+ # one served here and the one a delta is measured from agree.
#: () -> String
def sync_token
- "http://pro-tacts/sync/#{ctag}/#{book_digest}"
+ SyncToken.mint(ctag, book_digest)
end
- # The name half of a sync token: the first 16 hex digits of the
- # SHA-256 of the requester's login.
#: () -> String
def book_digest
- Digest::SHA256.hexdigest(@login)[0, 16].to_s
+ SyncToken.book(@login)
end
# The whole of the PUT route: a private method because a Roda route
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 98937c9..3e80609 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -670,8 +670,8 @@ class AdminContactsPagesTest < Minitest::Test
# the action for, rendered here.
def test_the_change_log_leads_with_the_latest_entry
with_contacts({"ada" => ADA}) do |store|
- store.rewrite("ada", ProTacts::VCard.new(ADA.sub("Ada Lovelace", "Ada L.")),
- birthday: store.contact("ada").birthday)
+ store.save_edit("ada", ProTacts::VCard.new(ADA.sub("Ada Lovelace", "Ada L.")),
+ birthday: store.contact("ada").birthday)
get "/contacts/ada"
@@ -700,8 +700,8 @@ class AdminContactsPagesTest < Minitest::Test
# and signed rather than only colored.
def test_the_change_log_shows_what_each_write_moved
with_contacts({"ada" => ADA}) do |store|
- store.rewrite("ada", ProTacts::VCard.new(ADA.sub("FN:Ada Lovelace", "FN:Ada L.")),
- birthday: store.contact("ada").birthday)
+ store.save_edit("ada", ProTacts::VCard.new(ADA.sub("FN:Ada Lovelace", "FN:Ada L.")),
+ birthday: store.contact("ada").birthday)
get "/contacts/ada"
@@ -1211,7 +1211,7 @@ class AdminContactsPagesTest < Minitest::Test
# The save is addressed operations, and this is the whole design:
# the four properties the form names are replaced, every other line
# of the stored card is byte-identical, and the write lands through
- # Store#rewrite with its change-log entry.
+ # Store#save_edit with its change-log entry.
def test_saving_the_edit_replaces_only_the_addressed_properties
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "King", nickname: "Countess",
@@ -1769,7 +1769,7 @@ class AdminContactsPagesTest < Minitest::Test
# A birthday edit is a model write: the served card composes the new
# BDAY, the stored bytes never move, and the change-log entry carries
# the new composed etag — a client's token sees what a device
- # downloads (Store#rewrite).
+ # downloads (Store#save_edit).
def test_saving_a_changed_birthday_moves_the_served_card_only
with_contacts({"ada" => ADA}) do |store|
before = store.contact("ada").stored.to_s
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 9187c4b..ab3d55c 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -328,18 +328,18 @@ class StoreTest < Minitest::Test
## The editor's write
- # rewrite is the store's own editor's path: the bytes land byte for
+ # save_edit is the store's own editor's path: the bytes land byte for
# byte, the change-log entry carries the composed etag a client
# would download, and the action names the side that wrote it — put
# is a client's submission, edit is the admin's save. The birthday
# passed beside the card is the model's whole new state
# (docs/plans/2026-09-07-web-birthday-editor.md), so the caller
# handing back the current one is the card-only save.
- def test_a_rewrite_stores_the_card_and_logs_the_composed_etag
+ def test_an_edit_stores_the_card_and_logs_the_composed_etag
with_store({"aiden" => AIDEN_BORN}) do |store|
edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
- contact = store.rewrite("aiden", vcard(edited), birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+ contact = store.save_edit("aiden", vcard(edited), birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
assert_equal edited, card_row(store, "aiden").fetch(:vcard)
composed = edited.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
@@ -351,16 +351,16 @@ class StoreTest < Minitest::Test
end
end
- # The hazard rewrite exists for: an editor's save carries no BDAY
+ # The hazard save_edit exists for: an editor's save carries no BDAY
# line by construction, and put reads exactly that as a birthday the
# client dropped — so running the save through put would delete the
- # model row and the contact's birthday with it. rewrite writes the
+ # model row and the contact's birthday with it. save_edit writes the
# birthday it is handed, so the caller passing the current model —
# what a card-only save does — keeps the row.
- def test_a_rewrite_writing_the_current_birthday_keeps_it
+ def test_an_edit_writing_the_current_birthday_keeps_it
with_store({"aiden" => AIDEN_BORN}) do |store|
- store.rewrite("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")),
- birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+ store.save_edit("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")),
+ birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
assert_includes store.contact("aiden").vcard.to_s, "BDAY:1985-04-12"
@@ -371,11 +371,11 @@ class StoreTest < Minitest::Test
# it: the composed BDAY lands at the new value, the stored bytes do
# not move at all, and the change-log entry carries the new composed
# etag — a client's token has to see what a device would download.
- def test_a_rewrite_with_a_new_birthday_moves_the_served_card_only
+ def test_an_edit_with_a_new_birthday_moves_the_served_card_only
with_store({"aiden" => AIDEN_BORN}) do |store|
stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
- contact = store.rewrite("aiden", vcard(stored), birthday: ProTacts::Birthday.new(month: 4, day: 12))
+ contact = store.save_edit("aiden", vcard(stored), birthday: ProTacts::Birthday.new(month: 4, day: 12))
assert_equal stored, card_row(store, "aiden").fetch(:vcard)
assert_includes contact.vcard.to_s, "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"
@@ -386,11 +386,11 @@ class StoreTest < Minitest::Test
# Nil is the row's blank-equals-absent: the model row goes, and the
# composed card with it.
- def test_a_rewrite_with_no_birthday_deletes_the_row
+ def test_an_edit_with_no_birthday_deletes_the_row
with_store({"aiden" => AIDEN_BORN}) do |store|
stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
- contact = store.rewrite("aiden", vcard(stored), birthday: nil)
+ contact = store.save_edit("aiden", vcard(stored), birthday: nil)
assert_nil birthday_row(store, "aiden")
refute_includes contact.vcard.to_s, "BDAY"
@@ -1196,7 +1196,7 @@ class StoreTest < Minitest::Test
FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
put = store.put("aiden", vcard(store.contact("aiden").vcard.to_s))
- edit = store.rewrite("aiden", vcard(AIDEN), birthday: nil)
+ edit = store.save_edit("aiden", vcard(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] }
@@ -2054,7 +2054,7 @@ class StoreTest < Minitest::Test
assert_empty store.changes
store.put("aiden", vcard(AIDEN))
- store.rewrite("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")), birthday: nil)
+ store.save_edit("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")), birthday: nil)
change = store.changes.last
assert_equal %w[aiden edit], [change.card_id, change.action]
assert_equal 2, change.sequence
diff --git a/test/pro_tacts/test_sync_token.rb b/test/pro_tacts/test_sync_token.rb
new file mode 100644
index 0000000..2cee828
--- /dev/null
+++ b/test/pro_tacts/test_sync_token.rb
@@ -0,0 +1,40 @@
+require_relative "../test_helper"
+
+require "pro_tacts/sync_token"
+
+class SyncTokenTest < Minitest::Test
+ BOOK = ProTacts::SyncToken.book("alpha@example.com")
+
+ def test_a_minted_token_reads_back_as_its_sequence
+ assert_equal 22, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK), BOOK)
+ end
+
+ # Zero is what a book nothing has been written to carries, and it has
+ # to read back as a sequence rather than as no token at all.
+ def test_the_zero_sequence_reads_back
+ assert_equal 0, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("0", BOOK), BOOK)
+ end
+
+ def test_another_books_token_reads_as_nothing
+ other = ProTacts::SyncToken.book("zed@example.com")
+
+ assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", other), BOOK)
+ end
+
+ # The shape macOS was recorded sending before books existed
+ # (test/fixtures/macos-exchange/08-report-sync-collection).
+ def test_a_token_from_before_books_reads_as_nothing
+ assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/1", BOOK)
+ end
+
+ def test_anything_that_is_not_a_token_reads_as_nothing
+ ["", "1", "http://pro-tacts/sync//#{BOOK}", "http://pro-tacts/sync/22/#{BOOK}/"].each do |text|
+ assert_nil ProTacts::SyncToken.read(text, BOOK), text
+ end
+ end
+
+ def test_a_book_is_sixteen_hex_digits_of_its_login
+ assert_match(/\A[0-9a-f]{16}\z/, BOOK)
+ refute_equal BOOK, ProTacts::SyncToken.book("zed@example.com")
+ end
+end