dump: write the store out as readable files
Cards go out as bytes, and birthdays and groups as YAML text;
whatever left the store is gone from the dump that follows next.

Assisted-by: Claude Opus 5 via Claude Code
change plkpllslpnrrlumxokvzxzzmzrorvqvk
commit 7e3c8df265dc872a1caf55e4ad499dc3d449f7ac
author Alpha Chen <alpha@kejadlen.dev>
date
parent vnwnwmrt
diff --git a/AGENTS.md b/AGENTS.md
index 97ed937..43c809b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,6 +15,8 @@ rake                  # Tests (the default task)
 rake steep            # Type check lib against the RBS comments in it
 rake fixtures         # Re-record response fixtures from current behavior
 rake index:rebuild    # Derive the parsed index again from the stored cards
+rake db:dump          # Write the cards, birthdays, and groups to data/dump
+                      # (DUMP=path to move it)
 rake dev              # Dev server, seeded from test/fixtures/cards into a
                       # throwaway tmpdir on every start, reloading on change
                       # (needs fd and entr)
diff --git a/README.md b/README.md
index 8703ee6..c66393f 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ does work is under Status below.
 
 - Groups with attributes (e.g., an address shared by all members)
 - Selective sync (choose which contacts to sync rather than all-or-nothing)
-- vCards exported to git after each write (version history, human-readable)
+- A readable dump of the database, for history kept in version control (`rake db:dump`)
 
 ## Status
 
@@ -42,7 +42,10 @@ makes the case at length. The same database holds the change log and
 the birthdays — the two things in it that cannot be rebuilt, the
 birthdays because a partial date has no vCard 3.0 spelling — and an
 index of parsed properties, which can: `rake index:rebuild` derives
-that again from the stored cards alone.
+that again from the stored cards alone. `rake db:dump` writes the
+cards, birthdays, and groups out as plain files, into `data/dump`
+unless `DUMP` names another directory
+(`docs/plans/2026-09-12-database-dump.md`).
 
 macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
 later work has a known-good baseline to change. See
diff --git a/Steepfile b/Steepfile
index b0cbfac..8c4ad9e 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", "json", "logger", "pathname", "securerandom", "strscan"
+  library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan", "yaml"
 end
diff --git a/docs/plans/2026-09-12-database-dump.md b/docs/plans/2026-09-12-database-dump.md
new file mode 100644
index 0000000..bb6ca38
--- /dev/null
+++ b/docs/plans/2026-09-12-database-dump.md
@@ -0,0 +1,34 @@
+# The database dump
+
+2026-09-12. `rake db:dump` writes what the store cannot rebuild as
+plain files, into `data/dump` unless `DUMP` names another directory.
+It replaces the git export
+after every write that `2026-08-24-vcard-storage-and-groups.md`
+describes: the app runs no git, and history is whatever the directory's
+owner keeps of it.
+
+## What it holds
+
+That plan predates birthdays and groups living beside the card, so "the
+stored card" is no longer everything about a contact. The dump holds
+all three, split the way the store splits them:
+
+| Path | Holds |
+|---|---|
+| `cards/<id>.vcf` | The stored card, byte for byte |
+| `birthdays.yml` | Every birthday, keyed by card id, in any of the six shapes |
+| `groups/<id>.yml` | A group's name, the lines it lends, and its members |
+
+The change log and the index are left out. The index is rebuilt from the
+cards, and the change log is sync history that no restore would replay.
+Nothing reads a dump back; a restore would need an importer.
+
+## A dump over a dump
+
+`cards/` and `groups/` are made to hold exactly what the store holds, so
+a card or group deleted from the store is deleted from the directory.
+Anything else in the directory, such as a repository's own files, is left
+alone, and unchanged files are not rewritten.
+
+The parts are read in one transaction (`Store#snapshot`), deferred so the
+read takes no write lock and a running server's writes are not held up.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 28528a0..764a5c4 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -87,6 +87,12 @@ module ProTacts
     # @rbs skip
     GroupEdit = Data.define(:group_id, :position, :line)
 
+    # Everything the store cannot rebuild, read at one moment: the
+    # stored cards by id, the birthdays by card id, and the groups.
+    # What `rake db:dump` writes (tasks/db.rake).
+    # @rbs skip
+    Snapshot = Data.define(:cards, :birthdays, :groups)
+
     # A group as the admin screens read one: its own row, the lines it
     # lends in the order it lends them, and its members' card ids. The
     # label is the SQL one #group_label computes, so a tag and a
@@ -222,6 +228,22 @@ module ProTacts
       @database.disconnect
     end
 
+    # The store's state for a dump, in one read transaction so the parts
+    # agree. Deferred rather than the store's usual immediate: a read
+    # needs no write lock, and taking one would hold up writers.
+    #: () -> Snapshot
+    def snapshot
+      @database.transaction(mode: :deferred) do
+        Snapshot.new(
+          cards: cards.order(:id).all.to_h {
+            [it.fetch(:id).to_s, it.fetch(:vcard).to_s] #: [String, String]
+          },
+          birthdays: birthdays_by_id,
+          groups: all_groups,
+        )
+      end
+    end
+
     # Every contact, ordered by id so that a listing does not depend on
     # what SQLite feels like returning.
     #: () -> Array[Contact]
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index ef3d266..e8182d5 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -55,7 +55,7 @@ module Sequel
 
     # Joins a transaction already open rather than nesting, so a write
     # inside a write is still one atomic unit.
-    def transaction: [T] () { () -> T } -> T
+    def transaction: [T] (?mode: Symbol) { () -> T } -> T
 
     # SQLite's own: which BEGIN every transaction opens with. Nil,
     # :deferred, :immediate, or :exclusive.
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 9687cf2..a84ff25 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -46,6 +46,14 @@ module ProTacts
       def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
     end
 
+    class Snapshot
+      attr_reader cards: Hash[String, String]
+      attr_reader birthdays: Hash[String, Birthday]
+      attr_reader groups: Array[Group]
+
+      def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group]) -> instance
+    end
+
     class GroupEdit
       attr_reader group_id: String
       attr_reader position: Integer
diff --git a/tasks/db.rake b/tasks/db.rake
new file mode 100644
index 0000000..2813110
--- /dev/null
+++ b/tasks/db.rake
@@ -0,0 +1,55 @@
+# A readable copy of what the store cannot rebuild: each stored card as
+# its bytes, the birthdays, and the groups, split the way the store
+# splits them (docs/plans/2026-09-12-database-dump.md). cards/ and
+# groups/ are made to hold exactly what the store holds, so a dump kept
+# in version control shows deletions too; anything else in the
+# directory is left alone.
+
+require "pathname"
+require "pro_tacts"
+
+dump = Pathname.new(ENV.fetch("DUMP") { (ProTacts.config.data_dir / "dump").to_s })
+cards = dump / "cards"
+groups = dump / "groups"
+
+directory cards.to_s
+directory groups.to_s
+
+namespace :db do
+  desc "Dump the cards, birthdays, and groups into #{dump} (DUMP=path to move it)"
+  task dump: [cards.to_s, groups.to_s] do
+    require "yaml"
+    require "pro_tacts/store"
+
+    database = ProTacts.config.database_path
+    ProTacts::Store.connect(database) do |store|
+      snapshot = store.snapshot
+
+      dump_directory(cards, snapshot.cards.to_h { |id, card| ["#{id}.vcf", card] })
+
+      birthdays = snapshot.birthdays.sort_by { |id, _| id }.to_h { |id, birthday|
+        [id, {"year" => birthday.year, "month" => birthday.month, "day" => birthday.day}.compact]
+      }
+      dump_file(dump / "birthdays.yml", YAML.dump(birthdays))
+
+      dump_directory(groups, snapshot.groups.to_h { |group|
+        ["#{group.id}.yml", YAML.dump({"name" => group.name, "lines" => group.lines, "members" => group.members})]
+      })
+    end
+    puts "dumped #{database} into #{dump}"
+  end
+
+  # A directory holding exactly these files, so what left the store
+  # leaves the dump.
+  def dump_directory(directory, files)
+    directory.children.each { it.delete unless files.key?(it.basename.to_s) }
+    files.each { |name, content| dump_file(directory / name, content) }
+  end
+
+  # Unchanged files are not rewritten, so a dump over a dump touches
+  # only what moved. Compared as bytes: a card is UTF-8 and a file read
+  # back is binary.
+  def dump_file(path, content)
+    path.binwrite(content) unless path.exist? && path.binread == content.b
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 481b4c7..000cea6 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1819,6 +1819,19 @@ class StoreTest < Minitest::Test
     end
   end
 
+  def test_a_snapshot_holds_the_stored_state
+    with_store({"aiden" => AIDEN_BORN}) do |store|
+      group = store.create_group(name: "Booles")
+      store.add_member(group, "aiden")
+
+      snapshot = store.snapshot
+
+      assert_equal({"aiden" => AIDEN}, snapshot.cards)
+      assert_equal({"aiden" => ProTacts::Birthday.new(year: 1985, month: 4, day: 12)}, snapshot.birthdays)
+      assert_equal [["Booles", ["aiden"]]], snapshot.groups.map { [it.name, it.members] }
+    end
+  end
+
   private
 
   # A store whose change-log write fails, after Store#put has already put
diff --git a/test/tasks/test_db.rb b/test/tasks/test_db.rb
new file mode 100644
index 0000000..2e19c33
--- /dev/null
+++ b/test/tasks/test_db.rb
@@ -0,0 +1,77 @@
+require_relative "../test_helper"
+
+require "pathname"
+require "rake"
+require "tmpdir"
+require "yaml"
+
+require "pro_tacts/store"
+require "pro_tacts/vcard"
+
+class DbTasksTest < Minitest::Test
+  AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+  # The stored card as its bytes, and the two things beside it that no
+  # card carries: the birthday, in a shape no client is sent, and the
+  # group with what it lends and who it lends to.
+  def test_the_dump_writes_what_the_store_cannot_rebuild
+    with_store do |store, root|
+      store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")))
+      group = store.create_group(name: "Booles")
+      store.set_group_lines(group, ["NOTE:a household"])
+      store.add_member(group, "aiden")
+
+      dump(root)
+
+      assert_equal AIDEN, (root / "dump" / "cards" / "aiden.vcf").binread
+      assert_equal({"aiden" => {"year" => 1985, "month" => 4}}, YAML.safe_load((root / "dump" / "birthdays.yml").read))
+      assert_equal({"name" => "Booles", "lines" => ["NOTE:a household"], "members" => ["aiden"]},
+        YAML.safe_load((root / "dump" / "groups" / "#{group}.yml").read))
+    end
+  end
+
+  # A dump over a dump matches the store: a card gone from it is gone
+  # from the directory, and what the dump does not write is left alone.
+  def test_a_dump_over_a_dump_drops_what_left_the_store
+    with_store do |store, root|
+      store.put("aiden", vcard(AIDEN))
+      (root / "dump" / "cards").mkpath
+      (root / "dump" / "cards" / "gone.vcf").write("stale")
+      (root / "dump" / "README").write("mine")
+
+      dump(root)
+
+      assert_equal ["aiden.vcf"], (root / "dump" / "cards").children.map { it.basename.to_s }
+      assert_equal "mine", (root / "dump" / "README").read
+    end
+  end
+
+  private
+
+  def with_store
+    Dir.mktmpdir do |root|
+      root = Pathname.new(root)
+      ProTacts::Store.connect(root / "contacts.db") { |store| yield store, root }
+    end
+  end
+
+  def vcard(bytes) = ProTacts::VCard.new(bytes)
+
+  # Runs db:dump in a Rake application of its own, configured with root
+  # as the data directory, so the database is root's and the default
+  # dump lands in root/dump.
+  def dump(root)
+    config = ProTacts.config
+    application = Rake.application
+    overridden = ENV.delete("DUMP")
+
+    ProTacts.config = ProTacts::Config.new("PRO_TACTS_DATA_DIR" => root.to_s)
+    Rake.application = Rake::Application.new
+    load (Pathname.new(__dir__).parent.parent / "tasks" / "db.rake").to_s
+    capture_io { Rake.application["db:dump"].invoke }
+  ensure
+    ProTacts.config = config
+    Rake.application = application
+    ENV["DUMP"] = overridden
+  end
+end