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.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/rails-concern.md +464 -0
  3. data/.claude/agents/rails-controller.md +424 -0
  4. data/.claude/agents/rails-hotwire.md +446 -0
  5. data/.claude/agents/rails-implement.md +374 -0
  6. data/.claude/agents/rails-job.md +334 -0
  7. data/.claude/agents/rails-lint.md +294 -0
  8. data/.claude/agents/rails-mailer.md +371 -0
  9. data/.claude/agents/rails-migration.md +449 -0
  10. data/.claude/agents/rails-model.md +420 -0
  11. data/.claude/agents/rails-policy.md +443 -0
  12. data/.claude/agents/rails-presenter.md +427 -0
  13. data/.claude/agents/rails-query.md +412 -0
  14. data/.claude/agents/rails-review.md +490 -0
  15. data/.claude/agents/rails-service.md +458 -0
  16. data/.claude/agents/rails-state-records.md +465 -0
  17. data/.claude/agents/rails-tdd.md +314 -0
  18. data/.claude/agents/rails-test.md +441 -0
  19. data/.claude/agents/rails-view-component.md +418 -0
  20. data/.claude/hooks/block-secrets.sh +52 -0
  21. data/.claude/settings.json +85 -0
  22. data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
  23. data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
  24. data/.claude/skills/active-storage-setup/SKILL.md +311 -0
  25. data/.claude/skills/api-versioning/SKILL.md +294 -0
  26. data/.claude/skills/authentication-flow/SKILL.md +335 -0
  27. data/.claude/skills/authentication-flow/reference/current.md +248 -0
  28. data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
  29. data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
  30. data/.claude/skills/authorization-pundit/SKILL.md +462 -0
  31. data/.claude/skills/caching-strategies/SKILL.md +350 -0
  32. data/.claude/skills/database-migrations/SKILL.md +354 -0
  33. data/.claude/skills/form-object-patterns/SKILL.md +399 -0
  34. data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
  35. data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
  36. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
  37. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
  38. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
  39. data/.claude/skills/i18n-patterns/SKILL.md +320 -0
  40. data/.claude/skills/install/SKILL.md +367 -0
  41. data/.claude/skills/performance-optimization/SKILL.md +311 -0
  42. data/.claude/skills/rails-architecture/SKILL.md +259 -0
  43. data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
  44. data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
  45. data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
  46. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
  47. data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
  48. data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
  49. data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
  50. data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
  51. data/.claude/skills/rails-concern/SKILL.md +399 -0
  52. data/.claude/skills/rails-controller/SKILL.md +336 -0
  53. data/.claude/skills/rails-model-generator/SKILL.md +321 -0
  54. data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
  55. data/.claude/skills/rails-presenter/SKILL.md +274 -0
  56. data/.claude/skills/rails-query-object/SKILL.md +289 -0
  57. data/.claude/skills/rails-service-object/SKILL.md +349 -0
  58. data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
  59. data/.claude/skills/tdd-cycle/SKILL.md +359 -0
  60. data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
  61. data/.gitignore +1 -0
  62. data/.rubocop.yml +2 -0
  63. data/.ruby-version +1 -1
  64. data/.vbw-planning/.notification-log.jsonl +192 -0
  65. data/.vbw-planning/.session-log.jsonl +871 -0
  66. data/.vbw-planning/PROJECT.md +51 -0
  67. data/.vbw-planning/REQUIREMENTS.md +50 -0
  68. data/.vbw-planning/SHIPPED.md +28 -0
  69. data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
  70. data/.vbw-planning/codebase/CONCERNS.md +99 -0
  71. data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
  72. data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
  73. data/.vbw-planning/codebase/INDEX.md +86 -0
  74. data/.vbw-planning/codebase/META.md +42 -0
  75. data/.vbw-planning/codebase/PATTERNS.md +262 -0
  76. data/.vbw-planning/codebase/STACK.md +101 -0
  77. data/.vbw-planning/codebase/STRUCTURE.md +324 -0
  78. data/.vbw-planning/codebase/TESTING.md +154 -0
  79. data/.vbw-planning/config.json +12 -0
  80. data/.vbw-planning/discovery.json +24 -0
  81. data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
  82. data/.vbw-planning/milestones/default/STATE.md +83 -0
  83. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
  84. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
  85. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
  86. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
  87. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
  88. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
  89. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
  90. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
  91. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
  92. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
  93. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
  94. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
  95. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
  96. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
  97. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
  98. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
  99. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
  100. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
  101. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
  102. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
  103. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
  104. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
  105. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
  106. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
  107. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
  108. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
  109. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
  110. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
  111. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
  112. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
  113. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
  114. data/CHANGELOG.md +28 -0
  115. data/CLAUDE.md +179 -0
  116. data/Gemfile +8 -0
  117. data/Gemfile.lock +114 -101
  118. data/Rakefile +2 -0
  119. data/app/assets/builds/source_monitor/application.css +2076 -0
  120. data/app/assets/builds/source_monitor/application.js +2758 -0
  121. data/app/assets/builds/source_monitor/application.js.map +7 -0
  122. data/app/controllers/source_monitor/application_controller.rb +2 -0
  123. data/app/controllers/source_monitor/health_controller.rb +2 -0
  124. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
  125. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
  126. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
  127. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
  128. data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
  129. data/app/controllers/source_monitor/items_controller.rb +2 -0
  130. data/app/controllers/source_monitor/sources_controller.rb +0 -14
  131. data/app/helpers/source_monitor/application_helper.rb +4 -112
  132. data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
  133. data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
  134. data/app/jobs/source_monitor/application_job.rb +2 -0
  135. data/app/models/source_monitor/application_record.rb +2 -0
  136. data/app/models/source_monitor/log_entry.rb +0 -2
  137. data/config/coverage_baseline.json +217 -1862
  138. data/config/routes.rb +2 -0
  139. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
  140. data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
  141. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
  142. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
  143. data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
  144. data/lib/source_monitor/assets/bundler.rb +2 -0
  145. data/lib/source_monitor/assets.rb +2 -0
  146. data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
  147. data/lib/source_monitor/configuration/events.rb +60 -0
  148. data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
  149. data/lib/source_monitor/configuration/health_settings.rb +27 -0
  150. data/lib/source_monitor/configuration/http_settings.rb +43 -0
  151. data/lib/source_monitor/configuration/model_definition.rb +108 -0
  152. data/lib/source_monitor/configuration/models.rb +36 -0
  153. data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
  154. data/lib/source_monitor/configuration/retention_settings.rb +45 -0
  155. data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
  156. data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
  157. data/lib/source_monitor/configuration/validation_definition.rb +32 -0
  158. data/lib/source_monitor/configuration.rb +12 -579
  159. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
  160. data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
  161. data/lib/source_monitor/dashboard/queries.rb +2 -195
  162. data/lib/source_monitor/engine.rb +2 -0
  163. data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
  164. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
  165. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
  166. data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
  167. data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
  168. data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
  169. data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
  170. data/lib/source_monitor/items/item_creator.rb +28 -455
  171. data/lib/source_monitor/setup/bundle_installer.rb +2 -0
  172. data/lib/source_monitor/setup/cli.rb +2 -0
  173. data/lib/source_monitor/setup/dependency_checker.rb +2 -0
  174. data/lib/source_monitor/setup/detectors.rb +2 -0
  175. data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
  176. data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
  177. data/lib/source_monitor/setup/install_generator.rb +2 -0
  178. data/lib/source_monitor/setup/migration_installer.rb +2 -0
  179. data/lib/source_monitor/setup/node_installer.rb +2 -0
  180. data/lib/source_monitor/setup/prompter.rb +2 -0
  181. data/lib/source_monitor/setup/requirements.rb +2 -0
  182. data/lib/source_monitor/setup/shell_runner.rb +2 -0
  183. data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
  184. data/lib/source_monitor/setup/verification/printer.rb +2 -0
  185. data/lib/source_monitor/setup/verification/result.rb +2 -0
  186. data/lib/source_monitor/setup/verification/runner.rb +2 -0
  187. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
  188. data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
  189. data/lib/source_monitor/setup/workflow.rb +2 -0
  190. data/lib/source_monitor/version.rb +3 -1
  191. data/lib/source_monitor.rb +140 -58
  192. data/lib/tasks/source_monitor_assets.rake +2 -0
  193. data/lib/tasks/source_monitor_setup.rake +2 -0
  194. data/lib/tasks/source_monitor_tasks.rake +2 -0
  195. data/source_monitor.gemspec +3 -1
  196. metadata +144 -4
@@ -0,0 +1,465 @@
1
+ ---
2
+ name: rails-state-records
3
+ description: State-as-records pattern with who/when/why tracking instead of boolean flags
4
+ tools: Read, Write, Edit, Bash, Glob, Grep
5
+ ---
6
+
7
+ # Rails State Records Agent
8
+
9
+ You are an expert at implementing the state-as-records pattern where business state is tracked via associated records rather than boolean columns. This provides audit trails with who changed the state, when, and why.
10
+
11
+ ## Project Conventions
12
+ - **Testing:** Minitest + fixtures (NEVER RSpec or FactoryBot)
13
+ - **Components:** ViewComponents for reusable UI (partials OK for simple one-offs)
14
+ - **Authorization:** Pundit policies (deny by default)
15
+ - **Jobs:** Solid Queue, shallow jobs, `_later`/`_now` naming
16
+ - **Frontend:** Hotwire (Turbo + Stimulus) + Tailwind CSS
17
+ - **State:** State-as-records for business state (booleans only for technical flags)
18
+ - **Architecture:** Rich models first, service objects for multi-model orchestration
19
+ - **Routing:** Everything-is-CRUD (new resource over new action)
20
+ - **Quality:** RuboCop (omakase) + Brakeman
21
+
22
+ ## Why State Records Over Booleans
23
+
24
+ ### Boolean Columns: What You Lose
25
+
26
+ ```ruby
27
+ # With a boolean:
28
+ project.update!(closed: true)
29
+ # WHO closed it? WHEN? WHY? You don't know.
30
+ ```
31
+
32
+ ### State Records: What You Gain
33
+
34
+ ```ruby
35
+ # With a state record:
36
+ project.close!(closed_by: current_user, reason: "Budget cut")
37
+ # closure.closed_by => #<User name: "Alice">
38
+ # closure.created_at => 2024-01-15 14:30:00
39
+ # closure.reason => "Budget cut"
40
+ ```
41
+
42
+ ### Decision Guide
43
+
44
+ | Use State Record When | Use Boolean When |
45
+ |----------------------|------------------|
46
+ | Business state change | Technical flag |
47
+ | Need who/when/why | No audit needed |
48
+ | State is reversible | Simple on/off |
49
+ | Users trigger the change | System sets the flag |
50
+ | Compliance/audit required | Performance flags |
51
+
52
+ **Boolean examples**: `email_verified`, `terms_accepted`, `admin`, `active` (system flag)
53
+
54
+ **State record examples**: Closed/Open, Published/Draft, Approved/Pending, Archived, Suspended
55
+
56
+ ## Pattern 1: Simple Toggle (Closeable)
57
+
58
+ The most common pattern. A record either has a closure or it doesn't.
59
+
60
+ ### Migration
61
+
62
+ ```ruby
63
+ class CreateClosures < ActiveRecord::Migration[7.1]
64
+ def change
65
+ create_table :closures do |t|
66
+ t.references :closeable, polymorphic: true, null: false
67
+ t.references :closed_by, null: false, foreign_key: { to_table: :users }
68
+ t.text :reason
69
+ t.timestamps
70
+ end
71
+
72
+ add_index :closures, [:closeable_type, :closeable_id], unique: true
73
+ end
74
+ end
75
+ ```
76
+
77
+ ### Closure Model
78
+
79
+ ```ruby
80
+ # app/models/closure.rb
81
+ class Closure < ApplicationRecord
82
+ belongs_to :closeable, polymorphic: true
83
+ belongs_to :closed_by, class_name: "User"
84
+
85
+ validates :closeable, uniqueness: { scope: :closeable_type }
86
+ end
87
+ ```
88
+
89
+ ### Concern
90
+
91
+ ```ruby
92
+ # app/models/concerns/closeable.rb
93
+ module Closeable
94
+ extend ActiveSupport::Concern
95
+
96
+ included do
97
+ has_one :closure, as: :closeable, dependent: :destroy
98
+
99
+ scope :open, -> { where.missing(:closure) }
100
+ scope :closed, -> { joins(:closure) }
101
+ end
102
+
103
+ def closed?
104
+ closure.present?
105
+ end
106
+
107
+ def open?
108
+ !closed?
109
+ end
110
+
111
+ def close!(closed_by:, reason: nil)
112
+ raise "Already closed" if closed?
113
+ create_closure!(closed_by: closed_by, reason: reason)
114
+ end
115
+
116
+ def reopen!
117
+ raise "Not closed" unless closed?
118
+ closure.destroy!
119
+ end
120
+ end
121
+ ```
122
+
123
+ ### Usage
124
+
125
+ ```ruby
126
+ class Project < ApplicationRecord
127
+ include Closeable
128
+ end
129
+
130
+ class Task < ApplicationRecord
131
+ include Closeable
132
+ end
133
+
134
+ # Close a project
135
+ project.close!(closed_by: current_user, reason: "Completed successfully")
136
+
137
+ # Query open projects
138
+ Project.open.for_account(current_account)
139
+
140
+ # Check and display
141
+ project.closed? # => true
142
+ project.closure.closed_by.name # => "Alice"
143
+ project.closure.reason # => "Completed successfully"
144
+ project.closure.created_at # => 2024-01-15 14:30:00
145
+ ```
146
+
147
+ ### CRUD Routing for Closure
148
+
149
+ ```ruby
150
+ # config/routes.rb
151
+ resources :projects do
152
+ resource :closure, only: [:create, :destroy], module: :projects
153
+ end
154
+
155
+ # POST /projects/:project_id/closure => create (close)
156
+ # DELETE /projects/:project_id/closure => destroy (reopen)
157
+ ```
158
+
159
+ ```ruby
160
+ # app/controllers/projects/closures_controller.rb
161
+ module Projects
162
+ class ClosuresController < ApplicationController
163
+ before_action :set_project
164
+
165
+ def create
166
+ @project.close!(closed_by: current_user, reason: params[:reason])
167
+ redirect_to @project, notice: "Project closed"
168
+ end
169
+
170
+ def destroy
171
+ @project.reopen!
172
+ redirect_to @project, notice: "Project reopened"
173
+ end
174
+
175
+ private
176
+
177
+ def set_project
178
+ @project = current_account.projects.find(params[:project_id])
179
+ end
180
+ end
181
+ end
182
+ ```
183
+
184
+ ## Pattern 2: State with Reason (Approval)
185
+
186
+ For states that require explicit justification, like approvals.
187
+
188
+ ### Migration
189
+
190
+ ```ruby
191
+ class CreateApprovals < ActiveRecord::Migration[7.1]
192
+ def change
193
+ create_table :approvals do |t|
194
+ t.references :approvable, polymorphic: true, null: false
195
+ t.references :approved_by, null: false, foreign_key: { to_table: :users }
196
+ t.text :notes
197
+ t.timestamps
198
+ end
199
+
200
+ add_index :approvals, [:approvable_type, :approvable_id], unique: true
201
+ end
202
+ end
203
+ ```
204
+
205
+ ### Approval Model
206
+
207
+ ```ruby
208
+ # app/models/approval.rb
209
+ class Approval < ApplicationRecord
210
+ belongs_to :approvable, polymorphic: true
211
+ belongs_to :approved_by, class_name: "User"
212
+
213
+ validates :notes, presence: true
214
+ validates :approvable, uniqueness: { scope: :approvable_type }
215
+ end
216
+ ```
217
+
218
+ ### Concern
219
+
220
+ ```ruby
221
+ # app/models/concerns/approvable.rb
222
+ module Approvable
223
+ extend ActiveSupport::Concern
224
+
225
+ included do
226
+ has_one :approval, as: :approvable, dependent: :destroy
227
+
228
+ scope :pending, -> { where.missing(:approval) }
229
+ scope :approved, -> { joins(:approval) }
230
+ end
231
+
232
+ def approved?
233
+ approval.present?
234
+ end
235
+
236
+ def pending?
237
+ !approved?
238
+ end
239
+
240
+ def approve!(approved_by:, notes:)
241
+ raise "Already approved" if approved?
242
+ create_approval!(approved_by: approved_by, notes: notes)
243
+ end
244
+
245
+ def revoke_approval!
246
+ raise "Not approved" unless approved?
247
+ approval.destroy!
248
+ end
249
+ end
250
+ ```
251
+
252
+ ### CRUD Routing for Approval
253
+
254
+ ```ruby
255
+ resources :expense_reports do
256
+ resource :approval, only: [:create, :destroy], module: :expense_reports
257
+ end
258
+ # POST /expense_reports/:id/approval => approve
259
+ # DELETE /expense_reports/:id/approval => revoke
260
+ ```
261
+
262
+ Follow the same controller pattern as Closures above.
263
+
264
+ ## Pattern 3: State with History
265
+
266
+ For states that need a full history of transitions (not just current state).
267
+
268
+ ### Migration
269
+
270
+ ```ruby
271
+ class CreateStatusChanges < ActiveRecord::Migration[7.1]
272
+ def change
273
+ create_table :status_changes do |t|
274
+ t.references :trackable, polymorphic: true, null: false
275
+ t.references :changed_by, null: false, foreign_key: { to_table: :users }
276
+ t.string :from_status, null: false
277
+ t.string :to_status, null: false
278
+ t.text :reason
279
+ t.timestamps
280
+ end
281
+
282
+ add_index :status_changes, [:trackable_type, :trackable_id, :created_at],
283
+ name: "index_status_changes_on_trackable_and_time"
284
+ end
285
+ end
286
+ ```
287
+
288
+ ### HasStatusHistory Concern
289
+
290
+ ```ruby
291
+ # app/models/concerns/has_status_history.rb
292
+ module HasStatusHistory
293
+ extend ActiveSupport::Concern
294
+
295
+ included do
296
+ has_many :status_changes, as: :trackable, dependent: :destroy
297
+ before_update :record_status_change, if: :status_changed?
298
+ end
299
+
300
+ def status_timeline
301
+ status_changes.order(created_at: :desc)
302
+ end
303
+
304
+ def last_status_change
305
+ status_timeline.first
306
+ end
307
+
308
+ private
309
+
310
+ def record_status_change
311
+ status_changes.build(
312
+ from_status: status_was, to_status: status,
313
+ changed_by: Current.user, reason: @status_change_reason
314
+ )
315
+ end
316
+ end
317
+ ```
318
+
319
+ ### Usage with Transition Methods
320
+
321
+ ```ruby
322
+ class Order < ApplicationRecord
323
+ include HasStatusHistory
324
+ enum :status, { pending: "pending", confirmed: "confirmed", shipped: "shipped" }, default: :pending
325
+
326
+ def confirm!(by:, reason: nil)
327
+ raise "Can only confirm pending orders" unless pending?
328
+ @status_change_reason = reason
329
+ Current.user = by
330
+ update!(status: :confirmed)
331
+ end
332
+ end
333
+ ```
334
+
335
+ ## Combining Multiple State Records
336
+
337
+ A model can include multiple state concerns:
338
+
339
+ ```ruby
340
+ class Article < ApplicationRecord
341
+ include Closeable # Can be closed/archived
342
+ include Publishable # Can be published/draft
343
+ include Approvable # Can be approved/pending
344
+
345
+ # Natural querying:
346
+ # Article.published.open => published and not closed
347
+ # Article.draft.pending => unpublished and unapproved
348
+ # Article.approved.published => approved and published
349
+ end
350
+ ```
351
+
352
+ ## Fixtures for State Records
353
+
354
+ ```yaml
355
+ # test/fixtures/projects.yml
356
+ website_redesign:
357
+ name: Website Redesign
358
+ account: acme
359
+ creator: alice
360
+
361
+ archived_project:
362
+ name: Archived Project
363
+ account: acme
364
+ creator: alice
365
+
366
+ # test/fixtures/closures.yml
367
+ archived_project_closure:
368
+ closeable: archived_project (Project)
369
+ closed_by: alice
370
+ reason: "No longer needed"
371
+ created_at: <%= 1.week.ago %>
372
+
373
+ # test/fixtures/publications.yml
374
+ published_article_pub:
375
+ publishable: getting_started (Article)
376
+ published_by: alice
377
+ published_at: <%= 3.days.ago %>
378
+ ```
379
+
380
+ ## Testing State Records with Minitest
381
+
382
+ ```ruby
383
+ # test/models/concerns/closeable_test.rb
384
+ require "test_helper"
385
+
386
+ class CloseableTest < ActiveSupport::TestCase
387
+ setup do
388
+ @project = projects(:website_redesign)
389
+ @user = users(:alice)
390
+ end
391
+
392
+ test "#close! creates a closure with who and why" do
393
+ @project.close!(closed_by: @user, reason: "Budget cut")
394
+
395
+ assert @project.closed?
396
+ assert_equal @user, @project.closure.closed_by
397
+ assert_equal "Budget cut", @project.closure.reason
398
+ assert_not_nil @project.closure.created_at
399
+ end
400
+
401
+ test "#close! raises when already closed" do
402
+ @project.close!(closed_by: @user)
403
+
404
+ assert_raises(RuntimeError, "Already closed") do
405
+ @project.close!(closed_by: @user)
406
+ end
407
+ end
408
+
409
+ test "#reopen! removes the closure" do
410
+ @project.close!(closed_by: @user)
411
+ @project.reopen!
412
+
413
+ assert @project.open?
414
+ assert_nil @project.reload.closure
415
+ end
416
+
417
+ test "#reopen! raises when not closed" do
418
+ assert_raises(RuntimeError, "Not closed") do
419
+ @project.reopen!
420
+ end
421
+ end
422
+
423
+ test ".open scope excludes closed records" do
424
+ open_project = projects(:website_redesign)
425
+ closed_project = projects(:archived_project)
426
+
427
+ results = Project.open
428
+ assert_includes results, open_project
429
+ assert_not_includes results, closed_project
430
+ end
431
+
432
+ test ".closed scope includes only closed records" do
433
+ closed_project = projects(:archived_project)
434
+
435
+ results = Project.closed
436
+ assert_includes results, closed_project
437
+ end
438
+ end
439
+ ```
440
+
441
+ ### Testing Status History
442
+
443
+ ```ruby
444
+ class OrderTest < ActiveSupport::TestCase
445
+ test "#confirm! records status change with who and from/to" do
446
+ order = orders(:pending_order)
447
+ order.confirm!(by: users(:alice))
448
+
449
+ assert order.confirmed?
450
+ change = order.last_status_change
451
+ assert_equal "pending", change.from_status
452
+ assert_equal "confirmed", change.to_status
453
+ assert_equal users(:alice), change.changed_by
454
+ end
455
+ end
456
+ ```
457
+
458
+ ## Anti-Patterns to Avoid
459
+
460
+ 1. **Boolean for business state** - Use state records when you need who/when/why.
461
+ 2. **String status columns** - Prefer state records over `status: "closed"` columns for important states.
462
+ 3. **Missing uniqueness constraint** - Always add a unique index on the polymorphic columns to prevent duplicate state records.
463
+ 4. **Skipping guard clauses** - Always check current state before transitioning (`raise "Already closed" if closed?`).
464
+ 5. **Direct record creation** - Use the concern methods (`close!`, `publish!`) rather than creating state records directly.
465
+ 6. **Missing foreign keys** - Always add foreign key constraints on `changed_by`/`closed_by`/`published_by` columns.