store: rewrite a stored card, the editor's write path
put reads a BDAY-less card as a birthday the client dropped, so an
admin save routed through it would delete every edited contact's
birthday; rewrite carries the model row along untouched. The change
log's action vocabulary grows an edit value for it, which SQLite
can only do by rebuilding the table — sequences copied, since they
are every client's sync token state.

Assisted-by: GLM-5.3 via pi
change oztpqvwlzqxxyplrpqyxlmrzyqmwynnm
commit ba1822991a70bae585fee9b285a6951757ae4510
author Alpha Chen <alpha@kejadlen.dev>
date
parent syzmruyn
diff --git a/db/migrations/003_change_log_edit_action.rb b/db/migrations/003_change_log_edit_action.rb
new file mode 100644
index 0000000..a61cb78
--- /dev/null
+++ b/db/migrations/003_change_log_edit_action.rb
@@ -0,0 +1,34 @@
+# The change log's action grows a third value: edit, the admin UI's
+# save (Store#rewrite). To a syncing client an edit reads the same as
+# a put — the member changed, at a new etag — but the log is also the
+# record of which side wrote a card, and the change-log display on the
+# contact's page renders the difference.
+#
+# SQLite cannot alter a CHECK constraint, so the table is rebuilt:
+# created again beside the old one, rows copied with their sequences,
+# old dropped, new renamed. The sequence is every client's sync token
+# state — copied, not regenerated, so no token skips a change; nothing
+# references this table, so the drop takes nothing with it.
+#
+# `text: true` on the string columns 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
+    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])
+    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 c0e2a70..0c9de38 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -111,6 +111,13 @@ module ProTacts
 
     attr_reader :id
 
+    # The stored card, without the birthday composed in — the editor's
+    # handle on the bytes it mutates (docs/plans/2026-09-05-web-card-editor.md).
+    # Everything else this class answers describes #vcard, the card a
+    # client downloads; the stored one is what a surgical edit applies
+    # to, and what the store writes back after it.
+    attr_reader :stored
+
     # The model held beside the card (see the class comment) — the
     # store's fact, not a parse of the served one. Nil over a card
     # carrying an unmodeled BDAY spelling; showing that is
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 88edc41..2311881 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -287,6 +287,30 @@ 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
+    # 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)
+      card = VCard.new(vcard)
+      contact = Contact.for(id:, stored: card, birthday: birthday_of(id))
+      @database.transaction do
+        cards
+          .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
+          .insert(id: contact.id, vcard: card.to_s)
+        record(contact.id, "edit", contact.etag)
+        reindex(contact.id, card)
+      end
+      contact
+    end
+
     # Removes a card, leaving the tombstone that tells a syncing client
     # it is gone. Returns whether there was anything to remove.
     #: (String id) -> bool
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 302de76..dfaaad5 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -331,6 +331,42 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## The editor's write
+
+  # 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.
+  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)
+
+      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")
+      assert_equal composed, contact.vcard.to_s
+      assert_equal ProTacts::Contact.etag_for(composed), contact.etag
+      change = store.changes.last
+      assert_equal %w[aiden edit], [change.card_id, change.action]
+      assert_equal ProTacts::Contact.etag_for(composed), change.etag
+    end
+  end
+
+  # 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
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      store.rewrite("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+
+      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
+
   def test_a_delete_leaves_a_tombstone_behind
     with_store({"aiden" => AIDEN}) do |store|
       assert store.delete("aiden")
@@ -867,6 +903,34 @@ class StoreTest < Minitest::Test
 
   ## The migration
 
+  # A database at the old action vocabulary — put and delete only — is
+  # carried over with its sequences intact: they are every client's
+  # sync token state, and a rebuilt table that skipped or reused one
+  # would silently drop a change from some client's window.
+  def test_the_migration_carries_the_change_log_and_its_sequences
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+      Sequel.connect("sqlite://#{path}") do |db|
+        Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 2)
+        db[:changes].insert(card_id: "aiden", action: "put", etag: '"a1"')
+        db[:changes].insert(card_id: "aiden", action: "delete", etag: nil)
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        assert_equal [%w[aiden put], %w[aiden delete]], store.changes.map { [it.card_id, it.action] }
+        assert_equal [1, 2], store.changes.map(&:sequence)
+
+        # 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"))
+        change = store.changes.last
+        assert_equal %w[aiden edit], [change.card_id, change.action]
+        assert_equal 4, change.sequence
+      end
+    end
+  end
+
   # A database at the old shape — BDAY in the card, no birthdays table
   # — is carried over by the same subtraction a write makes: modeled
   # forms move, unmodeled forms stay put.