rubino-agent 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# Ruby on Rails — the Rails Way
|
|
2
|
+
|
|
3
|
+
Pragmatic, current Rails for **Ruby 3.2–3.4** and **Rails 7.1–8.x**. Optimize for convention, clarity, and safe production changes.
|
|
4
|
+
|
|
5
|
+
> **Precedence rule:** existing project conventions ALWAYS win. If the app already uses interactors, dry-rb, Trailblazer, or a custom layout, match it. The patterns below are defaults for greenfield or under-specified code.
|
|
6
|
+
|
|
7
|
+
## Convention over configuration & the app/ layout
|
|
8
|
+
|
|
9
|
+
Rails autoloads via **Zeitwerk**: file path ⇒ constant name. `app/services/billing/charge_card.rb` ⇒ `Billing::ChargeCard`. Don't `require` app code; let Zeitwerk resolve it. Don't fight the naming (`app/models/user.rb` ⇒ `User`).
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
app/
|
|
13
|
+
models/ # Active Record + POROs that own domain data
|
|
14
|
+
user.rb
|
|
15
|
+
user/ # model-scoped concerns: User::Searchable -> app/models/user/searchable.rb
|
|
16
|
+
controllers/ # skinny; HTTP <-> domain glue only
|
|
17
|
+
services/ # service objects: one public #call
|
|
18
|
+
jobs/ # ActiveJob subclasses
|
|
19
|
+
mailers/
|
|
20
|
+
views/
|
|
21
|
+
components/ # ViewComponent, if used
|
|
22
|
+
models/concerns/ # cross-model concerns (use sparingly)
|
|
23
|
+
controllers/concerns/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Add your own top-level dirs (`app/queries`, `app/policies`, `app/forms`) freely — anything under `app/` is autoloaded.
|
|
27
|
+
|
|
28
|
+
## Active Record
|
|
29
|
+
|
|
30
|
+
### Associations
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class Post < ApplicationRecord
|
|
34
|
+
belongs_to :author, class_name: "User" # required by default (Rails 5+)
|
|
35
|
+
has_many :comments, dependent: :destroy
|
|
36
|
+
has_many :commenters, through: :comments, source: :user
|
|
37
|
+
has_one :feature_flag
|
|
38
|
+
has_many :tags, dependent: :delete_all # skips callbacks; faster, use when no callbacks needed
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `dependent: :destroy` runs callbacks per row (N deletes). `:delete_all` is one SQL DELETE but skips callbacks. `:nullify` to orphan.
|
|
43
|
+
- `belongs_to` is `optional: false` by default — add `optional: true` for nullable FKs, don't just remove the validation.
|
|
44
|
+
- Use `inverse_of` when Rails can't infer it (custom `class_name`/`foreign_key`) to avoid loading the parent twice.
|
|
45
|
+
- Always back associations with a DB **foreign key** (`add_foreign_key`) — `dependent:` is app-level only.
|
|
46
|
+
|
|
47
|
+
### Validations
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
|
51
|
+
validates :state, inclusion: { in: %w[draft published] }
|
|
52
|
+
validate :publish_date_in_future, if: :published?
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Uniqueness validation has a race** — two requests can pass simultaneously. Always pair it with a **DB unique index**; rescue `ActiveRecord::RecordNotUnique` for the true guarantee.
|
|
56
|
+
|
|
57
|
+
### Scopes
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
scope :published, -> { where(state: "published") }
|
|
61
|
+
scope :recent, ->(n = 10) { order(created_at: :desc).limit(n) }
|
|
62
|
+
|
|
63
|
+
# Class method is equivalent and better when logic is non-trivial:
|
|
64
|
+
def self.for_account(account) = where(account: account)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A scope MUST return a relation (chainable). Guard conditional scopes: `scope :search, ->(q) { where("name ILIKE ?", "%#{q}%") if q.present? }` — returning `nil`/`all` keeps it chainable. Prefer `where.not`, `merge`, and named scopes over raw SQL fragments.
|
|
68
|
+
|
|
69
|
+
### Callbacks — minimize them
|
|
70
|
+
|
|
71
|
+
Callbacks create hidden control flow that fires on every save, breaks in bulk operations, and makes tests slow. **Default to NOT using them for business logic.**
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# AVOID: side effects buried in a callback
|
|
75
|
+
class Order < ApplicationRecord
|
|
76
|
+
after_create :charge_customer, :send_receipt # fires in tests, seeds, imports...
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# PREFER: explicit orchestration in a service object
|
|
80
|
+
class PlaceOrder
|
|
81
|
+
def call(order)
|
|
82
|
+
order.save!
|
|
83
|
+
ChargeCustomer.new.call(order)
|
|
84
|
+
OrderMailer.receipt(order).deliver_later
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Acceptable callback uses: normalizing/deriving the record's **own** data (`before_validation` to downcase email), maintaining counters, setting defaults. Avoid callbacks that touch other records, send mail, enqueue jobs, or call external services. Never put `after_commit` chains across models — they become untraceable.
|
|
90
|
+
|
|
91
|
+
### Query interface
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
User.where(active: true).where.not(role: "admin").order(:name)
|
|
95
|
+
User.where(id: ids) # IN (...)
|
|
96
|
+
User.where("age >= ?", 18) # parameterized — never interpolate user input
|
|
97
|
+
Post.where(author: { admin: true }) # hash conditions across joins (Rails 7+)
|
|
98
|
+
User.where.missing(:posts) # LEFT JOIN ... WHERE posts.id IS NULL
|
|
99
|
+
Order.where(created_at: 1.day.ago..) # endless range
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Never string-interpolate user input into `where` — see `references/security.md` for SQL injection.
|
|
103
|
+
|
|
104
|
+
### Avoiding N+1: includes / preload / eager_load
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# N+1: one query per post for comments
|
|
108
|
+
Post.all.each { |p| p.comments.size }
|
|
109
|
+
|
|
110
|
+
# includes: Rails picks preload (2 queries) or eager_load (JOIN) automatically
|
|
111
|
+
Post.includes(:comments).each { |p| p.comments.size }
|
|
112
|
+
|
|
113
|
+
# Force the strategy when you need to:
|
|
114
|
+
Post.preload(:comments) # always separate queries; can't filter on comments
|
|
115
|
+
Post.eager_load(:comments) # always LEFT JOIN; needed to WHERE on the association
|
|
116
|
+
Post.includes(:comments).where(comments: { spam: false }).references(:comments)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Rule of thumb: `preload` (2 queries) is cheaper unless you must filter/order by the associated table, then use `eager_load`/`references`. Detect N+1 with the **bullet** gem or `prosopite`. Nested: `includes(comments: :author)`.
|
|
120
|
+
|
|
121
|
+
### select / pluck
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
User.pluck(:email) # ["a@x.com", ...] — no model instantiation, fast
|
|
125
|
+
User.pluck(:id, :email) # [[1, "a@x.com"], ...]
|
|
126
|
+
User.where(active: true).pick(:id) # first value only
|
|
127
|
+
User.select(:id, :email) # ActiveRecord objects, only those columns loaded
|
|
128
|
+
User.sum(:balance) # aggregate in SQL, not Ruby
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Use `pluck` for "just give me values"; `select` when you still need model behavior. Don't `User.all.map(&:email)` when `pluck(:email)` does it in one query.
|
|
132
|
+
|
|
133
|
+
### find_each / in_batches
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# Load 100k rows without blowing memory — batches of 1000 by default:
|
|
137
|
+
User.where(active: true).find_each { |u| u.recompute! }
|
|
138
|
+
|
|
139
|
+
User.in_batches(of: 500) do |relation|
|
|
140
|
+
relation.update_all(synced_at: Time.current) # one UPDATE per batch
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`find_each` ignores `order` (it orders by primary key for cursoring). For bulk column updates use `update_all`/`in_batches` (no callbacks/validations) — see migration note below.
|
|
145
|
+
|
|
146
|
+
### Transactions & locking
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
ApplicationRecord.transaction do
|
|
150
|
+
account.withdraw!(amount)
|
|
151
|
+
recipient.deposit!(amount)
|
|
152
|
+
raise ActiveRecord::Rollback if fraud? # rolls back without raising out
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Gotchas: a transaction commits at the **outermost** block end; nested transactions don't roll back independently unless `requires_new: true`. **Never enqueue a job or call an external API inside a transaction** — use `after_commit`/`enqueue after commit` so you don't act on uncommitted (or rolled-back) data.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# Optimistic locking: add a `lock_version` integer column; Rails raises on stale write
|
|
160
|
+
# StaleObjectError => reload & retry
|
|
161
|
+
|
|
162
|
+
# Pessimistic locking: SELECT ... FOR UPDATE, blocks other writers
|
|
163
|
+
Account.transaction do
|
|
164
|
+
account = Account.lock.find(id) # or .lock("FOR UPDATE NOWAIT")
|
|
165
|
+
account.update!(balance: account.balance - amount)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
product.with_lock { product.decrement!(:stock) } # transaction + row lock
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Use **optimistic** for low-contention web edits, **pessimistic** for money/inventory where you must serialize.
|
|
172
|
+
|
|
173
|
+
### Safe migrations (strong_migrations mindset)
|
|
174
|
+
|
|
175
|
+
A migration that locks a large table takes the app down. Use the **strong_migrations** gem and follow these:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
# DON'T: add NOT NULL column with default on a big table -> full table rewrite / lock (old PG)
|
|
179
|
+
add_column :users, :status, :string, null: false, default: "active"
|
|
180
|
+
|
|
181
|
+
# DO: nullable add, backfill in batches, then enforce
|
|
182
|
+
class AddStatus < ActiveRecord::Migration[7.2]
|
|
183
|
+
disable_ddl_transaction! # required for CONCURRENTLY
|
|
184
|
+
def change
|
|
185
|
+
add_column :users, :status, :string # nullable, no default
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
# separate migration / rake task: backfill
|
|
189
|
+
User.in_batches(of: 5_000) { |b| b.update_all(status: "active") }
|
|
190
|
+
# then: change_column_null + add default in a later deploy
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Indexes: build without locking writes
|
|
195
|
+
add_index :users, :email, algorithm: :concurrently, unique: true
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Rules: **add columns nullable**, **backfill in batches** (never `update_all` a whole giant table in one statement under load), **add indexes `algorithm: :concurrently`** (with `disable_ddl_transaction!`), add NOT NULL/FK as `validate: false` then `validate_foreign_key`/`validate_check_constraint` separately, drop columns via `ignored_columns` first. Make migrations reversible (`change` or explicit `up`/`down`).
|
|
199
|
+
|
|
200
|
+
## Skinny controllers / rich models
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# AVOID: business logic in the controller
|
|
204
|
+
def create
|
|
205
|
+
@order = Order.new(order_params)
|
|
206
|
+
@order.total = @order.line_items.sum(&:price) * 1.08
|
|
207
|
+
if @order.save
|
|
208
|
+
Stripe::Charge.create(...)
|
|
209
|
+
OrderMailer.receipt(@order).deliver_later
|
|
210
|
+
redirect_to @order
|
|
211
|
+
else
|
|
212
|
+
render :new, status: :unprocessable_entity
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# PREFER: controller delegates to a service / model method
|
|
217
|
+
def create
|
|
218
|
+
result = PlaceOrder.new.call(order_params)
|
|
219
|
+
if result.success?
|
|
220
|
+
redirect_to result.order, notice: "Order placed"
|
|
221
|
+
else
|
|
222
|
+
@order = result.order
|
|
223
|
+
render :new, status: :unprocessable_entity
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Controllers should: parse params, invoke one domain call, set status/flash, render/redirect. Push everything else down. See `references/oo-design.md` for service/Result object shapes.
|
|
229
|
+
|
|
230
|
+
## RESTful routing & resources
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
resources :posts do
|
|
234
|
+
resources :comments, only: %i[create destroy], shallow: true
|
|
235
|
+
member { post :publish } # POST /posts/:id/publish
|
|
236
|
+
collection { get :search } # GET /posts/search
|
|
237
|
+
end
|
|
238
|
+
resource :session, only: %i[new create destroy] # singular: no :id
|
|
239
|
+
namespace :admin { resources :users }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Prefer the 7 standard actions; when you reach for many custom member routes, that's a sign a **new resource** is hiding (`posts/:id/publish` ⇒ consider `resources :publications`). Use `only:`/`except:` to keep the route table tight.
|
|
243
|
+
|
|
244
|
+
## Strong parameters
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
def post_params
|
|
248
|
+
params.require(:post).permit(:title, :body, tag_ids: [], meta: {})
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`permit(:a, :b)` allowlists scalars; `tag_ids: []` permits an array; `meta: {}` permits an arbitrary hash (use cautiously). Never `permit!` user input. Rails 8 adds `params.expect(post: [:title, :body])` which raises a 400 on malformed structure — prefer it on Rails 8. See `references/security.md` for mass assignment.
|
|
253
|
+
|
|
254
|
+
## Concerns — done right and abused
|
|
255
|
+
|
|
256
|
+
`ActiveSupport::Concern` handles module dependencies and the `included do ... end` block.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# app/models/post/publishable.rb -> Post::Publishable (model-scoped concern)
|
|
260
|
+
module Post::Publishable
|
|
261
|
+
extend ActiveSupport::Concern
|
|
262
|
+
|
|
263
|
+
included do
|
|
264
|
+
scope :published, -> { where.not(published_at: nil) }
|
|
265
|
+
validates :published_at, presence: true, if: :published?
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def publish!(now = Time.current) = update!(published_at: now)
|
|
269
|
+
|
|
270
|
+
class_methods do
|
|
271
|
+
def latest_published = published.order(published_at: :desc)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
class Post < ApplicationRecord
|
|
276
|
+
include Publishable # resolves to Post::Publishable
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Good concern:** cohesive, named after a capability (`Publishable`, `Archivable`), ideally model-scoped under `app/models/<model>/`, shared by ≥2 models or extracted to shrink a fat model meaningfully.
|
|
281
|
+
|
|
282
|
+
**Concern abuse:** a "concern" that's just a junk drawer; a concern only one model uses and that references private internals of the host (that's not reuse, it's hiding code); deep `included do` blocks that mutate the host in surprising ways. If a concern needs the host's guts and isn't reused, it's a candidate for a **service or value object** instead (see `references/oo-design.md`). Concerns share behavior; they don't reduce coupling.
|
|
283
|
+
|
|
284
|
+
## Service objects (app/services)
|
|
285
|
+
|
|
286
|
+
One public method, usually `#call`. Verb-named class. Returns a Result, not a boolean grab-bag.
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# app/services/orders/place_order.rb -> Orders::PlaceOrder
|
|
290
|
+
module Orders
|
|
291
|
+
class PlaceOrder
|
|
292
|
+
Result = Data.define(:order, :error) do
|
|
293
|
+
def success? = error.nil?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def initialize(payments: Payments::Gateway.new) = @payments = payments # inject collaborators
|
|
297
|
+
|
|
298
|
+
def call(params)
|
|
299
|
+
order = Order.new(params)
|
|
300
|
+
Order.transaction do
|
|
301
|
+
order.save!
|
|
302
|
+
@payments.charge!(order)
|
|
303
|
+
end
|
|
304
|
+
Result.new(order:, error: nil)
|
|
305
|
+
rescue ActiveRecord::RecordInvalid, Payments::Error => e
|
|
306
|
+
Result.new(order:, error: e.message)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Inject dependencies via the constructor with sensible defaults (testable, no global mocks). Don't make services stateful across calls. See `references/oo-design.md` for Result/Either and `references/errors-and-types.md` for rescue discipline.
|
|
313
|
+
|
|
314
|
+
## ActiveJob & background jobs
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
class SyncContactJob < ApplicationJob
|
|
318
|
+
queue_as :default
|
|
319
|
+
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
|
|
320
|
+
discard_on ActiveJob::DeserializationError # record was deleted; don't retry forever
|
|
321
|
+
|
|
322
|
+
def perform(contact_id)
|
|
323
|
+
contact = Contact.find_by(id: contact_id)
|
|
324
|
+
return unless contact # idempotent: tolerate missing record
|
|
325
|
+
CRM.upsert(contact) # this op must itself be idempotent
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
SyncContactJob.perform_later(contact.id)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- **Pass IDs, not objects** — args are serialized; passing a record serializes a stale snapshot (`GlobalID` reloads it but adds a query; IDs are explicit and let you handle deletion).
|
|
333
|
+
- **Idempotency is mandatory** — jobs run at-least-once; a retry must not double-charge or double-send. Use unique keys / upserts / "already processed?" guards.
|
|
334
|
+
- **Retries:** `retry_on` for transient errors with backoff (`wait: :polynomially_longer`), `discard_on` for permanent ones. Cap attempts.
|
|
335
|
+
- **Queue choice:** separate latency-sensitive (`:mailers`, `:default`) from slow/bulk (`:low`, `:imports`) so a backlog of imports doesn't delay password-reset emails.
|
|
336
|
+
- **Backend:** **Solid Queue** (DB-backed, the Rails 8 default, no Redis) or **Sidekiq** (Redis, high throughput). Solid Queue ships in the default stack; pick Sidekiq when you need its throughput/ecosystem. Enqueue jobs **after commit**, not inside the transaction.
|
|
337
|
+
|
|
338
|
+
## Current attributes & request context
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# app/models/current.rb
|
|
342
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
343
|
+
attribute :user, :account, :request_id
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# in ApplicationController
|
|
347
|
+
before_action { Current.user = authenticated_user }
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`CurrentAttributes` is request/thread-local and **auto-reset after each request/job** — safe from leaking between requests. Use it for ambient context (current user, tenant, request id) to avoid threading them through every method. **Don't overuse it** as a global variable bus; it's still hidden global state and makes code harder to test. Never store it in jobs unless you re-set it from job args.
|
|
351
|
+
|
|
352
|
+
## Hotwire essentials (high level)
|
|
353
|
+
|
|
354
|
+
- **Turbo Drive:** intercepts links/forms, swaps `<body>` via fetch — SPA-like nav with zero JS. Forms that fail validation must render with `status: :unprocessable_entity` (422) or Turbo won't show the errors.
|
|
355
|
+
- **Turbo Frames:** `<turbo-frame id="x">` scopes navigation/updates to a region; a link inside replaces only that frame. Lazy-load with `src:`.
|
|
356
|
+
- **Turbo Streams:** server sends `append`/`prepend`/`replace`/`remove` actions over HTTP response or WebSocket (`turbo_stream_from`) to update specific DOM ids — used with `broadcasts_to` on models for live updates.
|
|
357
|
+
- **Stimulus:** small JS controllers (`data-controller`, `data-action`, targets) for sprinkles of behavior. Keep logic server-side; Stimulus glues DOM events to it.
|
|
358
|
+
|
|
359
|
+
Reach for Hotwire before a heavy SPA. See library docs for specifics; this file stays high-level.
|
|
360
|
+
|
|
361
|
+
## ActionMailer
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
class OrderMailer < ApplicationMailer
|
|
365
|
+
def receipt(order)
|
|
366
|
+
@order = order
|
|
367
|
+
mail(to: order.email, subject: "Your receipt")
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
OrderMailer.receipt(order).deliver_later # enqueue via ActiveJob; NOT deliver_now in requests
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Use `deliver_later` so SMTP latency/failure doesn't block the request. Mailer previews under `test/mailers/previews`. Keep view logic in the mailer template; mailers are a thin adapter.
|
|
375
|
+
|
|
376
|
+
## Caching
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
# Fragment + Russian-doll: nested fragments, inner key change busts only that fragment
|
|
380
|
+
<% cache @product do %> # key includes updated_at -> auto-busts on change
|
|
381
|
+
<% cache @product.vendor do %> ... <% end %>
|
|
382
|
+
<% end %>
|
|
383
|
+
|
|
384
|
+
# Low-level cache: expensive computation keyed yourself
|
|
385
|
+
Rails.cache.fetch("stats/#{account.id}", expires_in: 1.hour) do
|
|
386
|
+
account.compute_expensive_stats
|
|
387
|
+
end
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- Use `touch: true` on `belongs_to` so a child update bumps the parent's `updated_at` (drives Russian-doll invalidation).
|
|
391
|
+
- Cache keys should encode everything that affects output (model + `cache_version`/`updated_at`); never hand-roll keys that can go stale.
|
|
392
|
+
- **Solid Cache** is the Rails 8 default store (DB-backed). For per-request memoization use a method-level `||=` or `Current`, not the cache store.
|
|
393
|
+
|
|
394
|
+
## Secure defaults
|
|
395
|
+
|
|
396
|
+
- **CSRF:** `protect_from_forgery` is on by default for non-GET HTML; keep it. API-only controllers use token auth instead.
|
|
397
|
+
- **Strong params:** never `permit!`; allowlist explicitly (above).
|
|
398
|
+
- **Params filtering / logging:** `config.filter_parameters += [:password, :token, :ssn]` so secrets don't hit logs. Rails seeds common ones.
|
|
399
|
+
- **Encrypted credentials:** `bin/rails credentials:edit` ⇒ `config/credentials.yml.enc` + `master.key` (gitignored). Read with `Rails.application.credentials.dig(:stripe, :secret_key)`. Don't put secrets in `config/*.yml` or commit `master.key`.
|
|
400
|
+
- **Active Record Encryption** for column-level encryption of PII (`encrypts :ssn`).
|
|
401
|
+
- **Force SSL:** `config.force_ssl = true` in production.
|
|
402
|
+
|
|
403
|
+
Deeper vuln coverage (SQLi, XSS, mass assignment, SSRF, Brakeman) lives in `references/security.md`.
|
|
404
|
+
|
|
405
|
+
## Quick checklist
|
|
406
|
+
|
|
407
|
+
- Existing project conventions override every default here.
|
|
408
|
+
- Let Zeitwerk autoload; name files to match constants; don't `require` app code.
|
|
409
|
+
- Back every association/uniqueness rule with a real DB constraint (FK, unique index).
|
|
410
|
+
- Minimize callbacks; move side effects (mail, jobs, external calls) into service objects.
|
|
411
|
+
- Kill N+1 with `includes`/`preload`; use `eager_load`+`references` only to filter on the join.
|
|
412
|
+
- Use `pluck`/`select`/aggregates instead of loading-then-mapping in Ruby.
|
|
413
|
+
- `find_each`/`in_batches` for large sets; `update_all` for bulk column writes (no callbacks).
|
|
414
|
+
- Wrap multi-row writes in transactions; enqueue jobs and call APIs **after commit**, never inside.
|
|
415
|
+
- Pick optimistic (low contention) vs pessimistic (`lock`/`with_lock`, money/inventory) locking deliberately.
|
|
416
|
+
- Safe migrations: nullable add → batch backfill → enforce; indexes `algorithm: :concurrently` + `disable_ddl_transaction!`.
|
|
417
|
+
- Skinny controllers: parse params, one domain call, render/redirect; 422 on failed Turbo forms.
|
|
418
|
+
- Strong params allowlist only; `params.expect` on Rails 8; never `permit!`.
|
|
419
|
+
- Concerns must be cohesive and reused; otherwise prefer a service/value object.
|
|
420
|
+
- Service objects: verb name, one `#call`, inject collaborators, return a Result.
|
|
421
|
+
- Jobs: pass IDs, be idempotent, `retry_on`/`discard_on` with capped backoff, separate queues; Solid Queue or Sidekiq.
|
|
422
|
+
- `CurrentAttributes` for request context — but it's still global state; use sparingly.
|
|
423
|
+
- `deliver_later` for mail; Russian-doll caching with `touch: true`; `Rails.cache.fetch` for low-level.
|
|
424
|
+
- Keep credentials encrypted, filter params from logs, keep CSRF on; security depth in `references/security.md`.
|