Search: The Feature That Is Almost Entirely Convention
A capstone that traces Campfire's full-text search end-to-end — indexed, ranked, authorized, paginated — and shows that almost none of the code is "about search." Four principles (concerns, derive-don't-store, security-as-shape, altitude) collaborate so a production search feature falls out of a 25-line concern, one composed scope, and a partial you already wrote.
A production full-text search — indexed, stemmed, authorized, paginated — emerges from a 25-line concern collaborating with the access-shape and the renderer, with almost no code that is about search.
Cold open: the version you'd naturally vibe-code first
A user types "deploy" into the search box and wants every message that mentions it, newest last, but only from rooms they're actually in. You're in Rails. Search is WHERE, right? So you write the line you'd vibe-code before you'd thought about it for more than four seconds:
class SearchesController < ApplicationController
def index
@query = params[:q]
@messages = Message.where("body LIKE ?", "%#{@query}%").order(:created_at)
end
end
It's one line. It feels honest. And it is wrong in three independent ways, two of which won't even show up as errors:
Message.where("body LIKE ?", "%deploy%")
# 1. FULL TABLE SCAN — no index can help a leading-wildcard LIKE.
# Fine at 200 messages. A cliff at 2,000,000.
# 2. NO STEMMING — "deploy" won't match "deploys" or "deploying";
# LIKE matches substrings, not word stems, so you get the
# wrong recall both ways.
# 3. IT NEVER MATCHES ANYTHING. ← the one that ruins your afternoon
That third one is the gut-punch. Open message.rb and look at how the body is stored (message.rb:9):
has_rich_text :body
has_rich_text doesn't put the text in a messages.body column. It stores it in a separate action_text_rich_texts table, wrapped in markup. There is no body column on messages to LIKE against. Your query runs without error, scans the whole table, and returns nothing — forever — because it's matching a column that doesn't hold the text. You'd burn an hour adding LOWER() and trigram extensions and reordering before you discovered the text you're searching isn't where you think it is.
THE NAIVE "SEARCH" (the version you'd vibe-code first)
"deploy"
│
▼
Message.where("body LIKE ?", "%deploy%")
│
├── scans every row in `messages` ◄── O(n), no index
├── no stemming (won't match "deploys") ◄── substring, not word stem
├── matches a column that doesn't hold text ◄── body lives in
│ action_text_rich_texts
└── (and even if it worked) returns rooms
this user was never in ◄── no auth in the query
So search is not a fancier WHERE.
Now watch Campfire do the whole thing with one 25-line concern, one composed scope, and the partial you already wrote. The feature is almost entirely convention: a self-syncing index keeps itself current, the access-shape narrows the results without a single if, and the renderer is the same one chat already uses. Let's trace one query across the stack, stopping at each verified seam.
Step 1 — The Index: a concern that mirrors the model into a search engine
The naive LIKE is doomed because it searches the live messages rows. The fix isn't a cleverer query — it's a second representation of the data, built for searching, kept in lockstep with the first. That second representation is a SQLite FTS5 virtual table, declared once in the schema (schema.rb:182):
create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"]
That's a real full-text index: tokenized and stemmed (tokenize=porter collapses deploy / deploys / deploying to one stem). It is not a Rails model and has no messages foreign key — it's a parallel structure addressed by rowid. The whole job now is: keep this index in sync with the messages table, automatically, forever. And that is exactly the kind of behavior that gets a home of its own.
Open message.rb:1-2 and notice the shape before any logic:
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
That include line is the spec — the table of contents / the include line IS the spec — a message is attachable, broadcastable, mentionable, paginatable, searchable. We don't re-derive why the model reads as a table of contents here; that's see P8: Give Behavior a Home's job. We just follow the last word — Searchable — into its own file. The entire search feature lives in 28 lines (searchable.rb:1-28):
module Message::Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_in_index
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
end
private
def create_in_index
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
end
def update_in_index
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
end
def remove_from_index
execute_sql_with_binds "delete from message_search_index where rowid = ?", id
end
def execute_sql_with_binds(*statement)
self.class.connection.execute self.class.sanitize_sql(statement)
end
end
Read the included do block (searchable.rb:4-10) as a sentence: when a message commits, insert it into the index; when it updates, update the index; when it's destroyed, remove it. Three callbacks, perfectly symmetric — every way a message's text can change has a matching way the index changes. There is no fourth state to forget.
This isn't a Campfire quirk: Fizzy's shared Searchable concern installs the byte-for-byte same trio — after_create_commit :create_in_search_index, after_update_commit :update_in_search_index, after_destroy_commit :remove_from_search_index (concerns/searchable.rb:7-9) — so the self-syncing _commit index is the 37signals way, reached identically in two unrelated products.
"Three raw-SQL
connection.executecalls? That feels un-Rails — shouldn't there be a model for the index?" No — and this is the tell that 37signals reached for the right altitude. A virtual FTS5 table isn't a domain noun youcreate!and validate; it's a derived projection. Wrapping it in an Active Record model would buy you nothing and cost you a phantom entity. The raw SQL is funneled through oneexecute_sql_with_bindshelper (searchable.rb:25-27) that runs everything throughsanitize_sql, so the values are bound, not interpolated — no injection surface. The naive instinct ("everything is a model") would manufacture aSearchIndexEntryclass with its own callbacks that then need to not drift from the messages they mirror. Here the message owns its own index rows directly, so they can't desync.
Now the load-bearing token. Look again at line 5: it's after_create_commit, not after_create. The suffix means _commit means after-durable — the callback fires only after the database transaction is durable. We don't unpack the transaction lifecycle here; that's F1: the callback lifecycle and P9: Put Work at Its Right Altitude. We only need the consequence: if it were a plain after_create (which fires inside the transaction) and the transaction later rolled back, the index would hold a row pointing at a message id that no longer exists — a search hit that 500s the moment a user clicks it, a phantom in the index. The single token _commit designs that bug out of existence.
The contrast: The naive version has no index, so it has no sync problem — and no working search. The moment you reach for a real index by hand, you reach for after_create (it feels symmetric with everything else), and now a rolled-back message leaves a ghost in your search results. You then bolt on a nightly reconciliation job to sweep orphaned index rows — a whole cron you only need because you used the wrong callback. Campfire writes three symmetric _commit callbacks and never sweeps anything.
Step 2 — The Text: deriving what's searchable instead of storing it
The index callbacks all index plain_text_body, not body. That method is the bridge between "rich text wrapped in markup, stored in another table" and "plain words a search engine can tokenize" (message.rb:23-25):
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
This is derive, don't store doing quiet, load-bearing work. There is no searchable_text column maintained alongside the body. The searchable representation is computed from the body the user already wrote — to_plain_text strips the ActionText markup down to words, so you index "deploy the new build," never <div>deploy the new build</div>. A stored copy would be a second source of truth that drifts the day someone edits a message and you forget to recompute it; deriving it at index-write time means it's correct by construction.
Fizzy reaches for the identical seam: its searchable content is description.to_plain_text for a Card and body.to_plain_text for a Comment (card/searchable.rb:18, comment/searchable.rb:13), computed at index time rather than stored — the same derive-the-tokenizable-text-from-the-rich-text move, in a product that isn't a chat app.
Then the chain of fallbacks earns the whole line. Read it right to left:
body.to_plain_text.presence— the words, if there are any.|| attachment&.filename&.to_s— if the message is just an image, index its filename. So a user who uploadedq3-deploy-plan.pngwith no caption can still find it by typing "deploy." That's a product decision (image-only messages are findable) expressed as one||.|| ""— never indexnil, so the FTS insert can't choke on a null.
"Why not just add a
searchable_textcolumn and fill it with a callback?" Because you'd own its freshness forever. Every create, every edit, every backfill migration has to remember to recompute it, and the day one path forgets, search silently returns stale or missing results with no error. The derivedplain_text_bodyis recomputed at the exact moment the index is written (it's literally the argument to theINSERT/UPDATE), so "the indexed text" and "the message's current text" are the same expression evaluated at the same instant. Derivation here doesn't just save a column — it deletes a class of staleness bug. The deeper unpack of this instinct lives in P2: Derive, Don't Store; here it's the seam between rich text and a tokenizer.
The contrast: The naive LIKE "%deploy%" matched messages.body — a column that doesn't exist — so it found nothing and it could never have found the image uploaded as q3-deploy-plan.png, because a filename isn't in the message text at all. plain_text_body makes both the words and the filename searchable through one derived method, and the index only ever stores plain, current, non-null text.
Step 3 — The Query: authorization composed onto the search, not bolted after it
Now the search itself. The scope from Step 1 (searchable.rb:9) is, deliberately, an ordinary chainable Relation:
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
It joins the FTS5 index on rowid, uses FTS5's match operator (which is what gives you tokenized, stemmed matching for free — none of LIKE's problems), and ends in .ordered. That .ordered is Message's own scope, order(:created_at) (message.rb:14) — so the results come back in chronological order, not by FTS relevance score. Campfire deliberately doesn't sort by rank; it shows matching messages oldest-to-newest, the way they'd read in a room. (FTS5 can rank by relevance; this scope just chooses not to.) Crucially, search returns a Relation, not an array. That single fact is what lets the controller compose the entire feature in one line (searches_controller.rb:21-23):
def set_messages
if query.present?
@messages = Current.user.reachable_messages.search(query).last(100)
Read that left to right and watch four concerns stack onto one expression:
Current.user ── who is asking
.reachable_messages ── ONLY messages in rooms they belong to ← authorization
.search(query) ── full-text match + chronological order ← search
.last(100) ── cap the result set ← pagination
reachable_messages is the hinge. It's a single association on the user (user.rb:7):
has_many :reachable_messages, through: :rooms, source: :messages
This is authorization-by-association, and it's why there is not one if about permissions anywhere in the search code. We don't re-derive the worldview — that's P3: Security Is the Shape of Your Data Access. The point for this feature: because the search scope chains onto Current.user.reachable_messages instead of a global Message, the IDOR you cannot type is in force. A message in a room the user was never in simply doesn't exist from their vantage point — it's filtered by the JOIN through rooms before the FTS match ever runs. There is no "search results from rooms you can't see" bug, because there is no global Message.search for the controller to reach for. The leaking version is one you literally cannot write here.
Fizzy proves this is doctrine, not a Campfire accident: its search controller teleports to a card by number with Current.user.accessible_cards.find_by_id(@query) (searches_controller.rb:7), where accessible_cards is the same authorization-by-association scope the rest of the app loads through — so the by-id jump is authorized for free, by composing onto the access-shape exactly as reachable_messages.search does.
This is the seam where three principles meet on a single line: search stays a composable Relation (P6: Model Every State Change as CRUD on a Noun's sibling instinct — keep things chainable), so reachable_messages (P3) and .last(100) (pagination) compose onto it with zero glue. The naive version, by contrast, would Message.search(query) and then remember to add .where(room_id: current_user.room_ids) — and the day a new search feature copies only the first half, every private room leaks into search.
"Isn't
.last(100)loading everything and throwing most of it away?" No — becausesearchis a Relation with anORDER BY created_atalready on it,.last(100)becomes part of the SQL (it reverses the order and applies aLIMIT), not an in-memory slice. This is the payoff of the scope refusing to return an array: ordering and capping push down into the database. Hadsearchreturned anArrayof results (the easy version, where you'd.mapmatches into objects),.last(100)would mean "fetch all of them, then keep 100 in Ruby." The chainable Relation is what keeps the cap cheap.
There's one more quiet guard, in the controller's query method (searches_controller.rb:29-31):
def query
params[:q]&.gsub(/[^[:word:]]/, " ")
end
It scrubs everything that isn't a word character into a space before the string reaches FTS5's match. FTS5 has its own query syntax (NEAR, *, column filters, quotes); a raw user string containing those operators would either error or do something surprising. Reducing the input to bare words means the user gets plain word-search semantics and can't accidentally (or deliberately) drive the FTS query parser. One gsub, and the match operator only ever sees words.
The contrast: The naive controller does Message.where("body LIKE ?", "%#{params[:q]}%") — global scope (every room leaks), raw interpolation (a % or _ in the query becomes a wildcard; a quote is an injection probe), no cap, no stemming. Campfire's one line scopes through the user, runs a real stemmed FTS match, caps in SQL, and sanitizes the input to words — and every one of those is a composition, not a branch.
Step 4 — The Render: the partial you already wrote
A search result is a message. So how does Campfire render the list of hits? Open the search view (searches/index.html.erb:56-58):
<%= search_results_tag do %>
<%= render @messages %>
<% end %>
render @messages. That's it. No search_result partial, no custom result row, no "matched snippet" template. render @messages resolves, by convention, to the same messages/_message partial that paints the live chat timeline, the initial page load, and the WebSocket append. We don't re-derive one partial, every path — that's P5: One Renderer: HTML Over the Wire and F3: Views, Partials & Helpers. The capstone point is that search inherited it for free: a message found by search looks byte-identical to a message in its room, with the same avatar, timestamp, boosts, and mention rendering, because there is exactly one renderer and search just pointed at it.
This is dom_id is the address paying off across features. Each rendered hit gets id="message_<client_message_id>" — message_tag builds the wrapper with dom_id(message) (messages_helper.rb:28), and because Message#to_key returns [client_message_id] (message.rb:27-29), that's the id the chat timeline uses too. So the existing search-results Stimulus controller (background; the reader doesn't need the JS) can decorate hits — highlight your own messages, mark threaded ones — using the identical DOM identity the rest of the app speaks. The view author wrote zero lines about how a search result should look. They wrote render @messages and the convention did the rest.
The contrast: The naive version, having finally gotten matches back, now hand-writes a <div> for each result — re-deriving the avatar, the timestamp formatting, the boost badges — a second rendering of a message that drifts from the canonical one by the next feature (chat shows a new boost style; search forgets it). Campfire renders search hits through the one partial that already exists, so a message looks like itself everywhere it appears.
Step 5 — Recent searches: the only thing that is a noun
Here's the tell that confirms the whole design. The one part of search that genuinely persists state — the "recent searches" list — is the one part modeled as a real table, because it's the one part that's a genuine domain noun. Everything else (the index, the query) was derived or composed; recent-searches is CRUD on a Search (search.rb:1-18):
class Search < ApplicationRecord
belongs_to :user
after_create :trim_recent_searches
scope :ordered, -> { order(updated_at: :desc) }
class << self
def record(query)
find_or_create_by(query: query).touch
end
end
private
def trim_recent_searches
user.searches.excluding(user.searches.ordered.limit(10)).destroy_all
end
end
Performing a search is create; the controller is two lines (searches_controller.rb:10-13):
def create
Current.user.searches.record(query)
redirect_to searches_url(q: query)
end
This is verb-as-noun — "searching" is the creation of a Search — and we don't re-derive it (P6: Model Every State Change as CRUD on a Noun owns it). Two details earn their place. First, record uses find_or_create_by(query:).touch so searching the same term twice doesn't pile up duplicates; it bumps updated_at so it floats to the top of ordered — recency for free. Second, trim_recent_searches fires after_create and keeps only the ten most recent via excluding(... ordered.limit(10)).destroy_all — the list trims itself, on the model, with no cron and no controller bookkeeping. The fact that this — and only this — is a table tells you everything: search results aren't stored because they're derived; the index isn't a model because it's a projection; the search query isn't persisted because it's transient. The single durable noun gets the single table.
The whole journey, on one screen
USER types "deploy", hits Enter
│
▼
SearchesController#create ── Current.user.searches.record("deploy") (Step 5: verb-as-noun)
│ └─ find_or_create_by + touch, self-trims to 10
▼ redirect to ?q=deploy
SearchesController#index → set_messages
│
│ query = params[:q].gsub(/[^[:word:]]/," ") ← scrub to words (Step 3)
▼
Current.user.reachable_messages.search("deploy").last(100)
│ │ │ │
│ │ │ └─ ORDER BY created_at + LIMIT in SQL
│ │ │ (chainable Relation)
│ │ └─ JOIN message_search_index ON rowid
│ │ WHERE body MATCH ? (stemmed) (Step 1)
│ └─ ONLY rooms the user is in ← authorization-by-association (Step 3)
└─ the asking user
│
▼
render @messages ──► messages/_message (the ONE renderer, shared with chat) (Step 4)
── meanwhile, kept current with zero effort ──
message created/edited/destroyed
│ after_create_commit / after_update_commit / after_destroy_commit (Step 1)
▼ (_commit = after durable → no ghost rows in the index)
INSERT/UPDATE/DELETE message_search_index(rowid, plain_text_body)
│
plain_text_body = body.to_plain_text
|| attachment.filename (Step 2: derive)
Count the code Campfire wrote that is genuinely about search: a 25-line concern (three callbacks + one scope), a plain_text_body method shared with push notifications, one create_virtual_table line, and a two-line controller. Now count what it didn't write: no search service, no sync worker, no reconciliation cron, no permission filter, no result template, no duplicate-text column.
That's the aha, stated plainly: a production full-text search — indexed, stemmed, authorized, paginated, self-syncing, with recent-searches and image-by-filename matching — is almost entirely the collaboration of conventions that already existed for other reasons. The concern gives the index a home and keeps it synced via _commit; derivation turns rich text into searchable words without a stored copy; authorization-by-association narrows the results by being the shape of the query rather than a filter after it; and one renderer paints the hits. Search isn't a subsystem you build. It's what falls out when each layer trusts the convention at its boundary — and you can feel the throughline here directly: count the edge cases this line absorbs for free (the rolled-back row, the markup in the body, the cross-room leak, the divergent result template), and almost every one is absorbed by a line that wasn't written for search at all.
A Fizzy coda: the same search on two databases, with no if adapter
Campfire's search rests on one fact it never has to question: the database is SQLite, so the matcher is FTS5. Fizzy can't make that assumption — the open-source build runs on SQLite, the hosted build runs on MySQL, and the same search feature has to speak both. The naive Rails way to handle two databases is the branch you'd reach for without thinking: a case connection.adapter_name (or worse, an if Rails.env...) sprinkled wherever a query is built, each arm hand-writing the dialect-specific SQL. That conditional metastasizes — every new query is a new place to remember the fork, and the day one method forgets the MySQL arm, search silently breaks on exactly one of your two products.
Fizzy deletes the conditional by letting the adapter name itself pick the strategy. The entire dispatch is one line at the top of the search-index model (search/record.rb:1-2):
class Search::Record < ApplicationRecord
include const_get(connection.adapter_name)
Read it as a sentence: include the module named after whatever database I'm connected to. On SQLite, connection.adapter_name returns "SQLite", so const_get resolves Search::Record::SQLite and mixes it in; on MySQL it returns "Trilogy" (Rails' MySQL adapter), so it mixes in Search::Record::Trilogy. Both modules define the same scope :matching — that's the contract — but each implements it in its own dialect. The SQLite one drives an FTS5 virtual table, the exact mechanism Campfire uses (search/record/sqlite.rb:13-16):
scope :matching, ->(query, account_id) {
joins("INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id")
.where("search_records_fts MATCH ?", query)
}
The MySQL one issues a boolean full-text MATCH ... AGAINST instead (search/record/trilogy.rb:10-13):
scope :matching, ->(query, account_id) do
full_query = "+account#{account_id} +(#{Search::Stemmer.stem(query)})"
where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query)
end
Now look at the caller. Search::Record.search (search/record.rb:35-42) builds the relation through matching(...) and never asks which database it's on — it can't, because the branch was resolved once, at class-load time, by which module got mixed in. There is no if adapter == anywhere in the query code, and there's no place to add one: the convention (include const_get(connection.adapter_name)) erased the conditional before it could spread. This is the same instinct C2 has been tracing — convention absorbing an edge case — turned outward across an entire database engine. Campfire absorbs the rolled-back row with _commit; Fizzy absorbs the whole SQL dialect with a one-line const_get.
Two more shapes fall out of the same model, and both read straight off search/record.rb. First, the index spans several models at once: Search::Record is belongs_to :searchable, polymorphic: true (search/record.rb:4), so one index table holds rows for cards and comments — Campfire indexed only messages, but the polymorphic searchable lets a single search query return hits across unrelated model types, ranked together. Second, the by-id teleport: before running any full-text match, the controller tries Current.user.accessible_cards.find_by_id(@query) (searches_controller.rb:7), so typing a bare card number jumps you straight to that card instead of fuzzy-matching it — and because it composes onto accessible_cards (the same authorization-by-association scope from Step 3), the jump is authorized for free. The adapter-name dispatch, the multi-model index, and the by-id jump are three different conventions, and not one of them is an if.
Key Takeaways — Patterns to Steal
- When you add search, your first instinct is
Message.where("body LIKE ?", "%#{q}%")against the column you think holds the text — buthas_rich_text :bodystores the words in a separateaction_text_rich_textstable, so that query full-scans the wrong column, never stems, and matches nothing forever. The fix is a second representation built for searching: project the text into a real FTS5 index and query that. Campfire declares it once withcreate_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"](schema.rb:182), andtokenize=porteris what collapses deploy / deploys / deploying to one stem. - An index that mirrors a table has to be kept in lockstep forever, and the tempting place to put that wiring is loose callbacks scattered on the model — but that buries the whole feature in
Messageand makes it impossible to read in one place. Give the behavior a home in a concern instead, so the model'sincludeline names it and one file holds it all. Campfire's entire search feature is one name on theincludeline —Searchable(message.rb:2) — pointing at a 28-line module (message/searchable.rb:1-28) whoseincluded doblock reaches back and installs the callbacks, soMessagenever has to know an index exists. - To keep the index current you'll reach for
after_create/after_update/after_destroybecause they feel symmetric with everything else — but the plain versions fire inside the transaction, so a rolled-back message leaves a ghost row pointing at an id that no longer exists, a search hit that 500s on click, and you end up bolting on a nightly cron to sweep orphans. Use the_commitvariants so the index only changes once the row is durable. Campfire writes three symmetric callbacks —after_create_commit,after_update_commit,after_destroy_commit(message/searchable.rb:5-7) — one per way the text can change, no fourth state to forget, and never sweeps anything. - The obvious way to have searchable plain text is a
searchable_textcolumn filled by a callback — but then you own its freshness on every create, edit, and backfill, and the day one path forgets to recompute it, search silently returns stale results with no error. Derive the text at the moment you write the index instead, so "indexed text" and "current text" are the same expression evaluated at the same instant. Campfire'splain_text_bodymethod returnsbody.to_plain_text.presence || attachment&.filename&.to_s || ""(message.rb:23-25) — it strips the markup, falls back to a filename so an image uploaded asq3-deploy-plan.pngis still findable by typing "deploy," and never yieldsnilto choke the FTS insert. - The reflex is
Message.search(query)followed by remembering to tack on.where(room_id: current_user.room_ids)— and the day a new feature copies only the first half, every private room leaks into search. Compose authorization onto the query by making it the shape of the data you start from, not a filter you add afterward. Campfire searchesCurrent.user.reachable_messages.search(query).last(100)(searches_controller.rb:23), wherereachable_messagesis one association —has_many :reachable_messages, through: :rooms, source: :messages(user.rb:7) — so cross-room hits are filtered by the JOIN beforematchever runs, and there's no globalMessage.searchto leak through and noifabout permissions anywhere. - Having finally gotten matches back, you'd hand-write a result row for each one — re-deriving the avatar, the timestamp, the boost badges — and that second rendering of a message drifts from the canonical one by the next feature. Render the hits through the partial chat already uses, so a search result is byte-identical to a real message. Campfire's search view drops the hits straight into a collection render —
render @messages(searches/index.html.erb:57, inside asearch_results_tagblock) — which resolves by convention tomessages/_message, the same renderer as the live timeline, so search inherits avatars, timestamps, boosts, anddom_ididentity for free and can't visually diverge. - After deriving the index and composing the query, the temptation is to treat everything in search as a model — but the index is a projection and the results are transient, so modeling them buys a phantom entity that has to be kept from drifting. Model only the one part that genuinely persists state and is a real domain noun. The single table is
Search(search.rb), where recording a search isfind_or_create_by(query: query).touchso the same term floats to the top instead of duplicating (search.rb:9), and anafter_create :trim_recent_searches(search.rb:4) keeps just the ten most recent viaexcluding(user.searches.ordered.limit(10)).destroy_all(search.rb:16) — a list that trims itself with no cron, while the index and results stay un-modeled.