From 5cb4316133fd47141527c91cd88b251bebafad9e Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 00:55:24 +0200 Subject: [PATCH 1/7] 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 00000000..58ff43f2 --- /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 e7f15403..28ee7c44 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 5ffecea2..67331100 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 6cdcb459b95de42de9163027d10c8e084ddbe0bf Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 01:08:53 +0200 Subject: [PATCH 2/7] 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 58ea47fc..b792cd0d 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 80abfb4f..34ff01b0 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 5ffecea2..e6add9af 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 3/7] 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 58ff43f2..a46b27b6 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 28ee7c44..8a900cb2 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 4/7] 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 4d0d06f1..d1a340ae 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 c04cd553..8cc7de9e 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 5/7] 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 28b7fd03..9f1f3520 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 00000000..ddb5b4f2 --- /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 e12651240ca179327144df35945e4cecf0806e53 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 13:51:56 +0200 Subject: [PATCH 6/7] 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 67331100..5ffecea2 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 7/7] 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 0ef555b7..e70e2058 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.