source_monitor 0.3.0 → 0.3.2

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-architecture/SKILL.md +233 -0
  3. data/.claude/skills/sm-architecture/reference/extraction-patterns.md +192 -0
  4. data/.claude/skills/sm-architecture/reference/module-map.md +194 -0
  5. data/.claude/skills/sm-configuration-setting/SKILL.md +264 -0
  6. data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +248 -0
  7. data/.claude/skills/sm-configuration-setting/reference/settings-pattern.md +297 -0
  8. data/.claude/skills/sm-configure/SKILL.md +153 -0
  9. data/.claude/skills/sm-configure/reference/configuration-reference.md +321 -0
  10. data/.claude/skills/sm-dashboard-widget/SKILL.md +344 -0
  11. data/.claude/skills/sm-dashboard-widget/reference/dashboard-patterns.md +304 -0
  12. data/.claude/skills/sm-domain-model/SKILL.md +188 -0
  13. data/.claude/skills/sm-domain-model/reference/model-graph.md +114 -0
  14. data/.claude/skills/sm-domain-model/reference/table-structure.md +348 -0
  15. data/.claude/skills/sm-engine-migration/SKILL.md +395 -0
  16. data/.claude/skills/sm-engine-migration/reference/migration-conventions.md +255 -0
  17. data/.claude/skills/sm-engine-test/SKILL.md +302 -0
  18. data/.claude/skills/sm-engine-test/reference/test-helpers.md +259 -0
  19. data/.claude/skills/sm-engine-test/reference/test-patterns.md +411 -0
  20. data/.claude/skills/sm-event-handler/SKILL.md +265 -0
  21. data/.claude/skills/sm-event-handler/reference/events-api.md +229 -0
  22. data/.claude/skills/sm-health-rule/SKILL.md +327 -0
  23. data/.claude/skills/sm-health-rule/reference/health-system.md +269 -0
  24. data/.claude/skills/sm-host-setup/SKILL.md +223 -0
  25. data/.claude/skills/sm-host-setup/reference/initializer-template.md +195 -0
  26. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +134 -0
  27. data/.claude/skills/sm-job/SKILL.md +263 -0
  28. data/.claude/skills/sm-job/reference/job-conventions.md +245 -0
  29. data/.claude/skills/sm-model-extension/SKILL.md +287 -0
  30. data/.claude/skills/sm-model-extension/reference/extension-api.md +317 -0
  31. data/.claude/skills/sm-pipeline-stage/SKILL.md +254 -0
  32. data/.claude/skills/sm-pipeline-stage/reference/completion-handlers.md +152 -0
  33. data/.claude/skills/sm-pipeline-stage/reference/entry-processing.md +191 -0
  34. data/.claude/skills/sm-pipeline-stage/reference/feed-fetcher-architecture.md +198 -0
  35. data/.claude/skills/sm-scraper-adapter/SKILL.md +284 -0
  36. data/.claude/skills/sm-scraper-adapter/reference/adapter-contract.md +167 -0
  37. data/.claude/skills/sm-scraper-adapter/reference/example-adapter.md +274 -0
  38. data/.vbw-planning/.notification-log.jsonl +102 -0
  39. data/.vbw-planning/.session-log.jsonl +505 -0
  40. data/AGENTS.md +20 -57
  41. data/CHANGELOG.md +19 -0
  42. data/CLAUDE.md +44 -1
  43. data/CONTRIBUTING.md +5 -5
  44. data/Gemfile.lock +20 -21
  45. data/README.md +18 -5
  46. data/VERSION +1 -0
  47. data/docs/deployment.md +1 -1
  48. data/docs/setup.md +4 -4
  49. data/lib/source_monitor/setup/skills_installer.rb +94 -0
  50. data/lib/source_monitor/setup/workflow.rb +17 -2
  51. data/lib/source_monitor/version.rb +1 -1
  52. data/lib/tasks/source_monitor_setup.rake +58 -0
  53. data/source_monitor.gemspec +1 -0
  54. metadata +39 -1
@@ -0,0 +1,304 @@
1
+ # Dashboard Patterns Reference
2
+
3
+ ## Query Patterns
4
+
5
+ ### Single-Value Aggregation (StatsQuery pattern)
6
+ Use `connection.select_value` for scalar results:
7
+
8
+ ```ruby
9
+ def total_items_count
10
+ SourceMonitor::Item.connection.select_value(
11
+ "SELECT COUNT(*) FROM #{SourceMonitor::Item.quoted_table_name}"
12
+ ).to_i
13
+ end
14
+ ```
15
+
16
+ ### Multi-Column Aggregation
17
+ Use `connection.exec_query` with conditional aggregation:
18
+
19
+ ```ruby
20
+ def source_counts
21
+ @source_counts ||= begin
22
+ SourceMonitor::Source.connection.exec_query(<<~SQL.squish).first || {}
23
+ SELECT
24
+ COUNT(*) AS total_sources,
25
+ SUM(CASE WHEN active THEN 1 ELSE 0 END) AS active_sources,
26
+ SUM(CASE WHEN (failure_count > 0) THEN 1 ELSE 0 END) AS failed_sources
27
+ FROM #{SourceMonitor::Source.quoted_table_name}
28
+ SQL
29
+ end
30
+ end
31
+ ```
32
+
33
+ ### UNION ALL Cross-Table Query (RecentActivityQuery pattern)
34
+ Combine events from multiple tables into a unified feed:
35
+
36
+ ```ruby
37
+ def unified_sql_template
38
+ <<~SQL
39
+ SELECT resource_type, resource_id, occurred_at, ...
40
+ FROM (
41
+ #{fetch_log_sql}
42
+ UNION ALL
43
+ #{scrape_log_sql}
44
+ UNION ALL
45
+ #{item_sql}
46
+ ) AS dashboard_events
47
+ WHERE occurred_at IS NOT NULL
48
+ ORDER BY occurred_at DESC
49
+ LIMIT ?
50
+ SQL
51
+ end
52
+ ```
53
+
54
+ Each sub-query must:
55
+ - Use the same column names/count (pad with `NULL AS column_name`)
56
+ - Use `quoted_table_name` for all table references
57
+ - Use string constants for type discriminators: `'fetch_log' AS resource_type`
58
+
59
+ Sanitize with:
60
+ ```ruby
61
+ ActiveRecord::Base.send(:sanitize_sql_array, [sql_template, limit])
62
+ ```
63
+
64
+ ### Time-Window Grouping (UpcomingFetchSchedule pattern)
65
+ Group records into defined time buckets relative to a reference time:
66
+
67
+ ```ruby
68
+ INTERVAL_DEFINITIONS = [
69
+ { key: "0-30", label: "Within 30 minutes", min_minutes: 0, max_minutes: 30 },
70
+ { key: "30-60", label: "30-60 minutes", min_minutes: 30, max_minutes: 60 },
71
+ # ...
72
+ ].freeze
73
+
74
+ def definition_for(next_fetch_at)
75
+ minutes = (next_fetch_at - reference_time) / 60.0
76
+ INTERVAL_DEFINITIONS.find do |d|
77
+ minutes >= d[:min_minutes] && (d[:max_minutes].nil? || minutes < d[:max_minutes])
78
+ end
79
+ end
80
+ ```
81
+
82
+ ### ActiveRecord Scope Query
83
+ For simpler queries, use ActiveRecord directly:
84
+
85
+ ```ruby
86
+ def fetches_today_count
87
+ SourceMonitor::FetchLog.where("started_at >= ?", start_of_day).count
88
+ end
89
+ ```
90
+
91
+ ## Presenter Patterns
92
+
93
+ ### Collection Presenter
94
+ Transforms an array of domain objects into view-model hashes:
95
+
96
+ ```ruby
97
+ class RecentActivityPresenter
98
+ def initialize(events, url_helpers:)
99
+ @events = events
100
+ @url_helpers = url_helpers
101
+ end
102
+
103
+ def to_a
104
+ events.map { |event| build_view_model(event) }
105
+ end
106
+
107
+ private
108
+
109
+ attr_reader :events, :url_helpers
110
+
111
+ def build_view_model(event)
112
+ case event.type
113
+ when :fetch_log then fetch_event(event)
114
+ when :item then item_event(event)
115
+ else fallback_event(event)
116
+ end
117
+ end
118
+
119
+ def fetch_event(event)
120
+ {
121
+ label: "Fetch ##{event.id}",
122
+ description: "#{event.items_created.to_i} created",
123
+ status: event.success? ? :success : :failure,
124
+ type: :fetch,
125
+ time: event.occurred_at,
126
+ path: url_helpers.fetch_log_path(event.id)
127
+ }
128
+ end
129
+ end
130
+ ```
131
+
132
+ ### Static Actions Presenter
133
+ Resolves route names to actual paths:
134
+
135
+ ```ruby
136
+ class QuickActionsPresenter
137
+ def initialize(actions, url_helpers:)
138
+ @actions = actions
139
+ @url_helpers = url_helpers
140
+ end
141
+
142
+ def to_a
143
+ actions.map do |action|
144
+ {
145
+ label: action.label,
146
+ description: action.description,
147
+ path: url_helpers.public_send(action.route_name)
148
+ }
149
+ end
150
+ end
151
+ end
152
+ ```
153
+
154
+ ## Turbo Stream Broadcast Patterns
155
+
156
+ ### Stream Subscription (in view)
157
+ ```erb
158
+ <%= turbo_stream_from SourceMonitor::Dashboard::TurboBroadcaster::STREAM_NAME %>
159
+ ```
160
+
161
+ ### Broadcasting a Replace
162
+ ```ruby
163
+ Turbo::StreamsChannel.broadcast_replace_to(
164
+ STREAM_NAME, # channel name
165
+ target: "source_monitor_dashboard_stats", # DOM element id
166
+ html: render_partial( # rendered HTML
167
+ "source_monitor/dashboard/stats",
168
+ stats: queries.stats
169
+ )
170
+ )
171
+ ```
172
+
173
+ ### Rendering Partials Outside Request Context
174
+ ```ruby
175
+ def render_partial(partial, locals)
176
+ SourceMonitor::DashboardController.render(
177
+ partial:,
178
+ locals:
179
+ )
180
+ end
181
+ ```
182
+
183
+ ### Event Callback Registration
184
+ ```ruby
185
+ def setup!
186
+ return unless turbo_streams_available?
187
+
188
+ register_callback(:after_fetch_completed, fetch_callback)
189
+ register_callback(:after_item_created, item_callback)
190
+ end
191
+
192
+ def register_callback(name, callback)
193
+ callbacks = SourceMonitor.config.events.callbacks_for(name)
194
+ return if callbacks.include?(callback)
195
+
196
+ SourceMonitor.config.events.public_send(name, callback)
197
+ end
198
+ ```
199
+
200
+ ### Guard Against Missing Turbo
201
+ ```ruby
202
+ def turbo_streams_available?
203
+ defined?(Turbo::StreamsChannel)
204
+ end
205
+ ```
206
+
207
+ ## View Patterns
208
+
209
+ ### Card Container
210
+ ```erb
211
+ <div id="source_monitor_dashboard_my_widget"
212
+ class="rounded-lg border border-slate-200 bg-white shadow-sm">
213
+ <div class="border-b border-slate-200 px-5 py-4">
214
+ <h2 class="text-lg font-medium">Title</h2>
215
+ <p class="mt-1 text-xs text-slate-500">Subtitle.</p>
216
+ </div>
217
+ <div class="divide-y divide-slate-100">
218
+ <!-- content rows -->
219
+ </div>
220
+ </div>
221
+ ```
222
+
223
+ ### Stat Card (Collection Render)
224
+ ```erb
225
+ <div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-5">
226
+ <%= render partial: "stat_card", collection: [
227
+ { label: "Sources", value: stats[:total_sources], caption: "Total registered" },
228
+ { label: "Active", value: stats[:active_sources], caption: "Fetching on schedule" }
229
+ ] %>
230
+ </div>
231
+ ```
232
+
233
+ Each stat card:
234
+ ```erb
235
+ <div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
236
+ <dt class="text-xs font-medium uppercase tracking-wide text-slate-500"><%= stat_card[:label] %></dt>
237
+ <dd class="mt-2 text-3xl font-semibold text-slate-900"><%= value %></dd>
238
+ <p class="mt-1 text-xs text-slate-500"><%= stat_card[:caption] %></p>
239
+ </div>
240
+ ```
241
+
242
+ ### Status Badge
243
+ ```erb
244
+ <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold
245
+ <%= event[:status] == :success ? 'bg-green-100 text-green-700' : 'bg-rose-100 text-rose-700' %>">
246
+ <%= event[:status] == :success ? "Success" : "Failure" %>
247
+ </span>
248
+ ```
249
+
250
+ ### Empty State
251
+ ```erb
252
+ <% if data.any? %>
253
+ <!-- content -->
254
+ <% else %>
255
+ <div class="px-5 py-6 text-sm text-slate-500">
256
+ No data available yet.
257
+ </div>
258
+ <% end %>
259
+ ```
260
+
261
+ ### Dashboard Layout Grid
262
+ The dashboard uses a 3-column grid at large breakpoints:
263
+ ```erb
264
+ <section class="grid gap-6 lg:grid-cols-3">
265
+ <div class="lg:col-span-2 space-y-6">
266
+ <!-- main content (recent activity, fetch schedule) -->
267
+ </div>
268
+ <div class="space-y-6">
269
+ <!-- sidebar (job metrics, quick actions) -->
270
+ </div>
271
+ </section>
272
+ ```
273
+
274
+ ## Metrics Integration
275
+
276
+ Every query method in the facade emits:
277
+ - `dashboard_{name}_duration_ms` -- execution time gauge
278
+ - `dashboard_{name}_last_run_at_epoch` -- timestamp of last execution
279
+ - Widget-specific gauges (e.g., `dashboard_stats_total_sources`)
280
+
281
+ ```ruby
282
+ def measure(name, metadata = {})
283
+ started_at = monotonic_time
284
+ result = yield
285
+ duration_ms = ((monotonic_time - started_at) * 1000.0).round(2)
286
+
287
+ ActiveSupport::Notifications.instrument("source_monitor.dashboard.#{name}", ...)
288
+ SourceMonitor::Metrics.gauge(:"dashboard_#{name}_duration_ms", duration_ms)
289
+
290
+ result
291
+ end
292
+ ```
293
+
294
+ ## Naming Conventions
295
+
296
+ | Layer | Naming Pattern | Example |
297
+ |-------|----------------|---------|
298
+ | Query class | `{Widget}Query` | `StatsQuery` |
299
+ | Presenter | `{Widget}Presenter` | `RecentActivityPresenter` |
300
+ | Struct | `{Widget}::Event` or `{Widget}::Group` | `RecentActivity::Event` |
301
+ | View partial | `_snake_case.html.erb` | `_recent_activity.html.erb` |
302
+ | Turbo target ID | `source_monitor_dashboard_{snake_case}` | `source_monitor_dashboard_stats` |
303
+ | Metric name | `dashboard_{snake_case}_{metric}` | `dashboard_stats_total_sources` |
304
+ | Notification | `source_monitor.dashboard.{name}` | `source_monitor.dashboard.stats` |
@@ -0,0 +1,188 @@
1
+ ---
2
+ name: sm-domain-model
3
+ description: Provides SourceMonitor engine domain model context. Use when working with engine models, associations, validations, scopes, database schema, or any model-layer code in the source_monitor namespace.
4
+ allowed-tools: Read, Glob, Grep
5
+ user-invocable: false
6
+ ---
7
+
8
+ # SourceMonitor Domain Model
9
+
10
+ ## Overview
11
+
12
+ SourceMonitor is a Rails 8 mountable engine for RSS/feed monitoring. All models live under the `SourceMonitor::` namespace and inherit from `SourceMonitor::ApplicationRecord`. Tables use a configurable prefix (default: `sourcemon_`).
13
+
14
+ ## Model Graph
15
+
16
+ ```
17
+ Source (central entity)
18
+ |-- has_many :items (active only, via scope)
19
+ |-- has_many :all_items (includes soft-deleted)
20
+ |-- has_many :fetch_logs
21
+ |-- has_many :scrape_logs
22
+ |-- has_many :health_check_logs
23
+ |-- has_many :log_entries
24
+ |
25
+ Item
26
+ |-- belongs_to :source (counter_cache: true)
27
+ |-- has_one :item_content (dependent: :destroy, autosave: true)
28
+ |-- has_many :scrape_logs
29
+ |-- has_many :log_entries
30
+ |
31
+ ItemContent
32
+ |-- belongs_to :item (touch: true)
33
+ |
34
+ FetchLog (includes Loggable)
35
+ |-- belongs_to :source
36
+ |-- has_one :log_entry (as: :loggable, polymorphic)
37
+ |
38
+ ScrapeLog (includes Loggable)
39
+ |-- belongs_to :item
40
+ |-- belongs_to :source
41
+ |-- has_one :log_entry (as: :loggable, polymorphic)
42
+ |
43
+ HealthCheckLog (includes Loggable)
44
+ |-- belongs_to :source
45
+ |-- has_one :log_entry (as: :loggable, polymorphic)
46
+ |
47
+ LogEntry (delegated_type :loggable)
48
+ |-- belongs_to :source
49
+ |-- belongs_to :item (optional)
50
+ |-- loggable types: FetchLog, ScrapeLog, HealthCheckLog
51
+ |
52
+ ImportSession (standalone)
53
+ |-- user_id (FK to host app)
54
+ |
55
+ ImportHistory (standalone)
56
+ |-- user_id (FK to host app)
57
+ ```
58
+
59
+ ## Models Summary
60
+
61
+ | Model | Table | Purpose |
62
+ |-------|-------|---------|
63
+ | `Source` | `sourcemon_sources` | Feed source with URL, fetch config, health tracking |
64
+ | `Item` | `sourcemon_items` | Individual feed entry/article |
65
+ | `ItemContent` | `sourcemon_item_contents` | Scraped HTML/content (split from items for performance) |
66
+ | `FetchLog` | `sourcemon_fetch_logs` | Record of each feed fetch attempt |
67
+ | `ScrapeLog` | `sourcemon_scrape_logs` | Record of each item scrape attempt |
68
+ | `HealthCheckLog` | `sourcemon_health_check_logs` | Record of each health check |
69
+ | `LogEntry` | `sourcemon_log_entries` | Unified log view via delegated_type (polymorphic) |
70
+ | `ImportSession` | `sourcemon_import_sessions` | OPML import wizard state |
71
+ | `ImportHistory` | `sourcemon_import_histories` | Completed import records |
72
+
73
+ ## Key Concerns
74
+
75
+ ### Loggable (`app/models/concerns/source_monitor/loggable.rb`)
76
+ Shared by FetchLog, ScrapeLog, HealthCheckLog:
77
+ - `attribute :metadata, default: -> { {} }`
78
+ - `validates :started_at, presence: true`
79
+ - `validates :duration_ms, numericality: { >= 0 }, allow_nil: true`
80
+ - Scopes: `recent`, `successful`, `failed`
81
+
82
+ ### Sanitizable (`lib/source_monitor/models/sanitizable.rb`)
83
+ String/hash attribute sanitization. Used by Source.
84
+
85
+ ### UrlNormalizable (`lib/source_monitor/models/url_normalizable.rb`)
86
+ URL normalization and format validation. Used by Source and Item.
87
+
88
+ ## Source Model Details
89
+
90
+ ### State Values
91
+
92
+ | Field | Values | Notes |
93
+ |-------|--------|-------|
94
+ | `fetch_status` | `idle`, `queued`, `fetching`, `failed`, `invalid` | DB CHECK constraint |
95
+ | `health_status` | `healthy` (default) | String, extensible |
96
+ | `active` | `true`/`false` | Boolean toggle |
97
+
98
+ ### Key Scopes
99
+
100
+ | Scope | Meaning |
101
+ |-------|---------|
102
+ | `active` | `WHERE active = true` |
103
+ | `failed` | `failure_count > 0 OR last_error IS NOT NULL OR last_error_at IS NOT NULL` |
104
+ | `healthy` | `active AND failure_count = 0 AND last_error IS NULL AND last_error_at IS NULL` |
105
+ | `due_for_fetch(reference_time:)` | Class method. Active sources where `next_fetch_at IS NULL OR <= reference_time` |
106
+
107
+ ### Validations
108
+
109
+ | Field | Rules |
110
+ |-------|-------|
111
+ | `name` | presence |
112
+ | `feed_url` | presence, uniqueness (case insensitive) |
113
+ | `fetch_interval_minutes` | numericality > 0 |
114
+ | `scraper_adapter` | presence |
115
+ | `items_retention_days` | integer >= 0, allow nil |
116
+ | `max_items` | integer >= 0, allow nil |
117
+ | `fetch_status` | inclusion in FETCH_STATUS_VALUES |
118
+ | `fetch_retry_attempt` | integer >= 0 |
119
+ | `health_auto_pause_threshold` | custom: 0..1 range |
120
+
121
+ ### Notable Methods
122
+
123
+ - `fetch_interval_hours` / `fetch_interval_hours=` -- convenience accessors converting minutes
124
+ - `fetch_circuit_open?` -- circuit breaker check
125
+ - `auto_paused?` -- health-based auto-pause check
126
+ - `reset_items_counter!` -- recalculate counter cache from active items
127
+
128
+ ## Item Model Details
129
+
130
+ ### Soft Delete Pattern
131
+ Items use soft delete via `deleted_at` column (NOT default_scope):
132
+ - `scope :active` -- `WHERE deleted_at IS NULL`
133
+ - `scope :with_deleted` -- unscopes deleted_at
134
+ - `scope :only_deleted` -- `WHERE deleted_at IS NOT NULL`
135
+ - `soft_delete!(timestamp:)` -- sets deleted_at, decrements counter cache
136
+ - `deleted?` -- checks deleted_at presence
137
+
138
+ ### Key Scopes
139
+
140
+ | Scope | Meaning |
141
+ |-------|---------|
142
+ | `active` | `WHERE deleted_at IS NULL` |
143
+ | `recent` | Active, ordered by `published_at DESC NULLS LAST, created_at DESC` |
144
+ | `published` | Active, `WHERE published_at IS NOT NULL` |
145
+ | `pending_scrape` | Active, `WHERE scraped_at IS NULL` |
146
+ | `failed_scrape` | Active, `WHERE scrape_status = 'failed'` |
147
+
148
+ ### Validations
149
+
150
+ | Field | Rules |
151
+ |-------|-------|
152
+ | `source` | presence |
153
+ | `guid` | presence, uniqueness scoped to source_id (case insensitive) |
154
+ | `content_fingerprint` | uniqueness scoped to source_id, allow blank |
155
+ | `url` | presence |
156
+
157
+ ### Content Delegation
158
+ `scraped_html` and `scraped_content` delegate to `ItemContent`. Setting these values auto-creates/destroys the ItemContent association.
159
+
160
+ ## LogEntry Delegated Type
161
+
162
+ LogEntry uses Rails `delegated_type` to unify FetchLog, ScrapeLog, and HealthCheckLog:
163
+ - `loggable_type` -- polymorphic type column
164
+ - `loggable_id` -- polymorphic ID column
165
+ - Helper methods: `fetch?`, `scrape?`, `health_check?`, `log_type`
166
+ - After-save sync: each log type calls `Logs::EntrySync.call(self)` to keep LogEntry in sync
167
+
168
+ ## ImportSession Wizard Steps
169
+
170
+ Ordered steps: `upload` -> `preview` -> `health_check` -> `configure` -> `confirm`
171
+
172
+ Methods: `next_step`, `previous_step`, `health_stream_name`, `health_check_targets`
173
+
174
+ ## ModelExtensions Registry
175
+
176
+ All models register via `SourceMonitor::ModelExtensions.register(self, :key)`. This system:
177
+ 1. Assigns table names using the configured prefix
178
+ 2. Applies host-app concerns
179
+ 3. Applies host-app validations
180
+ 4. Supports `reload!` for configuration changes
181
+
182
+ ## References
183
+
184
+ - [Model Relationship Graph](reference/model-graph.md) -- Visual model relationships
185
+ - [Table Structure](reference/table-structure.md) -- Complete schema with columns, types, indexes
186
+ - Source files: `app/models/source_monitor/*.rb`
187
+ - Concern: `app/models/concerns/source_monitor/loggable.rb`
188
+ - Migrations: `db/migrate/*.rb`
@@ -0,0 +1,114 @@
1
+ # SourceMonitor Model Relationship Graph
2
+
3
+ ## Core Feed Monitoring
4
+
5
+ ```
6
+ SourceMonitor::Source
7
+ =====================
8
+ Central entity: a feed URL to monitor
9
+ Table: sourcemon_sources
10
+ |
11
+ +-----------+-------------+-------------+-----------+------------+
12
+ | | | | | |
13
+ has_many has_many has_many has_many has_many has_many
14
+ :items :all_items :fetch_logs :scrape_ :health_ :log_entries
15
+ (active) (all) logs check_logs
16
+ | | | | | |
17
+ v v v v v v
18
+ SourceMonitor::Item FetchLog ScrapeLog HealthCheckLog LogEntry
19
+ =================== ======== ========= ============== ========
20
+ Feed entry/article Fetch Scrape Health Unified
21
+ Table: sourcemon_items attempt attempt check log view
22
+ | record record record
23
+ |
24
+ +----+----+
25
+ | |
26
+ has_one has_many
27
+ :item_ :scrape_logs
28
+ content
29
+ | |
30
+ v v
31
+ ItemContent ScrapeLog
32
+ =========== (also belongs_to :source)
33
+ Scraped
34
+ content
35
+ ```
36
+
37
+ ## Association Details
38
+
39
+ ### Source -> Item
40
+ - `has_many :items` -- Only active (non-deleted) items via `-> { active }` scope
41
+ - `has_many :all_items` -- All items including soft-deleted
42
+ - `dependent: :destroy` on all_items
43
+ - Counter cache: `items_count` on Source (tracks active items)
44
+
45
+ ### Item -> ItemContent
46
+ - `has_one :item_content` -- Lazy-created when scraped content is assigned
47
+ - `dependent: :destroy, autosave: true`
48
+ - `touch: true` on the belongs_to side
49
+ - Content is auto-destroyed when both `scraped_html` and `scraped_content` become blank
50
+
51
+ ### Source -> FetchLog
52
+ - `has_many :fetch_logs, dependent: :destroy`
53
+ - Each FetchLog records one feed fetch attempt with HTTP details, item counts, timing
54
+
55
+ ### Source -> ScrapeLog (and Item -> ScrapeLog)
56
+ - ScrapeLog has dual parents: `belongs_to :source` AND `belongs_to :item`
57
+ - Validates that `item.source_id == source_id` (source must match item's source)
58
+ - Records one content scrape attempt per item
59
+
60
+ ### Source -> HealthCheckLog
61
+ - `has_many :health_check_logs, dependent: :destroy`
62
+ - Records health check results for the source
63
+
64
+ ### LogEntry (Delegated Type)
65
+ - Polymorphic unified view of all log types
66
+ - `delegated_type :loggable, types: [FetchLog, ScrapeLog, HealthCheckLog]`
67
+ - `belongs_to :source` (always present)
68
+ - `belongs_to :item` (optional, present for scrape logs)
69
+ - Synced via `Logs::EntrySync.call()` after each log save
70
+
71
+ ## Import Subsystem (Standalone)
72
+
73
+ ```
74
+ ImportSession ImportHistory
75
+ ============= =============
76
+ OPML import wizard state Completed import record
77
+ Table: sourcemon_import_sessions Table: sourcemon_import_histories
78
+
79
+ Fields: Fields:
80
+ - user_id (FK -> users) - user_id (FK -> users)
81
+ - opml_file_metadata (jsonb) - imported_sources (jsonb)
82
+ - parsed_sources (jsonb) - failed_sources (jsonb)
83
+ - selected_source_ids (jsonb) - skipped_duplicates (jsonb)
84
+ - bulk_settings (jsonb) - bulk_settings (jsonb)
85
+ - current_step (string) - started_at / completed_at
86
+ - health_checks_active (bool)
87
+ - health_check_target_ids (jsonb)
88
+ ```
89
+
90
+ These models reference the host app's `users` table and are NOT directly associated with Source/Item models.
91
+
92
+ ## Polymorphic Patterns
93
+
94
+ ### Delegated Type (LogEntry)
95
+ LogEntry uses `delegated_type` (not STI) for the unified log view:
96
+ ```ruby
97
+ # LogEntry
98
+ delegated_type :loggable, types: %w[
99
+ SourceMonitor::FetchLog
100
+ SourceMonitor::ScrapeLog
101
+ SourceMonitor::HealthCheckLog
102
+ ]
103
+ ```
104
+
105
+ ### STI Column on Source
106
+ Source has a `type` column for potential STI subclassing but it is not actively used with subclasses currently.
107
+
108
+ ## Counter Caches
109
+
110
+ | Parent | Column | Child | Notes |
111
+ |--------|--------|-------|-------|
112
+ | Source | `items_count` | Item | Only counts active (non-deleted) items |
113
+
114
+ The counter is decremented manually during `soft_delete!` and can be recalculated via `reset_items_counter!`.