From 5cb4316133fd47141527c91cd88b251bebafad9e Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 00:55:24 +0200 Subject: [PATCH 01/10] Index messages by room and creation time Every page of a room's messages (opening the room, scrolling back, the refresh after reconnecting, the bot API) selects WHERE room_id = ? ORDER BY created_at LIMIT 40. With only the room_id index, SQLite reads every message in the room and sorts them in a temporary B-tree to return 40, so these requests grow with the room's history. A composite index on (room_id, created_at) lets SQLite read the 40 rows in order straight from the index. It covers every lookup the room_id index served, so that one goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- ...oom_id_and_created_at_index_to_messages.rb | 6 +++++ db/schema.rb | 4 ++-- test/models/message_test.rb | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb diff --git a/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb b/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb new file mode 100644 index 0000000..58ff43f --- /dev/null +++ b/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb @@ -0,0 +1,6 @@ +class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2] + def change + add_index :messages, %i[ room_id created_at ] + remove_index :messages, :room_id + end +end diff --git a/db/schema.rb b/db/schema.rb index e7f1540..28ee7c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do +ActiveRecord::Schema[8.2].define(version: 2026_10_05_010000) do create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.text "custom_styles" @@ -101,7 +101,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do t.integer "room_id", null: false t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_messages_on_creator_id" - t.index ["room_id"], name: "index_messages_on_room_id" + t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at" end create_table "push_subscriptions", force: :cascade do |t| diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 5ffecea..6733110 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,7 +65,30 @@ class MessageTest < ActiveSupport::TestCase assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id) end + test "pages read a room's messages from an index instead of sorting the whole room" do + room, message = rooms(:watercooler), messages(:sixth) + statements = capture_message_selects do + room.messages.last_page + room.messages.page_before(message) + room.messages.page_after(message) + room.messages.page_created_since(1.day.ago) + end + + assert_equal 4, statements.size + statements.each do |sql| + plan = Message.connection.select_rows("EXPLAIN QUERY PLAN #{sql}").map(&:last).join(" | ") + assert_no_match(/TEMP B-TREE/, plan, sql) + end + end + private + def capture_message_selects(&block) + statements = [] + callback = ->(*, payload) { statements << payload[:sql] if payload[:sql].start_with?(%(SELECT "messages")) } + ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block) + statements + end + def create_new_message_in(room) room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123") end From cf0ba58d8d4e654c2b0faa8883df37099aab1277 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 01:03:39 +0200 Subject: [PATCH 02/10] Check for a second page of messages without counting the room The original room shows its welcome box until it holds more than a page of messages, and paged? answered that with a COUNT(*) over every message in the room on each visit. Asking whether there is a row past the first page answers the same question while reading at most a page of index entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- app/models/message/pagination.rb | 2 +- test/models/message_test.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/models/message/pagination.rb b/app/models/message/pagination.rb index 3be7983..0e7690b 100644 --- a/app/models/message/pagination.rb +++ b/app/models/message/pagination.rb @@ -49,7 +49,7 @@ module Message::Pagination end def paged? - count > PAGE_SIZE + offset(PAGE_SIZE).exists? end end end diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 5ffecea..df3a694 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,6 +65,18 @@ class MessageTest < ActiveSupport::TestCase assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id) end + test "paged? tells whether a room has more than a page of messages without counting them all" do + room = Rooms::Closed.create!(name: "Paging", creator: users(:david)) + Message.insert_all Array.new(Message::Pagination::PAGE_SIZE) { |i| { room_id: room.id, creator_id: users(:david).id, client_message_id: "paging-#{i}" } } + + assert_no_queries_match(/COUNT/i) do + assert_not room.messages.paged? + + Message.insert_all [ { room_id: room.id, creator_id: users(:david).id, client_message_id: "paging-next" } ] + assert room.messages.paged? + end + end + private def create_new_message_in(room) room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123") From 6cdcb459b95de42de9163027d10c8e084ddbe0bf Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 01:08:53 +0200 Subject: [PATCH 03/10] Search for operator words instead of parsing them The search box keeps only word characters, but FTS5 still reads AND, OR, NOT and NEAR in the query as operators, so searching for "AND" or for "salt AND" raised "fts5: syntax error" and answered with a 500. Quoting each word makes FTS5 look for it as text, which is how it already treats the same words in lower case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- app/models/message/searchable.rb | 10 +++++++++- test/controllers/searches_controller_test.rb | 18 ++++++++++++++++++ test/models/message_test.rb | 6 ++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/models/message/searchable.rb b/app/models/message/searchable.rb index 58ea47f..b792cd0 100644 --- a/app/models/message/searchable.rb +++ b/app/models/message/searchable.rb @@ -6,7 +6,15 @@ module Message::Searchable 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 } + scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered } + end + + class_methods do + # Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than + # parsing them as operators, which fails on a query like "AND" or "salt AND". + def match_terms(query) + query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ") + end end private diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 80abfb4..34ff01b 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -20,6 +20,24 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest assert_select ".message", text: /Hello world!/ end + test "operator words are searched for as words" do + rooms(:designers).messages.create! body: "Salt and pepper", client_message_id: "operators", creator: users(:david) + + get searches_url, params: { q: "salt AND" } + + assert_response :success + assert_select ".message", text: /Salt and pepper/ + end + + test "searching for an operator word alone doesn't fail" do + %w[ AND OR NOT ].each do |word| + get searches_url, params: { q: word } + + assert_response :success + assert_select ".message", count: 0 + end + end + test "unreachable messages are not found" do memberships(:david_designers).destroy! diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 5ffecea..e6add9a 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,6 +65,12 @@ class MessageTest < ActiveSupport::TestCase assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id) end + test "search reads quotes and operator words in the query as text" do + message = rooms(:designers).messages.create!(body: "Say hi, NOT bye", client_message_id: "quoted", creator: users(:david)) + + assert_equal [ message ], Message.search(%(say "hi NOT)).to_a + end + private def create_new_message_in(room) room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123") From 74bdbb0764fcf75c77c51925ddc3116803cab21d Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 01:39:36 +0200 Subject: [PATCH 04/10] Keep the room_id index and date the migration in UTC The composite index doesn't serve everything the room_id one did. In SQLite that index is (room_id, rowid), so it also reads a room's messages in id order, and it is the narrower one to count a room with. Keeping both, as memberships already does, leaves those lookups as they were. The migration was named after local time, two hours ahead of the UTC timestamp Rails generates; it now carries a UTC one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- ...1004225000_add_room_id_and_created_at_index_to_messages.rb} | 1 - db/schema.rb | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) rename db/migrate/{20261005010000_add_room_id_and_created_at_index_to_messages.rb => 20261004225000_add_room_id_and_created_at_index_to_messages.rb} (79%) diff --git a/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb b/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb similarity index 79% rename from db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb rename to db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb index 58ff43f..a46b27b 100644 --- a/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb +++ b/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb @@ -1,6 +1,5 @@ class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2] def change add_index :messages, %i[ room_id created_at ] - remove_index :messages, :room_id end end diff --git a/db/schema.rb b/db/schema.rb index 28ee7c4..8a900cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_10_05_010000) do +ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.text "custom_styles" @@ -102,6 +102,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_010000) do t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_messages_on_creator_id" t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at" + t.index ["room_id"], name: "index_messages_on_room_id" end create_table "push_subscriptions", force: :cascade do |t| From c057484dfbff973f70c485f9021c3bf25f08b954 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Sun, 4 Oct 2026 20:06:53 -0400 Subject: [PATCH 05/10] Wire the inline boost link to the soft keyboard link_to passed `action: "soft-keyboard#open"` as a plain option, so it rendered as an `action` attribute, which Stimulus never reads. The link in messages/_actions.html.erb already passes it under `data:`; this one now does too, so tapping "Add a boost" on a touch device opens the keyboard for the boost form as the menu's link does. Co-Authored-By: Claude Opus 5.5 --- app/views/messages/boosts/_boosts.html.erb | 2 +- test/controllers/messages/boosts_controller_test.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/views/messages/boosts/_boosts.html.erb b/app/views/messages/boosts/_boosts.html.erb index 4d0d06f..d1a340a 100644 --- a/app/views/messages/boosts/_boosts.html.erb +++ b/app/views/messages/boosts/_boosts.html.erb @@ -7,7 +7,7 @@ <%= turbo_frame_tag message, :new_boost do %>
- <%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %> + <%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %> <%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %> Add a boost <% end %> diff --git a/test/controllers/messages/boosts_controller_test.rb b/test/controllers/messages/boosts_controller_test.rb index c04cd55..8cc7de9 100644 --- a/test/controllers/messages/boosts_controller_test.rb +++ b/test/controllers/messages/boosts_controller_test.rb @@ -6,6 +6,13 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest @message = messages(:first) end + test "index wires the new boost link to the soft keyboard" do + get message_boosts_url(@message) + + assert_response :success + assert_select ".message__boost-inline a.boost__action[data-action='soft-keyboard#open']" + end + test "create" do assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do assert_difference -> { @message.boosts.count }, 1 do From b8be941b9921c3e77e5fd003e248e63b909d0a41 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Sun, 4 Oct 2026 20:06:53 -0400 Subject: [PATCH 06/10] Ask for JSON when autocompleting people to ping `as: "json"` is a @rails/request.js option; plain fetch ignores it and sends `Accept: */*`, so Autocompletable::UsersController answered with its HTML format and response.json() threw. The new ping form never showed any suggestions. Send an explicit Accept header instead. Co-Authored-By: Claude Opus 5.5 --- .../lib/autocomplete/base_autocomplete_handler.js | 2 +- test/system/starting_pings_test.rb | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 test/system/starting_pings_test.rb diff --git a/app/javascript/lib/autocomplete/base_autocomplete_handler.js b/app/javascript/lib/autocomplete/base_autocomplete_handler.js index 28b7fd0..9f1f352 100644 --- a/app/javascript/lib/autocomplete/base_autocomplete_handler.js +++ b/app/javascript/lib/autocomplete/base_autocomplete_handler.js @@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler { #fetchAutocompletables(url) { if (url) { - return fetch(url, { as: "json" }).then(response => response.json()) + return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json()) } else { return Promise.resolve() } diff --git a/test/system/starting_pings_test.rb b/test/system/starting_pings_test.rb new file mode 100644 index 0000000..ddb5b4f --- /dev/null +++ b/test/system/starting_pings_test.rb @@ -0,0 +1,15 @@ +require "application_system_test_case" + +class StartingPingsTest < ApplicationSystemTestCase + setup do + sign_in "kevin@37signals.com" + end + + test "suggesting people to ping" do + visit new_rooms_direct_url + + find("[data-autocomplete-target='input']").send_keys("Jas") + + assert_selector "suggestion-option[role='option']", text: "Jason" + end +end From 1d39b8cbe1523173573673be73ea90e3e93dbb7c Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Sun, 4 Oct 2026 20:06:53 -0400 Subject: [PATCH 07/10] Drop two unmatched closing spans from the flash The flash icons were followed by `` with no opening tag. Browsers discard them, so nothing renders differently, but HTML-aware tools such as Herb report them as errors. Co-Authored-By: Claude Opus 5.5 --- app/views/layouts/application.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 00f7e5b..23a66c5 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -40,9 +40,9 @@
"> <% if flash[:alert] %> - <%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> + <%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> <% else %> - <%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> + <%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %> <% end %>
<%= notice %> From e12651240ca179327144df35945e4cecf0806e53 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 13:51:56 +0200 Subject: [PATCH 08/10] Drop the query plan test The index is a schema change; a test that pins SQLite's plan for each pagination scope locks in more than the change needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9 --- test/models/message_test.rb | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 6733110..5ffecea 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,30 +65,7 @@ class MessageTest < ActiveSupport::TestCase assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id) end - test "pages read a room's messages from an index instead of sorting the whole room" do - room, message = rooms(:watercooler), messages(:sixth) - statements = capture_message_selects do - room.messages.last_page - room.messages.page_before(message) - room.messages.page_after(message) - room.messages.page_created_since(1.day.ago) - end - - assert_equal 4, statements.size - statements.each do |sql| - plan = Message.connection.select_rows("EXPLAIN QUERY PLAN #{sql}").map(&:last).join(" | ") - assert_no_match(/TEMP B-TREE/, plan, sql) - end - end - private - def capture_message_selects(&block) - statements = [] - callback = ->(*, payload) { statements << payload[:sql] if payload[:sql].start_with?(%(SELECT "messages")) } - ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block) - statements - end - def create_new_message_in(room) room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123") end From 345e63718f45441bbcf70a723b497febdd1dbdf0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 5 Oct 2026 07:52:23 -0400 Subject: [PATCH 09/10] Update README with latest Campfire benchmark comparison (#315) --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0ef555b..e70e205 100644 --- a/README.md +++ b/README.md @@ -51,15 +51,15 @@ If you'd rather run the Docker image yourself, you can read more about that in t ## Other implementations -Campfire also has implementations in Django, Laravel, Elixir, Go and Rust: +Campfire also has implementations in Django, Laravel, Express, Elixir, Go and Rust: -| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | -|---|---:|---:|---:|---:|---:|---:| -| Room page | 242 | 170 | 164 | 722 | 3,860 | 36,260 | -| Messages page | 402 | 196 | 175 | 1,053 | 5,573 | 40,872 | -| Sidebar | 541 | 615 | 715 | 1,275 | 19,753 | 34,672 | -| Search | 424 | 315 | 305 | 1,156 | 7,053 | 33,299 | -| Post a message | 225 | 154 | 137 | 801 | 4,767 | 6,896 | +| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | +|---|---:|---:|---:|---:|---:|---:|---:| +| Room page | 241 | 170 | 164 | 559 | 722 | 3,860 | 36,260 | +| Messages page | 413 | 196 | 175 | 777 | 1,053 | 5,573 | 40,872 | +| Sidebar | 552 | 615 | 715 | 4,125 | 1,275 | 19,753 | 34,672 | +| Search | 435 | 315 | 305 | 1,294 | 1,156 | 7,053 | 33,299 | +| Post a message | 273 | 154 | 137 | 256 | 801 | 4,767 | 6,896 | Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395, with four hardware threads allocated to each app. From 259c06bea107b713ae1b567337c0d30bc55979c3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 5 Oct 2026 16:30:18 +0200 Subject: [PATCH 10/10] Remove trailing whitespace --- test/models/message_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 05fc9b5..a5b73f0 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -76,7 +76,7 @@ class MessageTest < ActiveSupport::TestCase assert room.messages.paged? end end - + test "search reads quotes and operator words in the query as text" do message = rooms(:designers).messages.create!(body: "Say hi, NOT bye", client_message_id: "quoted", creator: users(:david))