store: rewrite takes the birthday it serves
Assisted-by: GLM-5.3 via pi
change zwkuxrxmymyrlswqtxyxkztrkuupkmqz
commit 524d9dca2682605db637501b11883a7e7a540aae
author Alpha Chen <alpha@kejadlen.dev>
date
parent mkzpkupp
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