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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Rails: API Design
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Design APIs to be consistent, versioned, and self-documenting. Use Grape or Rails API mode with `jbuilder`/`jsonapi-serializer`. Follow RESTful conventions. Handle errors uniformly.
|
|
6
|
+
|
|
7
|
+
### API Controller Structure
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/controllers/api/v1/base_controller.rb
|
|
11
|
+
module Api
|
|
12
|
+
module V1
|
|
13
|
+
class BaseController < ActionController::API
|
|
14
|
+
include Authenticatable
|
|
15
|
+
include Paginatable
|
|
16
|
+
include ErrorHandling
|
|
17
|
+
|
|
18
|
+
before_action :authenticate!
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def render_success(data, status: :ok, meta: {})
|
|
23
|
+
response = { data: data }
|
|
24
|
+
response[:meta] = meta if meta.present?
|
|
25
|
+
render json: response, status: status
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render_created(data)
|
|
29
|
+
render_success(data, status: :created)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render_error(message, status:, details: nil)
|
|
33
|
+
body = { error: { message: message } }
|
|
34
|
+
body[:error][:details] = details if details
|
|
35
|
+
render json: body, status: status
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/controllers/api/v1/orders_controller.rb
|
|
44
|
+
module Api
|
|
45
|
+
module V1
|
|
46
|
+
class OrdersController < BaseController
|
|
47
|
+
def index
|
|
48
|
+
orders = paginate(current_user.orders.includes(:line_items).recent)
|
|
49
|
+
|
|
50
|
+
render_success(
|
|
51
|
+
orders.map { |o| OrderSerializer.new(o).as_json },
|
|
52
|
+
meta: pagination_meta(orders)
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def show
|
|
57
|
+
order = current_user.orders.find(params[:id])
|
|
58
|
+
render_success(OrderSerializer.new(order).as_json)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create
|
|
62
|
+
result = Orders::CreateService.call(order_params.to_h, current_user)
|
|
63
|
+
|
|
64
|
+
if result.success?
|
|
65
|
+
render_created(OrderSerializer.new(result.order).as_json)
|
|
66
|
+
else
|
|
67
|
+
render_error("Validation failed", status: :unprocessable_entity,
|
|
68
|
+
details: result.order.errors.full_messages)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def order_params
|
|
75
|
+
params.require(:order).permit(:shipping_address, line_items: [:product_id, :quantity])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Consistent Error Responses
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# app/controllers/concerns/error_handling.rb
|
|
86
|
+
module ErrorHandling
|
|
87
|
+
extend ActiveSupport::Concern
|
|
88
|
+
|
|
89
|
+
included do
|
|
90
|
+
rescue_from ActiveRecord::RecordNotFound do |e|
|
|
91
|
+
render_error("Resource not found", status: :not_found)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
rescue_from ActiveRecord::RecordInvalid do |e|
|
|
95
|
+
render_error("Validation failed", status: :unprocessable_entity,
|
|
96
|
+
details: e.record.errors.full_messages)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
rescue_from ActionController::ParameterMissing do |e|
|
|
100
|
+
render_error("Missing parameter: #{e.param}", status: :bad_request)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
rescue_from Pundit::NotAuthorizedError do
|
|
104
|
+
render_error("Forbidden", status: :forbidden)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Serializers
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# app/serializers/order_serializer.rb
|
|
114
|
+
class OrderSerializer
|
|
115
|
+
def initialize(order)
|
|
116
|
+
@order = order
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def as_json(*)
|
|
120
|
+
{
|
|
121
|
+
id: @order.id,
|
|
122
|
+
reference: @order.reference,
|
|
123
|
+
status: @order.status,
|
|
124
|
+
total: @order.total,
|
|
125
|
+
shipping_address: @order.shipping_address,
|
|
126
|
+
line_items: @order.line_items.map { |li| LineItemSerializer.new(li).as_json },
|
|
127
|
+
created_at: @order.created_at.iso8601,
|
|
128
|
+
updated_at: @order.updated_at.iso8601
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Versioning
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# config/routes.rb
|
|
138
|
+
Rails.application.routes.draw do
|
|
139
|
+
namespace :api do
|
|
140
|
+
namespace :v1 do
|
|
141
|
+
resources :orders, only: [:index, :show, :create, :update, :destroy]
|
|
142
|
+
resources :projects, only: [:index, :show, :create] do
|
|
143
|
+
resources :embeddings, only: [:index, :create], controller: "project_embeddings"
|
|
144
|
+
end
|
|
145
|
+
namespace :ai do
|
|
146
|
+
post :refactor
|
|
147
|
+
post :review
|
|
148
|
+
post :explain
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Authentication
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# app/controllers/concerns/authenticatable.rb
|
|
159
|
+
module Authenticatable
|
|
160
|
+
extend ActiveSupport::Concern
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def authenticate!
|
|
165
|
+
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
|
|
166
|
+
render_error("Unauthorized", status: :unauthorized) and return unless token
|
|
167
|
+
|
|
168
|
+
api_key = ApiKey.active.find_by_token(token)
|
|
169
|
+
render_error("Invalid API key", status: :unauthorized) and return unless api_key
|
|
170
|
+
|
|
171
|
+
api_key.touch(:last_used_at)
|
|
172
|
+
@current_user = api_key.user
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def current_user
|
|
176
|
+
@current_user
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Why This Is Good
|
|
182
|
+
|
|
183
|
+
- **Consistent response shape.** Every success returns `{ data: ... }`. Every error returns `{ error: { message: ..., details: ... } }`. Clients parse responses predictably.
|
|
184
|
+
- **Versioned from day one.** `/api/v1/` allows breaking changes in v2 without breaking existing clients.
|
|
185
|
+
- **Centralized error handling.** `rescue_from` in a concern handles all common exceptions. No begin/rescue in every action.
|
|
186
|
+
- **Serializers control the API surface.** Only expose the fields you intend. Internal fields (password_digest, internal notes) never leak.
|
|
187
|
+
- **Pagination metadata in every list endpoint.** Clients always know total count, current page, and total pages.
|
|
188
|
+
|
|
189
|
+
## Anti-Pattern
|
|
190
|
+
|
|
191
|
+
Inconsistent responses, no versioning, and exposing model internals:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# BAD: render model directly
|
|
195
|
+
def show
|
|
196
|
+
render json: Order.find(params[:id])
|
|
197
|
+
# Exposes EVERY column including internal fields
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# BAD: inconsistent error formats
|
|
201
|
+
def create
|
|
202
|
+
order = Order.create!(params)
|
|
203
|
+
render json: order
|
|
204
|
+
rescue => e
|
|
205
|
+
render json: { msg: e.message }, status: 500 # Different shape than other errors
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## When To Apply
|
|
210
|
+
|
|
211
|
+
- **Every API.** Consistent structure, versioning, and error handling should be established in the first endpoint.
|
|
212
|
+
- **Even internal APIs.** Microservice-to-microservice APIs benefit from the same discipline. Future developers will thank you.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Rails: ActiveRecord Associations
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Define associations explicitly with appropriate options. Always set `dependent` on `has_many`. Use `inverse_of` when Rails can't infer it. Prefer `has_many :through` over `has_and_belongs_to_many`. Use `counter_cache` to avoid N+1 counts.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class User < ApplicationRecord
|
|
9
|
+
has_many :orders, dependent: :destroy, inverse_of: :user
|
|
10
|
+
has_many :line_items, through: :orders
|
|
11
|
+
has_many :reviews, dependent: :destroy
|
|
12
|
+
has_many :project_memberships, dependent: :destroy
|
|
13
|
+
has_many :projects, through: :project_memberships
|
|
14
|
+
has_one :profile, dependent: :destroy
|
|
15
|
+
|
|
16
|
+
# Optional belongs_to (Rails 5+ requires belongs_to by default)
|
|
17
|
+
belongs_to :company, optional: true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Order < ApplicationRecord
|
|
21
|
+
belongs_to :user, counter_cache: true
|
|
22
|
+
has_many :line_items, dependent: :destroy, inverse_of: :order
|
|
23
|
+
has_one :shipment, dependent: :destroy
|
|
24
|
+
|
|
25
|
+
# Scoped association
|
|
26
|
+
has_many :active_line_items, -> { where(cancelled: false) },
|
|
27
|
+
class_name: "LineItem",
|
|
28
|
+
inverse_of: :order
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class LineItem < ApplicationRecord
|
|
32
|
+
belongs_to :order, counter_cache: true
|
|
33
|
+
belongs_to :product
|
|
34
|
+
|
|
35
|
+
# Validate presence of the association, not just the foreign key
|
|
36
|
+
validates :order, presence: true
|
|
37
|
+
validates :product, presence: true
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`has_many :through` for many-to-many with join model:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class Project < ApplicationRecord
|
|
45
|
+
has_many :project_memberships, dependent: :destroy
|
|
46
|
+
has_many :users, through: :project_memberships
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class ProjectMembership < ApplicationRecord
|
|
50
|
+
belongs_to :project
|
|
51
|
+
belongs_to :user
|
|
52
|
+
|
|
53
|
+
enum :role, { owner: 0, admin: 1, member: 2, viewer: 3 }
|
|
54
|
+
|
|
55
|
+
validates :project_id, uniqueness: { scope: :user_id }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class User < ApplicationRecord
|
|
59
|
+
has_many :project_memberships, dependent: :destroy
|
|
60
|
+
has_many :projects, through: :project_memberships
|
|
61
|
+
|
|
62
|
+
def role_in(project)
|
|
63
|
+
project_memberships.find_by(project: project)&.role
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def member_of?(project)
|
|
67
|
+
project_memberships.exists?(project: project)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Why This Is Good
|
|
73
|
+
|
|
74
|
+
- **`dependent: :destroy` prevents orphans.** When a user is deleted, their orders are destroyed too. Without this, you get orphaned records with foreign keys pointing to nothing.
|
|
75
|
+
- **`inverse_of` optimizes memory.** Rails reuses the same object in memory instead of loading a new one. `order.user` and `user.orders.first.user` return the same object instance, saving queries and preventing stale data.
|
|
76
|
+
- **`counter_cache` eliminates count queries.** `user.orders_count` reads a column instead of running `SELECT COUNT(*)`. For pages that display counts for many records, this prevents N+1 count queries.
|
|
77
|
+
- **`has_many :through` gives you a join model.** The join model can have its own attributes (role, permissions, created_at), validations, and callbacks. `has_and_belongs_to_many` can't.
|
|
78
|
+
- **Scoped associations provide named, preloadable subsets.** `order.active_line_items` is preloadable with `includes(:active_line_items)` and reads clearly.
|
|
79
|
+
|
|
80
|
+
## Anti-Pattern
|
|
81
|
+
|
|
82
|
+
Missing dependent options, using HABTM, and ignoring inverse_of:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class User < ApplicationRecord
|
|
86
|
+
# BAD: No dependent — deleting a user orphans all their orders
|
|
87
|
+
has_many :orders
|
|
88
|
+
|
|
89
|
+
# BAD: HABTM — no join model, can't add attributes or validations
|
|
90
|
+
has_and_belongs_to_many :projects
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class Order < ApplicationRecord
|
|
94
|
+
# BAD: belongs_to without counter_cache when counts are displayed frequently
|
|
95
|
+
belongs_to :user
|
|
96
|
+
|
|
97
|
+
# BAD: No dependent — deleting an order orphans line items
|
|
98
|
+
has_many :line_items
|
|
99
|
+
|
|
100
|
+
# BAD: Accessing association in a way that breaks inverse_of
|
|
101
|
+
has_many :items, class_name: "LineItem", foreign_key: "order_id"
|
|
102
|
+
# Rails can't infer inverse_of for :items because the name doesn't match
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Why This Is Bad
|
|
107
|
+
|
|
108
|
+
- **Missing `dependent` creates orphaned records.** `User.destroy` leaves behind orders, line items, and shipments with `user_id` pointing to a deleted record. Foreign key constraints fail or, worse, the data silently rots.
|
|
109
|
+
- **HABTM can't have join attributes.** You can't store when a user joined a project, what role they have, or who invited them. You're stuck with just the two foreign keys. Every non-trivial many-to-many needs a join model eventually — start with `has_many :through`.
|
|
110
|
+
- **Missing `inverse_of` causes extra queries.** Without it, `order.line_items.first.order` loads the order again from the database instead of reusing the object already in memory. In loops, this multiplies into hundreds of unnecessary queries.
|
|
111
|
+
- **Missing `counter_cache` on frequently counted associations.** If your UI shows "12 orders" next to every user, that's a COUNT query per user. With 50 users on the page, that's 50 COUNT queries.
|
|
112
|
+
|
|
113
|
+
## When To Apply
|
|
114
|
+
|
|
115
|
+
- **Always set `dependent` on `has_many` and `has_one`.** Choose:
|
|
116
|
+
- `:destroy` — run callbacks on each child (use when children have their own dependents or callbacks)
|
|
117
|
+
- `:delete_all` — single DELETE SQL, skip callbacks (faster, use when children have no dependents)
|
|
118
|
+
- `:nullify` — set foreign key to NULL (use when the child can exist without the parent)
|
|
119
|
+
- `:restrict_with_error` — prevent deletion if children exist (use for referential integrity)
|
|
120
|
+
|
|
121
|
+
- **Always use `has_many :through`** for many-to-many. Even if you don't need join attributes today, you will tomorrow.
|
|
122
|
+
|
|
123
|
+
- **Set `inverse_of`** when the association name doesn't match the class name, or when using `:foreign_key`, `:class_name`, or scoped associations.
|
|
124
|
+
|
|
125
|
+
- **Use `counter_cache`** when you display counts in lists (index pages, admin panels, dashboards).
|
|
126
|
+
|
|
127
|
+
## When NOT To Apply
|
|
128
|
+
|
|
129
|
+
- **Don't add `dependent: :destroy` on `belongs_to`.** Destroying a line item should not destroy the order it belongs to. Dependent options go on the "parent" side (`has_many`/`has_one`).
|
|
130
|
+
- **Don't over-use `counter_cache`.** It adds a write on every insert/delete of the child. If counts are only viewed in admin reports (not on every page load), a query is fine.
|
|
131
|
+
- **Don't create associations you don't need.** If `User` never needs to directly access `LineItem` without going through `Order`, don't add `has_many :line_items, through: :orders` unless you have a concrete use case.
|
|
132
|
+
|
|
133
|
+
## Edge Cases
|
|
134
|
+
|
|
135
|
+
**Polymorphic associations:**
|
|
136
|
+
Use when multiple models can be the parent:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class Comment < ApplicationRecord
|
|
140
|
+
belongs_to :commentable, polymorphic: true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class Order < ApplicationRecord
|
|
144
|
+
has_many :comments, as: :commentable, dependent: :destroy
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class Product < ApplicationRecord
|
|
148
|
+
has_many :comments, as: :commentable, dependent: :destroy
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Downside: polymorphic foreign keys can't have database-level foreign key constraints. Use application-level validations.
|
|
153
|
+
|
|
154
|
+
**Self-referential associations:**
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
class Employee < ApplicationRecord
|
|
158
|
+
belongs_to :manager, class_name: "Employee", optional: true, inverse_of: :direct_reports
|
|
159
|
+
has_many :direct_reports, class_name: "Employee", foreign_key: :manager_id,
|
|
160
|
+
dependent: :nullify, inverse_of: :manager
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**`touch: true` for cache invalidation:**
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class LineItem < ApplicationRecord
|
|
168
|
+
belongs_to :order, touch: true # Updates order.updated_at when line item changes
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This is essential for Russian doll caching — changing a line item invalidates the order's cache fragment automatically.
|
|
173
|
+
|
|
174
|
+
**Preloading polymorphic associations:**
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# Must specify each possible type
|
|
178
|
+
Comment.includes(:commentable) # Works but may generate N queries for N types
|
|
179
|
+
|
|
180
|
+
# Better: preload specific types
|
|
181
|
+
comments = Comment.where(commentable_type: "Order").includes(:commentable)
|
|
182
|
+
```
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Rails: Background Jobs (Sidekiq)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Design jobs to be small, idempotent, and retriable. Pass IDs not objects. Set appropriate queues and retry strategies. Use Sidekiq's features (bulk, batches, rate limiting) for complex workflows.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# GOOD: Small, idempotent, passes ID
|
|
9
|
+
class OrderConfirmationJob < ApplicationJob
|
|
10
|
+
queue_as :default
|
|
11
|
+
retry_on ActiveRecord::RecordNotFound, wait: 5.seconds, attempts: 3
|
|
12
|
+
|
|
13
|
+
def perform(order_id)
|
|
14
|
+
order = Order.find(order_id)
|
|
15
|
+
return if order.confirmation_sent? # Idempotent check
|
|
16
|
+
|
|
17
|
+
OrderMailer.confirmation(order).deliver_now
|
|
18
|
+
order.update!(confirmation_sent_at: Time.current)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Enqueue
|
|
23
|
+
OrderConfirmationJob.perform_later(order.id)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# GOOD: Batch processing with find_each
|
|
28
|
+
class RecalculateTotalsJob < ApplicationJob
|
|
29
|
+
queue_as :low
|
|
30
|
+
|
|
31
|
+
def perform
|
|
32
|
+
Order.where(total: nil).find_each(batch_size: 500) do |order|
|
|
33
|
+
order.update!(total: order.line_items.sum("quantity * unit_price"))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# GOOD: Job with error handling and dead letter
|
|
41
|
+
class WebhookDeliveryJob < ApplicationJob
|
|
42
|
+
queue_as :webhooks
|
|
43
|
+
retry_on Faraday::TimeoutError, wait: :polynomially_longer, attempts: 5
|
|
44
|
+
discard_on Faraday::ClientError # 4xx errors won't succeed on retry
|
|
45
|
+
|
|
46
|
+
def perform(webhook_id)
|
|
47
|
+
webhook = Webhook.find(webhook_id)
|
|
48
|
+
response = Faraday.post(webhook.url, webhook.payload.to_json, "Content-Type" => "application/json")
|
|
49
|
+
|
|
50
|
+
if response.success?
|
|
51
|
+
webhook.update!(delivered_at: Time.current, status: :delivered)
|
|
52
|
+
else
|
|
53
|
+
webhook.update!(status: :failed, last_error: "HTTP #{response.status}")
|
|
54
|
+
raise Faraday::ServerError, "Webhook failed: #{response.status}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Queue configuration:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
# config/sidekiq.yml
|
|
64
|
+
:concurrency: 10
|
|
65
|
+
:queues:
|
|
66
|
+
- [critical, 3] # Payments, auth — 3x priority
|
|
67
|
+
- [default, 2] # Email, notifications — 2x priority
|
|
68
|
+
- [embeddings, 1] # Codebase indexing — normal priority
|
|
69
|
+
- [low, 1] # Reports, cleanup — normal priority
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Why This Is Good
|
|
73
|
+
|
|
74
|
+
- **Pass IDs, not objects.** Serializing an ActiveRecord object into Redis is fragile — the object might change between enqueue and execution. `Order.find(order_id)` always loads the current state.
|
|
75
|
+
- **Idempotent jobs are safe to retry.** If the job runs twice (Redis failover, process crash, manual retry), `return if order.confirmation_sent?` prevents sending a duplicate email. The second run is a no-op.
|
|
76
|
+
- **`retry_on` with specific exceptions.** Transient errors (timeout, record not found due to replication lag) get retried with backoff. Permanent errors (`discard_on` for client errors) don't waste retries.
|
|
77
|
+
- **Queue separation by priority.** Payment processing gets 3x the scheduling weight of report generation. A backlog of reports doesn't delay payment confirmations.
|
|
78
|
+
- **`find_each` in batch jobs.** Processing 100,000 orders loads 500 at a time, not all at once. Memory stays flat.
|
|
79
|
+
|
|
80
|
+
## Anti-Pattern
|
|
81
|
+
|
|
82
|
+
Passing objects, doing too much in one job, no idempotency, no retry strategy:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# BAD: Passes entire object
|
|
86
|
+
class ProcessOrderJob < ApplicationJob
|
|
87
|
+
def perform(order)
|
|
88
|
+
# order is a serialized/deserialized AR object — stale data
|
|
89
|
+
order.line_items.each do |item|
|
|
90
|
+
item.product.decrement!(:stock, item.quantity)
|
|
91
|
+
end
|
|
92
|
+
OrderMailer.confirmation(order).deliver_now
|
|
93
|
+
WarehouseApi.notify(order)
|
|
94
|
+
Analytics.track("order_created", order.attributes)
|
|
95
|
+
order.update!(processed: true)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# BAD: God job that does everything
|
|
102
|
+
class NightlyProcessingJob < ApplicationJob
|
|
103
|
+
def perform
|
|
104
|
+
# Recalculate all totals
|
|
105
|
+
Order.find_each { |o| o.recalculate! }
|
|
106
|
+
# Send reminder emails
|
|
107
|
+
User.inactive.each { |u| ReminderMailer.nudge(u).deliver_now }
|
|
108
|
+
# Clean up old records
|
|
109
|
+
Order.where("created_at < ?", 1.year.ago).destroy_all
|
|
110
|
+
# Generate reports
|
|
111
|
+
ReportGenerator.monthly.generate!
|
|
112
|
+
# Sync to external system
|
|
113
|
+
ExternalSync.full_sync!
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Why This Is Bad
|
|
119
|
+
|
|
120
|
+
- **Serialized objects are stale.** The order's data at enqueue time may differ from the database when the job runs (seconds, minutes, or hours later). The price could change, the status could update, line items could be modified.
|
|
121
|
+
- **No idempotency.** If `ProcessOrderJob` runs twice, stock is decremented twice, two confirmation emails are sent, and the warehouse is notified twice. Retries after a crash corrupt data.
|
|
122
|
+
- **God jobs can't be retried partially.** If `NightlyProcessingJob` fails during report generation, retrying it re-runs total recalculation, re-sends reminder emails, and re-deletes old records — all of which already completed.
|
|
123
|
+
- **No error isolation.** One failure in the god job kills the entire run. A network error in `ExternalSync.full_sync!` means reports don't generate and reminders don't send.
|
|
124
|
+
- **No queue differentiation.** Everything runs in the default queue. A burst of slow external API calls blocks email delivery.
|
|
125
|
+
- **`deliver_now` in a job.** Mailer delivery should use `deliver_now` inside a job (it's already async). But if the job itself fails and retries, the email sends again — unless you add an idempotency check.
|
|
126
|
+
|
|
127
|
+
## When To Apply
|
|
128
|
+
|
|
129
|
+
Move work to a background job when ANY of these are true:
|
|
130
|
+
|
|
131
|
+
- **External API calls** — HTTP requests to payment providers, notification services, webhooks. These are slow, unreliable, and shouldn't block a web response.
|
|
132
|
+
- **Email delivery** — Always `deliver_later`, never `deliver_now` in a controller. Let the job handle retries.
|
|
133
|
+
- **Data processing** — Recalculations, imports, exports, reports. These can take seconds or minutes and shouldn't tie up a web worker.
|
|
134
|
+
- **User-facing response doesn't need the result.** If the user doesn't need to see the outcome immediately (like "your report is being generated"), do it in a background job.
|
|
135
|
+
|
|
136
|
+
## When NOT To Apply
|
|
137
|
+
|
|
138
|
+
- **Don't background everything.** A 50ms database write that the user needs to see the result of (creating a comment, updating a profile) should happen synchronously in the request. Adding a job adds latency (Redis round trip + queue wait) for no benefit.
|
|
139
|
+
- **Don't use jobs for request-response patterns.** If the user is waiting for a result (like a refactored code response), use streaming — not "enqueue a job and poll for completion."
|
|
140
|
+
- **Don't create jobs for operations that must be transactional with the web request.** If creating an order and deducting credits must succeed or fail together, do both in the request within a transaction.
|
|
141
|
+
|
|
142
|
+
## Edge Cases
|
|
143
|
+
|
|
144
|
+
**Job needs to run after a transaction commits:**
|
|
145
|
+
Use `after_commit` or `ActiveRecord::Base.after_transaction` to ensure the record is visible to the job:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# In a service object
|
|
149
|
+
def call
|
|
150
|
+
ActiveRecord::Base.transaction do
|
|
151
|
+
order = Order.create!(params)
|
|
152
|
+
# Job runs AFTER the transaction commits
|
|
153
|
+
order.run_callbacks(:commit) { OrderConfirmationJob.perform_later(order.id) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Or in the model
|
|
158
|
+
after_commit :send_confirmation, on: :create
|
|
159
|
+
|
|
160
|
+
def send_confirmation
|
|
161
|
+
OrderConfirmationJob.perform_later(id)
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Unique jobs (prevent duplicates):**
|
|
166
|
+
Use `sidekiq-unique-jobs` or check within the job:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class IndexCodebaseJob < ApplicationJob
|
|
170
|
+
def perform(project_id)
|
|
171
|
+
project = Project.find(project_id)
|
|
172
|
+
return if project.indexing? # Already running
|
|
173
|
+
|
|
174
|
+
project.update!(indexing: true)
|
|
175
|
+
# ... do work ...
|
|
176
|
+
project.update!(indexing: false)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Long-running jobs:**
|
|
182
|
+
Break into smaller jobs that each process a chunk:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
class BulkImportJob < ApplicationJob
|
|
186
|
+
def perform(file_path, offset: 0, batch_size: 1000)
|
|
187
|
+
rows = CSV.read(file_path)[offset, batch_size]
|
|
188
|
+
return if rows.blank?
|
|
189
|
+
|
|
190
|
+
rows.each { |row| import_row(row) }
|
|
191
|
+
|
|
192
|
+
# Enqueue next batch
|
|
193
|
+
BulkImportJob.perform_later(file_path, offset: offset + batch_size, batch_size: batch_size)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Testing jobs:**
|
|
199
|
+
Test the job logic directly with `perform_now`. Test the enqueuing separately.
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
it "sends confirmation" do
|
|
203
|
+
order = create(:order)
|
|
204
|
+
expect { OrderConfirmationJob.perform_now(order.id) }
|
|
205
|
+
.to change { ActionMailer::Base.deliveries.count }.by(1)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "enqueues on order creation" do
|
|
209
|
+
expect { create(:order) }
|
|
210
|
+
.to have_enqueued_job(OrderConfirmationJob)
|
|
211
|
+
end
|
|
212
|
+
```
|