store: rewrite takes the birthday it serves
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 2311881..2d03fe0 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -287,24 +287,28 @@ module ProTacts
contact
end
- # The store's own editor changed a stored card: upsert the bytes,
- # reindex, and record the change-log entry carrying the composed
- # etag a client would download — and touch nothing else. Not #put,
- # which answers a client's submission and reads a BDAY-less card as
- # a birthday the client dropped (its rewrite arm deletes the model
+ # The store's own editor changed a contact: upsert the card's
+ # bytes and the birthday beside it, reindex, and record one
+ # change-log entry carrying the composed etag a client would
+ # download — the served card moves when either half moves, so one
+ # save is one entry however it split between them. Not #put, which
+ # answers a client's submission and reads a BDAY-less card as a
+ # birthday the client dropped (its rewrite arm deletes the model
# row accordingly): an editor's save carries no BDAY line by
- # construction, so running it through put would delete every edited
- # contact's birthday. rewrite's input is the stored card by
- # definition, and the model row rides along untouched
- # (docs/plans/2026-09-05-web-card-editor.md).
- #: (String id, String vcard) -> Contact
- def rewrite(id, vcard)
+ # construction, so running it through put would delete every
+ # edited contact's birthday. rewrite's card input is the stored
+ # one by definition, and the birthday passed beside it is the
+ # model's whole new state — an upsert or a delete, never a splice
+ # (docs/plans/2026-09-07-web-birthday-editor.md).
+ #: (String id, String vcard, birthday: Birthday?) -> Contact
+ def rewrite(id, vcard, birthday:)
card = VCard.new(vcard)
- contact = Contact.for(id:, stored: card, birthday: birthday_of(id))
+ contact = Contact.for(id:, stored: card, birthday:)
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
.insert(id: contact.id, vcard: card.to_s)
+ write_birthday(contact.id, birthday)
record(contact.id, "edit", contact.etag)
reindex(contact.id, card)
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 860a417..ee0654d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -673,7 +673,7 @@ module ProTacts
# race window between check and write, noted there.
return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
- store.rewrite(id, edited_card(contact, first, last, r.params).to_s)
+ store.rewrite(id, edited_card(contact, first, last, r.params).to_s, birthday: contact.birthday)
r.redirect "/contacts/#{id}", 303
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index dfaaad5..87881db 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -336,12 +336,15 @@ class StoreTest < Minitest::Test
# rewrite 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.
+ # 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
with_store({"aiden" => AIDEN_BORN}) do |store|
edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
- contact = store.rewrite("aiden", edited)
+ contact = store.rewrite("aiden", 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")
@@ -356,17 +359,49 @@ class StoreTest < Minitest::Test
# The hazard rewrite 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 touches
- # nothing but the card, the log, and the index.
- def test_a_rewrite_leaves_the_birthday_alone
+ # model row and the contact's birthday with it. rewrite 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
with_store({"aiden" => AIDEN_BORN}) do |store|
- store.rewrite("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+ store.rewrite("aiden", 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"
end
end
+ # A birthday edit is a model write, and the served card moves with
+ # 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
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
+
+ contact = store.rewrite("aiden", 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"
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), birthday_row(store, "aiden")
+ assert_equal contact.etag, store.changes.last.etag
+ end
+ end
+
+ # 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
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
+
+ contact = store.rewrite("aiden", stored, birthday: nil)
+
+ assert_nil birthday_row(store, "aiden")
+ refute_includes contact.vcard.to_s, "BDAY"
+ end
+ end
+
def test_a_delete_leaves_a_tombstone_behind
with_store({"aiden" => AIDEN}) do |store|
assert store.delete("aiden")
@@ -923,7 +958,7 @@ class StoreTest < Minitest::Test
# The widened vocabulary admits the editor's action, and the
# sequence continues past the copied high-water mark.
store.put("aiden", AIDEN)
- store.rewrite("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+ store.rewrite("aiden", 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 4, change.sequence