odsl 0.1.1 → 0.3.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: b972b04076091e4880481f3120c36fc38d233dae65c68ed62cfb5089b5064978
4
- data.tar.gz: a974bbde6e1cc8e7c9fa9f6af823c0d7ded81e5f11ca0bbf2b69ac69adc17cc5
3
+ metadata.gz: a5247ca88b029a8621f55ca52a4cfc376c8c4f170059a2e62edd714d2ced6a1d
4
+ data.tar.gz: a68b985769287f222931e2c2ca290e875275cefddfce53070b8c71ba148a88b0
5
5
  SHA512:
6
- metadata.gz: efd5eaaee6252374c7bdf82c44479770d211e570e1daedea948285aaa51a2e121020361c585480be3728ee49bb0a93e2301a49bacc48276fbeeeea5cc7dfe9fa
7
- data.tar.gz: 63e3865d022968e0fae4e09c3c8fe5ccef63b7b0f1a46efcad11a18be34c50edd93d07edf55daf900cb0b1987017979f3f753d3b24b2f8c43a9a41fc22f505ab
6
+ metadata.gz: a9c50af3890a595878927066ef6bbceb44e20428b29e94b21624c0b57e1bd829ad31b5f3e5376f72b862fd20d3d4af27699341771113ef20207b10f9f9316ab5
7
+ data.tar.gz: 9d0d31d06e99681966c96099d970ae5d53181af59fe6606118b8645365e2e0146b578d14aa4d29110b05b412fbadd1f96cae201c9338579c8f84ca1d68ef9ad6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
1
8
  ## [Unreleased]
2
9
 
3
- ## [0.1.0] - 2023-07-03
10
+ ## [0.3.0] - 2026-10-10
11
+
12
+ ### Added
13
+ - Pluggable background adapters: `:thread` (default) and `:active_job`
14
+ - `run_in_background(:method)` and `run_in_parallel(*methods)` on service instances
15
+ - `Odsl::Configuration` with `background_adapter`, `audit_ttl`, `lock_redis_url`, `lock_ttl`
16
+ - `Odsl.configure {}` and `Odsl.config` with thread-safe access
17
+ - Step-level audit system: `Odsl::Audit::ExecutionContext` and `Odsl::Audit::StepRecord`
18
+ - Idempotent runs via `run(:method, key: "...")` — no-op if already completed
19
+ - `resume(key:)` class method — restarts a failed run from the first incomplete step
20
+ - TTL support for audit records via `audit_ttl` config option
21
+ - `Odsl.cleanup_expired!` and `rake odsl:cleanup` for pruning expired records
22
+ - Distributed locking via `lock(*vars)` DSL method (backed by [Redlock](https://github.com/leandromoreira/redlock-rb))
23
+ - `Odsl::LockError`, `Odsl::AlreadyRunningError`, `Odsl::ContextNotFoundError`
24
+ - Rails generator: `rails generate odsl:install` (migration + initializer)
25
+ - `Odsl::Railtie` for automatic Rake task registration in Rails apps
26
+ - `Odsl::Audit::Serializer` for JSON-safe step result persistence
27
+ - GitHub Actions CI across Ruby 3.1, 3.2, 3.3
28
+
29
+ ### Fixed
30
+ - Thread-safe configuration access via `CONFIG_MUTEX`
31
+ - `lib/odsl/error.rb` replaced with `lib/odsl/errors.rb` (syntax fix, plural naming)
32
+
33
+ ### Changed
34
+ - Minimum Ruby version remains `>= 3.1.0`
35
+ - Version bumped to `0.3.0`
36
+
37
+ ## [0.2.0] - 2024-01-01
38
+
39
+ ### Added
40
+ - `inmutable` mode
41
+ - `run_in_background` / `run_in_parallel`
42
+ - `subscribe` / `dispatch` event bus
43
+ - `finish_with` / `value_return` aliases
44
+ - `set` / `link` aliases for `make` / `call`
45
+
46
+ ## [0.1.0] - 2023-01-01
47
+
48
+ ### Added
49
+ - Initial DSL: `build`, `with`, `make`, `call`, `get`, `use`, `instantiate`, `finish_with`
50
+ - `Odsl::Dsl` base class
51
+ - `inmutable` class-level flag
52
+ - Minitest test suite
4
53
 
5
- - Initial release
54
+ [Unreleased]: https://github.com/aleguro/ODSL/compare/v0.3.0...HEAD
55
+ [0.3.0]: https://github.com/aleguro/ODSL/compare/v0.2.0...v0.3.0
56
+ [0.2.0]: https://github.com/aleguro/ODSL/compare/v0.1.0...v0.2.0
57
+ [0.1.0]: https://github.com/aleguro/ODSL/releases/tag/v0.1.0
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,40 @@
1
+ # Contributing to Odsl
2
+
3
+ Thanks for taking the time to contribute!
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ git clone https://github.com/aleguro/ODSL.git
9
+ cd ODSL
10
+ bundle install
11
+ ```
12
+
13
+ ## Running Tests
14
+
15
+ ```bash
16
+ bundle exec rake test
17
+ ```
18
+
19
+ The test suite uses an in-memory SQLite database — no external services required.
20
+
21
+ ## Adding a Feature
22
+
23
+ 1. Fork the repo and create a branch: `git checkout -b feat/your-feature`
24
+ 2. Write a failing test first
25
+ 3. Implement the feature
26
+ 4. Run `bundle exec rake test` — all tests must pass
27
+ 5. Update `README.md` if the public API changed
28
+ 6. Add an entry to `CHANGELOG.md` under `[Unreleased]`
29
+ 7. Open a PR against `master`
30
+
31
+ ## Code Style
32
+
33
+ - No unnecessary comments — let well-named identifiers speak
34
+ - No added error handling for scenarios that can't happen
35
+ - Prefer editing existing files over creating new ones
36
+ - Keep files focused; one clear responsibility per file
37
+
38
+ ## Reporting a Bug
39
+
40
+ Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) and include a minimal reproducible example.
data/README.md CHANGED
@@ -1,109 +1,362 @@
1
- # Object DSL
1
+ # Odsl
2
2
 
3
- This is a prototype oriented to use OOP and a functional programming approach by providing a DSL to decouple code logic from coding itself while enforcing good practices and providing:
3
+ [![Gem Version](https://badge.fury.io/rb/odsl.svg)](https://badge.fury.io/rb/odsl)
4
+ [![CI](https://github.com/aleguro/ODSL/actions/workflows/ci.yml/badge.svg)](https://github.com/aleguro/ODSL/actions/workflows/ci.yml)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
7
+ [![Maintainability](https://img.shields.io/badge/maintainability-A-brightgreen)](https://github.com/aleguro/ODSL)
8
+ [![RubyGems MFA](https://img.shields.io/badge/rubygems%20MFA-enabled-green)](https://guides.rubygems.org/mfa-requirement-opt-in/)
4
9
 
5
- - Design patterns
6
- - Inmutability
7
- - Dry off
8
- - Decouple aspects from code ( background, paralelization )
10
+ > **O**bject **D**SL — a fluent, chainable framework for writing service objects in Ruby that are easy to read, test, and extend.
9
11
 
10
- # Your code
12
+ Odsl lets you express complex business workflows as a sequence of named steps — composing objects, calling methods, and storing intermediate results — without the sprawling `def call` anti-pattern. Every service is immutable by default, background-capable, audit-ready, and optionally protected by distributed locking.
11
13
 
14
+ ---
15
+
16
+ ## Why Odsl?
17
+
18
+ Most Ruby service objects end up as a `call` method with a hundred lines of code, private helpers tangled with side effects, and zero insight into which step failed when something goes wrong.
19
+
20
+ Odsl solves this by making each step **named**, **recorded**, and **resumable**:
21
+
22
+ ```ruby
23
+ def create_order
24
+ build(Inventory, Pricing) # compose a scoped object
25
+ .get(:catalog) # store it as @catalog
26
+
27
+ with(:catalog, :items, :user)
28
+ .make(:calculate_total) # call your private method
29
+ .get(:order_total) # store the result
30
+
31
+ with(:user, :order_total)
32
+ .instantiate(Order) # build an AR model
33
+ .get(:order)
34
+
35
+ with(:order)
36
+ .make(:charge_customer)
37
+ .get(:payment)
38
+
39
+ finish_with(:order, :payment) # declare what to return
40
+ end
12
41
  ```
13
- class ScoreBoard
14
42
 
15
- def initialize(game_object)
16
- @game, @player = game_object.game, game_object.player
17
- end
43
+ Each `.get(:var)` is a named checkpoint — recorded to the audit log, replayable on resume, and crystal clear in stack traces.
44
+
45
+ ---
46
+
47
+ ## Installation
18
48
 
19
- def publish
20
- STDOUT.puts "Player: @player.name is playing: #{@game.game_type}"
49
+ ```ruby
50
+ # Gemfile
51
+ gem "odsl"
52
+
53
+ # Optional: background jobs
54
+ gem "activejob"
55
+
56
+ # Optional: distributed locking
57
+ gem "redlock"
58
+ ```
59
+
60
+ For audit tables (Rails):
61
+
62
+ ```bash
63
+ rails generate odsl:install
64
+ rails db:migrate
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Quick Start
70
+
71
+ ```ruby
72
+ # Define your modules (plain Ruby — no inheritance required)
73
+ module Player
74
+ attr_reader :player
75
+
76
+ def with_player(player)
77
+ @player = Struct.new(:name).new(player)
78
+ self
21
79
  end
22
80
  end
23
81
 
24
82
  module Game
83
+ attr_reader :game
84
+
25
85
  def start(game_type)
26
- @game = Struct.new(game_type: game_type, player: @player.name)
86
+ @game = Struct.new(:game_type, :player).new(game_type, @player.name)
27
87
  self
28
88
  end
29
89
  end
30
90
 
31
- module Player
32
- def with_player(player)
33
- @player = Struct.new(name: player)
34
- self
91
+ # Define your service
92
+ class GameService < Odsl::Dsl
93
+ include GameCrud
94
+ inmutable true
95
+
96
+ subscribe 'start_game', to: :create
97
+ end
98
+
99
+ module GameCrud
100
+ def create
101
+ build(Game, Player)
102
+ .get(:game_object)
103
+
104
+ with(:player, :game, :game_object)
105
+ .make(:new_game)
106
+ .get(:game_instance)
107
+
108
+ with(:game_instance)
109
+ .instantiate(ScoreBoard)
110
+ .get(:score_board)
111
+
112
+ finish_with(:game_instance, :score_board)
113
+ end
114
+
115
+ private
116
+
117
+ def new_game(player, game, game_object)
118
+ game_object
119
+ .with_player(player[:name])
120
+ .start(game)
35
121
  end
36
122
  end
37
123
 
38
- module Game
39
- module Crud
40
- def create
41
- build(Game, Players)
42
- .get(:game_object)
124
+ # Run it
125
+ game, board = GameService.new(player: { name: "alejandro" }, game: "chess").run(:create)
126
+ game.game.game_type # => "chess"
127
+ game.game.player # => "alejandro"
43
128
 
44
- with(:args, :game_object)
45
- .set(:new_game)
46
- .get(:game_instance)
129
+ # Or via event dispatch
130
+ game, board = GameService.dispatch('start_game', player: { name: "alejandro" }, game: "chess")
131
+ ```
47
132
 
48
- with(:game_object)
49
- .instanciate(ScoreBoard)
50
- .get(:score_board_object)
133
+ ---
51
134
 
52
- with(:score_board_object)
53
- .call(:publish)
135
+ ## DSL Reference
54
136
 
55
- finish_with(:game_instance)
56
- end
137
+ ### Chaining Methods
57
138
 
58
- def delete(id)
59
- end
139
+ | Method | Alias(es) | Description |
140
+ |--------|-----------|-------------|
141
+ | `build(*modules)` | — | Composes a new object by mixing modules into an anonymous class |
142
+ | `with(*attrs)` | — | Sets the arguments passed to the next `make`/`call`/`instantiate` |
143
+ | `make(:method)` | `set`, `link` | Calls a private method with the current `with` attrs |
144
+ | `call(:method)` | — | Calls a method on the currently delegated object |
145
+ | `get(*vars)` | `to` | Stores last result(s) into named instance variables |
146
+ | `use(:object)` | — | Points delegation to the object in `@object` |
147
+ | `instantiate(Class)` | — | Instantiates a class using the current `with` attrs |
148
+ | `finish_with(*vars)` | `value_return` | Declares the return value(s) of `run` |
60
149
 
61
- private
150
+ ### Running
62
151
 
63
- def new_game(args, game_object)
152
+ | Method | Description |
153
+ |--------|-------------|
154
+ | `instance.run(:method)` | Executes a method, returns `finish_with` values |
155
+ | `instance.execute(:method)` | Alias for `run` |
156
+ | `MyService.resume(key:)` | Resumes a failed run from the first incomplete step |
64
157
 
65
- game_object
66
- .with_player(args[:player])
67
- .start(args[:game_name])
68
- end
69
- end
158
+ ### Class-Level DSL
159
+
160
+ | Method | Description |
161
+ |--------|-------------|
162
+ | `inmutable true` | Each `run` operates on a fresh dup — no shared state |
163
+ | `subscribe 'event', to: :method` | Maps a string event name to a method |
164
+ | `dispatch('event', **kwargs)` | Finds the subscription and runs it |
165
+
166
+ ---
167
+
168
+ ## Features
169
+
170
+ ### Immutability
171
+
172
+ ```ruby
173
+ class ReportService < Odsl::Dsl
174
+ inmutable true
70
175
  end
71
-
72
- module Game
73
- class Service < Odsl:Dsl
74
- include Crud
75
176
 
76
- inmutable true
77
- end
177
+ service = ReportService.new(year: 2024)
178
+ result1 = service.run(:generate)
179
+ result2 = service.run(:generate) # identical — original service is untouched
180
+ ```
181
+
182
+ When `inmutable true` is set, every `run` works on a `dup` of the instance — your kwargs and context are never mutated.
183
+
184
+ ---
185
+
186
+ ### Background Jobs
187
+
188
+ ```ruby
189
+ # Fire and forget (returns a Thread by default)
190
+ thread = service.run_in_background(:process)
191
+ result = thread.value # wait for it
192
+
193
+ # Run multiple steps in parallel
194
+ game1, game2 = service.run_in_parallel(:create, :create)
195
+ ```
196
+
197
+ Switch adapters as your stack grows:
198
+
199
+ ```ruby
200
+ Odsl.configure do |config|
201
+ config.background_adapter = :active_job # delegates to your configured queue backend
78
202
  end
79
203
  ```
80
204
 
81
- # How to use call it.
205
+ With `:active_job`, Odsl enqueues an `Odsl::OdslJob` that reconstructs the service from its kwargs and calls the method. Works with Sidekiq, Solid Queue, Good Job, and any other ActiveJob backend.
82
206
 
207
+ ---
208
+
209
+ ### Audit & Idempotency
210
+
211
+ Every `run` can be made idempotent by passing a `key:`. Odsl records an `ExecutionContext` and a `StepRecord` per `.get(:var)` checkpoint.
212
+
213
+ ```ruby
214
+ # Won't re-run if already completed with this key
215
+ service.run(:process_order, key: "order-#{order.id}")
216
+
217
+ # A second call with the same key returns nil immediately
218
+ service.run(:process_order, key: "order-#{order.id}") # => nil
83
219
  ```
84
- Games::Service
85
- .new(player: { name: 'alejandro' } , game: 'chez')
86
- .run(:create)
220
+
221
+ **Step-level audit** means you can see exactly which steps completed and what their results were:
222
+
223
+ ```ruby
224
+ ctx = Odsl::Audit::ExecutionContext.find_by(idempotency_key: "order-42")
225
+ ctx.status # => "completed"
226
+ ctx.step_records # => [#<StepRecord name="catalog">, #<StepRecord name="order_total">, ...]
87
227
  ```
88
228
 
89
- # Future development
229
+ **Resume a failed run** from the first step that wasn't recorded:
90
230
 
91
- - Run in background mode
231
+ ```ruby
232
+ # Process died after step 2 of 5 — resume picks up from step 3
233
+ result = OrderService.resume(key: "order-42")
234
+ ```
92
235
 
93
- ```.run_in_background(:method)```
94
-
95
- - Run sevreral methods in paralell
236
+ Completed steps are loaded from the database (no side effects re-run). Non-serializable results (complex objects) are re-executed.
96
237
 
97
- ```.run_in_parallel(:method_a, :method_b)```
238
+ **TTL & cleanup:**
98
239
 
99
- - Make service objects subscribe to messaages
240
+ ```ruby
241
+ Odsl.configure do |config|
242
+ config.audit_ttl = 7 * 24 * 3600 # expire records after 1 week
243
+ end
100
244
 
101
- module Game
102
- class Service < Odsl:Dsl
103
- include Crud
245
+ # Remove expired records
246
+ Odsl.cleanup_expired!
104
247
 
105
- subscribe 'start_game', to: :create
106
- subscribe 'end_game', to: end_game
107
- end
248
+ # Or with Rake
249
+ rake odsl:cleanup
250
+ ```
251
+
252
+ **Rails setup:**
253
+
254
+ ```bash
255
+ rails generate odsl:install
256
+ rails db:migrate
257
+ ```
258
+
259
+ Generates `config/initializers/odsl.rb` and a migration for `odsl_execution_contexts` + `odsl_step_records`.
260
+
261
+ ---
262
+
263
+ ### Distributed Locking
264
+
265
+ Prevent double-execution when users submit the same action from two browser tabs simultaneously.
266
+
267
+ ```ruby
268
+ # Gemfile
269
+ gem "redlock"
270
+ ```
271
+
272
+ ```ruby
273
+ Odsl.configure do |config|
274
+ config.lock_redis_url = ENV["REDIS_URL"]
275
+ config.lock_ttl = 10_000 # ms — how long to hold the lock
276
+ end
277
+ ```
278
+
279
+ Call `lock` at the top of any business method:
280
+
281
+ ```ruby
282
+ def process_order
283
+ lock(:order_id) # key = "OrderService:42"
284
+
285
+ with(:order_id)
286
+ .make(:fetch_order)
287
+ .get(:order)
288
+
289
+ # ...
290
+ finish_with(:order)
108
291
  end
109
- ```
292
+ ```
293
+
294
+ `lock` builds the key from the service class name + the string values of the given instance variables. If the lock is already held, `Odsl::LockError` is raised immediately (fail-fast — no waiting). The lock is released automatically in the `ensure` block.
295
+
296
+ `lock` is a **no-op** when `lock_redis_url` is not configured, so the same code works in development without Redis.
297
+
298
+ ---
299
+
300
+ ### Event Bus
301
+
302
+ ```ruby
303
+ class GameService < Odsl::Dsl
304
+ include Crud
305
+
306
+ subscribe 'game.start', to: :create
307
+ subscribe 'game.finish', to: :end_game
308
+ end
309
+
310
+ # Dispatch by event name
311
+ result = GameService.dispatch('game.start', player: { name: 'alejandro' }, game: 'chess')
312
+ ```
313
+
314
+ Useful for decoupling event producers from the service implementation — the event string is the contract, not the method name.
315
+
316
+ ---
317
+
318
+ ## Configuration Reference
319
+
320
+ ```ruby
321
+ Odsl.configure do |config|
322
+ # :thread (default) or :active_job
323
+ config.background_adapter = :thread
324
+
325
+ # Seconds to keep audit records. nil = never expire.
326
+ config.audit_ttl = nil
327
+
328
+ # Redis URL for Redlock. nil = locking disabled (no-op).
329
+ config.lock_redis_url = nil
330
+
331
+ # Distributed lock TTL in milliseconds.
332
+ config.lock_ttl = 10_000
333
+ end
334
+ ```
335
+
336
+ ---
337
+
338
+ ## Error Reference
339
+
340
+ | Error | When raised |
341
+ |-------|-------------|
342
+ | `Odsl::LockError` | Distributed lock couldn't be acquired |
343
+ | `Odsl::AlreadyRunningError` | Idempotency key is currently in a `running` state |
344
+ | `Odsl::ContextNotFoundError` | `resume(key:)` called for an unknown key |
345
+
346
+ ---
347
+
348
+ ## Contributing
349
+
350
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, code style, and the PR checklist.
351
+
352
+ ---
353
+
354
+ ## Changelog
355
+
356
+ See [CHANGELOG.md](CHANGELOG.md) for a full history of releases.
357
+
358
+ ---
359
+
360
+ ## License
361
+
362
+ MIT — see [LICENSE.txt](LICENSE.txt).