search: match each card's stored key, build only the hits
Every /search/results request built the whole book from the index and
parsed each contact to match it: about 400 ms at 1,000 cards whatever
the query. Each card's values are now folded into a search_keys row as
it is indexed, Admin::Search matches those rows, and only the contacts
it finds are built. A blank query and the dashboard read their ten
newest rows rather than the book. docs/plans/2026-10-17-search-keys.md
has the numbers and why not FTS5.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQvcDxcd8ucY7iVaS6cJB8
diff --git a/db/migrations/014_search_keys.rb b/db/migrations/014_search_keys.rb
new file mode 100644
index 0000000..5783d9c
--- /dev/null
+++ b/db/migrations/014_search_keys.rb
@@ -0,0 +1,38 @@
+# Each card's search key: its values folded the way the search compares
+# them, worked out when the card is written so that a search reads
+# these rows rather than building every contact to read them off
+# (docs/plans/2026-10-17-search-keys.md). Derived from the stored card
+# like card_properties, so Store#rebuild_index makes it again and a
+# card's delete cascades it away.
+#
+# Filled here through Admin::Search.key, the app's own rule, rather
+# than a copy spelled out against VCard the way 012 is: this is index,
+# not data, so what it must agree with is the search reading it today,
+# and a later change to what is keyed re-derives it the same way.
+#
+# `text: true` on every column because this is a STRICT table, which
+# refuses Sequel's plain String.
+require "pro_tacts/admin/search"
+require "pro_tacts/contact"
+require "pro_tacts/vcard"
+
+Sequel.migration do
+ up do
+ create_table(:search_keys, strict: true) do
+ foreign_key :card_id, :cards, type: String, text: true, primary_key: true, null: false, on_delete: :cascade
+ String :text, text: true, null: false
+ String :phones, text: true, null: false
+ String :names, text: true, null: false
+ end
+
+ self[:cards].order(:id).each do |row|
+ id = row.fetch(:id)
+ contact = ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(row.fetch(:vcard)), birthday: nil, inherited: [])
+ self[:search_keys].insert(card_id: id, **ProTacts::Admin::Search.key(contact).to_h)
+ end
+ end
+
+ down do
+ drop_table(:search_keys)
+ end
+end
diff --git a/docs/plans/2026-10-17-search-keys.md b/docs/plans/2026-10-17-search-keys.md
new file mode 100644
index 0000000..6bc5597
--- /dev/null
+++ b/docs/plans/2026-10-17-search-keys.md
@@ -0,0 +1,56 @@
+# Search keys
+
+2026-10-17. Every request the live search makes used to build the
+whole book. It now reads a short folded key for each card, matches
+those keys, and builds only the contacts it found.
+
+## What was slow
+
+Before, each request to `GET /search/results?q=` built every contact
+from the index (`Store#listed_by_id`). It then parsed each one and
+folded its values for `Admin::Search`. That cost grew with the size of
+the book and hardly depended on the query. The book was the 600-card
+seed from `2026-10-14-lists-read-the-index.md`, scaled to 1,000 cards,
+with requests sent in process:
+
+| Query | Before | After |
+|---|---|---|
+| `zzz` (no hits) | 510 ms | 18 ms |
+| `ada` (66 hits) | 380 ms | 48 ms |
+| `ad` (134 hits) | 420 ms | 67 ms |
+| `a` (every card) | 457 ms | 364 ms |
+| blank (10 recent) | 168 ms | 17 ms |
+
+The browser was never the cause, because the results always came from
+the server (`2026-09-30-live-search.md`). A query that matches every
+card still builds and renders every card. That is what an uncapped
+list costs, and a cap would be a separate decision.
+
+## Shape
+
+- `search_keys` holds one row per card: `text` (each text field
+ folded, one per line), `phones` (each number's digits) and `names`
+ (the name and nickname, for ranking). `Store#reindex` writes the row
+ next to the card's other index rows, and `#rebuild_index` derives it
+ again. Migration 014 fills it for the cards that are already stored.
+- `Admin::Search` is still the one place that decides a match. It
+ builds the key (`Search.key`) and reads it. The matching rules have
+ not changed.
+- A group's contribution is keyed at search time from its label and
+ from what it lends (`Group#reading`). It is never copied into its
+ members' keys, because then a rename or a change to a lent line
+ would have to rewrite every member's row. A group lends no name, so
+ a match on a group's label never ranks as a name hit.
+- A blank query and the dashboard read the 10 newest rows. They no
+ longer build the whole book in order to show 10 contacts.
+
+## Why not FTS5
+
+The trigram tokenizer handles substrings and, in the bundled SQLite
+3.53, folds diacritics, so "zoe" finds Zoë. It cannot match a word
+shorter than three characters, though, and every search starts with
+those keystrokes. Phones would still need their own digit column. The
+ranking would stay in Ruby, and group labels would have to be copied
+into every member's row. At family size, walking the stored keys
+already takes milliseconds. If the book ever grows enough that the
+walk becomes slow, these same columns can feed an FTS5 table.
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
index 7ba2455..d4d1273 100644
--- a/lib/pro_tacts/admin/search.rb
+++ b/lib/pro_tacts/admin/search.rb
@@ -11,30 +11,64 @@ module ProTacts
# compares digits alone, since a number is spelled with whatever
# punctuation its writer liked.
#
- # Matching stays substring-generous rather than typo-tolerant or
- # indexed: a family's book is small enough to walk whole, and SQLite
- # FTS5 was weighed and set aside for it (ranger pnrqpyvk).
+ # Matching stays substring-generous rather than typo-tolerant: a
+ # family's book is small enough to walk whole, and SQLite FTS5 was
+ # weighed and set aside for it, twice (ranger pnrqpyvk, and
+ # docs/plans/2026-10-17-search-keys.md). What is walked is each
+ # contact's Key, folded when its card is written, rather than the
+ # contacts themselves.
module Search
# The punctuation a phone number is written with, which a word made
# of nothing else (and at least one digit) is a number spelled.
PHONE_WORD = /\A[\d().+-]*\d[\d().+-]*\z/ #: Regexp
private_constant :PHONE_WORD
- # The contacts the query matches, in the order a search is read:
- # a hit on the name first — every word the start of a word of the
- # name or nickname, "ada" or "love" for Ada Lovelace — then the
- # hits anywhere else, recency keeping its order within each. The
- # rows arrive in recency order, and partition keeps it. The groups
- # arrive whole because a contact is found by the names of the
- # groups it is in.
- #: (Array[Store::RecentContact] rows, query: String, groups: Array[Group]) -> Array[Store::RecentContact]
+ # A contact's values folded for matching, worked out once when its
+ # card is written (Store#reindex) rather than on every search, so
+ # a search reads three short columns and builds only the contacts
+ # it finds. `text` is every text field (#text_fields) folded and
+ # one to a line, `phones` each number's digits, a space between,
+ # and `names` the name and nickname a name hit is read off. A word
+ # is split on whitespace, so it cannot span the separator between
+ # two values: a word found in the joined string is a word found in
+ # one of them.
+ #
+ # A group's values are keyed the same way, at search time, from
+ # what it lends (Group#reading) and its label: a contact is found
+ # by those too, and keying them into each member would leave every
+ # member's key to rewrite on a rename or a change to a lent line.
+ # @rbs skip
+ Key = Data.define(:text, :phones, :names)
+
+ # A contact's key, read off its own card alone: what its groups
+ # lend is matched as the group's (Key).
+ #: (Contact | ListedContact contact) -> Key
+ def self.key(contact)
+ Key.new(
+ text: text_fields(contact).map { Format.search_key(it) }.join("\n"),
+ phones: contact.phones.map { digits(it.value) }.join(" "),
+ names: [contact.name, contact.nickname].compact.map { Format.search_key(it) }.join(" "),
+ )
+ end
+
+ # The ids of the contacts the query matches, in the order a search
+ # is read: a hit on the name first — every word the start of a
+ # word of the name or nickname, "ada" or "love" for Ada Lovelace —
+ # then the hits anywhere else, recency keeping its order within
+ # each. The rows arrive in recency order, and partition keeps it.
+ # The groups arrive whole because a contact is found by the names
+ # of the groups it is in and by what they lend it.
+ #: (Array[Store::KeyedContact] rows, query: String, groups: Array[Group]) -> Array[String]
def self.contacts(rows, query:, groups:)
words = words(query)
- labels = {} #: Hash[String, Array[String]]
- groups.each { |group| group.members.each { (labels[it] ||= []) << group.label } }
- matched = rows.select { |row| matches?(row.contact, words, labels.fetch(row.contact.id, [])) }
- names, rest = matched.partition { name_hit?(it.contact, words) }
- names + rest
+ lent = {} #: Hash[String, Array[Key]]
+ groups.each { |group|
+ key = group_key(group)
+ group.members.each { (lent[it] ||= []) << key }
+ }
+ matched = rows.select { |row| matches?([row.key, *lent.fetch(row.id, [])], words) }
+ names, rest = matched.partition { name_hit?(it.key, words) }
+ (names + rest).map(&:id)
end
# The groups whose names the query matches, every word somewhere
@@ -54,23 +88,31 @@ module ProTacts
end
private_class_method :words
- #: (ListedContact contact, Array[String] words, Array[String] labels) -> bool
- def self.matches?(contact, words, labels)
- texts = text_fields(contact, labels).map { Format.search_key(it) }
- phones = contact.phones.map { digits(it.value) }
+ # What a group adds to each member's key: the lines it lends, read
+ # as a card, and its label. A name it does not lend, so its key's
+ # names stay its own and no member's name hit comes from a group.
+ #: (Group group) -> Key
+ def self.group_key(group)
+ lent = key(group.reading)
+ lent.with(text: [lent.text, Format.search_key(group.label)].join("\n"))
+ end
+ private_class_method :group_key
+
+ #: (Array[Key] keys, Array[String] words) -> bool
+ def self.matches?(keys, words)
words.all? { |word|
- texts.any? { it.include?(word) } ||
- (word.match?(PHONE_WORD) && phones.any? { it.include?(digits(word)) })
+ keys.any? { it.text.include?(word) } ||
+ (word.match?(PHONE_WORD) && keys.any? { it.phones.include?(digits(word)) })
}
end
private_class_method :matches?
# Every value of the contact a word is matched against as text:
- # its names, organization, emails, addresses, notes, and groups.
- # An address is its components run together, so "main st" finds
- # a street.
- #: (ListedContact contact, Array[String] labels) -> Array[String]
- def self.text_fields(contact, labels)
+ # its names, organization, emails, addresses, and notes. An
+ # address is its components run together, so "main st" finds a
+ # street.
+ #: (Contact | ListedContact contact) -> Array[String]
+ def self.text_fields(contact)
[
contact.name,
contact.nickname,
@@ -78,14 +120,13 @@ module ProTacts
*contact.emails.map(&:value),
*contact.addresses.map { [it.extended, it.street, it.locality, it.region, it.postal_code, it.country].compact.join(" ") },
*contact.notes.map(&:value),
- *labels,
].compact
end
private_class_method :text_fields
- #: (ListedContact contact, Array[String] words) -> bool
- def self.name_hit?(contact, words)
- name_words = [contact.name, contact.nickname].compact.flat_map { Format.search_key(it).split }
+ #: (Key key, Array[String] words) -> bool
+ def self.name_hit?(key, words)
+ name_words = key.names.split
words.all? { |word| name_words.any? { it.start_with?(word) } }
end
private_class_method :name_hit?
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 09aab1b..cb79d9d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -4,6 +4,7 @@ require "json"
require "pathname"
require "sequel"
+require "pro_tacts/admin/search"
require "pro_tacts/birthday"
require "pro_tacts/card_diff"
require "pro_tacts/change_id"
@@ -90,6 +91,12 @@ module ProTacts
# @rbs skip
RecentContact = Data.define(:contact, :updated_at)
+ # A contact's id beside the key a search matches it by
+ # (Admin::Search::Key), with no contact built: what a search walks
+ # the whole book as, building only the contacts it finds.
+ # @rbs skip
+ KeyedContact = Data.define(:id, :key)
+
# A contact paired with the calendar day its birthday next lands
# on — the read behind the admin's upcoming-birthdays list, where
# the order is the coming year's, not the stored components'.
@@ -265,15 +272,30 @@ module ProTacts
# Every contact as a list shows it, paired with its card's
# updated_at, newest first — the read behind any "recently updated"
# surface, ordered by the column SQLite already stamps rather than
- # by id.
- #: () -> Array[RecentContact]
- def contacts_by_recency
- listed = listed_by_id(nil)
- cards.order(Sequel.desc(:updated_at)).select(:id, :updated_at).map {
+ # by id. The ids narrow it to the contacts a search found, and the
+ # limit to the newest few, and either way only those are built.
+ #: (?Array[String]? ids, ?limit: Integer?) -> Array[RecentContact]
+ def contacts_by_recency(ids = nil, limit: nil)
+ rows = (ids ? cards.where(id: ids) : cards).order(Sequel.desc(:updated_at)).select(:id, :updated_at)
+ rows = (limit ? rows.limit(limit) : rows).all
+ listed = listed_by_id(limit ? rows.map { it.fetch(:id).to_s } : ids)
+ rows.map {
RecentContact.new(contact: listed.fetch(it.fetch(:id).to_s), updated_at: it.fetch(:updated_at).to_s)
}
end
+ # Every contact's search key, newest first like
+ # #contacts_by_recency, which a search hands the ids it finds back
+ # to (Web#search_results).
+ #: () -> Array[KeyedContact]
+ def keyed_by_recency
+ search_keys.join(:cards, id: :card_id).order(Sequel.desc(Sequel[:cards][:updated_at]))
+ .select(Sequel[:search_keys][:card_id], :text, :phones, :names).map {
+ key = Admin::Search::Key.new(text: it.fetch(:text).to_s, phones: it.fetch(:phones).to_s, names: it.fetch(:names).to_s)
+ KeyedContact.new(id: it.fetch(:card_id).to_s, key:)
+ }
+ end
+
# The collection's content tag: one value that changes when any card
# is added, removed, or changed and never otherwise, so a client
# comparing two of them learns whether to resync — never what
@@ -584,6 +606,7 @@ module ProTacts
# rather than moved — the deal #reindex always gives it. The
# parameters follow their properties away on the cascade.
card_properties.where(card_id: id).delete
+ search_keys.where(card_id: id).delete
after = contact!(new_id)
record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before.vcard, nil))
record(
@@ -911,6 +934,7 @@ module ProTacts
def rebuild_index
@database.transaction do
card_properties.delete
+ search_keys.delete
cards.order(:id).all.each do
reindex(it.fetch(:id).to_s, VCard.new(it.fetch(:vcard).to_s))
end
@@ -934,6 +958,11 @@ module ProTacts
@database[:card_properties]
end
+ #: () -> Sequel::Dataset
+ def search_keys
+ @database[:search_keys]
+ end
+
#: () -> Sequel::Dataset
def card_parameters
@database[:card_parameters]
@@ -1177,12 +1206,14 @@ module ProTacts
# way, and there is no version of this write that fixes a line the
# parser cannot read. Takes the stored card — with the birthday
# already subtracted — so the index never sees a BDAY no stored
- # card carries.
+ # card carries. The card's search key goes with its rows, read off
+ # the stored card alone for the reason Admin::Search::Key gives.
#: (String id, VCard vcard) -> void
def reindex(id, vcard)
# Before the parse, so that a card which has stopped parsing does
# not keep the rows from when it did.
card_properties.where(card_id: id).delete
+ search_keys.where(card_id: id).delete
vcard.properties.each.with_index do |property, position|
card_properties.insert(
@@ -1196,6 +1227,7 @@ module ProTacts
card_parameters.insert(card_id: id, position:, name:, value:)
end
end
+ search_keys.insert(card_id: id, **Admin::Search.key(Contact.new(id:, stored: vcard, birthday: nil, inherited: [])).to_h)
end
# Applies whatever migrations the database has not seen. On one it is
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index d5a6110..a78fd74 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -134,7 +134,7 @@ module ProTacts
def dashboard(notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::Dashboard.call(
- recent: store.contacts_by_recency,
+ recent: store.contacts_by_recency(limit: Admin::Dashboard::RECENT_LIMIT),
upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
login: @login,
notice:,
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 42f8ca4..a27e7b6 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -36,23 +36,33 @@ module ProTacts
# A blank query answers the most recently updated contacts, the
# dashboard's own list, so the search page is never empty before a
- # word is typed.
+ # word is typed. A query walks every contact's search key and builds
+ # only the contacts it finds (docs/plans/2026-10-17-search-keys.md).
#: (String query) -> Admin::SearchResults
def search_results(query)
- recent = span("search.contacts_by_recency") { |step|
- store.contacts_by_recency.tap { step.set_attribute("rows", it.length) }
- }
if query.empty?
- return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
+ recent = span("search.contacts_by_recency") { |step|
+ store.contacts_by_recency(limit: Admin::Dashboard::RECENT_LIMIT).tap { step.set_attribute("rows", it.length) }
+ }
+ return Admin::SearchResults.new(contacts: recent, groups: [], recent: true)
end
+ keyed = span("search.keyed_by_recency") { |step| store.keyed_by_recency.tap { step.set_attribute("rows", it.length) } }
groups = span("search.all_groups") { |step| store.all_groups.tap { step.set_attribute("rows", it.length) } }
- span("search.match") { |step|
- contacts = Admin::Search.contacts(recent, query:, groups:)
- step.set_attribute("words", query.split.length)
- step.set_attribute("contacts", contacts.length)
- Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
+ ids = span("search.match") { |step|
+ Admin::Search.contacts(keyed, query:, groups:).tap {
+ step.set_attribute("words", query.split.length)
+ step.set_attribute("contacts", it.length)
+ }
+ }
+ contacts = span("search.contacts_by_recency") { |step|
+ found = store.contacts_by_recency(ids).to_h {
+ [it.contact.id, it] #: [String, Store::RecentContact]
+ }
+ step.set_attribute("rows", found.length)
+ ids.map { found.fetch(it) }
}
+ Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
end
end
end
diff --git a/sig/pro_tacts/admin/search.rbs b/sig/pro_tacts/admin/search.rbs
new file mode 100644
index 0000000..e8dd9f9
--- /dev/null
+++ b/sig/pro_tacts/admin/search.rbs
@@ -0,0 +1,18 @@
+# Admin::Search::Key is a Data class, whose members the inline parser
+# cannot read (see sig/pro_tacts/contact.rbs). Search itself is a plain
+# module and carries its signatures inline.
+module ProTacts
+ module Admin
+ module Search
+ class Key
+ attr_reader text: String
+ attr_reader phones: String
+ attr_reader names: String
+
+ def self.new: (text: String, phones: String, names: String) -> instance
+ def with: (?text: String, ?phones: String, ?names: String) -> instance
+ def to_h: () -> { text: String, phones: String, names: String }
+ end
+ end
+ end
+end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 1b23018..f501402 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -1,7 +1,7 @@
-# Change, GroupChange, RecentContact, UpcomingBirthday, GroupChoice, and
-# Snapshot are Data classes, whose members the inline parser cannot read
-# (see sig/pro_tacts/contact.rbs). Store itself is an ordinary class and
-# its types are inline in store.rb.
+# Change, GroupChange, RecentContact, KeyedContact, UpcomingBirthday,
+# GroupChoice, and Snapshot are Data classes, whose members the inline
+# parser cannot read (see sig/pro_tacts/contact.rbs). Store itself is an
+# ordinary class and its types are inline in store.rb.
module ProTacts
class Store
class Change
@@ -32,6 +32,13 @@ module ProTacts
def self.new: (contact: ListedContact, updated_at: String) -> instance
end
+ class KeyedContact
+ attr_reader id: String
+ attr_reader key: Admin::Search::Key
+
+ def self.new: (id: String, key: Admin::Search::Key) -> instance
+ end
+
class UpcomingBirthday
attr_reader contact: ListedContact
attr_reader occurs_on: Date
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 27d42e1..b3726f5 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -403,7 +403,7 @@ class AdminContactsPagesTest < Minitest::Test
assert_equal 1, transactions.length
transaction = transactions.first
spans = transaction.spans.select { it[:op].start_with?("search.") }
- assert_equal %w[search.contacts_by_recency search.all_groups search.match search.render], spans.map { it[:op] }
+ assert_equal %w[search.keyed_by_recency search.all_groups search.match search.contacts_by_recency search.render], spans.map { it[:op] }
assert_equal({"otel.kind" => :internal, "words" => 1, "contacts" => 1}, spans.find { it[:op] == "search.match" }[:data])
refute_includes JSON.generate(transaction.to_h), "lovelace"
end
diff --git a/test/pro_tacts/admin/test_search.rb b/test/pro_tacts/admin/test_search.rb
index 5eab97d..ed75e70 100644
--- a/test/pro_tacts/admin/test_search.rb
+++ b/test/pro_tacts/admin/test_search.rb
@@ -7,17 +7,17 @@ require "pro_tacts/store"
require "pro_tacts/vcard"
class SearchTest < Minitest::Test
- # A card from its extra lines, and its row in recency order: the
- # rows arrive most recent first, the way Store#contacts_by_recency
- # hands them over.
+ # A card from its extra lines, keyed as the store keys it: the rows
+ # arrive most recent first, the way Store#keyed_by_recency hands them
+ # over.
def row(id, name, *lines)
bytes = ["BEGIN:VCARD", "VERSION:3.0", "FN:#{name}", "UID:#{id}", *lines, "END:VCARD", ""].join("\r\n")
contact = ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday: nil, inherited: [])
- ProTacts::Store::RecentContact.new(contact:, updated_at: Time.now)
+ ProTacts::Store::KeyedContact.new(id:, key: ProTacts::Admin::Search.key(contact))
end
def search(rows, query, groups: [])
- ProTacts::Admin::Search.contacts(rows, query:, groups:).map { it.contact.id }
+ ProTacts::Admin::Search.contacts(rows, query:, groups:)
end
# Each word finds its own field: neither "ada" nor "london" is
@@ -62,6 +62,29 @@ class SearchTest < Minitest::Test
assert_equal ["ada"], search([ada], "mathematicians", groups: [math])
end
+ # What a group lends is matched as the group's, not keyed into the
+ # member: a lent address and a lent note find every member, and
+ # nobody outside the group.
+ def test_what_a_group_lends_finds_its_members
+ ada = row("ada", "Ada Lovelace")
+ grace = row("grace", "Grace Hopper")
+ home = ProTacts::Group.new(id: "home", name: "Home", label: "Home",
+ lines: ["ADR:;;12 St James Sq;London;;;UK", "NOTE:Gate code 7946"], members: ["ada"])
+
+ assert_equal ["ada"], search([ada, grace], "james sq", groups: [home])
+ assert_equal ["ada"], search([ada, grace], "gate code", groups: [home])
+ end
+
+ # A group lends no name, so its label finds a member without making
+ # the find a name hit: Grace's own name still leads.
+ def test_a_group_label_is_not_a_name_hit
+ ada = row("ada", "Ada Lovelace")
+ grace = row("grace", "Grace Hopper")
+ graces = ProTacts::Group.new(id: "graces", name: "Graces", label: "Graces", lines: [], members: ["ada"])
+
+ assert_equal ["grace", "ada"], search([ada, grace], "grace", groups: [graces])
+ end
+
# Charles is the more recent, and only his note says "ada"; Ada's
# name does, so she leads anyway. Within a tier, recency stands:
# the two notes keep the order they arrived in.
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index c39d604..60c8627 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -339,6 +339,18 @@ class StoreTest < Minitest::Test
end
end
+ # The newest few, and the ones a search found, each still newest
+ # first: the two narrowings the search and the dashboard read.
+ def test_contacts_by_recency_narrows_to_a_limit_or_to_ids
+ with_store({"aiden" => AIDEN}) do |store|
+ sleep 0.002
+ store.put("znorth", vcard(ZED))
+
+ assert_equal %w[znorth], store.contacts_by_recency(limit: 1).map { it.contact.id }
+ assert_equal %w[aiden], store.contacts_by_recency(["aiden"]).map { it.contact.id }
+ end
+ end
+
def test_contacts_by_recency_carries_the_stamp
with_store({"aiden" => AIDEN}) do |store|
recent = store.contacts_by_recency.fetch(0)
@@ -524,6 +536,7 @@ class StoreTest < Minitest::Test
assert_equal [new_id], store.group(group).members
assert_equal new_id, store.card_id_with_uid(new_id)
assert database(store)[:card_properties].where(card_id: UUID).empty?
+ assert_equal [new_id], store.keyed_by_recency.map(&:id)
end
end
@@ -692,6 +705,31 @@ class StoreTest < Minitest::Test
end
end
+ # A card's own values, folded, and nothing its groups lend: a lent
+ # address is matched as the group's (Admin::Search::Key).
+ def test_a_card_is_keyed_for_the_search_off_its_own_lines
+ card = AIDEN.sub("FN:Aiden\r\n", "FN:Áiden\r\nNICKNAME:Ade\r\nTEL:+1 (555) 123-4567\r\nNOTE:Plays chess\r\n")
+ with_store({"aiden" => card}) do |store|
+ group = store.create_group(name: "Booles")
+ store.add_member(group, "aiden")
+ store.set_group_lines(group, [HOUSEHOLD_ADDRESS])
+
+ key = store.keyed_by_recency.fetch(0).key
+ assert_equal "aiden\nade\nplays chess", key.text
+ assert_equal "15551234567", key.phones
+ assert_equal "aiden ade", key.names
+ end
+ end
+
+ def test_keys_are_listed_newest_first
+ with_store({"aiden" => AIDEN}) do |store|
+ sleep 0.002
+ store.put("znorth", vcard(ZED))
+
+ assert_equal %w[znorth aiden], store.keyed_by_recency.map(&:id)
+ end
+ end
+
def test_replacing_a_card_replaces_its_index_rows
with_store({"aiden" => AIDEN}) do |store|
store.put("aiden", vcard(AIDEN.sub("FN:Aiden\r\n", "")))
@@ -2457,6 +2495,23 @@ class StoreTest < Minitest::Test
# serves the group's joined note as one section, the shape every
# write composes from here
# (docs/plans/2026-09-25-one-note-per-contact.md).
+ # The cards already stored are keyed as the table lands, so a search
+ # finds them before any is written again.
+ def test_the_migration_keys_the_cards_already_stored
+ 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: 13)
+ db[:cards].insert(id: "aiden", vcard: AIDEN)
+ db[:cards].insert(id: "znorth", vcard: ZED)
+ end
+
+ ProTacts::Store.connect(path) do |store|
+ assert_equal({"aiden" => "aiden", "znorth" => "zed"}, store.keyed_by_recency.to_h { [it.id, it.key.names] })
+ end
+ end
+ end
+
def test_the_migration_joins_several_notes_into_one
two_notes = AIDEN.sub("END:VCARD\r\n", "NOTE:First.\r\nNOTE:Second.\r\nEND:VCARD\r\n")
Dir.mktmpdir do |dir|
@@ -2649,6 +2704,7 @@ class StoreTest < Minitest::Test
[
database(store)[:card_properties].order(:card_id, :position).all,
database(store)[:card_parameters].order(:card_id, :position, :name, :value).all,
+ database(store)[:search_keys].order(:card_id).all,
]
end
@@ -2662,5 +2718,6 @@ class StoreTest < Minitest::Test
def wreck_the_index(store)
database(store)[:card_parameters].delete
database(store)[:card_properties].update(value: "wrong")
+ database(store)[:search_keys].delete
end
end