anima-core 0.3.0 → 1.0.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 (269) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +219 -25
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +76 -28
  12. data/app/jobs/agent_request_job.rb +24 -0
  13. data/app/jobs/analytical_brain_job.rb +33 -0
  14. data/app/jobs/count_event_tokens_job.rb +1 -1
  15. data/app/models/concerns/event/broadcasting.rb +20 -2
  16. data/app/models/event.rb +1 -1
  17. data/app/models/goal.rb +91 -0
  18. data/app/models/session.rb +347 -22
  19. data/config/application.rb +2 -0
  20. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  21. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  22. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  23. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  24. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  25. data/db/migrate/20260315140843_create_goals.rb +16 -0
  26. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  27. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  28. data/lib/agent_loop.rb +65 -9
  29. data/lib/agents/definition.rb +116 -0
  30. data/lib/agents/registry.rb +106 -0
  31. data/lib/analytical_brain/runner.rb +276 -0
  32. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  33. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  34. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  35. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  36. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  37. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  38. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  39. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  40. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  41. data/lib/analytical_brain.rb +23 -0
  42. data/lib/anima/cli/mcp/secrets.rb +76 -0
  43. data/lib/anima/cli/mcp.rb +197 -0
  44. data/lib/anima/cli.rb +4 -0
  45. data/lib/anima/installer.rb +168 -0
  46. data/lib/anima/settings.rb +226 -0
  47. data/lib/anima/version.rb +1 -1
  48. data/lib/anima.rb +9 -0
  49. data/lib/credential_store.rb +103 -0
  50. data/lib/environment_probe.rb +232 -0
  51. data/lib/llm/client.rb +29 -10
  52. data/lib/mcp/client_manager.rb +86 -0
  53. data/lib/mcp/config.rb +213 -0
  54. data/lib/mcp/health_check.rb +77 -0
  55. data/lib/mcp/secrets.rb +73 -0
  56. data/lib/mcp/stdio_transport.rb +206 -0
  57. data/lib/providers/anthropic.rb +8 -7
  58. data/lib/shell_session.rb +11 -10
  59. data/lib/skills/definition.rb +97 -0
  60. data/lib/skills/registry.rb +105 -0
  61. data/lib/tools/edit.rb +3 -4
  62. data/lib/tools/mcp_tool.rb +114 -0
  63. data/lib/tools/read.rb +15 -16
  64. data/lib/tools/registry.rb +14 -12
  65. data/lib/tools/request_feature.rb +121 -0
  66. data/lib/tools/return_result.rb +81 -0
  67. data/lib/tools/spawn_specialist.rb +109 -0
  68. data/lib/tools/spawn_subagent.rb +111 -0
  69. data/lib/tools/subagent_prompts.rb +12 -0
  70. data/lib/tools/web_get.rb +8 -9
  71. data/lib/tui/app.rb +332 -43
  72. data/lib/tui/message_store.rb +20 -0
  73. data/lib/tui/screens/chat.rb +207 -20
  74. data/lib/workflows/definition.rb +97 -0
  75. data/lib/workflows/registry.rb +89 -0
  76. data/skills/activerecord/SKILL.md +255 -0
  77. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  78. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  79. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  80. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  81. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  82. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  83. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  84. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  85. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  86. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  87. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  88. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  89. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  90. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  91. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  92. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  93. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  94. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  95. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  96. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  97. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  98. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  99. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  100. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  101. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  102. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  103. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  104. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  105. data/skills/activerecord/references/associations.md +709 -0
  106. data/skills/activerecord/references/basics.md +622 -0
  107. data/skills/activerecord/references/callbacks.md +738 -0
  108. data/skills/activerecord/references/migrations.md +657 -0
  109. data/skills/activerecord/references/querying.md +655 -0
  110. data/skills/activerecord/references/validations.md +596 -0
  111. data/skills/dragonruby/SKILL.md +250 -0
  112. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  113. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  114. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  115. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  116. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  117. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  118. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  119. data/skills/dragonruby/examples/core/labels.rb +22 -0
  120. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  121. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  122. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  123. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  124. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  125. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  126. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  127. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  128. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  129. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  130. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  131. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  132. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  133. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  134. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  135. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  136. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  137. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  138. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  139. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  140. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  141. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  142. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  143. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  144. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  145. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  146. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  147. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  148. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  149. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  150. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  151. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  152. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  153. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  154. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  155. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  156. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  157. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  158. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  159. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  160. data/skills/dragonruby/references/audio.md +396 -0
  161. data/skills/dragonruby/references/core.md +385 -0
  162. data/skills/dragonruby/references/distribution.md +434 -0
  163. data/skills/dragonruby/references/entities.md +516 -0
  164. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  165. data/skills/dragonruby/references/game-logic/state.md +389 -0
  166. data/skills/dragonruby/references/input.md +414 -0
  167. data/skills/dragonruby/references/rendering/animation.md +467 -0
  168. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  169. data/skills/dragonruby/references/scenes.md +443 -0
  170. data/skills/draper-decorators/SKILL.md +344 -0
  171. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  172. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  173. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  174. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  175. data/skills/draper-decorators/references/patterns.md +507 -0
  176. data/skills/draper-decorators/references/testing.md +559 -0
  177. data/skills/gh-issue.md +182 -0
  178. data/skills/mcp-server/SKILL.md +177 -0
  179. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  180. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  181. data/skills/mcp-server/examples/http_client.rb +48 -0
  182. data/skills/mcp-server/examples/http_server.rb +97 -0
  183. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  184. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  185. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  186. data/skills/mcp-server/references/gotchas.md +183 -0
  187. data/skills/mcp-server/references/prompts.md +98 -0
  188. data/skills/mcp-server/references/resources.md +53 -0
  189. data/skills/mcp-server/references/server.md +140 -0
  190. data/skills/mcp-server/references/tools.md +146 -0
  191. data/skills/mcp-server/references/transport.md +104 -0
  192. data/skills/ratatui-ruby/SKILL.md +315 -0
  193. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  194. data/skills/ratatui-ruby/references/events.md +387 -0
  195. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  196. data/skills/ratatui-ruby/references/layout.md +423 -0
  197. data/skills/ratatui-ruby/references/styling.md +268 -0
  198. data/skills/ratatui-ruby/references/testing.md +433 -0
  199. data/skills/ratatui-ruby/references/widgets.md +532 -0
  200. data/skills/rspec/SKILL.md +340 -0
  201. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  202. data/skills/rspec/examples/core/configuration.rb +126 -0
  203. data/skills/rspec/examples/core/hooks.rb +126 -0
  204. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  205. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  206. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  207. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  208. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  209. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  210. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  211. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  212. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  213. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  214. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  215. data/skills/rspec/examples/matchers/change.rb +115 -0
  216. data/skills/rspec/examples/matchers/collections.rb +154 -0
  217. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  218. data/skills/rspec/examples/matchers/composing.rb +155 -0
  219. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  220. data/skills/rspec/examples/matchers/equality.rb +58 -0
  221. data/skills/rspec/examples/matchers/errors.rb +136 -0
  222. data/skills/rspec/examples/matchers/output.rb +103 -0
  223. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  224. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  225. data/skills/rspec/examples/matchers/types.rb +82 -0
  226. data/skills/rspec/examples/matchers/yield.rb +147 -0
  227. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  228. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  229. data/skills/rspec/examples/mocks/constants.rb +177 -0
  230. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  231. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  232. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  233. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  234. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  235. data/skills/rspec/examples/mocks/responses.rb +223 -0
  236. data/skills/rspec/examples/mocks/spies.rb +149 -0
  237. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  238. data/skills/rspec/examples/rails/channels.rb +250 -0
  239. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  240. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  241. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  242. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  243. data/skills/rspec/examples/rails/matchers.rb +374 -0
  244. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  245. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  246. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  247. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  248. data/skills/rspec/examples/rails/transactions.rb +254 -0
  249. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  250. data/skills/rspec/references/core.md +816 -0
  251. data/skills/rspec/references/factory_bot.md +641 -0
  252. data/skills/rspec/references/matchers.md +516 -0
  253. data/skills/rspec/references/mocks.md +381 -0
  254. data/skills/rspec/references/rails.md +528 -0
  255. data/templates/soul.md +40 -0
  256. data/workflows/commit.md +45 -0
  257. data/workflows/create_handoff.md +98 -0
  258. data/workflows/create_note.md +82 -0
  259. data/workflows/create_plan.md +457 -0
  260. data/workflows/decompose_ticket.md +109 -0
  261. data/workflows/feature.md +91 -0
  262. data/workflows/implement_plan.md +87 -0
  263. data/workflows/iterate_plan.md +247 -0
  264. data/workflows/research_codebase.md +210 -0
  265. data/workflows/resume_handoff.md +217 -0
  266. data/workflows/review_pr.md +320 -0
  267. data/workflows/thoughts_init.md +71 -0
  268. data/workflows/validate_plan.md +166 -0
  269. metadata +284 -1
@@ -0,0 +1,340 @@
1
+ # Transaction Callbacks Examples
2
+ # Demonstrates after_commit, after_rollback, and transaction gotchas
3
+
4
+ # =============================================================================
5
+ # The Critical Difference: after_save vs after_commit
6
+ # =============================================================================
7
+
8
+ class Order < ApplicationRecord
9
+ # WRONG - Race condition with background jobs
10
+ # The job may start before the transaction commits
11
+ # after_save :enqueue_processing_wrong
12
+
13
+ # CORRECT - Job runs after transaction commits
14
+ after_commit :enqueue_processing, on: :create
15
+
16
+ private
17
+
18
+ def enqueue_processing_wrong
19
+ # Sidekiq might query: "Couldn't find Order with 'id'=123"
20
+ # because the transaction hasn't committed yet
21
+ OrderProcessingJob.perform_later(id)
22
+ end
23
+
24
+ def enqueue_processing
25
+ # Transaction committed - record guaranteed to exist
26
+ OrderProcessingJob.perform_later(id)
27
+ end
28
+ end
29
+
30
+ # =============================================================================
31
+ # Transaction Callback Variants (Rails 7.1+)
32
+ # =============================================================================
33
+
34
+ class Article < ApplicationRecord
35
+ # Fires on any commit (create, update, or destroy)
36
+ after_commit :clear_cache
37
+
38
+ # Scoped to specific actions
39
+ after_create_commit :notify_followers
40
+ after_update_commit :sync_to_search
41
+ after_destroy_commit :cleanup_attachments
42
+
43
+ # Fires on create OR update (not destroy)
44
+ after_save_commit :reindex_content
45
+
46
+ # Equivalent to after_create_commit + after_update_commit
47
+ after_commit :update_analytics, on: [:create, :update]
48
+
49
+ # Rollback callback
50
+ after_rollback :log_failure
51
+
52
+ private
53
+
54
+ def clear_cache
55
+ Rails.cache.delete("article_#{id}")
56
+ Rails.cache.delete("articles_list")
57
+ end
58
+
59
+ def notify_followers
60
+ author.followers.find_each do |follower|
61
+ NotificationJob.perform_later(follower.id, "new_article", id)
62
+ end
63
+ end
64
+
65
+ def sync_to_search
66
+ SearchIndexJob.perform_later("update", self.class.name, id)
67
+ end
68
+
69
+ def cleanup_attachments
70
+ AttachmentCleanupJob.perform_later(attachment_keys)
71
+ end
72
+
73
+ def reindex_content
74
+ FullTextIndexJob.perform_later(id)
75
+ end
76
+
77
+ def update_analytics
78
+ AnalyticsJob.perform_later("article_saved", id: id)
79
+ end
80
+
81
+ def log_failure
82
+ Rails.logger.error("Article #{id || 'new'} save failed, transaction rolled back")
83
+ end
84
+ end
85
+
86
+ # =============================================================================
87
+ # Gotcha: Callback Deduplication
88
+ # =============================================================================
89
+
90
+ class Product < ApplicationRecord
91
+ # WRONG - Only the LAST one runs due to deduplication
92
+ after_commit :sync_inventory
93
+ after_commit :sync_inventory # This one "wins"
94
+
95
+ # Also deduplicated across variants!
96
+ after_commit :notify_warehouse
97
+ after_create_commit :notify_warehouse # These are considered duplicates
98
+ after_save_commit :notify_warehouse
99
+
100
+ # CORRECT - Use :on option
101
+ after_commit :sync_inventory, on: [:create, :update]
102
+ after_commit :notify_warehouse, on: :create
103
+
104
+ private
105
+
106
+ def sync_inventory
107
+ InventorySyncJob.perform_later(id)
108
+ end
109
+
110
+ def notify_warehouse
111
+ WarehouseNotificationJob.perform_later(id)
112
+ end
113
+ end
114
+
115
+ # =============================================================================
116
+ # Gotcha: Exception Handling in after_commit
117
+ # =============================================================================
118
+
119
+ class Payment < ApplicationRecord
120
+ after_commit :notify_external_service
121
+ after_commit :update_analytics
122
+ after_commit :send_receipt
123
+
124
+ private
125
+
126
+ def notify_external_service
127
+ # If this raises, update_analytics and send_receipt won't run!
128
+ ExternalPaymentService.notify(self)
129
+ rescue ExternalPaymentService::Error => e
130
+ # Handle gracefully - don't let it bubble up
131
+ Rails.logger.error("External notification failed: #{e.message}")
132
+ ErrorTracker.capture(e)
133
+ # Don't re-raise - let other callbacks run
134
+ end
135
+
136
+ def update_analytics
137
+ Analytics.track("payment_completed", amount:, user_id:)
138
+ rescue => e
139
+ Rails.logger.error("Analytics failed: #{e.message}")
140
+ end
141
+
142
+ def send_receipt
143
+ PaymentMailer.receipt(self).deliver_later
144
+ end
145
+ end
146
+
147
+ # =============================================================================
148
+ # Gotcha: previous_changes in after_commit
149
+ # =============================================================================
150
+
151
+ class User < ApplicationRecord
152
+ after_commit :log_changes
153
+
154
+ private
155
+
156
+ def log_changes
157
+ # WARNING: If the record was saved multiple times in one transaction,
158
+ # previous_changes only contains the LAST save's changes
159
+
160
+ # If transaction did: user.save, then user.save again
161
+ # previous_changes only shows changes from the second save
162
+
163
+ Rails.logger.info("User #{id} changed: #{previous_changes.inspect}")
164
+ end
165
+ end
166
+
167
+ # =============================================================================
168
+ # Gotcha: after_commit Also Fires on Destroy
169
+ # =============================================================================
170
+
171
+ class Subscription < ApplicationRecord
172
+ # Be careful! This fires on CREATE, UPDATE, AND DESTROY
173
+ after_commit :sync_to_billing
174
+
175
+ # If you only want create/update:
176
+ after_save_commit :sync_to_billing_safe
177
+
178
+ # Or be explicit:
179
+ after_commit :sync_to_billing_explicit, on: [:create, :update]
180
+
181
+ private
182
+
183
+ def sync_to_billing
184
+ # This will run on destroy too - might not be what you want!
185
+ BillingService.sync(self)
186
+ end
187
+
188
+ def sync_to_billing_safe
189
+ BillingService.sync(self)
190
+ end
191
+
192
+ def sync_to_billing_explicit
193
+ BillingService.sync(self)
194
+ end
195
+ end
196
+
197
+ # =============================================================================
198
+ # Transaction Callback Ordering (Rails 7.1+)
199
+ # =============================================================================
200
+
201
+ class Invoice < ApplicationRecord
202
+ # Rails 7.1+ default: runs in definition order
203
+ after_commit :step_one # Runs first
204
+ after_commit :step_two # Runs second
205
+ after_commit :step_three # Runs third
206
+
207
+ # Pre-7.1 behavior (if configured): reverse order
208
+ # config.active_record.run_after_transaction_callbacks_in_order_defined = false
209
+
210
+ private
211
+
212
+ def step_one
213
+ Rails.logger.info("Step 1: Generate PDF")
214
+ PdfGeneratorJob.perform_later(id)
215
+ end
216
+
217
+ def step_two
218
+ Rails.logger.info("Step 2: Send email")
219
+ InvoiceMailer.send_invoice(self).deliver_later
220
+ end
221
+
222
+ def step_three
223
+ Rails.logger.info("Step 3: Update dashboard")
224
+ DashboardRefreshJob.perform_later(user_id)
225
+ end
226
+ end
227
+
228
+ # =============================================================================
229
+ # Real-World Pattern: External System Sync
230
+ # =============================================================================
231
+
232
+ class Customer < ApplicationRecord
233
+ after_create_commit :create_in_crm
234
+ after_update_commit :update_in_crm
235
+ after_destroy_commit :delete_from_crm
236
+
237
+ private
238
+
239
+ def create_in_crm
240
+ CrmSyncJob.perform_later("create", id)
241
+ end
242
+
243
+ def update_in_crm
244
+ # Only sync if relevant fields changed
245
+ relevant_changes = previous_changes.keys & %w[name email phone company]
246
+ return if relevant_changes.empty?
247
+
248
+ CrmSyncJob.perform_later("update", id, changes: previous_changes.slice(*relevant_changes))
249
+ end
250
+
251
+ def delete_from_crm
252
+ # Can't use id lookup since record is gone
253
+ CrmSyncJob.perform_later("delete", crm_external_id)
254
+ end
255
+ end
256
+
257
+ # =============================================================================
258
+ # Real-World Pattern: Search Index Management
259
+ # =============================================================================
260
+
261
+ class Post < ApplicationRecord
262
+ after_save_commit :update_search_index
263
+ after_destroy_commit :remove_from_search_index
264
+
265
+ private
266
+
267
+ def update_search_index
268
+ SearchIndexJob.perform_later(
269
+ action: "index",
270
+ type: "post",
271
+ id:,
272
+ body: {
273
+ title:,
274
+ content:,
275
+ author_name: author.name,
276
+ published_at:,
277
+ tags: tags.pluck(:name)
278
+ }
279
+ )
280
+ end
281
+
282
+ def remove_from_search_index
283
+ SearchIndexJob.perform_later(
284
+ action: "delete",
285
+ type: "post",
286
+ id:
287
+ )
288
+ end
289
+ end
290
+
291
+ # =============================================================================
292
+ # Real-World Pattern: Cache Invalidation
293
+ # =============================================================================
294
+
295
+ class Category < ApplicationRecord
296
+ has_many :products
297
+
298
+ after_commit :invalidate_caches
299
+
300
+ private
301
+
302
+ def invalidate_caches
303
+ Rails.cache.delete("category_#{id}")
304
+ Rails.cache.delete("category_#{id}_products")
305
+ Rails.cache.delete("categories_tree")
306
+ Rails.cache.delete("navigation_menu")
307
+
308
+ # Invalidate parent category caches if nested
309
+ parent&.invalidate_caches if parent_id_previously_changed?
310
+ end
311
+ end
312
+
313
+ # =============================================================================
314
+ # Nested Transactions and Callbacks
315
+ # =============================================================================
316
+
317
+ class Transfer < ApplicationRecord
318
+ def self.create_with_ledger_entries!(from_account:, to_account:, amount:)
319
+ transaction do
320
+ transfer = create!(from_account:, to_account:, amount:)
321
+
322
+ # Nested transaction - callbacks still wait for outer commit
323
+ transaction(requires_new: true) do
324
+ LedgerEntry.create!(account: from_account, amount: -amount, transfer:)
325
+ LedgerEntry.create!(account: to_account, amount:, transfer:)
326
+ end
327
+
328
+ transfer
329
+ end
330
+ # after_commit callbacks run here, after outer transaction commits
331
+ end
332
+
333
+ after_commit :notify_accounts, on: :create
334
+
335
+ private
336
+
337
+ def notify_accounts
338
+ TransferNotificationJob.perform_later(id)
339
+ end
340
+ end
@@ -0,0 +1,337 @@
1
+ # ActiveRecord Migration Examples: Indexes and Constraints
2
+
3
+ # =============================================================================
4
+ # BASIC INDEXES
5
+ # =============================================================================
6
+
7
+ class AddBasicIndexes < ActiveRecord::Migration[7.2]
8
+ def change
9
+ # Single column index
10
+ add_index :users, :email
11
+
12
+ # Unique index
13
+ add_index :users, :username, unique: true
14
+
15
+ # Named index
16
+ add_index :users, :created_at, name: "idx_users_created"
17
+ end
18
+ end
19
+
20
+ # =============================================================================
21
+ # COMPOSITE INDEXES
22
+ # =============================================================================
23
+
24
+ # Column order matters! Most selective column first
25
+ class AddCompositeIndexes < ActiveRecord::Migration[7.2]
26
+ def change
27
+ # Good for: WHERE last_name = 'X' AND first_name = 'Y'
28
+ # Good for: WHERE last_name = 'X'
29
+ # NOT useful for: WHERE first_name = 'Y' alone
30
+ add_index :users, [:last_name, :first_name]
31
+
32
+ # Foreign key + status (common lookup pattern)
33
+ add_index :orders, [:user_id, :status]
34
+
35
+ # Polymorphic association (always add this!)
36
+ add_index :comments, [:commentable_type, :commentable_id]
37
+
38
+ # Unique composite
39
+ add_index :memberships, [:user_id, :organization_id], unique: true
40
+ end
41
+ end
42
+
43
+ # =============================================================================
44
+ # PARTIAL INDEXES (PostgreSQL, SQLite)
45
+ # =============================================================================
46
+
47
+ class AddPartialIndexes < ActiveRecord::Migration[7.2]
48
+ def change
49
+ # Index only active records (smaller, faster)
50
+ add_index :users, :email, where: "active = true", name: "idx_users_email_active"
51
+
52
+ # Index only non-null values
53
+ add_index :sessions, :user_id, where: "user_id IS NOT NULL"
54
+
55
+ # Index only pending orders
56
+ add_index :orders, :created_at, where: "status = 'pending'"
57
+
58
+ # Unique email only for non-deleted users (soft delete pattern)
59
+ add_index :users, :email, unique: true, where: "deleted_at IS NULL"
60
+
61
+ # Index only recent records
62
+ add_index :events, :created_at, where: "created_at > '2024-01-01'"
63
+ end
64
+ end
65
+
66
+ # =============================================================================
67
+ # EXPRESSION INDEXES (PostgreSQL)
68
+ # =============================================================================
69
+
70
+ class AddExpressionIndexes < ActiveRecord::Migration[7.2]
71
+ def change
72
+ # Case-insensitive email lookup
73
+ add_index :users, "lower(email)", unique: true, name: "idx_users_email_lower"
74
+
75
+ # Date extraction
76
+ add_index :events, "date(created_at)", name: "idx_events_created_date"
77
+
78
+ # JSON field indexing
79
+ add_index :users, "(preferences->>'theme')", name: "idx_users_theme"
80
+ end
81
+ end
82
+
83
+ # =============================================================================
84
+ # COVERING INDEXES (PostgreSQL)
85
+ # =============================================================================
86
+
87
+ class AddCoveringIndexes < ActiveRecord::Migration[7.2]
88
+ def change
89
+ # Include additional columns to avoid table lookups
90
+ # Useful when you always SELECT these columns with the WHERE clause
91
+ add_index :orders, :user_id, include: [:status, :total]
92
+
93
+ # SELECT status, total FROM orders WHERE user_id = ?
94
+ # Can be answered entirely from index!
95
+ end
96
+ end
97
+
98
+ # =============================================================================
99
+ # CONCURRENT INDEXES (PostgreSQL)
100
+ # =============================================================================
101
+
102
+ class AddConcurrentIndex < ActiveRecord::Migration[7.2]
103
+ disable_ddl_transaction! # REQUIRED for concurrent operations
104
+
105
+ def change
106
+ # Won't lock the table during creation
107
+ add_index :users, :email, algorithm: :concurrently
108
+
109
+ # Remove index concurrently
110
+ remove_index :users, :old_column, algorithm: :concurrently
111
+ end
112
+ end
113
+
114
+ # Adding concurrent index with safety check
115
+ class AddEmailIndexSafely < ActiveRecord::Migration[7.2]
116
+ disable_ddl_transaction!
117
+
118
+ def change
119
+ # Check if index exists before creating
120
+ unless index_exists?(:users, :email, name: "index_users_on_email")
121
+ add_index :users, :email, algorithm: :concurrently
122
+ end
123
+ end
124
+ end
125
+
126
+ # =============================================================================
127
+ # SPECIAL INDEX TYPES
128
+ # =============================================================================
129
+
130
+ class AddSpecialIndexes < ActiveRecord::Migration[7.2]
131
+ def change
132
+ # GIN index for JSONB (PostgreSQL)
133
+ add_index :products, :metadata, using: :gin
134
+
135
+ # GiST index for geometric/range types (PostgreSQL)
136
+ add_index :locations, :coordinates, using: :gist
137
+
138
+ # Full-text search index (PostgreSQL)
139
+ add_index :articles, "to_tsvector('english', title || ' ' || body)",
140
+ using: :gin, name: "idx_articles_fulltext"
141
+
142
+ # Trigram index for LIKE queries (PostgreSQL, requires pg_trgm)
143
+ add_index :products, :name, using: :gin, opclass: :gin_trgm_ops
144
+
145
+ # FULLTEXT index (MySQL)
146
+ # add_index :articles, [:title, :body], type: :fulltext
147
+ end
148
+ end
149
+
150
+ # =============================================================================
151
+ # INDEX WITH ORDERING
152
+ # =============================================================================
153
+
154
+ class AddOrderedIndexes < ActiveRecord::Migration[7.2]
155
+ def change
156
+ # Descending order (useful for ORDER BY col DESC queries)
157
+ add_index :events, :created_at, order: :desc
158
+
159
+ # Mixed ordering
160
+ add_index :leaderboards, [:game_id, :score], order: { game_id: :asc, score: :desc }
161
+
162
+ # NULLS positioning (PostgreSQL)
163
+ add_index :tasks, :due_date, order: { due_date: "ASC NULLS LAST" }
164
+ end
165
+ end
166
+
167
+ # =============================================================================
168
+ # FOREIGN KEY CONSTRAINTS
169
+ # =============================================================================
170
+
171
+ class AddForeignKeys < ActiveRecord::Migration[7.2]
172
+ def change
173
+ # Basic foreign key
174
+ add_foreign_key :orders, :users
175
+
176
+ # With custom column name
177
+ add_foreign_key :orders, :users, column: :customer_id
178
+
179
+ # With ON DELETE behavior
180
+ add_foreign_key :comments, :posts, on_delete: :cascade
181
+
182
+ # With ON UPDATE behavior
183
+ add_foreign_key :order_items, :products, on_update: :cascade
184
+
185
+ # Referencing non-id primary key
186
+ add_foreign_key :profiles, :users, primary_key: :uuid, column: :user_uuid
187
+
188
+ # Self-referential
189
+ add_foreign_key :employees, :employees, column: :manager_id
190
+ end
191
+ end
192
+
193
+ # Foreign key with custom name
194
+ class AddNamedForeignKey < ActiveRecord::Migration[7.2]
195
+ def change
196
+ add_foreign_key :orders, :users,
197
+ column: :placed_by_id,
198
+ name: "fk_orders_placed_by_user"
199
+ end
200
+ end
201
+
202
+ # =============================================================================
203
+ # CHECK CONSTRAINTS
204
+ # =============================================================================
205
+
206
+ class AddCheckConstraints < ActiveRecord::Migration[7.2]
207
+ def change
208
+ # Positive price
209
+ add_check_constraint :products, "price > 0", name: "products_price_positive"
210
+
211
+ # Valid quantity
212
+ add_check_constraint :order_items, "quantity >= 1", name: "order_items_quantity_min"
213
+
214
+ # Status validation
215
+ add_check_constraint :orders, "status IN ('pending', 'processing', 'shipped', 'delivered')",
216
+ name: "orders_valid_status"
217
+
218
+ # Date range
219
+ add_check_constraint :events, "end_date >= start_date", name: "events_valid_dates"
220
+
221
+ # Email format (basic)
222
+ add_check_constraint :users, "email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$'",
223
+ name: "users_valid_email"
224
+
225
+ # Percentage range
226
+ add_check_constraint :discounts, "percentage BETWEEN 0 AND 100",
227
+ name: "discounts_valid_percentage"
228
+ end
229
+ end
230
+
231
+ # PostgreSQL: Add constraint without validation (for large tables)
232
+ class AddConstraintSafely < ActiveRecord::Migration[7.2]
233
+ def change
234
+ # Step 1: Add without validation
235
+ add_check_constraint :products, "price > 0",
236
+ name: "products_price_positive",
237
+ validate: false
238
+ end
239
+ end
240
+
241
+ class ValidateConstraint < ActiveRecord::Migration[7.2]
242
+ def change
243
+ # Step 2: Validate in separate migration/deployment
244
+ validate_check_constraint :products, name: "products_price_positive"
245
+ end
246
+ end
247
+
248
+ # =============================================================================
249
+ # UNIQUE CONSTRAINTS
250
+ # =============================================================================
251
+
252
+ class AddUniqueConstraints < ActiveRecord::Migration[7.2]
253
+ def change
254
+ # Simple unique
255
+ add_index :users, :email, unique: true
256
+
257
+ # Composite unique
258
+ add_index :subscriptions, [:user_id, :plan_id], unique: true
259
+
260
+ # Unique with condition (soft delete)
261
+ add_index :users, :email, unique: true, where: "deleted_at IS NULL"
262
+
263
+ # Case-insensitive unique (PostgreSQL)
264
+ add_index :users, "lower(email)", unique: true, name: "idx_users_email_unique_lower"
265
+ end
266
+ end
267
+
268
+ # =============================================================================
269
+ # REMOVING INDEXES AND CONSTRAINTS
270
+ # =============================================================================
271
+
272
+ class RemoveIndexesAndConstraints < ActiveRecord::Migration[7.2]
273
+ def change
274
+ # Remove index by column (must specify column for reversibility)
275
+ remove_index :users, :email
276
+
277
+ # Remove index by name
278
+ remove_index :users, name: "idx_users_email_lower"
279
+
280
+ # Remove composite index
281
+ remove_index :orders, [:user_id, :status]
282
+
283
+ # Remove foreign key
284
+ remove_foreign_key :orders, :users
285
+
286
+ # Remove foreign key by column
287
+ remove_foreign_key :orders, column: :customer_id, to_table: :users
288
+
289
+ # Remove check constraint
290
+ remove_check_constraint :products, name: "products_price_positive"
291
+ end
292
+ end
293
+
294
+ # =============================================================================
295
+ # PRACTICAL PATTERNS
296
+ # =============================================================================
297
+
298
+ # Complete reference setup with all constraints
299
+ class CreateOrdersWithConstraints < ActiveRecord::Migration[7.2]
300
+ def change
301
+ create_table :orders do |t|
302
+ t.references :user, null: false
303
+ t.references :shipping_address, null: false
304
+ t.string :status, null: false, default: "pending"
305
+ t.decimal :subtotal, precision: 10, scale: 2, null: false
306
+ t.decimal :tax, precision: 10, scale: 2, null: false
307
+ t.decimal :total, precision: 10, scale: 2, null: false
308
+ t.timestamps
309
+ end
310
+
311
+ # Foreign keys
312
+ add_foreign_key :orders, :users, on_delete: :restrict
313
+ add_foreign_key :orders, :addresses, column: :shipping_address_id
314
+
315
+ # Check constraints
316
+ add_check_constraint :orders, "subtotal >= 0", name: "orders_subtotal_non_negative"
317
+ add_check_constraint :orders, "tax >= 0", name: "orders_tax_non_negative"
318
+ add_check_constraint :orders, "total = subtotal + tax", name: "orders_total_calculation"
319
+ add_check_constraint :orders, "status IN ('pending', 'paid', 'shipped', 'delivered', 'cancelled')",
320
+ name: "orders_valid_status"
321
+
322
+ # Indexes for common queries
323
+ add_index :orders, [:user_id, :status]
324
+ add_index :orders, [:status, :created_at]
325
+ end
326
+ end
327
+
328
+ # Index strategy for polymorphic association
329
+ class SetupPolymorphicIndexes < ActiveRecord::Migration[7.2]
330
+ def change
331
+ # Always create composite index for polymorphic associations
332
+ add_index :taggings, [:taggable_type, :taggable_id]
333
+
334
+ # If you query by tag_id within a type
335
+ add_index :taggings, [:taggable_type, :tag_id]
336
+ end
337
+ end