feedkit 0.1.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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +547 -0
- data/Rakefile +15 -0
- data/app/jobs/feedkit/dispatch_job.rb +31 -0
- data/app/jobs/feedkit/generate_feed_job.rb +19 -0
- data/app/models/feedkit/feed.rb +17 -0
- data/lib/feedkit/configuration.rb +15 -0
- data/lib/feedkit/engine.rb +15 -0
- data/lib/feedkit/feeds_owner.rb +16 -0
- data/lib/feedkit/generator.rb +84 -0
- data/lib/feedkit/registry.rb +52 -0
- data/lib/feedkit/schedulable.rb +29 -0
- data/lib/feedkit/schedule.rb +222 -0
- data/lib/feedkit/version.rb +5 -0
- data/lib/feedkit.rb +45 -0
- data/lib/generators/feedkit/generator_generator.rb +34 -0
- data/lib/generators/feedkit/install_generator.rb +46 -0
- data/lib/generators/feedkit/templates/generator.rb.tt +23 -0
- data/lib/generators/feedkit/templates/generator_test.rb.tt +38 -0
- data/lib/generators/feedkit/templates/initializer.rb.tt +19 -0
- data/lib/generators/feedkit/templates/migration.rb.tt +19 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0fc80f79e7e3169ea207e7496284b8d1ab30722aa39e9de731e48e1dbc5f8192
|
|
4
|
+
data.tar.gz: 7ffc0fc5b23e1ba23cb9cbe97660a0b6ad6e57e2c9821b9790d446f5a038bdcd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b2bb5d8e545f76d5a1e239a4c281ae96ed8a48fe8e75c0b6ff51050491e2d3ee52faca97460ae3952639947f1020f7e0354db603b05f00696bfed433c7973432
|
|
7
|
+
data.tar.gz: c0fa337cd9044a3832829dacad9691a4c602088e35a4988cbd8464648b1c3d5ac4dcc8894008d665d852b627321bc6e89950a0c203b48176e11ff0691ed284bf
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-02-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Base `Feedkit::Generator` class with auto-registration via Ruby's `inherited` hook
|
|
8
|
+
- Schedule DSL with support for `hour`, `day`, `weekday`, `week`, and `month` conditions
|
|
9
|
+
- Arbitrary `ActiveSupport::Duration` periods (`1.hour`, `1.day`, `1.week`, `1.month`, `1.year`, etc.)
|
|
10
|
+
- Schedule precedence with `superseded_by` to prevent overlapping feeds
|
|
11
|
+
- Automatic deduplication within schedule periods
|
|
12
|
+
- `Feedkit::Registry` for tracking and querying registered generators
|
|
13
|
+
- `Feedkit::Feed` model with `for_owner`, `by_type`, `latest`, and `recent` query scopes
|
|
14
|
+
- `Feedkit::FeedsOwner` concern for adding feeds association to owner models
|
|
15
|
+
- `Feedkit::DispatchJob` for scheduled feed generation via ActiveJob
|
|
16
|
+
- `Feedkit::GenerateFeedJob` for per-owner feed generation with error handling
|
|
17
|
+
- Ad-hoc generator support (ownerless and unscheduled generators)
|
|
18
|
+
- Generator `options` accessor for passing arbitrary context
|
|
19
|
+
- Configurable table name, association name, generator paths, owner ID type, and logger
|
|
20
|
+
- Rails generator for installation (`rails generate feedkit:install`)
|
|
21
|
+
- Rails generator for scaffolding generators (`rails generate feedkit:generator NAME`)
|
|
22
|
+
- UUID primary key support via `--owner_id_type=uuid` option
|
|
23
|
+
- PostgreSQL `jsonb` storage for feed data
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MilkStraw AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
# Feedkit
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/feedkit)
|
|
4
|
+
[](https://github.com/milkstrawai/feedkit/actions)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Scheduled feed generation for Rails applications.**
|
|
8
|
+
|
|
9
|
+
Feedkit is a Rails engine that lets you define generator classes that produce structured data feeds on a schedule. Generators auto-register when they inherit from `Feedkit::Generator` — define your class anywhere, add a schedule, and Feedkit handles the rest.
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
- [The Problem](#the-problem)
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
- [Quick Start](#quick-start)
|
|
16
|
+
- [Generators](#generators)
|
|
17
|
+
- [Scheduling](#scheduling)
|
|
18
|
+
- [Ad-hoc Generators](#ad-hoc-generators)
|
|
19
|
+
- [Querying Feeds](#querying-feeds)
|
|
20
|
+
- [Configuration](#configuration)
|
|
21
|
+
- [How It Works](#how-it-works)
|
|
22
|
+
- [Requirements](#requirements)
|
|
23
|
+
- [Roadmap](#roadmap)
|
|
24
|
+
- [Development](#development)
|
|
25
|
+
- [Contributing](#contributing)
|
|
26
|
+
- [License](#license)
|
|
27
|
+
|
|
28
|
+
## The Problem
|
|
29
|
+
|
|
30
|
+
Applications often need to produce periodic data summaries — cost reports, usage digests, analytics snapshots. The typical approach scatters this logic across cron jobs, service objects, and ad-hoc scripts. When you have multiple report types, each with its own schedule, deduplication, and storage, things get messy fast.
|
|
31
|
+
|
|
32
|
+
Feedkit gives you a single pattern: define a generator class, declare its schedule, implement a `#data` method that returns a hash. Feedkit handles dispatching, deduplication, and persistence.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Add Feedkit to your Gemfile:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
gem 'feedkit'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Install and run the generator:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bundle install
|
|
46
|
+
rails generate feedkit:install
|
|
47
|
+
rails db:migrate
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This creates three things:
|
|
51
|
+
- `config/initializers/feedkit.rb` — configuration file
|
|
52
|
+
- A migration for the `feedkit_feeds` table
|
|
53
|
+
- `app/generators/` — directory for your generator classes
|
|
54
|
+
|
|
55
|
+
> **Note:** Feedkit currently requires PostgreSQL. The migration uses `jsonb` for the feed data column.
|
|
56
|
+
|
|
57
|
+
If your models use UUID primary keys, pass the `--owner_id_type` option:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
rails generate feedkit:install --owner_id_type=uuid
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
### 1. Include `FeedsOwner` in your model
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class Organization < ApplicationRecord
|
|
69
|
+
include Feedkit::FeedsOwner
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This adds a `feeds` association to the model.
|
|
74
|
+
|
|
75
|
+
### 2. Generate a feed generator
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
rails generate feedkit:generator CostOverview --owner Organization
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This creates `app/generators/cost_overview.rb` and a corresponding test file.
|
|
82
|
+
|
|
83
|
+
### 3. Implement the `#data` method
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class CostOverview < Feedkit::Generator
|
|
87
|
+
owned_by Organization
|
|
88
|
+
|
|
89
|
+
schedule every: 1.day, at: { hour: 13 }, as: :daily
|
|
90
|
+
schedule every: 1.week, at: { hour: 14, weekday: :tuesday }, as: :weekly
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def data
|
|
95
|
+
return if owner.costs.none?
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
total_cost: owner.costs.sum(:amount),
|
|
99
|
+
top_services: owner.costs.group(:service).sum(:amount).sort_by { |_, v| -v }.first(5)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Return a hash to create a feed, or `nil` to skip.
|
|
106
|
+
|
|
107
|
+
### 4. Schedule the dispatch job
|
|
108
|
+
|
|
109
|
+
Feedkit needs a cron-like scheduler to trigger `Feedkit::DispatchJob` periodically. With [GoodJob](https://github.com/bensheldon/good_job):
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# config/initializers/good_job.rb
|
|
113
|
+
config.cron = {
|
|
114
|
+
feedkit_dispatch: { cron: '0 * * * *', class: 'Feedkit::DispatchJob' }
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
With [Sidekiq](https://github.com/sidekiq/sidekiq):
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
# config/sidekiq_cron.yml
|
|
122
|
+
feedkit_dispatch:
|
|
123
|
+
cron: '0 * * * *'
|
|
124
|
+
class: Feedkit::DispatchJob
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
How often you run the dispatch job depends on your schedules. Running it hourly works well for most setups — it only enqueues work for generators that are actually due.
|
|
128
|
+
|
|
129
|
+
## Generators
|
|
130
|
+
|
|
131
|
+
A generator is a class that inherits from `Feedkit::Generator`. It defines what data to produce, for which owner model, and on what schedule.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
class WeeklyDigest < Feedkit::Generator
|
|
135
|
+
owned_by Organization
|
|
136
|
+
|
|
137
|
+
schedule every: 1.week, at: { hour: 9, weekday: :monday }, as: :weekly
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def data
|
|
142
|
+
{
|
|
143
|
+
active_users: owner.users.active.count,
|
|
144
|
+
new_signups: owner.users.where(created_at: 1.week.ago..).count
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Auto-registration
|
|
151
|
+
|
|
152
|
+
Generators are automatically registered when their class is loaded. There is no manual registration step. Define the class, and Feedkit discovers it.
|
|
153
|
+
|
|
154
|
+
In production (with `config.eager_load = true`), Rails loads all classes at boot, so all generators in `app/generators/` are registered automatically.
|
|
155
|
+
|
|
156
|
+
In development, Feedkit calls `eager_load_generators!` before each dispatch cycle to ensure all generator files are loaded from the configured `generator_paths`.
|
|
157
|
+
|
|
158
|
+
### The `#data` method
|
|
159
|
+
|
|
160
|
+
This is the only method you need to implement. It receives no arguments — access the owner via the `owner` accessor.
|
|
161
|
+
|
|
162
|
+
- Return a **Hash** to create a feed record with that data
|
|
163
|
+
- Return **`nil`** to skip feed creation (useful for conditional feeds)
|
|
164
|
+
|
|
165
|
+
### The `owner` accessor
|
|
166
|
+
|
|
167
|
+
Inside your generator, `owner` gives you the model instance that the feed belongs to. Use it to query for the data you need.
|
|
168
|
+
|
|
169
|
+
### The `options` accessor
|
|
170
|
+
|
|
171
|
+
Generators accept arbitrary keyword arguments that are available via the `options` accessor. This is useful for passing context when triggering generators manually:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class AuditReport < Feedkit::Generator
|
|
175
|
+
owned_by Organization
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def data
|
|
180
|
+
{
|
|
181
|
+
findings: owner.run_audit,
|
|
182
|
+
requested_by: options[:requested_by],
|
|
183
|
+
scope: options[:scope] || "full"
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Pass options when calling
|
|
189
|
+
AuditReport.new(organization, requested_by: "admin@example.com", scope: "billing").call
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Generator scaffolding
|
|
193
|
+
|
|
194
|
+
The generator generator (yes) creates a class file and a test:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# With an owner
|
|
198
|
+
rails generate feedkit:generator MonthlySummary --owner Organization
|
|
199
|
+
|
|
200
|
+
# Without an owner (for ownerless generators)
|
|
201
|
+
rails generate feedkit:generator SystemHealthCheck
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Scheduling
|
|
205
|
+
|
|
206
|
+
### The `schedule` DSL
|
|
207
|
+
|
|
208
|
+
Each generator can define one or more schedules:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
schedule every: <period>, at: <conditions>, as: <name>, superseded_by: <names>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
| Parameter | Required | Description |
|
|
215
|
+
|-----------|----------|-------------|
|
|
216
|
+
| `every:` | Yes | Any `ActiveSupport::Duration` — `1.hour`, `1.day`, `1.week`, `2.weeks`, `1.month`, `1.year`, `6.hours`, etc. |
|
|
217
|
+
| `at:` | Yes | Hash of conditions that must all match for the schedule to be due |
|
|
218
|
+
| `as:` | No | Name for this schedule (auto-generated from period and conditions if omitted) |
|
|
219
|
+
| `superseded_by:` | No | Array of schedule names that take precedence when both are due |
|
|
220
|
+
|
|
221
|
+
### Conditions
|
|
222
|
+
|
|
223
|
+
Conditions are AND-ed together. All must match for the schedule to fire.
|
|
224
|
+
|
|
225
|
+
| Condition | Values | Examples |
|
|
226
|
+
|-----------|--------|---------|
|
|
227
|
+
| `hour:` | `0..23` | `hour: 6`, `hour: [6, 12, 18]`, `hour: 9..17` |
|
|
228
|
+
| `day:` | `1..31`, `:first`, `:last` | `day: 1`, `day: :last`, `day: 1..15` |
|
|
229
|
+
| `weekday:` | `0..6`, `:sunday`..`:saturday` | `weekday: :monday`, `weekday: :monday..:friday` |
|
|
230
|
+
| `week:` | `1..53`, `:even`, `:odd` | `week: 42`, `week: :even` |
|
|
231
|
+
| `month:` | `1..12`, `:january`..`:december` | `month: :january`, `month: :january..:march` |
|
|
232
|
+
|
|
233
|
+
All condition types accept integers, symbols (where applicable), ranges, and arrays.
|
|
234
|
+
|
|
235
|
+
### Examples
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# Every day at 6 AM
|
|
239
|
+
schedule every: 1.day, at: { hour: 6 }, as: :daily
|
|
240
|
+
|
|
241
|
+
# Every Monday at 7 AM
|
|
242
|
+
schedule every: 1.week, at: { hour: 7, weekday: :monday }, as: :weekly
|
|
243
|
+
|
|
244
|
+
# First of every month at 8 AM
|
|
245
|
+
schedule every: 1.month, at: { hour: 8, day: 1 }, as: :monthly
|
|
246
|
+
|
|
247
|
+
# January 15 at 9 AM (yearly)
|
|
248
|
+
schedule every: 1.year, at: { hour: 9, month: :january, day: 15 }, as: :annual
|
|
249
|
+
|
|
250
|
+
# Every even week
|
|
251
|
+
schedule every: 2.weeks, at: { hour: 6, week: :even }
|
|
252
|
+
|
|
253
|
+
# Weekdays only at 6 AM
|
|
254
|
+
schedule every: 1.day, at: { hour: 6, weekday: :monday..:friday }
|
|
255
|
+
|
|
256
|
+
# Multiple times per day
|
|
257
|
+
schedule every: 1.day, at: { hour: [6, 12, 18] }
|
|
258
|
+
|
|
259
|
+
# Q1 only
|
|
260
|
+
schedule every: 1.month, at: { hour: 6, day: 1, month: :january..:march }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Schedule precedence with `superseded_by`
|
|
264
|
+
|
|
265
|
+
When a generator has multiple schedules, you sometimes want a longer-period schedule to take precedence. For example, you don't want both a daily and weekly feed generated on the same Monday morning.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
class CostOverview < Feedkit::Generator
|
|
269
|
+
owned_by Organization
|
|
270
|
+
|
|
271
|
+
schedule every: 1.day, at: { hour: 6 }, as: :daily,
|
|
272
|
+
superseded_by: %i[weekly monthly]
|
|
273
|
+
|
|
274
|
+
schedule every: 1.week, at: { hour: 6, weekday: :monday }, as: :weekly,
|
|
275
|
+
superseded_by: :monthly
|
|
276
|
+
|
|
277
|
+
schedule every: 1.month, at: { hour: 6, day: 1 }, as: :monthly
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def data
|
|
282
|
+
{ total: owner.costs.sum(:amount) }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
On a regular Tuesday at 6 AM, only `:daily` fires. On a Monday at 6 AM, only `:weekly` fires (`:daily` is superseded). On the 1st of the month at 6 AM if it's a Monday, only `:monthly` fires (both `:daily` and `:weekly` are superseded).
|
|
288
|
+
|
|
289
|
+
### Deduplication
|
|
290
|
+
|
|
291
|
+
Scheduled generators automatically deduplicate within their period. If a daily generator already created a feed for today, calling it again is a no-op. This prevents duplicates if the dispatch job runs more than once in the same period.
|
|
292
|
+
|
|
293
|
+
Deduplication checks `created_at > period.ago` scoped to the owner and feed type. No configuration needed.
|
|
294
|
+
|
|
295
|
+
## Ad-hoc Generators
|
|
296
|
+
|
|
297
|
+
Not every generator needs a schedule. You can define generators that are triggered manually from controllers, jobs, or the console.
|
|
298
|
+
|
|
299
|
+
### Ownerless generator
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
class SystemHealthReport < Feedkit::Generator
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def data
|
|
306
|
+
{
|
|
307
|
+
memory_usage: calculate_memory,
|
|
308
|
+
cpu_load: calculate_cpu,
|
|
309
|
+
checked_at: Time.current
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Trigger from anywhere
|
|
315
|
+
SystemHealthReport.new.call
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Owned but unscheduled
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
class AuditReport < Feedkit::Generator
|
|
322
|
+
owned_by Organization
|
|
323
|
+
|
|
324
|
+
private
|
|
325
|
+
|
|
326
|
+
def data
|
|
327
|
+
{ findings: owner.run_audit }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Trigger manually
|
|
332
|
+
AuditReport.new(organization).call
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Ad-hoc generators (no schedule) skip deduplication entirely — each call creates a new feed.
|
|
336
|
+
|
|
337
|
+
## Querying Feeds
|
|
338
|
+
|
|
339
|
+
### Via the owner association
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
organization.feeds # All feeds
|
|
343
|
+
organization.feeds.by_type(:cost_overview) # Filter by generator
|
|
344
|
+
organization.feeds.by_type(:cost_overview).recent(10) # Latest 10
|
|
345
|
+
organization.feeds.latest # Ordered by newest first
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Via the Feed model directly
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
Feedkit::Feed.for_owner(organization).latest
|
|
352
|
+
Feedkit::Feed.by_type(:system_health_report).recent(5)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Available scopes
|
|
356
|
+
|
|
357
|
+
| Scope | Description |
|
|
358
|
+
|-------|-------------|
|
|
359
|
+
| `for_owner(owner)` | Feeds belonging to a specific owner |
|
|
360
|
+
| `by_type(type)` | Feeds of a specific generator type |
|
|
361
|
+
| `latest` | Ordered by `created_at DESC` |
|
|
362
|
+
| `recent(n)` | Latest `n` feeds (default: 50) |
|
|
363
|
+
|
|
364
|
+
### Feed attributes
|
|
365
|
+
|
|
366
|
+
| Attribute | Type | Description |
|
|
367
|
+
|-----------|------|-------------|
|
|
368
|
+
| `owner` | Polymorphic | The owner record (can be `nil` for ownerless feeds) |
|
|
369
|
+
| `feed_type` | String | Derived from the generator class name (e.g., `"cost_overview"`) |
|
|
370
|
+
| `period_name` | String | Schedule name (e.g., `"daily"`, `"weekly"`) or `nil` for ad-hoc |
|
|
371
|
+
| `data` | Hash | The payload returned by `#data` (stored as `jsonb`) |
|
|
372
|
+
| `created_at` | DateTime | When the feed was generated |
|
|
373
|
+
|
|
374
|
+
## Configuration
|
|
375
|
+
|
|
376
|
+
The install generator creates `config/initializers/feedkit.rb`:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
Feedkit.configure do |config|
|
|
380
|
+
# Table name for the feeds table (default: 'feedkit_feeds')
|
|
381
|
+
# config.table_name = 'feedkit_feeds'
|
|
382
|
+
|
|
383
|
+
# Association name added to owner models (default: :feeds)
|
|
384
|
+
# config.association_name = :feeds
|
|
385
|
+
|
|
386
|
+
# Glob paths to load generator classes in development (default: ['app/generators/**/*.rb'])
|
|
387
|
+
# Only used when Rails eager loading is disabled (development mode)
|
|
388
|
+
# config.generator_paths = ['app/generators/**/*.rb']
|
|
389
|
+
|
|
390
|
+
# Primary key type for the owner_id column (default: :bigint)
|
|
391
|
+
# Set before running the migration
|
|
392
|
+
config.owner_id_type = :bigint
|
|
393
|
+
|
|
394
|
+
# Logger instance (defaults to Rails.logger)
|
|
395
|
+
# config.logger = Rails.logger
|
|
396
|
+
end
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Configuration Options
|
|
400
|
+
|
|
401
|
+
| Option | Default | Description |
|
|
402
|
+
|--------|---------|-------------|
|
|
403
|
+
| `table_name` | `'feedkit_feeds'` | Database table name for feed records |
|
|
404
|
+
| `association_name` | `:feeds` | Name of the `has_many` association added to owner models |
|
|
405
|
+
| `generator_paths` | `['app/generators/**/*.rb']` | Glob patterns for loading generators in development |
|
|
406
|
+
| `owner_id_type` | `:bigint` | Column type for `owner_id` (`:bigint` or `:uuid`) |
|
|
407
|
+
| `logger` | `Rails.logger` | Logger instance for Feedkit's internal logging |
|
|
408
|
+
|
|
409
|
+
## How It Works
|
|
410
|
+
|
|
411
|
+
### Architecture
|
|
412
|
+
|
|
413
|
+
Feedkit has four main components:
|
|
414
|
+
|
|
415
|
+
1. **Generator** — Base class with auto-registration via Ruby's `inherited` hook. When you define `class MyGen < Feedkit::Generator`, it's automatically added to the registry.
|
|
416
|
+
|
|
417
|
+
2. **Registry** — Tracks all generator classes. Knows which are scheduled, which have owners, and which are due at any given time.
|
|
418
|
+
|
|
419
|
+
3. **DispatchJob** — An ActiveJob that asks the registry "what's due right now?", then enqueues a `GenerateFeedJob` for each owner of each due generator.
|
|
420
|
+
|
|
421
|
+
4. **GenerateFeedJob** — An ActiveJob that instantiates a single generator for a single owner, calls `#data`, and persists the result as a `Feedkit::Feed` record.
|
|
422
|
+
|
|
423
|
+
### Dispatch Flow
|
|
424
|
+
|
|
425
|
+
```
|
|
426
|
+
DispatchJob (hourly cron)
|
|
427
|
+
→ Registry.due_at(Time.current)
|
|
428
|
+
→ For each due generator:
|
|
429
|
+
→ generator.owner_class.find_each do |owner|
|
|
430
|
+
→ GenerateFeedJob.perform_later(owner, generator, period_name)
|
|
431
|
+
→ generator.new(owner, period_name:).call
|
|
432
|
+
→ Check deduplication (skip if already generated this period)
|
|
433
|
+
→ Call #data (skip if nil)
|
|
434
|
+
→ Create Feedkit::Feed record
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Error Handling
|
|
438
|
+
|
|
439
|
+
`GenerateFeedJob` handles errors gracefully:
|
|
440
|
+
|
|
441
|
+
- **Owner deleted** between dispatch and execution — the job is silently skipped (the record no longer exists, so there's nothing to generate for)
|
|
442
|
+
- **Generator errors** — logged via `Feedkit.logger` with the full backtrace, job does not re-raise
|
|
443
|
+
|
|
444
|
+
### Database Schema
|
|
445
|
+
|
|
446
|
+
The migration creates a `feedkit_feeds` table with three indexes:
|
|
447
|
+
|
|
448
|
+
| Index | Columns | Purpose |
|
|
449
|
+
|-------|---------|---------|
|
|
450
|
+
| `created_at` | `created_at` | Ordering and pagination |
|
|
451
|
+
| `idx_feedkit_feeds_lookup` | `owner_type, owner_id, feed_type, created_at` | Querying feeds for an owner |
|
|
452
|
+
| `idx_feedkit_feeds_dedup` | `owner_type, owner_id, feed_type, period_name` | Deduplication checks |
|
|
453
|
+
|
|
454
|
+
## Requirements
|
|
455
|
+
|
|
456
|
+
- **Ruby** >= 3.2
|
|
457
|
+
- **Rails** >= 7.0
|
|
458
|
+
- **PostgreSQL** (for `jsonb` column support)
|
|
459
|
+
- **ActiveJob backend** (GoodJob, Sidekiq, etc.) with cron/recurring job support
|
|
460
|
+
|
|
461
|
+
## Roadmap
|
|
462
|
+
|
|
463
|
+
Features we're considering for future releases:
|
|
464
|
+
|
|
465
|
+
- [ ] **MySQL support** — Adapter pattern for non-PostgreSQL databases (currently requires `jsonb`)
|
|
466
|
+
- [ ] **Feed retention policies** — Auto-cleanup of old feeds based on age or count per generator
|
|
467
|
+
- [ ] **Callbacks** — `before_generate` and `after_generate` hooks for logging, notifications, or side effects
|
|
468
|
+
- [ ] **Web dashboard** — Mountable engine with a UI for browsing feeds and monitoring generator health
|
|
469
|
+
- [ ] **Feed versioning** — Schema version tracking for feed data to handle generator changes over time
|
|
470
|
+
|
|
471
|
+
Have a feature request? [Open an issue](https://github.com/milkstrawai/feedkit/issues) to discuss it!
|
|
472
|
+
|
|
473
|
+
## Development
|
|
474
|
+
|
|
475
|
+
### Setup
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
git clone https://github.com/milkstrawai/feedkit.git
|
|
479
|
+
cd feedkit
|
|
480
|
+
bundle install
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Running Tests
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
# Run test suite
|
|
487
|
+
bundle exec rake test
|
|
488
|
+
|
|
489
|
+
# Run with coverage report
|
|
490
|
+
bundle exec rake test && open coverage/index.html
|
|
491
|
+
|
|
492
|
+
# Run linter
|
|
493
|
+
bundle exec rubocop
|
|
494
|
+
|
|
495
|
+
# Run both (default rake task)
|
|
496
|
+
bundle exec rake
|
|
497
|
+
|
|
498
|
+
# Run against a specific Rails version
|
|
499
|
+
bundle exec appraisal rails-8-1 rake test
|
|
500
|
+
|
|
501
|
+
# Run against all Rails versions
|
|
502
|
+
bundle exec appraisal rake test
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Test Coverage
|
|
506
|
+
|
|
507
|
+
The project maintains high test coverage standards:
|
|
508
|
+
- Line coverage: 100%
|
|
509
|
+
- Branch coverage: 90%
|
|
510
|
+
|
|
511
|
+
### Multi-version Testing
|
|
512
|
+
|
|
513
|
+
Feedkit is tested against a matrix of Ruby and Rails versions using [Appraisal](https://github.com/thoughtbot/appraisal):
|
|
514
|
+
|
|
515
|
+
| | Rails 7.0 | Rails 7.1 | Rails 7.2 | Rails 8.0 | Rails 8.1 |
|
|
516
|
+
|---|---|---|---|---|---|
|
|
517
|
+
| Ruby 3.2 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
518
|
+
| Ruby 3.3 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
519
|
+
| Ruby 3.4 | — | ✓ | ✓ | ✓ | ✓ |
|
|
520
|
+
|
|
521
|
+
## Contributing
|
|
522
|
+
|
|
523
|
+
Contributions are welcome! Here's how you can help:
|
|
524
|
+
|
|
525
|
+
1. **Fork** the repository
|
|
526
|
+
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
|
527
|
+
3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
|
|
528
|
+
4. **Push** to the branch (`git push origin feature/amazing-feature`)
|
|
529
|
+
5. **Open** a Pull Request
|
|
530
|
+
|
|
531
|
+
### Guidelines
|
|
532
|
+
|
|
533
|
+
- Write tests for new features
|
|
534
|
+
- Follow existing code style (RuboCop will help)
|
|
535
|
+
- Update documentation as needed
|
|
536
|
+
- Keep commits focused and atomic
|
|
537
|
+
|
|
538
|
+
### Reporting Issues
|
|
539
|
+
|
|
540
|
+
Found a bug? Please open an issue with:
|
|
541
|
+
- Ruby and Rails versions
|
|
542
|
+
- Steps to reproduce
|
|
543
|
+
- Expected vs actual behavior
|
|
544
|
+
|
|
545
|
+
## License
|
|
546
|
+
|
|
547
|
+
Feedkit is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "rubocop/rake_task"
|
|
6
|
+
|
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
|
8
|
+
t.libs << "test"
|
|
9
|
+
t.libs << "lib"
|
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RuboCop::RakeTask.new
|
|
14
|
+
|
|
15
|
+
task default: %i[test rubocop]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class DispatchJob < ActiveJob::Base
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
time = Time.current
|
|
9
|
+
due_feeds = Feedkit::Registry.due_at(time)
|
|
10
|
+
|
|
11
|
+
Feedkit.logger.info "[Feedkit::DispatchJob] Found #{due_feeds.count} feeds due at #{time}"
|
|
12
|
+
|
|
13
|
+
due_feeds.each do |config|
|
|
14
|
+
config[:generator].owner_class.find_each do |owner|
|
|
15
|
+
enqueue_feed_job(owner, config[:generator], config[:period_name])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def enqueue_feed_job(owner, generator, period_name)
|
|
23
|
+
Feedkit::GenerateFeedJob.perform_later(
|
|
24
|
+
owner_id: owner.id,
|
|
25
|
+
owner_class: owner.class.name,
|
|
26
|
+
generator_class: generator.name,
|
|
27
|
+
period_name: period_name
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class GenerateFeedJob < ActiveJob::Base
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(owner_id:, owner_class:, generator_class:, period_name:)
|
|
8
|
+
owner = owner_class.constantize.find(owner_id)
|
|
9
|
+
generator = generator_class.constantize
|
|
10
|
+
|
|
11
|
+
generator.new(owner, period_name: period_name).call
|
|
12
|
+
rescue ActiveRecord::RecordNotFound
|
|
13
|
+
# Owner deleted between dispatch and execution - skip silently
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
Feedkit.logger.error "[Feedkit] Failed: #{generator_class} for #{owner_class}##{owner_id}"
|
|
16
|
+
Feedkit.logger.error e.full_message
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class Feed < ActiveRecord::Base
|
|
5
|
+
self.table_name = Feedkit.configuration.table_name
|
|
6
|
+
|
|
7
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
8
|
+
|
|
9
|
+
validates :feed_type, presence: true
|
|
10
|
+
validates :data, presence: true, unless: -> { data == {} }
|
|
11
|
+
|
|
12
|
+
scope :for_owner, ->(owner) { where(owner: owner) }
|
|
13
|
+
scope :latest, -> { order(created_at: :desc) }
|
|
14
|
+
scope :by_type, ->(type) { where(feed_type: type) }
|
|
15
|
+
scope :recent, ->(limit = 50) { latest.limit(limit) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :table_name, :association_name, :generator_paths, :owner_id_type, :logger
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@table_name = "feedkit_feeds"
|
|
9
|
+
@association_name = :feeds
|
|
10
|
+
@generator_paths = ["app/generators/**/*.rb"]
|
|
11
|
+
@owner_id_type = :bigint
|
|
12
|
+
@logger = nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|