dav: read only the cards a request serves
The collection read every card in the database and then dropped the
ones outside the requester's book, and a multiget or a sync delta
composed the whole book to answer for the few cards it names.
Store#contacts takes the ids to read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNVLyiMrZJ7sZctEYq6WCM
change
commit 0b8d29bdc63b7890f163db9da7057224e9c4d199
author Claude <noreply@anthropic.com>
date
parent 4f1ce61e
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 189cec8..2bba8f3 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -232,13 +232,15 @@ module ProTacts
       end
     end
 
-    # Every contact, ordered by id so that a listing does not depend on
-    # what SQLite feels like returning.
-    #: () -> Array[Contact]
-    def contacts
+    # Every contact, or only those of these ids, ordered by id so that a
+    # listing does not depend on what SQLite feels like returning. The
+    # ids narrow the read itself, so a book or a multiget composes no
+    # card it will not serve.
+    #: (?Array[String]? ids) -> Array[Contact]
+    def contacts(ids = nil)
       birthdays = birthdays_by_id
       inherited = inherited_by_id
-      cards.order(:id).map {
+      (ids ? cards.where(id: ids) : cards).order(:id).map {
         id = it.fetch(:id).to_s
         contact_from(it, birthdays[id], inherited.fetch(id, []))
       }
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 62931e6..161c11b 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -193,9 +193,10 @@ module ProTacts
               end
             elsif (sequence = SyncToken.read(token, book_digest, store.database_id)) && sequence <= store.latest_sequence
               net = store.changes(after: sequence).map { it.card_id }.uniq
+              found = members(net)
               multistatus("card", sync_token:) do |d|
                 net.each do |id|
-                  contact = contacts.find { it.id == id }
+                  contact = found[id]
                   contact ? etag_response(d, contact) : d.missing(contact_href(id))
                 end
               end
@@ -219,15 +220,17 @@ module ProTacts
             # address-data the client asks for is section 10.4.
             wants_cards = doc.xpath("//address-data").any?
 
+            requested = doc.xpath("//href").map { it.text }
+            ids = requested.map { it[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1] }
+            found = members(ids.compact)
             multistatus("card") do |d, card|
-              doc.xpath("//href").map { it.text }.each do |requested|
-                id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
-                contact = id && contacts.find { it.id == id }
+              requested.zip(ids).each do |href, id|
+                contact = id && found[id]
 
                 if contact
                   wants_cards ? card_response(d, card, contact) : etag_response(d, contact)
                 else
-                  d.missing(requested)
+                  d.missing(href)
                 end
               end
             end
@@ -315,7 +318,16 @@ module ProTacts
     # that collection (docs/plans/2026-09-12-per-user-books.md).
     #: () -> Array[Contact]
     def contacts
-      @contacts ||= store.contacts.select { book.include?(it.id) }
+      @contacts ||= store.contacts(book.to_a)
+    end
+
+    # The members of the requester's book among these ids, keyed by id:
+    # a report naming a few cards reads those and not the whole book.
+    #: (Array[String] ids) -> Hash[String, Contact]
+    def members(ids)
+      store.contacts(ids.select { book.include?(it) }).to_h {
+        [it.id, it] #: [String, Contact]
+      }
     end
 
     #: () -> Set[String]