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
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.