store, admin: log the lines each write moved
An etag says a card changed but never says how,
so each write hands the log the lines it moved, from now.
No old row had a diff, and none could be spun,
so the table came down and the sequence starts at one.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index e77d55e..97ed937 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -169,6 +169,12 @@ not a fixture; edit a `.vcf` to change what the replay serves.
one — no second path takes an etag on trust. The etag in `changes` is
the exception and is not the same fact — it is what the card hashed
to at that write, which nothing can recompute once the card moves on.
+ Its `diff` is the same kind of fact and the same kind of exception
+ (`CardDiff`): the lines that write added and removed, of the card as
+ served, so the etag and the diff describe one download. It records a
+ card's lines and not its bytes — folding is normalized away — so it
+ says what a write did without being able to rebuild what it did it
+ to.
- Nothing above `vcard/parser.rb` handles a parse error, and nothing
should start. `Parser.lines` is the only read: a line that will not
read comes back as a Line with no property. The card is served from
diff --git a/Steepfile b/Steepfile
index ad199e6..b0cbfac 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,5 +21,5 @@ target :lib do
ignore "lib/pro_tacts/admin", inline: true
signature "sig"
- library "date", "digest", "fileutils", "logger", "pathname", "securerandom", "strscan"
+ library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan"
end
diff --git a/db/migrations/006_change_diffs.rb b/db/migrations/006_change_diffs.rb
new file mode 100644
index 0000000..18cccf6
--- /dev/null
+++ b/db/migrations/006_change_diffs.rb
@@ -0,0 +1,44 @@
+# The change log grows a diff: the lines each write added and removed,
+# as the JSON object ProTacts::CardDiff spells. The etag says a card
+# changed and the diff says how — and like the etag, it is a fact about
+# a card that has since moved on, so nothing can derive it later. The
+# card as served, not the bytes stored, for the etag's own reason: the
+# two describe one write, and a client's download is what both are
+# about.
+#
+# The old rows are dropped rather than copied across, which every
+# migration before this one refused to do. An entry written before this
+# column has no diff and no way to be given one, so carrying it would
+# mean a nullable column whose whole point is that it is never null —
+# and this address book has never run anywhere but a development
+# machine, so the history discarded is a seeded database's.
+#
+# What that costs, if this is ever untrue: the sequence starts at 1
+# again, so a client holding a token from the old log sits past the end
+# of the new one and hears about nothing until the log grows past its
+# number, and Store#ctag — the sequence itself — moves backwards, which
+# a client is entitled to read as no change at all. Both are resolved
+# by removing the account and adding it again. A later column on this
+# table is an ALTER TABLE ADD COLUMN or a rebuild that copies, never
+# this.
+#
+# `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
+ drop_table(:changes)
+
+ create_table(:changes, 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 :diff, text: true, null: false
+ String :created_at, text: true, null: false, default: Sequel.lit(now)
+ constraint(:action_is_known, action: %w[put delete edit group])
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index e39cf13..6a0b1aa 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -214,12 +214,12 @@ module ProTacts
# One row per entry, on the record's own grid: the action in the
# type column, because it is the kind of thing the row is, and
- # the moment and the etag in the value column. Both of those are
- # the value — the etag is what the card hashed to at that write
- # and the one hash in the schema that is stored rather than
- # derived (AGENTS.md), so it is shown whole. A delete's etag is
- # null, and the row is its moment alone: a tombstone hashes
- # nothing.
+ # the moment, the etag, and the lines the write moved in the
+ # value column. All three are the value — the etag is what the
+ # card hashed to at that write and the one hash in the schema
+ # that is stored rather than derived (AGENTS.md), so it is shown
+ # whole. A delete's etag is null, and the row is its moment and
+ # the card it carried away: a tombstone hashes nothing.
#: () -> void
def change_rows
@changes.each do |change|
@@ -227,10 +227,45 @@ module ProTacts
dd(class: "type-body-sm") do
div { Format.stamp(change.created_at) }
div(class: "type-mono") { change.etag } if change.etag
+ diff_lines(change.diff)
end
end
end
+ # Removed lines and then added ones, each marked the way a diff
+ # marks them — the sign, not the color, is what says which, so a
+ # row reads the same to someone who cannot tell the two hues
+ # apart. Order within each is the card's own (CardDiff), and a
+ # write that moved no line at all renders nothing rather than an
+ # empty block: a rewrite storing what was already there is a real
+ # entry with nothing to show.
+ #: (CardDiff diff) -> void
+ def diff_lines(diff)
+ return if diff.empty?
+
+ div(class: "diff type-mono") do
+ # Named rather than `it`: Phlex yields the component to an
+ # element's block, so an inner `it` is this view, not the
+ # line.
+ diff.removed.each { |line| div(class: "diff-removed") { "-#{elide_text(line)}" } }
+ diff.added.each { |line| div(class: "diff-added") { "+#{elide_text(line)}" } }
+ end
+ end
+
+ # A diff's line elided the way the raw card's are. It arrives as
+ # text rather than as a card's line, so it is read back into one
+ # to reach #elide's rebuild — worth the parse: the first entry of
+ # a card with a picture is the whole card, and a base64 payload
+ # is exactly the wall ELIDE_ABOVE exists for. A line the parser
+ # cannot read still elides, by the byte surgery #elide falls back
+ # to; a line the terminator was stripped from keeps none, so the
+ # rebuilt one sheds the CRLF #elide gives it.
+ #: (String text) -> String
+ def elide_text(text)
+ line = VCard::Parser.lines(text).first
+ line ? elide(line).chomp : text
+ end
+
# The stored card for display: byte for byte, except a value long
# enough to be a wall. The count says exactly what is there; what
# it stands in for is not readable text.
diff --git a/lib/pro_tacts/card_diff.rb b/lib/pro_tacts/card_diff.rb
new file mode 100644
index 0000000..524f496
--- /dev/null
+++ b/lib/pro_tacts/card_diff.rb
@@ -0,0 +1,93 @@
+require "json"
+
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+ # What one write did to a card, as the lines it added and the lines
+ # it removed — the change log's second fact that nothing can
+ # recompute (see Store), since the card this one replaced is gone the
+ # moment the write lands.
+ #
+ # A multiset difference over the card's lines, not an edit script:
+ # macOS Contacts reorders properties on every card it touches
+ # (docs/macos-contacts.md), so a positional diff would report a
+ # shuffle as a change to every line below the first move. Lines that
+ # both cards carry cancel, however far apart they sit, and what is
+ # left is the write. A card carrying the same line twice is two
+ # lines, which is why this counts rather than sets: dropping one copy
+ # is a removal, and a set difference would call it nothing.
+ #
+ # The unit is the logical line unfolded and shorn of its terminator,
+ # which is the line as the grammar reads it (RFC 2426 section 2.6
+ # folding). So a fold moved or a card rewritten CRLF for LF is no
+ # change here, and the diff is a record of a card's lines rather than
+ # of its bytes — it cannot rebuild the card it came from, and is not
+ # meant to.
+ #
+ # The signature lives in sig/pro_tacts/card_diff.rbs, this being a
+ # Data class.
+ # @rbs skip
+ CardDiff = Data.define(:added, :removed)
+
+ # Reopened rather than defined in the block above, for the reason
+ # VCard::Parser::Property is.
+ class CardDiff
+ # The write from the card before it to the card after it, either of
+ # them nil: a create adds every line of a card that was not there,
+ # and a delete removes every line of one that will not be.
+ #: (VCard? before, VCard? after) -> CardDiff
+ def self.between(before, after)
+ old_lines = content_lines(before)
+ new_lines = content_lines(after)
+ new(added: without(new_lines, old_lines), removed: without(old_lines, new_lines))
+ end
+
+ # The stored spelling: a JSON object, so a line goes in and comes
+ # back byte for byte whatever it holds. A diff is displayed beside
+ # the entry it belongs to and never queried, so the column is one
+ # value rather than a table of lines.
+ #: (String json) -> CardDiff
+ def self.from_json(json)
+ parsed = JSON.parse(json) #: Hash[String, Array[String]]
+ new(added: parsed.fetch("added"), removed: parsed.fetch("removed"))
+ end
+
+ # A card's lines, each unfolded and stripped of its terminator. The
+ # blank line a card may end with reads as no line at all, the same
+ # answer Parser gives it, so a card's trailing whitespace is not a
+ # line to add or remove.
+ #: (VCard? vcard) -> Array[String]
+ def self.content_lines(vcard)
+ return [] if vcard.nil?
+
+ vcard.lines.filter_map {
+ line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+ line unless line.empty?
+ }
+ end
+
+ # `lines` minus one occurrence of each line `others` also carries.
+ # Order is the card's own, so a diff reads down the card the lines
+ # came from.
+ #: (Array[String] lines, Array[String] others) -> Array[String]
+ def self.without(lines, others)
+ remaining = others.tally
+ lines.reject { |line|
+ count = remaining.fetch(line, 0)
+ next false if count.zero?
+
+ remaining[line] = count - 1
+ true
+ }
+ end
+
+ private_class_method :content_lines, :without
+
+ #: () -> bool
+ def empty? = added.empty? && removed.empty?
+
+ #: () -> String
+ def to_json(*) = {added:, removed:}.to_json
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7462d26..87d6005 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -5,6 +5,7 @@ require "sequel"
require "sentry-ruby"
require "pro_tacts/birthday"
+require "pro_tacts/card_diff"
require "pro_tacts/contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -49,10 +50,11 @@ module ProTacts
class Store
# @rbs @database: Sequel::Database
- # One entry in the change log. The signature is in
- # sig/pro_tacts/store.rbs with Store's own.
+ # One entry in the change log: what happened to a card, what it
+ # hashed to afterwards, and which of its lines the write moved. The
+ # signature is in sig/pro_tacts/store.rbs with Store's own.
# @rbs skip
- Change = Data.define(:sequence, :card_id, :action, :etag, :created_at)
+ Change = Data.define(:sequence, :card_id, :action, :etag, :diff, :created_at)
# A contact paired with when its card last changed. Contact itself
# carries no timestamp — it is derived from the card alone, see its
@@ -343,6 +345,11 @@ module ProTacts
# logged etag is its hash, so a client's token describes the card
# it downloads.
inherited = inherited_of(id)
+ # 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 = subtract_inherited(stored, inherited, own)
contact = Contact.new(id:, stored:, birthday:, inherited:)
@database.transaction do
@@ -350,7 +357,7 @@ module ProTacts
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
.insert(id: contact.id, vcard: stored.to_s)
write_birthday(contact.id, birthday)
- record(contact.id, "put", contact.etag)
+ record(contact.id, "put", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
reindex(contact.id, stored)
end
contact
@@ -368,25 +375,29 @@ module ProTacts
# again to reach what the caller already had.
#: (String id, VCard vcard, birthday: Birthday?) -> Contact
def rewrite(id, vcard, birthday:)
+ before = contact(id)
contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
.insert(id: contact.id, vcard: vcard.to_s)
write_birthday(contact.id, birthday)
- record(contact.id, "edit", contact.etag)
+ record(contact.id, "edit", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
reindex(contact.id, vcard)
end
contact
end
# Removes a card, leaving the change-log entry that tells a syncing
- # client it is gone.
+ # client it is gone. The tombstone carries the whole card away in
+ # its diff — every line removed and none added — which is the only
+ # record left of what was here, the row itself being gone.
#: (String id) -> bool
def delete(id)
@database.transaction do
+ before = contact(id)
deleted = cards.where(id:).delete.positive?
- record(id, "delete", nil) if deleted
+ record(id, "delete", nil, CardDiff.between(before&.vcard, nil)) if deleted
deleted
end
end
@@ -500,9 +511,13 @@ module ProTacts
SecureRandom.hex(2).tr("0-9a-f", REVERSE_HEX)
end
- #: (String card_id, String action, String? etag) -> void
- def record(card_id, action, etag)
- change_log.insert(card_id:, action:, etag:)
+ # The diff comes in already computed rather than being taken here
+ # 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)
+ change_log.insert(card_id:, action:, etag:, diff: diff.to_json)
end
# Replaces a card's rows in the index. Rebuilt wholesale rather than
@@ -966,6 +981,7 @@ module ProTacts
card_id: row.fetch(:card_id).to_s,
action: row.fetch(:action).to_s,
etag: row.fetch(:etag),
+ diff: CardDiff.from_json(row.fetch(:diff).to_s),
created_at: row.fetch(:created_at).to_s,
)
end
diff --git a/public/admin.css b/public/admin.css
index 5a378f6..74f686a 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -594,3 +594,23 @@ img.avatar {
.change-log dd > .type-mono {
overflow-wrap: anywhere;
}
+
+/* The lines a write moved, under the etag they hashed to. Removed
+ above added, the order a diff hunk reads in, and the sign in front
+ of each line is what says which — the color is a second telling of
+ it, not the only one, so a row survives being read in one hue. Both
+ wrap for the etag's reason: a line here is a card's, and a card's
+ lines run past 75 octets often enough that the RFC has a folding
+ rule for it. */
+.diff {
+ margin-top: var(--gl-space-2xs);
+}
+.diff > div {
+ overflow-wrap: anywhere;
+}
+.diff-removed {
+ color: var(--gl-color-danger);
+}
+.diff-added {
+ color: var(--gl-color-success);
+}
diff --git a/sig/pro_tacts/card_diff.rbs b/sig/pro_tacts/card_diff.rbs
new file mode 100644
index 0000000..77a1379
--- /dev/null
+++ b/sig/pro_tacts/card_diff.rbs
@@ -0,0 +1,12 @@
+# CardDiff is a Data class, whose members the inline parser cannot
+# read (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip` in
+# card_diff.rb and signed here instead; the methods reopened onto it
+# carry their own inline types.
+module ProTacts
+ class CardDiff
+ attr_reader added: Array[String]
+ attr_reader removed: Array[String]
+
+ def self.new: (added: Array[String], removed: Array[String]) -> instance
+ end
+end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 69fdbf5..1eb38f8 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -8,9 +8,10 @@ module ProTacts
attr_reader card_id: String
attr_reader action: String
attr_reader etag: String?
+ attr_reader diff: CardDiff
attr_reader created_at: String
- def self.new: (sequence: Integer, card_id: String, action: String, etag: String?, created_at: String) -> instance
+ def self.new: (sequence: Integer, card_id: String, action: String, etag: String?, diff: CardDiff, created_at: String) -> instance
end
class RecentContact
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index dac4641..33533a8 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -478,6 +478,36 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # Each entry shows the lines its write moved, removed above added
+ # 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)
+
+ get "/contacts/ada"
+
+ body = last_response.body
+ assert_includes body, '<div class="diff-removed">-FN:Ada Lovelace</div>'
+ assert_includes body, '<div class="diff-added">+FN:Ada L.</div>'
+ assert_operator body.index("-FN:Ada Lovelace"), :<, body.index("+FN:Ada L.")
+ end
+ end
+
+ # A first write's diff is the whole card, so a picture's payload
+ # would be a base64 wall in the log as surely as in the raw card —
+ # and elides the same way. The refuted chunk is a continuation line
+ # of the stored card, contiguous only in an unelided render.
+ def test_the_change_log_elides_a_photo_payload
+ with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
+ get "/contacts/pic"
+
+ body = last_response.body
+ assert_includes body, '<div class="diff-added">+PHOTO'
+ refute_includes body, store.contact("pic").vcard.to_s.lines.grep(/\A /).first.to_s
+ end
+ end
+
# A photo card's raw section elides the base64 wall to its octet
# count; the property's name and parameters, and every other line,
# stay byte for byte. The refuted chunk is a continuation line of
diff --git a/test/pro_tacts/test_card_diff.rb b/test/pro_tacts/test_card_diff.rb
new file mode 100644
index 0000000..d2e62b8
--- /dev/null
+++ b/test/pro_tacts/test_card_diff.rb
@@ -0,0 +1,72 @@
+require_relative "../test_helper"
+
+require "pro_tacts/card_diff"
+require "pro_tacts/vcard"
+
+class CardDiffTest < Minitest::Test
+ AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+ def card(bytes) = ProTacts::VCard.new(bytes)
+
+ def between(before, after) = ProTacts::CardDiff.between(before, after)
+
+ def test_a_card_out_of_nothing_adds_every_line
+ diff = between(nil, card(AIDEN))
+
+ assert_equal ["BEGIN:VCARD", "VERSION:3.0", "FN:Aiden", "UID:aiden", "END:VCARD"], diff.added
+ assert_empty diff.removed
+ end
+
+ def test_a_card_going_to_nothing_removes_every_line
+ diff = between(card(AIDEN), nil)
+
+ assert_empty diff.added
+ assert_equal 5, diff.removed.length
+ end
+
+ def test_the_same_card_twice_is_no_change
+ assert between(card(AIDEN), card(AIDEN)).empty?
+ end
+
+ def test_an_edited_line_is_a_removal_and_an_addition
+ diff = between(card(AIDEN), card(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")))
+
+ assert_equal ["FN:Aiden Smith"], diff.added
+ assert_equal ["FN:Aiden"], diff.removed
+ end
+
+ # macOS reorders properties on every card it touches
+ # (docs/macos-contacts.md), so a diff that read position would call a
+ # shuffle a rewrite of everything below the first move.
+ def test_reordering_a_card_is_no_change
+ shuffled = AIDEN.sub("FN:Aiden\r\nUID:aiden\r\n", "UID:aiden\r\nFN:Aiden\r\n")
+
+ assert between(card(AIDEN), card(shuffled)).empty?
+ end
+
+ # Folding is transport (RFC 2426 section 2.6), so the two spellings
+ # of one line are one line.
+ def test_folding_a_line_is_no_change
+ long = AIDEN.sub("FN:Aiden", "NOTE:one two")
+ folded = AIDEN.sub("FN:Aiden", "NOTE:one\r\n two")
+
+ assert between(card(long), card(folded)).empty?
+ end
+
+ # Counted, not set-differenced: a card carrying a line twice and one
+ # carrying it once are not the same card.
+ def test_dropping_one_of_two_identical_lines_is_a_removal
+ twice = AIDEN.sub("END:VCARD", "NOTE:Same\r\nNOTE:Same\r\nEND:VCARD")
+ once = AIDEN.sub("END:VCARD", "NOTE:Same\r\nEND:VCARD")
+
+ diff = between(card(twice), card(once))
+ assert_equal ["NOTE:Same"], diff.removed
+ assert_empty diff.added
+ end
+
+ def test_a_diff_survives_the_column_it_is_stored_in
+ diff = between(card(AIDEN), card(AIDEN.sub("FN:Aiden", "FN:Aiden \\n Smith")))
+
+ assert_equal diff, ProTacts::CardDiff.from_json(diff.to_json)
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index a9d7da7..e253148 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -445,6 +445,65 @@ class StoreTest < Minitest::Test
end
end
+ def test_a_first_write_adds_the_whole_card
+ with_store({"aiden" => AIDEN}) do |store|
+ diff = store.changes.last.diff
+
+ assert_equal AIDEN.lines.map(&:chomp), diff.added
+ assert_empty diff.removed
+ end
+ end
+
+ def test_a_writes_diff_records_the_lines_it_moved
+ with_store({"aiden" => AIDEN}) do |store|
+ store.put("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")))
+
+ diff = store.changes.last.diff
+ assert_equal ["FN:Aiden Smith"], diff.added
+ assert_equal ["FN:Aiden"], diff.removed
+ end
+ end
+
+ # The tombstone is the only record left of a deleted card, the row
+ # being gone: what it carries away is the whole of what was there.
+ def test_a_tombstone_carries_the_card_away
+ with_store({"aiden" => AIDEN}) do |store|
+ store.delete("aiden")
+
+ diff = store.changes.last.diff
+ assert_equal AIDEN.lines.map(&:chomp), diff.removed
+ assert_empty diff.added
+ end
+ end
+
+ # The diff describes the card a client downloads, the same one the
+ # logged etag hashes — so a birthday that moved to the model, and is
+ # composed back in on every read, is a line the diff records adding
+ # even though no stored card carries it.
+ def test_the_diff_is_of_the_card_as_served
+ with_store({"aiden" => AIDEN}) do |store|
+ store.put("aiden", vcard(AIDEN_BORN))
+
+ assert_equal ["BDAY:1985-04-12"], store.changes.last.diff.added
+ end
+ end
+
+ # A group's line is in the served card either side of a member's
+ # write, so it cancels: what a member's entry records is what the
+ # member's write did, not what it inherited while doing it.
+ def test_a_lent_line_is_no_part_of_a_members_diff
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ served = store.contact("aiden").vcard.to_s
+
+ store.put("aiden", vcard(served.sub("FN:Aiden", "FN:Aiden Smith")))
+
+ diff = store.changes.last.diff
+ assert_equal ["FN:Aiden Smith"], diff.added
+ assert_equal ["FN:Aiden"], diff.removed
+ end
+ end
+
def test_an_unknown_cards_changes_are_empty
with_store({"aiden" => AIDEN}) do |store|
assert_empty store.changes_of("nobody")
@@ -1164,9 +1223,12 @@ class StoreTest < Minitest::Test
# one when it does.
def test_the_change_log_admits_the_group_action
with_store({"aiden" => AIDEN}) do |store|
- database(store)[:changes].insert(card_id: "aiden", action: "group", etag: "\"x\"")
+ database(store)[:changes]
+ .insert(card_id: "aiden", action: "group", etag: "\"x\"", diff: diff_json(added: ["NOTE:Gate code."]))
- assert_equal "group", store.changes.last.action
+ change = store.changes.last
+ assert_equal "group", change.action
+ assert_equal ["NOTE:Gate code."], change.diff.added
end
end
@@ -1418,11 +1480,12 @@ 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
+ # A database from before the diff column loses its log to 006, which
+ # is what that migration trades for a column that is never null: an
+ # entry written then has no diff and no way to be given one. The
+ # sequence restarts with it, and the widened action vocabulary is
+ # still there on the other side.
+ def test_the_diff_migration_drops_the_change_log
Dir.mktmpdir do |dir|
path = Pathname.new(dir) / "contacts.db"
Sequel.connect("sqlite://#{path}") do |db|
@@ -1432,16 +1495,13 @@ class StoreTest < Minitest::Test
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)
+ assert_empty store.changes
- # The widened vocabulary admits the editor's action, and the
- # sequence continues past the copied high-water mark.
store.put("aiden", vcard(AIDEN))
store.rewrite("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 4, change.sequence
+ assert_equal 2, change.sequence
end
end
end
@@ -1518,7 +1578,7 @@ class StoreTest < Minitest::Test
class FailingLog < ProTacts::Store
private
- def record(card_id, action, etag)
+ def record(card_id, action, etag, diff)
raise "the change log is unavailable"
end
end
@@ -1530,6 +1590,11 @@ class StoreTest < Minitest::Test
store.instance_variable_get(:@database)
end
+ # A diff column's value, for a row inserted around the store.
+ def diff_json(added: [], removed: [])
+ ProTacts::CardDiff.new(added:, removed:).to_json
+ end
+
def card_row(store, id)
database(store)[:cards].where(id:).sole
end