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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fc80f79e7e3169ea207e7496284b8d1ab30722aa39e9de731e48e1dbc5f8192
4
- data.tar.gz: 7ffc0fc5b23e1ba23cb9cbe97660a0b6ad6e57e2c9821b9790d446f5a038bdcd
3
+ metadata.gz: 74138d59abacb0faf60572624e29afc713f1fdf3e1fed0c553206cc86e357d71
4
+ data.tar.gz: dd88f9f5876b4e65ea21c92aad49aa351c7359aebd00f41b78ff479fa4710bb6
5
5
  SHA512:
6
- metadata.gz: b2bb5d8e545f76d5a1e239a4c281ae96ed8a48fe8e75c0b6ff51050491e2d3ee52faca97460ae3952639947f1020f7e0354db603b05f00696bfed433c7973432
7
- data.tar.gz: c0fa337cd9044a3832829dacad9691a4c602088e35a4988cbd8464648b1c3d5ac4dcc8894008d665d852b627321bc6e89950a0c203b48176e11ff0691ed284bf
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 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.
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
- 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.
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 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.
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` configuration file
51
+ - `config/initializers/feedkit.rb`: configuration file
52
52
  - A migration for the `feedkit_feeds` table
53
- - `app/generators/` directory for your generator classes
53
+ - `app/generators/`: directory for your generator classes
54
54
 
55
- > **Note:** Feedkit currently requires PostgreSQL. The migration uses `jsonb` for the feed data column.
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
- schedule every: 1.day, at: { hour: 13 }, as: :daily
90
- schedule every: 1.week, at: { hour: 14, weekday: :tuesday }, as: :weekly
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 it only enqueues work for generators that are actually due.
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
- schedule every: 1.week, at: { hour: 9, weekday: :monday }, as: :weekly
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 are automatically registered when their class is loaded. There is no manual registration step. Define the class, and Feedkit discovers it.
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 all classes at boot, so all generators in `app/generators/` are registered automatically.
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 access the owner via the `owner` accessor.
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 `schedule` DSL
233
+ ### The `every` DSL
207
234
 
208
235
  Each generator can define one or more schedules:
209
236
 
210
237
  ```ruby
211
- schedule every: <period>, at: <conditions>, as: <name>, superseded_by: <names>
238
+ every <period>, at: <conditions>, as: <name>, superseded_by: <names>
212
239
  ```
213
240
 
214
241
  | Parameter | Required | Description |
215
242
  |-----------|----------|-------------|
216
- | `every:` | Yes | Any `ActiveSupport::Duration` — `1.hour`, `1.day`, `1.week`, `2.weeks`, `1.month`, `1.year`, `6.hours`, etc. |
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`, `:sunday`..`:saturday` | `weekday: :monday`, `weekday: :monday..:friday` |
230
- | `week:` | `1..53`, `:even`, `:odd` | `week: 42`, `week: :even` |
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
- schedule every: 1.day, at: { hour: 6 }, as: :daily
268
+ every :day, at: { hour: 6 }, as: :daily
240
269
 
241
270
  # Every Monday at 7 AM
242
- schedule every: 1.week, at: { hour: 7, weekday: :monday }, as: :weekly
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
- schedule every: 1.month, at: { hour: 8, day: 1 }, as: :monthly
277
+ every :month, at: { hour: 8, day: 1 }, as: :monthly
246
278
 
247
279
  # 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 }
280
+ every :year, at: { hour: 9, month: :january, day: 15 }, as: :annual
252
281
 
253
282
  # Weekdays only at 6 AM
254
- schedule every: 1.day, at: { hour: 6, weekday: :monday..:friday }
283
+ every :day, at: { hour: 6, weekday: :monday..:friday }
255
284
 
256
- # Multiple times per day
257
- schedule every: 1.day, at: { hour: [6, 12, 18] }
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
- schedule every: 1.month, at: { hour: 6, day: 1, month: :january..:march }
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
- 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
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 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.
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 checks `created_at > period.ago` scoped to the owner and feed type. No configuration needed.
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
- ### Owned but unscheduled
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
- Ad-hoc generators (no schedule) skip deduplication entirely each call creates a new feed.
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., `"cost_overview"`) |
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** Base class with auto-registration via Ruby's `inherited` hook. When you define `class MyGen < Feedkit::Generator`, it's automatically added to the registry.
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** Tracks all generator classes. Knows which are scheduled, which have owners, and which are due at any given time.
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** An ActiveJob that asks the registry "what's due right now?", then enqueues a `GenerateFeedJob` for each owner of each due generator.
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** An ActiveJob that instantiates a single generator for a single owner, calls `#data`, and persists the result as a `Feedkit::Feed` record.
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(owner, generator, period_name)
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` handles errors gracefully:
469
+ `GenerateFeedJob` logs errors and does not re-raise:
440
470
 
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
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** 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
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
- The project maintains high test coverage standards:
537
+ Coverage is enforced:
508
538
  - Line coverage: 100%
509
- - Branch coverage: 90%
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! Here's how you can help:
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: 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: period_name).call
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
@@ -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: 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) }
@@ -21,8 +21,14 @@ module Feedkit
21
21
  @owner_class
22
22
  end
23
23
 
24
- def feed_type
25
- name.demodulize.underscore.to_sym
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
- return if already_generated?
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 already_generated?
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: period_name)
69
- .exists?(["created_at > ?", period.ago])
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!(feed_type: self.class.feed_type, period_name: period_name, data: payload)
88
+ feed_scope.create!(attrs)
75
89
  else
76
- Feedkit::Feed.create!(feed_type: self.class.feed_type, period_name: period_name, data: payload)
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
@@ -43,7 +43,7 @@ module Feedkit
43
43
 
44
44
  dispatchable_generators.flat_map do |generator|
45
45
  generator.schedules_due(time).map do |schedule|
46
- { generator: generator, period_name: schedule.period_name }
46
+ { generator:, period_name: schedule.period_name }
47
47
  end
48
48
  end
49
49
  end
@@ -11,13 +11,21 @@ module Feedkit
11
11
  @schedules ||= []
12
12
  end
13
13
 
14
- def schedule(every:, at:, as: nil, superseded_by: [])
15
- schedules << Feedkit::Schedule.new(every: every, at: at, as: as, superseded_by: superseded_by)
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.map(&:period_name)
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