feedkit 0.1.0 → 0.2.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/CHANGELOG.md +18 -0
- data/README.md +94 -64
- data/app/jobs/feedkit/dispatch_job.rb +4 -3
- data/app/jobs/feedkit/generate_feed_job.rb +2 -2
- data/app/models/feedkit/feed.rb +1 -1
- data/lib/feedkit/generator.rb +29 -11
- data/lib/feedkit/registry.rb +1 -1
- data/lib/feedkit/schedulable.rb +11 -3
- data/lib/feedkit/schedule/constants.rb +65 -0
- data/lib/feedkit/schedule/matching.rb +58 -0
- data/lib/feedkit/schedule/naming.rb +81 -0
- data/lib/feedkit/schedule/normalization.rb +70 -0
- data/lib/feedkit/schedule/period_start_calculator.rb +212 -0
- data/lib/feedkit/schedule/validation.rb +91 -0
- data/lib/feedkit/schedule.rb +49 -181
- data/lib/feedkit/version.rb +1 -1
- data/lib/feedkit.rb +1 -0
- data/lib/generators/feedkit/templates/generator.rb.tt +2 -2
- data/lib/generators/feedkit/templates/initializer.rb.tt +1 -1
- data/lib/generators/feedkit/templates/migration.rb.tt +3 -1
- metadata +35 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74138d59abacb0faf60572624e29afc713f1fdf3e1fed0c553206cc86e357d71
|
|
4
|
+
data.tar.gz: dd88f9f5876b4e65ea21c92aad49aa351c7359aebd00f41b78ff479fa4710bb6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2b97d983d035764a00b2dda527710269644737e5812e2bcdb4c119d3b03c6e7f9c1e1482ed3caa9b0a7a201aa58c292dd29394281b501d3b01a30411ba6145e
|
|
7
|
+
data.tar.gz: 805e9ed65e6fb34adbd42cdd8a0a36d6b5be2742605b1d76d6950782932f1c79c5538fd88b85e3f55d1cb7a581685a627fb1c71b381b3ac3b53e938e5670c33b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-02-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `:year` schedules with `month:` and `day:` conditions
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Schedule periods are now symbolic (`:hour`, `:day`, `:week`, `:month`, `:year`) instead of arbitrary `ActiveSupport::Duration` values
|
|
12
|
+
- Schedule matching, naming, validation, and period boundary calculations were refactored into focused modules
|
|
13
|
+
- Deduplication uses schedule boundaries (`period_start_at`) rather than sliding time windows
|
|
14
|
+
- `week:` condition now supports ISO week parity only (`:odd`/`:even`)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Handle DST transitions when calculating `period_start_at` (skip ambiguous/non-existent local ticks)
|
|
19
|
+
- Documentation clarifications around weekday integers (`wday`), multi-tick schedules (ranges/arrays), and when deduplication applies
|
|
20
|
+
|
|
3
21
|
## [0.1.0] - 2026-02-05
|
|
4
22
|
|
|
5
23
|
### Added
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Scheduled feed generation for Rails applications.**
|
|
8
8
|
|
|
9
|
-
Feedkit is a Rails engine
|
|
9
|
+
Feedkit is a Rails engine for scheduled feed generation. You write generator classes that return a hash. Feedkit runs them on a schedule, stores the results, and deduplicates per schedule period.
|
|
10
10
|
|
|
11
11
|
## Table of Contents
|
|
12
12
|
|
|
@@ -27,9 +27,9 @@ Feedkit is a Rails engine that lets you define generator classes that produce st
|
|
|
27
27
|
|
|
28
28
|
## The Problem
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Most applications end up needing periodic snapshots: cost reports, usage digests, analytics rollups. In practice, that code gets spread across cron, one-off jobs, and service objects. After a few report types, you start re-solving the same problems (when to run, how to avoid duplicates, where to store the output).
|
|
31
31
|
|
|
32
|
-
Feedkit
|
|
32
|
+
Feedkit keeps it in one place: define a generator class, declare its schedule, and implement `#data`. Feedkit takes care of dispatching, persistence, and "once per period" deduplication.
|
|
33
33
|
|
|
34
34
|
## Installation
|
|
35
35
|
|
|
@@ -48,11 +48,11 @@ rails db:migrate
|
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
This creates three things:
|
|
51
|
-
- `config/initializers/feedkit.rb
|
|
51
|
+
- `config/initializers/feedkit.rb`: configuration file
|
|
52
52
|
- A migration for the `feedkit_feeds` table
|
|
53
|
-
- `app/generators
|
|
53
|
+
- `app/generators/`: directory for your generator classes
|
|
54
54
|
|
|
55
|
-
> **Note:** Feedkit
|
|
55
|
+
> **Note:** Feedkit requires PostgreSQL. The migration uses `jsonb` for the feed data column.
|
|
56
56
|
|
|
57
57
|
If your models use UUID primary keys, pass the `--owner_id_type` option:
|
|
58
58
|
|
|
@@ -86,8 +86,8 @@ This creates `app/generators/cost_overview.rb` and a corresponding test file.
|
|
|
86
86
|
class CostOverview < Feedkit::Generator
|
|
87
87
|
owned_by Organization
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
every :day, at: { hour: 13 }, as: :daily
|
|
90
|
+
every :week, at: { hour: 14, weekday: :tuesday }, as: :weekly
|
|
91
91
|
|
|
92
92
|
private
|
|
93
93
|
|
|
@@ -124,7 +124,7 @@ feedkit_dispatch:
|
|
|
124
124
|
class: Feedkit::DispatchJob
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
How often you run the dispatch job depends on your schedules. Running it hourly works well for most setups
|
|
127
|
+
How often you run the dispatch job depends on your schedules. Running it hourly works well for most setups. Feedkit does not backfill missed ticks; it only enqueues work for schedules that are due at the time `DispatchJob` runs.
|
|
128
128
|
|
|
129
129
|
## Generators
|
|
130
130
|
|
|
@@ -134,7 +134,10 @@ A generator is a class that inherits from `Feedkit::Generator`. It defines what
|
|
|
134
134
|
class WeeklyDigest < Feedkit::Generator
|
|
135
135
|
owned_by Organization
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
# Optional: override the stored feed_type (defaults to the underscored class name)
|
|
138
|
+
# feed_type :weekly_digest
|
|
139
|
+
|
|
140
|
+
every :week, at: { hour: 9, weekday: :monday }, as: :weekly
|
|
138
141
|
|
|
139
142
|
private
|
|
140
143
|
|
|
@@ -149,23 +152,47 @@ end
|
|
|
149
152
|
|
|
150
153
|
### Auto-registration
|
|
151
154
|
|
|
152
|
-
Generators
|
|
155
|
+
Generators register themselves when their class is loaded. There is no manual registration step.
|
|
153
156
|
|
|
154
|
-
In production (with `config.eager_load = true`), Rails loads
|
|
157
|
+
In production (with `config.eager_load = true`), Rails loads application code at boot, so generator classes under `app/generators/` are loaded and registered automatically.
|
|
155
158
|
|
|
156
159
|
In development, Feedkit calls `eager_load_generators!` before each dispatch cycle to ensure all generator files are loaded from the configured `generator_paths`.
|
|
157
160
|
|
|
158
161
|
### The `#data` method
|
|
159
162
|
|
|
160
|
-
This is the only method you need to implement. It receives no arguments
|
|
163
|
+
This is the only method you need to implement. It receives no arguments. Access the owner via the `owner` accessor.
|
|
161
164
|
|
|
162
165
|
- Return a **Hash** to create a feed record with that data
|
|
163
166
|
- Return **`nil`** to skip feed creation (useful for conditional feeds)
|
|
164
167
|
|
|
168
|
+
### The `owned_by` macro
|
|
169
|
+
|
|
170
|
+
Use `owned_by` when you want `Feedkit::DispatchJob` to run a generator automatically for every record of an owner model. It tells Feedkit what class to iterate over when dispatching scheduled runs.
|
|
171
|
+
|
|
172
|
+
If you only run a generator manually, `owned_by` is optional. You can still pass an owner instance to `new`, and Feedkit will persist the feed under that owner.
|
|
173
|
+
|
|
165
174
|
### The `owner` accessor
|
|
166
175
|
|
|
167
176
|
Inside your generator, `owner` gives you the model instance that the feed belongs to. Use it to query for the data you need.
|
|
168
177
|
|
|
178
|
+
### The `feed_type` macro
|
|
179
|
+
|
|
180
|
+
By default, Feedkit stores feeds under a `feed_type` derived from the generator class name (including namespaces). You can override it per generator:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
class CostOverview < Feedkit::Generator
|
|
184
|
+
owned_by Organization
|
|
185
|
+
|
|
186
|
+
feed_type :cost_overview
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def data
|
|
191
|
+
{ total_cost: owner.costs.sum(:amount) }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
169
196
|
### The `options` accessor
|
|
170
197
|
|
|
171
198
|
Generators accept arbitrary keyword arguments that are available via the `options` accessor. This is useful for passing context when triggering generators manually:
|
|
@@ -203,19 +230,19 @@ rails generate feedkit:generator SystemHealthCheck
|
|
|
203
230
|
|
|
204
231
|
## Scheduling
|
|
205
232
|
|
|
206
|
-
### The `
|
|
233
|
+
### The `every` DSL
|
|
207
234
|
|
|
208
235
|
Each generator can define one or more schedules:
|
|
209
236
|
|
|
210
237
|
```ruby
|
|
211
|
-
|
|
238
|
+
every <period>, at: <conditions>, as: <name>, superseded_by: <names>
|
|
212
239
|
```
|
|
213
240
|
|
|
214
241
|
| Parameter | Required | Description |
|
|
215
242
|
|-----------|----------|-------------|
|
|
216
|
-
| `every
|
|
243
|
+
| `every` | Yes | One of: `:hour`, `:day`, `:week`, `:month`, `:year` |
|
|
217
244
|
| `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) |
|
|
245
|
+
| `as:` | No | Name for this schedule (must be unique per generator; auto-generated from period and conditions if omitted) |
|
|
219
246
|
| `superseded_by:` | No | Array of schedule names that take precedence when both are due |
|
|
220
247
|
|
|
221
248
|
### Conditions
|
|
@@ -226,38 +253,40 @@ Conditions are AND-ed together. All must match for the schedule to fire.
|
|
|
226
253
|
|-----------|--------|---------|
|
|
227
254
|
| `hour:` | `0..23` | `hour: 6`, `hour: [6, 12, 18]`, `hour: 9..17` |
|
|
228
255
|
| `day:` | `1..31`, `:first`, `:last` | `day: 1`, `day: :last`, `day: 1..15` |
|
|
229
|
-
| `weekday:` | `0..6
|
|
230
|
-
| `week:` |
|
|
256
|
+
| `weekday:` | `0..6` (Ruby/Rails `wday`: `0` = Sunday), `:sunday`..`:saturday` | `weekday: :monday`, `weekday: :monday..:friday` |
|
|
257
|
+
| `week:` | `:odd`, `:even` (ISO week parity, `Date#cweek`) | `week: :odd` |
|
|
231
258
|
| `month:` | `1..12`, `:january`..`:december` | `month: :january`, `month: :january..:march` |
|
|
232
259
|
|
|
233
|
-
All condition types accept integers, symbols (where applicable), ranges, and arrays.
|
|
260
|
+
All condition types except `week:` accept integers, symbols (where applicable), ranges, and arrays. `week:` only accepts `:odd` or `:even`.
|
|
261
|
+
|
|
262
|
+
Ranges/arrays expand to multiple matching values. If that results in multiple occurrences within a period (for example `every :day, at: { hour: [6, 12, 18] }`), Feedkit treats each occurrence as a distinct tick and generates one feed per tick (per owner).
|
|
234
263
|
|
|
235
264
|
### Examples
|
|
236
265
|
|
|
237
266
|
```ruby
|
|
238
267
|
# Every day at 6 AM
|
|
239
|
-
|
|
268
|
+
every :day, at: { hour: 6 }, as: :daily
|
|
240
269
|
|
|
241
270
|
# Every Monday at 7 AM
|
|
242
|
-
|
|
271
|
+
every :week, at: { hour: 7, weekday: :monday }, as: :weekly
|
|
272
|
+
|
|
273
|
+
# Every Monday at 7 AM on odd ISO weeks
|
|
274
|
+
every :week, at: { hour: 7, weekday: :monday, week: :odd }
|
|
243
275
|
|
|
244
276
|
# First of every month at 8 AM
|
|
245
|
-
|
|
277
|
+
every :month, at: { hour: 8, day: 1 }, as: :monthly
|
|
246
278
|
|
|
247
279
|
# January 15 at 9 AM (yearly)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# Every even week
|
|
251
|
-
schedule every: 2.weeks, at: { hour: 6, week: :even }
|
|
280
|
+
every :year, at: { hour: 9, month: :january, day: 15 }, as: :annual
|
|
252
281
|
|
|
253
282
|
# Weekdays only at 6 AM
|
|
254
|
-
|
|
283
|
+
every :day, at: { hour: 6, weekday: :monday..:friday }
|
|
255
284
|
|
|
256
|
-
#
|
|
257
|
-
|
|
285
|
+
# Any of these hours (one feed per scheduled hour)
|
|
286
|
+
every :day, at: { hour: [6, 12, 18] }
|
|
258
287
|
|
|
259
288
|
# Q1 only
|
|
260
|
-
|
|
289
|
+
every :month, at: { hour: 6, day: 1, month: :january..:march }
|
|
261
290
|
```
|
|
262
291
|
|
|
263
292
|
### Schedule precedence with `superseded_by`
|
|
@@ -268,13 +297,9 @@ When a generator has multiple schedules, you sometimes want a longer-period sche
|
|
|
268
297
|
class CostOverview < Feedkit::Generator
|
|
269
298
|
owned_by Organization
|
|
270
299
|
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
300
|
+
every :day, at: { hour: 6 }, as: :daily, superseded_by: %i[weekly monthly]
|
|
301
|
+
every :week, at: { hour: 6, weekday: :monday }, as: :weekly, superseded_by: :monthly
|
|
302
|
+
every :month, at: { hour: 6, day: 1 }, as: :monthly
|
|
278
303
|
|
|
279
304
|
private
|
|
280
305
|
|
|
@@ -288,9 +313,13 @@ On a regular Tuesday at 6 AM, only `:daily` fires. On a Monday at 6 AM, only `:w
|
|
|
288
313
|
|
|
289
314
|
### Deduplication
|
|
290
315
|
|
|
291
|
-
Scheduled generators automatically deduplicate within their period. If a
|
|
316
|
+
Scheduled generators automatically deduplicate within their period. If a generator already created a feed for the current schedule period (for example, for the current scheduled hour), calling it again is a no-op. This prevents duplicates if the dispatch job runs more than once in the same period.
|
|
317
|
+
|
|
318
|
+
Deduplication is based on **schedule boundaries**, not a sliding `period.ago` window. For scheduled feeds, Feedkit computes a `period_start_at` timestamp from the schedule and stores it on the feed record. Subsequent runs in the same schedule period are skipped.
|
|
319
|
+
|
|
320
|
+
`period_start_at` is computed in the app's time zone. Around DST transitions, some local times don't exist or repeat; Feedkit skips ticks that can't be represented as a stable local timestamp.
|
|
292
321
|
|
|
293
|
-
Deduplication
|
|
322
|
+
Deduplication only applies when a generator is invoked as a scheduled run (with `period_name:` set, which is what `DispatchJob` does) and an owner is present. Ad-hoc calls do not deduplicate, including calling a scheduled generator without `period_name:` and ownerless generators.
|
|
294
323
|
|
|
295
324
|
## Ad-hoc Generators
|
|
296
325
|
|
|
@@ -315,12 +344,10 @@ end
|
|
|
315
344
|
SystemHealthReport.new.call
|
|
316
345
|
```
|
|
317
346
|
|
|
318
|
-
###
|
|
347
|
+
### Run a generator for an owner
|
|
319
348
|
|
|
320
349
|
```ruby
|
|
321
350
|
class AuditReport < Feedkit::Generator
|
|
322
|
-
owned_by Organization
|
|
323
|
-
|
|
324
351
|
private
|
|
325
352
|
|
|
326
353
|
def data
|
|
@@ -332,7 +359,9 @@ end
|
|
|
332
359
|
AuditReport.new(organization).call
|
|
333
360
|
```
|
|
334
361
|
|
|
335
|
-
|
|
362
|
+
If you want this generator to be dispatched automatically on a schedule, add `owned_by Organization` and one or more `every ...` schedules.
|
|
363
|
+
|
|
364
|
+
Ad-hoc generators (no schedule) skip deduplication entirely. Each call creates a new feed.
|
|
336
365
|
|
|
337
366
|
## Querying Feeds
|
|
338
367
|
|
|
@@ -366,8 +395,9 @@ Feedkit::Feed.by_type(:system_health_report).recent(5)
|
|
|
366
395
|
| Attribute | Type | Description |
|
|
367
396
|
|-----------|------|-------------|
|
|
368
397
|
| `owner` | Polymorphic | The owner record (can be `nil` for ownerless feeds) |
|
|
369
|
-
| `feed_type` | String | Derived from the generator class name (e.g., `"
|
|
398
|
+
| `feed_type` | String | Derived from the generator class name (namespaces included, e.g., `"admin_digest"` for `Admin::Digest`) |
|
|
370
399
|
| `period_name` | String | Schedule name (e.g., `"daily"`, `"weekly"`) or `nil` for ad-hoc |
|
|
400
|
+
| `period_start_at` | DateTime | Start of the schedule period used for deduplication (`nil` for ad-hoc feeds) |
|
|
371
401
|
| `data` | Hash | The payload returned by `#data` (stored as `jsonb`) |
|
|
372
402
|
| `created_at` | DateTime | When the feed was generated |
|
|
373
403
|
|
|
@@ -412,13 +442,13 @@ end
|
|
|
412
442
|
|
|
413
443
|
Feedkit has four main components:
|
|
414
444
|
|
|
415
|
-
1. **Generator
|
|
445
|
+
1. **Generator**: Base class with auto-registration via Ruby's `inherited` hook. When you define `class MyGen < Feedkit::Generator`, it is added to the registry.
|
|
416
446
|
|
|
417
|
-
2. **Registry
|
|
447
|
+
2. **Registry**: Tracks generator classes. Knows which are scheduled, which have owners, and which are due at a given time.
|
|
418
448
|
|
|
419
|
-
3. **DispatchJob
|
|
449
|
+
3. **DispatchJob**: An ActiveJob that asks the registry what is due, then enqueues a `GenerateFeedJob` for each owner of each due generator.
|
|
420
450
|
|
|
421
|
-
4. **GenerateFeedJob
|
|
451
|
+
4. **GenerateFeedJob**: An ActiveJob that instantiates one generator for one owner, calls `#data`, and persists the result as a `Feedkit::Feed` record.
|
|
422
452
|
|
|
423
453
|
### Dispatch Flow
|
|
424
454
|
|
|
@@ -427,8 +457,8 @@ DispatchJob (hourly cron)
|
|
|
427
457
|
→ Registry.due_at(Time.current)
|
|
428
458
|
→ For each due generator:
|
|
429
459
|
→ generator.owner_class.find_each do |owner|
|
|
430
|
-
→ GenerateFeedJob.perform_later(
|
|
431
|
-
→ generator.new(owner, period_name:).call
|
|
460
|
+
→ GenerateFeedJob.perform_later(..., period_name:, scheduled_at:)
|
|
461
|
+
→ generator.new(owner, period_name:).call(run_at: scheduled_at)
|
|
432
462
|
→ Check deduplication (skip if already generated this period)
|
|
433
463
|
→ Call #data (skip if nil)
|
|
434
464
|
→ Create Feedkit::Feed record
|
|
@@ -436,10 +466,10 @@ DispatchJob (hourly cron)
|
|
|
436
466
|
|
|
437
467
|
### Error Handling
|
|
438
468
|
|
|
439
|
-
`GenerateFeedJob`
|
|
469
|
+
`GenerateFeedJob` logs errors and does not re-raise:
|
|
440
470
|
|
|
441
|
-
- **
|
|
442
|
-
-
|
|
471
|
+
- If an **owner is deleted** between dispatch and execution, the job is skipped.
|
|
472
|
+
- If a **generator raises**, the error is logged via `Feedkit.logger` (with a full backtrace).
|
|
443
473
|
|
|
444
474
|
### Database Schema
|
|
445
475
|
|
|
@@ -449,7 +479,7 @@ The migration creates a `feedkit_feeds` table with three indexes:
|
|
|
449
479
|
|-------|---------|---------|
|
|
450
480
|
| `created_at` | `created_at` | Ordering and pagination |
|
|
451
481
|
| `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 |
|
|
482
|
+
| `idx_feedkit_feeds_dedup` | `owner_type, owner_id, feed_type, period_name, period_start_at` | Deduplication checks |
|
|
453
483
|
|
|
454
484
|
## Requirements
|
|
455
485
|
|
|
@@ -462,11 +492,11 @@ The migration creates a `feedkit_feeds` table with three indexes:
|
|
|
462
492
|
|
|
463
493
|
Features we're considering for future releases:
|
|
464
494
|
|
|
465
|
-
- [ ] **MySQL support
|
|
466
|
-
- [ ] **Feed retention policies
|
|
467
|
-
- [ ] **Callbacks
|
|
468
|
-
- [ ] **Web dashboard
|
|
469
|
-
- [ ] **Feed versioning
|
|
495
|
+
- [ ] **MySQL support**: Adapter pattern for non-PostgreSQL databases (Feedkit uses `jsonb` today)
|
|
496
|
+
- [ ] **Feed retention policies**: Auto-cleanup of old feeds based on age or count per generator
|
|
497
|
+
- [ ] **Callbacks**: `before_generate` and `after_generate` hooks for logging, notifications, or side effects
|
|
498
|
+
- [ ] **Web dashboard**: Mountable engine with a UI for browsing feeds and monitoring generator health
|
|
499
|
+
- [ ] **Feed versioning**: Schema version tracking for feed data to handle generator changes over time
|
|
470
500
|
|
|
471
501
|
Have a feature request? [Open an issue](https://github.com/milkstrawai/feedkit/issues) to discuss it!
|
|
472
502
|
|
|
@@ -504,9 +534,9 @@ bundle exec appraisal rake test
|
|
|
504
534
|
|
|
505
535
|
### Test Coverage
|
|
506
536
|
|
|
507
|
-
|
|
537
|
+
Coverage is enforced:
|
|
508
538
|
- Line coverage: 100%
|
|
509
|
-
- Branch coverage:
|
|
539
|
+
- Branch coverage: 95%
|
|
510
540
|
|
|
511
541
|
### Multi-version Testing
|
|
512
542
|
|
|
@@ -516,11 +546,11 @@ Feedkit is tested against a matrix of Ruby and Rails versions using [Appraisal](
|
|
|
516
546
|
|---|---|---|---|---|---|
|
|
517
547
|
| Ruby 3.2 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
518
548
|
| Ruby 3.3 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
519
|
-
| Ruby 3.4 |
|
|
549
|
+
| Ruby 3.4 | n/a | ✓ | ✓ | ✓ | ✓ |
|
|
520
550
|
|
|
521
551
|
## Contributing
|
|
522
552
|
|
|
523
|
-
Contributions are welcome
|
|
553
|
+
Contributions are welcome. Typical flow:
|
|
524
554
|
|
|
525
555
|
1. **Fork** the repository
|
|
526
556
|
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
|
@@ -12,19 +12,20 @@ module Feedkit
|
|
|
12
12
|
|
|
13
13
|
due_feeds.each do |config|
|
|
14
14
|
config[:generator].owner_class.find_each do |owner|
|
|
15
|
-
enqueue_feed_job(owner, config[:generator], config[:period_name])
|
|
15
|
+
enqueue_feed_job(owner, config[:generator], config[:period_name], time)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def enqueue_feed_job(owner, generator, period_name)
|
|
22
|
+
def enqueue_feed_job(owner, generator, period_name, scheduled_at)
|
|
23
23
|
Feedkit::GenerateFeedJob.perform_later(
|
|
24
24
|
owner_id: owner.id,
|
|
25
25
|
owner_class: owner.class.name,
|
|
26
26
|
generator_class: generator.name,
|
|
27
|
-
period_name
|
|
27
|
+
period_name:,
|
|
28
|
+
scheduled_at:
|
|
28
29
|
)
|
|
29
30
|
end
|
|
30
31
|
end
|
|
@@ -4,11 +4,11 @@ module Feedkit
|
|
|
4
4
|
class GenerateFeedJob < ActiveJob::Base
|
|
5
5
|
queue_as :default
|
|
6
6
|
|
|
7
|
-
def perform(owner_id:, owner_class:, generator_class:, period_name:)
|
|
7
|
+
def perform(owner_id:, owner_class:, generator_class:, period_name:, scheduled_at: Time.current)
|
|
8
8
|
owner = owner_class.constantize.find(owner_id)
|
|
9
9
|
generator = generator_class.constantize
|
|
10
10
|
|
|
11
|
-
generator.new(owner, period_name:
|
|
11
|
+
generator.new(owner, period_name:).call(run_at: scheduled_at)
|
|
12
12
|
rescue ActiveRecord::RecordNotFound
|
|
13
13
|
# Owner deleted between dispatch and execution - skip silently
|
|
14
14
|
rescue StandardError => e
|
data/app/models/feedkit/feed.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Feedkit
|
|
|
9
9
|
validates :feed_type, presence: true
|
|
10
10
|
validates :data, presence: true, unless: -> { data == {} }
|
|
11
11
|
|
|
12
|
-
scope :for_owner, ->(owner) { where(owner:
|
|
12
|
+
scope :for_owner, ->(owner) { where(owner:) }
|
|
13
13
|
scope :latest, -> { order(created_at: :desc) }
|
|
14
14
|
scope :by_type, ->(type) { where(feed_type: type) }
|
|
15
15
|
scope :recent, ->(limit = 50) { latest.limit(limit) }
|
data/lib/feedkit/generator.rb
CHANGED
|
@@ -21,8 +21,14 @@ module Feedkit
|
|
|
21
21
|
@owner_class
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def feed_type
|
|
25
|
-
|
|
24
|
+
def feed_type(value = nil)
|
|
25
|
+
@feed_type = value.to_sym if value
|
|
26
|
+
|
|
27
|
+
@feed_type || default_feed_type
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def default_feed_type
|
|
31
|
+
name.underscore.tr("/", "_").to_sym
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def scheduled?
|
|
@@ -38,11 +44,12 @@ module Feedkit
|
|
|
38
44
|
raise ArgumentError, "Unknown schedule: #{period_name}" if period_name && !@schedule
|
|
39
45
|
end
|
|
40
46
|
|
|
41
|
-
def call
|
|
42
|
-
|
|
47
|
+
def call(run_at: Time.current)
|
|
48
|
+
period_start = period_start_at(run_at)
|
|
49
|
+
return if already_generated?(period_start)
|
|
43
50
|
return unless (payload = data)
|
|
44
51
|
|
|
45
|
-
create_feed!(payload)
|
|
52
|
+
create_feed!(payload, period_start)
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
private
|
|
@@ -61,20 +68,31 @@ module Feedkit
|
|
|
61
68
|
@schedule&.period_name
|
|
62
69
|
end
|
|
63
70
|
|
|
64
|
-
def
|
|
71
|
+
def period_start_at(run_at)
|
|
72
|
+
@schedule&.period_start_at(run_at)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def already_generated?(period_start)
|
|
65
76
|
return false unless @schedule
|
|
66
77
|
return false unless @owner
|
|
67
78
|
|
|
68
|
-
feed_scope.where(feed_type: self.class.feed_type, period_name:
|
|
69
|
-
.exists?(
|
|
79
|
+
feed_scope.where(feed_type: self.class.feed_type, period_name:)
|
|
80
|
+
.exists?(period_start_at: period_start)
|
|
70
81
|
end
|
|
71
82
|
|
|
72
|
-
def create_feed!(payload)
|
|
83
|
+
def create_feed!(payload, period_start)
|
|
84
|
+
attrs = { feed_type: self.class.feed_type, period_name:, data: payload }
|
|
85
|
+
attrs[:period_start_at] = period_start if @schedule
|
|
86
|
+
|
|
73
87
|
if @owner
|
|
74
|
-
feed_scope.create!(
|
|
88
|
+
feed_scope.create!(attrs)
|
|
75
89
|
else
|
|
76
|
-
Feedkit::Feed.create!(
|
|
90
|
+
Feedkit::Feed.create!(attrs)
|
|
77
91
|
end
|
|
92
|
+
rescue ActiveRecord::RecordNotUnique
|
|
93
|
+
# Concurrency-safe dedup: another worker created this feed for the same
|
|
94
|
+
# (owner, feed_type, period_name, period_start_at) after our check.
|
|
95
|
+
nil
|
|
78
96
|
end
|
|
79
97
|
|
|
80
98
|
def feed_scope
|
data/lib/feedkit/registry.rb
CHANGED
data/lib/feedkit/schedulable.rb
CHANGED
|
@@ -11,13 +11,21 @@ module Feedkit
|
|
|
11
11
|
@schedules ||= []
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
|
|
14
|
+
def every(period, at:, as: nil, superseded_by: [])
|
|
15
|
+
schedule = Feedkit::Schedule.new(period:, at:, as:, superseded_by:)
|
|
16
|
+
|
|
17
|
+
if schedules.any? { |s| s.period_name == schedule.period_name }
|
|
18
|
+
raise ArgumentError,
|
|
19
|
+
"Duplicate schedule name '#{schedule.period_name}' for #{name || self}. " \
|
|
20
|
+
"Schedule names must be unique per generator."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
schedules << schedule
|
|
16
24
|
end
|
|
17
25
|
|
|
18
26
|
def schedules_due(time = Time.current)
|
|
19
27
|
due = schedules.select { |s| s.due?(time) }
|
|
20
|
-
due_names = due.
|
|
28
|
+
due_names = due.to_set(&:period_name)
|
|
21
29
|
due.reject { |s| s.superseded_by.any? { |name| due_names.include?(name) } }
|
|
22
30
|
end
|
|
23
31
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Feedkit
|
|
4
|
+
class Schedule
|
|
5
|
+
VALID_PERIODS = %i[hour day week month year].freeze
|
|
6
|
+
VALID_CONDITION_TYPES = %i[hour day weekday week month].freeze
|
|
7
|
+
CONDITION_NAME_ORDER = %i[hour day weekday week month].freeze
|
|
8
|
+
|
|
9
|
+
VALID_HOUR_RANGE = (0..23)
|
|
10
|
+
VALID_DAY_RANGE = (1..31)
|
|
11
|
+
VALID_WEEKDAY_RANGE = (0..6)
|
|
12
|
+
VALID_MONTH_RANGE = (1..12)
|
|
13
|
+
|
|
14
|
+
SYMBOLIC_DAY_VALUES = %i[first last].freeze
|
|
15
|
+
SYMBOLIC_WEEK_VALUES = %i[odd even].freeze
|
|
16
|
+
|
|
17
|
+
WEEKDAYS = {
|
|
18
|
+
sunday: 0,
|
|
19
|
+
monday: 1,
|
|
20
|
+
tuesday: 2,
|
|
21
|
+
wednesday: 3,
|
|
22
|
+
thursday: 4,
|
|
23
|
+
friday: 5,
|
|
24
|
+
saturday: 6
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
MONTHS = {
|
|
28
|
+
january: 1,
|
|
29
|
+
february: 2,
|
|
30
|
+
march: 3,
|
|
31
|
+
april: 4,
|
|
32
|
+
may: 5,
|
|
33
|
+
june: 6,
|
|
34
|
+
july: 7,
|
|
35
|
+
august: 8,
|
|
36
|
+
september: 9,
|
|
37
|
+
october: 10,
|
|
38
|
+
november: 11,
|
|
39
|
+
december: 12
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
CONDITION_ABBREVIATIONS = {
|
|
43
|
+
hour: "h",
|
|
44
|
+
day: "d",
|
|
45
|
+
weekday: "wd",
|
|
46
|
+
week: "wk",
|
|
47
|
+
month: "m"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
PERIOD_ABBREVIATIONS = {
|
|
51
|
+
hour: "h1",
|
|
52
|
+
day: "d1",
|
|
53
|
+
week: "w1",
|
|
54
|
+
month: "m1",
|
|
55
|
+
year: "y1"
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
ARRAY_VALUE_CANONICALIZERS = {
|
|
59
|
+
hour: :canonicalize_hour_array,
|
|
60
|
+
day: :canonicalize_day_array,
|
|
61
|
+
weekday: :canonicalize_weekday_array,
|
|
62
|
+
month: :canonicalize_month_array
|
|
63
|
+
}.freeze
|
|
64
|
+
end
|
|
65
|
+
end
|