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,177 @@
|
|
|
1
|
+
# Design Pattern: Observer
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. In Rails, this replaces scattered callbacks with explicit, decoupled event handling.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Simple Ruby observer using ActiveSupport::Notifications (Rails built-in)
|
|
9
|
+
|
|
10
|
+
# PUBLISHER: fires events after key actions
|
|
11
|
+
class Orders::CreateService
|
|
12
|
+
def call(params, user)
|
|
13
|
+
order = user.orders.create!(params)
|
|
14
|
+
|
|
15
|
+
# Publish event — doesn't know or care who's listening
|
|
16
|
+
ActiveSupport::Notifications.instrument("order.created", order: order)
|
|
17
|
+
|
|
18
|
+
Result.new(success: true, order: order)
|
|
19
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
20
|
+
Result.new(success: false, order: e.record)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# SUBSCRIBERS: each handles one concern, registered independently
|
|
25
|
+
|
|
26
|
+
# config/initializers/event_subscribers.rb
|
|
27
|
+
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
|
|
28
|
+
order = payload[:order]
|
|
29
|
+
OrderMailer.confirmation(order).deliver_later
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
|
|
33
|
+
order = payload[:order]
|
|
34
|
+
WarehouseNotificationJob.perform_later(order.id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
|
|
38
|
+
order = payload[:order]
|
|
39
|
+
Analytics.track("order_created", order_id: order.id, total: order.total)
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Custom event system for more structure:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/events/event_bus.rb
|
|
47
|
+
module EventBus
|
|
48
|
+
SUBSCRIBERS = Hash.new { |h, k| h[k] = [] }
|
|
49
|
+
|
|
50
|
+
def self.subscribe(event_name, handler)
|
|
51
|
+
SUBSCRIBERS[event_name] << handler
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.publish(event_name, **payload)
|
|
55
|
+
SUBSCRIBERS[event_name].each do |handler|
|
|
56
|
+
handler.call(**payload)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
Rails.logger.error("EventBus: #{handler} failed for #{event_name}: #{e.message}")
|
|
59
|
+
# Don't let one subscriber failure block others
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Subscriber classes — focused, testable
|
|
65
|
+
class OrderCreatedHandlers::SendConfirmation
|
|
66
|
+
def self.call(order:)
|
|
67
|
+
OrderMailer.confirmation(order).deliver_later
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class OrderCreatedHandlers::NotifyWarehouse
|
|
72
|
+
def self.call(order:)
|
|
73
|
+
WarehouseNotificationJob.perform_later(order.id)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class OrderCreatedHandlers::TrackAnalytics
|
|
78
|
+
def self.call(order:)
|
|
79
|
+
Analytics.track("order_created", order_id: order.id, total: order.total)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Registration
|
|
84
|
+
EventBus.subscribe("order.created", OrderCreatedHandlers::SendConfirmation)
|
|
85
|
+
EventBus.subscribe("order.created", OrderCreatedHandlers::NotifyWarehouse)
|
|
86
|
+
EventBus.subscribe("order.created", OrderCreatedHandlers::TrackAnalytics)
|
|
87
|
+
|
|
88
|
+
# Publisher — fires and forgets
|
|
89
|
+
class Orders::CreateService
|
|
90
|
+
def call(params, user)
|
|
91
|
+
order = user.orders.create!(params)
|
|
92
|
+
EventBus.publish("order.created", order: order)
|
|
93
|
+
Result.new(success: true, order: order)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Why This Is Good
|
|
99
|
+
|
|
100
|
+
- **Publisher doesn't know its subscribers.** `CreateService` publishes "order.created" and moves on. It doesn't import, reference, or depend on mailers, warehouses, or analytics.
|
|
101
|
+
- **Adding new reactions doesn't modify existing code.** Sending a Slack notification on order creation? Add one subscriber. The publisher, the mailer subscriber, and the warehouse subscriber are untouched.
|
|
102
|
+
- **Each subscriber is independently testable.** Test `SendConfirmation.call(order: order)` in isolation — no service, no other subscribers.
|
|
103
|
+
- **Error isolation.** If analytics tracking fails, the email still sends and the warehouse still gets notified. One subscriber's failure doesn't cascade.
|
|
104
|
+
- **Replaces callback chains.** Instead of 5 `after_create` callbacks on the model, there are 5 focused subscriber classes registered in one place.
|
|
105
|
+
|
|
106
|
+
## Anti-Pattern
|
|
107
|
+
|
|
108
|
+
Using model callbacks as an implicit observer pattern:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class Order < ApplicationRecord
|
|
112
|
+
after_create :send_confirmation
|
|
113
|
+
after_create :notify_warehouse
|
|
114
|
+
after_create :track_analytics
|
|
115
|
+
after_create :update_inventory
|
|
116
|
+
after_create :award_loyalty_points
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def send_confirmation
|
|
121
|
+
OrderMailer.confirmation(self).deliver_later
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def notify_warehouse
|
|
125
|
+
WarehouseApi.notify(id: id)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ... 30 more lines of callback methods
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Why This Is Bad
|
|
133
|
+
|
|
134
|
+
- **Tightly coupled.** Every subscriber is a method on the model. Adding a new reaction means modifying the model class.
|
|
135
|
+
- **Hidden execution order.** Callbacks run in declaration order, but that's not obvious. Reordering lines changes behavior silently.
|
|
136
|
+
- **Can't skip selectively.** Creating an order in seeds or tests triggers ALL callbacks. There's no way to say "create without notifications."
|
|
137
|
+
- **Transaction danger.** `after_create` runs inside the transaction. If `notify_warehouse` raises, the entire order creation rolls back.
|
|
138
|
+
|
|
139
|
+
## When To Apply
|
|
140
|
+
|
|
141
|
+
- **Multiple side effects triggered by one action.** An order is created → send email, notify warehouse, track analytics, update inventory. Each side effect is a subscriber.
|
|
142
|
+
- **Different teams own different reactions.** The billing team owns payment processing, the ops team owns warehouse notifications, the marketing team owns analytics. Each team's code is a separate subscriber.
|
|
143
|
+
- **You want to add/remove reactions without touching the core flow.** Feature flags can enable/disable subscribers without modifying the publisher.
|
|
144
|
+
|
|
145
|
+
## When NOT To Apply
|
|
146
|
+
|
|
147
|
+
- **One or two simple side effects.** If creating an order only sends one email, a direct call in the service object is clearer than an event bus.
|
|
148
|
+
- **Synchronous, transactional requirements.** If the side effect MUST succeed for the action to succeed (deducting credits must happen for the AI response to be valid), use direct calls within a transaction — not events.
|
|
149
|
+
- **Don't build an event bus for 3 events.** The overhead of a custom event system isn't justified until you have 10+ events with multiple subscribers each.
|
|
150
|
+
|
|
151
|
+
## Rails-Specific Alternatives
|
|
152
|
+
|
|
153
|
+
**`after_commit` for job enqueueing:**
|
|
154
|
+
If you want callback-style simplicity with event-style decoupling:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
class Order < ApplicationRecord
|
|
158
|
+
after_commit :publish_created_event, on: :create
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def publish_created_event
|
|
163
|
+
OrderCreatedJob.perform_later(id)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The job dispatches to handlers
|
|
168
|
+
class OrderCreatedJob < ApplicationJob
|
|
169
|
+
def perform(order_id)
|
|
170
|
+
order = Order.find(order_id)
|
|
171
|
+
OrderCreatedHandlers::SendConfirmation.call(order: order)
|
|
172
|
+
OrderCreatedHandlers::NotifyWarehouse.call(order: order)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This is pragmatic for small apps — it uses Rails conventions while keeping handlers extracted.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Design Pattern: Proxy
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Provide a surrogate or placeholder for another object to control access to it. Proxies add a layer between the client and the real object — for lazy loading, access control, logging, or caching — without the client knowing the difference.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Caching proxy — caches expensive API calls
|
|
9
|
+
class CachingEmbeddingProxy
|
|
10
|
+
def initialize(real_client, cache: Rails.cache, ttl: 24.hours)
|
|
11
|
+
@real_client = real_client
|
|
12
|
+
@cache = cache
|
|
13
|
+
@ttl = ttl
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def embed(texts)
|
|
17
|
+
cache_key = "embeddings:#{Digest::SHA256.hexdigest(texts.sort.join('|'))}"
|
|
18
|
+
|
|
19
|
+
@cache.fetch(cache_key, expires_in: @ttl) do
|
|
20
|
+
@real_client.embed(texts)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def embed_query(text)
|
|
25
|
+
# Queries are unique per request — don't cache
|
|
26
|
+
@real_client.embed_query(text)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Usage — caller doesn't know it's a proxy
|
|
31
|
+
client = Embeddings::HttpClient.new(base_url: ENV["EMBEDDING_URL"])
|
|
32
|
+
client = CachingEmbeddingProxy.new(client)
|
|
33
|
+
vectors = client.embed(["class Order; end"]) # Cached after first call
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Access control proxy — checks permissions before delegating
|
|
38
|
+
class AuthorizingProjectProxy
|
|
39
|
+
def initialize(project, user)
|
|
40
|
+
@project = project
|
|
41
|
+
@user = user
|
|
42
|
+
@membership = project.project_memberships.find_by(user: user)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def code_embeddings
|
|
46
|
+
require_role!(:viewer)
|
|
47
|
+
@project.code_embeddings
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update!(attributes)
|
|
51
|
+
require_role!(:admin)
|
|
52
|
+
@project.update!(attributes)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def destroy!
|
|
56
|
+
require_role!(:owner)
|
|
57
|
+
@project.destroy!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def method_missing(method, ...)
|
|
61
|
+
require_role!(:viewer)
|
|
62
|
+
@project.public_send(method, ...)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def respond_to_missing?(method, include_private = false)
|
|
66
|
+
@project.respond_to?(method, include_private)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
ROLE_HIERARCHY = { viewer: 0, member: 1, admin: 2, owner: 3 }.freeze
|
|
72
|
+
|
|
73
|
+
def require_role!(minimum)
|
|
74
|
+
current = ROLE_HIERARCHY[@membership&.role&.to_sym] || -1
|
|
75
|
+
required = ROLE_HIERARCHY[minimum]
|
|
76
|
+
|
|
77
|
+
raise Forbidden, "Requires #{minimum} role" if current < required
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Usage
|
|
82
|
+
project = AuthorizingProjectProxy.new(project, current_user)
|
|
83
|
+
project.code_embeddings # Works for viewer+
|
|
84
|
+
project.update!(name: "New Name") # Only admin+
|
|
85
|
+
project.destroy! # Only owner
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Lazy loading proxy — defers expensive initialization
|
|
90
|
+
class LazyModelProxy
|
|
91
|
+
def initialize(&loader)
|
|
92
|
+
@loader = loader
|
|
93
|
+
@loaded = false
|
|
94
|
+
@target = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def method_missing(method, ...)
|
|
98
|
+
load_target!
|
|
99
|
+
@target.public_send(method, ...)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def respond_to_missing?(method, include_private = false)
|
|
103
|
+
load_target!
|
|
104
|
+
@target.respond_to?(method, include_private)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def load_target!
|
|
110
|
+
unless @loaded
|
|
111
|
+
@target = @loader.call
|
|
112
|
+
@loaded = true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Usage — the DB query only runs when you access the object
|
|
118
|
+
expensive_report = LazyModelProxy.new { Report.generate_monthly(Date.current) }
|
|
119
|
+
# No query yet...
|
|
120
|
+
expensive_report.total # NOW the query runs
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Why This Is Good
|
|
124
|
+
|
|
125
|
+
- **Transparent to the caller.** The proxy responds to the same methods as the real object. Code that uses the real client works unchanged with the caching proxy.
|
|
126
|
+
- **Separation of concerns.** Caching logic lives in the proxy, not in the client. Auth logic lives in the auth proxy, not in the model.
|
|
127
|
+
- **Composable with other patterns.** A caching proxy can wrap a logging decorator which wraps the real client. Each layer adds one concern.
|
|
128
|
+
|
|
129
|
+
## When To Apply
|
|
130
|
+
|
|
131
|
+
- **Caching expensive operations.** API calls, database queries, computations.
|
|
132
|
+
- **Access control.** Check permissions before allowing operations on a resource.
|
|
133
|
+
- **Lazy loading.** Defer initialization of expensive objects until they're actually used.
|
|
134
|
+
- **Remote objects.** Wrap a remote API to look like a local object.
|
|
135
|
+
|
|
136
|
+
## When NOT To Apply
|
|
137
|
+
|
|
138
|
+
- **Simple delegation.** If you're just forwarding calls without adding behavior, use `delegate` or `SimpleDelegator` — not a proxy.
|
|
139
|
+
- **Decorator already fits.** Proxies control access. Decorators add behavior. If you're adding behavior (logging, metrics), use a decorator.
|
|
140
|
+
- **The object is cheap to create.** Lazy loading a simple `User.new` adds complexity without benefit.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Design Pattern: Singleton
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Ensure a class has only one instance and provide a global point of access to it. Ruby provides a built-in `Singleton` module, but in practice you should almost always use module-level state or class methods instead.
|
|
6
|
+
|
|
7
|
+
### Ruby's Built-In Singleton
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "singleton"
|
|
11
|
+
|
|
12
|
+
class AppConfig
|
|
13
|
+
include Singleton
|
|
14
|
+
|
|
15
|
+
attr_accessor :api_key, :environment, :log_level
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@environment = ENV.fetch("RACK_ENV", "development")
|
|
19
|
+
@log_level = :info
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Usage
|
|
24
|
+
AppConfig.instance.api_key = "sk-123"
|
|
25
|
+
AppConfig.instance.log_level # => :info
|
|
26
|
+
|
|
27
|
+
# .new raises NoMethodError
|
|
28
|
+
AppConfig.new # => NoMethodError: private method 'new' called
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Better Alternative: Module with State
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# More idiomatic Ruby — module with class-level state
|
|
35
|
+
module AppConfig
|
|
36
|
+
class << self
|
|
37
|
+
attr_accessor :api_key, :environment, :log_level
|
|
38
|
+
|
|
39
|
+
def configure
|
|
40
|
+
yield self if block_given?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset!
|
|
44
|
+
@api_key = nil
|
|
45
|
+
@environment = "development"
|
|
46
|
+
@log_level = :info
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Defaults
|
|
51
|
+
self.environment = ENV.fetch("RACK_ENV", "development")
|
|
52
|
+
self.log_level = :info
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Usage — cleaner, no .instance call
|
|
56
|
+
AppConfig.configure do |config|
|
|
57
|
+
config.api_key = "sk-123"
|
|
58
|
+
config.log_level = :debug
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
AppConfig.api_key # => "sk-123"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Thread-Safe Singleton (When You Actually Need One)
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class ConnectionPool
|
|
68
|
+
include Singleton
|
|
69
|
+
|
|
70
|
+
def initialize
|
|
71
|
+
@mutex = Mutex.new
|
|
72
|
+
@connections = []
|
|
73
|
+
@max_size = 10
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def checkout
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@connections.pop || create_connection
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def checkin(conn)
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@connections.push(conn) if @connections.size < @max_size
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def create_connection
|
|
91
|
+
DatabaseConnection.new
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## When To Apply
|
|
97
|
+
|
|
98
|
+
- **Connection pools.** A single pool managing shared resources across threads.
|
|
99
|
+
- **Configuration objects.** Global app configuration accessed everywhere (but prefer the module pattern above).
|
|
100
|
+
- **Caches.** A single in-memory cache shared across the application.
|
|
101
|
+
- **Logger instances.** One logger configured once, used everywhere.
|
|
102
|
+
|
|
103
|
+
## When NOT To Apply (Most of the Time)
|
|
104
|
+
|
|
105
|
+
- **Don't use Singleton as a global variable.** If you're using Singleton to share state between unrelated classes, you have a coupling problem. Pass dependencies explicitly.
|
|
106
|
+
- **Don't use Singleton in Rails.** Rails has `Rails.application.config`, `Rails.cache`, `Rails.logger`. Use those instead of rolling your own singletons.
|
|
107
|
+
- **Don't use Singleton for testability-killing global state.** Singletons persist across tests, causing test pollution. Module-level state with a `reset!` method is easier to test.
|
|
108
|
+
- **Prefer dependency injection.** Instead of `AppConfig.instance.api_key` deep inside a service, pass the API key as a constructor argument. This makes the dependency explicit and testable.
|
|
109
|
+
|
|
110
|
+
## Edge Cases
|
|
111
|
+
|
|
112
|
+
**Singleton + Threads:**
|
|
113
|
+
Ruby's `Singleton` module is thread-safe for instance creation. But the instance's mutable state is NOT thread-safe unless you add synchronization (`Mutex`).
|
|
114
|
+
|
|
115
|
+
**Testing Singletons:**
|
|
116
|
+
Always provide a `reset!` method:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
def teardown
|
|
120
|
+
AppConfig.reset!
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Without this, state leaks between tests and causes order-dependent failures.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Design Pattern: State
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Allow an object to change its behavior when its internal state changes by delegating to state objects. Instead of `case` statements on a status field, each state is a class that defines the valid transitions and behavior for that state.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# State classes — each defines what's possible in that state
|
|
9
|
+
module OrderStates
|
|
10
|
+
class Pending
|
|
11
|
+
def confirm(order)
|
|
12
|
+
return Result.new(success: false, error: "No items") if order.line_items.empty?
|
|
13
|
+
|
|
14
|
+
order.update!(status: :confirmed, confirmed_at: Time.current)
|
|
15
|
+
OrderMailer.confirmed(order).deliver_later
|
|
16
|
+
Result.new(success: true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def cancel(order, reason:)
|
|
20
|
+
order.update!(status: :cancelled, cancelled_at: Time.current, cancel_reason: reason)
|
|
21
|
+
Result.new(success: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ship(_order) = Result.new(success: false, error: "Cannot ship a pending order")
|
|
25
|
+
def deliver(_order) = Result.new(success: false, error: "Cannot deliver a pending order")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Confirmed
|
|
29
|
+
def confirm(_order) = Result.new(success: false, error: "Already confirmed")
|
|
30
|
+
|
|
31
|
+
def ship(order)
|
|
32
|
+
order.update!(status: :shipped, shipped_at: Time.current)
|
|
33
|
+
OrderMailer.shipped(order).deliver_later
|
|
34
|
+
Result.new(success: true)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cancel(order, reason:)
|
|
38
|
+
order.update!(status: :cancelled, cancelled_at: Time.current, cancel_reason: reason)
|
|
39
|
+
Orders::RefundService.call(order)
|
|
40
|
+
Result.new(success: true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deliver(_order) = Result.new(success: false, error: "Must ship before delivering")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Shipped
|
|
47
|
+
def confirm(_order) = Result.new(success: false, error: "Already shipped")
|
|
48
|
+
def ship(_order) = Result.new(success: false, error: "Already shipped")
|
|
49
|
+
def cancel(_order, reason: nil) = Result.new(success: false, error: "Cannot cancel shipped order")
|
|
50
|
+
|
|
51
|
+
def deliver(order)
|
|
52
|
+
order.update!(status: :delivered, delivered_at: Time.current)
|
|
53
|
+
OrderMailer.delivered(order).deliver_later
|
|
54
|
+
Result.new(success: true)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Delivered
|
|
59
|
+
def confirm(_order) = Result.new(success: false, error: "Already delivered")
|
|
60
|
+
def ship(_order) = Result.new(success: false, error: "Already delivered")
|
|
61
|
+
def cancel(_order, reason: nil) = Result.new(success: false, error: "Cannot cancel delivered order")
|
|
62
|
+
def deliver(_order) = Result.new(success: false, error: "Already delivered")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Cancelled
|
|
66
|
+
def confirm(_order) = Result.new(success: false, error: "Order is cancelled")
|
|
67
|
+
def ship(_order) = Result.new(success: false, error: "Order is cancelled")
|
|
68
|
+
def cancel(_order, reason: nil) = Result.new(success: false, error: "Already cancelled")
|
|
69
|
+
def deliver(_order) = Result.new(success: false, error: "Order is cancelled")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
MAPPING = {
|
|
73
|
+
"pending" => Pending.new,
|
|
74
|
+
"confirmed" => Confirmed.new,
|
|
75
|
+
"shipped" => Shipped.new,
|
|
76
|
+
"delivered" => Delivered.new,
|
|
77
|
+
"cancelled" => Cancelled.new
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
def self.for(status)
|
|
81
|
+
MAPPING.fetch(status)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The Order delegates state-dependent behavior
|
|
86
|
+
class Order < ApplicationRecord
|
|
87
|
+
def current_state
|
|
88
|
+
OrderStates.for(status)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def confirm!
|
|
92
|
+
current_state.confirm(self)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ship!
|
|
96
|
+
current_state.ship(self)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cancel!(reason:)
|
|
100
|
+
current_state.cancel(self, reason: reason)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def deliver!
|
|
104
|
+
current_state.deliver(self)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Usage is clean and safe
|
|
109
|
+
order = Order.find(params[:id])
|
|
110
|
+
result = order.confirm! # Works when pending
|
|
111
|
+
result = order.ship! # Works when confirmed
|
|
112
|
+
result = order.cancel!(reason: "changed mind") # Invalid when shipped
|
|
113
|
+
# result.success? => false, result.error => "Cannot cancel shipped order"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Why This Is Good
|
|
117
|
+
|
|
118
|
+
- **Invalid transitions return errors, not crashes.** Calling `ship!` on a pending order returns a descriptive error instead of silently doing nothing or raising an exception.
|
|
119
|
+
- **Each state's rules are visible in one place.** Open `Confirmed` to see everything that can happen from the confirmed state. No scanning a 200-line model for scattered `if status == "confirmed"` checks.
|
|
120
|
+
- **Adding a new state means adding one class.** A `Refunded` state is one new class with 4 methods. Existing states don't change.
|
|
121
|
+
- **Testable per state.** Test `Pending#confirm` in isolation — does it update status, send email, return success? Test `Shipped#cancel` — does it return the right error?
|
|
122
|
+
|
|
123
|
+
## Anti-Pattern
|
|
124
|
+
|
|
125
|
+
A case/when on status scattered throughout the model:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class Order < ApplicationRecord
|
|
129
|
+
def confirm!
|
|
130
|
+
case status
|
|
131
|
+
when "pending"
|
|
132
|
+
update!(status: :confirmed, confirmed_at: Time.current)
|
|
133
|
+
OrderMailer.confirmed(self).deliver_later
|
|
134
|
+
when "confirmed"
|
|
135
|
+
raise "Already confirmed"
|
|
136
|
+
when "shipped", "delivered"
|
|
137
|
+
raise "Cannot confirm — already #{status}"
|
|
138
|
+
when "cancelled"
|
|
139
|
+
raise "Cannot confirm cancelled order"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ship!
|
|
144
|
+
case status
|
|
145
|
+
when "confirmed"
|
|
146
|
+
update!(status: :shipped, shipped_at: Time.current)
|
|
147
|
+
when "pending"
|
|
148
|
+
raise "Must confirm first"
|
|
149
|
+
# ... another 10 lines of case/when
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def cancel!
|
|
154
|
+
case status
|
|
155
|
+
when "pending", "confirmed"
|
|
156
|
+
update!(status: :cancelled)
|
|
157
|
+
Orders::RefundService.call(self) if status == "confirmed"
|
|
158
|
+
when "shipped"
|
|
159
|
+
raise "Cannot cancel shipped order"
|
|
160
|
+
# ... more branching
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Why This Is Bad
|
|
167
|
+
|
|
168
|
+
- **N methods × M states = N×M branches.** 4 actions × 5 states = 20 case branches scattered across 4 methods. Adding a 6th state means editing all 4 methods.
|
|
169
|
+
- **Rules for one state are split across multiple methods.** To understand "what can a confirmed order do?" you read `confirm!`, `ship!`, `cancel!`, and `deliver!` — scanning for `when "confirmed"` in each.
|
|
170
|
+
- **Inconsistent error handling.** Some branches raise, some return nil, some silently do nothing. The State pattern enforces a consistent return type (`Result`).
|
|
171
|
+
|
|
172
|
+
## When To Apply
|
|
173
|
+
|
|
174
|
+
- **An object has 3+ states with different behavior.** Orders (pending/confirmed/shipped/delivered/cancelled), subscriptions (trialing/active/past_due/cancelled), projects (draft/active/archived).
|
|
175
|
+
- **You find yourself writing `case status` or `if object.pending?` in multiple places.** That's the State pattern trying to emerge.
|
|
176
|
+
- **State transitions have side effects.** Confirming sends an email, shipping notifies the warehouse, cancelling triggers a refund. Each state's transitions have different side effects.
|
|
177
|
+
|
|
178
|
+
## When NOT To Apply
|
|
179
|
+
|
|
180
|
+
- **Two states with simple behavior.** An `active`/`inactive` boolean with one behavior difference doesn't need state objects. A simple `if active?` is clearer.
|
|
181
|
+
- **Status is display-only.** If the status field only affects what badge is shown in the UI, a helper method or enum is sufficient.
|
|
182
|
+
- **The team uses `aasm` or `statesman` gems.** Follow existing conventions. These gems implement the State pattern with DSL sugar.
|
|
183
|
+
|
|
184
|
+
## Edge Cases
|
|
185
|
+
|
|
186
|
+
**State machine gems vs hand-rolled:**
|
|
187
|
+
For simple state machines (3-5 states, clear transitions), hand-rolled state objects are clearer. For complex machines (10+ states, guards, audit trails), consider `statesman` or `aasm`.
|
|
188
|
+
|
|
189
|
+
**Querying by state:**
|
|
190
|
+
State objects handle behavior. Database queries use the status column directly:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
scope :actionable, -> { where(status: %w[pending confirmed]) }
|
|
194
|
+
scope :completed, -> { where(status: %w[delivered cancelled]) }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Persisting state transitions for audit:**
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
class OrderStates::Confirmed
|
|
201
|
+
def ship(order)
|
|
202
|
+
order.update!(status: :shipped, shipped_at: Time.current)
|
|
203
|
+
order.state_transitions.create!(from: "confirmed", to: "shipped", actor: Current.user)
|
|
204
|
+
Result.new(success: true)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|