odsl 0.1.0 → 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: 3381098ae987f86e0112bd8dd2da9b3e8967f8f1ff877db7b3276fd4953c252a
4
- data.tar.gz: 4f80c98a9b2545b5b34b98a224cc09b4f079b9cb16160976fa89bd41e67547c4
3
+ metadata.gz: a5247ca88b029a8621f55ca52a4cfc376c8c4f170059a2e62edd714d2ced6a1d
4
+ data.tar.gz: a68b985769287f222931e2c2ca290e875275cefddfce53070b8c71ba148a88b0
5
5
  SHA512:
6
- metadata.gz: fc52a73349084832f0d5b33abf0cce2ae2dc66bfe69c4d1c7f56820b29c823a3ea75c66a79b6446e8104dc709ba18916f5e13ff172c3146633c74bd17aab27a5
7
- data.tar.gz: 83412e38868589940d872853ae46e7fe34f9954bc19e2c460eb7597a7c5511df591ff9747fd61720c38a7f42a5228f319b82ee8e9ad3d73ef0aac03259bd6821
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,75 +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
 
12
- ```
13
- module Game
14
- def start(game_type)
15
- @game = Struct.new(game_type: game_type, player: @player.name)
16
- self
17
- end
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
18
40
  end
41
+ ```
42
+
43
+ Each `.get(:var)` is a named checkpoint — recorded to the audit log, replayable on resume, and crystal clear in stack traces.
44
+
45
+ ---
19
46
 
47
+ ## Installation
48
+
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)
20
73
  module Player
74
+ attr_reader :player
75
+
21
76
  def with_player(player)
22
- @player = Struct.new(name: player)
77
+ @player = Struct.new(:name).new(player)
23
78
  self
24
79
  end
25
80
  end
26
81
 
27
82
  module Game
28
- module Crud
29
- def create
30
- build(Game, Players)
31
- .get(:game_object)
83
+ attr_reader :game
84
+
85
+ def start(game_type)
86
+ @game = Struct.new(:game_type, :player).new(game_type, @player.name)
87
+ self
88
+ end
89
+ end
32
90
 
33
- with(:args, :game_object)
34
- .set(:new_game)
35
- .get(:game_instance)
91
+ # Define your service
92
+ class GameService < Odsl::Dsl
93
+ include GameCrud
94
+ inmutable true
36
95
 
37
- finish_with(:game_instance)
38
- end
96
+ subscribe 'start_game', to: :create
97
+ end
39
98
 
40
- def delete(id)
41
- end
99
+ module GameCrud
100
+ def create
101
+ build(Game, Player)
102
+ .get(:game_object)
42
103
 
43
- private
104
+ with(:player, :game, :game_object)
105
+ .make(:new_game)
106
+ .get(:game_instance)
44
107
 
45
- def new_game(args, game_object)
108
+ with(:game_instance)
109
+ .instantiate(ScoreBoard)
110
+ .get(:score_board)
46
111
 
47
- game_object
48
- .with_player(args[:player])
49
- .start(args[:game_name])
50
- end
112
+ finish_with(:game_instance, :score_board)
51
113
  end
52
- end
53
-
54
- module Game
55
- class Service < Odsl:Dsl
56
- include Crud
57
114
 
58
- inmutable true
115
+ private
116
+
117
+ def new_game(player, game, game_object)
118
+ game_object
119
+ .with_player(player[:name])
120
+ .start(game)
59
121
  end
60
122
  end
123
+
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"
128
+
129
+ # Or via event dispatch
130
+ game, board = GameService.dispatch('start_game', player: { name: "alejandro" }, game: "chess")
131
+ ```
132
+
133
+ ---
134
+
135
+ ## DSL Reference
136
+
137
+ ### Chaining Methods
138
+
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` |
149
+
150
+ ### Running
151
+
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 |
157
+
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
175
+ end
176
+
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
202
+ end
203
+ ```
204
+
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.
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
219
+ ```
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">, ...]
227
+ ```
228
+
229
+ **Resume a failed run** from the first step that wasn't recorded:
230
+
231
+ ```ruby
232
+ # Process died after step 2 of 5 — resume picks up from step 3
233
+ result = OrderService.resume(key: "order-42")
234
+ ```
235
+
236
+ Completed steps are loaded from the database (no side effects re-run). Non-serializable results (complex objects) are re-executed.
237
+
238
+ **TTL & cleanup:**
239
+
240
+ ```ruby
241
+ Odsl.configure do |config|
242
+ config.audit_ttl = 7 * 24 * 3600 # expire records after 1 week
243
+ end
244
+
245
+ # Remove expired records
246
+ Odsl.cleanup_expired!
247
+
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
61
277
  ```
62
278
 
63
- # How to use call it.
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)
64
288
 
289
+ # ...
290
+ finish_with(:order)
291
+ end
65
292
  ```
66
- Games::Service
67
- .new(player: { name: 'alejandro' } , game: 'chez')
68
- .run(:create)
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')
69
312
  ```
70
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
+ ---
71
359
 
72
- # Future development
360
+ ## License
73
361
 
74
- .run_in_background()
75
- .run_in_parallel()
362
+ MIT — see [LICENSE.txt](LICENSE.txt).