Roda → Rails Migration Plan
Overview
This document covers the full migration from the current Roda + Sequel stack to Rails + ActiveRecord, including an integration-spec-first strategy so existing behaviour is locked in before any routes are touched.
Stack (Before → After)
| Layer | Before | After |
|---|---|---|
| Web framework | Roda ~2.x | Rails ~8.1 |
| ORM | Sequel ~5.96 | ActiveRecord (Rails built-in) |
| Templates | ERB via Roda :render plugin |
ERB via Rails layouts/views |
| Server | Puma via Rackup | Puma via Rails |
| DB | PostgreSQL + pgvector | Same |
| LLM / Embeddings | ruby_llm, pgvector gem | Same + neighbor gem for has_neighbors |
| Tests | Minitest + Rack::Test (minimal — one file) | Minitest + ActionDispatch::IntegrationTest (87 tests) |
| Rake tasks | Root Rakefile with require_relative "lib/knowledge_base" |
lib/tasks/knowledge_base.rake with task foo: :environment |
Note: lib/knowledge_base modules (Scraper, QA, FeedImporter, etc.) still use Sequel internally. They are framework-agnostic and were not rewritten.
Why This Migration Is Non-Trivial
- 40+ routes across one 610-line file (
app_roda.rb) — no controller separation - 15 Sequel models with custom association names, hooks, and filtered associations (
propositionsas a scopedone_to_many) - pgvector — needs
pgvectorRails adapter or a raw SQL approach - Rake tasks reference Sequel models directly — need updating or dual-booting during transition
- No meaningful test coverage — this is the biggest risk; behaviour must be captured first
- Session/auth — simple cookie sessions via Roda; maps cleanly to Rails sessions
- Helpers (
h,markdown,status_badge) — defined inline inapp_roda.rb; need to move to Rails helpers ruby_llm,redcarpet— framework-agnostic, keep as-is
Phase 0: Integration Specs First ✅ COMPLETED
The goal is a suite of request specs that run against the current Roda app and pass. These same specs will be run again against the Rails app. When they all pass on Rails, the migration is done.
0.1 Add gems to Gemfile (test group)
group :test do
gem 'rack-test' # already present
gem 'minitest' # already present
gem 'minitest-spec-rails' # optional, nicer syntax
gem 'vcr' # record/replay HTTP calls (scraper tests)
gem 'webmock' # stub external HTTP
gem 'factory_bot' # test data
gem 'database_cleaner-sequel'
end
0.2 Test helper setup
Create test/integration_helper.rb:
require 'rack/test'
require_relative '../config.ru' # loads the Roda app
module IntegrationHelpers
include Rack::Test::Methods
def app
# Points at the Roda app today; will be swapped to Rails app later
Rack::Builder.parse_file('config.ru').first
end
end
0.3 What to cover (priority order)
Write one test file per major feature area. Each file should cover happy-path responses (correct status codes, key content present), and the most important error/edge cases.
test/integration/bookmarks_test.rb
GET /bookmarks— 200, bookmark titles presentGET /bookmarks/:id— 200, scraped content shownPOST /bookmarks/:id/star— toggles starred, JSON response{ starred: true/false }POST /bookmarks/:id/title— updates title, JSON{ ok: true, title: "..." }POST /bookmarks/:id/note— saves note, 200POST /bookmarks/:id/read_status— 200POST /bookmarks/:id/tags— adds tag, JSON{ ok: true, name: "..." }POST /bookmarks/:id/tags/:name— removes tag, 200GET /bookmarks/:id/links/search?q=foo— JSON array of bookmarksPOST /bookmarks/:id/links— links bookmark, JSON{ ok: true, id:, title: }POST /bookmarks/:id/links/:id/delete— unlinks, JSON{ ok: true }POST /bookmarks/:id/annotations— creates annotation, 200POST /bookmarks/:id/annotations/:id/update— edits, 200POST /bookmarks/:id/annotations/:id/delete— removes, 200GET /bookmarks/:id/export.md— returns markdown content-typeGET /bookmarks/:id/export.md?include_related=true— includes related bookmarksPOST /bookmarks/:id/delete— deletes, redirects to/
test/integration/search_test.rb
GET /search?q=foo— 200, results section present (stub embedding + LLM calls)GET /search(no query) — 200, empty state
test/integration/qa_test.rb
GET /bookmarks/:id/qa— 200, conversation UIPOST /bookmarks/:id/qa— stubs LLM, returns 200 with response
test/integration/feeds_test.rb
GET /feeds— 200, folder listGET /feeds/:folder— 200, feed listGET /feeds/feed/:id— 200, itemsPOST /feeds/feed/:id/refresh— 200 or redirectPOST /feeds/item/:id/save— saves as bookmark
test/integration/misc_test.rb
GET /— 200, uploaded files listGET /uploads— same as/GET /tags/:name— 200, filtered bookmarksGET /favorited— 200, starred bookmarksGET /topics— 200GET /attachments/:id— 200 with binary body or redirect to signed URL
0.4 Test data strategy
Use plain helper methods in the base IntegrationTest class to create the minimum data needed per test. Wrap each test in DB.transaction(rollback: :always, auto_savepoint: true) so no data persists between tests. No external gems (databasecleaner, factorybot) are needed.
class IntegrationTest < Minitest::Test
include Rack::Test::Methods
def app; App; end
def run(*)
DB.transaction(rollback: :always, auto_savepoint: true) { super }
end
def create_bookmark(**attrs)
defaults = {
source_id: 2, url: "https://example.com/test-#{SecureRandom.hex(6)}",
title: "Test Bookmark", status: "scraped", read_status: "unread",
starred: false, metadata: "{}", created_at: Time.now, updated_at: Time.now
}
KnowledgeBase::Bookmark.create(**defaults.merge(attrs))
end
def create_scraped_content(bookmark, **attrs)
# Must use DB insert directly — ScrapedContent blocks created_at= via mass assignment
defaults = { bookmark_id: bookmark.id, content: "Test content.",
word_count: 3, excerpt: "Test.", scraped_at: Time.now }
id = DB[:scraped_contents].insert(defaults.merge(attrs))
KnowledgeBase::ScrapedContent.first(id: id)
end
# ... create_tag, create_annotation, create_rss_feed, create_attachment, create_rss_item
# Note: create_rss_item must also use DB[:rss_items].insert — same mass assignment issue
end
0.5 Stubbing AI calls
All LLM and embedding calls must be stubbed in tests — they're slow, cost money, and introduce flakiness. Do not use Minitest::Mock or Object#stub — Minitest 6 does not expose Object#stub. Instead, use define_singleton_method with an ensure-based restore:
def with_stub(object, method_name, return_value)
original = object.method(method_name)
object.define_singleton_method(method_name) { |*_args| return_value }
yield
ensure
object.define_singleton_method(method_name, original)
end
STUB_QA_RESULT = {
answer: "Stubbed LLM answer for testing.",
context: [], optimized_query: "stubbed query"
}.freeze
# Usage in a test:
with_stub(KnowledgeBase::QA, :ask, STUB_QA_RESULT) do
get "/search?query=what+is+ruby"
end
0.6 App bugs uncovered during Phase 0
Writing the specs found three bugs in app_roda.rb that had to be fixed before tests could pass:
1. r.halt wrong signature (6 places)
Roda 3.101's halt only accepts a single Rack array argument. The app was calling r.halt(404, "message") throughout, which raises ArgumentError. Fix:
```ruby
Wrong
r.halt(404, "Not found")
Correct
r.halt([404, {}, ["Not found"]])
```
2. Sequel::Model mass assignment restriction on created_at
Several models (ScrapedContent, RssItem) block created_at= via Sequel's mass assignment restrictions. Calling Model.create(created_at: Time.now, ...) raises Sequel::MassAssignmentRestriction. Fix: use DB[:table].insert(row) directly in factory helpers, then reload with Model.first(id: id).
3. add_bookmark_note doesn't exist
Sequel only generates add_* methods for one_to_many associations, not one_to_one. Bookmark has one_to_one :bookmark_note, so @bookmark.add_bookmark_note(...) raises NoMethodError. Fix in app_roda.rb:
```ruby
Wrong
@bookmark.addbookmarknote(content: content)
Correct
KnowledgeBase::BookmarkNote.create(
bookmarkid: @bookmark.id, content: content,
createdat: Time.now, updated_at: Time.now
)
```
0.7 Running the suite
The Roda integration tests (now archived in archive/roda/test/integration/) were run with:
bundle exec ruby -Itest \
test/integration/bookmarks_test.rb \
test/integration/feeds_test.rb \
test/integration/misc_test.rb \
test/integration/search_test.rb \
test/integration/qa_test.rb
The equivalent Rails tests (in test/integration/) now run as part of the standard suite:
bundle exec rails test
Phase 1: Rails App Scaffolding ✅ COMPLETED
Stand up a Rails app alongside the Roda app. Both can read from the same database.
1.1 Generate the Rails app
# In a sibling directory or a subfolder
rails new knowledge_base_rails \
--database=postgresql \
--skip-action-mailer \
--skip-action-mailbox \
--skip-action-cable \
--skip-active-storage \
--skip-action-text \
--skip-jbuilder \
--no-rc
For a personal app with no asset pipeline complexity, add --asset-pipeline=none or just use import maps.
1.2 Database config
Point config/database.yml at the same Postgres database. No new migrations yet — Rails will read the existing schema.
1.3 Keep lib/knowledge_base/ as-is
The lib/ directory is framework-agnostic. Copy or symlink it. All the heavy logic (QA, scraper, embedder, chunker, etc.) stays unchanged.
Add to config/application.rb — use autoload_lib with knowledge_base excluded from Zeitwerk (it uses Sequel and has its own loading structure):
ruby
config.autoload_lib(ignore: %w[assets tasks knowledge_base])
Note: config.autoload_paths << Rails.root.join('lib') was the original plan but autoload_lib is the Rails 7.1+ idiom. The knowledge_base directory must be in the ignore list because Zeitwerk cannot auto-load Sequel models that live alongside AR models under the same namespace.
1.4 Gemfile migration
Carry over all non-Roda gems. Swap:
| Remove | Add |
|---|---|
roda |
— (gone) |
sequel |
activerecord (bundled with Rails) |
rackup |
— (Rails uses Puma directly) |
tilt |
— (Rails uses ERB natively) |
sinatra_helpers (roda plugin) |
Rails helpers |
database_cleaner-sequel |
database_cleaner-active_record |
Keep: ruby_llm, pgvector, redcarpet, nokogiri, ruby-readability, reverse_markdown, notion-ruby-client, rss, google-cloud-storage, dotenv-rails.
Note: pgvector has a Rails/ActiveRecord adapter — add gem 'pgvector' and it works with has_neighbors.
Phase 2: ActiveRecord Models ✅ COMPLETED
15 models created in app/models/ (13 mirroring Sequel models + 2 new join-table models), all plain ApplicationRecord subclasses — no KnowledgeBase:: namespace.
2.1 Association mapping (Sequel → ActiveRecord)
| Sequel | ActiveRecord |
|---|---|
many_to_one :source |
belongs_to :source |
one_to_many :bookmarks |
has_many :bookmarks |
many_to_many :tags |
has_many :tags, through: :bookmark_tags |
one_to_one :scraped_content |
has_one :scraped_content |
one_to_many :bookmark_annotations, order: :created_at |
has_many :bookmark_annotations, -> { order(:created_at) } |
before_save { self.updated_at = Time.now } |
Not needed — AR handles timestamps automatically |
Two extra model files added that have no Sequel equivalent: BookmarkTag and BookmarkLink (join table models needed for has_many :through and related_bookmarks).
2.2 model_name dangerous attribute
The embeddings table has a model_name column which conflicts with ActiveRecord::Base.model_name (a class-level method from ActiveModel::Naming). Rails raises ActiveRecord::DangerousAttributeError at boot when it tries to define the attribute accessor. Fix:
# app/models/embedding.rb
def self.dangerous_attribute_method?(name)
return false if name.to_s == "model_name"
super
end
This suppresses the error so AR defines the model_name instance getter/setter normally. The column value is accessible as embedding.model_name and holds the embedding model string (e.g. "gemini-embedding-001").
2.3 Filtered association (propositions)
ScrapedContent's propositions association filters embeddings by chunk_type. In AR, this uses a scope lambda:
has_many :propositions,
-> { where(chunk_type: "proposition").order(:chunk_index) },
class_name: "Embedding",
foreign_key: :scraped_content_id
2.4 Related bookmarks (bidirectional join)
bookmark_links is a directional join table. related_bookmarks queries both directions. In AR, this becomes two pluck calls on the BookmarkLink model rather than raw SQL:
def related_bookmarks
forward_ids = BookmarkLink.where(bookmark_id: id).pluck(:linked_bookmark_id)
reverse_ids = BookmarkLink.where(linked_bookmark_id: id).pluck(:bookmark_id)
ids = (forward_ids + reverse_ids).uniq
return [] if ids.empty?
Bookmark.where(id: ids).order(:title)
end
2.5 record_timestamps = false
Several tables don't have both created_at and updated_at. Rails will error if it tries to write a column that doesn't exist, so those models need self.record_timestamps = false:
| Model | Situation |
|---|---|
Tag, Attachment, Embedding, QaMessage, RssFeed, BookmarkLink |
created_at only, no updated_at |
ScrapedContent |
Neither — has scraped_at instead |
RssItem |
Neither — has fetched_at and published_at |
Library |
Neither — has indexed_at |
2.6 pgvector / has_neighbors
The plan said gem 'pgvector' would provide has_neighbors. It does not — pgvector 0.3.3 only provides the Sequel adapter and the raw PG type. has_neighbors comes from the separate neighbor gem:
# Gemfile
gem "pgvector" # vector type registration
gem "neighbor" # has_neighbors for ActiveRecord
# config/initializers/pgvector.rb
require "neighbor"
# app/models/embedding.rb
class Embedding < ApplicationRecord
has_neighbors :embedding
end
The raw vector SQL in lib/knowledge_base/qa.rb (find_context) continues to use Sequel for now and is untouched.
Phase 3: Routes → Controllers ✅ COMPLETED
12 controllers created in app/controllers/. The BookmarkActionsController from the original plan was folded into BookmarksController to keep related actions together.
| Controller | Actions |
|---|---|
BookmarksController |
uploads (root + /uploads), favorited, index, show, star, scrape, extractmetadata, title, note, readstatus, destroy |
TagsController |
show (tag filter page), create (on bookmark), destroy (from bookmark) |
LinksController |
search, create, destroy |
AnnotationsController |
create, update, destroy |
PropositionsController |
delete_all, destroy |
QaController |
show, create |
ExportsController |
show (markdown download) |
SearchController |
index |
TopicsController |
index, create |
FeedsController |
index, folder, show, refresh, destroy, discover, add |
FeedItemsController |
save |
AttachmentsController |
show |
3.1 URL structure
Rails resources DSL was not used — routes are defined as flat explicit paths to exactly match the Roda app's URLs. This means the integration tests written against Roda can run against Rails without any URL changes.
Key ordering rule: /feeds/feed/:id must be declared before /feeds/:folder in routes.rb, otherwise "feed" matches as a folder name.
CSRF protection is disabled (protect_from_forgery with: :null_session) since this is a local personal app. The Rails integration tests use ActionDispatch::IntegrationTest which does not send CSRF tokens by default.
3.2 Sequel → ActiveRecord method translations
| Roda (Sequel) | Rails (ActiveRecord) |
|---|---|
Tag.find_or_create(name: name) |
Tag.find_or_create_by(name: name) |
@bookmark.add_tag(tag) |
BookmarkTag.find_or_create_by(bookmark_id:, tag_id:) |
@bookmark.remove_tag(tag) |
@bookmark.tags.delete(tag) |
@bookmark.add_bookmark_annotation(...) |
@bookmark.bookmark_annotations.create!(...) |
conversation.add_qa_message(...) |
conversation.qa_messages.create!(...) |
conversation.update(updated_at: Time.now) |
conversation.touch |
db[:bookmark_links].insert(...) |
BookmarkLink.create!(...) |
db[:embeddings].where(...).delete |
Embedding.where(...).delete_all |
QaConversation.find_or_create(bookmark_id: id) |
QaConversation.find_or_create_by(bookmark_id: id) |
r.halt([404, {}, ["msg"]]) |
render plain: "msg", status: :not_found |
r.halt([422, {}, []]) |
head :unprocessable_entity |
3.3 Helpers
Moved from inline Roda methods to ApplicationHelper:
- markdown(text) — Redcarpet rendering, returns html_safe string
- status_badge(status) — coloured HTML span, returns html_safe
- read_status_badge(status) — same
- h(text) — already available in Rails via ERB::Util
Phase 4: Views ✅ COMPLETED
ERB templates ported with the following adaptations:
| Roda | Rails |
|---|---|
h(text) |
h(text) (same — from ERB::Util) |
<%= markdown(sc.content) %> |
Same, helper defined in ApplicationHelper |
r.redirect '/' |
redirect_to root_path |
Layout via :render plugin |
app/views/layouts/application.html.erb |
@vars set in route block |
@vars set in controller action |
request.params["notice"] |
params[:notice] |
View file mapping (all 14 templates ported)
| Roda view | Rails view |
|---|---|
views/layout.erb |
app/views/layouts/application.html.erb |
views/index.erb |
app/views/bookmarks/uploads.html.erb |
views/uploads.erb |
merged into bookmarks/uploads.html.erb |
views/bookmarks.erb |
app/views/bookmarks/index.html.erb |
views/show.erb |
app/views/bookmarks/show.html.erb |
views/favorited.erb |
app/views/bookmarks/favorited.html.erb |
views/tag.erb |
app/views/tags/show.html.erb |
views/search.erb |
app/views/search/index.html.erb |
views/qa.erb |
app/views/qa/show.html.erb |
views/topics.erb |
app/views/topics/index.html.erb |
views/feeds_index.erb |
app/views/feeds/index.html.erb |
views/feeds_folder.erb |
app/views/feeds/folder.html.erb |
views/feeds_feed.erb |
app/views/feeds/show.html.erb |
views/export_md.erb |
app/views/exports/show.erb |
Non-trivial adaptations
@body_class = "show-page" in view → controller
- In Roda, the show template set @body_class inline; in Rails, moved to BookmarksController#show as @body_class = "show-page" so it's available when the layout renders.
Inline DB queries moved to controllers
- search.erb called KnowledgeBase::Library.order(...) inline → moved to SearchController#index as @libraries
- qa.erb called KnowledgeBase::Embedding.join(...) inline → moved to QaController#show as @has_embeddings
Feeds index: AR aggregate access vs hash access
- Roda's Sequel query returned plain hashes (row[:folder], row[:feed_count]); AR returns model instances with attribute methods → changed to row.folder, row.feed_count.to_i, row.last_fetched_at
Export view
- exports/show.erb renders plain Markdown (no HTML layout); controller already sets layout: false and content_type: "text/markdown" — no changes needed in the view itself
.to_json.html_safe for ANNOTATIONS JS constant
- In show.html.erb, the annotations JSON embedded in a <script> tag uses .to_json.html_safe to prevent double-escaping
confirm() strings in folder view
- Replaced feed.title.gsub("'", "\\'") Ruby-side escaping with Rails' j() helper for JS-safe string embedding
Phase 5: Rake Tasks ✅ COMPLETED
All tasks ported to lib/tasks/knowledge_base.rake. Each task depends on :environment so Rails + AR models are loaded automatically. The lib/knowledge_base modules still use Sequel internally (fine — Sequel remains in the Gemfile); only the rake task shell code itself uses AR.
Sequel → AR translation in tasks
| Sequel (old Rakefile) | ActiveRecord (new .rake file) |
|---|---|
KnowledgeBase::ScrapedContent |
ScrapedContent |
KnowledgeBase::Embedding |
Embedding |
KnowledgeBase::BookmarkNote |
BookmarkNote |
KnowledgeBase::BookmarkAnnotation |
BookmarkAnnotation |
KnowledgeBase::Library |
Library |
KnowledgeBase::Tag |
Tag |
KnowledgeBase::Bookmark[id] |
Bookmark.find_by(id: id) |
.exclude(id: list) |
.where.not(id: list) |
.select_map(:field) |
.pluck(:field) |
.all |
.to_a |
Model.where(...).delete |
Model.where(...).delete_all |
lib.delete |
lib.destroy |
KnowledgeBase::Database.connection |
ActiveRecord::Base.connection (via kb_connection) |
db.literal(text) |
conn.quote(text) |
db.run(sql) |
conn.execute(sql) |
DB[sql, arg1, arg2] |
conn.select_all(sql) with literal interpolation |
Sequel.function(:length, :content) |
"length(content)" in .sum(...) |
task :foo do ... require_relative "lib/kb" ... end |
task foo: :environment do ... end |
What was NOT ported
db:migrate— Rails built-in (bundle exec rails db:migrate) replaces the Sequel migrator taskservertask — not needed; userails serverorbin/rails s
Note: db:dump and db:restore were ported to lib/tasks/knowledge_base.rake (they use docker compose exec + pg_dump/psql, no ORM dependency, but still live in the same .rake file for discoverability).
lib/knowledge_base modules
These modules (Scraper, NotionSync, NotionContent, KeepImport, FeedImporter, PropositionExtractor, LibraryDocScraper, LibrarySourceExtractor) still use Sequel internally. They continue to work because:
1. Sequel is still in Gemfile
2. config/initializers/knowledge_base.rb does require_relative "../../lib/knowledge_base" which sets up the Sequel connection
3. These are called from rake tasks and controllers that go through lib/knowledge_base — no change needed
Phase 6: Cutover ✅ COMPLETED
All 85 integration tests pass against the Rails app. Cutover actions taken:
Files archived to archive/roda/
app_roda.rb— the 610-line Roda route fileconfig.ru— Rack entry point for Rodaviews/— all 14 Roda ERB templates (ported toapp/views/)test/integration_helper.rb— Roda integration test base classtest/integration/— Roda integration test filestest/routes/— Roda route unit tests (obsolete after cutover)
Gemfile
Removed Roda-specific gems: roda, tilt, rackup. Sequel is kept because lib/knowledge_base modules (scraper, embedder, etc.) still use it internally.
Rails app moved to repo root
The Rails app was initially built in a rails/ subdirectory, then moved to the repo root. Files from rails/ were merged:
- app/, config/, public/, script/, vendor/, log/ — moved directly
- bin/ — merged (Rails scripts + existing kb-add, dump-production, delete-character-embeddings, mdview)
- lib/tasks/knowledge_base.rake — moved to lib/tasks/ (root's lib/knowledge_base/ was identical; duplicate deleted)
- test/integration/ and test/integration_helper.rb — moved to root test/
- test/test_helper.rb — replaced with Rails version
- db/seeds.rb, db/cache_schema.rb, db/queue_schema.rb — added to root db/
- Gemfile, Gemfile.lock, Rakefile, config.ru, Dockerfile — replaced root versions
- .gitignore, .rubocop.yml, .ruby-version, .kamal/, .github/ — moved
- rails/ directory removed
Rakefile
The root Rakefile was replaced with the standard Rails one (require_relative "config/application" + Rails.application.load_tasks). All rake tasks now live in lib/tasks/knowledge_base.rake and are loaded automatically.
test/queryrewritertest.rb
The existing unit test for KnowledgeBase::QueryRewriter was updated to load lib/knowledge_base directly instead of going through the Roda test_helper. It is now picked up by bundle exec rails test.
Final test count
87 runs, 146 assertions, 0 failures (85 integration + 2 query_rewriter unit tests).
What was NOT done
lib/knowledge_base modules were not rewritten to use ActiveRecord — they still use Sequel internally. This is intentional: the modules are framework-agnostic and work correctly with Sequel in the Gemfile.
What to Watch Out For
Sequel vs ActiveRecord query semantics
- Sequel is explicit (no magic
updated_at); ActiveRecord has more conventions - Sequel uses
datasetchaining; ActiveRecord usesscopeandwhere Sequel::Model.dbraw queries →ActiveRecord::Base.connection.execute
pgvector
pgvectorregisters the vector type; theneighborgem provideshas_neighborsfor ActiveRecord (they are separate gems —pgvectoralone is not sufficient).- Raw vector SQL in
qa.rb(find_context) continues to run through Sequel and is untouched.
Session handling
- Roda uses a simple cookie session. Rails sessions work the same way by default — no change needed for users.
CSRF
- Roda has no CSRF middleware by default (this app has none). Rails adds CSRF by default. For JSON endpoints, use
protect_from_forgery with: :null_sessionorskip_before_action :verify_authenticity_tokenon API-like actions — but audit this carefully; this is a local-only personal app so it's low risk.
Migration Complete
All 6 phases done. The app runs on Rails 8.1 + ActiveRecord at the repo root. Test suite: 87 runs, 146 assertions, 0 failures (bundle exec rails test).
Lessons learned
- Write the integration specs first — Phase 0 uncovered 3 real bugs in the Roda app before any Rails code was written. Even without the migration the specs would have been worth it.
autoload_libmust ignoreknowledge_base— Zeitwerk cannot auto-load a directory of Sequel models alongside AR models. The ignore list is essential.neighboris separate frompgvector— easy to miss when reading docs;pgvectoronly registers the PG type,neighboraddshas_neighbors.model_nameis a reserved AR method — any table with amodel_namecolumn needsdangerous_attribute_method?overridden in the model.rss_itemsusesfetched_at, notcreated_at— discovered during test setup; the column has a DB default so it never needed explicit assignment.