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,158 @@
|
|
|
1
|
+
# Design Pattern: Builder
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Construct complex objects step by step. The Builder pattern lets you produce different representations of an object using the same construction process. In Ruby, builders are often implemented as chainable method calls or configuration blocks.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Builder with chainable methods — idiomatic Ruby
|
|
9
|
+
class PromptBuilder
|
|
10
|
+
def initialize
|
|
11
|
+
@system_parts = []
|
|
12
|
+
@messages = []
|
|
13
|
+
@model = "claude-haiku-4-5-20251001"
|
|
14
|
+
@max_tokens = 4096
|
|
15
|
+
@temperature = 0.0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def system(content)
|
|
19
|
+
@system_parts << content
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def best_practice(document)
|
|
24
|
+
@system_parts << "## Best Practice: #{document.title}\n\n#{document.content}"
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def codebase_context(embeddings)
|
|
29
|
+
context = embeddings.map { |e| "# #{e.file_path}\n```ruby\n#{e.chunk_content}\n```" }.join("\n\n")
|
|
30
|
+
@system_parts << "## Relevant Codebase Context\n\n#{context}"
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def user(content)
|
|
35
|
+
@messages << { role: "user", content: content }
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def assistant(content)
|
|
40
|
+
@messages << { role: "assistant", content: content }
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def model(name)
|
|
45
|
+
@model = name
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def max_tokens(n)
|
|
50
|
+
@max_tokens = n
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def temperature(t)
|
|
55
|
+
@temperature = t
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build
|
|
60
|
+
{
|
|
61
|
+
model: @model,
|
|
62
|
+
max_tokens: @max_tokens,
|
|
63
|
+
temperature: @temperature,
|
|
64
|
+
system: @system_parts.join("\n\n---\n\n"),
|
|
65
|
+
messages: @messages
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Usage — reads like a recipe
|
|
71
|
+
prompt = PromptBuilder.new
|
|
72
|
+
.system("You are Rubyn, an expert Ruby and Rails coding assistant.")
|
|
73
|
+
.best_practice(service_objects_doc)
|
|
74
|
+
.best_practice(callbacks_doc)
|
|
75
|
+
.codebase_context(relevant_embeddings)
|
|
76
|
+
.user("Refactor this controller action into a service object:\n\n```ruby\n#{code}\n```")
|
|
77
|
+
.model("claude-haiku-4-5-20251001")
|
|
78
|
+
.max_tokens(4096)
|
|
79
|
+
.build
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Builder with block configuration — Ruby convention:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class QueryBuilder
|
|
86
|
+
attr_reader :scope
|
|
87
|
+
|
|
88
|
+
def initialize(base_scope)
|
|
89
|
+
@scope = base_scope
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.build(base_scope, &block)
|
|
93
|
+
builder = new(base_scope)
|
|
94
|
+
builder.instance_eval(&block) if block
|
|
95
|
+
builder.scope
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def where(**conditions)
|
|
99
|
+
@scope = @scope.where(conditions)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def search(query)
|
|
103
|
+
return unless query.present?
|
|
104
|
+
@scope = @scope.where("name ILIKE ?", "%#{query}%")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def status(value)
|
|
108
|
+
return unless value.present?
|
|
109
|
+
@scope = @scope.where(status: value)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def date_range(from:, to:)
|
|
113
|
+
@scope = @scope.where(created_at: from..to) if from && to
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def sort_by(column, direction = :asc)
|
|
117
|
+
@scope = @scope.order(column => direction)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def paginate(page:, per: 25)
|
|
121
|
+
@scope = @scope.page(page).per(per)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Usage with block
|
|
126
|
+
orders = QueryBuilder.build(current_user.orders) do
|
|
127
|
+
status params[:status]
|
|
128
|
+
search params[:q]
|
|
129
|
+
date_range from: params[:from], to: params[:to]
|
|
130
|
+
sort_by :created_at, :desc
|
|
131
|
+
paginate page: params[:page]
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Why This Is Good
|
|
136
|
+
|
|
137
|
+
- **Step-by-step construction.** Complex objects are built incrementally. Each step is named and self-documenting. The final `build` call assembles everything.
|
|
138
|
+
- **Optional steps.** Not every prompt needs best practices or codebase context. The builder doesn't care which steps are called or in what order.
|
|
139
|
+
- **Chainable API is readable.** `.system(...).best_practice(...).user(...)` reads as a sequence of construction steps. It's clearer than a constructor with 8 keyword arguments.
|
|
140
|
+
- **Separates construction from representation.** The same builder process can produce different outputs — a hash for the API, a string for logging, an object for testing.
|
|
141
|
+
- **Block form is idiomatic Ruby.** `QueryBuilder.build(scope) { status "active" }` follows Ruby conventions (like `Faraday.new { |f| f.adapter :net_http }`).
|
|
142
|
+
|
|
143
|
+
## When To Apply
|
|
144
|
+
|
|
145
|
+
- **Objects with many optional parts.** An API request with optional system prompt, codebase context, conversation history, model selection, and temperature.
|
|
146
|
+
- **Objects constructed in different configurations.** A query that sometimes has filters, sometimes has sorting, sometimes has pagination — but never all of them.
|
|
147
|
+
- **When a constructor has 5+ parameters.** The builder replaces a long argument list with named, chainable steps.
|
|
148
|
+
- **Testing.** Builders make it easy to create test fixtures with specific configurations without specifying every field.
|
|
149
|
+
|
|
150
|
+
## When NOT To Apply
|
|
151
|
+
|
|
152
|
+
- **Simple objects with 2-3 required fields.** `Order.new(user: user, total: 100)` doesn't need a builder.
|
|
153
|
+
- **Objects that are always constructed the same way.** If every construction uses the same steps, a factory method is simpler.
|
|
154
|
+
- **Don't create a builder just for one call site.** Builders shine when used from multiple places with different configurations.
|
|
155
|
+
|
|
156
|
+
## Rails Examples
|
|
157
|
+
|
|
158
|
+
Rails uses the builder pattern extensively — `Arel` query building, `ActionMailer` message construction, `ActiveStorage` attachment configuration. Follow the same pattern for your domain objects.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Design Pattern: Command
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Encapsulate a request as an object, allowing you to parameterize clients with different requests, queue requests, log them, and support undo operations. In Ruby/Rails, service objects are already a form of the Command pattern — each one encapsulates a single operation.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Commands as objects — queueable, loggable, undoable
|
|
9
|
+
class Commands::Base
|
|
10
|
+
attr_reader :executed_at, :result
|
|
11
|
+
|
|
12
|
+
def execute
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def undo
|
|
17
|
+
raise NotImplementedError, "#{self.class} does not support undo"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def description
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Commands::ChangeOrderStatus < Commands::Base
|
|
26
|
+
def initialize(order, new_status, actor:)
|
|
27
|
+
@order = order
|
|
28
|
+
@new_status = new_status
|
|
29
|
+
@actor = actor
|
|
30
|
+
@previous_status = order.status
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def execute
|
|
34
|
+
@order.update!(status: @new_status)
|
|
35
|
+
@executed_at = Time.current
|
|
36
|
+
AuditLog.record(actor: @actor, action: description, target: @order)
|
|
37
|
+
@result = :success
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def undo
|
|
41
|
+
@order.update!(status: @previous_status)
|
|
42
|
+
AuditLog.record(actor: @actor, action: "Undo: #{description}", target: @order)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def description
|
|
46
|
+
"Changed order #{@order.reference} from #{@previous_status} to #{@new_status}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Commands::ApplyDiscount < Commands::Base
|
|
51
|
+
def initialize(order, discount_code, actor:)
|
|
52
|
+
@order = order
|
|
53
|
+
@discount_code = discount_code
|
|
54
|
+
@actor = actor
|
|
55
|
+
@previous_discount = order.discount_amount
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute
|
|
59
|
+
discount = Discount.active.find_by!(code: @discount_code)
|
|
60
|
+
amount = discount.calculate(@order.subtotal)
|
|
61
|
+
@order.update!(discount_amount: amount, discount_code: @discount_code)
|
|
62
|
+
@executed_at = Time.current
|
|
63
|
+
@result = :success
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def undo
|
|
67
|
+
@order.update!(discount_amount: @previous_discount, discount_code: nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def description
|
|
71
|
+
"Applied discount #{@discount_code} to order #{@order.reference}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Command history for undo support
|
|
76
|
+
class CommandHistory
|
|
77
|
+
def initialize
|
|
78
|
+
@history = []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def execute(command)
|
|
82
|
+
command.execute
|
|
83
|
+
@history.push(command)
|
|
84
|
+
command
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def undo_last
|
|
88
|
+
command = @history.pop
|
|
89
|
+
return unless command
|
|
90
|
+
command.undo
|
|
91
|
+
command
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def log
|
|
95
|
+
@history.map { |cmd| "#{cmd.executed_at}: #{cmd.description}" }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Usage
|
|
100
|
+
history = CommandHistory.new
|
|
101
|
+
history.execute(Commands::ChangeOrderStatus.new(order, "confirmed", actor: admin))
|
|
102
|
+
history.execute(Commands::ApplyDiscount.new(order, "SAVE10", actor: admin))
|
|
103
|
+
|
|
104
|
+
# Undo last action
|
|
105
|
+
history.undo_last # Reverses the discount
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Why This Is Good
|
|
109
|
+
|
|
110
|
+
- **Operations are first-class objects.** Each command can be queued, logged, serialized, and undone. You can't do this with bare method calls.
|
|
111
|
+
- **Audit trail is built in.** Every command has a `description` and `executed_at`. The history is an automatic audit log.
|
|
112
|
+
- **Undo support.** Each command stores the state needed to reverse itself. Admin actions, bulk operations, and user-facing "undo" features are straightforward.
|
|
113
|
+
- **Deferred execution.** Commands can be serialized and executed later — in a background job, after approval, or in a batch.
|
|
114
|
+
|
|
115
|
+
## When To Apply
|
|
116
|
+
|
|
117
|
+
- **Admin actions that need audit trails.** Status changes, refunds, account modifications — wrap each in a command that logs who did what.
|
|
118
|
+
- **User-facing undo.** "Undo archive", "undo delete", "undo status change" — commands store previous state.
|
|
119
|
+
- **Batch operations.** Collect multiple commands, validate them all, then execute as a group.
|
|
120
|
+
- **Background job payloads.** Serialize a command and enqueue it. The job deserializes and executes.
|
|
121
|
+
|
|
122
|
+
## When NOT To Apply
|
|
123
|
+
|
|
124
|
+
- **Simple CRUD without undo or audit.** A standard `Order.create!(params)` doesn't need a Command wrapper.
|
|
125
|
+
- **Your existing service objects already work.** If service objects handle your use case without undo or queueing needs, don't add Command on top.
|
|
126
|
+
- **Fire-and-forget operations.** If you never need to undo or replay the action, a plain service object is simpler.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Design Pattern: Composite
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Compose objects into tree structures to represent part-whole hierarchies. The Composite pattern lets clients treat individual objects and compositions of objects uniformly — the same interface for a single item and a group of items.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Permission system — individual permissions and permission groups share the same interface
|
|
9
|
+
|
|
10
|
+
class Permission
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(name)
|
|
14
|
+
@name = name
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def grants?(action)
|
|
18
|
+
name == action
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all_permissions
|
|
22
|
+
[name]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_s
|
|
26
|
+
name
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class PermissionGroup
|
|
31
|
+
attr_reader :name
|
|
32
|
+
|
|
33
|
+
def initialize(name)
|
|
34
|
+
@name = name
|
|
35
|
+
@children = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add(permission)
|
|
39
|
+
@children << permission
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def grants?(action)
|
|
44
|
+
@children.any? { |child| child.grants?(action) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def all_permissions
|
|
48
|
+
@children.flat_map(&:all_permissions)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
"#{name}: [#{@children.map(&:to_s).join(', ')}]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a permission tree
|
|
57
|
+
read_code = Permission.new("code:read")
|
|
58
|
+
write_code = Permission.new("code:write")
|
|
59
|
+
delete_code = Permission.new("code:delete")
|
|
60
|
+
|
|
61
|
+
code_admin = PermissionGroup.new("code_admin")
|
|
62
|
+
.add(read_code)
|
|
63
|
+
.add(write_code)
|
|
64
|
+
.add(delete_code)
|
|
65
|
+
|
|
66
|
+
read_billing = Permission.new("billing:read")
|
|
67
|
+
manage_billing = Permission.new("billing:manage")
|
|
68
|
+
|
|
69
|
+
billing_admin = PermissionGroup.new("billing_admin")
|
|
70
|
+
.add(read_billing)
|
|
71
|
+
.add(manage_billing)
|
|
72
|
+
|
|
73
|
+
super_admin = PermissionGroup.new("super_admin")
|
|
74
|
+
.add(code_admin) # Group containing group
|
|
75
|
+
.add(billing_admin) # Group containing group
|
|
76
|
+
|
|
77
|
+
# Uniform interface — works the same for single permissions and groups
|
|
78
|
+
read_code.grants?("code:read") # true
|
|
79
|
+
code_admin.grants?("code:read") # true
|
|
80
|
+
super_admin.grants?("billing:manage") # true — traverses the tree
|
|
81
|
+
super_admin.all_permissions
|
|
82
|
+
# => ["code:read", "code:write", "code:delete", "billing:read", "billing:manage"]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Rails-practical example — pricing rules:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# Single rule and rule groups share the same interface
|
|
89
|
+
class Pricing::FlatDiscount
|
|
90
|
+
def initialize(amount)
|
|
91
|
+
@amount = amount
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply(price)
|
|
95
|
+
[price - @amount, 0].max
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class Pricing::PercentDiscount
|
|
100
|
+
def initialize(percent)
|
|
101
|
+
@percent = percent
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def apply(price)
|
|
105
|
+
price * (1 - @percent / 100.0)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Pricing::DiscountChain
|
|
110
|
+
def initialize
|
|
111
|
+
@discounts = []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def add(discount)
|
|
115
|
+
@discounts << discount
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def apply(price)
|
|
120
|
+
@discounts.reduce(price) { |p, discount| discount.apply(p) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Compose discounts
|
|
125
|
+
holiday_deal = Pricing::DiscountChain.new
|
|
126
|
+
.add(Pricing::PercentDiscount.new(10)) # 10% off first
|
|
127
|
+
.add(Pricing::FlatDiscount.new(5_00)) # Then $5 off
|
|
128
|
+
|
|
129
|
+
final_price = holiday_deal.apply(100_00) # $100 → $90 → $85
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Why This Is Good
|
|
133
|
+
|
|
134
|
+
- **Uniform interface.** `grants?("code:read")` works on a single permission, a group, or a tree of groups. The caller never checks types.
|
|
135
|
+
- **Recursive composition.** Groups can contain other groups. `super_admin` contains `code_admin` which contains individual permissions. Any depth works.
|
|
136
|
+
- **Easy to extend.** New permission types (time-limited, IP-restricted) just implement `grants?` and `all_permissions`. They plug into any group.
|
|
137
|
+
|
|
138
|
+
## When To Apply
|
|
139
|
+
|
|
140
|
+
- **Tree structures** — menus, categories, org charts, file systems, permission hierarchies.
|
|
141
|
+
- **Part-whole relationships** — a single discount and a chain of discounts, a single validator and a validator pipeline.
|
|
142
|
+
- **When clients need to treat single items and collections identically.**
|
|
143
|
+
|
|
144
|
+
## When NOT To Apply
|
|
145
|
+
|
|
146
|
+
- **Flat collections.** If items don't nest, use a simple array. Don't build a Composite for a list.
|
|
147
|
+
- **When the leaf and composite have very different interfaces.** If a single permission and a permission group need fundamentally different methods, Composite adds forced uniformity.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Design Pattern: Decorator
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Attach additional behavior to an object dynamically by wrapping it in a decorator object. The decorator forwards method calls to the wrapped object and adds behavior before, after, or around the delegation. In Ruby, decorators are often implemented with `SimpleDelegator` or `method_missing`, but explicit delegation is clearest.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Base class — the object to be decorated
|
|
9
|
+
class Ai::CompletionClient
|
|
10
|
+
def complete(messages, model:, max_tokens:)
|
|
11
|
+
response = Anthropic::Client.new.messages.create(
|
|
12
|
+
model: model,
|
|
13
|
+
max_tokens: max_tokens,
|
|
14
|
+
messages: messages
|
|
15
|
+
)
|
|
16
|
+
CompletionResult.new(
|
|
17
|
+
content: response.content.first.text,
|
|
18
|
+
input_tokens: response.usage.input_tokens,
|
|
19
|
+
output_tokens: response.usage.output_tokens
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Decorator: adds logging around the real call
|
|
25
|
+
class Ai::LoggingDecorator
|
|
26
|
+
def initialize(client)
|
|
27
|
+
@client = client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def complete(messages, model:, max_tokens:)
|
|
31
|
+
Rails.logger.info("[AI] Requesting #{model} with #{messages.length} messages")
|
|
32
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
|
|
34
|
+
result = @client.complete(messages, model: model, max_tokens: max_tokens)
|
|
35
|
+
|
|
36
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
37
|
+
Rails.logger.info("[AI] Completed in #{elapsed.round(2)}s — #{result.input_tokens}in/#{result.output_tokens}out")
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Decorator: adds caching
|
|
44
|
+
class Ai::CachingDecorator
|
|
45
|
+
def initialize(client, cache: Rails.cache, ttl: 1.hour)
|
|
46
|
+
@client = client
|
|
47
|
+
@cache = cache
|
|
48
|
+
@ttl = ttl
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def complete(messages, model:, max_tokens:)
|
|
52
|
+
cache_key = "ai:#{Digest::SHA256.hexdigest(messages.to_json)}:#{model}"
|
|
53
|
+
|
|
54
|
+
@cache.fetch(cache_key, expires_in: @ttl) do
|
|
55
|
+
@client.complete(messages, model: model, max_tokens: max_tokens)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Decorator: adds retry logic
|
|
61
|
+
class Ai::RetryDecorator
|
|
62
|
+
def initialize(client, max_retries: 3)
|
|
63
|
+
@client = client
|
|
64
|
+
@max_retries = max_retries
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def complete(messages, model:, max_tokens:)
|
|
68
|
+
retries = 0
|
|
69
|
+
begin
|
|
70
|
+
@client.complete(messages, model: model, max_tokens: max_tokens)
|
|
71
|
+
rescue Faraday::TimeoutError, Faraday::ServerError => e
|
|
72
|
+
retries += 1
|
|
73
|
+
raise if retries > @max_retries
|
|
74
|
+
sleep(2**retries + rand(0.0..0.5))
|
|
75
|
+
retry
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Compose decorators — each wraps the previous one
|
|
81
|
+
client = Ai::CompletionClient.new
|
|
82
|
+
client = Ai::RetryDecorator.new(client)
|
|
83
|
+
client = Ai::CachingDecorator.new(client)
|
|
84
|
+
client = Ai::LoggingDecorator.new(client)
|
|
85
|
+
|
|
86
|
+
# The caller sees ONE object with ONE interface
|
|
87
|
+
result = client.complete(messages, model: "claude-haiku-4-5-20251001", max_tokens: 4096)
|
|
88
|
+
# Logs → checks cache → retries on failure → calls Anthropic
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Using `SimpleDelegator` for view decorators (presenters):
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class OrderPresenter < SimpleDelegator
|
|
95
|
+
def formatted_total
|
|
96
|
+
"$#{format('%.2f', total)}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def status_badge
|
|
100
|
+
case status
|
|
101
|
+
when "pending" then '<span class="badge bg-warning">Pending</span>'
|
|
102
|
+
when "shipped" then '<span class="badge bg-info">Shipped</span>'
|
|
103
|
+
when "delivered" then '<span class="badge bg-success">Delivered</span>'
|
|
104
|
+
else '<span class="badge bg-secondary">Unknown</span>'
|
|
105
|
+
end.html_safe
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def created_at_formatted
|
|
109
|
+
created_at.strftime("%B %d, %Y at %I:%M %p")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Usage in controller
|
|
114
|
+
@order = OrderPresenter.new(Order.find(params[:id]))
|
|
115
|
+
|
|
116
|
+
# In the view, all Order methods work plus the presenter methods
|
|
117
|
+
<%= @order.formatted_total %>
|
|
118
|
+
<%= @order.status_badge %>
|
|
119
|
+
<%= @order.user.name %> <!-- delegated to the real Order -->
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Why This Is Good
|
|
123
|
+
|
|
124
|
+
- **Composable behaviors.** Logging, caching, and retry are separate concerns, each in its own class. You compose them like LEGO — add or remove as needed.
|
|
125
|
+
- **Same interface throughout.** Every decorator responds to `complete(messages, model:, max_tokens:)`. The caller doesn't know or care how many decorators are stacked.
|
|
126
|
+
- **Open/Closed compliant.** Adding rate limiting means writing a `RateLimitDecorator` — not modifying the client, the logger, or the cache.
|
|
127
|
+
- **Testable in isolation.** Test `RetryDecorator` by wrapping a fake client that fails twice then succeeds. No real HTTP, no logging, no caching involved.
|
|
128
|
+
- **Presenters keep views clean.** `@order.formatted_total` is cleaner than `number_to_currency(@order.total)` scattered across 10 views.
|
|
129
|
+
|
|
130
|
+
## Anti-Pattern
|
|
131
|
+
|
|
132
|
+
Putting all cross-cutting concerns inside the base class:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class Ai::CompletionClient
|
|
136
|
+
def complete(messages, model:, max_tokens:)
|
|
137
|
+
cache_key = "ai:#{Digest::SHA256.hexdigest(messages.to_json)}"
|
|
138
|
+
cached = Rails.cache.read(cache_key)
|
|
139
|
+
return cached if cached
|
|
140
|
+
|
|
141
|
+
Rails.logger.info("[AI] Requesting #{model}")
|
|
142
|
+
start = Time.now
|
|
143
|
+
|
|
144
|
+
retries = 0
|
|
145
|
+
begin
|
|
146
|
+
response = Anthropic::Client.new.messages.create(
|
|
147
|
+
model: model, max_tokens: max_tokens, messages: messages
|
|
148
|
+
)
|
|
149
|
+
rescue Faraday::TimeoutError
|
|
150
|
+
retries += 1
|
|
151
|
+
retry if retries <= 3
|
|
152
|
+
raise
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
elapsed = Time.now - start
|
|
156
|
+
Rails.logger.info("[AI] Completed in #{elapsed}s")
|
|
157
|
+
|
|
158
|
+
result = CompletionResult.new(content: response.content.first.text)
|
|
159
|
+
Rails.cache.write(cache_key, result, expires_in: 1.hour)
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Why This Is Bad
|
|
166
|
+
|
|
167
|
+
- **One 30-line method with 4 responsibilities.** API call, logging, caching, and retry are tangled together. Modifying retry logic means reading through cache and logging code.
|
|
168
|
+
- **Can't disable caching for tests.** The cache is hardcoded. Tests either hit the cache (stale results) or need `Rails.cache.clear` before every test.
|
|
169
|
+
- **Can't reuse retry logic.** If the embedding client also needs retry, you duplicate the retry block. With a decorator, `RetryDecorator.new(embedding_client)` reuses it.
|
|
170
|
+
|
|
171
|
+
## When To Apply
|
|
172
|
+
|
|
173
|
+
- **Cross-cutting concerns** — logging, caching, retry, rate limiting, metrics, authentication wrapping. Each is a decorator.
|
|
174
|
+
- **View presentation logic** — formatting dates, currencies, status badges, display names. Use `SimpleDelegator` presenters.
|
|
175
|
+
- **Feature toggles** — a decorator that conditionally enables new behavior while forwarding to the old behavior by default.
|
|
176
|
+
- **API response transformation** — a decorator that normalizes different API response formats into a consistent internal structure.
|
|
177
|
+
|
|
178
|
+
## When NOT To Apply
|
|
179
|
+
|
|
180
|
+
- **One behavior that won't be reused.** If only the AI client needs retry logic and nothing else ever will, putting retry inline is simpler than a decorator class.
|
|
181
|
+
- **Deep stacks obscure behavior.** If you stack 7 decorators, debugging which one modified the response is difficult. Keep stacks to 3-4 max.
|
|
182
|
+
- **Don't decorate ActiveRecord models for persistence logic.** Use service objects. Decorators are for presentation and cross-cutting concerns, not business logic.
|
|
183
|
+
|
|
184
|
+
## Edge Cases
|
|
185
|
+
|
|
186
|
+
**`Module#prepend` as an inline decorator:**
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
module Logging
|
|
190
|
+
def complete(messages, model:, max_tokens:)
|
|
191
|
+
Rails.logger.info("[AI] Requesting #{model}")
|
|
192
|
+
result = super
|
|
193
|
+
Rails.logger.info("[AI] Done: #{result.input_tokens} tokens")
|
|
194
|
+
result
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Ai::CompletionClient.prepend(Logging)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
This is Ruby's most concise decorator pattern but less flexible — it modifies the class globally rather than per-instance.
|
|
202
|
+
|
|
203
|
+
**Draper gem for view decorators:**
|
|
204
|
+
If the team uses Draper, follow its conventions. Otherwise, `SimpleDelegator` is lighter and framework-free.
|