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.
- checksums.yaml +4 -4
- data/.claude/skills/sm-architecture/SKILL.md +233 -0
- data/.claude/skills/sm-architecture/reference/extraction-patterns.md +192 -0
- data/.claude/skills/sm-architecture/reference/module-map.md +194 -0
- data/.claude/skills/sm-configuration-setting/SKILL.md +264 -0
- data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +248 -0
- data/.claude/skills/sm-configuration-setting/reference/settings-pattern.md +297 -0
- data/.claude/skills/sm-configure/SKILL.md +153 -0
- data/.claude/skills/sm-configure/reference/configuration-reference.md +321 -0
- data/.claude/skills/sm-dashboard-widget/SKILL.md +344 -0
- data/.claude/skills/sm-dashboard-widget/reference/dashboard-patterns.md +304 -0
- data/.claude/skills/sm-domain-model/SKILL.md +188 -0
- data/.claude/skills/sm-domain-model/reference/model-graph.md +114 -0
- data/.claude/skills/sm-domain-model/reference/table-structure.md +348 -0
- data/.claude/skills/sm-engine-migration/SKILL.md +395 -0
- data/.claude/skills/sm-engine-migration/reference/migration-conventions.md +255 -0
- data/.claude/skills/sm-engine-test/SKILL.md +302 -0
- data/.claude/skills/sm-engine-test/reference/test-helpers.md +259 -0
- data/.claude/skills/sm-engine-test/reference/test-patterns.md +411 -0
- data/.claude/skills/sm-event-handler/SKILL.md +265 -0
- data/.claude/skills/sm-event-handler/reference/events-api.md +229 -0
- data/.claude/skills/sm-health-rule/SKILL.md +327 -0
- data/.claude/skills/sm-health-rule/reference/health-system.md +269 -0
- data/.claude/skills/sm-host-setup/SKILL.md +223 -0
- data/.claude/skills/sm-host-setup/reference/initializer-template.md +195 -0
- data/.claude/skills/sm-host-setup/reference/setup-checklist.md +134 -0
- data/.claude/skills/sm-job/SKILL.md +263 -0
- data/.claude/skills/sm-job/reference/job-conventions.md +245 -0
- data/.claude/skills/sm-model-extension/SKILL.md +287 -0
- data/.claude/skills/sm-model-extension/reference/extension-api.md +317 -0
- data/.claude/skills/sm-pipeline-stage/SKILL.md +254 -0
- data/.claude/skills/sm-pipeline-stage/reference/completion-handlers.md +152 -0
- data/.claude/skills/sm-pipeline-stage/reference/entry-processing.md +191 -0
- data/.claude/skills/sm-pipeline-stage/reference/feed-fetcher-architecture.md +198 -0
- data/.claude/skills/sm-scraper-adapter/SKILL.md +284 -0
- data/.claude/skills/sm-scraper-adapter/reference/adapter-contract.md +167 -0
- data/.claude/skills/sm-scraper-adapter/reference/example-adapter.md +274 -0
- data/.vbw-planning/.notification-log.jsonl +102 -0
- data/.vbw-planning/.session-log.jsonl +505 -0
- data/AGENTS.md +20 -57
- data/CHANGELOG.md +19 -0
- data/CLAUDE.md +44 -1
- data/CONTRIBUTING.md +5 -5
- data/Gemfile.lock +20 -21
- data/README.md +18 -5
- data/VERSION +1 -0
- data/docs/deployment.md +1 -1
- data/docs/setup.md +4 -4
- data/lib/source_monitor/setup/skills_installer.rb +94 -0
- data/lib/source_monitor/setup/workflow.rb +17 -2
- data/lib/source_monitor/version.rb +1 -1
- data/lib/tasks/source_monitor_setup.rake +58 -0
- data/source_monitor.gemspec +1 -0
- 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!`.
|