rubyn-code 0.1.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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,198 @@
1
+ # Rails: Logging and Instrumentation
2
+
3
+ ## Pattern
4
+
5
+ Use structured logging for production observability, `ActiveSupport::Notifications` for custom instrumentation, and tagged logging for request-scoped context. Logs should answer: what happened, when, to whom, and how long it took.
6
+
7
+ ### Structured Logging with Lograge
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "lograge"
12
+
13
+ # config/environments/production.rb
14
+ config.lograge.enabled = true
15
+ config.lograge.formatter = Lograge::Formatters::Json.new
16
+
17
+ config.lograge.custom_options = lambda do |event|
18
+ {
19
+ user_id: event.payload[:user_id],
20
+ request_id: event.payload[:request_id],
21
+ ip: event.payload[:ip],
22
+ credits_used: event.payload[:credits_used]
23
+ }.compact
24
+ end
25
+
26
+ config.lograge.custom_payload do |controller|
27
+ {
28
+ user_id: controller.current_user&.id,
29
+ request_id: controller.request.request_id,
30
+ ip: controller.request.remote_ip
31
+ }
32
+ end
33
+
34
+ # Output per request:
35
+ # {"method":"POST","path":"/api/v1/ai/refactor","format":"json","controller":"Api::V1::Ai::RefactorController",
36
+ # "action":"create","status":200,"duration":1245.3,"user_id":42,"request_id":"abc-123","credits_used":3}
37
+ ```
38
+
39
+ ### Tagged Logging
40
+
41
+ ```ruby
42
+ # config/application.rb
43
+ config.log_tags = [:request_id] # Adds request ID to every log line
44
+
45
+ # Custom tags
46
+ config.log_tags = [
47
+ :request_id,
48
+ ->(request) { "user:#{request.cookie_jar.signed[:user_id]}" }
49
+ ]
50
+
51
+ # Manual tagging in services
52
+ Rails.logger.tagged("OrderService", "user:#{user.id}") do
53
+ Rails.logger.info("Creating order")
54
+ Rails.logger.info("Order created: #{order.id}")
55
+ end
56
+ # [abc-123] [OrderService] [user:42] Creating order
57
+ # [abc-123] [OrderService] [user:42] Order created: 17
58
+ ```
59
+
60
+ ### Log Levels Done Right
61
+
62
+ ```ruby
63
+ class Ai::CompletionService
64
+ def call(prompt, context:)
65
+ # DEBUG: Detailed info for development troubleshooting — never in production
66
+ Rails.logger.debug { "Prompt tokens estimate: #{estimate_tokens(prompt)}" }
67
+
68
+ # INFO: Normal operations that are useful for monitoring
69
+ Rails.logger.info("[AI] Request started model=#{@model} user=#{@user.id}")
70
+
71
+ response = @client.complete(messages, model: @model, max_tokens: 4096)
72
+
73
+ # INFO: Successful completion with metrics
74
+ Rails.logger.info(
75
+ "[AI] Request completed model=#{@model} " \
76
+ "input_tokens=#{response.input_tokens} output_tokens=#{response.output_tokens} " \
77
+ "duration_ms=#{elapsed_ms} cache_hit=#{response.cache_read_tokens > 0}"
78
+ )
79
+
80
+ response
81
+ rescue Faraday::TimeoutError => e
82
+ # WARN: Recoverable problem — retrying or degraded behavior
83
+ Rails.logger.warn("[AI] Timeout after #{elapsed_ms}ms, retrying (attempt #{retries}/3)")
84
+ retry if (retries += 1) <= 3
85
+ raise
86
+ rescue Anthropic::ApiError => e
87
+ # ERROR: Failure that needs attention but isn't crashing the app
88
+ Rails.logger.error("[AI] API error status=#{e.status} message=#{e.message} user=#{@user.id}")
89
+ raise
90
+ rescue StandardError => e
91
+ # FATAL: Unexpected failure — something is seriously wrong
92
+ Rails.logger.fatal("[AI] Unexpected error: #{e.class}: #{e.message}")
93
+ Rails.logger.fatal(e.backtrace.first(10).join("\n"))
94
+ raise
95
+ end
96
+ end
97
+ ```
98
+
99
+ ### Custom Instrumentation with ActiveSupport::Notifications
100
+
101
+ ```ruby
102
+ # Publishing events
103
+ class Credits::DeductionService
104
+ def call(user, credits)
105
+ ActiveSupport::Notifications.instrument("credits.deducted", {
106
+ user_id: user.id,
107
+ credits: credits,
108
+ balance_after: user.credit_balance - credits
109
+ }) do
110
+ user.deduct_credits!(credits)
111
+ end
112
+ end
113
+ end
114
+
115
+ # Subscribing to events
116
+ # config/initializers/instrumentation.rb
117
+ ActiveSupport::Notifications.subscribe("credits.deducted") do |name, start, finish, id, payload|
118
+ duration = (finish - start) * 1000
119
+ Rails.logger.info(
120
+ "[Credits] Deducted #{payload[:credits]} from user=#{payload[:user_id]} " \
121
+ "balance=#{payload[:balance_after]} duration=#{duration.round(1)}ms"
122
+ )
123
+ end
124
+
125
+ ActiveSupport::Notifications.subscribe("credits.deducted") do |*, payload|
126
+ StatsD.increment("credits.deducted", tags: ["user:#{payload[:user_id]}"])
127
+ StatsD.gauge("credits.balance", payload[:balance_after], tags: ["user:#{payload[:user_id]}"])
128
+ end
129
+
130
+ # Subscribe to Rails built-in events
131
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
132
+ if payload[:duration] > 100 # Log slow queries
133
+ Rails.logger.warn("[SlowQuery] #{payload[:duration].round(1)}ms: #{payload[:sql]}")
134
+ end
135
+ end
136
+
137
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
138
+ if payload[:duration] > 1000 # Log slow requests
139
+ Rails.logger.warn("[SlowRequest] #{payload[:path]} #{payload[:duration].round(0)}ms")
140
+ end
141
+ end
142
+ ```
143
+
144
+ ### What to Log (and What Not To)
145
+
146
+ ```ruby
147
+ # GOOD: Structured, searchable, useful
148
+ Rails.logger.info("[Orders::Create] Created order=#{order.id} user=#{user.id} total=#{order.total} items=#{order.line_items.count}")
149
+
150
+ # GOOD: Error with context
151
+ Rails.logger.error("[Payments] Charge failed user=#{user.id} amount=#{amount} error=#{e.message}")
152
+
153
+ # BAD: Unstructured, unsearchable
154
+ Rails.logger.info("Order created successfully!")
155
+ Rails.logger.info("Something went wrong: #{e}")
156
+
157
+ # BAD: Logging sensitive data
158
+ Rails.logger.info("User signed in with password: #{params[:password]}")
159
+ Rails.logger.info("API key used: #{api_key}")
160
+ Rails.logger.info("Credit card: #{card_number}")
161
+
162
+ # BAD: Logging entire objects (huge, contains sensitive fields)
163
+ Rails.logger.info("User: #{user.inspect}")
164
+ Rails.logger.info("Params: #{params.inspect}")
165
+
166
+ # GOOD: Log only what you need
167
+ Rails.logger.info("[Auth] Sign in user=#{user.id} email=#{user.email} ip=#{request.remote_ip}")
168
+ ```
169
+
170
+ ## Why This Is Good
171
+
172
+ - **Structured logs are searchable.** `user=42 model=haiku duration_ms=345` can be filtered and aggregated in any log platform (Datadog, Papertrail, CloudWatch). "Order created successfully!" can't.
173
+ - **Tagged logging adds context automatically.** Every log line in a request includes the request ID and user ID — no manual threading of context.
174
+ - **`ActiveSupport::Notifications` decouples events from reactions.** The service publishes "credits deducted." Logging subscribes. Metrics subscribes. Alerting subscribes. The service doesn't know about any of them.
175
+ - **Log levels filter noise.** Production runs at `:info`. Development runs at `:debug`. Slow query warnings are `:warn` — they're visible in production without drowning in debug noise.
176
+
177
+ ## Anti-Pattern
178
+
179
+ ```ruby
180
+ # BAD: puts in production code
181
+ puts "Order created"
182
+
183
+ # BAD: p for debugging left in committed code
184
+ p user.attributes
185
+
186
+ # BAD: Logging inside a loop (10,000 log lines for 10,000 records)
187
+ users.each { |u| Rails.logger.info("Processing user #{u.id}") }
188
+
189
+ # BETTER: Log the batch
190
+ Rails.logger.info("[BatchProcess] Processing #{users.count} users")
191
+ ```
192
+
193
+ ## When To Apply
194
+
195
+ - **Every service object** should log entry, exit, and errors with structured key=value pairs.
196
+ - **Lograge in production** — always. Default Rails logging is verbose and unstructured.
197
+ - **`ActiveSupport::Notifications`** for cross-cutting metrics (slow queries, credit usage, API latency).
198
+ - **Never log passwords, API keys, tokens, or PII.**
@@ -0,0 +1,180 @@
1
+ # Rails: Mailers
2
+
3
+ ## Pattern
4
+
5
+ Mailers are the email equivalent of controllers — thin orchestrators that set up data and pick a template. Keep them simple, always deliver asynchronously, use previews for development, and test the envelope (to, from, subject) separately from the content.
6
+
7
+ ```ruby
8
+ # app/mailers/application_mailer.rb
9
+ class ApplicationMailer < ActionMailer::Base
10
+ default from: "Rubyn <noreply@rubyn.ai>"
11
+ layout "mailer"
12
+ end
13
+
14
+ # app/mailers/order_mailer.rb
15
+ class OrderMailer < ApplicationMailer
16
+ def confirmation(order)
17
+ @order = order
18
+ @user = order.user
19
+
20
+ mail(
21
+ to: @user.email,
22
+ subject: "Order #{@order.reference} Confirmed"
23
+ )
24
+ end
25
+
26
+ def shipped(order)
27
+ @order = order
28
+ @user = order.user
29
+ @tracking_url = tracking_url(@order.tracking_number)
30
+
31
+ mail(
32
+ to: @user.email,
33
+ subject: "Your order has shipped!"
34
+ )
35
+ end
36
+
37
+ def receipt(order)
38
+ @order = order.includes(:line_items)
39
+ @user = order.user
40
+
41
+ attachments["receipt-#{@order.reference}.pdf"] = Orders::ReceiptPdfService.call(@order)
42
+
43
+ mail(
44
+ to: @user.email,
45
+ subject: "Receipt for Order #{@order.reference}"
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ def tracking_url(number)
52
+ "https://tracking.example.com/#{number}"
53
+ end
54
+ end
55
+ ```
56
+
57
+ ### Always Deliver Asynchronously
58
+
59
+ ```ruby
60
+ # GOOD: deliver_later — enqueues to Active Job (Sidekiq/etc)
61
+ OrderMailer.confirmation(order).deliver_later
62
+
63
+ # GOOD: deliver_later with delay
64
+ OrderMailer.review_reminder(order).deliver_later(wait: 7.days)
65
+
66
+ # BAD: deliver_now blocks the request
67
+ OrderMailer.confirmation(order).deliver_now
68
+ # User waits 1-3 seconds for SMTP handshake — terrible UX
69
+
70
+ # EXCEPTION: deliver_now is fine inside a background job
71
+ class OrderConfirmationJob < ApplicationJob
72
+ def perform(order_id)
73
+ order = Order.find(order_id)
74
+ OrderMailer.confirmation(order).deliver_now # Already async — job handles the retry
75
+ end
76
+ end
77
+ ```
78
+
79
+ ### Mailer Previews
80
+
81
+ ```ruby
82
+ # test/mailers/previews/order_mailer_preview.rb (or spec/mailers/previews/)
83
+ class OrderMailerPreview < ActionMailer::Preview
84
+ def confirmation
85
+ order = Order.first || FactoryBot.create(:order)
86
+ OrderMailer.confirmation(order)
87
+ end
88
+
89
+ def shipped
90
+ order = Order.shipped.first || FactoryBot.create(:order, :shipped, tracking_number: "1Z999AA10123456784")
91
+ OrderMailer.shipped(order)
92
+ end
93
+
94
+ def receipt
95
+ order = Order.includes(:line_items).first || FactoryBot.create(:order, :with_line_items)
96
+ OrderMailer.receipt(order)
97
+ end
98
+ end
99
+
100
+ # Visit http://localhost:3000/rails/mailers to see rendered previews
101
+ # No actual email sent — just renders the template in the browser
102
+ ```
103
+
104
+ ### Views
105
+
106
+ ```erb
107
+ <%# app/views/order_mailer/confirmation.html.erb %>
108
+ <h1>Order Confirmed!</h1>
109
+ <p>Hi <%= @user.name %>,</p>
110
+ <p>Your order <strong><%= @order.reference %></strong> has been confirmed.</p>
111
+
112
+ <table>
113
+ <% @order.line_items.each do |item| %>
114
+ <tr>
115
+ <td><%= item.product.name %></td>
116
+ <td><%= item.quantity %></td>
117
+ <td>$<%= format("%.2f", item.unit_price / 100.0) %></td>
118
+ </tr>
119
+ <% end %>
120
+ </table>
121
+
122
+ <p><strong>Total: $<%= format("%.2f", @order.total / 100.0) %></strong></p>
123
+ <p>Shipping to: <%= @order.shipping_address %></p>
124
+
125
+ <%= link_to "View Order", order_url(@order) %>
126
+ ```
127
+
128
+ ```erb
129
+ <%# app/views/order_mailer/confirmation.text.erb — always provide a text version %>
130
+ Order Confirmed!
131
+
132
+ Hi <%= @user.name %>,
133
+
134
+ Your order <%= @order.reference %> has been confirmed.
135
+
136
+ <% @order.line_items.each do |item| %>
137
+ - <%= item.product.name %> x<%= item.quantity %> — $<%= format("%.2f", item.unit_price / 100.0) %>
138
+ <% end %>
139
+
140
+ Total: $<%= format("%.2f", @order.total / 100.0) %>
141
+ Shipping to: <%= @order.shipping_address %>
142
+
143
+ View your order: <%= order_url(@order) %>
144
+ ```
145
+
146
+ ## Why This Is Good
147
+
148
+ - **Thin mailers.** The mailer sets instance variables and calls `mail()`. No business logic, no formatting, no conditionals beyond what's needed for the template.
149
+ - **`deliver_later` is non-blocking.** The user's request completes instantly. The email sends in a background job with automatic retries.
150
+ - **Previews catch visual bugs.** See the rendered email in your browser without sending it. Catch broken layouts, missing data, and formatting issues before they reach users.
151
+ - **Text + HTML versions.** Email clients that don't render HTML (or users who prefer plain text) get a readable version. Also improves spam score.
152
+ - **Attachments via service objects.** PDF generation is delegated to a service, not done inline in the mailer.
153
+
154
+ ## Anti-Pattern
155
+
156
+ ```ruby
157
+ # BAD: Business logic in the mailer
158
+ class OrderMailer < ApplicationMailer
159
+ def confirmation(order)
160
+ @order = order
161
+ @user = order.user
162
+ @discount = order.total > 100_00 ? "Use code SAVE10 for 10% off!" : nil
163
+ @recommendations = Product.where.not(id: order.line_items.pluck(:product_id)).limit(3)
164
+ @user.update!(last_emailed_at: Time.current) # Side effect in a mailer!
165
+ mail(to: @user.email, subject: "Order Confirmed")
166
+ end
167
+ end
168
+ ```
169
+
170
+ ## When To Apply
171
+
172
+ - **Every email.** Use mailers for all outgoing email, even simple ones. Direct `Mail.deliver` bypasses Rails' template rendering, previews, and testing infrastructure.
173
+ - **`deliver_later` always.** The only exception is inside a background job that's already async.
174
+ - **Previews for every mailer.** Set them up once, save hours of "send test email, check inbox, repeat."
175
+ - **Both HTML and text templates.** Plain text is required for accessibility and deliverability.
176
+
177
+ ## When NOT To Apply
178
+
179
+ - **Transactional SMS or push notifications.** These aren't emails — use dedicated services, not ActionMailer.
180
+ - **Don't put conditional sending logic in the mailer.** "Don't send if user has unsubscribed" belongs in the service that calls the mailer, not in the mailer itself.
@@ -0,0 +1,200 @@
1
+ # Rails: Safe Migrations
2
+
3
+ ## Pattern
4
+
5
+ Write migrations that are safe for zero-downtime deploys. Add indexes concurrently. Never remove columns without a two-step deploy. Use `strong_migrations` gem to catch unsafe operations automatically.
6
+
7
+ ```ruby
8
+ # SAFE: Add a column with a default (Rails 5+ handles this without rewriting the table)
9
+ class AddStatusToOrders < ActiveRecord::Migration[8.0]
10
+ def change
11
+ add_column :orders, :priority, :integer, default: 0, null: false
12
+ end
13
+ end
14
+ ```
15
+
16
+ ```ruby
17
+ # SAFE: Add an index concurrently (doesn't lock the table)
18
+ class AddIndexOnOrdersStatus < ActiveRecord::Migration[8.0]
19
+ disable_ddl_transaction!
20
+
21
+ def change
22
+ add_index :orders, :status, algorithm: :concurrently
23
+ end
24
+ end
25
+ ```
26
+
27
+ ```ruby
28
+ # SAFE: Two-step column removal
29
+ # Deploy 1: Stop using the column in code, add ignore
30
+ class IgnoreDeletedAtOnOrders < ActiveRecord::Migration[8.0]
31
+ def change
32
+ safety_assured { remove_column :orders, :deleted_at, :datetime }
33
+ end
34
+ end
35
+ # But first: update the model to ignore the column
36
+ # class Order < ApplicationRecord
37
+ # self.ignored_columns += ["deleted_at"]
38
+ # end
39
+ ```
40
+
41
+ ```ruby
42
+ # SAFE: Rename via add/copy/remove (not rename_column)
43
+ # Step 1: Add new column
44
+ class AddFullNameToUsers < ActiveRecord::Migration[8.0]
45
+ def change
46
+ add_column :users, :full_name, :string
47
+ end
48
+ end
49
+
50
+ # Step 2: Backfill data (in a separate migration or rake task)
51
+ class BackfillFullName < ActiveRecord::Migration[8.0]
52
+ def up
53
+ User.in_batches.update_all("full_name = name")
54
+ end
55
+
56
+ def down
57
+ # no-op
58
+ end
59
+ end
60
+
61
+ # Step 3: (After deploy) Remove old column
62
+ class RemoveNameFromUsers < ActiveRecord::Migration[8.0]
63
+ def change
64
+ safety_assured { remove_column :users, :name, :string }
65
+ end
66
+ end
67
+ ```
68
+
69
+ ```ruby
70
+ # SAFE: Add a foreign key constraint
71
+ class AddForeignKeyOnOrders < ActiveRecord::Migration[8.0]
72
+ def change
73
+ add_foreign_key :orders, :users, validate: false
74
+ end
75
+ end
76
+
77
+ # Separate migration to validate (non-blocking)
78
+ class ValidateForeignKeyOnOrders < ActiveRecord::Migration[8.0]
79
+ def change
80
+ validate_foreign_key :orders, :users
81
+ end
82
+ end
83
+ ```
84
+
85
+ Add `strong_migrations` to catch unsafe operations:
86
+
87
+ ```ruby
88
+ # Gemfile
89
+ gem 'strong_migrations'
90
+
91
+ # config/initializers/strong_migrations.rb
92
+ StrongMigrations.start_after = 20260101000000
93
+ ```
94
+
95
+ ## Why This Is Good
96
+
97
+ - **Zero-downtime deploys.** The new code deploys while the migration runs. No maintenance window, no "please wait" page, no interruption to users.
98
+ - **Concurrent indexes don't lock.** `algorithm: :concurrently` builds the index without locking the table for writes. A standard `add_index` on a 10-million-row table locks writes for minutes.
99
+ - **Two-step column removal prevents errors.** If you remove a column while old code is still running (during deploy), queries referencing that column fail. Ignoring the column first ensures old code doesn't reference it.
100
+ - **`strong_migrations` catches mistakes.** It raises an error if you try to run an unsafe migration in production, with a helpful message explaining the safe alternative.
101
+ - **Separate validation of foreign keys.** Adding a FK with `validate: false` is instant. Validating it in a separate migration scans the table without blocking writes.
102
+
103
+ ## Anti-Pattern
104
+
105
+ Migrations that lock tables, remove columns in one step, or change types without safety:
106
+
107
+ ```ruby
108
+ # DANGEROUS: Locks the entire table while building the index
109
+ class AddIndexOnOrdersEmail < ActiveRecord::Migration[8.0]
110
+ def change
111
+ add_index :orders, :email # Blocks writes on large tables
112
+ end
113
+ end
114
+ ```
115
+
116
+ ```ruby
117
+ # DANGEROUS: Removes column while running code may still reference it
118
+ class RemoveLegacyField < ActiveRecord::Migration[8.0]
119
+ def change
120
+ remove_column :orders, :old_status # Active servers still querying old_status
121
+ end
122
+ end
123
+ ```
124
+
125
+ ```ruby
126
+ # DANGEROUS: Changes column type — rewrites entire table, locks it
127
+ class ChangeOrderTotalType < ActiveRecord::Migration[8.0]
128
+ def change
129
+ change_column :orders, :total, :decimal, precision: 10, scale: 2
130
+ end
131
+ end
132
+ ```
133
+
134
+ ```ruby
135
+ # DANGEROUS: Data migration mixed with schema migration
136
+ class AddAndBackfillStatus < ActiveRecord::Migration[8.0]
137
+ def change
138
+ add_column :orders, :status, :string, default: "pending"
139
+ Order.update_all(status: "pending") # Locks table, runs in same transaction
140
+ end
141
+ end
142
+ ```
143
+
144
+ ## Why This Is Bad
145
+
146
+ - **Table locks block writes.** A standard `add_index` acquires an exclusive lock on the table. On a 10-million-row orders table, this blocks all INSERT/UPDATE/DELETE for 5-30 minutes. Every request that touches orders hangs.
147
+ - **Column removal during deploy breaks requests.** Rails caches the column list at boot. Old servers (still running during rolling deploy) try to SELECT the removed column and get a database error.
148
+ - **Type changes rewrite the entire table.** Changing a column type on a 50-million-row table creates a new copy of the table with the new type, copies all data, then swaps. This locks the table for the entire duration.
149
+ - **Data migrations in schema migrations are dangerous.** They run inside the migration transaction, hold locks longer, and can timeout. If they fail halfway, the schema migration rolls back too. Keep data migrations separate.
150
+
151
+ ## When To Apply
152
+
153
+ - **Every migration in a production application.** Even if you're small now, building safe habits means you never have to relearn when your tables grow to millions of rows.
154
+ - **`add_index` on any table with more than 10,000 rows** should use `algorithm: :concurrently`.
155
+ - **Any column removal** should use the two-step process: ignore first, remove in a later deploy.
156
+ - **Any column type change** should use the add-new/copy/remove-old pattern.
157
+ - **Data backfills** should be separate from schema changes, use `in_batches`, and run outside the migration transaction.
158
+
159
+ ## When NOT To Apply
160
+
161
+ - **Brand new tables** (no data yet) can have indexes added normally. No need for `concurrently` on an empty table.
162
+ - **Development/test environments** don't need concurrent indexes or two-step removal. These safeguards are for production deploys.
163
+ - **Tiny tables** (reference data with 100 rows) can be modified with standard migrations. The lock duration is negligible.
164
+
165
+ ## Edge Cases
166
+
167
+ **Adding a NOT NULL column to an existing table:**
168
+ Add the column as nullable first, backfill, then add the constraint:
169
+
170
+ ```ruby
171
+ # Step 1
172
+ add_column :orders, :region, :string
173
+
174
+ # Step 2 (separate migration)
175
+ Order.in_batches.update_all(region: "us")
176
+
177
+ # Step 3 (separate migration)
178
+ change_column_null :orders, :region, false
179
+ ```
180
+
181
+ **Adding a column with a default on PostgreSQL:**
182
+ Rails 5+ with PostgreSQL adds the default at the column metadata level, not by rewriting the table. This is safe and instant. But always verify your Rails and PostgreSQL versions support this.
183
+
184
+ **`reversible` for complex migrations:**
185
+
186
+ ```ruby
187
+ class AddStatusIndex < ActiveRecord::Migration[8.0]
188
+ disable_ddl_transaction!
189
+
190
+ def change
191
+ reversible do |dir|
192
+ dir.up { add_index :orders, :status, algorithm: :concurrently }
193
+ dir.down { remove_index :orders, :status }
194
+ end
195
+ end
196
+ end
197
+ ```
198
+
199
+ **Renaming tables:**
200
+ Don't. Add a new table, migrate data, drop the old one. Or use a database view as an alias during the transition.