source_monitor 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-service-object
|
|
3
|
+
description: Creates service objects following single-responsibility principle with comprehensive tests. Use when extracting business logic from controllers, creating complex operations, implementing interactors, or when user mentions service objects or POROs.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Service Object Pattern
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Service objects encapsulate business logic:
|
|
12
|
+
- Single responsibility (one public method: `#call`)
|
|
13
|
+
- Easy to test in isolation
|
|
14
|
+
- Reusable across controllers, jobs, rake tasks
|
|
15
|
+
- Clear input/output contract
|
|
16
|
+
- Dependency injection for testability
|
|
17
|
+
|
|
18
|
+
## When to Use Service Objects
|
|
19
|
+
|
|
20
|
+
| Scenario | Use Service Object? |
|
|
21
|
+
|----------|---------------------|
|
|
22
|
+
| Complex business logic spanning multiple models | Yes |
|
|
23
|
+
| Multiple model interactions in one operation | Yes |
|
|
24
|
+
| External API calls | Yes |
|
|
25
|
+
| Logic shared across controllers/jobs | Yes |
|
|
26
|
+
| Operations with side effects (emails, webhooks) | Yes |
|
|
27
|
+
| Simple CRUD operations | **No** (use model) |
|
|
28
|
+
| Single model validation | **No** (use model) |
|
|
29
|
+
| Simple query/filter | **No** (use scope or query object) |
|
|
30
|
+
| View formatting | **No** (use presenter) |
|
|
31
|
+
| Form handling with validations | **No** (use form object) |
|
|
32
|
+
|
|
33
|
+
## When NOT to Use Service Objects
|
|
34
|
+
|
|
35
|
+
**Don't create a service object when:**
|
|
36
|
+
- A model callback does the job (e.g., `after_create :send_welcome_email`)
|
|
37
|
+
- The logic is a single ActiveRecord operation
|
|
38
|
+
- A concern would share the behavior more naturally
|
|
39
|
+
- You're wrapping a single method call (adds indirection for no benefit)
|
|
40
|
+
- The "service" just delegates to one model method
|
|
41
|
+
|
|
42
|
+
**Rule of thumb:** If your service object's `#call` method is under 5 lines and calls one model method, you don't need it.
|
|
43
|
+
|
|
44
|
+
## Workflow Checklist
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
Service Object Progress:
|
|
48
|
+
- [ ] Step 1: Define input/output contract
|
|
49
|
+
- [ ] Step 2: Create service test (RED)
|
|
50
|
+
- [ ] Step 3: Run test (fails - no service)
|
|
51
|
+
- [ ] Step 4: Create service file with empty #call
|
|
52
|
+
- [ ] Step 5: Run test (fails - wrong return)
|
|
53
|
+
- [ ] Step 6: Implement #call method
|
|
54
|
+
- [ ] Step 7: Run test (GREEN)
|
|
55
|
+
- [ ] Step 8: Add error case tests
|
|
56
|
+
- [ ] Step 9: Implement error handling
|
|
57
|
+
- [ ] Step 10: Final test run
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Step 1: Define Contract
|
|
61
|
+
|
|
62
|
+
```markdown
|
|
63
|
+
## Service: Orders::CreateService
|
|
64
|
+
|
|
65
|
+
### Purpose
|
|
66
|
+
Creates a new order with inventory validation and payment processing.
|
|
67
|
+
|
|
68
|
+
### Input
|
|
69
|
+
- user: User (required)
|
|
70
|
+
- items: Array<Hash> (required) - [{product_id:, quantity:}]
|
|
71
|
+
- payment_method_id: Integer (optional)
|
|
72
|
+
|
|
73
|
+
### Output (Result object)
|
|
74
|
+
Success: { success?: true, data: Order }
|
|
75
|
+
Failure: { success?: false, error: String, code: Symbol }
|
|
76
|
+
|
|
77
|
+
### Dependencies
|
|
78
|
+
- inventory_service: Checks product availability
|
|
79
|
+
- payment_gateway: Processes payment
|
|
80
|
+
|
|
81
|
+
### Side Effects
|
|
82
|
+
- Creates Order and OrderItem records
|
|
83
|
+
- Decrements inventory
|
|
84
|
+
- Charges payment method
|
|
85
|
+
- Sends confirmation email (async)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Step 2: Service Test
|
|
89
|
+
|
|
90
|
+
Location: `test/services/orders/create_service_test.rb`
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# frozen_string_literal: true
|
|
94
|
+
|
|
95
|
+
require "test_helper"
|
|
96
|
+
|
|
97
|
+
class Orders::CreateServiceTest < ActiveSupport::TestCase
|
|
98
|
+
setup do
|
|
99
|
+
@user = users(:one)
|
|
100
|
+
@product = products(:available)
|
|
101
|
+
@items = [{ product_id: @product.id, quantity: 2 }]
|
|
102
|
+
@service = Orders::CreateService.new
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
test "#call with valid inputs returns success" do
|
|
106
|
+
result = @service.call(user: @user, items: @items)
|
|
107
|
+
|
|
108
|
+
assert result.success?
|
|
109
|
+
assert_instance_of Order, result.data
|
|
110
|
+
assert_equal @user, result.data.user
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
test "#call with valid inputs creates an order" do
|
|
114
|
+
assert_difference("Order.count", 1) do
|
|
115
|
+
@service.call(user: @user, items: @items)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
test "#call with empty items returns failure" do
|
|
120
|
+
result = @service.call(user: @user, items: [])
|
|
121
|
+
|
|
122
|
+
assert result.failure?
|
|
123
|
+
assert_equal "No items provided", result.error
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
test "#call with insufficient inventory returns failure" do
|
|
127
|
+
items = [{ product_id: @product.id, quantity: 999_999 }]
|
|
128
|
+
|
|
129
|
+
result = @service.call(user: @user, items: items)
|
|
130
|
+
|
|
131
|
+
assert result.failure?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
test "#call with insufficient inventory does not create order" do
|
|
135
|
+
items = [{ product_id: @product.id, quantity: 999_999 }]
|
|
136
|
+
|
|
137
|
+
assert_no_difference("Order.count") do
|
|
138
|
+
@service.call(user: @user, items: items)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Step 3-6: Implement Service
|
|
145
|
+
|
|
146
|
+
Location: `app/services/orders/create_service.rb`
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# frozen_string_literal: true
|
|
150
|
+
|
|
151
|
+
module Orders
|
|
152
|
+
class CreateService
|
|
153
|
+
def initialize(inventory_service: InventoryService.new,
|
|
154
|
+
payment_gateway: PaymentGateway.new)
|
|
155
|
+
@inventory_service = inventory_service
|
|
156
|
+
@payment_gateway = payment_gateway
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def call(user:, items:, payment_method_id: nil)
|
|
160
|
+
return failure("No items provided", :empty_items) if items.empty?
|
|
161
|
+
return failure("Insufficient inventory", :insufficient_inventory) unless inventory_available?(items)
|
|
162
|
+
|
|
163
|
+
order = create_order(user, items)
|
|
164
|
+
process_payment(order, payment_method_id) if payment_method_id
|
|
165
|
+
|
|
166
|
+
success(order)
|
|
167
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
168
|
+
failure(e.message, :validation_failed)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
attr_reader :inventory_service, :payment_gateway
|
|
174
|
+
|
|
175
|
+
def inventory_available?(items)
|
|
176
|
+
items.all? do |item|
|
|
177
|
+
inventory_service.available?(item[:product_id], item[:quantity])
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def create_order(user, items)
|
|
182
|
+
ActiveRecord::Base.transaction do
|
|
183
|
+
order = Order.create!(user: user, status: :pending)
|
|
184
|
+
|
|
185
|
+
items.each do |item|
|
|
186
|
+
order.order_items.create!(
|
|
187
|
+
product_id: item[:product_id],
|
|
188
|
+
quantity: item[:quantity]
|
|
189
|
+
)
|
|
190
|
+
inventory_service.decrement(item[:product_id], item[:quantity])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
order
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def process_payment(order, payment_method_id)
|
|
198
|
+
payment_gateway.charge(
|
|
199
|
+
amount: order.total,
|
|
200
|
+
payment_method_id: payment_method_id
|
|
201
|
+
)
|
|
202
|
+
order.update!(status: :paid)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def success(data)
|
|
206
|
+
Result.new(success: true, data: data)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def failure(error, code = :unknown)
|
|
210
|
+
Result.new(success: false, error: error, code: code)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Result Object
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# app/services/result.rb
|
|
220
|
+
# frozen_string_literal: true
|
|
221
|
+
|
|
222
|
+
class Result
|
|
223
|
+
attr_reader :data, :error, :code
|
|
224
|
+
|
|
225
|
+
def initialize(success:, data: nil, error: nil, code: nil)
|
|
226
|
+
@success = success
|
|
227
|
+
@data = data
|
|
228
|
+
@error = error
|
|
229
|
+
@code = code
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def success?
|
|
233
|
+
@success
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def failure?
|
|
237
|
+
!@success
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def deconstruct_keys(keys)
|
|
241
|
+
{ success: @success, data: @data, error: @error, code: @code }
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Testing with Mocked Dependencies
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
class Orders::CreateServiceTest < ActiveSupport::TestCase
|
|
250
|
+
setup do
|
|
251
|
+
@inventory_service = Minitest::Mock.new
|
|
252
|
+
@payment_gateway = Minitest::Mock.new
|
|
253
|
+
@service = Orders::CreateService.new(
|
|
254
|
+
inventory_service: @inventory_service,
|
|
255
|
+
payment_gateway: @payment_gateway
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
test "calls inventory service to check availability" do
|
|
260
|
+
@inventory_service.expect(:available?, true, [Integer, Integer])
|
|
261
|
+
@inventory_service.expect(:decrement, true, [Integer, Integer])
|
|
262
|
+
|
|
263
|
+
@service.call(user: users(:one), items: [{ product_id: 1, quantity: 2 }])
|
|
264
|
+
|
|
265
|
+
@inventory_service.verify
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Calling Services
|
|
271
|
+
|
|
272
|
+
### From Controllers
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class OrdersController < ApplicationController
|
|
276
|
+
def create
|
|
277
|
+
result = Orders::CreateService.new.call(
|
|
278
|
+
user: current_user,
|
|
279
|
+
items: order_params[:items],
|
|
280
|
+
payment_method_id: order_params[:payment_method_id]
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if result.success?
|
|
284
|
+
redirect_to result.data, notice: "Order created"
|
|
285
|
+
else
|
|
286
|
+
flash.now[:alert] = result.error
|
|
287
|
+
render :new, status: :unprocessable_entity
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### From Jobs
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
class ProcessOrderJob < ApplicationJob
|
|
297
|
+
def perform(user_id, items)
|
|
298
|
+
user = User.find(user_id)
|
|
299
|
+
result = Orders::CreateService.new.call(user: user, items: items)
|
|
300
|
+
|
|
301
|
+
unless result.success?
|
|
302
|
+
Rails.logger.error("Order failed: #{result.error}")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Directory Structure
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
app/services/
|
|
312
|
+
result.rb
|
|
313
|
+
orders/
|
|
314
|
+
create_service.rb
|
|
315
|
+
cancel_service.rb
|
|
316
|
+
users/
|
|
317
|
+
register_service.rb
|
|
318
|
+
payments/
|
|
319
|
+
charge_service.rb
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Conventions
|
|
323
|
+
|
|
324
|
+
1. **Naming**: `Namespace::VerbNounService` (e.g., `Orders::CreateService`)
|
|
325
|
+
2. **Location**: `app/services/[namespace]/[name]_service.rb`
|
|
326
|
+
3. **Interface**: Single public method `#call`
|
|
327
|
+
4. **Return**: Always return Result object
|
|
328
|
+
5. **Dependencies**: Inject via constructor
|
|
329
|
+
6. **Errors**: Catch and wrap in Result, don't raise
|
|
330
|
+
|
|
331
|
+
## Anti-Patterns to Avoid
|
|
332
|
+
|
|
333
|
+
1. **God service**: Too many responsibilities - split it
|
|
334
|
+
2. **Hidden dependencies**: Using globals instead of injection
|
|
335
|
+
3. **No return contract**: Returning different types
|
|
336
|
+
4. **Raising exceptions**: Use Result objects instead
|
|
337
|
+
5. **Service wrapping one method**: Just call the method directly
|
|
338
|
+
6. **Service with multiple public methods**: Use separate services
|
|
339
|
+
|
|
340
|
+
## Checklist
|
|
341
|
+
|
|
342
|
+
- [ ] Contract defined (input/output/side effects)
|
|
343
|
+
- [ ] Test written first (RED)
|
|
344
|
+
- [ ] Single public method `#call`
|
|
345
|
+
- [ ] Returns Result object consistently
|
|
346
|
+
- [ ] Dependencies injected via constructor
|
|
347
|
+
- [ ] Error cases tested
|
|
348
|
+
- [ ] Transaction wraps multi-model operations
|
|
349
|
+
- [ ] All tests GREEN
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solid-queue-setup
|
|
3
|
+
description: Configures Solid Queue for background jobs in Rails 8. Use when setting up background processing, creating background jobs, configuring job queues, recurring jobs, or migrating from Sidekiq to Solid Queue.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Solid Queue Setup for Rails 8
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Solid Queue is Rails 8's default Active Job backend:
|
|
12
|
+
- Database-backed (no Redis required)
|
|
13
|
+
- Built-in concurrency controls
|
|
14
|
+
- Supports priorities and multiple queues
|
|
15
|
+
- Web UI available via Mission Control
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle add solid_queue
|
|
21
|
+
bin/rails solid_queue:install
|
|
22
|
+
bin/rails db:migrate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Configuration
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
# config/solid_queue.yml
|
|
29
|
+
default: &default
|
|
30
|
+
dispatchers:
|
|
31
|
+
- polling_interval: 1
|
|
32
|
+
batch_size: 500
|
|
33
|
+
workers:
|
|
34
|
+
- queues: "*"
|
|
35
|
+
threads: 3
|
|
36
|
+
processes: 1
|
|
37
|
+
polling_interval: 0.1
|
|
38
|
+
|
|
39
|
+
development:
|
|
40
|
+
<<: *default
|
|
41
|
+
|
|
42
|
+
production:
|
|
43
|
+
<<: *default
|
|
44
|
+
workers:
|
|
45
|
+
- queues: [critical, default]
|
|
46
|
+
threads: 5
|
|
47
|
+
processes: 2
|
|
48
|
+
- queues: [low]
|
|
49
|
+
threads: 2
|
|
50
|
+
processes: 1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Set as Active Job Adapter
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# config/application.rb
|
|
57
|
+
config.active_job.queue_adapter = :solid_queue
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Naming Convention
|
|
61
|
+
|
|
62
|
+
Use `_later` for async, `_now` for synchronous:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# Async (queued via Solid Queue) - preferred
|
|
66
|
+
SendWelcomeEmailJob.perform_later(user.id)
|
|
67
|
+
|
|
68
|
+
# Synchronous (runs immediately, skips queue) - use sparingly
|
|
69
|
+
SendWelcomeEmailJob.perform_now(user.id)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Creating Jobs
|
|
73
|
+
|
|
74
|
+
### Basic Job
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# app/jobs/send_welcome_email_job.rb
|
|
78
|
+
class SendWelcomeEmailJob < ApplicationJob
|
|
79
|
+
queue_as :default
|
|
80
|
+
|
|
81
|
+
def perform(user_id)
|
|
82
|
+
user = User.find(user_id)
|
|
83
|
+
UserMailer.welcome(user).deliver_now
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Job with Retries
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# app/jobs/process_payment_job.rb
|
|
92
|
+
class ProcessPaymentJob < ApplicationJob
|
|
93
|
+
queue_as :critical
|
|
94
|
+
|
|
95
|
+
retry_on PaymentGatewayError, wait: :polynomially_longer, attempts: 5
|
|
96
|
+
discard_on ActiveRecord::RecordNotFound
|
|
97
|
+
|
|
98
|
+
rescue_from(StandardError) do |exception|
|
|
99
|
+
ErrorNotifier.notify(exception)
|
|
100
|
+
raise
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def perform(order_id)
|
|
104
|
+
order = Order.find(order_id)
|
|
105
|
+
PaymentService.new.charge(order)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Job with Priority
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
class UrgentNotificationJob < ApplicationJob
|
|
114
|
+
queue_as :critical
|
|
115
|
+
|
|
116
|
+
# Lower number = higher priority (default is 0)
|
|
117
|
+
def priority
|
|
118
|
+
-10
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def perform(notification_id)
|
|
122
|
+
notification = Notification.find(notification_id)
|
|
123
|
+
notification.deliver!
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Enqueueing Jobs
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Enqueue immediately
|
|
132
|
+
SendWelcomeEmailJob.perform_later(user.id)
|
|
133
|
+
|
|
134
|
+
# Enqueue with delay
|
|
135
|
+
SendReminderJob.set(wait: 1.hour).perform_later(user.id)
|
|
136
|
+
|
|
137
|
+
# Enqueue at specific time
|
|
138
|
+
SendReportJob.set(wait_until: Date.tomorrow.noon).perform_later
|
|
139
|
+
|
|
140
|
+
# Enqueue on specific queue
|
|
141
|
+
ProcessJob.set(queue: :low).perform_later(data)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Recurring Jobs
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
# config/recurring.yml
|
|
148
|
+
production:
|
|
149
|
+
daily_report:
|
|
150
|
+
class: GenerateDailyReportJob
|
|
151
|
+
schedule: every day at 6am
|
|
152
|
+
queue: low
|
|
153
|
+
|
|
154
|
+
cleanup:
|
|
155
|
+
class: CleanupOldRecordsJob
|
|
156
|
+
schedule: every sunday at 2am
|
|
157
|
+
|
|
158
|
+
sync:
|
|
159
|
+
class: SyncExternalDataJob
|
|
160
|
+
schedule: every 15 minutes
|
|
161
|
+
|
|
162
|
+
session_cleanup:
|
|
163
|
+
class: SessionCleanupJob
|
|
164
|
+
schedule: every day at 3am
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Testing Jobs
|
|
168
|
+
|
|
169
|
+
### Job Test Template
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# test/jobs/send_welcome_email_job_test.rb
|
|
173
|
+
require "test_helper"
|
|
174
|
+
|
|
175
|
+
class SendWelcomeEmailJobTest < ActiveJob::TestCase
|
|
176
|
+
setup do
|
|
177
|
+
@user = users(:one)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
test "sends welcome email" do
|
|
181
|
+
assert_enqueued_emails 1 do
|
|
182
|
+
SendWelcomeEmailJob.perform_now(@user.id)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
test "enqueues on default queue" do
|
|
187
|
+
assert_enqueued_with(job: SendWelcomeEmailJob, queue: "default") do
|
|
188
|
+
SendWelcomeEmailJob.perform_later(@user.id)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Testing Enqueueing
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# test/jobs/process_payment_job_test.rb
|
|
198
|
+
require "test_helper"
|
|
199
|
+
|
|
200
|
+
class ProcessPaymentJobTest < ActiveJob::TestCase
|
|
201
|
+
test "enqueues the job with correct arguments" do
|
|
202
|
+
order = orders(:one)
|
|
203
|
+
|
|
204
|
+
assert_enqueued_with(job: ProcessPaymentJob, args: [order.id]) do
|
|
205
|
+
ProcessPaymentJob.perform_later(order.id)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
test "enqueues on critical queue" do
|
|
210
|
+
assert_enqueued_with(job: ProcessPaymentJob, queue: "critical") do
|
|
211
|
+
ProcessPaymentJob.perform_later(orders(:one).id)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Testing Job Side Effects
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# test/jobs/cleanup_old_records_job_test.rb
|
|
221
|
+
require "test_helper"
|
|
222
|
+
|
|
223
|
+
class CleanupOldRecordsJobTest < ActiveJob::TestCase
|
|
224
|
+
test "deletes old sessions" do
|
|
225
|
+
old_session = sessions(:old)
|
|
226
|
+
old_session.update!(created_at: 31.days.ago)
|
|
227
|
+
recent_session = sessions(:one)
|
|
228
|
+
|
|
229
|
+
CleanupOldRecordsJob.perform_now
|
|
230
|
+
|
|
231
|
+
assert_not Session.exists?(old_session.id)
|
|
232
|
+
assert Session.exists?(recent_session.id)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Testing with perform_enqueued_jobs
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# test/integration/signup_flow_test.rb
|
|
241
|
+
require "test_helper"
|
|
242
|
+
|
|
243
|
+
class SignupFlowTest < ActionDispatch::IntegrationTest
|
|
244
|
+
test "signup sends welcome email" do
|
|
245
|
+
perform_enqueued_jobs do
|
|
246
|
+
post signups_path, params: {
|
|
247
|
+
signup: { email: "new@example.com", name: "Test" }
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
assert_emails 1
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Running Solid Queue
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Development
|
|
260
|
+
bin/rails solid_queue:start
|
|
261
|
+
|
|
262
|
+
# Production (Procfile)
|
|
263
|
+
web: bin/rails server
|
|
264
|
+
worker: bin/rails solid_queue:start
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Monitoring
|
|
268
|
+
|
|
269
|
+
### Mission Control (Web UI)
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
# Gemfile
|
|
273
|
+
gem "mission_control-jobs"
|
|
274
|
+
|
|
275
|
+
# config/routes.rb
|
|
276
|
+
mount MissionControl::Jobs::Engine, at: "/jobs"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Console Queries
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
SolidQueue::Job.where(finished_at: nil).count # Pending
|
|
283
|
+
SolidQueue::FailedExecution.count # Failed
|
|
284
|
+
SolidQueue::FailedExecution.last.retry # Retry
|
|
285
|
+
SolidQueue::Job.where("finished_at < ?", 1.week.ago).delete_all # Cleanup
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Migration from Sidekiq
|
|
289
|
+
|
|
290
|
+
| Sidekiq | Solid Queue |
|
|
291
|
+
|---------|-------------|
|
|
292
|
+
| `perform_async(args)` | `perform_later(args)` |
|
|
293
|
+
| `perform_in(5.minutes, args)` | `set(wait: 5.minutes).perform_later(args)` |
|
|
294
|
+
| `sidekiq_options queue: 'critical'` | `queue_as :critical` |
|
|
295
|
+
| `sidekiq_retry_in` | `retry_on` with `wait:` |
|
|
296
|
+
|
|
297
|
+
## Checklist
|
|
298
|
+
|
|
299
|
+
- [ ] Solid Queue gem installed
|
|
300
|
+
- [ ] Migrations run
|
|
301
|
+
- [ ] Queue adapter configured
|
|
302
|
+
- [ ] Jobs use `perform_later` (not `perform_now`)
|
|
303
|
+
- [ ] Error handling with `retry_on` / `discard_on`
|
|
304
|
+
- [ ] Recurring jobs configured
|
|
305
|
+
- [ ] Job tests written
|
|
306
|
+
- [ ] Mission Control mounted (optional)
|
|
307
|
+
- [ ] All tests GREEN
|