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 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
+ [![Gem Version](https://badge.fury.io/rb/feedkit.svg)](https://badge.fury.io/rb/feedkit)
4
+ [![Build Status](https://github.com/milkstrawai/feedkit/actions/workflows/main.yml/badge.svg)](https://github.com/milkstrawai/feedkit/actions)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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