├── log └── .keep ├── storage ├── .keep └── production.sqlite3 ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep ├── vendor ├── .keep └── javascript │ └── .keep ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── session_test.rb │ ├── user_test.rb │ ├── comment_test.rb │ └── post_test.rb ├── system │ ├── .keep │ ├── posts_test.rb │ ├── comments_test.rb │ ├── sessions_test.rb │ └── users_test.rb ├── controllers │ ├── .keep │ ├── posts_controller_test.rb │ ├── users_controller_test.rb │ ├── sessions_controller_test.rb │ └── comments_controller_test.rb ├── integration │ └── .keep ├── fixtures │ ├── files │ │ └── .keep │ ├── comments.yml │ ├── users.yml │ ├── sessions.yml │ └── posts.yml ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb └── test_helper.rb ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── images │ │ └── .keep │ ├── config │ │ └── manifest.js │ └── stylesheets │ │ ├── application.tailwind.css │ │ └── application.css ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── current.rb │ ├── session.rb │ ├── comment.rb │ ├── post.rb │ └── user.rb ├── controllers │ ├── concerns │ │ ├── .keep │ │ └── authentication.rb │ ├── application_controller.rb │ ├── sessions_controller.rb │ ├── users_controller.rb │ ├── comments_controller.rb │ ├── posts_controller.rb │ └── benchmarking_controller.rb ├── helpers │ ├── posts_helper.rb │ ├── users_helper.rb │ ├── comments_helper.rb │ ├── sessions_helper.rb │ └── application_helper.rb ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── mailers │ └── application_mailer.rb ├── javascript │ ├── application.js │ └── controllers │ │ ├── hello_controller.js │ │ ├── application.js │ │ └── index.js ├── jobs │ └── application_job.rb ├── views │ ├── posts │ │ ├── new.html.erb │ │ ├── edit.html.erb │ │ ├── _post.html.erb │ │ ├── _form.html.erb │ │ ├── index.html.erb │ │ └── show.html.erb │ ├── pwa │ │ ├── manifest.json.erb │ │ └── service-worker.js │ ├── users │ │ ├── _user.html.erb │ │ ├── edit.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb │ ├── comments │ │ ├── edit.html.erb │ │ ├── _form.html.erb │ │ └── _comment.html.erb │ ├── sessions │ │ ├── _session.html.erb │ │ └── new.html.erb │ └── layouts │ │ └── application.html.erb └── constraints │ └── authenticated_constraint.rb ├── .ruby-version ├── config ├── master.key ├── credentials │ └── production.key ├── environment.rb ├── boot.rb ├── cable.yml ├── importmap.rb ├── credentials.yml.enc ├── initializers │ ├── filter_parameter_logging.rb │ ├── permissions_policy.rb │ ├── assets.rb │ ├── inflections.rb │ └── content_security_policy.rb ├── tailwind.config.js ├── application.rb ├── database.yml ├── storage.yml ├── routes.rb ├── locales │ └── en.yml ├── puma.rb └── environments │ ├── test.rb │ ├── development.rb │ └── production.rb ├── Procfile.dev ├── public ├── icon.png ├── assets │ ├── manifest-2f0ac84ec540eb753a59078be1e78cb9bb00e12a9a8d6d826039a818a0008610.js │ ├── trix-5fc7656c4bff8fe505ff90fda4bc9409db4447ada6efcc1204914dc782c6066c.css.gz │ ├── trix-686ab55c2aea8035a7f728b61ec7afedfe857f70d6279ab453da775b7469e9e8.js.gz │ ├── turbo-84e828a0e6f1f9418a277df89df877e9a30524438cf8dc007c066655017a44bc.js.gz │ ├── manifest-2f0ac84ec540eb753a59078be1e78cb9bb00e12a9a8d6d826039a818a0008610.js.gz │ ├── stimulus-f75215805563870a61ee9dc5a207ce46d4675c7e667558a54344fd1e7baa697f.js.gz │ ├── actioncable-323d156a10054ee2c94ba8a17c20458f542a17234f934ea53d16e0e1899fda5e.js.gz │ ├── actiontext-78de0ebeae470799f9ec25fd0e20ae2d931df88c2ff9315918d1054a2fca2596.js.gz │ ├── application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js.gz │ ├── inter-font-8c3e82affb176f4bca9616b838d906343d1251adc8408efe02cf2b1e4fcf2bc4.css.gz │ ├── tailwind-9aea498d3a42c7697a243252c7b9e2a1a7db20ab8e5b80391185fc37c3a2ffc1.css.gz │ ├── turbo.min-cd3ce4205eaa3eb1f80c30fedaf47bccb15a7668eb53b1cb1a5e0dda16009d4d.js.gz │ ├── activestorage-503a4fe23aabfbcb752dad255f01835904e6961d5f20d1de13987a691c27d9cd.js.gz │ ├── application-9e3778bbe94c3025445599d20f7571b4959afbbb53a2a451470e40b7ce396306.css.gz │ ├── stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js.gz │ ├── turbo.min.js-8c7b8876336313c7b6f4b9a098dff2eacad3972491b3171b58bcbe0861320337.map.gz │ ├── actioncable.esm-b66d83871f0a453b10ba8b4c147c2bafa655caaa5d13fa34e9bf361c1c2aedb7.js.gz │ ├── actiontext.esm-328ef022563f73c1b9b45ace742bd21330da0f6bd6c1c96d352d52fc8b8857e5.js.gz │ ├── application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js │ ├── stimulus-loading-3576ce92b149ad5d6959438c6f291e2426c86df3b874c525b30faad51b0d96b3.js.gz │ ├── stimulus.min.js-2cc63625fa177963b45da974806e7aee846cbf1d4930815733d0fdf3fb232325.map.gz │ ├── activestorage.esm-b3f7f0a5ef90530b509c5e681c4b3ef5d5046851e5b70d57fdb45e32b039c883.js.gz │ ├── controllers │ │ ├── index-31a9bee606cbc5cdb1593881f388bbf4c345bf693ea24e124f84b6d5c98ab648.js.gz │ │ ├── application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js.gz │ │ ├── hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js.gz │ │ ├── hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js │ │ ├── application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js │ │ └── index-31a9bee606cbc5cdb1593881f388bbf4c345bf693ea24e124f84b6d5c98ab648.js │ ├── Inter-roman.extra.var-29781a209d7978155bf343eb1653779269965fa4a43d504b1fb361ab20e8487b.woff2 │ ├── Inter-roman.greek.var-1134a39060691474b0f57b2de5e09977a31a8c06df90960b982314f58a5b4c63.woff2 │ ├── Inter-roman.latin.var-4b87a3d384ea557b10afa9570b753eda868b12b5e51eea0977ffa6e641998f6a.woff2 │ ├── application.tailwind-feead411e60f8b934b9744e29484c9d2b74801f894f27dcf190c30ccfaec0d3a.css.gz │ ├── stimulus-autoloader-c584942b568ba74879da31c7c3d51366737bacaf6fbae659383c0a5653685693.js.gz │ ├── Inter-italic.extra.var-cc59cf4b324781109d703309fc7d8814b6ba74dd2822a3c029715da8cb98ea57.woff2 │ ├── Inter-italic.greek.var-77ec12f9397b88a29c16c05f2db87d9c0d0c2b157f1980271fed020f00b35321.woff2 │ ├── Inter-italic.latin.var-cbfc0334f8cfad4727431982cd32c32f7daf6e73f0ab6f759ec3dc757c1c6bf7.woff2 │ ├── Inter-roman.symbols.var-847d2a436a03ba1fbd4b750915db031a12a713befe0dcd79e8f39bf6a6e1a647.woff2 │ ├── Inter-italic.cyrillic.var-a31159d5ce68b317b19edc7ee7f1f3ffdd5316ee899945b502406abda313afa2.woff2 │ ├── Inter-italic.latin-ext.var-802705fb8496b82eda3f410c7e70a35c9407ba98b97cc5d9754db82750466fad.woff2 │ ├── Inter-italic.symbols.var-59f27012284f5e97bbff303eb599ca98f29629a1c0fe7352bcc15b27d1dcd937.woff2 │ ├── Inter-roman.alternates.var-29781a209d7978155bf343eb1653779269965fa4a43d504b1fb361ab20e8487b.woff2 │ ├── Inter-roman.cyrillic.var-8aa524d3da3b8fd4a03ff2d0d41ea50520113fb2e8ae57c15079dabea44b3529.woff2 │ ├── Inter-roman.latin-ext.var-46bc23ba7e33ea447579cd95b95f34665d7560377065b72f3a77df8bca4b0e5c.woff2 │ ├── Inter-roman.vietnamese.var-a10e650090dc30250de0db440654c9bf29a843b233ee95a699b008bab0a6d498.woff2 │ ├── Inter-italic.alternates.var-cc59cf4b324781109d703309fc7d8814b6ba74dd2822a3c029715da8cb98ea57.woff2 │ ├── Inter-italic.vietnamese.var-b5dc5b6490ba49e8193315ff62c8e90551102055440f084a6e982e5eab8e589a.woff2 │ ├── stimulus-importmap-autoloader-db2076c783bf2dbee1226e2add52fef290b5d31b5bcd1edd999ac8a6dd31c44a.js.gz │ ├── application.tailwind-feead411e60f8b934b9744e29484c9d2b74801f894f27dcf190c30ccfaec0d3a.css │ ├── application-9e3778bbe94c3025445599d20f7571b4959afbbb53a2a451470e40b7ce396306.css │ ├── stimulus-importmap-autoloader-db2076c783bf2dbee1226e2add52fef290b5d31b5bcd1edd999ac8a6dd31c44a.js │ ├── stimulus-autoloader-c584942b568ba74879da31c7c3d51366737bacaf6fbae659383c0a5653685693.js │ └── stimulus-loading-3576ce92b149ad5d6959438c6f291e2426c86df3b874c525b30faad51b0d96b3.js ├── robots.txt ├── icon.svg ├── 406-unsupported-browser.html ├── 500.html ├── 422.html └── 404.html ├── bin ├── rake ├── importmap ├── rails ├── brakeman ├── rubocop ├── serve ├── dev ├── docker-entrypoint ├── setup └── bundle ├── config.ru ├── db ├── migrate │ ├── 20240904195059_add_posts_count_to_users.rb │ ├── 20240904195113_add_comments_count_to_posts.rb │ ├── 20240904175617_create_sessions.rb │ ├── 20240904190006_create_posts.rb │ ├── 20240904191508_create_comments.rb │ └── 20240904171418_create_users.rb ├── seeds.rb └── schema.rb ├── Rakefile ├── .rubocop.yml ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitattributes ├── .dockerignore ├── .gitignore ├── Gemfile ├── Dockerfile ├── workshop ├── 12-solid-error-monitoring.md ├── 08-adding-solid-cache.md ├── 04-simplifying-fixes.md ├── 10-controlling-sqlite-compilation.md ├── 09-using-sqlite-extensions.md ├── 06-restoring-from-a-backup.md ├── 00-run-baseline-load-tests.md ├── 02-fixing-errored-responses.md └── 11-branch-specific-databases.md └── README.md /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.1.6 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/master.key: -------------------------------------------------------------------------------- 1 | 96e6844b902c9da045093be407a80d7e -------------------------------------------------------------------------------- /app/helpers/posts_helper.rb: -------------------------------------------------------------------------------- 1 | module PostsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/users_helper.rb: -------------------------------------------------------------------------------- 1 | module UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/comments_helper.rb: -------------------------------------------------------------------------------- 1 | module CommentsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /config/credentials/production.key: -------------------------------------------------------------------------------- 1 | 052574364e947abcbd6e7bb35d4e1203 -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server 2 | css: bin/rails tailwindcss:watch 3 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/icon.png -------------------------------------------------------------------------------- /storage/production.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/storage/production.sqlite3 -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /public/assets/manifest-2f0ac84ec540eb753a59078be1e78cb9bb00e12a9a8d6d826039a818a0008610.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /app/models/current.rb: -------------------------------------------------------------------------------- 1 | class Current < ActiveSupport::CurrentAttributes 2 | attribute :session 3 | delegate :user, to: :session, allow_nil: true 4 | end -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /bin/brakeman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | ARGV.unshift("--ensure-latest") 6 | 7 | load Gem.bin_path("brakeman", "brakeman") 8 | -------------------------------------------------------------------------------- /app/models/session.rb: -------------------------------------------------------------------------------- 1 | class Session < ApplicationRecord 2 | COOKIE_KEY = :session_token 3 | 4 | belongs_to :user 5 | 6 | def browser = @browser ||= Browser.new(user_agent) 7 | end 8 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers" 4 | -------------------------------------------------------------------------------- /app/models/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment < ApplicationRecord 2 | belongs_to :post, counter_cache: true 3 | belongs_to :user 4 | 5 | validates :body, presence: true, length: { minimum: 5 } 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20240904195059_add_posts_count_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddPostsCountToUsers < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :users, :posts_count, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../../javascript .js 4 | //= link_tree ../../../vendor/javascript .js 5 | //= link_tree ../builds 6 | -------------------------------------------------------------------------------- /db/migrate/20240904195113_add_comments_count_to_posts.rb: -------------------------------------------------------------------------------- 1 | class AddCommentsCountToPosts < ActiveRecord::Migration[7.2] 2 | def change 3 | add_column :posts, :comments_count, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] 5 | end 6 | -------------------------------------------------------------------------------- /app/javascript/controllers/hello_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.textContent = "Hello World!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | belongs_to :user, counter_cache: true 3 | has_many :comments, dependent: :destroy 4 | 5 | validates :title, presence: true, uniqueness: true, length: { minimum: 5 } 6 | end 7 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /public/assets/trix-5fc7656c4bff8fe505ff90fda4bc9409db4447ada6efcc1204914dc782c6066c.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/trix-5fc7656c4bff8fe505ff90fda4bc9409db4447ada6efcc1204914dc782c6066c.css.gz -------------------------------------------------------------------------------- /public/assets/trix-686ab55c2aea8035a7f728b61ec7afedfe857f70d6279ab453da775b7469e9e8.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/trix-686ab55c2aea8035a7f728b61ec7afedfe857f70d6279ab453da775b7469e9e8.js.gz -------------------------------------------------------------------------------- /public/assets/turbo-84e828a0e6f1f9418a277df89df877e9a30524438cf8dc007c066655017a44bc.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/turbo-84e828a0e6f1f9418a277df89df877e9a30524438cf8dc007c066655017a44bc.js.gz -------------------------------------------------------------------------------- /public/assets/manifest-2f0ac84ec540eb753a59078be1e78cb9bb00e12a9a8d6d826039a818a0008610.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/manifest-2f0ac84ec540eb753a59078be1e78cb9bb00e12a9a8d6d826039a818a0008610.js.gz -------------------------------------------------------------------------------- /public/assets/stimulus-f75215805563870a61ee9dc5a207ce46d4675c7e667558a54344fd1e7baa697f.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/stimulus-f75215805563870a61ee9dc5a207ce46d4675c7e667558a54344fd1e7baa697f.js.gz -------------------------------------------------------------------------------- /public/assets/actioncable-323d156a10054ee2c94ba8a17c20458f542a17234f934ea53d16e0e1899fda5e.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/actioncable-323d156a10054ee2c94ba8a17c20458f542a17234f934ea53d16e0e1899fda5e.js.gz -------------------------------------------------------------------------------- /public/assets/actiontext-78de0ebeae470799f9ec25fd0e20ae2d931df88c2ff9315918d1054a2fca2596.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/actiontext-78de0ebeae470799f9ec25fd0e20ae2d931df88c2ff9315918d1054a2fca2596.js.gz -------------------------------------------------------------------------------- /public/assets/application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js.gz -------------------------------------------------------------------------------- /public/assets/inter-font-8c3e82affb176f4bca9616b838d906343d1251adc8408efe02cf2b1e4fcf2bc4.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/inter-font-8c3e82affb176f4bca9616b838d906343d1251adc8408efe02cf2b1e4fcf2bc4.css.gz -------------------------------------------------------------------------------- /public/assets/tailwind-9aea498d3a42c7697a243252c7b9e2a1a7db20ab8e5b80391185fc37c3a2ffc1.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/tailwind-9aea498d3a42c7697a243252c7b9e2a1a7db20ab8e5b80391185fc37c3a2ffc1.css.gz -------------------------------------------------------------------------------- /public/assets/turbo.min-cd3ce4205eaa3eb1f80c30fedaf47bccb15a7668eb53b1cb1a5e0dda16009d4d.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/turbo.min-cd3ce4205eaa3eb1f80c30fedaf47bccb15a7668eb53b1cb1a5e0dda16009d4d.js.gz -------------------------------------------------------------------------------- /public/assets/activestorage-503a4fe23aabfbcb752dad255f01835904e6961d5f20d1de13987a691c27d9cd.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/activestorage-503a4fe23aabfbcb752dad255f01835904e6961d5f20d1de13987a691c27d9cd.js.gz -------------------------------------------------------------------------------- /public/assets/application-9e3778bbe94c3025445599d20f7571b4959afbbb53a2a451470e40b7ce396306.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/application-9e3778bbe94c3025445599d20f7571b4959afbbb53a2a451470e40b7ce396306.css.gz -------------------------------------------------------------------------------- /public/assets/stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/stimulus.min-dd364f16ec9504dfb72672295637a1c8838773b01c0b441bd41008124c407894.js.gz -------------------------------------------------------------------------------- /public/assets/turbo.min.js-8c7b8876336313c7b6f4b9a098dff2eacad3972491b3171b58bcbe0861320337.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/turbo.min.js-8c7b8876336313c7b6f4b9a098dff2eacad3972491b3171b58bcbe0861320337.map.gz -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /public/assets/actioncable.esm-b66d83871f0a453b10ba8b4c147c2bafa655caaa5d13fa34e9bf361c1c2aedb7.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/actioncable.esm-b66d83871f0a453b10ba8b4c147c2bafa655caaa5d13fa34e9bf361c1c2aedb7.js.gz -------------------------------------------------------------------------------- /public/assets/actiontext.esm-328ef022563f73c1b9b45ace742bd21330da0f6bd6c1c96d352d52fc8b8857e5.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/actiontext.esm-328ef022563f73c1b9b45ace742bd21330da0f6bd6c1c96d352d52fc8b8857e5.js.gz -------------------------------------------------------------------------------- /public/assets/application-37f365cbecf1fa2810a8303f4b6571676fa1f9c56c248528bc14ddb857531b95.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers"; 4 | -------------------------------------------------------------------------------- /public/assets/stimulus-loading-3576ce92b149ad5d6959438c6f291e2426c86df3b874c525b30faad51b0d96b3.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/stimulus-loading-3576ce92b149ad5d6959438c6f291e2426c86df3b874c525b30faad51b0d96b3.js.gz -------------------------------------------------------------------------------- /public/assets/stimulus.min.js-2cc63625fa177963b45da974806e7aee846cbf1d4930815733d0fdf3fb232325.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/stimulus.min.js-2cc63625fa177963b45da974806e7aee846cbf1d4930815733d0fdf3fb232325.map.gz -------------------------------------------------------------------------------- /public/assets/activestorage.esm-b3f7f0a5ef90530b509c5e681c4b3ef5d5046851e5b70d57fdb45e32b039c883.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/activestorage.esm-b3f7f0a5ef90530b509c5e681c4b3ef5d5046851e5b70d57fdb45e32b039c883.js.gz -------------------------------------------------------------------------------- /public/assets/controllers/index-31a9bee606cbc5cdb1593881f388bbf4c345bf693ea24e124f84b6d5c98ab648.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/controllers/index-31a9bee606cbc5cdb1593881f388bbf4c345bf693ea24e124f84b6d5c98ab648.js.gz -------------------------------------------------------------------------------- /public/assets/Inter-roman.extra.var-29781a209d7978155bf343eb1653779269965fa4a43d504b1fb361ab20e8487b.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.extra.var-29781a209d7978155bf343eb1653779269965fa4a43d504b1fb361ab20e8487b.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.greek.var-1134a39060691474b0f57b2de5e09977a31a8c06df90960b982314f58a5b4c63.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.greek.var-1134a39060691474b0f57b2de5e09977a31a8c06df90960b982314f58a5b4c63.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.latin.var-4b87a3d384ea557b10afa9570b753eda868b12b5e51eea0977ffa6e641998f6a.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.latin.var-4b87a3d384ea557b10afa9570b753eda868b12b5e51eea0977ffa6e641998f6a.woff2 -------------------------------------------------------------------------------- /public/assets/application.tailwind-feead411e60f8b934b9744e29484c9d2b74801f894f27dcf190c30ccfaec0d3a.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/application.tailwind-feead411e60f8b934b9744e29484c9d2b74801f894f27dcf190c30ccfaec0d3a.css.gz -------------------------------------------------------------------------------- /public/assets/stimulus-autoloader-c584942b568ba74879da31c7c3d51366737bacaf6fbae659383c0a5653685693.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/stimulus-autoloader-c584942b568ba74879da31c7c3d51366737bacaf6fbae659383c0a5653685693.js.gz -------------------------------------------------------------------------------- /test/fixtures/comments.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | post: one 5 | user: one 6 | body: comment one 7 | 8 | two: 9 | post: two 10 | user: two 11 | body: comment two 12 | -------------------------------------------------------------------------------- /public/assets/Inter-italic.extra.var-cc59cf4b324781109d703309fc7d8814b6ba74dd2822a3c029715da8cb98ea57.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.extra.var-cc59cf4b324781109d703309fc7d8814b6ba74dd2822a3c029715da8cb98ea57.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-italic.greek.var-77ec12f9397b88a29c16c05f2db87d9c0d0c2b157f1980271fed020f00b35321.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.greek.var-77ec12f9397b88a29c16c05f2db87d9c0d0c2b157f1980271fed020f00b35321.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-italic.latin.var-cbfc0334f8cfad4727431982cd32c32f7daf6e73f0ab6f759ec3dc757c1c6bf7.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.latin.var-cbfc0334f8cfad4727431982cd32c32f7daf6e73f0ab6f759ec3dc757c1c6bf7.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.symbols.var-847d2a436a03ba1fbd4b750915db031a12a713befe0dcd79e8f39bf6a6e1a647.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.symbols.var-847d2a436a03ba1fbd4b750915db031a12a713befe0dcd79e8f39bf6a6e1a647.woff2 -------------------------------------------------------------------------------- /public/assets/controllers/application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/controllers/application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js.gz -------------------------------------------------------------------------------- /public/assets/Inter-italic.cyrillic.var-a31159d5ce68b317b19edc7ee7f1f3ffdd5316ee899945b502406abda313afa2.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.cyrillic.var-a31159d5ce68b317b19edc7ee7f1f3ffdd5316ee899945b502406abda313afa2.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-italic.latin-ext.var-802705fb8496b82eda3f410c7e70a35c9407ba98b97cc5d9754db82750466fad.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.latin-ext.var-802705fb8496b82eda3f410c7e70a35c9407ba98b97cc5d9754db82750466fad.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-italic.symbols.var-59f27012284f5e97bbff303eb599ca98f29629a1c0fe7352bcc15b27d1dcd937.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.symbols.var-59f27012284f5e97bbff303eb599ca98f29629a1c0fe7352bcc15b27d1dcd937.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.alternates.var-29781a209d7978155bf343eb1653779269965fa4a43d504b1fb361ab20e8487b.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.alternates.var-29781a209d7978155bf343eb1653779269965fa4a43d504b1fb361ab20e8487b.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.cyrillic.var-8aa524d3da3b8fd4a03ff2d0d41ea50520113fb2e8ae57c15079dabea44b3529.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.cyrillic.var-8aa524d3da3b8fd4a03ff2d0d41ea50520113fb2e8ae57c15079dabea44b3529.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.latin-ext.var-46bc23ba7e33ea447579cd95b95f34665d7560377065b72f3a77df8bca4b0e5c.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.latin-ext.var-46bc23ba7e33ea447579cd95b95f34665d7560377065b72f3a77df8bca4b0e5c.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-roman.vietnamese.var-a10e650090dc30250de0db440654c9bf29a843b233ee95a699b008bab0a6d498.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-roman.vietnamese.var-a10e650090dc30250de0db440654c9bf29a843b233ee95a699b008bab0a6d498.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-italic.alternates.var-cc59cf4b324781109d703309fc7d8814b6ba74dd2822a3c029715da8cb98ea57.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.alternates.var-cc59cf4b324781109d703309fc7d8814b6ba74dd2822a3c029715da8cb98ea57.woff2 -------------------------------------------------------------------------------- /public/assets/Inter-italic.vietnamese.var-b5dc5b6490ba49e8193315ff62c8e90551102055440f084a6e982e5eab8e589a.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/Inter-italic.vietnamese.var-b5dc5b6490ba49e8193315ff62c8e90551102055440f084a6e982e5eab8e589a.woff2 -------------------------------------------------------------------------------- /public/assets/controllers/hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/controllers/hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js.gz -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | 4 | # Overwrite or add rules to create your own house style 5 | # 6 | # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` 7 | # Layout/SpaceInsideArrayLiteralBrackets: 8 | # Enabled: false 9 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_secure_password 3 | has_many :sessions, dependent: :destroy 4 | has_many :posts, dependent: :destroy 5 | has_many :comments, dependent: :destroy 6 | 7 | validates :screen_name, presence: true, uniqueness: true 8 | end 9 | -------------------------------------------------------------------------------- /public/assets/stimulus-importmap-autoloader-db2076c783bf2dbee1226e2add52fef290b5d31b5bcd1edd999ac8a6dd31c44a.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/euruko-2024/HEAD/public/assets/stimulus-importmap-autoloader-db2076c783bf2dbee1226e2add52fef290b5d31b5bcd1edd999ac8a6dd31c44a.js.gz -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: redis://localhost:6379/1 4 | 5 | test: 6 | adapter: test 7 | 8 | production: 9 | adapter: redis 10 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 11 | channel_prefix: euruko_2024_production 12 | -------------------------------------------------------------------------------- /public/assets/controllers/hello_controller-549135e8e7c683a538c3d6d517339ba470fcfb79d62f738a0a089ba41851a554.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.textContent = "Hello World!" 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /bin/serve: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | export PORT="${PORT:-3000}" 4 | export RAILS_ENV="production" 5 | export RELAX_SSL="true" 6 | export RAILS_LOG_LEVEL="warn" 7 | export WEB_CONCURRENCY="${WEB_CONCURRENCY:-10}" 8 | export RAILS_MAX_THREADS="${RAILS_MAX_THREADS:-5}" 9 | 10 | ./bin/rails server 11 | -------------------------------------------------------------------------------- /test/models/session_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SessionTest < ActiveSupport::TestCase 4 | test "belongs to user" do 5 | session = Session.new(user: nil) 6 | session.valid? 7 | assert_includes session.errors[:user], "email or password is incorrect" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap via controllers/**/*_controller 2 | import { application } from "controllers/application" 3 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 4 | eagerLoadControllersFrom("controllers", application) 5 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 0 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: monthly 12 | open-pull-requests-limit: 0 13 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application" 4 | pin "@hotwired/turbo-rails", to: "turbo.min.js" 5 | pin "@hotwired/stimulus", to: "stimulus.min.js" 6 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" 7 | pin_all_from "app/javascript/controllers", under: "controllers" 8 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Authentication 3 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 4 | allow_browser versions: :modern 5 | 6 | NotAuthorized = Class.new(StandardError) 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240904175617_create_sessions.rb: -------------------------------------------------------------------------------- 1 | class CreateSessions < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :sessions do |t| 4 | t.belongs_to :user, null: false, foreign_key: true 5 | t.string :user_agent 6 | t.string :ip_address 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | *, ::after, ::before { 7 | background-color: inherit; 8 | font-family: inherit; 9 | color: inherit; 10 | overflow-wrap: break-word; 11 | margin: 0; 12 | padding: 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /db/migrate/20240904190006_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :posts do |t| 4 | t.belongs_to :user, null: false, foreign_key: true 5 | t.string :title, null: false, index: { unique: true } 6 | t.text :content 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20240904191508_create_comments.rb: -------------------------------------------------------------------------------- 1 | class CreateComments < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :comments do |t| 4 | t.belongs_to :post, null: false, foreign_key: true 5 | t.belongs_to :user, null: false, foreign_key: true 6 | t.text :body 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /public/assets/controllers/application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application }; 10 | -------------------------------------------------------------------------------- /public/assets/controllers/index-31a9bee606cbc5cdb1593881f388bbf4c345bf693ea24e124f84b6d5c98ab648.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap via controllers/**/*_controller 2 | import { application } from "controllers/application" 3 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 4 | eagerLoadControllersFrom("controllers", application); 5 | -------------------------------------------------------------------------------- /test/channels/application_cable/connection_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module ApplicationCable 4 | class ConnectionTest < ActionCable::Connection::TestCase 5 | # test "connects with cookies" do 6 | # cookies.signed[:user_id] = 42 7 | # 8 | # connect 9 | # 10 | # assert_equal connection.user_id, "42" 11 | # end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20240904171418_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :users do |t| 4 | t.string :screen_name, null: false, index: { unique: true } 5 | t.string :password_digest, null: false 6 | t.text :about 7 | t.datetime :last_seen_at 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/posts/new.html.erb: -------------------------------------------------------------------------------- 1 |

2 | New post 3 |

4 | <%= link_to "← Back to posts".html_safe, posts_path, class: "absolute -top-4 right-4 py-px px-2 -mx-2 border-2 border-transparent hover:border-black focus:outline-none focus:ring focus:ring-offset-0 focus:ring-blue-500" %> 5 | 6 | <%= render "form", post: @post %> -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | config/credentials/*.yml.enc diff=rails_credentials 9 | config/credentials.yml.enc diff=rails_credentials 10 | -------------------------------------------------------------------------------- /app/constraints/authenticated_constraint.rb: -------------------------------------------------------------------------------- 1 | class AuthenticatedConstraint 2 | def matches?(request) 3 | cookies = ActionDispatch::Cookies::CookieJar.build(request, request.cookies) 4 | session = Session.find_signed(cookies.signed[Session::COOKIE_KEY]) 5 | if session 6 | Current.session = session 7 | true 8 | else 9 | false 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /public/assets/application.tailwind-feead411e60f8b934b9744e29484c9d2b74801f894f27dcf190c30ccfaec0d3a.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | *, ::after, ::before { 7 | background-color: inherit; 8 | font-family: inherit; 9 | color: inherit; 10 | overflow-wrap: break-word; 11 | margin: 0; 12 | padding: 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! gem list foreman -i --silent; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | # Default to port 3000 if not specified 9 | export PORT="${PORT:-3000}" 10 | 11 | # Let the debug gem allow remote connections, 12 | # but avoid loading until `debugger` is called 13 | export RUBY_DEBUG_OPEN="true" 14 | export RUBY_DEBUG_LAZY="true" 15 | 16 | exec foreman start -f Procfile.dev "$@" 17 | -------------------------------------------------------------------------------- /bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Enable jemalloc for reduced memory usage and latency. 4 | if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then 5 | export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)" 6 | fi 7 | 8 | # If running the rails server then create or migrate existing database 9 | if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then 10 | ./bin/rails db:prepare 11 | fi 12 | 13 | exec "${@}" 14 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | jFMp3jwQAb2RNQ6M2Muoinhr8eHsxSucHxgMXlNIsjK5LrM0vjDVDa2S9A5Ae+M7LjjkJm+0V/avsnRqqT/0ZzxNwDl+Kx8mZTbosa2poT6kuoAMF48WljbN9691mc1fbGNJ5bo1zNd2CIXJCcvqbmdNb2NV6jCOis6SO+BlHOTjSd6O8jePeFmjM/NnHK5FWmkB21/eVou5u1IQ4aK6YVLrYhGw+eZSwjs7/1TVbwtvKbr8yJ8T1ShCOs5xtDdMu0A9/TMH21UEjdtqANTK4w1yKF+jYcm+U4W1ulIGJS7fTs4UBfH/qQT/VP/1GYkPJSAzGIfEYScZbGSKK0DYin8rGbcLOo9iwcwYQCP1E9SLRoNEWqX0s8M5qHjsTccaK2J7lHOi+ap80HT+ZM/rFfd3/ILL--JUlJiNJxAWGFzvp1--eoX68+lro63rNMIoDnc78w== -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | screen_name: one 5 | password_digest: <%= BCrypt::Password.create("secret") %> 6 | about: I'm user one 7 | last_seen_at: 2024-09-04 19:14:18 8 | posts_count: 1 9 | 10 | two: 11 | screen_name: two 12 | password_digest: <%= BCrypt::Password.create("secret") %> 13 | about: I'm user two 14 | last_seen_at: 2024-09-04 19:14:18 15 | posts_count: 1 16 | -------------------------------------------------------------------------------- /test/fixtures/sessions.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | user: one 5 | user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1" 6 | ip_address: "69.89.31.226" 7 | 8 | two: 9 | user: two 10 | user_agent: "Mozilla/5.0 (platform; rv:geckoversion) Gecko/geckotrail Firefox/firefoxversion" 11 | ip_address: "2002:4559:1FE2::4559:1FE2" -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | test "screen_name cannot be nil" do 5 | user = User.new(screen_name: nil) 6 | user.valid? 7 | assert_includes user.errors[:screen_name], "can't be blank" 8 | end 9 | 10 | test "screen_name must be unique" do 11 | one = users(:one) 12 | user = User.new(screen_name: one.screen_name) 13 | user.valid? 14 | assert_includes user.errors[:screen_name], "has already been taken" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Euruko2024", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "Euruko2024.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w[ admin.js admin.css ] 13 | -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | module.exports = { 4 | content: [ 5 | './public/*.html', 6 | './app/helpers/**/*.rb', 7 | './app/javascript/**/*.js', 8 | './app/views/**/*.{erb,haml,html,slim}' 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Inter var', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | }, 17 | plugins: [ 18 | require('@tailwindcss/forms'), 19 | require('@tailwindcss/typography'), 20 | require('@tailwindcss/container-queries'), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /app/views/users/_user.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | Screen name: 4 | <%= user.screen_name %> 5 |

6 | 7 |

8 | About: 9 | <%= user.about %> 10 |

11 | 12 |

13 | Last seen: 14 | 15 | <%= time_tag user.last_seen_at, time_ago_in_words(user.last_seen_at) %> ago 16 | 17 |

18 | 19 |

20 | Posts count: 21 | <%= user.posts_count %> 22 |

23 | 24 |
25 | -------------------------------------------------------------------------------- /app/views/posts/edit.html.erb: -------------------------------------------------------------------------------- 1 |

2 | Editing post 3 |

4 | <%= link_to "← Back to posts".html_safe, posts_path, class: "absolute -top-4 right-4 py-px px-2 -mx-2 border-2 border-transparent hover:border-black focus:outline-none focus:ring focus:ring-offset-0 focus:ring-blue-500" %> 5 | 6 | <%= render "form", post: @post %> 7 | 8 |
9 | <%= link_to "Show this post", @post, class: "inline-block border border-transparent text-black bg-white py-1 px-2 cursor-pointer hover:border-black focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 10 |
-------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/models/comment_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommentTest < ActiveSupport::TestCase 4 | test "belongs to post" do 5 | comment = Comment.new(post: nil) 6 | comment.valid? 7 | assert_includes comment.errors[:post], "must exist" 8 | end 9 | 10 | test "belongs to user" do 11 | comment = Comment.new(user: nil) 12 | comment.valid? 13 | assert_includes comment.errors[:user], "must exist" 14 | end 15 | 16 | test "body cannot be nil" do 17 | comment = Comment.new(body: nil) 18 | comment.valid? 19 | assert_includes comment.errors[:body], "can't be blank" 20 | end 21 | 22 | test "body must be more than 5 characters" do 23 | comment = Comment.new(body: "1234") 24 | comment.valid? 25 | assert_includes comment.errors[:body], "is too short (minimum is 5 characters)" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/models/post_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PostTest < ActiveSupport::TestCase 4 | test "belongs to user" do 5 | post = Post.new(user: nil) 6 | post.valid? 7 | assert_includes post.errors[:user], "must exist" 8 | end 9 | 10 | test "title cannot be nil" do 11 | post = Post.new(title: nil) 12 | post.valid? 13 | assert_includes post.errors[:title], "can't be blank" 14 | end 15 | 16 | test "title must be unique" do 17 | one = posts(:one) 18 | post = Post.new(title: one.title) 19 | post.valid? 20 | assert_includes post.errors[:title], "has already been taken" 21 | end 22 | 23 | test "title must be more than 5 characters" do 24 | post = Post.new(title: "1234") 25 | post.valid? 26 | assert_includes post.errors[:title], "is too short (minimum is 5 characters)" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/views/comments/edit.html.erb: -------------------------------------------------------------------------------- 1 |

2 | Editing comment 3 |

4 | <%= link_to "← Back to post".html_safe, @comment.post, class: "absolute -top-4 right-4 py-px px-2 -mx-2 border-2 border-transparent hover:border-black focus:outline-none focus:ring focus:ring-offset-0 focus:ring-blue-500" %> 5 | 6 | 7 | on <%= link_to @comment.post.title, @comment.post, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 8 | · 9 | 10 | <%= time_tag @comment.created_at, time_ago_in_words(@comment.created_at) %> ago 11 | 12 | 13 | 14 |
15 |
16 | 17 | <%= render "form", comment: @comment %> -------------------------------------------------------------------------------- /app/views/posts/_post.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if controller_name == "posts" && action_name == "show" %> 3 | <%= post.title %> 4 | <% else %> 5 | <%= link_to post.title, post, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 6 | <% end %> 7 |
8 | 9 | 10 | <% if controller_name == "posts" %> 11 | by <%= link_to (post.user == Current.user ? "you" : post.user.screen_name), user_path(post.user), class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 12 | · 13 | <% end %> 14 | 15 | <%= time_tag post.created_at, time_ago_in_words(post.created_at) %> ago 16 | 17 | · 18 | <%= pluralize post.comments_count, "comment" %> 19 | 20 | -------------------------------------------------------------------------------- /app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /app/views/sessions/_session.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 |
    4 |
    5 | <%= session.browser.platform.id.to_s.capitalize %> 6 | <%= session.browser.id.to_s.capitalize %> 7 | <%= session.browser.full_version %> 8 | <% if session == Current.session %> 9 | active 10 | <% end %> 11 |
    12 |
    13 | <%= time_tag session.created_at, session.created_at.to_formatted_s(:long) %> 14 |
    15 |
    16 | 17 | <%= button_to "Revoke", session, method: :delete, class: "border border-red-500 text-red-500 bg-white py-1 px-2 cursor-pointer hover:bg-red-500 hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 18 |
    19 |
  • -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. 2 | 3 | # Ignore git directory. 4 | /.git/ 5 | /.gitignore 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files (except templates). 11 | /.env* 12 | !/.env*.erb 13 | 14 | # Ignore all default key files. 15 | /config/master.key 16 | /config/credentials/*.key 17 | 18 | # Ignore all logfiles and tempfiles. 19 | /log/* 20 | /tmp/* 21 | !/log/.keep 22 | !/tmp/.keep 23 | 24 | # Ignore pidfiles, but keep the directory. 25 | /tmp/pids/* 26 | !/tmp/pids/.keep 27 | 28 | # Ignore storage (uploaded files in development and any SQLite databases). 29 | /storage/* 30 | !/storage/.keep 31 | /tmp/storage/* 32 | !/tmp/storage/.keep 33 | 34 | # Ignore assets. 35 | /node_modules/ 36 | /app/assets/builds/* 37 | !/app/assets/builds/.keep 38 | /public/assets 39 | 40 | # Ignore CI service files. 41 | /.github 42 | 43 | # Ignore development files 44 | /.devcontainer 45 | 46 | # Ignore Docker-related files 47 | /.dockerignore 48 | /Dockerfile* 49 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= "test" 2 | require_relative "../config/environment" 3 | require "rails/test_help" 4 | 5 | # Minimize BCrypt's cost factor to speed up tests. 6 | BCrypt::Engine.cost = BCrypt::Engine::MIN_COST 7 | 8 | module ActiveSupport 9 | class TestCase 10 | # Run tests in parallel with specified workers 11 | parallelize(workers: :number_of_processors) 12 | 13 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 14 | fixtures :all 15 | 16 | # Add more helper methods to be used by all tests here... 17 | def authenticate(user:) 18 | session = user.sessions.create!(user_agent: "TEST", ip_address: "1234567890") 19 | Current.session = session 20 | cookie_jar = ActionDispatch::Request.new(Rails.application.env_config.deep_dup).cookie_jar 21 | cookie_jar.signed.permanent[Session::COOKIE_KEY] = { 22 | value: session.signed_id, 23 | httponly: true, 24 | secure: false 25 | } 26 | cookies[Session::COOKIE_KEY] = cookie_jar[Session::COOKIE_KEY] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Euruko2024 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 7.2 13 | 14 | # Please, add to the `ignore` list any other `lib` subdirectories that do 15 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 16 | # Common ones are `templates`, `generators`, or `middleware`, for example. 17 | config.autoload_lib(ignore: %w[assets tasks]) 18 | 19 | # Configuration for the application, engines, and railties goes here. 20 | # 21 | # These settings can be overridden in specific environments using the files 22 | # in config/environments, which are processed later. 23 | # 24 | # config.time_zone = "Central Time (US & Canada)" 25 | # config.eager_load_paths << Rails.root.join("extras") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | 24 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | database: storage/production.sqlite3 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # Temporary files generated by your text editor or operating system 4 | # belong in git's global ignore instead: 5 | # `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files (except templates). 11 | /.env* 12 | !/.env*.erb 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore pidfiles, but keep the directory. 21 | /tmp/pids/* 22 | !/tmp/pids/ 23 | !/tmp/pids/.keep 24 | 25 | # Ignore storage (uploaded files in development and any SQLite databases). 26 | /storage/* 27 | !/storage/.keep 28 | /tmp/storage/* 29 | !/tmp/storage/ 30 | !/tmp/storage/.keep 31 | !/app/assets/builds/.keep 32 | 33 | # Don't ignore precompiled assets. 34 | !/public/assets 35 | !/app/assets/builds/* 36 | 37 | # Don't ignore master key for decrypting credentials and more. 38 | !/config/master.key 39 | 40 | # Don't ignore the production database file 41 | !/storage/production.sqlite3 42 | .DS_Store 43 | -------------------------------------------------------------------------------- /app/views/comments/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(model: [@post, comment]) do |form| %> 2 | <% if comment.errors.any? %> 3 | 13 | <% end %> 14 | 15 |
    16 | <%= form.label :body, class: "sr-only" %> 17 | <%= form.text_area :body, rows: 3, placeholder: "What do you think?", class: "placeholder:italic" %> 18 |
    19 | 20 |
    21 | <%= form.submit class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 22 |
    23 | <% end %> -------------------------------------------------------------------------------- /public/assets/application-9e3778bbe94c3025445599d20f7571b4959afbbb53a2a451470e40b7ce396306.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | *, ::after, ::before { 7 | background-color: inherit; 8 | font-family: inherit; 9 | color: inherit; 10 | overflow-wrap: break-word; 11 | margin: 0; 12 | padding: 0; 13 | } 14 | } 15 | /* 16 | * This is a manifest file that'll be compiled into application.css, which will include all the files 17 | * listed below. 18 | * 19 | * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's 20 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 21 | * 22 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 23 | * compiled file so the styles you add here take precedence over styles defined in any other CSS 24 | * files in this directory. Styles in this file should be added after the last require_* statement. 25 | * It is generally better to create a new file per style scope. 26 | * 27 | 28 | 29 | */ 30 | -------------------------------------------------------------------------------- /public/assets/stimulus-importmap-autoloader-db2076c783bf2dbee1226e2add52fef290b5d31b5bcd1edd999ac8a6dd31c44a.js: -------------------------------------------------------------------------------- 1 | // FIXME: es-module-shim won't shim the dynamic import without this explicit import 2 | import "@hotwired/stimulus" 3 | 4 | export function registerControllersFrom(under, application) { 5 | const paths = Object.keys(parseImportmapJson()) 6 | .filter(path => path.match(new RegExp(`^${under}/.*_controller$`))) 7 | 8 | paths.forEach(path => registerControllerFromPath(path, under, application)) 9 | } 10 | 11 | export function parseImportmapJson() { 12 | return JSON.parse(document.querySelector("script[type=importmap]").text).imports 13 | } 14 | 15 | function registerControllerFromPath(path, under, application) { 16 | const name = path 17 | .replace(new RegExp(`^${under}/`), "") 18 | .replace("_controller", "") 19 | .replace(/\//g, "--") 20 | .replace(/_/g, "-") 21 | 22 | import(path) 23 | .then(module => application.register(name, module.default)) 24 | .catch(error => console.error(`Failed to register controller: ${name} (${path})`, error)) 25 | } 26 | 27 | console.warn("stimulus-importmap-autoload.js has been deprecated in favor of stimulus-loading.js"); 28 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /test/system/posts_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | class PostsTest < ApplicationSystemTestCase 4 | setup do 5 | @post = posts(:one) 6 | end 7 | 8 | test "visiting the index" do 9 | visit posts_url 10 | assert_selector "h1", text: "Posts" 11 | end 12 | 13 | test "should create post" do 14 | visit posts_url 15 | click_on "New post" 16 | 17 | fill_in "Content", with: @post.content 18 | fill_in "Title", with: @post.title 19 | fill_in "User", with: @post.user_id 20 | click_on "Create Post" 21 | 22 | assert_text "Post was successfully created" 23 | click_on "Back" 24 | end 25 | 26 | test "should update Post" do 27 | visit post_url(@post) 28 | click_on "Edit this post", match: :first 29 | 30 | fill_in "Content", with: @post.content 31 | fill_in "Title", with: @post.title 32 | fill_in "User", with: @post.user_id 33 | click_on "Update Post" 34 | 35 | assert_text "Post was successfully updated" 36 | click_on "Back" 37 | end 38 | 39 | test "should destroy Post" do 40 | visit post_url(@post) 41 | click_on "Destroy this post", match: :first 42 | 43 | assert_text "Post was successfully destroyed" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | APP_NAME = "euruko-2024" 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | 34 | # puts "\n== Configuring puma-dev ==" 35 | # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" 36 | # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" 37 | end 38 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/system/comments_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | class CommentsTest < ApplicationSystemTestCase 4 | setup do 5 | @comment = comments(:one) 6 | end 7 | 8 | test "visiting the index" do 9 | visit comments_url 10 | assert_selector "h1", text: "Comments" 11 | end 12 | 13 | test "should create comment" do 14 | visit comments_url 15 | click_on "New comment" 16 | 17 | fill_in "Body", with: @comment.body 18 | fill_in "Post", with: @comment.post_id 19 | fill_in "User", with: @comment.user_id 20 | click_on "Create Comment" 21 | 22 | assert_text "Comment was successfully created" 23 | click_on "Back" 24 | end 25 | 26 | test "should update Comment" do 27 | visit comment_url(@comment) 28 | click_on "Edit this comment", match: :first 29 | 30 | fill_in "Body", with: @comment.body 31 | fill_in "Post", with: @comment.post_id 32 | fill_in "User", with: @comment.user_id 33 | click_on "Update Comment" 34 | 35 | assert_text "Comment was successfully updated" 36 | click_on "Back" 37 | end 38 | 39 | test "should destroy Comment" do 40 | visit comment_url(@comment) 41 | click_on "Destroy this comment", match: :first 42 | 43 | assert_text "Comment was successfully destroyed" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/system/sessions_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | class SessionsTest < ApplicationSystemTestCase 4 | setup do 5 | @session = sessions(:one) 6 | end 7 | 8 | test "visiting the index" do 9 | visit sessions_url 10 | assert_selector "h1", text: "Sessions" 11 | end 12 | 13 | test "should create session" do 14 | visit sessions_url 15 | click_on "New session" 16 | 17 | fill_in "Ip address", with: @session.ip_address 18 | fill_in "User agent", with: @session.user_agent 19 | fill_in "User", with: @session.user_id 20 | click_on "Create Session" 21 | 22 | assert_text "Session was successfully created" 23 | click_on "Back" 24 | end 25 | 26 | test "should update Session" do 27 | visit session_url(@session) 28 | click_on "Edit this session", match: :first 29 | 30 | fill_in "Ip address", with: @session.ip_address 31 | fill_in "User agent", with: @session.user_agent 32 | fill_in "User", with: @session.user_id 33 | click_on "Update Session" 34 | 35 | assert_text "Session was successfully updated" 36 | click_on "Back" 37 | end 38 | 39 | test "should destroy Session" do 40 | visit session_url(@session) 41 | click_on "Destroy this session", match: :first 42 | 43 | assert_text "Session was successfully destroyed" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | resources :users, only: %i[ show new create ] 4 | resources :sessions, only: %i[ new create ] 5 | 6 | constraints(AuthenticatedConstraint.new) do 7 | resources :users, only: %i[ edit update destroy ] 8 | resources :sessions, only: %i[ destroy ] 9 | resources :posts, only: %i[ new create edit update destroy ] do 10 | resources :comments, only: %i[ create edit update destroy ], shallow: true 11 | end 12 | end 13 | 14 | resources :posts, only: %i[ index show ] 15 | 16 | namespace :benchmarking do 17 | post "post_create" 18 | post "comment_create" 19 | post "post_destroy" 20 | post "comment_destroy" 21 | post "post_show" 22 | post "posts_index" 23 | post "user_show" 24 | end 25 | 26 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 27 | # Can be used by load balancers and uptime monitors to verify that the app is live. 28 | get "up" => "rails/health#show", as: :rails_health_check 29 | 30 | # Render dynamic PWA files from app/views/pwa/* 31 | get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 32 | get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 33 | 34 | # Defines the root path route ("/") 35 | root "posts#index" 36 | end 37 | -------------------------------------------------------------------------------- /app/views/posts/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(model: post, class: "contents") do |form| %> 2 | <% if post.errors.any? %> 3 | 13 | <% end %> 14 | 15 |
    16 | <%= form.label :title %> 17 | <%= form.text_field :title %> 18 |
    19 | 20 |
    21 | <%= form.label :content %> 22 | <%= form.text_area :content, rows: 10 %> 23 |
    24 | 25 |
    26 | <%= form.submit class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 27 | <%= form.submit "Generate Lorem", class: "inline-block border border-transparent text-black bg-white py-1 px-2 cursor-pointer hover:border-black focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500", formaction: new_post_path, formmethod: :get, name: :lorem %> 28 |
    29 | <% end %> -------------------------------------------------------------------------------- /test/system/users_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | class UsersTest < ApplicationSystemTestCase 4 | setup do 5 | @user = users(:one) 6 | end 7 | 8 | test "visiting the index" do 9 | visit users_url 10 | assert_selector "h1", text: "Users" 11 | end 12 | 13 | test "should create user" do 14 | visit users_url 15 | click_on "New user" 16 | 17 | fill_in "About", with: @user.about 18 | fill_in "Last seen at", with: @user.last_seen_at 19 | fill_in "Password", with: "secret" 20 | fill_in "Password confirmation", with: "secret" 21 | fill_in "Screen name", with: @user.screen_name 22 | click_on "Create User" 23 | 24 | assert_text "User was successfully created" 25 | click_on "Back" 26 | end 27 | 28 | test "should update User" do 29 | visit user_url(@user) 30 | click_on "Edit this user", match: :first 31 | 32 | fill_in "About", with: @user.about 33 | fill_in "Last seen at", with: @user.last_seen_at.to_s 34 | fill_in "Password", with: "secret" 35 | fill_in "Password confirmation", with: "secret" 36 | fill_in "Screen name", with: @user.screen_name 37 | click_on "Update User" 38 | 39 | assert_text "User was successfully updated" 40 | click_on "Back" 41 | end 42 | 43 | test "should destroy User" do 44 | visit user_url(@user) 45 | click_on "Destroy this user", match: :first 46 | 47 | assert_text "User was successfully destroyed" 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | # ----- unauthenticated actions ----- 3 | allow_unauthenticated_access only: %i[ new create ] 4 | 5 | # GET /sessions/new 6 | def new 7 | @session = Session.new 8 | end 9 | 10 | # POST /sessions 11 | def create 12 | user = User.authenticate_by( 13 | screen_name: session_params.dig(:user, :screen_name), 14 | password: session_params.dig(:user, :password) 15 | ) 16 | 17 | if user 18 | start_new_session_for user 19 | redirect_to after_authentication_url, notice: "You have been signed in." 20 | else 21 | redirect_to new_session_path(screen_name_hint: session_params.dig(:user, :screen_name)), alert: "Try another email address or password." 22 | end 23 | end 24 | 25 | # ----- authenticated actions ----- 26 | before_action :set_and_authorize_session, only: %i[ destroy ] 27 | 28 | # DELETE /sessions/1 29 | def destroy 30 | @session.destroy! 31 | redirect_to @session.user, notice: "That session has been successfully logged out." 32 | end 33 | 34 | private 35 | # Use callbacks to share common setup or constraints between actions. 36 | def set_and_authorize_session 37 | @session = Current.user.sessions.find(params[:id]) 38 | end 39 | 40 | # Only allow a list of trusted parameters through. 41 | def session_params 42 | params.require(:session).permit(user: [ :screen_name, :password ]) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/views/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | Sign in 3 |

    4 | 5 | <%= form_with(model: @session, class: "contents") do |form| %> 6 | <% if @session.errors.any? %> 7 | 17 | <% end %> 18 | 19 | <%= form.fields_for :user do |fields| %> 20 |
    21 | <%= fields.label :screen_name %> 22 | <%= fields.text_field :screen_name, value: params[:screen_name_hint], required: true %> 23 |
    24 | 25 |
    26 | <%= fields.label :password %> 27 | <%= fields.password_field :password, required: true, autocomplete: "current-password" %> 28 |
    29 | <% end %> 30 | 31 |
    32 | <%= form.submit class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 33 |
    34 | <% end %> -------------------------------------------------------------------------------- /app/views/users/edit.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | Editing profile 3 |

    4 | 5 | <%= form_with(model: @user, class: "contents") do |form| %> 6 | <% if @user.errors.any? %> 7 | 17 | <% end %> 18 | 19 |
    20 | <%= form.label :screen_name %> 21 | <%= form.text_field :screen_name %> 22 |
    23 | 24 |
    25 | <%= form.label :about %> 26 | <%= form.text_area :about %> 27 |
    28 | 29 |
    30 | <%= form.submit class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 31 |
    32 | <% end %> 33 | 34 |
    35 | <%= link_to "Show this user", @user, class: "inline-block border border-transparent text-black bg-white py-1 px-2 cursor-pointer hover:border-black focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 36 |
    -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | # ----- unauthenticated actions ----- 3 | allow_unauthenticated_access only: %i[ show new create ] 4 | 5 | # GET /users/1 6 | def show 7 | @user = User.find(params[:id]) 8 | end 9 | 10 | # GET /users/new 11 | def new 12 | @user = User.new 13 | end 14 | 15 | # POST /users 16 | def create 17 | @user = User.new(user_params) 18 | 19 | if @user.save 20 | start_new_session_for @user 21 | redirect_to @user, notice: "Welcome! You have signed up successfully." 22 | else 23 | render :new, status: :unprocessable_entity 24 | end 25 | end 26 | 27 | # ----- authenticated actions ----- 28 | before_action :set_current_user, only: %i[ edit update destroy ] 29 | 30 | # GET /users/1/edit 31 | def edit 32 | end 33 | 34 | # PATCH/PUT /users/1 35 | def update 36 | if @user.update(user_params) 37 | redirect_to @user, notice: "Profile was successfully updated." 38 | else 39 | render :edit, status: :unprocessable_entity 40 | end 41 | end 42 | 43 | # DELETE /users/1 44 | def destroy 45 | @user.destroy! 46 | redirect_to users_url, notice: "Profile was successfully deleted." 47 | end 48 | 49 | private 50 | # Use callbacks to share common setup or constraints between actions. 51 | def set_current_user 52 | @user = Current.user 53 | end 54 | 55 | # Only allow a list of trusted parameters through. 56 | def user_params 57 | params.require(:user).permit(:screen_name, :password, :password_confirmation, :about) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | helpers: 32 | submit: 33 | session: 34 | create: "Sign in" 35 | user: 36 | create: "Create account" 37 | update: "Update profile" 38 | comment: 39 | create: "Post comment" 40 | errors: 41 | messages: 42 | not_saved: 43 | one: "1 error prohibited this %{resource} from being saved:" 44 | other: "%{count} errors prohibited this %{resource} from being saved:" 45 | activerecord: 46 | errors: 47 | models: 48 | session: 49 | attributes: 50 | user: 51 | required: "email or password is incorrect" -------------------------------------------------------------------------------- /app/views/posts/index.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | Recent posts 3 |

    4 | <% if Current.user %> 5 | <%= link_to "+ New post".html_safe, new_post_path, class: "absolute -top-4 right-4 py-px px-2 -mx-2 border-2 border-transparent hover:border-black focus:outline-none focus:ring focus:ring-offset-0 focus:ring-blue-500" %> 6 | <% end %> 7 | 8 |
    9 | <% if @posts.any? %> 10 | 11 | 12 | <% @posts.each do |post| %> 13 | 14 | 19 | 25 | 26 | <% end %> 27 | 28 |
    15 | <% cache post do %> 16 | <%= render post %> 17 | <% end %> 18 | 20 | <% if post.user == Current.user %> 21 | <%= link_to "Edit", edit_post_path(post), class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 22 | <%= button_to "Destroy", post, method: :delete, class: "border border-red-500 text-red-500 bg-white py-1 px-2 cursor-pointer hover:bg-red-500 hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 23 | <% end %> 24 |
    29 | <% else %> 30 |
    31 | No posts yet… Want to be the first? 32 |
    33 | <% end %> 34 |
    -------------------------------------------------------------------------------- /app/views/users/new.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | Sign up 3 |

    4 | 5 | <%= form_with(model: @user, url: "/users", class: "contents") do |form| %> 6 | <% if @user.errors.any? %> 7 | 17 | <% end %> 18 | 19 |
    20 | <%= form.label :screen_name %> 21 | <%= form.text_field :screen_name %> 22 |
    23 | 24 |
    25 | <%= form.label :password %> 26 | <%= form.password_field :password, required: true, autocomplete: "new-password", aria: { describedby: form.field_id(:password, :help) } %> 27 | 8 characters minimum. 28 |
    29 | 30 |
    31 | <%= form.label :password_confirmation %> 32 | <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %> 33 |
    34 | 35 |
    36 | <%= form.submit class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 37 |
    38 | <% end %> 39 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # The ideal number of threads per worker depends both on how much time the 9 | # application spends waiting for IO operations and on how much you wish to 10 | # to prioritize throughput over latency. 11 | # 12 | # As a rule of thumb, increasing the number of threads will increase how much 13 | # traffic a given process can handle (throughput), but due to CRuby's 14 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 15 | # response time (latency) of the application. 16 | # 17 | # The default is set to 3 threads as it's deemed a decent compromise between 18 | # throughput and latency for the average Rails application. 19 | # 20 | # Any libraries that use a connection pool or another resource pool should 21 | # be configured to provide at least as many connections as the number of 22 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 23 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 24 | threads threads_count, threads_count 25 | 26 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 27 | port ENV.fetch("PORT", 3000) 28 | 29 | # Allow puma to be restarted by `bin/rails restart` command. 30 | plugin :tmp_restart 31 | 32 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 33 | # In other environments, only set the PID file if requested. 34 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 35 | -------------------------------------------------------------------------------- /app/controllers/concerns/authentication.rb: -------------------------------------------------------------------------------- 1 | module Authentication 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action :require_authentication 6 | helper_method :authenticated? 7 | end 8 | 9 | class_methods do 10 | def allow_unauthenticated_access(**options) 11 | skip_before_action :require_authentication, **options 12 | before_action :resume_session, **options 13 | end 14 | end 15 | 16 | private 17 | def authenticated? 18 | Current.session.present? 19 | end 20 | 21 | def require_authentication 22 | resume_session || request_authentication 23 | end 24 | 25 | def resume_session 26 | if session = find_session_by_cookie 27 | set_current_session session 28 | end 29 | end 30 | 31 | def find_session_by_cookie 32 | if token = cookies.signed[Session::COOKIE_KEY] 33 | Session.find_signed(token) 34 | end 35 | end 36 | 37 | def request_authentication 38 | session[:return_to_after_authenticating] = request.url 39 | redirect_to new_session_url 40 | end 41 | 42 | def after_authentication_url 43 | session.delete(:return_to_after_authenticating) || user_path(Current.user) 44 | end 45 | 46 | def start_new_session_for(user) 47 | user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| 48 | set_current_session session 49 | end 50 | end 51 | 52 | def set_current_session(session) 53 | Current.session = session 54 | cookies.signed.permanent[Session::COOKIE_KEY] = { value: session.signed_id, httponly: true, same_site: :lax } 55 | end 56 | 57 | def terminate_session 58 | Current.session.destroy 59 | cookies.delete(Session::COOKIE_KEY) 60 | end 61 | end -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should ensure the existence of records required to run the application in every environment (production, 2 | # development, test). The code here should be idempotent so that it can be executed at any point in every environment. 3 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). 4 | 5 | User.insert_all( 6 | 100.times.map do |n| 7 | { screen_name: [ Faker::Lorem.word, n.to_s.rjust(3, '0') ].join("_"), 8 | password_digest: BCrypt::Password.create("password", cost: 1), 9 | about: Faker::Lorem.paragraph, 10 | created_at: Faker::Time.between(from: 1.week.ago, to: DateTime.now) } 11 | end 12 | ) 13 | user_ids_and_created_ats = User.pluck(:id, :created_at) 14 | 1_000.times do |n| 15 | user_id, created_at = user_ids_and_created_ats.sample 16 | results = Post.insert({ 17 | user_id: user_id, 18 | title: Faker::Lorem.words(number: rand(2..5)).join(" "), 19 | content: rand(5..10).times.collect { rand(5..10).times.collect { Faker::Lorem.sentence(word_count: rand(5..10)) }.join(" ") }.join("\n\n"), 20 | created_at: Faker::Time.between(from: created_at, to: DateTime.now) 21 | }, returning: [ :id, :created_at ]) 22 | 23 | next unless results.present? 24 | 25 | result = results.to_a[0] 26 | rand(5..15).times do |nn| 27 | timestamp = Faker::Time.between(from: result["created_at"], to: DateTime.now) 28 | Comment.insert({ 29 | post_id: result["id"], 30 | user_id: user_ids_and_created_ats.sample.first, 31 | body: Faker::Lorem.paragraph, 32 | created_at: timestamp, 33 | updated_at: timestamp 34 | }) 35 | end 36 | end 37 | 38 | Post.pluck(:id).each { |post_id| Post.reset_counters(post_id, :comments) } 39 | User.pluck(:id).each { |user_id| User.reset_counters(user_id, :posts) } 40 | -------------------------------------------------------------------------------- /app/controllers/comments_controller.rb: -------------------------------------------------------------------------------- 1 | class CommentsController < ApplicationController 2 | include ActionView::RecordIdentifier 3 | 4 | # ----- authenticated actions ----- 5 | before_action :set_post, only: %i[ create ] 6 | before_action :set_and_authorize_comment, only: %i[ edit update destroy ] 7 | 8 | # GET /comments/1/edit 9 | def edit 10 | end 11 | 12 | # POST /posts/:post_id/comments 13 | def create 14 | @comment = @post.comments.new(comment_params) 15 | 16 | if @comment.save 17 | redirect_to post_path(@comment.post, anchor: dom_id(@comment)), notice: "Comment was successfully created." 18 | else 19 | @post = @comment.post 20 | render "posts/show", status: :unprocessable_entity 21 | end 22 | end 23 | 24 | # PATCH/PUT /comments/1 25 | def update 26 | if @comment.update(comment_params) 27 | redirect_to post_path(@comment.post, anchor: dom_id(@comment)), notice: "Comment was successfully updated.", status: :see_other 28 | else 29 | render :edit, status: :unprocessable_entity 30 | end 31 | end 32 | 33 | # DELETE /comments/1 34 | def destroy 35 | @comment.destroy! 36 | redirect_to post_path(@comment.post), notice: "Comment was successfully destroyed.", status: :see_other 37 | end 38 | 39 | private 40 | # Use callbacks to share common setup or constraints between actions. 41 | def set_post 42 | @post = Post.find(params[:post_id]) 43 | end 44 | 45 | def set_and_authorize_comment 46 | @comment = Comment.find(params[:id]) 47 | raise ApplicationController::NotAuthorized, "not allowed to #{action_name} this comment" unless @comment.user == Current.user 48 | end 49 | 50 | # Only allow a list of trusted parameters through. 51 | def comment_params 52 | params.require(:comment).permit(:body).merge(user_id: Current.user.id) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/fixtures/posts.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | user: one 5 | title: Voluptatem id sequi cupiditate dignissimos 6 | content: Curabitur cursus semper condimentum. Mauris quis tincidunt magna. Nulla suscipit, ex at rhoncus consectetur, eros lectus auctor arcu, non consequat arcu eros quis tellus. Nunc posuere erat vel nisl eleifend, ac faucibus ipsum molestie. Integer venenatis, velit quis lacinia vestibulum, lacus magna sagittis diam, vel dignissim neque purus quis eros. Ut eu mi eu risus placerat porttitor. Aenean vel lacus eros. Duis placerat gravida arcu elementum viverra. Sed dapibus tellus sed eros efficitur, vitae semper dui eleifend. Phasellus aliquet euismod enim vitae rutrum. Nullam pharetra rutrum eros ac hendrerit. Etiam vel sem eu dui viverra tincidunt sit amet eu libero. Integer vitae sapien ex. Proin luctus leo cursus feugiat euismod. Nulla a nibh vitae quam blandit elementum. 7 | comments_count: 0 8 | 9 | two: 10 | user: two 11 | title: Assumenda sed 12 | content: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras nibh neque, tempus non varius quis, imperdiet consectetur massa. Maecenas non faucibus eros, sed vulputate quam. Aenean quis ipsum ultrices, commodo dui sit amet, interdum purus. Suspendisse sit amet condimentum massa, at faucibus purus. Praesent quis ex et purus ultricies dictum. Phasellus eu tempus erat. Sed interdum, risus pulvinar consequat malesuada, felis libero tristique neque, at sollicitudin libero quam sit amet sem. Donec at libero aliquam arcu rhoncus dictum sit amet et leo. Vestibulum aliquam rutrum dolor, vitae cursus nisl facilisis eu. Curabitur ut mi rhoncus arcu tristique elementum ac mattis sem. Pellentesque id mattis augue, nec euismod nisl. Donec tristique id ex non gravida. Nulla eget suscipit lectus, non dignissim dui. 13 | comments_count: 0 14 | -------------------------------------------------------------------------------- /public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Your browser is not supported (406) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    Your browser is not supported.

    62 |

    Please upgrade your browser to continue.

    63 |
    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    We're sorry, but something went wrong.

    62 |
    63 |

    If you are the application owner check the logs for more information.

    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /app/views/comments/_comment.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 5 | <% if controller_name == "users" %> 6 | on <%= link_to comment.post.title, comment.post, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 7 | <% elsif controller_name == "posts" || controller_name == "comments" %> 8 | by <%= link_to comment.user.screen_name, comment.user, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 9 | <% end %> 10 | · 11 | 12 | <%= time_tag comment.created_at, time_ago_in_words(comment.created_at) %> ago 13 | 14 | <% if comment.created_at != comment.updated_at %> 15 | · 16 | edited 17 | <% end %> 18 | 19 | 20 |
    21 | <% if comment.user == Current.user %> 22 | <%= link_to "Edit", edit_comment_path(comment), class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 23 | <%= button_to "Destroy", comment, method: :delete, class: "border border-red-500 text-red-500 bg-white py-1 px-2 cursor-pointer hover:bg-red-500 hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 24 | <% end %> 25 |
    26 |
    27 |
    28 | 29 | <%= comment.body %> 30 |
    -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The change you wanted was rejected.

    62 |

    Maybe you tried to change something you didn't have access to.

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The page you were looking for doesn't exist.

    62 |

    You may have mistyped the address or the page may have moved.

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /public/assets/stimulus-autoloader-c584942b568ba74879da31c7c3d51366737bacaf6fbae659383c0a5653685693.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | const { controllerAttribute } = application.schema 5 | const registeredControllers = {} 6 | 7 | function autoloadControllersWithin(element) { 8 | queryControllerNamesWithin(element).forEach(loadController) 9 | } 10 | 11 | function queryControllerNamesWithin(element) { 12 | return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).map(extractControllerNamesFrom).flat() 13 | } 14 | 15 | function extractControllerNamesFrom(element) { 16 | return element.getAttribute(controllerAttribute).split(/\s+/).filter(content => content.length) 17 | } 18 | 19 | function loadController(name) { 20 | import(controllerFilename(name)) 21 | .then(module => registerController(name, module)) 22 | .catch(error => console.error(`Failed to autoload controller: ${name}`, error)) 23 | } 24 | 25 | function controllerFilename(name) { 26 | return `controllers/${name.replace(/--/g, "/").replace(/-/g, "_")}_controller` 27 | } 28 | 29 | function registerController(name, module) { 30 | if (name in registeredControllers) return 31 | 32 | application.register(name, module.default) 33 | registeredControllers[name] = true 34 | } 35 | 36 | 37 | new MutationObserver((mutationsList) => { 38 | for (const { attributeName, target, type } of mutationsList) { 39 | switch (type) { 40 | case "attributes": { 41 | if (attributeName == controllerAttribute && target.getAttribute(controllerAttribute)) { 42 | extractControllerNamesFrom(target).forEach(loadController) 43 | } 44 | } 45 | case "childList": { 46 | autoloadControllersWithin(target) 47 | } 48 | } 49 | } 50 | }).observe(document, { attributeFilter: [controllerAttribute], subtree: true, childList: true }) 51 | 52 | autoloadControllersWithin(document) 53 | 54 | console.warn("stimulus-autoload.js has been deprecated in favor of stimulus-loading.js"); 55 | -------------------------------------------------------------------------------- /app/controllers/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsController < ApplicationController 2 | # ----- unauthenticated actions ----- 3 | allow_unauthenticated_access only: %i[ index show ] 4 | 5 | # GET /posts 6 | def index 7 | @posts = Post.all.order(created_at: :desc).limit(50) 8 | end 9 | 10 | # GET /posts/1 11 | def show 12 | @post = Post.find(params[:id]) 13 | end 14 | 15 | # ----- authenticated actions ----- 16 | before_action :set_and_authorize_post, only: %i[ edit update destroy ] 17 | 18 | # GET /posts/new 19 | def new 20 | post_attributes = if params[:lorem] 21 | { 22 | title: Faker::Lorem.words(number: rand(2..5)).join(" "), 23 | content: rand(5..10).times.collect { rand(5..10).times.collect { Faker::Lorem.sentence(word_count: rand(5..10)) }.join(" ") }.join("\n\n") 24 | } 25 | else 26 | {} 27 | end 28 | @post = Current.user.posts.new(post_attributes) 29 | end 30 | 31 | # GET /posts/1/edit 32 | def edit 33 | end 34 | 35 | # POST /posts 36 | def create 37 | @post = Current.user.posts.new(post_params) 38 | 39 | if @post.save 40 | redirect_to @post, notice: "Post was successfully created." 41 | else 42 | render :new, status: :unprocessable_entity 43 | end 44 | end 45 | 46 | # PATCH/PUT /posts/1 47 | def update 48 | if @post.update(post_params) 49 | redirect_to @post, notice: "Post was successfully updated.", status: :see_other 50 | else 51 | render :edit, status: :unprocessable_entity 52 | end 53 | end 54 | 55 | # DELETE /posts/1 56 | def destroy 57 | @post.destroy! 58 | redirect_to posts_url, notice: "Post was successfully destroyed.", status: :see_other 59 | end 60 | 61 | private 62 | # Use callbacks to share common setup or constraints between actions. 63 | def set_and_authorize_post 64 | @post = Post.find(params[:id]) 65 | raise ApplicationController::NotAuthorized, "not allowed to #{action_name} this post" unless @post.user == Current.user 66 | end 67 | 68 | # Only allow a list of trusted parameters through. 69 | def post_params 70 | params.require(:post).permit(:title, :content) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /app/views/posts/show.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | <%= @post.title %> 3 |

    4 | <%= link_to "← Back to posts".html_safe, posts_path, class: "absolute -top-4 right-4 py-px px-2 -mx-2 border-2 border-transparent hover:border-black focus:outline-none focus:ring focus:ring-offset-0 focus:ring-blue-500" %> 5 | 6 | 7 | 8 | <% if controller_name == "posts" %> 9 | by <%= link_to (@post.user == Current.user ? "you" : @post.user.screen_name), user_path(@post.user), class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 10 | · 11 | <% end %> 12 | 13 | <%= time_tag @post.created_at, time_ago_in_words(@post.created_at) %> ago 14 | 15 | · 16 | <%= link_to pluralize(@post.comments_count, "comment"), url_for(anchor: :comments), class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 17 | 18 | 19 | 20 |
    21 | <%= simple_format @post.content %> 22 |
    23 | 24 | <% if @post.user == Current.user %> 25 |
    26 | 27 |
    28 | <%= link_to "Edit", edit_post_path(@post), class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 29 | <%= button_to "Destroy", @post, method: :delete, class: "border border-red-500 text-red-500 bg-white py-1 px-2 cursor-pointer hover:bg-red-500 hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 30 |
    31 | <% end %> 32 | 33 |
    34 |
    35 |
    36 | 37 | <% if Current.user.present? %> 38 | <%= render "comments/form", comment: @comment || @post.comments.new %> 39 | <% end %> 40 | 41 | <% if @post.comments.exists? %> 42 |
    43 |
    44 | <%= render @post.comments.order(created_at: :desc) %> 45 |
    46 | <% end %> -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 4 | gem "rails", "~> 7.2.1" 5 | # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] 6 | gem "sprockets-rails" 7 | # Use sqlite3 as the database for Active Record 8 | gem "sqlite3", ">= 1.4" 9 | # Use the Puma web server [https://github.com/puma/puma] 10 | gem "puma", ">= 5.0" 11 | # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] 12 | gem "importmap-rails" 13 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] 14 | gem "turbo-rails" 15 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] 16 | gem "stimulus-rails" 17 | # Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] 18 | gem "tailwindcss-rails" 19 | # Use Redis adapter to run Action Cable in production 20 | gem "redis", ">= 4.0.1" 21 | 22 | # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] 23 | # gem "kredis" 24 | 25 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] 26 | gem "bcrypt", "~> 3.1.7" 27 | 28 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 29 | gem "tzinfo-data", platforms: %i[ windows jruby ] 30 | 31 | # Reduces boot times through caching; required in config/boot.rb 32 | gem "bootsnap", require: false 33 | 34 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] 35 | # gem "image_processing", "~> 1.2" 36 | 37 | group :development, :test do 38 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 39 | gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" 40 | 41 | # Static analysis for security vulnerabilities [https://brakemanscanner.org/] 42 | gem "brakeman", require: false 43 | 44 | # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] 45 | gem "rubocop-rails-omakase", require: false 46 | end 47 | 48 | group :development do 49 | # Use console on exceptions pages [https://github.com/rails/web-console] 50 | gem "web-console" 51 | end 52 | 53 | group :test do 54 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] 55 | gem "capybara" 56 | gem "selenium-webdriver" 57 | end 58 | 59 | gem "browser", "~> 5.3" 60 | 61 | gem "faker", "~> 3.5" 62 | -------------------------------------------------------------------------------- /app/controllers/benchmarking_controller.rb: -------------------------------------------------------------------------------- 1 | class BenchmarkingController < ApplicationController 2 | skip_before_action :verify_authenticity_token 3 | allow_unauthenticated_access 4 | before_action :sign_in_random_user 5 | 6 | def post_create 7 | @post = Post.create!(user: @user, title: "Post #{request.uuid}", content: format(request:)) 8 | render "posts/show", status: :ok 9 | end 10 | 11 | def comment_create 12 | @post = Post.where("id >= ?", rand(Post.minimum(:id)..Post.maximum(:id))).limit(1).first 13 | comment = Comment.create!(user: @user, post: post, body: "Comment #{request.uuid}") 14 | render "posts/show", status: :ok 15 | end 16 | 17 | def post_destroy 18 | post = Post.where("id >= ?", rand(Post.minimum(:id)..Post.maximum(:id))).limit(1).first 19 | post.destroy! 20 | posts_index 21 | end 22 | 23 | def comment_destroy 24 | comment = Comment.where("id >= ?", rand(Comment.minimum(:id)..Comment.maximum(:id))).limit(1).first 25 | comment.destroy! 26 | @post = comment.post 27 | render "posts/show", status: :ok 28 | end 29 | 30 | def post_show 31 | @post = Post.where("id >= ?", rand(Post.minimum(:id)..Post.maximum(:id))).limit(1).first 32 | render "posts/show", status: :ok 33 | end 34 | 35 | def posts_index 36 | @posts = Post.where("id >= ?", rand(Post.minimum(:id)..Post.maximum(:id))).limit(100) 37 | render "posts/index", status: :ok 38 | end 39 | 40 | def user_show 41 | render "users/show", status: :ok 42 | end 43 | 44 | private 45 | 46 | def sign_in_random_user 47 | @user = User.where("id >= ?", rand(User.minimum(:id)..User.maximum(:id))).limit(1).first 48 | start_new_session_for @user 49 | end 50 | 51 | def format(request:) 52 | request.headers.to_h.slice( 53 | "GATEWAY_INTERFACE", 54 | "HTTP_ACCEPT", 55 | "HTTP_HOST", 56 | "HTTP_USER_AGENT", 57 | "HTTP_VERSION", 58 | "ORIGINAL_FULLPATH", 59 | "ORIGINAL_SCRIPT_NAME", 60 | "PATH_INFO", 61 | "QUERY_STRING", 62 | "REMOTE_ADDR", 63 | "REQUEST_METHOD", 64 | "REQUEST_PATH", 65 | "REQUEST_URI", 66 | "SCRIPT_NAME", 67 | "SERVER_NAME", 68 | "SERVER_PORT", 69 | "SERVER_PROTOCOL", 70 | "SERVER_SOFTWARE", 71 | "action_dispatch.request_id", 72 | "puma.request_body_wait", 73 | ).map { _1.join(": ") }.join("\n") 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: 4 | # docker build -t my-app . 5 | # docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY= my-app 6 | 7 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version 8 | ARG RUBY_VERSION=3.3.4 9 | FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base 10 | 11 | # Rails app lives here 12 | WORKDIR /rails 13 | 14 | # Install base packages 15 | RUN apt-get update -qq && \ 16 | apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ 17 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 18 | 19 | # Set production environment 20 | ENV RAILS_ENV="production" \ 21 | BUNDLE_DEPLOYMENT="1" \ 22 | BUNDLE_PATH="/usr/local/bundle" \ 23 | BUNDLE_WITHOUT="development" 24 | 25 | # Throw-away build stage to reduce size of final image 26 | FROM base AS build 27 | 28 | # Install packages needed to build gems 29 | RUN apt-get update -qq && \ 30 | apt-get install --no-install-recommends -y build-essential git pkg-config && \ 31 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 32 | 33 | # Install application gems 34 | COPY Gemfile Gemfile.lock ./ 35 | RUN bundle install && \ 36 | rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ 37 | bundle exec bootsnap precompile --gemfile 38 | 39 | # Copy application code 40 | COPY . . 41 | 42 | # Precompile bootsnap code for faster boot times 43 | RUN bundle exec bootsnap precompile app/ lib/ 44 | 45 | # Precompiling assets for production without requiring secret RAILS_MASTER_KEY 46 | RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile 47 | 48 | 49 | 50 | 51 | # Final stage for app image 52 | FROM base 53 | 54 | # Copy built artifacts: gems, application 55 | COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" 56 | COPY --from=build /rails /rails 57 | 58 | # Run and own only the runtime files as a non-root user for security 59 | RUN groupadd --system --gid 1000 rails && \ 60 | useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ 61 | chown -R rails:rails db log storage tmp 62 | USER 1000:1000 63 | 64 | # Entrypoint prepares the database. 65 | ENTRYPOINT ["/rails/bin/docker-entrypoint"] 66 | 67 | # Start the server by default, this can be overwritten at runtime 68 | EXPOSE 3000 69 | CMD ["./bin/rails", "server"] 70 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.2].define(version: 2024_09_04_195113) do 14 | create_table "comments", force: :cascade do |t| 15 | t.integer "post_id", null: false 16 | t.integer "user_id", null: false 17 | t.text "body" 18 | t.datetime "created_at", null: false 19 | t.datetime "updated_at", null: false 20 | t.index ["post_id"], name: "index_comments_on_post_id" 21 | t.index ["user_id"], name: "index_comments_on_user_id" 22 | end 23 | 24 | create_table "posts", force: :cascade do |t| 25 | t.integer "user_id", null: false 26 | t.string "title", null: false 27 | t.text "content" 28 | t.datetime "created_at", null: false 29 | t.datetime "updated_at", null: false 30 | t.integer "comments_count", default: 0 31 | t.index ["title"], name: "index_posts_on_title", unique: true 32 | t.index ["user_id"], name: "index_posts_on_user_id" 33 | end 34 | 35 | create_table "sessions", force: :cascade do |t| 36 | t.integer "user_id", null: false 37 | t.string "user_agent" 38 | t.string "ip_address" 39 | t.datetime "created_at", null: false 40 | t.datetime "updated_at", null: false 41 | t.index ["user_id"], name: "index_sessions_on_user_id" 42 | end 43 | 44 | create_table "users", force: :cascade do |t| 45 | t.string "screen_name", null: false 46 | t.string "password_digest", null: false 47 | t.text "about" 48 | t.datetime "last_seen_at" 49 | t.datetime "created_at", null: false 50 | t.datetime "updated_at", null: false 51 | t.integer "posts_count", default: 0 52 | t.index ["screen_name"], name: "index_users_on_screen_name", unique: true 53 | end 54 | 55 | add_foreign_key "comments", "posts" 56 | add_foreign_key "comments", "users" 57 | add_foreign_key "posts", "users" 58 | add_foreign_key "sessions", "users" 59 | end 60 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | scan_ruby: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: .ruby-version 20 | bundler-cache: true 21 | 22 | - name: Scan for common Rails security vulnerabilities using static analysis 23 | run: bin/brakeman --no-pager 24 | 25 | scan_js: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: .ruby-version 36 | bundler-cache: true 37 | 38 | - name: Scan for security vulnerabilities in JavaScript dependencies 39 | run: bin/importmap audit 40 | 41 | lint: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | 47 | - name: Set up Ruby 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: .ruby-version 51 | bundler-cache: true 52 | 53 | - name: Lint code for consistent style 54 | run: bin/rubocop -f github 55 | 56 | test: 57 | runs-on: ubuntu-latest 58 | 59 | # services: 60 | # redis: 61 | # image: redis 62 | # ports: 63 | # - 6379:6379 64 | # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 65 | steps: 66 | - name: Install packages 67 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips sqlite3 68 | 69 | - name: Checkout code 70 | uses: actions/checkout@v4 71 | 72 | - name: Set up Ruby 73 | uses: ruby/setup-ruby@v1 74 | with: 75 | ruby-version: .ruby-version 76 | bundler-cache: true 77 | 78 | - name: Run tests 79 | env: 80 | RAILS_ENV: test 81 | # REDIS_URL: redis://localhost:6379/0 82 | run: bin/rails db:test:prepare test test:system 83 | 84 | - name: Keep screenshots from failed system tests 85 | uses: actions/upload-artifact@v4 86 | if: failure() 87 | with: 88 | name: screenshots 89 | path: ${{ github.workspace }}/tmp/screenshots 90 | if-no-files-found: ignore 91 | -------------------------------------------------------------------------------- /test/controllers/posts_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PostsControllerTest < ActionDispatch::IntegrationTest 4 | class UnauthenticatedTest < PostsControllerTest 5 | setup do 6 | @post = posts(:one) 7 | end 8 | 9 | test "should get index" do 10 | get posts_url 11 | assert_response :success 12 | end 13 | 14 | test "shouldn't define new" do 15 | get new_post_url 16 | assert_response :not_found 17 | end 18 | 19 | test "shouldn't define create" do 20 | assert_difference("Post.count", 0) do 21 | post posts_url, params: { post: { title: "new post", content: "content" } } 22 | end 23 | 24 | assert_response :not_found 25 | end 26 | 27 | test "should show post" do 28 | get post_url(@post) 29 | assert_response :success 30 | end 31 | 32 | test "shouldn't define edit" do 33 | get edit_post_url(@post) 34 | assert_response :not_found 35 | end 36 | 37 | test "shouldn't define update" do 38 | patch post_url(@post), params: { post: { title: @post.title + "_updated", content: @post.content } } 39 | assert_response :not_found 40 | end 41 | 42 | test "shouldn't define destroy" do 43 | assert_difference("Post.count", 0) do 44 | delete post_url(@post) 45 | end 46 | 47 | assert_response :not_found 48 | end 49 | end 50 | 51 | class AuthenticatedTest < PostsControllerTest 52 | setup do 53 | @post = posts(:one) 54 | authenticate(user: @post.user) 55 | end 56 | 57 | test "should get index" do 58 | get posts_url 59 | assert_response :success 60 | end 61 | 62 | test "should get new" do 63 | get new_post_url 64 | assert_response :success 65 | end 66 | 67 | test "should create post" do 68 | assert_difference("Post.count", 1) do 69 | post posts_url, params: { post: { title: "new post", content: "content" } } 70 | end 71 | 72 | assert_redirected_to post_url(Post.last) 73 | end 74 | 75 | test "should show post" do 76 | get post_url(@post) 77 | assert_response :success 78 | end 79 | 80 | test "should get edit" do 81 | get edit_post_url(@post) 82 | assert_response :success 83 | end 84 | 85 | test "should update post" do 86 | patch post_url(@post), params: { post: { title: @post.title + "_updated", content: @post.content } } 87 | assert_redirected_to post_url(@post) 88 | end 89 | 90 | test "should destroy post" do 91 | assert_difference("Post.count", -1) do 92 | delete post_url(@post) 93 | end 94 | 95 | assert_redirected_to posts_url 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /app/views/users/show.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | <%= @user.screen_name %> 3 |

    4 | 5 |
    6 |
    user:
    7 |
    8 | <%= @user.screen_name %> 9 |
    10 |
    created:
    11 |
    12 | <%= time_ago_in_words(@user.created_at) %> ago 13 |
    14 |
    about:
    15 |
    16 | <%= simple_format @user.about %> 17 |
    18 |
    19 | 20 |
    21 | 22 |
    23 |
    24 | 25 | Posts: 26 | <%= @user.posts.size %> 27 | 28 |
      29 | <% @user.posts.order(created_at: :desc).each do |post| %> 30 |
    • 31 |
      32 | <%= render post %> 33 |
      34 | 35 | <% if post.user == Current.user %> 36 |
      37 | <%= link_to "Edit", edit_post_path(post), class: "border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 38 | <%= button_to "Destroy", post, method: :delete, class: "border border-red-500 text-red-500 bg-white py-1 px-2 cursor-pointer hover:bg-red-500 hover:text-white focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 39 |
      40 | <% end %> 41 |
    • 42 | <% end %> 43 |
    44 |
    45 | 46 |
    47 | 48 | Comments: 49 | <%= @user.comments.size %> 50 | 51 |
      52 | <% @user.comments.order(created_at: :desc).each do |comment| %> 53 |
    • <%= render comment %>
    • 54 | <% end %> 55 |
    56 |
    57 | 58 | <% if @user == Current.user %> 59 |
    60 | 61 | Sessions: 62 | <%= @user.sessions.size %> 63 | 64 |
      65 | <%= render @user.sessions.order(created_at: :desc) %> 66 |
    67 |
    68 | <% end %> 69 |
    -------------------------------------------------------------------------------- /test/controllers/users_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class UsersControllerTest < ActionDispatch::IntegrationTest 4 | class UnauthenticatedTest < UsersControllerTest 5 | setup do 6 | @user = users(:one) 7 | end 8 | 9 | test "shouldn't define index" do 10 | get users_url 11 | assert_response :not_found 12 | end 13 | 14 | test "should get new" do 15 | get new_user_url 16 | assert_response :success 17 | end 18 | 19 | test "should create user" do 20 | assert_difference("User.count") do 21 | post users_url, params: { user: { screen_name: "new_user", password: "secret12", password_confirmation: "secret12" } } 22 | end 23 | 24 | assert_redirected_to user_url(User.last) 25 | end 26 | 27 | test "should show user" do 28 | get user_url(@user) 29 | assert_response :success 30 | end 31 | 32 | test "shouldn't define edit" do 33 | get edit_user_url(@user) 34 | assert_response :not_found 35 | end 36 | 37 | test "shouldn't define update" do 38 | patch user_url(@user), params: { user: { screen_name: @user.screen_name + "_updated", password: "secret12", password_confirmation: "secret12" } } 39 | assert_response :not_found 40 | end 41 | 42 | test "shouldn't define destroy" do 43 | assert_difference("User.count", 0) do 44 | delete user_url(@user) 45 | end 46 | 47 | assert_response :not_found 48 | end 49 | end 50 | 51 | class AuthenticatedTest < UsersControllerTest 52 | setup do 53 | @user = users(:one) 54 | authenticate(user: @user) 55 | end 56 | 57 | test "shouldn't define index" do 58 | get users_url 59 | assert_response :not_found 60 | end 61 | 62 | test "should get new" do 63 | get new_user_url 64 | assert_response :success 65 | end 66 | 67 | test "should create user" do 68 | assert_difference("User.count") do 69 | post users_url, params: { user: { screen_name: "new_user", password: "secret12", password_confirmation: "secret12" } } 70 | end 71 | 72 | assert_redirected_to user_url(User.last) 73 | end 74 | 75 | test "should show user" do 76 | get user_url(@user) 77 | assert_response :success 78 | end 79 | 80 | test "should get edit" do 81 | get edit_user_url(@user) 82 | assert_response :success 83 | end 84 | 85 | test "should update user" do 86 | patch user_url(@user), params: { user: { screen_name: @user.screen_name + "_updated", password: "secret", password_confirmation: "secret" } } 87 | assert_redirected_to user_url(@user) 88 | end 89 | 90 | test "should destroy user" do 91 | assert_difference("User.count", -1) do 92 | delete user_url(@user) 93 | end 94 | 95 | assert_redirected_to users_url 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/controllers/sessions_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SessionsControllerTest < ActionDispatch::IntegrationTest 4 | class UnauthenticatedTest < SessionsControllerTest 5 | setup do 6 | @session = sessions(:one) 7 | end 8 | 9 | test "shouldn't define index" do 10 | get sessions_url 11 | assert_response :not_found 12 | end 13 | 14 | test "should get new" do 15 | get new_session_url 16 | assert_response :success 17 | end 18 | 19 | test "should create session" do 20 | assert_difference("Session.count") do 21 | post sessions_url, params: { session: { user: { screen_name: @session.user.screen_name, password: "secret" } } } 22 | end 23 | 24 | assert_redirected_to user_url(Session.last.user) 25 | end 26 | 27 | test "shouldn't define show" do 28 | get session_url(@session) 29 | assert_response :not_found 30 | end 31 | 32 | test "shouldn't define edit" do 33 | assert_raises NoMethodError do 34 | get edit_session_url(@session) 35 | end 36 | end 37 | 38 | test "shouldn't define update" do 39 | patch session_url(@session), params: { session: { user_id: @session.user_id } } 40 | assert_response :not_found 41 | end 42 | 43 | test "shouldn't define destroy" do 44 | assert_difference("Session.count", 0) do 45 | delete session_url(@session) 46 | end 47 | 48 | assert_response :not_found 49 | end 50 | end 51 | 52 | class AuthenticatedTest < SessionsControllerTest 53 | setup do 54 | @session = sessions(:one) 55 | authenticate(user: @session.user) 56 | end 57 | 58 | test "shouldn't define index" do 59 | get sessions_url 60 | assert_response :not_found 61 | end 62 | 63 | test "should get new" do 64 | get new_session_url 65 | assert_response :success 66 | end 67 | 68 | test "should create session" do 69 | assert_difference("Session.count") do 70 | post sessions_url, params: { session: { user: { screen_name: @session.user.screen_name, password: "secret" } } } 71 | end 72 | 73 | assert_redirected_to user_url(Session.last.user) 74 | end 75 | 76 | test "shouldn't define show" do 77 | get session_url(@session) 78 | assert_response :not_found 79 | end 80 | 81 | test "shouldn't define edit" do 82 | assert_raises NoMethodError do 83 | get edit_session_url(@session) 84 | end 85 | end 86 | 87 | test "shouldn't define update" do 88 | patch session_url(@session), params: { session: { user_id: @session.user_id } } 89 | assert_response :not_found 90 | end 91 | 92 | test "should destroy session" do 93 | assert_difference("Session.count", -1) do 94 | delete session_url(@session) 95 | end 96 | 97 | assert_redirected_to user_url(@session.user) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/controllers/comments_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommentsControllerTest < ActionDispatch::IntegrationTest 4 | class UnauthenticatedTest < CommentsControllerTest 5 | setup do 6 | @comment = comments(:one) 7 | end 8 | 9 | test "shouldn't define index" do 10 | assert_raises NameError do 11 | get comments_url 12 | end 13 | end 14 | 15 | test "shouldn't define new" do 16 | assert_raises NameError do 17 | get new_comment_url 18 | end 19 | end 20 | 21 | test "shouldn't define create" do 22 | assert_raises NameError do 23 | post comments_url, params: { comment: { body: @comment.body, post_id: @comment.post_id, user_id: @comment.user_id } } 24 | end 25 | end 26 | 27 | test "shouldn't define show" do 28 | get comment_url(@comment) 29 | assert_response :not_found 30 | end 31 | 32 | test "shouldn't define edit" do 33 | get edit_comment_url(@comment) 34 | assert_response :not_found 35 | end 36 | 37 | test "shouldn't define update" do 38 | patch comment_url(@comment), params: { comment: { body: @comment.body, post_id: @comment.post_id, user_id: @comment.user_id } } 39 | assert_response :not_found 40 | end 41 | 42 | test "shouldn't define destroy" do 43 | assert_difference("Comment.count", 0) do 44 | delete comment_url(@comment) 45 | end 46 | 47 | assert_response :not_found 48 | end 49 | end 50 | 51 | class AuthenticatedTest < CommentsControllerTest 52 | setup do 53 | @comment = comments(:one) 54 | authenticate(user: @comment.user) 55 | end 56 | 57 | test "shouldn't define index" do 58 | assert_raises NameError do 59 | get comments_url 60 | end 61 | end 62 | 63 | test "shouldn't define new" do 64 | assert_raises NameError do 65 | get new_comment_url 66 | end 67 | end 68 | 69 | test "should create comment" do 70 | assert_difference("Comment.count", 1) do 71 | post post_comments_url(@comment.post), params: { comment: { body: "new comment" } } 72 | end 73 | 74 | assert_redirected_to post_url(@comment.post, anchor: "comment_#{Comment.last.id}") 75 | end 76 | 77 | test "shouldn't define show" do 78 | get comment_url(@comment) 79 | assert_response :not_found 80 | end 81 | 82 | test "should get edit" do 83 | get edit_comment_url(@comment) 84 | assert_response :success 85 | end 86 | 87 | test "should update comment" do 88 | patch comment_url(@comment), params: { comment: { body: @comment.body + "_updated" } } 89 | assert_redirected_to post_url(@comment.post, anchor: "comment_#{@comment.id}") 90 | end 91 | 92 | test "should destroy comment" do 93 | assert_difference("Comment.count", -1) do 94 | delete comment_url(@comment) 95 | end 96 | 97 | assert_redirected_to post_url(@comment.post) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /workshop/12-solid-error-monitoring.md: -------------------------------------------------------------------------------- 1 | ## Solid Error Monitoring 2 | 3 | After working with Solid Queue and Solid Cache, you might get curious how else one might leverage this pattern of spinning up separate SQLite databases to drive additional services for our Rails application. I personally got curious and explored this how this pattern could compliment Rails' error reporter interface. You might come up with other great ideas. 4 | 5 | Let's walk through how we can add integrated error monitoring into our application by using the [Solid Errors](https://github.com/fractaledmind/solid_errors) gem. 6 | 7 | ### The Implementation 8 | 9 | Step one, as often, is to install the gem: 10 | 11 | ```sh 12 | bundle add solid_errors 13 | ``` 14 | 15 | Following the pattern of setting up a new SQLite database to back this service, let's create an `errors` database: 16 | 17 | ```yaml 18 | errors: &errors 19 | <<: *default 20 | migrations_paths: db/errors_migrate 21 | database: storage/<%= Rails.env %>-errors.sqlite3 22 | ``` 23 | 24 | And configure our `production` environment to use this database: 25 | 26 | ```yaml 27 | production: 28 | primary: *primary 29 | queue: *queue 30 | cache: *cache 31 | errors: *errors 32 | ``` 33 | 34 | With our new `errors` database configured, we can generate the Solid Errors migrations for this database: 35 | 36 | ```sh 37 | RAILS_ENV=production bin/rails generate solid_errors:install --database errors 38 | ``` 39 | 40 | And then run those migrations: 41 | 42 | ```sh 43 | RAILS_ENV=production bin/rails db:migrate:errors 44 | ``` 45 | 46 | Finally, we need to tell Solid Errors to use this dedicated database in our `config/application.rb` file: 47 | 48 | ```ruby 49 | # Use a separate database for error monitoring 50 | config.solid_errors.connects_to = { database: { writing: :errors } } 51 | ``` 52 | 53 | Like Mission Control Jobs, Solid Errors comes with a web dashboard that allows us to view our application's unresolved errors. You can mount that in your `config/routes.rb` file under our `AuthenticatedConstraint` block: 54 | 55 | ```ruby 56 | mount SolidErrors::Engine, at: "/errors" 57 | ``` 58 | 59 | In addition to the web UI, Solid Errors also supports sending email notifications when an error is raised. This is opt-in behavior though, so you need to configure the from and to email addresses in your `config/application.rb` file: 60 | 61 | ```ruby 62 | config.solid_errors.send_emails = true 63 | config.solid_errors.email_from = "errors@euruko-2024.com" 64 | config.solid_errors.email_to = "devs@euruko-2024.com" 65 | ``` 66 | 67 | This provides a pretty solid foundation for error monitoring. Certainly not as robust as a 3rd party service like Honeybadger or AppSignal, but a great place to start for a new application where you need to keep initial costs to a minimum. 68 | 69 | Test how it works by restarting your Rails server process and causing an error. I find the simplest way to generate an error is to sign out and then try to access an authorized route like `/posts/:id/edit` as a guest. Once you have caused the exception, sign back in and visit the `/errors` dashboard to see what Solid Errors provides. 70 | 71 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # While tests run files are not watched, reloading is not necessary. 12 | config.enable_reloading = false 13 | 14 | # Eager loading loads your entire application. When running a single test locally, 15 | # this is usually not necessary, and can slow down your test suite. However, it's 16 | # recommended that you enable it in continuous integration systems to ensure eager 17 | # loading is working properly before deploying your code. 18 | config.eager_load = ENV["CI"].present? 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } 22 | 23 | # Show full error reports and disable caching. 24 | config.consider_all_requests_local = true 25 | config.action_controller.perform_caching = false 26 | config.cache_store = :null_store 27 | 28 | # Render exception templates for rescuable exceptions and raise for other exceptions. 29 | config.action_dispatch.show_exceptions = :rescuable 30 | 31 | # Disable request forgery protection in test environment. 32 | config.action_controller.allow_forgery_protection = false 33 | 34 | # Store uploaded files on the local file system in a temporary directory. 35 | config.active_storage.service = :test 36 | 37 | # Disable caching for Action Mailer templates even if Action Controller 38 | # caching is enabled. 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Unlike controllers, the mailer instance doesn't have any context about the 47 | # incoming request so you'll need to provide the :host parameter yourself. 48 | config.action_mailer.default_url_options = { host: "www.example.com" } 49 | 50 | # Print deprecation notices to the stderr. 51 | config.active_support.deprecation = :stderr 52 | 53 | # Raise exceptions for disallowed deprecations. 54 | config.active_support.disallowed_deprecation = :raise 55 | 56 | # Tell Active Support which deprecation messages to disallow. 57 | config.active_support.disallowed_deprecation_warnings = [] 58 | 59 | # Raises error for missing translations. 60 | # config.i18n.raise_on_missing_translations = true 61 | 62 | # Annotate rendered view with file names. 63 | # config.action_view.annotate_rendered_view_with_filenames = true 64 | 65 | # Raise error when a before_action's only/except options reference missing actions. 66 | config.action_controller.raise_on_missing_callback_actions = true 67 | end 68 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.enable_reloading = true 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing. 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } 28 | else 29 | config.action_controller.perform_caching = false 30 | 31 | config.cache_store = :null_store 32 | end 33 | 34 | # Store uploaded files on the local file system (see config/storage.yml for options). 35 | config.active_storage.service = :local 36 | 37 | # Don't care if the mailer can't send. 38 | config.action_mailer.raise_delivery_errors = false 39 | 40 | # Disable caching for Action Mailer templates even if Action Controller 41 | # caching is enabled. 42 | config.action_mailer.perform_caching = false 43 | 44 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 45 | 46 | # Print deprecation notices to the Rails logger. 47 | config.active_support.deprecation = :log 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raise an error on page load if there are pending migrations. 56 | config.active_record.migration_error = :page_load 57 | 58 | # Highlight code that triggered database queries in logs. 59 | config.active_record.verbose_query_logs = true 60 | 61 | # Highlight code that enqueued background job in logs. 62 | config.active_job.verbose_enqueue_logs = true 63 | 64 | # Suppress logger output for asset requests. 65 | config.assets.quiet = true 66 | 67 | # Raises error for missing translations. 68 | # config.i18n.raise_on_missing_translations = true 69 | 70 | # Annotate rendered view with file names. 71 | config.action_view.annotate_rendered_view_with_filenames = true 72 | 73 | # Uncomment if you wish to allow Action Cable access from any origin. 74 | # config.action_cable.disable_request_forgery_protection = true 75 | 76 | # Raise error when a before_action's only/except options reference missing actions. 77 | config.action_controller.raise_on_missing_callback_actions = true 78 | 79 | # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. 80 | # config.generators.apply_rubocop_autocorrect_after_generate! 81 | end 82 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /public/assets/stimulus-loading-3576ce92b149ad5d6959438c6f291e2426c86df3b874c525b30faad51b0d96b3.js: -------------------------------------------------------------------------------- 1 | // FIXME: es-module-shim won't shim the dynamic import without this explicit import 2 | import "@hotwired/stimulus" 3 | 4 | const controllerAttribute = "data-controller" 5 | 6 | // Eager load all controllers registered beneath the `under` path in the import map to the passed application instance. 7 | export function eagerLoadControllersFrom(under, application) { 8 | const paths = Object.keys(parseImportmapJson()).filter(path => path.match(new RegExp(`^${under}/.*_controller$`))) 9 | paths.forEach(path => registerControllerFromPath(path, under, application)) 10 | } 11 | 12 | function parseImportmapJson() { 13 | return JSON.parse(document.querySelector("script[type=importmap]").text).imports 14 | } 15 | 16 | function registerControllerFromPath(path, under, application) { 17 | const name = path 18 | .replace(new RegExp(`^${under}/`), "") 19 | .replace("_controller", "") 20 | .replace(/\//g, "--") 21 | .replace(/_/g, "-") 22 | 23 | if (canRegisterController(name, application)) { 24 | import(path) 25 | .then(module => registerController(name, module, application)) 26 | .catch(error => console.error(`Failed to register controller: ${name} (${path})`, error)) 27 | } 28 | } 29 | 30 | 31 | // Lazy load controllers registered beneath the `under` path in the import map to the passed application instance. 32 | export function lazyLoadControllersFrom(under, application, element = document) { 33 | lazyLoadExistingControllers(under, application, element) 34 | lazyLoadNewControllers(under, application, element) 35 | } 36 | 37 | function lazyLoadExistingControllers(under, application, element) { 38 | queryControllerNamesWithin(element).forEach(controllerName => loadController(controllerName, under, application)) 39 | } 40 | 41 | function lazyLoadNewControllers(under, application, element) { 42 | new MutationObserver((mutationsList) => { 43 | for (const { attributeName, target, type } of mutationsList) { 44 | switch (type) { 45 | case "attributes": { 46 | if (attributeName == controllerAttribute && target.getAttribute(controllerAttribute)) { 47 | extractControllerNamesFrom(target).forEach(controllerName => loadController(controllerName, under, application)) 48 | } 49 | } 50 | 51 | case "childList": { 52 | lazyLoadExistingControllers(under, application, target) 53 | } 54 | } 55 | } 56 | }).observe(element, { attributeFilter: [controllerAttribute], subtree: true, childList: true }) 57 | } 58 | 59 | function queryControllerNamesWithin(element) { 60 | return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).map(extractControllerNamesFrom).flat() 61 | } 62 | 63 | function extractControllerNamesFrom(element) { 64 | return element.getAttribute(controllerAttribute).split(/\s+/).filter(content => content.length) 65 | } 66 | 67 | function loadController(name, under, application) { 68 | if (canRegisterController(name, application)) { 69 | import(controllerFilename(name, under)) 70 | .then(module => registerController(name, module, application)) 71 | .catch(error => console.error(`Failed to autoload controller: ${name}`, error)) 72 | } 73 | } 74 | 75 | function controllerFilename(name, under) { 76 | return `${under}/${name.replace(/--/g, "/").replace(/-/g, "_")}_controller` 77 | } 78 | 79 | function registerController(name, module, application) { 80 | if (canRegisterController(name, application)) { 81 | application.register(name, module.default) 82 | } 83 | } 84 | 85 | function canRegisterController(name, application){ 86 | return !application.router.modulesByIdentifier.has(name) 87 | }; 88 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "EuRuKo 2024" %> 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | 10 | <%= yield :head %> 11 | 12 | 13 | 14 | 15 | 16 | <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> 17 | <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 18 | <%= javascript_importmap_tags %> 19 | 20 | 21 | 22 |
    23 |

    24 | 25 | Lorem News 26 | 27 |

    28 |
    29 | <% if Current.user %> 30 | 31 | 32 | 33 | 34 | <%= link_to Current.user.screen_name_in_database, Current.user, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 35 | | 36 | <%= button_to "Sign out", Current.session, method: :delete, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 37 | <% else %> 38 | <%= link_to "Sign in", new_session_path, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> | 39 | <%= link_to "Sign up", new_user_path, class: "hover:underline focus:outline-none focus:ring focus:ring-offset-2 focus:ring-blue-500" %> 40 | <% end %> 41 |
    42 |
    43 | 44 |
    45 | <%= yield %> 46 |
    47 | 48 | 53 | 54 |
    55 | <% if notice.present? %> 56 | 59 | <% end %> 60 | 61 | <% if alert.present? %> 62 | 65 | <% end %> 66 |
    67 | 68 | 69 | -------------------------------------------------------------------------------- /workshop/08-adding-solid-cache.md: -------------------------------------------------------------------------------- 1 | ## Adding Solid Cache 2 | 3 | In addition to a job backend, a full-featured Rails application often needs a cache backend. Modern Rails provides a database-backed default cache store named [Solid Cache](https://github.com/rails/solid_cache). 4 | 5 | ### The Implementation 6 | 7 | We install it like any other gem: 8 | 9 | ```sh 10 | bundle add solid_cache 11 | ``` 12 | 13 | Like with Solid Queue, we need to configure Solid Cache to use a separate database. We can do this by adding a new database configuration to our `config/database.yml` file: 14 | 15 | ```yaml 16 | cache: &cache 17 | <<: *default 18 | migrations_paths: db/cache_migrate 19 | database: storage/<%= Rails.env %>-cache.sqlite3 20 | ``` 21 | 22 | We then need to ensure that our `production` environment uses this `cache` database, like so: 23 | 24 | ```yaml 25 | production: 26 | primary: 27 | <<: *default 28 | database: storage/production.sqlite3 29 | queue: *queue 30 | cache: *cache 31 | ``` 32 | 33 | With the new cache database configured, we can install Solid Cache into our application with that database 34 | 35 | ```sh 36 | RAILS_ENV=production DATABASE=cache bin/rails solid_cache:install 37 | ``` 38 | 39 | This will create the migration files in the `db/cache_migrate` directory and set Solid Cache to be the production cache store. You should see output like: 40 | 41 | ``` 42 | gsub config/environments/production.rb 43 | create config/solid_cache.yml 44 | gsub config/database.yml 45 | create db/cache_schema.rb 46 | ``` 47 | 48 | Once installed, we can then run the load the generated schema like so: 49 | 50 | ```sh 51 | RAILS_ENV=production rails db:prepare DATABASE=cache 52 | ``` 53 | 54 | > [!NOTE] 55 | > We are doing all of this in the `production` environment for this workshop. If you were setting up Solid Cache in `development`, you would also need to enable the cache in the `development` environment. This is done by running the `dev:cache` task: 56 | >```sh 57 | >bin/rails dev:cache 58 | >``` 59 | > You want to see the following output: 60 | >``` 61 | >Development mode is now being cached. 62 | >``` 63 | 64 | With Solid Cache enabled for the `production` environment, we can finally ensure that Solid Cache itself will use our new cache database. Luckily, the new default, is that Solid Cache expects you to a separate `cache` database. And this is precisely what we have setup. Check the configuration file at `config/solid_cache.yml` and ensure it looks like this: 65 | 66 | ```yaml 67 | default: &default 68 | database: cache 69 | store_options: 70 | # Cap age of oldest cache entry to fulfill retention policies 71 | # max_age: <%= 60.days.to_i %> 72 | max_size: <%= 256.megabytes %> 73 | namespace: <%= Rails.env %> 74 | 75 | development: 76 | <<: *default 77 | 78 | test: 79 | <<: *default 80 | 81 | production: 82 | <<: *default 83 | ``` 84 | 85 | ### Using Solid Cache 86 | 87 | With Solid Cache now fully integrated into our application, we can use it like any other Rails cache store. Let's confirm that everything is working as expecting by opening the Rails console: 88 | 89 | ```sh 90 | RAILS_ENV=production bin/rails console 91 | ``` 92 | 93 | write to the `Rails.cache` object: 94 | 95 | ```ruby 96 | Rails.cache.write(:key, "value") 97 | ``` 98 | 99 | If we then read that key back from the cache: 100 | 101 | ```ruby 102 | Rails.cache.read(:key) 103 | ``` 104 | 105 | You should see the value `"value"` returned. 106 | 107 | You can confirm that this entry was stored in the Solid Cache database by checking: 108 | 109 | ```ruby 110 | SolidCache::Entry.count 111 | ``` 112 | 113 | and also: 114 | 115 | ```ruby 116 | SolidCache::Entry.first.attributes 117 | ``` 118 | 119 | This output will confirm that Solid Cache is working as expected! 120 | 121 | With caching now enabled in our application, we can use Solid Cache to cache expensive operations, such as database queries, API calls, or view partials, to improve the performance of our application. 122 | 123 | We can cache the rendering of the posts partial in the `posts/index.html.erb` view like so: 124 | 125 | ```erb 126 | 127 | <% cache post do %> 128 | <%= render post %> 129 | <% end %> 130 | 131 | ``` 132 | 133 | ### Conclusion 134 | 135 | In this step, we added the Solid Cache gem, backed by a separate SQLite database, to our application. With Solid Cache installed and setup, the next step is to consider how to enhance SQLite with extensions. 136 | -------------------------------------------------------------------------------- /workshop/04-simplifying-fixes.md: -------------------------------------------------------------------------------- 1 | ## Simplifying Fixes 2 | 3 | In the last two steps, we have addressed the two major issues that we identified in our baseline load tests. We have resolved the `500` error responses by opening _immediate_ transactions in our `post_create` action. We have also resolved the 5+ second responses by ensuring that Ruby's GVL is released when queries are waiting to retry to acquire SQLite's write lock. 4 | 5 | Neither fix is profoundly combersome, and I wanted to ensure that you were comfortable understanding the problems and the solutions (especially _why_ the solutions work). But, you don't actually need to manually make these changes in your Rails application. You can simply add a gem to your project that will automatically make these changes for you. 6 | 7 | ### The Solution 8 | 9 | Instead of manually patching our application, we can address both of these pain points by simply bringing into our project the [`activerecord-enhancedsqlite3-adapter` gem](https://github.com/fractaledmind/activerecord-enhancedsqlite3-adapter). This gem is a zero-configuration drop-in enhancement for the `sqlite3` adapter that comes with Rails. It will automatically open transactions in immediate mode, and it will also ensure that whenever SQLite is waiting for a query to acquire the write lock that other Puma workers can continue to process requests. In addition, it will back port some nice ActiveRecord features that aren't yet in a point release, like deferred foreign key constraints, custom return columns, and generated columns. 10 | 11 | To add the `activerecord-enhancedsqlite3-adapter` gem to your project, simply run the following command: 12 | 13 | ```sh 14 | bundle add activerecord-enhancedsqlite3-adapter 15 | ``` 16 | 17 | Simply by adding the gem to your `Gemfile` you automatically get all of the gem's goodies. You don't need to configure anything. 18 | 19 | So, we can now remove the manual changes we made to our application. First, delete the `config/initializers/sqlite3_busy_timeout_patch.rb` file 20 | 21 | ```sh 22 | rm config/initializers/sqlite3_busy_timeout_patch.rb 23 | ``` 24 | 25 | Then, remove the `default_transaction_mode: IMMEDIATE` line from your `config/database.yml` file: 26 | 27 | ```sh 28 | sed -i '' '/default_transaction_mode: IMMEDIATE/d' config/database.yml 29 | ``` 30 | 31 | The enhanced adapter gem will supply both fixes automatically for us. 32 | 33 | ### Running the Load Tests 34 | 35 | Let's rerun our load tests and ensure things have stayed improved. We first need to restart our application server so that it picks up and uses the enhanced adapter. So, `Ctrl + C` to stop the running server, then re-run the `bin/serve` command in that terminal window/tab. 36 | 37 | Then, in the other terminal window/tab, run the `post_create` load test again: 38 | 39 | ```sh 40 | oha -c 20 -z 10s -m POST http://localhost:3000/benchmarking/post_create 41 | ``` 42 | 43 | which gave me these results: 44 | 45 |
    46 | 1017 RPS (click to see full breakdown) 47 | 48 | ``` 49 | Summary: 50 | Success rate: 100.00% 51 | Total: 10.0005 secs 52 | Slowest: 0.2814 secs 53 | Fastest: 0.0024 secs 54 | Average: 0.0197 secs 55 | Requests/sec: 1017.0507 56 | 57 | Total data: 86.05 MiB 58 | Size/request: 8.68 KiB 59 | Size/sec: 8.60 MiB 60 | 61 | Response time histogram: 62 | 0.002 [1] | 63 | 0.030 [8415] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 64 | 0.058 [1412] |■■■■■ 65 | 0.086 [212] | 66 | 0.114 [54] | 67 | 0.142 [28] | 68 | 0.170 [10] | 69 | 0.198 [9] | 70 | 0.226 [5] | 71 | 0.254 [3] | 72 | 0.281 [2] | 73 | 74 | Response time distribution: 75 | 10.00% in 0.0054 secs 76 | 25.00% in 0.0086 secs 77 | 50.00% in 0.0147 secs 78 | 75.00% in 0.0248 secs 79 | 90.00% in 0.0382 secs 80 | 95.00% in 0.0503 secs 81 | 99.00% in 0.0902 secs 82 | 99.90% in 0.1971 secs 83 | 99.99% in 0.2785 secs 84 | 85 | 86 | Details (average, fastest, slowest): 87 | DNS+dialup: 0.0013 secs, 0.0007 secs, 0.0018 secs 88 | DNS-lookup: 0.0001 secs, 0.0000 secs, 0.0003 secs 89 | 90 | Status code distribution: 91 | [200] 10151 responses 92 | 93 | Error distribution: 94 | [20] aborted due to deadline 95 | ``` 96 |
    97 | 98 | We see that there are still no `500` errored responses, requests per second remains above 1,000, and the slowest request is still under 300ms. The `activerecord-enhancedsqlite3-adapter` gem has successfully addressed the two major issues we identified in our baseline load tests. 99 | 100 | ### Conclusion 101 | 102 | While it is important to understand _what_ the enhanced adapter is doing, and some of you may actually prefer to have the code that fixes the problems we have discussed in your repository, it is nice that we can simply add a gem to our project and have these issues automatically addressed. 103 | -------------------------------------------------------------------------------- /workshop/10-controlling-sqlite-compilation.md: -------------------------------------------------------------------------------- 1 | ## Controling SQLite Compilation 2 | 3 | Because SQLite is simply a single executable, it is easy to control the actual compilation of SQLite. The `sqlite3-ruby` gem allows us to control the compilation flags used to compile the SQLite executable. We can set the compilation flags via the `BUNDLE_BUILD__SQLITE3` environment variable set in the `.bundle/config` file. Bundler allows you set set such configuration via the `bundle config set` command. To control SQLite compilation, you use the `bundle config set build.sqlite3` command passing the `--with-sqlite-cflags` argument. 4 | 5 | ### The Test 6 | 7 | Before we set the actual compilation flags we want for SQLite, let's first confirm that this process works. We will need a compilation flag that will be easy to check in the Rails console. Luckily, whether or not SQLite was compiled with thread-safety is a check that the `sqlite3-ruby` gem provides via `SQLite3.threadsafe?`. 8 | 9 | So, let's first check the current value of `SQLite.threadsafe?`. First, we need to start a Rails console: 10 | 11 | ```sh 12 | RAILS_ENV=production bin/rails c 13 | ``` 14 | 15 | Then, check the current value: 16 | 17 | ```irb 18 | > SQLite3.threadsafe? 19 | => true 20 | ``` 21 | 22 | Ok, so by default SQLite is compiled with thread-safety. Let's now try to compile SQLite with thread-safety off. 23 | 24 | As described above, we can set the compilation flags via the `bundle config set build.sqlite3` command: 25 | 26 | ```sh 27 | bundle config set build.sqlite3 \ 28 | "--with-sqlite-cflags='-DSQLITE_THREADSAFE=0'" 29 | ``` 30 | 31 | If it doesn't already exist, this command will create a `.bundle/config` file in your project directory. Its contents should look something like: 32 | 33 | ```yaml 34 | --- 35 | BUNDLE_BUILD__SQLITE3: "--with-sqlite-cflags=' -DSQLITE_THREADSAFE=0'" 36 | ``` 37 | 38 | Finally, in order to ensure that SQLite is compiled from source, we need to specify in the `Gemfile` that the SQLite gem should use the `ruby` platform version. 39 | 40 | ```ruby 41 | gem "sqlite3", ">= 2.0", force_ruby_platform: true 42 | ``` 43 | 44 | When you now run `bundle install`, you should see something like: 45 | 46 | ``` 47 | Fetching sqlite3 2.0.4 48 | Installing sqlite3 2.0.4 with native extensions 49 | ``` 50 | 51 | Compiling SQLite from source can take a while, so be patient. Once it is done, you can start a Rails console and check the value of `SQLite3.threadsafe?` now. You should hopefully see: 52 | 53 | ```irb 54 | > SQLite3.threadsafe? 55 | => false 56 | ``` 57 | 58 | If you see `false`, this confirms that the compilation flags were set correctly, that SQLite was compiled with thread-safety off, and that our Rails app is using the custom compiled SQLite executable. Now, we want to set the actually desired compilation flags for SQLite, which requires undoing the thread-safety flag we just set. 59 | 60 | If you ever want to undo custom compilation, you will not only need to remove the `build.sqlite3` configuration and the `force_ruby_platform` option from the `Gemfile`, but you will also need to delete the `sqlite3` gem from your system. This can be done with the following command: 61 | 62 | ```sh 63 | rm -rf $(bundle show sqlite3) 64 | ``` 65 | 66 | After that, simply run `bundle install` again to reinstall the `sqlite3` gem. 67 | 68 | ### The Implementation 69 | 70 | The [SQLite docs recommend 12 flags](https://www.sqlite.org/compile.html#recommended_compile_time_options) for a 5% improvement. The `sqlite3-ruby` gem needs some of the features recommended to be omitted, and some are useful for Rails apps. These 6 flags are my recommendation for a Rails app, and can be set using the following command: 71 | 72 | ```sh 73 | bundle config set build.sqlite3 \ 74 | "--with-sqlite-cflags=' 75 | -DSQLITE_DQS=0 76 | -DSQLITE_DEFAULT_MEMSTATUS=0 77 | -DSQLITE_LIKE_DOESNT_MATCH_BLOBS 78 | -DSQLITE_MAX_EXPR_DEPTH=0 79 | -DSQLITE_OMIT_SHARED_CACHE 80 | -DSQLITE_USE_ALLOCA'" 81 | ``` 82 | 83 | Typically, the `.bundle/config` file is removed from source control, but we add it back to make this app more portable. Note, however, that this does restrict individual configuration of Bundler. This requires a change to the `.gitignore` file. Find this portion of your `.gitignore` file (should be near the top): 84 | 85 | ``` 86 | # Ignore bundler config. 87 | /.bundle 88 | ``` 89 | 90 | And add this line: 91 | 92 | ``` 93 | !.bundle/config 94 | ``` 95 | 96 | Again, make sure that the SQLite gem forces the `ruby` platform version: 97 | 98 | ```ruby 99 | gem "sqlite3", ">= 2.0", force_ruby_platform: true 100 | ``` 101 | 102 | Run `bundle install` again to compile SQLite with the new flags. And we're done. 103 | 104 | ### Conclusion 105 | 106 | With SQLite custom compiled, the next step is to setup the repository to work with branch-specific databases. -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment 20 | # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. 24 | # config.public_file_server.enabled = false 25 | 26 | # Compress CSS using a preprocessor. 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fall back to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 33 | # config.asset_host = "http://assets.example.com" 34 | 35 | # Specifies the header that your server uses for sending files. 36 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 37 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 38 | 39 | # Store uploaded files on the local file system (see config/storage.yml for options). 40 | config.active_storage.service = :local 41 | 42 | # Mount Action Cable outside main process or domain. 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = "wss://example.com/cable" 45 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 46 | 47 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 48 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 49 | # config.assume_ssl = true 50 | 51 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 52 | config.force_ssl = ENV["RELAX_SSL"].blank? 53 | 54 | # Skip http-to-https redirect for the default health check endpoint. 55 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 56 | 57 | # Log to STDOUT by default 58 | config.logger = ActiveSupport::Logger.new(STDOUT) 59 | .tap { |logger| logger.formatter = ::Logger::Formatter.new } 60 | .then { |logger| ActiveSupport::TaggedLogging.new(logger) } 61 | 62 | # Prepend all log lines with the following tags. 63 | config.log_tags = [ :request_id ] 64 | 65 | # "info" includes generic and useful information about system operation, but avoids logging too much 66 | # information to avoid inadvertent exposure of personally identifiable information (PII). If you 67 | # want to log everything, set the level to "debug". 68 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 69 | 70 | # Use a different cache store in production. 71 | # config.cache_store = :mem_cache_store 72 | 73 | # Use a real queuing backend for Active Job (and separate queues per environment). 74 | # config.active_job.queue_adapter = :resque 75 | # config.active_job.queue_name_prefix = "euruko_2024_production" 76 | 77 | # Disable caching for Action Mailer templates even if Action Controller 78 | # caching is enabled. 79 | config.action_mailer.perform_caching = false 80 | 81 | # Ignore bad email addresses and do not raise email delivery errors. 82 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 83 | # config.action_mailer.raise_delivery_errors = false 84 | 85 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 86 | # the I18n.default_locale when a translation cannot be found). 87 | config.i18n.fallbacks = true 88 | 89 | # Don't log any deprecations. 90 | config.active_support.report_deprecations = false 91 | 92 | # Do not dump schema after migrations. 93 | config.active_record.dump_schema_after_migration = false 94 | 95 | # Enable DNS rebinding protection and other `Host` header attacks. 96 | # config.hosts = [ 97 | # "example.com", # Allow requests from example.com 98 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 99 | # ] 100 | # Skip DNS rebinding protection for the default health check endpoint. 101 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 102 | end 103 | -------------------------------------------------------------------------------- /workshop/09-using-sqlite-extensions.md: -------------------------------------------------------------------------------- 1 | ## Using SQLite Extensions 2 | 3 | Beyond simply spinning up separate SQLite databases for IO-bound Rails components, there are a number of ways that we can enhance working with SQLite itself. One of the most powerful features of SQLite is its support for [loadable extensions](https://www.sqlite.org/loadext.html). These extensions allow you to add new functionality to SQLite, such as full-text search, JSON support, or even custom functions. 4 | 5 | ### The Implementation 6 | 7 | There is an unofficial SQLite extension package manager called [sqlpkg](https://sqlpkg.org/). We can use sqlpkg to install a number of useful SQLite extensions. View all 97 extensions available in sqlpkg [here](https://sqlpkg.org/all/). 8 | 9 | We can install the [Ruby gem](https://github.com/fractaledmind/sqlpkg-ruby) that ships with precompiled executables like so: 10 | 11 | ```sh 12 | bundle add sqlpkg 13 | ``` 14 | 15 | And then we can install it into our Rails application like so: 16 | 17 | ```sh 18 | RAILS_ENV=production bin/rails generate sqlpkg:install 19 | ``` 20 | 21 | This will create 2 files in our application: 22 | 23 | 1. `.sqlpkg`, which ensures that sqlpkg will run in "project scope" 24 | 2. `sqlpkg.lock`, where sqlpkg will store information about the installed packages 25 | 26 | The gem provides the `sqlpkg` executable, which we can use to install SQLite extensions. For example, to install the [`uuid` extension](https://github.com/nalgeon/sqlean/blob/main/docs/uuid.md), we can run: 27 | 28 | ```sh 29 | bundle exec sqlpkg install nalgeon/uuid 30 | ``` 31 | 32 | Or, to install the [`ulid` extension](https://github.com/asg017/sqlite-ulid), we can run: 33 | 34 | ```sh 35 | bundle exec sqlpkg install asg017/ulid 36 | ``` 37 | 38 | As you will see on the [sqlpkg website](https://sqlpkg.org/all/), each extension has an identifier made up of a namespace and a name. There are many more extensions available. 39 | 40 | When you do install an extension, you will see logs like: 41 | 42 | ``` 43 | (project scope) 44 | > installing asg017/ulid... 45 | ✓ installed package asg017/ulid to .sqlpkg/asg017/ulid 46 | ``` 47 | 48 | In order to make use of these extensions in our Rails application, we need to load them when the database is opened. The enhanced adapter gem can load any extensions installed via `sqlpkg` by listing them in the `database.yml` file. For example, to load the `uuid` and `ulid` extensions, we would add the following to our `config/database.yml` file: 49 | 50 | ```yaml 51 | extensions: 52 | - nalgeon/uuid 53 | - asg017/ulid 54 | ``` 55 | 56 | If you want an extension to be loaded for each database (`primary`, `queue`, and `cache`), add this section to the `default` section of the `database.yml` file. If there are some extensions that you only want to load for a specific database, you can add this section to the specific database configuration. 57 | 58 | For example, if we only want to load the `uuid` extension for the `primary` database, we would add this section to the `primary` section of the `database.yml` file: 59 | 60 | ```yaml 61 | primary: &primary 62 | <<: *default 63 | database: storage/<%= Rails.env %>.sqlite3 64 | extensions: 65 | - nalgeon/uuid 66 | ``` 67 | 68 | But, if we wanted to load the `ulid` extension for all databases, we would add this section to the `default` section of the `database.yml` file: 69 | 70 | ```yaml 71 | default: &default 72 | adapter: sqlite3 73 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 74 | timeout: 5000 75 | extensions: 76 | - asg017/ulid 77 | ``` 78 | 79 | We can confirm that the extensions are loaded by opening the Rails console and running a query that uses the extension. For example, to generate a UUID, we can run: 80 | 81 | ```ruby 82 | ActiveRecord::Base.connection.execute 'select uuid4();' 83 | ``` 84 | 85 | If you see a return value something like: 86 | 87 | ```ruby 88 | [{"uuid4()"=>"abf3946d-5e04-4da0-8452-158cd983bd21"}] 89 | ``` 90 | 91 | then you know that the extension is loaded and working correctly. 92 | 93 | ### Using SQLite Extensions in CI and Production 94 | 95 | In order to ensure that your extensions are downloaded and installed in your production environment, you need to ensure that the `.sqlpkg` directory is present in your application's repository, but doesn't contain any files. Then, you need to call the `sqlpkg install` command as a part of your deployment process: 96 | 97 | ```sh 98 | bundle exec sqlpkg install 99 | ``` 100 | 101 | To do this, let's first create a `.keep` file in the `.sqlpkg` directory: 102 | 103 | ```sh 104 | touch .sqlpkg/.keep 105 | ``` 106 | 107 | Then, we can add the following to the `.gitignore` file: 108 | 109 | ``` 110 | /.sqlpkg/* 111 | !/.sqlpkg/.keep 112 | ``` 113 | 114 | This ignores all files in the `.sqlpkg` directory except for the `.keep` file. This way, the `.sqlpkg` directory will be present in the repository, but will not contain any files. This allows us to run the `sqlpkg install` command as a part of our deployment process. 115 | 116 | When you run the `sqlpkg install` command without specifying a package, it will install all packages listed in the `sqlpkg.lock` file. So, you can install SQLite extensions locally, commit the `sqlpkg.lock` file to your repository, and then run the `sqlpkg install` command as a part of your deployment process to ensure that the extensions are installed in your production environment. 117 | 118 | ### Conclusion 119 | 120 | With SQLite extensions integrated, the next step is to control how the SQLite executable is compiled. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This is an app built for demonstration purposes for the [EuRuKo 2024 conference](https://2024.euruko.org) held in Sarajevo, Bosnia & Herzegovina on September 11-13, 2024. 4 | 5 | The application is a basic "Hacker News" style app with `User`s, `Post`s, and `Comment`s. The seeds file will create ~100 users, ~1,000 posts, and ~10 comments per post (so ~10,000 comments). Every user has the same password: `password`, so you can sign in as any user to test the app. 6 | 7 | This application runs on Ruby >= 3.1, Rails 7.2.1, and SQLite 3.46.1 (gem version 2.0.4). 8 | 9 | ## Setup 10 | 11 | First you need to clone the repository to your local machine: 12 | 13 | ```sh 14 | git clone git@github.com:fractaledmind/euruko-2024.git 15 | cd euruko-2024 16 | ``` 17 | 18 | After cloning the repository, install the dependencies: 19 | 20 | ```sh 21 | bundle install 22 | ``` 23 | 24 | I have built the repository to be usable out-of-the-box. It contains a seeded production database, precompiled assets, and a binscript to start the server in production mode. The only other things you will need besides the RubyGems dependencies are multiple different Ruby versions and the load testing tool `oha`. 25 | 26 | ### Setup Ruby Versions 27 | 28 | By default, this repository uses Ruby 3.1.6, which is the most recent point release on the 3.1 branch. As a part of the exploration of the performance impact of different Ruby versions on SQLite-backed Rails applications, we will be testing the following Ruby versions: 29 | 30 | ``` 31 | ruby-3.3.5 32 | ruby-3.2.5 33 | ruby-3.1.6 34 | ``` 35 | 36 | Please make sure you have each of these Ruby versions installed on your machine. If you are using `rbenv`, you can install them with commands like the following: 37 | 38 | ```sh 39 | rbenv install 3.3.5 40 | ``` 41 | 42 | Or, if you are using `asdf`, you can install them like so: 43 | 44 | ```sh 45 | asdf install ruby 3.3.5 46 | ``` 47 | 48 | If you manage Ruby versions some other way, I'm sure you know how to install new Ruby versions with your tool of choice. 49 | 50 | ### Setup Load Testing 51 | 52 | Load testing can be done using the [`oha` CLI utility](https://github.com/hatoo/oha), which can be installed on MacOS via [homebrew](https://brew.sh): 53 | 54 | ```sh 55 | brew install oha 56 | ``` 57 | 58 | and on Windows via [winget](https://github.com/microsoft/winget-cli): 59 | 60 | ```sh 61 | winget install hatoo.oha 62 | ``` 63 | 64 | or using their [precompiled binaries](https://github.com/hatoo/oha?tab=readme-ov-file#installation) on other platforms. 65 | 66 | ## Load Testing 67 | 68 | Throughout this workshop, we will be load testing the application to observe how our various changes impact the performance of the application. In order to perform the load testing, you will need to run the web server in the `production` environment. I have provided a binscript to make this easier. To start the production server, run the following command: 69 | 70 | ```sh 71 | bin/serve 72 | ``` 73 | 74 | This simply a shortcut for the following command: 75 | 76 | ```sh 77 | RAILS_ENV=production RELAX_SSL=true RAILS_LOG_LEVEL=warn WEB_CONCURRENCY=10 RAILS_MAX_THREADS=5 bin/rails server 78 | ``` 79 | 80 | The `RELAX_SSL` environment variable is necessary to allow you to use `http://localhost`. The `RAILS_LOG_LEVEL` is set to `warn` to reduce the amount of logging output. Set `WEB_CONCURRENCY` to the number of cores you have on your laptop. I am on an M1 Macbook Pro with 10 cores, and thus I set the value to 10. The `RAILS_MAX_THREADS` controls the number of threads per worker. I left it at the default of 5, but you can tweak it to see how it affects performance. 81 | 82 | With your server running in one terminal window, you can use the load testing utility to test the app in another terminal window. Here is the shape of the command you will use to test the app: 83 | 84 | ```sh 85 | oha -c N -z 10s -m POST http://localhost:3000/benchmarking/PATH 86 | ``` 87 | 88 | `N` is the number of concurrent requests that `oha` will make. I recommend running a large variety of different scenarios with different values of `N`. Personally, I scale up from 1 to 256 concurrent requests, doubling the number of concurrent requests each time. In general, when `N` matches your `WEB_CONCURRENCY` number, this is mostly likely the sweet spot for this app. 89 | 90 | `PATH` can be any of the benchmarking paths defined in the app. The app has a few different paths that you can test. From the `routes.rb` file: 91 | 92 | ```ruby 93 | namespace :benchmarking do 94 | post "post_create" 95 | post "comment_create" 96 | post "post_destroy" 97 | post "comment_destroy" 98 | post "post_show" 99 | post "posts_index" 100 | post "user_show" 101 | end 102 | ``` 103 | 104 | You can validate that the application is properly set up for load testing by serving the application in one terminal window/tab (via `bin/serve`) and then running the following `curl` command in another terminal window/tab: 105 | 106 | ```sh 107 | curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3000/benchmarking/posts_index 108 | ``` 109 | 110 | If this returns `200`, then next ensure that you can run an `oha` command like the following: 111 | 112 | ```sh 113 | oha -c 1 -z 1s -m POST http://localhost:3000/benchmarking/posts_index 114 | ``` 115 | 116 | If this runs successfully, then you are ready to begin the workshop. 117 | 118 | ## Workshop 119 | 120 | You will find the workshop instructions in the `workshop/` directory. The workshop is broken down into a series of steps, each of which is contained in a separate markdown file. The workshop is designed to be self-guided, but I am available to help if you get stuck. Please feel free to reach out to me on Twitter at [@fractaledmind](https://twitter.com/fractaledmind) if you have any questions. 121 | 122 | The first step is to [run some baseline load tests](workshop/00-run-baseline-load-tests.md). Once you have completed that step, you can move on to the next step, which will be linked at the bottom of each step. 123 | -------------------------------------------------------------------------------- /workshop/06-restoring-from-a-backup.md: -------------------------------------------------------------------------------- 1 | ## Restoring from a Backup 2 | 3 | With Litestream streaming updates to your MinIO bucket, you can now restore your database from a backup. To do this, you can use the `litestream:restore` Rake task: 4 | 5 | ```sh 6 | RAILS_ENV=production bin/rails litestream:restore -- --database=storage/production.sqlite3 -o=storage/restored.sqlite3 7 | ``` 8 | 9 | This task will download the latest snapshot and WAL files from your MinIO bucket and restore them to your local database. 10 | 11 | When you run this task, you should see output like: 12 | 13 | ``` 14 | time=YYYY-MM-DDTHH:MM:SS.000+00:00 level=INFO msg="restoring snapshot" db=~/path/to/railsconf-2024/storage/production.sqlite3 replica=s3 generation=e9885230835eaf8b index=0 path=storage/restored.sqlite3.tmp 15 | time=YYYY-MM-DDTHH:MM:SS.000+00:00 level=INFO msg="restoring wal files" db=~/path/to/railsconf-2024/storage/production.sqlite3 replica=s3 generation=e9885230835eaf8b index_min=0 index_max=0 16 | time=YYYY-MM-DDTHH:MM:SS.000+00:00 level=INFO msg="downloaded wal" db=~/path/to/railsconf-2024/storage/production.sqlite3 replica=s3 generation=e9885230835eaf8b index=0 elapsed=2.622459ms 17 | time=YYYY-MM-DDTHH:MM:SS.000+00:00 level=INFO msg="applied wal" db=~/path/to/railsconf-2024/storage/production.sqlite3 replica=s3 generation=e9885230835eaf8b index=0 elapsed=913.333µs 18 | time=YYYY-MM-DDTHH:MM:SS.000+00:00 level=INFO msg="renaming database from temporary location" db=~/path/to/railsconf-2024/storage/production.sqlite3 replica=s3 19 | ``` 20 | 21 | You can inspect the contents of the `restored` database with the `sqlite3` console: 22 | 23 | ```sh 24 | sqlite3 storage/restored.sqlite3 25 | ``` 26 | 27 | Check how many records are in the `posts` table: 28 | 29 | ```sql 30 | SELECT COUNT(*) FROM posts; 31 | ``` 32 | 33 | and the same for the `comments` table: 34 | 35 | ```sql 36 | SELECT COUNT(*) FROM comments; 37 | ``` 38 | 39 | You should see the same number of records in the `restored` database as in the `production` database. 40 | 41 | You can close the `sqlite3` console by typing `.quit`. 42 | 43 | ### Verifying Backups 44 | 45 | Running a single restoration like this is useful for testing, but in a real-world scenario, you would likely want to ensure that your backups are both fresh and restorable. In order to ensure that you consistently have a resilient backup strategy, the Litestream gem provides a `Litestream.verify!` method to, well, verify your backups. It is worth noting, to be clear, that this is not a feature of the underlying Litestream utility, but only a feature of the Litestream gem itself. 46 | 47 | The method takes the path to a database file that you have configured Litestream to backup; that is, it takes one of the `path` values under the `dbs` key in your `litestream.yml` configuration file. In order to verify that the backup for that database is both restorable and fresh, the method will add a new row to that database under the `_litestream_verification` table. It will then wait 10 seconds to give the Litestream utility time to replicate that change to whatever storage providers you have configured. After that, it will download the latest backup from that storage provider and ensure that this verification row is present in the backup. If the verification row is _not_ present, the method will raise a `Litestream::VerificationFailure` exception. 48 | 49 | Since we are using the Puma plugin, we can actually run the `Litestream.verify!` method directly from the Rails console: 50 | 51 | ```sh 52 | RAILS_ENV=production bin/rails console 53 | ``` 54 | 55 | and run: 56 | 57 | ```ruby 58 | Litestream.verify!("storage/production.sqlite3") 59 | ``` 60 | 61 | After 10 seconds, you will see it return `true`. 62 | 63 | If you want to force a verification failure, you will need to comment out the Puma plugin and stop the `bin/serve` process. To confirm that the replication process is not running, check your running processes: 64 | 65 | ```sh 66 | ps -a | grep litestream 67 | ``` 68 | 69 | If you see a process running, you can kill it with: 70 | 71 | ```sh 72 | kill -9 73 | ``` 74 | 75 | where `` is the process ID of the Litestream process (the first number in the set of three columns returned by the `ps` command). 76 | 77 | Once you are sure that the replication process is not running, open the Rails console again: 78 | 79 | ```sh 80 | RAILS_ENV=production bin/rails console 81 | ``` 82 | 83 | This time, when you run: 84 | 85 | ```ruby 86 | Litestream.verify!("storage/production.sqlite3") 87 | ``` 88 | 89 | After 10 seconds, you will see an exception raised: 90 | 91 | ``` 92 | Verification failed for `storage/production.sqlite3` (Litestream::VerificationFailure) 93 | ``` 94 | 95 | This demonstrates that the `verify!` method truly does only report success when the verification row is present in the backup, which requires the replication process to be running smoothly. 96 | 97 | ### Automating Backup Verification 98 | 99 | Even better than manually verifying your backups is to automate the process. In addition to the `verify!` method, the Litestream gem provides a background job to verify our backups for us. If you have an Active Job adapter that supports recurring jobs, you can configure it to run this job at regular intervals. Or, you could simply use `cron` to run the job at regular intervals. In this workshop, we will setup Solid Queue as our background job adapter in the next step. Once it is setup, we will configure this recurring job. Until then, let's explore what the job does and run it manually. 100 | 101 | If you inspec the gem's source code, you will find that the job is implemented like so: 102 | 103 | ```ruby 104 | module Litestream 105 | class VerificationJob < ActiveJob::Base 106 | queue_as Litestream.queue 107 | 108 | def perform 109 | Litestream::Commands.databases.each do |db_hash| 110 | Litestream.verify!(db_hash["path"]) 111 | end 112 | end 113 | end 114 | end 115 | ``` 116 | 117 | This job will allow us to verify our backup strategy for all databases we have configured Litestream to replicate. If any database fails verification, the job will raise an exception, which will be caught by Rails and logged. 118 | 119 | All we need now is a job backend that will allow us to schedule this job to run at regular intervals. 120 | 121 | ### Conclusion 122 | 123 | It is essential to have a backup strategy in place for your application's data. Litestream is a great tool for this purpose. In this step we have installed the gem, configured it, explored how the replication and restoration processes work, and learned how to verify our backups. With backup verification setup, the next step is to add a background job adapter. 124 | -------------------------------------------------------------------------------- /workshop/00-run-baseline-load-tests.md: -------------------------------------------------------------------------------- 1 | ## Run Baseline Load Tests 2 | 3 | Before we start, let's establish a baseline. This is the starting point from which we will measure our progress. It's important to have a clear understanding of where we are now, so we can see how far we've come as we progress. 4 | 5 | We will run two load tests to assess the current state of the application's performance; one for the `post_create` action and one for the `posts_index` action. We will run each test with 20 concurrent requests for 10 seconds. 6 | 7 | We will run the read operation first since it can't have any effect on the write operation performance (while the inverse cannot be said). But first, it is often worth checking that the endpoint is responding as expected _before_ running a load test. So, let's make a single `curl` request first. 8 | 9 | In one terminal window, start the Rails server: 10 | 11 | ```sh 12 | bin/serve 13 | ``` 14 | 15 | In another, make a single `curl` request to the `posts_index` endpoint: 16 | 17 | ```sh 18 | curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3000/benchmarking/posts_index 19 | ``` 20 | 21 | You should see a `200` response. If you see that response, everything is working as expected. If you don't, you will need to troubleshoot the issue before proceeding. 22 | 23 | Once we have verified that our Rails application is responding to the `benchmarking/posts_index` route as expected, we can run the load test and record the results. 24 | 25 | As stated earlier, we will use the `oha` tool to run the load test. We will send waves of 20 concurrent requests, which is twice the number of Puma workers that our application has spun up. We will run the test for 10 seconds. The command to run the load test is as follows: 26 | 27 | ```sh 28 | oha -c 20 -z 10s -m POST http://localhost:3000/benchmarking/posts_index 29 | ``` 30 | 31 | Running this on my 2021 M1 MacBook Pro (32 GB of RAM running MacOS 14.6.1) against our Rails 7.2.1 app with Ruby 3.1.6, I get the following results: 32 | 33 |
    34 | 242 RPS (click to see full breakdown) 35 | 36 | ``` 37 | Summary: 38 | Success rate: 100.00% 39 | Total: 10.0014 secs 40 | Slowest: 0.1948 secs 41 | Fastest: 0.0053 secs 42 | Average: 0.0828 secs 43 | Requests/sec: 242.4653 44 | 45 | Total data: 153.67 MiB 46 | Size/request: 65.43 KiB 47 | Size/sec: 15.37 MiB 48 | 49 | Response time histogram: 50 | 0.005 [1] | 51 | 0.024 [53] |■■ 52 | 0.043 [115] |■■■■■■ 53 | 0.062 [507] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 54 | 0.081 [578] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 55 | 0.100 [499] |■■■■■■■■■■■■■■■■■■■■■■■■■■■ 56 | 0.119 [329] |■■■■■■■■■■■■■■■■■■ 57 | 0.138 [189] |■■■■■■■■■■ 58 | 0.157 [85] |■■■■ 59 | 0.176 [43] |■■ 60 | 0.195 [6] | 61 | 62 | Response time distribution: 63 | 10.00% in 0.0474 secs 64 | 25.00% in 0.0605 secs 65 | 50.00% in 0.0790 secs 66 | 75.00% in 0.1027 secs 67 | 90.00% in 0.1257 secs 68 | 95.00% in 0.1410 secs 69 | 99.00% in 0.1636 secs 70 | 99.90% in 0.1841 secs 71 | 99.99% in 0.1948 secs 72 | 73 | 74 | Details (average, fastest, slowest): 75 | DNS+dialup: 0.0022 secs, 0.0011 secs, 0.0032 secs 76 | DNS-lookup: 0.0002 secs, 0.0000 secs, 0.0007 secs 77 | 78 | Status code distribution: 79 | [200] 2405 responses 80 | 81 | Error distribution: 82 | [20] aborted due to deadline 83 | ``` 84 |
    85 | 86 | It is worth noting that when I ran this load test 4 months ago on the same machine, things were notably worse. The p99.99 response time was **over 5 seconds**, the RPS was only **~40**, and some responses simply errored out. The fixes and improvements continuously made to Rails and the SQLite gem are clearly having a positive impact. 87 | 88 | Now that we have the baseline for the `posts_index` action, we can move on to the `post_create` action. We will follow the same steps as above, but this time we will run the load test on the `post_create` endpoint. 89 | 90 | With the Rails server still running in one terminal window, we can make a single `curl` request to the `post_create` endpoint in another: 91 | 92 | ```sh 93 | curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3000/benchmarking/post_create 94 | ``` 95 | 96 | Again, you should see a `200` response. If you don't, you will need to troubleshoot the issue before proceeding. 97 | 98 | Once we have verified that our Rails application is responding to the `benchmarking/post_create` route as expected, we can run the load test and record the results. 99 | 100 | ```sh 101 | oha -c 20 -z 10s -m POST http://localhost:3000/benchmarking/post_create 102 | ``` 103 | 104 | Running this on my 2021 M1 MacBook Pro (32 GB of RAM running MacOS 14.6.1) against our Rails 7.2.1 app with Ruby 3.1.6, I get the following results: 105 | 106 |
    107 | 95 RPS (click to see full breakdown) 108 | 109 | ``` 110 | Summary: 111 | Success rate: 100.00% 112 | Total: 10.0037 secs 113 | Slowest: 5.2195 secs 114 | Fastest: 0.0029 secs 115 | Average: 0.0387 secs 116 | Requests/sec: 94.9652 117 | 118 | Total data: 3.31 MiB 119 | Size/request: 3.65 KiB 120 | Size/sec: 339.07 KiB 121 | 122 | Response time histogram: 123 | 0.003 [1] | 124 | 0.525 [925] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 125 | 1.046 [0] | 126 | 1.568 [0] | 127 | 2.090 [0] | 128 | 2.611 [0] | 129 | 3.133 [0] | 130 | 3.655 [0] | 131 | 4.176 [0] | 132 | 4.698 [0] | 133 | 5.220 [4] | 134 | 135 | Response time distribution: 136 | 10.00% in 0.0037 secs 137 | 25.00% in 0.0062 secs 138 | 50.00% in 0.0094 secs 139 | 75.00% in 0.0166 secs 140 | 90.00% in 0.0397 secs 141 | 95.00% in 0.0620 secs 142 | 99.00% in 0.1307 secs 143 | 99.90% in 5.2195 secs 144 | 99.99% in 5.2195 secs 145 | 146 | 147 | Details (average, fastest, slowest): 148 | DNS+dialup: 0.0021 secs, 0.0012 secs, 0.0025 secs 149 | DNS-lookup: 0.0001 secs, 0.0000 secs, 0.0004 secs 150 | 151 | Status code distribution: 152 | [500] 661 responses 153 | [200] 269 responses 154 | 155 | Error distribution: 156 | [20] aborted due to deadline 157 | ``` 158 |
    159 | 160 | Immediately, it should jump out just how many `500` responses we are seeing. **71%** of the responses are returning an error status code. Suffice it to say, this is not at all what we want from our application. We also now see some requests taking over 5 seconds to complete, which is aweful. And our requests per second have plummeted to 2.5× to only **95**. 161 | 162 | Our first challenge is to fix these performance issues. 163 | 164 | > [!NOTE] 165 | > If you want to ensure that you are running your load tests from a clean slate each time, you can reset your database (drop the database, create it, migrate it, seed it) before running the tests. You can do this by running the following command: 166 | > ```sh 167 | > DISABLE_DATABASE_ENVIRONMENT_CHECK=1 RAILS_ENV=production bin/rails db:reset 168 | > ``` 169 | > This isn't _necessary_ for the purposes of this workshop, as the load test exact results don't change anything, but it can be helpful if you want to run fairer, more direct comparisons. 170 | 171 | - - - 172 | 173 | The next step is to begin improving performance. You will find that step's instructions in the `workshop/01-improving-performance.md` file. 174 | 175 | There were no code changes in this step. 176 | -------------------------------------------------------------------------------- /workshop/02-fixing-errored-responses.md: -------------------------------------------------------------------------------- 1 | ## Fixing Errored Responses 2 | 3 | As of today, a SQLite on Rails application will struggle with concurrency. Although Rails, since version 7.1.0, ensures that your SQLite databases are running in [WAL mode](https://www.sqlite.org/wal.html), this is insufficient to ensure quality performance for web applications under concurrent load. 4 | 5 | ### The Problem 6 | 7 | The first problem to fix are all of the `500` error responses that we saw in our `post_create` load test and occassionally in the `posts_index` test. If you look at the server logs, you will see the error being thrown is: 8 | 9 | ``` 10 | ActiveRecord::StatementInvalid (SQLite3::BusyException: database is locked): 11 | ``` 12 | 13 | This is the [`SQLITE_BUSY` exception](https://www.sqlite.org/rescode.html#busy). 14 | 15 | I will save you hours, if not days, of debugging and investigation and tell you that these errors are caused by Rails not opening transactions in what SQLite calls ["immediate mode"](https://www.sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions). In order to ensure only one write operation occurs at a time, SQLite uses a write lock on the database. Only one connection can hold the write lock at a time. By default, SQLite interprets the `BEGIN TRANSACTION` command as initiating a _deferred_ transaction. This means that SQLite will not attempt to acquire the database write lock until a write operation is made inside that transaction. In contrast, an _immediate_ transaction will attempt to acquire the write lock immediately upon the `BEGIN IMMEDIATE TRANSACTION` command being issued. 16 | 17 | Opening deferred transactions in a web application with multiple connections open to the database _nearly guarantees_ that you will see a large number of `SQLite3::BusyException` errors. This is because SQLite is unable to retry the write operation within the deferred transaction if the write lock is already held by another connection because any retry would risk the transaction operating against a different snapshot of the database state. 18 | 19 | Opening _immediate_ transactions, on the other hand, is safer in a multi-connection environment because SQLite can safely retry the transaction opening command until the write lock is available, since the transaction won't grab a snapshot until the write lock is acquired. 20 | 21 | While future versions of Rails will address this issue by opening immediate transactions by default, we must fix this issue ourselves in the meantime. 22 | 23 | So, how do we ensure that our Rails application makes all transactions immediate? 24 | 25 | ### The Solution 26 | 27 | As of [version 1.6.9](https://github.com/sparklemotion/sqlite3-ruby/releases/tag/v1.6.9), the [`sqlite3-ruby` gem](https://github.com/sparklemotion/sqlite3-ruby) allows you to configure the default transaction mode with the `default_transaction_mode` option when initializing a new `SQLite3::Database` instance. Since Rails passes any top-level keys in your `database.yml` configuration directly to the `sqlite3-ruby` database initializer, you can easily ensure that Rails’ SQLite transactions are all run in IMMEDIATE mode by updating your `default` configuration in the `database.yml` file: 28 | 29 | ```yaml 30 | default: &default 31 | adapter: sqlite3 32 | pool: <%= ENV. fetch("RAILS_MAX_THREADS") { 5 }%› 33 | timeout: 5000 34 | default_transaction_mode: IMMEDIATE 35 | ``` 36 | 37 | This will ensure that all transactions in your Rails application are run in immediate mode. Let's run our load tests again to see if this fixes the `500` errors. 38 | 39 | ### Running the Load Tests 40 | 41 | As always, let's restart our application server first. Go to your first terminal window/tab and use `Ctrl + C` to stop the server, then re-run `bin/serve` to restart it. 42 | 43 | Once you have the server running with the new Ruby version, you can run the `posts_index` load test again in another terminal window/tab: 44 | 45 | ```sh 46 | oha -c 20 -z 10s -m POST http://localhost:3000/benchmarking/posts_index 47 | ``` 48 | 49 | You should not see any `500` errors this time: 50 | 51 |
    52 | 294 RPS (click to see full breakdown) 53 | 54 | ``` 55 | Summary: 56 | Success rate: 100.00% 57 | Total: 10.0004 secs 58 | Slowest: 0.7988 secs 59 | Fastest: 0.0032 secs 60 | Average: 0.0682 secs 61 | Requests/sec: 294.0875 62 | 63 | Total data: 186.72 MiB 64 | Size/request: 65.46 KiB 65 | Size/sec: 18.67 MiB 66 | 67 | Response time histogram: 68 | 0.003 [1] | 69 | 0.083 [2623] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 70 | 0.162 [247] |■■■ 71 | 0.242 [22] | 72 | 0.321 [7] | 73 | 0.401 [2] | 74 | 0.481 [2] | 75 | 0.560 [9] | 76 | 0.640 [2] | 77 | 0.719 [5] | 78 | 0.799 [1] | 79 | 80 | Response time distribution: 81 | 10.00% in 0.0512 secs 82 | 25.00% in 0.0579 secs 83 | 50.00% in 0.0625 secs 84 | 75.00% in 0.0673 secs 85 | 90.00% in 0.0830 secs 86 | 95.00% in 0.1065 secs 87 | 99.00% in 0.2179 secs 88 | 99.90% in 0.6678 secs 89 | 99.99% in 0.7988 secs 90 | 91 | 92 | Details (average, fastest, slowest): 93 | DNS+dialup: 0.0012 secs, 0.0007 secs, 0.0017 secs 94 | DNS-lookup: 0.0002 secs, 0.0000 secs, 0.0006 secs 95 | 96 | Status code distribution: 97 | [200] 2921 responses 98 | 99 | Error distribution: 100 | [20] aborted due to deadline 101 | ``` 102 |
    103 | 104 | But, we have seen that the `posts_index` load test can sometimes run smoothly. Let's run the `post_create` load test again to see if the `500` errors are gone: 105 | 106 | ```sh 107 | oha -c 20 -z 10s -m POST http://localhost:3000/benchmarking/post_create 108 | ``` 109 | 110 | No errors! The `500` errors are gone: 111 | 112 |
    113 | 816 RPS (click to see full breakdown) 114 | 115 | ``` 116 | Summary: 117 | Success rate: 100.00% 118 | Total: 10.0010 secs 119 | Slowest: 1.0928 secs 120 | Fastest: 0.0022 secs 121 | Average: 0.0245 secs 122 | Requests/sec: 816.3180 123 | 124 | Total data: 69.03 MiB 125 | Size/request: 8.68 KiB 126 | Size/sec: 6.90 MiB 127 | 128 | Response time histogram: 129 | 0.002 [1] | 130 | 0.111 [7821] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 131 | 0.220 [249] |■ 132 | 0.329 [36] | 133 | 0.438 [27] | 134 | 0.547 [4] | 135 | 0.657 [4] | 136 | 0.766 [0] | 137 | 0.875 [0] | 138 | 0.984 [1] | 139 | 1.093 [1] | 140 | 141 | Response time distribution: 142 | 10.00% in 0.0033 secs 143 | 25.00% in 0.0050 secs 144 | 50.00% in 0.0116 secs 145 | 75.00% in 0.0245 secs 146 | 90.00% in 0.0540 secs 147 | 95.00% in 0.0968 secs 148 | 99.00% in 0.2148 secs 149 | 99.90% in 0.4419 secs 150 | 99.99% in 1.0928 secs 151 | 152 | 153 | Details (average, fastest, slowest): 154 | DNS+dialup: 0.0007 secs, 0.0006 secs, 0.0009 secs 155 | DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0002 secs 156 | 157 | Status code distribution: 158 | [200] 8144 responses 159 | 160 | Error distribution: 161 | [20] aborted due to deadline 162 | ``` 163 |
    164 | 165 | ### Conclusion 166 | 167 | We have confirmed that this simple configuration change has fixed the `500` errors in our Rails application. By setting the `default_transaction_mode` option to `IMMEDIATE` in the `database.yml` file, we can ensure that all transactions in our Rails application are run in immediate mode. And this will help prevent `500` errors caused by SQLite's default deferred transaction mode. 168 | 169 | -------------------------------------------------------------------------------- /workshop/11-branch-specific-databases.md: -------------------------------------------------------------------------------- 1 | ## Branch-specific Databases 2 | 3 | Another enhancement that SQLite affords is a nice developer experience — branch-specific databases. If you have ever worked in team on a single codebase, you very likely have experienced the situation where you are working on a longer running feature branch, but then a colleague asks you to review or help on a feature branch that they had been working on. What happens when you had some migrations in your branch and they had some migrations in their branch? Because your database typically has no awareness of you changing git branches, your database ends up in a mixed state with both sets of migrations applied. When you return to your branch, your database is in an altered state than you left it. 4 | 5 | Because SQLite stores your entire database in literal files on disk and only runs embedded in your application process, databases are very cheap to create. So, what if we simply spun up a completely new database for each and every git branch you use in your application? Not only would this solve the mixed migrations issue, but it also opens up the ability to prepare branch-specific data that can then be shared with collegues or used in manual testing for that branch. 6 | 7 | ### The Implementation 8 | 9 | So, what all is entailed in getting such a setup for your Rails application? Well, the basic implementation is literally only 2 lines of code in 2 files! 10 | 11 | Firstly, in your `database.yml` file, we need to update how we set the database name for the `primary` database. Of course, if we wanted or needed to, we could do the same for our `queue` and `cache` databases as well, but I personally haven't yet needed that level of isolation. Instead of setting the name of the `primary` database to the current Rails environment, we want to set the name to the current git branch. Since we can execute shell commands easily in Ruby, this is nothing more than `git branch --show-current`. Because we can be in a detached state in git, we also need a fallback. You can either use `"development"` or `"detached"` or whatever else you'd like. In the end, our new configuration will look something like: 12 | 13 | ```yaml 14 | primary: &primary 15 | <<: *default 16 | database: storage/<%= `git branch --show-current`.chomp || "detached" %>.sqlite3 17 | ``` 18 | 19 | This ensures that whenever Rails loads the database configuration, it will simply introspect the current git branch and use that as the database name. The second requirement is that this database file be properly prepared; that is, have the schema set and seeds ran. 20 | 21 | Rails provides a Rake task for precisely this use: `db:prepare`. More importantly for us, though, is that Rails provides a corresponding Ruby method as well: `ActiveRecord::Tasks::DatabaseTasks.prepare_all`. We simply need to ensure that this is run whenever Rails boots, and this is just what the `config.after_initialize` hook is for. Since this is only a development feature, we can simply add this to our `config/environments/development.rb` file: 22 | 23 | ```ruby 24 | # Ensure that the git branch database schema is prepared 25 | config.after_initialize do 26 | ActiveRecord::Tasks::DatabaseTasks.prepare_all 27 | end 28 | ``` 29 | 30 | This hook ensures that whenever Rails boots, our database will definitely be prepared. This means when you open a console session, start the application server, or run a `rails runner` task. This is a very powerful feature that can save you a lot of time and headache when working on multiple branches simultaneously. 31 | 32 | ### The Enhancement 33 | 34 | But, what if you want to copy the table data from one branch to another? Well, that's a bit more involved, but it's still quite doable. The core piece of the implementation puzzle is SQLite's [`ATTACH` functionality](https://www.sqlite.org/lang_attach.html), which allows you to, well, attach another database to the current database connection. This allows you to run queries that span multiple databases. The basic idea is to attach the source database to the target database, and then copy the data from the source to the target. Mixin a bit of dynamic string generation, and you can craft a shell function that merges all table data from a source database into a target database: 35 | 36 | ```sh 37 | db_merge() { 38 | target="$1" 39 | source="$2" 40 | 41 | # Attach merging database to base database 42 | merge_sql="ATTACH DATABASE '$source' AS merging; BEGIN TRANSACTION;" 43 | # Loop through each table in merging database 44 | for table_name in $(sqlite3 $source "SELECT name FROM sqlite_master WHERE type = 'table';") 45 | do 46 | columns=$(sqlite3 $source "SELECT name FROM pragma_table_info('$table_name');" | tr '\n' ',' | sed 's/.$//') 47 | # Merge table data into target database, ignoring any duplicate entries 48 | merge_sql+=" INSERT OR IGNORE INTO $table_name ($columns) SELECT $columns FROM merging.$table_name;" 49 | done 50 | merge_sql+=" COMMIT TRANSACTION; DETACH DATABASE merging;" 51 | 52 | sqlite3 "$target" "$merge_sql" 53 | } 54 | ``` 55 | 56 | What I like to do is add a script to the `bin/` directory that provides the ability to branch or merge databases easily. Let's create a `bin/db` script and make it executable: 57 | 58 | ```sh 59 | touch bin/db 60 | chmod u+x bin/db 61 | ``` 62 | 63 | In addition to merging table data, we can provide the ability to clone a database's schema into a new database as well: 64 | 65 | ```sh 66 | db_branch() { 67 | target="$1" 68 | source="$2" 69 | 70 | sqlite3 "$source" ".schema --nosys" | sqlite3 "$target" 71 | } 72 | ``` 73 | 74 | All our `bin/db` script will do is provided structured access to these functions. We want it to support both a `branch` and a `merge` command, and the `branch` command should default to copying both the schema and the table data, but you can specify to only copy the schema. The `merge` command should only copy the table data. 75 | 76 | The file is relatively long (~175 lines), so I won't copy it here, but you can find it in the repository at this commit. In addition to our `after_initialize` automated hook, we now have the ability to branch and merge whatever SQLite databases we like, whenever we like. 77 | 78 | To give one example of how we could use this script to automate branching and copying table data, we could create a post-checkout git hook: 79 | 80 | ```sh 81 | touch .git/hooks/post-checkout 82 | chmod u+x .git/hooks/post-checkout 83 | ``` 84 | 85 | And then write some shell to ensure that we have checked out a new branch and call our `bin/db branch` command with the new branch and previous branch: 86 | 87 | ```sh 88 | # If this is a file checkout, do nothing 89 | if [ "$3" == "0" ]; then exit; fi 90 | 91 | # If the prev and curr refs don't match, do nothing 92 | if [ "$1" != "$2" ]; then exit; fi 93 | 94 | reflog=$(git reflog) 95 | prev_branch=$(echo $reflog | awk 'NR==1{ print $6; exit }') 96 | curr_branch=$(echo $reflog | awk 'NR==1{ print $8; exit }') 97 | num_checkouts=$(echo $reflog | grep -o $curr_branch | wc -l) 98 | 99 | # If the number of checkouts equals one, a new branch has been created 100 | if [ ${num_checkouts} -eq 1 ]; then 101 | bin/db branch "storage/$curr_branch.sqlite3" "storage/$prev_branch.sqlite3" --with-data 102 | fi 103 | ``` 104 | 105 | With this in place, we wouldn't really need the `after_initialize` Rails hook as our new branch database would be created in this post-checkout git hook. Moreover, in this example that database would include all of the data from the original branch database as well. 106 | 107 | Depending on how your team works, this kind of automation may be a bit too heavy handed. I personally prefer to simply have the `bin/db` script and run `bin/db merge` whenever I want to populate a new database with table data from a pre-existing database. But, I wanted to at least demonstrate the power and flexibility possible with these tools. 108 | 109 | Regardless of how precisely you wire everything together, working with branch-specific databases has been a solid developer experience improvement for me. 110 | 111 | ### Conclusion 112 | 113 | With the repository working with branch-specific databases, the final step is to setup error monitoring. --------------------------------------------------------------------------------