source_monitor 0.13.1 → 0.14.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/skills/sm-configuration-setting/reference/settings-catalog.md +1 -0
- data/.claude/skills/sm-configure/SKILL.md +8 -1
- data/.claude/skills/sm-configure/reference/configuration-reference.md +11 -0
- data/.claude/skills/sm-host-setup/SKILL.md +13 -3
- data/.claude/skills/sm-host-setup/reference/initializer-template.md +11 -0
- data/.claude/skills/sm-host-setup/reference/setup-checklist.md +9 -1
- data/.claude/skills/sm-upgrade/reference/version-history.md +12 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/README.md +3 -3
- data/VERSION +1 -1
- data/app/controllers/source_monitor/application_controller.rb +73 -14
- data/app/views/layouts/source_monitor/application.html.erb +6 -0
- data/docs/configuration.md +18 -1
- data/docs/deployment.md +1 -1
- data/docs/goals/engine-hardening/.goalbuddy-board/app.js +543 -0
- data/docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png +0 -0
- data/docs/goals/engine-hardening/.goalbuddy-board/index.html +111 -0
- data/docs/goals/engine-hardening/.goalbuddy-board/styles.css +991 -0
- data/docs/goals/engine-hardening/goal.md +97 -0
- data/docs/goals/engine-hardening/notes/T001-spec-validation.md +37 -0
- data/docs/goals/engine-hardening/state.yaml +324 -0
- data/docs/setup.md +3 -3
- data/docs/upgrade.md +27 -0
- data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +10 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +5 -1
- data/lib/source_monitor/security/authentication.rb +10 -0
- data/lib/source_monitor/version.rb +1 -1
- data/source_monitor.gemspec +7 -2
- metadata +8 -65
- data/.claude/agent-memory/vbw-vbw-debugger/MEMORY.md +0 -15
- data/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +0 -34
- data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +0 -49
- data/.claude/agents/rails-concern.md +0 -464
- data/.claude/agents/rails-controller.md +0 -424
- data/.claude/agents/rails-hotwire.md +0 -446
- data/.claude/agents/rails-implement.md +0 -374
- data/.claude/agents/rails-job.md +0 -334
- data/.claude/agents/rails-lint.md +0 -294
- data/.claude/agents/rails-mailer.md +0 -371
- data/.claude/agents/rails-migration.md +0 -449
- data/.claude/agents/rails-model.md +0 -420
- data/.claude/agents/rails-policy.md +0 -443
- data/.claude/agents/rails-presenter.md +0 -427
- data/.claude/agents/rails-query.md +0 -412
- data/.claude/agents/rails-review.md +0 -490
- data/.claude/agents/rails-service.md +0 -458
- data/.claude/agents/rails-state-records.md +0 -465
- data/.claude/agents/rails-tdd.md +0 -314
- data/.claude/agents/rails-test.md +0 -441
- data/.claude/agents/rails-view-component.md +0 -418
- data/.claude/commands/rails-audit.md +0 -77
- data/.claude/commands/release.md +0 -366
- data/.claude/hooks/block-secrets.sh +0 -52
- data/.claude/settings.json +0 -85
- data/.claude/skills/action-cable-patterns/SKILL.md +0 -296
- data/.claude/skills/action-mailer-patterns/SKILL.md +0 -295
- data/.claude/skills/active-storage-setup/SKILL.md +0 -311
- data/.claude/skills/api-versioning/SKILL.md +0 -294
- data/.claude/skills/authentication-flow/SKILL.md +0 -335
- data/.claude/skills/authentication-flow/reference/current.md +0 -248
- data/.claude/skills/authentication-flow/reference/passwordless.md +0 -253
- data/.claude/skills/authentication-flow/reference/sessions.md +0 -201
- data/.claude/skills/authorization-pundit/SKILL.md +0 -462
- data/.claude/skills/caching-strategies/SKILL.md +0 -350
- data/.claude/skills/database-migrations/SKILL.md +0 -354
- data/.claude/skills/form-object-patterns/SKILL.md +0 -399
- data/.claude/skills/hotwire-patterns/SKILL.md +0 -247
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +0 -307
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +0 -112
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +0 -158
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +0 -218
- data/.claude/skills/i18n-patterns/SKILL.md +0 -320
- data/.claude/skills/install/SKILL.md +0 -367
- data/.claude/skills/performance-optimization/SKILL.md +0 -311
- data/.claude/skills/rails-architecture/SKILL.md +0 -259
- data/.claude/skills/rails-architecture/reference/error-handling.md +0 -333
- data/.claude/skills/rails-architecture/reference/event-tracking.md +0 -142
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +0 -417
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +0 -152
- data/.claude/skills/rails-architecture/reference/query-patterns.md +0 -342
- data/.claude/skills/rails-architecture/reference/service-patterns.md +0 -286
- data/.claude/skills/rails-architecture/reference/state-records.md +0 -250
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +0 -326
- data/.claude/skills/rails-concern/SKILL.md +0 -399
- data/.claude/skills/rails-controller/SKILL.md +0 -336
- data/.claude/skills/rails-model-generator/SKILL.md +0 -321
- data/.claude/skills/rails-model-generator/reference/validations.md +0 -298
- data/.claude/skills/rails-presenter/SKILL.md +0 -274
- data/.claude/skills/rails-query-object/SKILL.md +0 -289
- data/.claude/skills/rails-service-object/SKILL.md +0 -349
- data/.claude/skills/solid-queue-setup/SKILL.md +0 -307
- data/.claude/skills/tdd-cycle/SKILL.md +0 -359
- data/.claude/skills/viewcomponent-patterns/SKILL.md +0 -333
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: source_monitor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dchuk
|
|
@@ -258,67 +258,6 @@ executables: []
|
|
|
258
258
|
extensions: []
|
|
259
259
|
extra_rdoc_files: []
|
|
260
260
|
files:
|
|
261
|
-
- ".claude/agent-memory/vbw-vbw-debugger/MEMORY.md"
|
|
262
|
-
- ".claude/agent-memory/vbw-vbw-dev/MEMORY.md"
|
|
263
|
-
- ".claude/agent-memory/vbw-vbw-lead/MEMORY.md"
|
|
264
|
-
- ".claude/agents/rails-concern.md"
|
|
265
|
-
- ".claude/agents/rails-controller.md"
|
|
266
|
-
- ".claude/agents/rails-hotwire.md"
|
|
267
|
-
- ".claude/agents/rails-implement.md"
|
|
268
|
-
- ".claude/agents/rails-job.md"
|
|
269
|
-
- ".claude/agents/rails-lint.md"
|
|
270
|
-
- ".claude/agents/rails-mailer.md"
|
|
271
|
-
- ".claude/agents/rails-migration.md"
|
|
272
|
-
- ".claude/agents/rails-model.md"
|
|
273
|
-
- ".claude/agents/rails-policy.md"
|
|
274
|
-
- ".claude/agents/rails-presenter.md"
|
|
275
|
-
- ".claude/agents/rails-query.md"
|
|
276
|
-
- ".claude/agents/rails-review.md"
|
|
277
|
-
- ".claude/agents/rails-service.md"
|
|
278
|
-
- ".claude/agents/rails-state-records.md"
|
|
279
|
-
- ".claude/agents/rails-tdd.md"
|
|
280
|
-
- ".claude/agents/rails-test.md"
|
|
281
|
-
- ".claude/agents/rails-view-component.md"
|
|
282
|
-
- ".claude/commands/rails-audit.md"
|
|
283
|
-
- ".claude/commands/release.md"
|
|
284
|
-
- ".claude/hooks/block-secrets.sh"
|
|
285
|
-
- ".claude/settings.json"
|
|
286
|
-
- ".claude/skills/action-cable-patterns/SKILL.md"
|
|
287
|
-
- ".claude/skills/action-mailer-patterns/SKILL.md"
|
|
288
|
-
- ".claude/skills/active-storage-setup/SKILL.md"
|
|
289
|
-
- ".claude/skills/api-versioning/SKILL.md"
|
|
290
|
-
- ".claude/skills/authentication-flow/SKILL.md"
|
|
291
|
-
- ".claude/skills/authentication-flow/reference/current.md"
|
|
292
|
-
- ".claude/skills/authentication-flow/reference/passwordless.md"
|
|
293
|
-
- ".claude/skills/authentication-flow/reference/sessions.md"
|
|
294
|
-
- ".claude/skills/authorization-pundit/SKILL.md"
|
|
295
|
-
- ".claude/skills/caching-strategies/SKILL.md"
|
|
296
|
-
- ".claude/skills/database-migrations/SKILL.md"
|
|
297
|
-
- ".claude/skills/form-object-patterns/SKILL.md"
|
|
298
|
-
- ".claude/skills/hotwire-patterns/SKILL.md"
|
|
299
|
-
- ".claude/skills/hotwire-patterns/reference/stimulus.md"
|
|
300
|
-
- ".claude/skills/hotwire-patterns/reference/tailwind-integration.md"
|
|
301
|
-
- ".claude/skills/hotwire-patterns/reference/turbo-frames.md"
|
|
302
|
-
- ".claude/skills/hotwire-patterns/reference/turbo-streams.md"
|
|
303
|
-
- ".claude/skills/i18n-patterns/SKILL.md"
|
|
304
|
-
- ".claude/skills/install/SKILL.md"
|
|
305
|
-
- ".claude/skills/performance-optimization/SKILL.md"
|
|
306
|
-
- ".claude/skills/rails-architecture/SKILL.md"
|
|
307
|
-
- ".claude/skills/rails-architecture/reference/error-handling.md"
|
|
308
|
-
- ".claude/skills/rails-architecture/reference/event-tracking.md"
|
|
309
|
-
- ".claude/skills/rails-architecture/reference/layer-interactions.md"
|
|
310
|
-
- ".claude/skills/rails-architecture/reference/multi-tenancy.md"
|
|
311
|
-
- ".claude/skills/rails-architecture/reference/query-patterns.md"
|
|
312
|
-
- ".claude/skills/rails-architecture/reference/service-patterns.md"
|
|
313
|
-
- ".claude/skills/rails-architecture/reference/state-records.md"
|
|
314
|
-
- ".claude/skills/rails-architecture/reference/testing-strategy.md"
|
|
315
|
-
- ".claude/skills/rails-concern/SKILL.md"
|
|
316
|
-
- ".claude/skills/rails-controller/SKILL.md"
|
|
317
|
-
- ".claude/skills/rails-model-generator/SKILL.md"
|
|
318
|
-
- ".claude/skills/rails-model-generator/reference/validations.md"
|
|
319
|
-
- ".claude/skills/rails-presenter/SKILL.md"
|
|
320
|
-
- ".claude/skills/rails-query-object/SKILL.md"
|
|
321
|
-
- ".claude/skills/rails-service-object/SKILL.md"
|
|
322
261
|
- ".claude/skills/sm-architecture/SKILL.md"
|
|
323
262
|
- ".claude/skills/sm-architecture/reference/extraction-patterns.md"
|
|
324
263
|
- ".claude/skills/sm-architecture/reference/module-map.md"
|
|
@@ -358,9 +297,6 @@ files:
|
|
|
358
297
|
- ".claude/skills/sm-upgrade/SKILL.md"
|
|
359
298
|
- ".claude/skills/sm-upgrade/reference/upgrade-workflow.md"
|
|
360
299
|
- ".claude/skills/sm-upgrade/reference/version-history.md"
|
|
361
|
-
- ".claude/skills/solid-queue-setup/SKILL.md"
|
|
362
|
-
- ".claude/skills/tdd-cycle/SKILL.md"
|
|
363
|
-
- ".claude/skills/viewcomponent-patterns/SKILL.md"
|
|
364
300
|
- ".gitignore"
|
|
365
301
|
- ".rubocop.yml"
|
|
366
302
|
- ".ruby-version"
|
|
@@ -542,6 +478,13 @@ files:
|
|
|
542
478
|
- docs/configuration.md
|
|
543
479
|
- docs/deployment.md
|
|
544
480
|
- docs/gh-cli-workflow.md
|
|
481
|
+
- docs/goals/engine-hardening/.goalbuddy-board/app.js
|
|
482
|
+
- docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png
|
|
483
|
+
- docs/goals/engine-hardening/.goalbuddy-board/index.html
|
|
484
|
+
- docs/goals/engine-hardening/.goalbuddy-board/styles.css
|
|
485
|
+
- docs/goals/engine-hardening/goal.md
|
|
486
|
+
- docs/goals/engine-hardening/notes/T001-spec-validation.md
|
|
487
|
+
- docs/goals/engine-hardening/state.yaml
|
|
545
488
|
- docs/setup-validation-log.md
|
|
546
489
|
- docs/setup.md
|
|
547
490
|
- docs/troubleshooting.md
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# Debugger Memory
|
|
2
|
-
|
|
3
|
-
## Rails Association Cache Pollution Pattern
|
|
4
|
-
- `source.items.new` AND `Item.new(source: source)` both add to the loaded association cache via inverse_of
|
|
5
|
-
- Only `Item.new(source_id: source.id)` truly bypasses inverse_of and avoids cache pollution
|
|
6
|
-
- When unsaved/invalid records are in a loaded has_many cache, `parent.update!` triggers auto-save and fails with `RecordInvalid: Items is invalid`
|
|
7
|
-
- `update_columns` bypasses all callbacks and auto-save, safe to use with polluted caches
|
|
8
|
-
- After `update_columns`, call `reload` so the in-memory object reflects DB state
|
|
9
|
-
|
|
10
|
-
## Test Patterns
|
|
11
|
-
- Use `clean_source_monitor_tables!` in setup for blank-slate DB
|
|
12
|
-
- `create_source!` is the factory helper (in test_helper.rb)
|
|
13
|
-
- WebMock stubs + VCR cassettes for HTTP; `stub_request(:get, url)`
|
|
14
|
-
- Stub class methods with `singleton_class.define_method` pattern
|
|
15
|
-
- Always restore stubs in `ensure` block
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# VBW Dev Agent Memory
|
|
2
|
-
|
|
3
|
-
## Project: source_monitor
|
|
4
|
-
- Rails engine gem for RSS/feed monitoring
|
|
5
|
-
- Ruby 3.4.4, Rails 8.x
|
|
6
|
-
- Test suite: 473 tests via `bin/rails test` (takes ~76 seconds)
|
|
7
|
-
- RuboCop uses `rubocop-rails-omakase` base config
|
|
8
|
-
|
|
9
|
-
## Key Learnings
|
|
10
|
-
|
|
11
|
-
### Shell/Bash in zsh
|
|
12
|
-
- `!` in zsh inline scripts causes `command not found` errors. Use `case` statements instead of `if ! ...` patterns.
|
|
13
|
-
- Use `IFS= read -r` when reading filenames from pipes to handle edge cases.
|
|
14
|
-
|
|
15
|
-
### RuboCop scope vs git scope
|
|
16
|
-
- RuboCop inspects ALL Ruby-like files (Gemfile, Rakefile, .gemspec, .rake, bin/*, config.ru), not just `.rb` files.
|
|
17
|
-
- `git ls-files -- '*.rb'` only matches `.rb` extensions. Plans scoped to `.rb` files may miss RuboCop violations in non-`.rb` Ruby files.
|
|
18
|
-
- `test/tmp/` contains untracked generated Rails app templates. These are NOT git-tracked but RuboCop scans them unless excluded.
|
|
19
|
-
|
|
20
|
-
### File structure
|
|
21
|
-
- `test/lib/tmp/install_generator/` contains test fixtures (1 tracked file: `config/initializers/source_monitor.rb`)
|
|
22
|
-
- `test/tmp/host_app_template_*` directories are generated test artifacts, not git-tracked
|
|
23
|
-
- `.rubocop.yml` is at project root, inherits from `rubocop-rails-omakase`
|
|
24
|
-
|
|
25
|
-
### Frozen string literal pragma
|
|
26
|
-
- Completed in commit `5f02db8` -- 113 files modified
|
|
27
|
-
- Added `test/tmp/**/*` to RuboCop exclude list
|
|
28
|
-
|
|
29
|
-
### RuboCop omakase ruleset details
|
|
30
|
-
- Only 45 cops enabled (out of 775 available)
|
|
31
|
-
- ALL Metrics cops disabled (ClassLength, MethodLength, BlockLength, etc.)
|
|
32
|
-
- After frozen_string_literal fix, codebase had zero remaining violations
|
|
33
|
-
- No `.rubocop.yml` exclusions needed for large files since Metrics cops are off
|
|
34
|
-
- Plan 02 completed with no code changes required
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# Lead Agent Memory -- SourceMonitor
|
|
2
|
-
|
|
3
|
-
## Project Quick Facts
|
|
4
|
-
- Rails 8 engine, Ruby 3.4+, PostgreSQL-only
|
|
5
|
-
- 325 git-tracked Ruby files, 130 test files
|
|
6
|
-
- Coverage baseline: 2328 uncovered lines in config/coverage_baseline.json
|
|
7
|
-
- RuboCop: rubocop-rails-omakase, config at .rubocop.yml
|
|
8
|
-
- CI: .github/workflows/ci.yml (lint, security, test, release-verify, nightly profiling)
|
|
9
|
-
- Large files: FeedFetcher (627), Configuration (655), ImportSessionsController (792)
|
|
10
|
-
|
|
11
|
-
## Phase 1 Findings
|
|
12
|
-
- 98 git-tracked .rb files missing frozen_string_literal (out of 325)
|
|
13
|
-
- Breakdown: app/(5), lib/(24), test/(43 non-dummy), test/dummy/(22), config/(1), db/migrate/(4)
|
|
14
|
-
- test/tmp/ is NOT git-tracked (generated by install_generator tests) -- exclude from changes
|
|
15
|
-
- test/lib/tmp/install_generator/config/initializers/source_monitor.rb IS tracked and already has pragma
|
|
16
|
-
- bin/rubocop wrapper forces --config .rubocop.yml
|
|
17
|
-
- Phase 1 criterion #3 (10% coverage shrink) not addressed by plans 01/02 -- noted for future
|
|
18
|
-
|
|
19
|
-
## Phase 2 Planning Insights
|
|
20
|
-
- Coverage baseline.json has 2117 uncovered lines across 105 files (not 2328 -- that was line count)
|
|
21
|
-
- Top 7 files account for ~743 uncovered lines (35% of total)
|
|
22
|
-
- FeedFetcher: 245 uncovered, 12 existing tests, uses VCR+WebMock
|
|
23
|
-
- ItemCreator: 228 uncovered, 8 existing tests, needs mock entries for Atom/JSON branches
|
|
24
|
-
- Configuration: 94 uncovered, 5 existing tests, ~12 nested settings classes
|
|
25
|
-
- Dashboard::Queries: 66 uncovered, 7 existing tests, uses raw SQL + Cache
|
|
26
|
-
- BulkSourceScraper: 66 uncovered, 6 existing tests, ActiveJob test adapter
|
|
27
|
-
- Broadcaster: 48 uncovered, NO existing test file -- needs creation
|
|
28
|
-
- SourcesIndexMetrics: 34 uncovered, 3 existing tests
|
|
29
|
-
- All 5 Phase 2 plans are Wave 1 (no inter-plan dependencies, no file conflicts)
|
|
30
|
-
- Testing main files will indirectly cover supporting files (retry_policy, fetch_error, state, enqueuer, etc.)
|
|
31
|
-
- Broadcaster tests need Turbo::StreamsChannel stubs -- no full Action Cable required
|
|
32
|
-
- 50% coverage target requires ~1059 lines covered; direct plans target ~630, rest from indirect
|
|
33
|
-
|
|
34
|
-
## Phase 4 Planning Insights
|
|
35
|
-
- 3 plans: conventions-audit (wave 1), item-creator-extraction (wave 1), final-verification (wave 2)
|
|
36
|
-
- SourcesController has dead `fetch`/`retry` methods (leftover from Phase 3 CRUD extraction)
|
|
37
|
-
- ImportSessionsController `new` and `create` are byte-for-byte identical
|
|
38
|
-
- 4 RuboCop violations in db/migrate/20260210204022_add_composite_index_to_log_entries.rb
|
|
39
|
-
- Duplicate test: test/controllers/concerns/ vs test/controllers/source_monitor/concerns/
|
|
40
|
-
- ItemCreator at 601 lines is the last file over 300 lines -- extract to entry_parser + content_extractor
|
|
41
|
-
- Coverage baseline still at 2117 uncovered (not regenerated since Phase 1) -- must regenerate
|
|
42
|
-
- 60% reduction target: at most 847 uncovered lines
|
|
43
|
-
- dashboard/queries.rb (356) and application_helper.rb (346) are near 300 but acceptable as view/query code
|
|
44
|
-
- `bin/update-coverage-baseline` requires `COVERAGE=1 bin/rails test` first
|
|
45
|
-
|
|
46
|
-
## Bash/Shell Notes
|
|
47
|
-
- zsh on macOS -- `!` in shell conditionals causes `command not found` errors
|
|
48
|
-
- Use `case` statements instead of `if ! grep` patterns in zsh
|
|
49
|
-
- git ls-files piped to while loops works reliably for file enumeration
|
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: rails-concern
|
|
3
|
-
description: Model and controller concerns for horizontal code sharing across classes
|
|
4
|
-
tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# Rails Concern Agent
|
|
8
|
-
|
|
9
|
-
You are an expert at creating well-bounded ActiveSupport::Concern modules for horizontal code sharing in Rails models and controllers.
|
|
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
|
-
## When to Use Concerns
|
|
23
|
-
|
|
24
|
-
Concerns are for **horizontal sharing of behavior** across multiple classes that share a common trait.
|
|
25
|
-
|
|
26
|
-
### Good Use Cases
|
|
27
|
-
|
|
28
|
-
| Pattern | Example | Why |
|
|
29
|
-
|---------|---------|-----|
|
|
30
|
-
| Shared validations | `Contactable` (email + phone on User, Company) | Same validation logic, multiple models |
|
|
31
|
-
| Shared scopes | `Searchable` (search scope on multiple models) | Same query pattern, multiple models |
|
|
32
|
-
| Shared callbacks | `Trackable` (track who changed what) | Same auditing, multiple models |
|
|
33
|
-
| State-as-records | `Closeable` (open/closed state pattern) | Same state pattern, multiple models |
|
|
34
|
-
| Shared associations | `HasComments` (polymorphic comments) | Same association setup |
|
|
35
|
-
|
|
36
|
-
### Bad Use Cases (Do NOT Use Concerns For)
|
|
37
|
-
|
|
38
|
-
| Anti-pattern | Problem | Better Approach |
|
|
39
|
-
|-------------|---------|-----------------|
|
|
40
|
-
| Kitchen-sink concern | Unrelated methods lumped together | Split into focused concerns |
|
|
41
|
-
| Single-model concern | Only one model uses it | Keep in the model |
|
|
42
|
-
| Cross-cutting orchestration | Coordinates multiple unrelated models | Service object |
|
|
43
|
-
| Concern depends on concern | Tight coupling between concerns | Merge or restructure |
|
|
44
|
-
| "Utils" concern | Grab-bag of helper methods | Module or standalone class |
|
|
45
|
-
|
|
46
|
-
## Model Concern Patterns
|
|
47
|
-
|
|
48
|
-
### Pattern: Closeable (State-as-Record)
|
|
49
|
-
|
|
50
|
-
```ruby
|
|
51
|
-
# app/models/concerns/closeable.rb
|
|
52
|
-
module Closeable
|
|
53
|
-
extend ActiveSupport::Concern
|
|
54
|
-
|
|
55
|
-
included do
|
|
56
|
-
has_one :closure, as: :closeable, dependent: :destroy
|
|
57
|
-
|
|
58
|
-
scope :open, -> { where.missing(:closure) }
|
|
59
|
-
scope :closed, -> { joins(:closure) }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def closed?
|
|
63
|
-
closure.present?
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def open?
|
|
67
|
-
!closed?
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def close!(closed_by:, reason: nil)
|
|
71
|
-
create_closure!(closed_by: closed_by, reason: reason)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def reopen!
|
|
75
|
-
closure&.destroy!
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### Pattern: Searchable
|
|
81
|
-
|
|
82
|
-
```ruby
|
|
83
|
-
# app/models/concerns/searchable.rb
|
|
84
|
-
module Searchable
|
|
85
|
-
extend ActiveSupport::Concern
|
|
86
|
-
|
|
87
|
-
included do
|
|
88
|
-
scope :search, ->(query) {
|
|
89
|
-
return all if query.blank?
|
|
90
|
-
columns = searchable_columns.map { |col| arel_table[col] }
|
|
91
|
-
conditions = columns.map { |col| col.matches("%#{sanitize_sql_like(query)}%") }
|
|
92
|
-
where(conditions.reduce(:or))
|
|
93
|
-
}
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
class_methods do
|
|
97
|
-
def searchable_columns
|
|
98
|
-
raise NotImplementedError, "#{name} must define .searchable_columns"
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Usage:
|
|
104
|
-
class Project < ApplicationRecord
|
|
105
|
-
include Searchable
|
|
106
|
-
|
|
107
|
-
def self.searchable_columns
|
|
108
|
-
%i[name description]
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
class User < ApplicationRecord
|
|
113
|
-
include Searchable
|
|
114
|
-
|
|
115
|
-
def self.searchable_columns
|
|
116
|
-
%i[name email]
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
### Pattern: Trackable (Audit Trail)
|
|
122
|
-
|
|
123
|
-
```ruby
|
|
124
|
-
# app/models/concerns/trackable.rb
|
|
125
|
-
module Trackable
|
|
126
|
-
extend ActiveSupport::Concern
|
|
127
|
-
|
|
128
|
-
included do
|
|
129
|
-
belongs_to :created_by, class_name: "User", optional: true
|
|
130
|
-
belongs_to :updated_by, class_name: "User", optional: true
|
|
131
|
-
|
|
132
|
-
before_create :set_created_by
|
|
133
|
-
before_update :set_updated_by
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
private
|
|
137
|
-
|
|
138
|
-
def set_created_by
|
|
139
|
-
self.created_by ||= Current.user
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def set_updated_by
|
|
143
|
-
self.updated_by = Current.user if Current.user
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
### Pattern: HasUuid
|
|
149
|
-
|
|
150
|
-
```ruby
|
|
151
|
-
# app/models/concerns/has_uuid.rb
|
|
152
|
-
module HasUuid
|
|
153
|
-
extend ActiveSupport::Concern
|
|
154
|
-
|
|
155
|
-
included do
|
|
156
|
-
before_create :generate_uuid
|
|
157
|
-
|
|
158
|
-
validates :uuid, uniqueness: true, allow_nil: true
|
|
159
|
-
|
|
160
|
-
scope :find_by_uuid!, ->(uuid) { find_by!(uuid: uuid) }
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
private
|
|
164
|
-
|
|
165
|
-
def generate_uuid
|
|
166
|
-
self.uuid ||= SecureRandom.uuid
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
### Pattern: Contactable
|
|
172
|
-
|
|
173
|
-
```ruby
|
|
174
|
-
# app/models/concerns/contactable.rb
|
|
175
|
-
module Contactable
|
|
176
|
-
extend ActiveSupport::Concern
|
|
177
|
-
|
|
178
|
-
included do
|
|
179
|
-
validates :email, presence: true,
|
|
180
|
-
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
181
|
-
validates :phone, format: { with: /\A\+?[\d\s\-()]+\z/ },
|
|
182
|
-
allow_blank: true
|
|
183
|
-
|
|
184
|
-
before_validation :normalize_email
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def has_phone?
|
|
188
|
-
phone.present?
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
private
|
|
192
|
-
|
|
193
|
-
def normalize_email
|
|
194
|
-
self.email = email&.downcase&.strip
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
## Controller Concern Patterns
|
|
200
|
-
|
|
201
|
-
### Pattern: SetCurrentAccount
|
|
202
|
-
|
|
203
|
-
```ruby
|
|
204
|
-
# app/controllers/concerns/set_current_account.rb
|
|
205
|
-
module SetCurrentAccount
|
|
206
|
-
extend ActiveSupport::Concern
|
|
207
|
-
|
|
208
|
-
included do
|
|
209
|
-
before_action :set_current_account
|
|
210
|
-
helper_method :current_account
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
private
|
|
214
|
-
|
|
215
|
-
def current_account
|
|
216
|
-
Current.account
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def set_current_account
|
|
220
|
-
Current.account = current_user&.account
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### Pattern: Authentication
|
|
226
|
-
|
|
227
|
-
```ruby
|
|
228
|
-
# app/controllers/concerns/authentication.rb
|
|
229
|
-
module Authentication
|
|
230
|
-
extend ActiveSupport::Concern
|
|
231
|
-
|
|
232
|
-
included do
|
|
233
|
-
before_action :require_authentication
|
|
234
|
-
helper_method :current_user, :signed_in?
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
private
|
|
238
|
-
|
|
239
|
-
def current_user
|
|
240
|
-
Current.user
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def signed_in?
|
|
244
|
-
current_user.present?
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def require_authentication
|
|
248
|
-
resume_session || request_authentication
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def resume_session
|
|
252
|
-
Current.session = find_session_by_cookie
|
|
253
|
-
Current.user = Current.session&.user
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def find_session_by_cookie
|
|
257
|
-
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def request_authentication
|
|
261
|
-
redirect_to new_session_path, alert: "Please sign in"
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
### Pattern: Paginatable
|
|
267
|
-
|
|
268
|
-
```ruby
|
|
269
|
-
# app/controllers/concerns/paginatable.rb
|
|
270
|
-
module Paginatable
|
|
271
|
-
extend ActiveSupport::Concern
|
|
272
|
-
|
|
273
|
-
private
|
|
274
|
-
|
|
275
|
-
def page
|
|
276
|
-
[params[:page].to_i, 1].max
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
def per_page
|
|
280
|
-
[(params[:per_page] || 25).to_i, 100].min
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def paginate(scope)
|
|
284
|
-
scope.offset((page - 1) * per_page).limit(per_page)
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
## Concern Design Rules
|
|
290
|
-
|
|
291
|
-
### 1. Single Responsibility
|
|
292
|
-
|
|
293
|
-
Each concern should represent one clear behavior or trait.
|
|
294
|
-
|
|
295
|
-
```ruby
|
|
296
|
-
# GOOD: One behavior
|
|
297
|
-
module Closeable # Manages open/closed state
|
|
298
|
-
module Searchable # Adds search capability
|
|
299
|
-
module Contactable # Validates contact info
|
|
300
|
-
|
|
301
|
-
# BAD: Multiple unrelated behaviors
|
|
302
|
-
module ModelHelpers # Kitchen sink of unrelated methods
|
|
303
|
-
module Utilities # Grab-bag
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
### 2. Self-Contained
|
|
307
|
-
|
|
308
|
-
A concern should work independently. Never depend on other concerns being included.
|
|
309
|
-
|
|
310
|
-
### 3. Explicit Contract
|
|
311
|
-
|
|
312
|
-
If a concern requires the including class to implement something, use `raise NotImplementedError` in a class method.
|
|
313
|
-
|
|
314
|
-
### 4. Polymorphic Associations for State Records
|
|
315
|
-
|
|
316
|
-
State concerns should use polymorphic `as:` so one closure/publication table serves many models.
|
|
317
|
-
|
|
318
|
-
## Concern Boundaries vs Service Objects
|
|
319
|
-
|
|
320
|
-
| Concern | Service Object |
|
|
321
|
-
|---------|---------------|
|
|
322
|
-
| Adds behavior to a single model | Coordinates multiple models |
|
|
323
|
-
| Shared trait (closeable, searchable) | Business process (onboarding, billing) |
|
|
324
|
-
| No external dependencies | May call APIs, send emails |
|
|
325
|
-
| Stateless (operates on `self`) | Stateful (takes arguments, returns result) |
|
|
326
|
-
|
|
327
|
-
### Decision Example
|
|
328
|
-
|
|
329
|
-
"Users and Companies both need to be archivable"
|
|
330
|
-
- **Use a concern**: `Archivable` adds `archive!`, `archived?`, scopes
|
|
331
|
-
- The behavior is a shared trait of the models
|
|
332
|
-
|
|
333
|
-
"When archiving a user, also archive their projects and notify the team"
|
|
334
|
-
- **Use a service**: `Users::ArchiveService` orchestrates the process
|
|
335
|
-
- Multiple models are involved in a business process
|
|
336
|
-
|
|
337
|
-
## Testing Concerns with Minitest
|
|
338
|
-
|
|
339
|
-
### Testing via the Including Model
|
|
340
|
-
|
|
341
|
-
The simplest and most practical approach:
|
|
342
|
-
|
|
343
|
-
```ruby
|
|
344
|
-
# test/models/project_test.rb
|
|
345
|
-
require "test_helper"
|
|
346
|
-
|
|
347
|
-
class ProjectTest < ActiveSupport::TestCase
|
|
348
|
-
# Test Closeable concern through Project
|
|
349
|
-
test "can be closed" do
|
|
350
|
-
project = projects(:website_redesign)
|
|
351
|
-
project.close!(closed_by: users(:alice), reason: "Completed")
|
|
352
|
-
assert project.closed?
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
test "can be reopened" do
|
|
356
|
-
project = projects(:website_redesign)
|
|
357
|
-
project.close!(closed_by: users(:alice))
|
|
358
|
-
project.reopen!
|
|
359
|
-
assert project.open?
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
test ".open scope excludes closed" do
|
|
363
|
-
project = projects(:website_redesign)
|
|
364
|
-
project.close!(closed_by: users(:alice))
|
|
365
|
-
assert_not_includes Project.open, project
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
# Test Searchable concern through Project
|
|
369
|
-
test ".search finds by name" do
|
|
370
|
-
results = Project.search("Redesign")
|
|
371
|
-
assert_includes results, projects(:website_redesign)
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
test ".search returns all when blank" do
|
|
375
|
-
assert_equal Project.count, Project.search("").count
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
### Testing Concerns in Isolation
|
|
381
|
-
|
|
382
|
-
For concerns shared across many models, test once with a fake model:
|
|
383
|
-
|
|
384
|
-
```ruby
|
|
385
|
-
# test/models/concerns/closeable_test.rb
|
|
386
|
-
require "test_helper"
|
|
387
|
-
|
|
388
|
-
class CloseableTest < ActiveSupport::TestCase
|
|
389
|
-
# Test through a real model that includes the concern
|
|
390
|
-
setup do
|
|
391
|
-
@project = projects(:website_redesign)
|
|
392
|
-
@user = users(:alice)
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
test "#close! creates a closure record" do
|
|
396
|
-
assert_difference -> { Closure.count }, 1 do
|
|
397
|
-
@project.close!(closed_by: @user, reason: "Done")
|
|
398
|
-
end
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
test "#closed? returns true after closing" do
|
|
402
|
-
@project.close!(closed_by: @user)
|
|
403
|
-
assert @project.closed?
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
test "#open? is inverse of closed?" do
|
|
407
|
-
assert @project.open?
|
|
408
|
-
@project.close!(closed_by: @user)
|
|
409
|
-
assert_not @project.open?
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
test "#reopen! destroys closure record" do
|
|
413
|
-
@project.close!(closed_by: @user)
|
|
414
|
-
@project.reopen!
|
|
415
|
-
assert @project.open?
|
|
416
|
-
assert_nil @project.reload.closure
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
test ".open scope returns unclosed records" do
|
|
420
|
-
open_project = projects(:website_redesign)
|
|
421
|
-
closed_project = projects(:archived_project)
|
|
422
|
-
# archived_project has a closure fixture
|
|
423
|
-
|
|
424
|
-
results = Project.open
|
|
425
|
-
assert_includes results, open_project
|
|
426
|
-
assert_not_includes results, closed_project
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
test ".closed scope returns closed records" do
|
|
430
|
-
@project.close!(closed_by: @user)
|
|
431
|
-
assert_includes Project.closed, @project
|
|
432
|
-
end
|
|
433
|
-
end
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
## File Organization
|
|
437
|
-
|
|
438
|
-
```
|
|
439
|
-
app/
|
|
440
|
-
models/
|
|
441
|
-
concerns/
|
|
442
|
-
closeable.rb # State: open/closed
|
|
443
|
-
publishable.rb # State: draft/published
|
|
444
|
-
searchable.rb # Search capability
|
|
445
|
-
trackable.rb # Audit trail (created_by, updated_by)
|
|
446
|
-
has_uuid.rb # UUID generation
|
|
447
|
-
contactable.rb # Email/phone validation
|
|
448
|
-
sortable.rb # Position ordering
|
|
449
|
-
controllers/
|
|
450
|
-
concerns/
|
|
451
|
-
authentication.rb # Session management
|
|
452
|
-
set_current_account.rb # Account scoping
|
|
453
|
-
paginatable.rb # Pagination helpers
|
|
454
|
-
error_handling.rb # Rescue handlers
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
## Anti-Patterns to Avoid
|
|
458
|
-
|
|
459
|
-
1. **Kitchen-sink concerns** - One concern doing too many unrelated things. Split into focused concerns.
|
|
460
|
-
2. **Concern dependencies** - Concern A requiring Concern B to be included. Each concern should be self-contained.
|
|
461
|
-
3. **Single-use concerns** - If only one model uses it, keep it in the model.
|
|
462
|
-
4. **Logic concerns** - If the concern orchestrates multiple models, it should be a service object.
|
|
463
|
-
5. **Overriding concern methods** - If you need to override a concern method in the including class, the concern boundary is wrong.
|
|
464
|
-
6. **Deeply nested concerns** - Concern including another concern. Keep the hierarchy flat.
|