dexkit 0.6.0 → 0.7.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: f8d7274727e727937b55704faa81e4da8a54bb3af7758b11e9144e59fb2dba14
4
- data.tar.gz: cb899ee88b5e83ec57a913f0173036ac228f672c7850d88e95209f4804218f96
3
+ metadata.gz: 34e5457c2e95efc96b2350babe7d7059854caa42ce6da92087e003f76acfb6d8
4
+ data.tar.gz: cf0466311827f2706fe883167b88d27a2571029c029777a1a5f37e0f44ae080a
5
5
  SHA512:
6
- metadata.gz: 7758fe2c0d5b9cdd2bcf62aa510c4146ef29898335ddffb118abb35409367d5f7cbd1aeba6365f865bb36d4011b1030f136cfb9b6eee27fb3aa3d06d11eeb21e
7
- data.tar.gz: e437a4af9b4aa0c121245de966775cd3be0bad5ceb5918f139cfaf059545159631797b630a9b21f3add8855308c832f5846efb78f97f36141042f40d28b32341
6
+ metadata.gz: 3885a4464112b937aa88bd2661ad03aca35af5128ba9b292c7345fccd69bbd8c53a363d5f1805c0c2173c097ff06f8f98ec7fc657115fd530b84a910af9b5759
7
+ data.tar.gz: 25ce509ae466f259b102b4eedb8d828eb27435d6223bd052c87682b60b43584a6ccaf223ed0bec3f2141988fadebaac4638a204c4025554870e70c389ac9d4e2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-03-08
4
+
5
+ ### Breaking
6
+
7
+ - **Operation record schema refactored** — the `response` column is renamed to `result`, the `error` column is split into `error_code`, `error_message`, and `error_details`, `params` no longer has `default: {}` (nil means "not captured"), and `status` is now `null: false`. The `record response: false` DSL option is now `record result: false`. Status value `done` is renamed to `completed`, and a new `error` status represents business errors via `error!`
8
+ - **All outcomes now recorded** — previously, only successful operations were recorded in the sync path. Now business errors (`error!`) record with status `error` and populate `error_code`/`error_message`/`error_details`, and unhandled exceptions record with status `failed`
9
+ - **Recording moved outside transaction** — operation records are now persisted outside the database transaction, so error and failure records survive rollbacks. Previously, records were created inside the transaction and would be rolled back alongside the operation's side effects
10
+ - **Pipeline order changed** — `RecordWrapper` now runs before `TransactionWrapper` (was after). The pipeline order is now: result → once → lock → record → transaction → rescue → guard → callback
11
+ - **`Operation.contract` shape changed** — `Contract` gains a fourth field `:guards`. Code using positional destructuring (`in Contract[params, success, errors]`) must be updated to include the new field. Keyword-based access (`.params`, `.errors`, etc.) is unaffected
12
+
13
+ ### Added
14
+
15
+ - **Ambient context** — `Dex.with_context(current_user: user) { ... }` sets fiber-local ambient state. The `context` DSL on operations and events maps props to ambient keys, auto-filling them when not passed explicitly. Explicit kwargs always win. Works with guards (`callable?`), events (captured at publish time), and nested operations. Introspection via `context_mappings`
16
+ - **`guard` DSL for precondition checks** — named, inline preconditions that detect threats (conditions under which the operation should not proceed). Guards auto-declare error codes, support dependencies (`requires:`), collect all independent failures, and skip dependent guards when a dependency fails. `callable?` and `callable` class methods check guards without running `perform` – useful for UI show/hide, disabled buttons with reasons, and API pre-validation. Contract introspection via `contract.guards`. Test helpers: `assert_callable`, `refute_callable`
17
+ - **`once` DSL for operation idempotency** — ensures an operation executes at most once for a given key, replaying stored results on subsequent calls. Supports prop-based keys (`once :order_id`), composite keys, block-based custom keys, call-site keys (`.once("key")`), optional expiry (`expires_in:`), and `clear_once!` for key management. Business errors are replayed; exceptions release the key for retry. Works with `.safe.call` and `.async.call`
18
+ - **`error_code`, `error_message`, `error_details` columns** — structured error recording replaces the single `error` string column
19
+ - **Recommended indexes** — `name`, `status`, `[:name, :status]` composite index, and unique partial index on `once_key` in the migration schema
20
+
3
21
  ## [0.6.0] - 2026-03-07
4
22
 
5
23
  ### Added
data/README.md CHANGED
@@ -69,6 +69,69 @@ end
69
69
  Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
70
70
  ```
71
71
 
72
+ **Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
73
+
74
+ ```ruby
75
+ class Payment::Charge < Dex::Operation
76
+ prop :order_id, Integer
77
+ prop :amount, Integer
78
+
79
+ once :order_id # key from prop
80
+ # once :order_id, :merchant_id # composite key
81
+ # once # all props as key
82
+ # once { "custom-#{order_id}" } # block-based key
83
+ # once :order_id, expires_in: 24.hours # expiring key
84
+
85
+ def perform
86
+ Gateway.charge!(order_id, amount)
87
+ end
88
+ end
89
+
90
+ # Call-site key (overrides class-level declaration)
91
+ Payment::Charge.new(order_id: 1, amount: 500).once("ext-key-123").call
92
+
93
+ # Bypass once guard for a single call
94
+ Payment::Charge.new(order_id: 1, amount: 500).once(nil).call
95
+
96
+ # Clear a stored key to allow re-execution
97
+ Payment::Charge.clear_once!(order_id: 1)
98
+ ```
99
+
100
+ Business errors are replayed; exceptions release the key so the operation can be retried. Requires the record backend (recording is enabled by default when `record_class` is configured).
101
+
102
+ **Guards** – inline precondition checks with introspection. Ask "can this operation run?" from views and controllers:
103
+
104
+ ```ruby
105
+ guard :out_of_stock, "Product must be in stock" do
106
+ !product.in_stock?
107
+ end
108
+
109
+ # In a view or controller:
110
+ Order::Place.callable?(customer: customer, product: product, quantity: 1)
111
+ ```
112
+
113
+ **Ambient context** – declare which props come from ambient state. Set once in a controller, auto-fill everywhere:
114
+
115
+ ```ruby
116
+ class Order::Place < Dex::Operation
117
+ prop :product, _Ref(Product)
118
+ prop :customer, _Ref(Customer)
119
+ context customer: :current_customer # filled from Dex.context[:current_customer]
120
+
121
+ def perform
122
+ Order.create!(product: product, customer: customer)
123
+ end
124
+ end
125
+
126
+ # Controller
127
+ Dex.with_context(current_customer: current_customer) do
128
+ Order::Place.call(product: product) # customer auto-filled
129
+ end
130
+
131
+ # Tests – just pass it explicitly
132
+ Order::Place.call(product: product, customer: customer)
133
+ ```
134
+
72
135
  **Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
73
136
 
74
137
  ### Testing
data/guides/llm/EVENT.md CHANGED
@@ -235,21 +235,43 @@ Persistence failures are silently rescued — they never halt event publishing.
235
235
 
236
236
  ---
237
237
 
238
- ## Context (Optional)
238
+ ## Ambient Context
239
239
 
240
- Capture ambient context (current user, tenant, etc.) at publish time:
240
+ Events use the same `context` DSL as operations. Context-mapped props are captured at **publish time** and stored as regular props on the event — handlers don't need ambient context, they read from the event.
241
241
 
242
242
  ```ruby
243
- Dex.configure do |c|
244
- c.event_context = -> { { user_id: Current.user&.id, tenant: Current.tenant } }
245
- c.restore_event_context = ->(ctx) {
246
- Current.user = User.find(ctx["user_id"]) if ctx["user_id"]
247
- Current.tenant = ctx["tenant"]
248
- }
243
+ class Order::Placed < Dex::Event
244
+ prop :order_id, Integer
245
+ prop :customer, _Ref(Customer)
246
+ context customer: :current_customer # resolved at publish time
247
+ end
248
+
249
+ # In a controller with Dex.with_context(current_customer: customer):
250
+ Order::Placed.publish(order_id: 1) # customer auto-filled from context
251
+
252
+ # Or pass explicitly:
253
+ Order::Placed.publish(order_id: 1, customer: customer)
254
+ ```
255
+
256
+ Handlers receive the event with everything already set — no `context` needed on handlers:
257
+
258
+ ```ruby
259
+ class AuditTrail < Dex::Event::Handler
260
+ on Order::Placed
261
+
262
+ def perform
263
+ AuditLog.create!(customer: event.customer, action: "placed", order_id: event.order_id)
264
+ end
249
265
  end
250
266
  ```
251
267
 
252
- Context is stored in event metadata and restored before async handler execution.
268
+ **Resolution order:** explicit kwarg ambient context prop default TypeError.
269
+
270
+ **Introspection:** `MyEvent.context_mappings` returns the mapping hash.
271
+
272
+ ### Legacy Context (Metadata)
273
+
274
+ The older `event_context` / `restore_event_context` configuration captures arbitrary metadata at publish time and restores it before async handler execution. Both mechanisms coexist.
253
275
 
254
276
  ---
255
277
 
@@ -11,7 +11,7 @@ All examples below build on this operation unless noted otherwise:
11
11
  ```ruby
12
12
  class CreateUser < Dex::Operation
13
13
  prop :email, String
14
- prop :name, String
14
+ prop :name, String
15
15
  prop? :role, _Union("admin", "member"), default: "member"
16
16
 
17
17
  success _Ref(User)
@@ -36,9 +36,10 @@ end
36
36
  CreateUser.call(email: "a@b.com", name: "Alice") # shorthand for new(...).call
37
37
  CreateUser.new(email: "a@b.com", name: "Alice").safe.call # Ok/Err wrapper
38
38
  CreateUser.new(email: "a@b.com", name: "Alice").async.call # background job
39
+ CreateUser.new(email: "a@b.com", name: "Alice").once("key").call # call-site idempotency
39
40
  ```
40
41
 
41
- Use `new(...)` form when chaining modifiers (`.safe`, `.async`).
42
+ Use `new(...)` form when chaining modifiers (`.safe`, `.async`, `.once`).
42
43
 
43
44
  ---
44
45
 
@@ -63,33 +64,33 @@ Types use `===` for validation. All constructors available in the operation clas
63
64
  | `_Ref(Model)` | Model reference | `_Ref(User)`, `_Ref(Account, lock: true)` |
64
65
 
65
66
  ```ruby
66
- prop :name, String # any String
67
- prop :count, Integer # any Integer
68
- prop :amount, Float # any Float
69
- prop :amount, BigDecimal # any BigDecimal
70
- prop :data, Hash # any Hash
71
- prop :items, Array # any Array
72
- prop :active, _Boolean # true or false
73
- prop :role, Symbol # any Symbol
74
- prop :count, _Integer(1..) # Integer >= 1
75
- prop :count, _Integer(0..100) # Integer 0–100
76
- prop :name, _String(length: 1..255) # String with length constraint
77
- prop :score, _Float(0.0..1.0) # Float in range
78
- prop :tags, _Array(String) # Array of Strings
79
- prop :ids, _Array(Integer) # Array of Integers
80
- prop :matrix, _Array(_Array(Integer)) # nested typed arrays
67
+ prop :name, String # any String
68
+ prop :count, Integer # any Integer
69
+ prop :amount, Float # any Float
70
+ prop :amount, BigDecimal # any BigDecimal
71
+ prop :data, Hash # any Hash
72
+ prop :items, Array # any Array
73
+ prop :active, _Boolean # true or false
74
+ prop :role, Symbol # any Symbol
75
+ prop :count, _Integer(1..) # Integer >= 1
76
+ prop :count, _Integer(0..100) # Integer 0–100
77
+ prop :name, _String(length: 1..255) # String with length constraint
78
+ prop :score, _Float(0.0..1.0) # Float in range
79
+ prop :tags, _Array(String) # Array of Strings
80
+ prop :ids, _Array(Integer) # Array of Integers
81
+ prop :matrix, _Array(_Array(Integer)) # nested typed arrays
81
82
  prop :currency, _Union("USD", "EUR", "GBP") # enum of values
82
- prop :id, _Union(String, Integer) # union of types
83
- prop :label, _Nilable(String) # String or nil
84
- prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
85
- prop :pair, _Tuple(String, Integer) # fixed-size typed array
86
- prop :name, _Frozen(String) # must be frozen
87
- prop :handler, _Callable # anything responding to .call
88
- prop :handler, _Interface(:call, :arity) # responds to listed methods
89
- prop :user, _Ref(User) # Dex-specific: model by instance or ID
90
- prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
91
- prop :title, String, default: "Untitled" # default value
92
- prop? :note, String # optional (nilable, default: nil)
83
+ prop :id, _Union(String, Integer) # union of types
84
+ prop :label, _Nilable(String) # String or nil
85
+ prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
86
+ prop :pair, _Tuple(String, Integer) # fixed-size typed array
87
+ prop :name, _Frozen(String) # must be frozen
88
+ prop :handler, _Callable # anything responding to .call
89
+ prop :handler, _Interface(:call, :arity) # responds to listed methods
90
+ prop :user, _Ref(User) # Dex-specific: model by instance or ID
91
+ prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
92
+ prop :title, String, default: "Untitled" # default value
93
+ prop? :note, String # optional (nilable, default: nil)
93
94
  ```
94
95
 
95
96
  ### _Ref(Model)
@@ -118,7 +119,118 @@ error :email_taken, :invalid_email
118
119
 
119
120
  Both inherit from parent class. Without `error` declaration, any code is accepted.
120
121
 
121
- **Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors` fields. Supports pattern matching and `to_h`.
122
+ **Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors`, `guards` fields. Supports pattern matching and `to_h`.
123
+
124
+ ---
125
+
126
+ ## Ambient Context
127
+
128
+ Map props to ambient context keys so they auto-fill from `Dex.with_context` when not passed explicitly. Explicit kwargs always win.
129
+
130
+ ```ruby
131
+ class Order::Place < Dex::Operation
132
+ prop :product, _Ref(Product)
133
+ prop :customer, _Ref(Customer)
134
+ prop :locale, Symbol
135
+
136
+ context customer: :current_customer # prop :customer ← Dex.context[:current_customer]
137
+ context :locale # shorthand: prop :locale ← Dex.context[:locale]
138
+ end
139
+ ```
140
+
141
+ **Setting context** (controller, middleware):
142
+
143
+ ```ruby
144
+ Dex.with_context(current_customer: customer, locale: I18n.locale) do
145
+ Order::Place.call(product: product) # customer + locale auto-filled
146
+ end
147
+ ```
148
+
149
+ **Resolution order:** explicit kwarg → ambient context → prop default → TypeError (if required).
150
+
151
+ **In tests** — just pass everything explicitly. No `Dex.with_context` needed:
152
+
153
+ ```ruby
154
+ Order::Place.call(product: product, customer: customer, locale: :en)
155
+ ```
156
+
157
+ **Nesting** supported — inner blocks merge with outer, restore on exit. Nested operations inherit the same ambient context.
158
+
159
+ **Works with guards** — context-mapped props are available in guard blocks and `callable?`:
160
+
161
+ ```ruby
162
+ Dex.with_context(current_customer: customer) do
163
+ Order::Place.callable?(product: product)
164
+ end
165
+ ```
166
+
167
+ **Works with optional props** (`prop?`) — if ambient context has the key, it fills in. If not, the prop is nil.
168
+
169
+ **Introspection:** `MyOp.context_mappings` returns `{ customer: :current_customer, locale: :locale }`.
170
+
171
+ **DSL validation:** `context user: :current_user` raises `ArgumentError` if no `prop :user` has been declared. Context declarations must come after the props they reference.
172
+
173
+ ---
174
+
175
+ ## Guards
176
+
177
+ Inline precondition checks. The guard name is the error code, the block detects the **threat** (truthy = threat detected = operation fails):
178
+
179
+ ```ruby
180
+ class PublishPost < Dex::Operation
181
+ prop :post, _Ref(Post)
182
+ prop :user, _Ref(User)
183
+
184
+ guard :unauthorized, "Only the author or admins can publish" do
185
+ !user.admin? && post.author != user
186
+ end
187
+
188
+ guard :already_published, "Post must be in draft state" do
189
+ post.published?
190
+ end
191
+
192
+ def perform
193
+ post.update!(published_at: Time.current)
194
+ post
195
+ end
196
+ end
197
+ ```
198
+
199
+ - Guards run in declaration order, before `perform`, after `rescue`
200
+ - All independent guards run – failures are collected, not short-circuited
201
+ - Guard names are auto-declared as error codes (no separate `error :unauthorized` needed)
202
+ - Same error code usable with `error!` in `perform`
203
+
204
+ **Dependencies:** skip dependent guards when a dependency fails:
205
+
206
+ ```ruby
207
+ guard :missing_author, "Author must be present" do
208
+ author.blank?
209
+ end
210
+
211
+ guard :unpaid_author, "Author must be a paid subscriber", requires: :missing_author do
212
+ author.free_plan?
213
+ end
214
+ ```
215
+
216
+ If author is nil: only `:missing_author` is reported. `:unpaid_author` is skipped.
217
+
218
+ **Introspection** – check guards without running `perform`:
219
+
220
+ ```ruby
221
+ PublishPost.callable?(post: post, user: user) # => true/false
222
+ PublishPost.callable?(:unauthorized, post: post, user: user) # check specific guard
223
+ result = PublishPost.callable(post: post, user: user) # => Ok or Err with details
224
+ result.details # => [{ guard: :unauthorized, message: "..." }, ...]
225
+ ```
226
+
227
+ `callable` bypasses the pipeline – no locks, transactions, recording, or callbacks. Cheap and side-effect-free.
228
+
229
+ **Contract:** `contract.guards` returns guard metadata. `contract.errors` includes guard codes.
230
+
231
+ **Inheritance:** parent guards run first, child guards appended.
232
+
233
+ **DSL validation:** code must be Symbol, block required, `requires:` must reference previously declared guards, duplicates raise `ArgumentError`.
122
234
 
123
235
  ---
124
236
 
@@ -154,13 +266,13 @@ begin
154
266
  CreateUser.call(email: "bad", name: "A")
155
267
  rescue Dex::Error => e
156
268
  case e
157
- in {code: :not_found} then handle_not_found
158
- in {code: :validation_failed, details: {field:}} then handle_field(field)
269
+ in { code: :not_found } then handle_not_found
270
+ in { code: :validation_failed, details: { field: } } then handle_field(field)
159
271
  end
160
272
  end
161
273
  ```
162
274
 
163
- **Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks and recording. `success!` commits, runs `after` callbacks, records normally.
275
+ **Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks, but are still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
164
276
 
165
277
  ---
166
278
 
@@ -187,7 +299,7 @@ result.value! # re-raises Dex::Error
187
299
 
188
300
  ```ruby
189
301
  case CreateUser.new(email: "a@b.com", name: "Alice").safe.call
190
- in Dex::Ok(name:) then puts "Created #{name}"
302
+ in Dex::Ok(name:) then puts "Created #{name}"
191
303
  in Dex::Err(code: :email_taken) then puts "Already exists"
192
304
  end
193
305
  ```
@@ -202,10 +314,10 @@ Map exceptions to structured `Dex::Error` codes — eliminates begin/rescue boil
202
314
 
203
315
  ```ruby
204
316
  class ChargeCard < Dex::Operation
205
- rescue_from Stripe::CardError, as: :card_declined
206
- rescue_from Stripe::RateLimitError, as: :rate_limited
317
+ rescue_from Stripe::CardError, as: :card_declined
318
+ rescue_from Stripe::RateLimitError, as: :rate_limited
207
319
  rescue_from Net::OpenTimeout, Net::ReadTimeout, as: :timeout
208
- rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
320
+ rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
209
321
 
210
322
  def perform
211
323
  Stripe::Charge.create(amount: amount, source: token)
@@ -226,7 +338,7 @@ end
226
338
  class ProcessOrder < Dex::Operation
227
339
  before :validate_stock # symbol → instance method
228
340
  before -> { log("starting") } # lambda (instance_exec'd)
229
- after :send_confirmation # runs after successful perform
341
+ after :send_confirmation # runs after successful perform
230
342
  around :with_timing # wraps everything, must yield
231
343
 
232
344
  def validate_stock
@@ -327,27 +439,99 @@ Record execution to database. Requires `Dex.configure { |c| c.record_class = Ope
327
439
 
328
440
  ```ruby
329
441
  create_table :operation_records do |t|
330
- t.string :name # Required: operation class name
331
- t.jsonb :params # Optional: serialized props
332
- t.jsonb :response # Optional: serialized result
333
- t.string :status # Optional: pending/running/done/failed (for async)
334
- t.string :error # Optional: error code on failure
335
- t.datetime :performed_at # Optional
442
+ t.string :name, null: false # operation class name
443
+ t.jsonb :params # serialized props (nil = not captured)
444
+ t.jsonb :result # serialized return value
445
+ t.string :status, null: false # pending/running/completed/error/failed
446
+ t.string :error_code # Dex::Error code or exception class
447
+ t.string :error_message # human-readable message
448
+ t.jsonb :error_details # structured details hash
449
+ t.string :once_key # idempotency key (used by `once`)
450
+ t.datetime :once_key_expires_at # key expiry (used by `once`)
451
+ t.datetime :performed_at # execution completion timestamp
336
452
  t.timestamps
337
453
  end
454
+
455
+ add_index :operation_records, :name
456
+ add_index :operation_records, :status
457
+ add_index :operation_records, [:name, :status]
338
458
  ```
339
459
 
340
460
  Control per-operation:
341
461
 
342
462
  ```ruby
343
463
  record false # disable entirely
344
- record response: false # params only
345
- record params: false # response only
464
+ record result: false # params only
465
+ record params: false # result only
346
466
  ```
347
467
 
348
- Recording happens inside the transaction rolled back on `error!`/`assert!`. Missing columns silently skipped.
468
+ All outcomes are recorded — success (`completed`), business errors (`error`), and exceptions (`failed`). Recording runs outside the operation's own transaction so error records survive its rollbacks. Records still participate in ambient transactions (e.g., an outer operation's transaction). Missing columns silently skipped.
469
+
470
+ Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
349
471
 
350
- When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
472
+ When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
473
+
474
+ ---
475
+
476
+ ## Idempotency (once)
477
+
478
+ Prevent duplicate execution with `once`. Requires recording to be configured (uses the record backend to store and look up idempotency keys).
479
+
480
+ **Class-level declaration:**
481
+
482
+ ```ruby
483
+ class ChargeOrder < Dex::Operation
484
+ prop :order_id, Integer
485
+ once :order_id # key: "ChargeOrder/order_id=123"
486
+
487
+ def perform
488
+ Stripe::Charge.create(amount: order.total)
489
+ end
490
+ end
491
+ ```
492
+
493
+ Key forms:
494
+
495
+ ```ruby
496
+ once :order_id # single prop → "ClassName/order_id=1"
497
+ once :merchant_id, :plan_id # composite → "ClassName/merchant_id=1/plan_id=2" (sorted)
498
+ once # bare — all props as key
499
+ once { "payment-#{order_id}" } # block — custom key (no auto scoping)
500
+ once :user_id, expires_in: 24.hours # key expires after duration
501
+ ```
502
+
503
+ **Call-site key** — override or add idempotency at the call site:
504
+
505
+ ```ruby
506
+ MyOp.new(payload: "data").once("webhook-123").call # explicit key
507
+ MyOp.new(order_id: 1).once(nil).call # bypass once guard entirely
508
+ ```
509
+
510
+ Works without a class-level `once` declaration — useful for one-off idempotency from controllers or jobs.
511
+
512
+ **Replay behavior:**
513
+
514
+ - Success results and business errors (`error!`) are replayed from the stored record. The operation does not re-execute.
515
+ - Unhandled exceptions release the key — the next call retries normally.
516
+ - Works with `.safe.call` (replays as `Ok`/`Err`) and `.async.call`.
517
+
518
+ **Clearing keys:**
519
+
520
+ ```ruby
521
+ ChargeOrder.clear_once!(order_id: 1) # by prop values (builds scoped key)
522
+ ChargeOrder.clear_once!("webhook-123") # by raw string key
523
+ ```
524
+
525
+ Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
526
+
527
+ **Pipeline position:** result → **once** → lock → record → transaction → rescue → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
528
+
529
+ **Requirements:**
530
+
531
+ - Record backend must be configured (`Dex.configure { |c| c.record_class = OperationRecord }`)
532
+ - The record table must have `once_key` and `once_key_expires_at` columns (see Recording schema above)
533
+ - `once` cannot be declared with `record false` — raises `ArgumentError`
534
+ - Only one `once` declaration per operation
351
535
 
352
536
  ---
353
537
 
@@ -390,7 +574,7 @@ class CreateUserTest < Minitest::Test
390
574
 
391
575
  def test_example
392
576
  result = call_operation(email: "a@b.com", name: "Alice") # => Ok or Err (safe)
393
- value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
577
+ value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
394
578
  end
395
579
  end
396
580
  ```
@@ -445,6 +629,17 @@ assert_contract(
445
629
  )
446
630
  ```
447
631
 
632
+ ### Guard Assertions
633
+
634
+ ```ruby
635
+ assert_callable(post: post, user: user) # all guards pass
636
+ assert_callable(PublishPost, post: post, user: user) # explicit class
637
+ refute_callable(:unauthorized, post: post, user: user) # specific guard fails
638
+ refute_callable(PublishPost, :unauthorized, post: post, user: user)
639
+ ```
640
+
641
+ Guard failures on the normal `call` path produce `Dex::Error`, so `assert_operation_error` and `assert_err` also work.
642
+
448
643
  ### Param Validation
449
644
 
450
645
  ```ruby
@@ -526,7 +721,9 @@ Global log of all operation calls:
526
721
  Dex::TestLog.calls # all entries
527
722
  Dex::TestLog.find(CreateUser) # filter by class
528
723
  Dex::TestLog.find(CreateUser, email: "a@b.com") # filter by class + params
529
- Dex::TestLog.size; Dex::TestLog.empty?; Dex::TestLog.clear!
724
+ Dex::TestLog.size
725
+ Dex::TestLog.empty?
726
+ Dex::TestLog.clear!
530
727
  Dex::TestLog.summary # human-readable for failure messages
531
728
  ```
532
729
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ # Shared context DSL for Operation and Event.
5
+ #
6
+ # Maps declared props to ambient context keys so they can be auto-filled
7
+ # from Dex.context when not passed explicitly as kwargs.
8
+ module ContextSetup
9
+ extend Dex::Concern
10
+
11
+ module ClassMethods
12
+ def context(*names, **mappings)
13
+ names.each do |name|
14
+ unless name.is_a?(Symbol)
15
+ raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
+ end
17
+ mappings[name] = name
18
+ end
19
+
20
+ raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
+
22
+ mappings.each do |prop_name, context_key|
23
+ unless _context_prop_declared?(prop_name)
24
+ raise ArgumentError,
25
+ "context references undeclared prop :#{prop_name}. Declare the prop before calling context."
26
+ end
27
+ unless context_key.is_a?(Symbol)
28
+ raise ArgumentError,
29
+ "context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
30
+ end
31
+ end
32
+
33
+ _context_own.merge!(mappings)
34
+ end
35
+
36
+ def context_mappings
37
+ parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
38
+ parent.merge(_context_own)
39
+ end
40
+
41
+ def new(**kwargs)
42
+ mappings = context_mappings
43
+ unless mappings.empty?
44
+ ambient = Dex.context
45
+ mappings.each do |prop_name, context_key|
46
+ next if kwargs.key?(prop_name)
47
+ kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
48
+ end
49
+ end
50
+ super
51
+ end
52
+
53
+ private
54
+
55
+ def _context_own
56
+ @_context_own_mappings ||= {}
57
+ end
58
+
59
+ def _context_prop_declared?(name)
60
+ respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/dex/event.rb CHANGED
@@ -15,6 +15,7 @@ module Dex
15
15
 
16
16
  include PropsSetup
17
17
  include TypeCoercion
18
+ include ContextSetup
18
19
 
19
20
  def self._warn(message)
20
21
  Dex.warn("Event: #{message}")
@@ -21,7 +21,9 @@ module Dex
21
21
 
22
22
  def enqueue_direct_job
23
23
  job = apply_options(Operation::DirectJob)
24
- job.perform_later(class_name: operation_class_name, params: serialized_params)
24
+ payload = { class_name: operation_class_name, params: serialized_params }
25
+ apply_once_payload!(payload)
26
+ job.perform_later(**payload)
25
27
  end
26
28
 
27
29
  def enqueue_record_job
@@ -32,7 +34,9 @@ module Dex
32
34
  )
33
35
  begin
34
36
  job = apply_options(Operation::RecordJob)
35
- job.perform_later(class_name: operation_class_name, record_id: record.id.to_s)
37
+ payload = { class_name: operation_class_name, record_id: record.id.to_s }
38
+ apply_once_payload!(payload)
39
+ job.perform_later(**payload)
36
40
  rescue => e
37
41
  begin
38
42
  record.destroy
@@ -68,6 +72,18 @@ module Dex
68
72
  raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
69
73
  end
70
74
 
75
+ def apply_once_payload!(payload)
76
+ return unless @operation.instance_variable_defined?(:@_once_key_explicit) &&
77
+ @operation.instance_variable_get(:@_once_key_explicit)
78
+
79
+ once_key = @operation.instance_variable_get(:@_once_key)
80
+ if once_key
81
+ payload[:once_key] = once_key
82
+ else
83
+ payload[:once_bypass] = true
84
+ end
85
+ end
86
+
71
87
  def merged_options
72
88
  @operation.class.settings_for(:async).merge(@runtime_options)
73
89
  end