1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# Birthdays leave the cards and become state of their own: a partial
# date has no vCard 3.0 spelling (RFC 2425 section 5.8.4 requires all
# three components), so the card stops carrying BDAY and the database
# holds it — docs/plans/2026-08-31-partial-birthdays.md.
#
# `text: true` on the string column because these are STRICT tables,
# which refuse Sequel's plain String. No timestamp: a birthday changes
# with its card's write and the card's stamp is the write's stamp.
require "pro_tacts/birthday"
require "pro_tacts/vcard"
Sequel.migration do
change do
# One birthday per card, keyed by it and dying with it. The columns
# are nullable integers whose nulls carry the shape, validated by
# Birthday rather than by the table: the six legal shapes are a
# grammar fact, not a constraint SQLite can check.
#
# This is the third table in the database that cannot be rebuilt.
# The cards and the change log were the first two; the index tables
# remain projections that `rake index:rebuild` will make again. A
# birthday cannot be re-derived from its card because it no longer
# lives there.
create_table(:birthdays, strict: true) do
foreign_key :card_id, :cards, type: String, text: true, null: false, primary_key: true, on_delete: :cascade
Integer :year
Integer :month
Integer :day
end
# The same move a write makes, run over the cards already stored: a
# BDAY in a modeled spelling moves into the new table and out of the
# card, and any other BDAY stays in the card verbatim. Composed
# again on read, a modeled birthday changes served bytes only by
# position — it lands before END:VCARD — so clients refetch each
# birthday-carrying card once, by design rather than by accident.
# The change log is not written: as far as any client can tell, no
# contact changed, and the stamps are left alone to say the same.
# Spelled out against VCard and Birthday directly rather than
# through a Store helper, so what this migration does cannot drift
# with the app's internals; its test pins the behavior.
self[:cards].order(:id).each do |row|
# The same move a write makes, one arm per shape the stored
# card's BDAY lines take — only the modeled single line moves.
case ProTacts::VCard.new(row.fetch(:vcard)).extract("BDAY")
in [[line], rest]
# Only a line that reads as a BDAY can move it out: the line's
# bytes move as one, and a line holding anything else — or
# nothing this parser will read — stays where it is.
property = line.property
next unless property&.name&.casecmp?("BDAY")
birthday = ProTacts::Birthday.from_property(property)
next if birthday.nil?
self[:cards].where(id: row.fetch(:id)).update(vcard: rest.to_s)
self[:birthdays].insert(
card_id: row.fetch(:id),
year: birthday.year,
month: birthday.month,
day: birthday.day,
)
in [[], _] | [_, _]
# Nothing to move, or lines the model cannot recompose: the
# card stands as stored.
end
end
end
end