dexkit 0.9.0 → 0.10.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: 41c8e4455fb4a4cca73b1d12da366b53bdbbada2cbae773917a12d344a150dd4
4
- data.tar.gz: 86c4e76004df8b968c094b7b252a988eafe2f7aee035505ddb7bbeae1f25e04a
3
+ metadata.gz: 493f1873cf71e1f8e2348f4f15e3e508962d9ae310b85f7b4aac8a756396de05
4
+ data.tar.gz: ad50fd8349a45e4c4bc536f68bb2c74ee10b59e8b80856fca2823bb9f9a998cf
5
5
  SHA512:
6
- metadata.gz: 89a9f6075f3955300dbe3944a9adc9b80ab56c3b4dc60d9458ea370f68c5c83ab5b3f2daad26c6f852d6f423a498ccf068914853e9866c05442765539ac6ac91
7
- data.tar.gz: cd541ee35700cf9aa877b3630d66f0b9580263d0fddc7f381200afe32c638f832d941112387f646fa59500c26bb552461ad5bf0f0ec79dcf7bb97d76ebcbfb86
6
+ metadata.gz: 18ca385b0d66155e35c3f08164d3b558e85f3f29a586b492d664c72c24f746e6e70c0d6692faedb348e7c849096eb0f3ddfd98a5515e2de5b5ce05084cab3d5e
7
+ data.tar.gz: 2e7c2e59443b2dff7fb3a9de4c3296ce1b71c4f870bf47d2ce503f5d5c23105a1cee5549618451741a21fb6d0c8fcb0eba53b94d4edcf8e999e1164e9eabf96f
data/CHANGELOG.md CHANGED
@@ -1,12 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0] - 2026-03-09
4
+
5
+ ### Added
6
+
7
+ - **`field` / `field?` DSL for forms** – `field :name, :string` declares a required field with auto-presence validation; unconditional explicit presence validators deduplicate with it, while scoped/conditional validators still layer on top. `field? :notes, :string` declares an optional field (nil by default). Both support `desc:` for metadata and `default:` for defaults. Raw `attribute` remains available as an escape hatch
8
+ - **Form registry and description** – `Dex::Form` now extends `Registry`, giving forms `description`, `Dex::Form.registry`, and `deregister` – the same ecosystem as Operation, Event, and Handler
9
+ - **Form ambient context** – forms support the same `context` DSL as Operation and Event. `context :locale` auto-fills from `Dex.context` during initialization. Uses the same shared `ContextDSL` module with Form-specific injection
10
+ - **Form export** – `Form.to_h` (class-level schema), `Form.to_json_schema`, and `Dex::Form.export(format:)` for bulk export. Nested forms are recursively included in both formats, and bulk export returns top-level named forms without listing nested helper classes separately
11
+ - **Query registry, description, context, and export** – `Dex::Query` now extends `Registry` (giving `description`, `Dex::Query.registry`, `deregister`), includes `ContextSetup` (enabling `context :tenant` to auto-fill from `Dex.with_context`), and adds `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)`. Query is now a full citizen alongside Operation, Event, and Form
12
+
13
+ ### Changed
14
+
15
+ - **Shared context DSL** – extracted `Dex::ContextDSL` as a shared module used by both `ContextSetup` (Operation/Event) and `Form::Context`. No behavior change for Operation or Event
16
+
3
17
  ## [0.9.0] - 2026-03-09
4
18
 
5
19
  ### Breaking
6
20
 
21
+ - **Unified execution tracing replaces event-only tracing** – `Dex::Trace` is a new fiber-local trace that spans operations, events, and handlers. Operations get `op_...` execution IDs, events get `ev_...` IDs (replacing UUIDs), handlers get `hd_...` IDs, and traces are correlated with `tr_...` IDs. `event.trace { }` is removed – use `caused_by:` for explicit event causality and `Dex::Trace.start(actor:)` at request/job boundaries. `Dex::Event::Trace` remains as a thin delegation layer
22
+ - **Operation record primary keys are now string IDs** – records use the operation's `op_...` execution ID as a string primary key instead of auto-increment integers. The recording schema adds `trace_id`, `actor_type`, `actor_id`, and `trace` columns. Existing tables need a migration to adopt the new schema
7
23
  - **Mongoid transaction support removed** — `transaction :mongoid` and `config.transaction_adapter = :mongoid` are no longer valid. Dex no longer ships a Mongoid transaction adapter. Before: Mongoid transactions could be enabled via configuration or per-operation DSL. After: both forms raise `ArgumentError` immediately at declaration/configuration time. Mongoid-only apps continue to work — transactions are automatically disabled (no adapter detected), and `after_commit` fires immediately after success. If you need Mongoid multi-document transactions, call `Mongoid.transaction` directly inside `perform`
8
24
  - **Recording backends now validate required attributes before use** — Dex no longer silently drops missing `params`, `result`, `status`, or `once` attributes from `record_class`. Before: partial ActiveRecord/Mongoid recording models could appear to work while losing status transitions, replay data, or async params. After: Dex raises `ArgumentError` naming the missing attributes required by core recording, async record jobs, or `once`. Apps using minimal recording models must add the required columns/fields or explicitly disable the features that need them
9
25
 
26
+ ### Added
27
+
28
+ - **`Dex::Trace` API** – `start(actor:, trace_id:)`, `.trace_id`, `.current`, `.current_id`, `.actor`, `.to_s`, `.dump`, `.restore`. Fiber-local, auto-starts when no trace is active, serializes across async job boundaries
29
+ - **Trace persistence** – operation records and event stores persist `id`, `trace_id`, `actor_type`, `actor_id`, and `trace` when the columns exist. Event metadata includes `event_ancestry` for materialized-path tree queries
30
+ - **`Dex::Id`** – Stripe-style prefixed ID generation with embedded timestamps for sortability
31
+
10
32
  ### Fixed
11
33
 
12
34
  - **Mongoid-only Rails compatibility** — Dex boots and runs cleanly in Mongoid-only Rails apps without `activerecord` loaded, with prescriptive `LoadError`s for unsupported paths such as `advisory_lock` and async event dispatch without `ActiveJob`
data/README.md CHANGED
@@ -73,6 +73,17 @@ end
73
73
  Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
74
74
  ```
75
75
 
76
+ **Execution tracing** – every operation gets a prefixed ID and joins a unified trace across operations, events, and handlers:
77
+
78
+ ```ruby
79
+ Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
80
+ Order::Place.call(customer: 42, product: 7, quantity: 2)
81
+ end
82
+
83
+ Dex::Trace.trace_id # => "tr_..."
84
+ Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
85
+ ```
86
+
76
87
  **Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
77
88
 
78
89
  ```ruby
@@ -210,11 +221,12 @@ Order::Placed.publish(order_id: 1, total: 99.99)
210
221
 
211
222
  **Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline. If ActiveJob is not loaded, async publish raises `LoadError`.
212
223
 
213
- **Causality tracing** link events in chains with shared `trace_id`:
224
+ **Causality tracing** events join the unified execution trace, and child events link to their cause:
214
225
 
215
226
  ```ruby
216
- order_placed.trace do
217
- Shipment::Reserved.publish(order_id: 1)
227
+ Dex::Trace.start(actor: { type: :user, id: 42 }) do
228
+ order_placed = Order::Placed.new(order_id: 1, total: 99.99)
229
+ Shipment::Reserved.publish(order_id: 1, caused_by: order_placed)
218
230
  end
219
231
  ```
220
232
 
@@ -241,25 +253,29 @@ end
241
253
 
242
254
  ## Forms
243
255
 
244
- Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
256
+ Form objects with typed fields, normalization, nested forms, ambient context, JSON Schema export, and Rails form builder compatibility.
245
257
 
246
258
  ```ruby
247
259
  class Employee::Form < Dex::Form
260
+ description "Employee onboarding form"
248
261
  model Employee
249
262
 
250
- attribute :first_name, :string
251
- attribute :last_name, :string
252
- attribute :email, :string
263
+ field :first_name, :string
264
+ field :last_name, :string
265
+ field :email, :string
266
+ field :locale, :string
267
+ field? :notes, :string
268
+
269
+ context :locale
253
270
 
254
271
  normalizes :email, with: -> { _1&.strip&.downcase.presence }
255
272
 
256
- validates :email, presence: true, uniqueness: true
257
- validates :first_name, :last_name, presence: true
273
+ validates :email, uniqueness: true
258
274
 
259
275
  nested_one :address do
260
- attribute :street, :string
261
- attribute :city, :string
262
- validates :street, :city, presence: true
276
+ field :street, :string
277
+ field :city, :string
278
+ field? :apartment, :string
263
279
  end
264
280
  end
265
281
 
@@ -270,18 +286,21 @@ form.valid?
270
286
 
271
287
  ### What you get out of the box
272
288
 
273
- **ActiveModel attributes** with type casting, normalization, and full Rails validation DSL.
289
+ **`field` / `field?`** — required and optional fields with auto-presence validation, `desc:` metadata, and defaults. Backed by ActiveModel attributes with type casting and normalization. Unconditional `validates :attr, presence: true` deduplicates with `field`; scoped validations still layer on top.
274
290
 
275
291
  **Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
276
292
 
277
293
  ```ruby
278
294
  nested_many :emergency_contacts do
279
- attribute :name, :string
280
- attribute :phone, :string
281
- validates :name, :phone, presence: true
295
+ field :name, :string
296
+ field :phone, :string
282
297
  end
283
298
  ```
284
299
 
300
+ **Ambient context** — auto-fill fields from `Dex.context`, same DSL as Operation and Event.
301
+
302
+ **Registry & Export** — `description`, `to_json_schema`, class-level `to_h`, and `Dex::Form.export` for schema introspection. Nested form schemas recurse in both export formats, and bulk export includes only top-level named forms.
303
+
285
304
  **Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
286
305
 
287
306
  **Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
@@ -305,11 +324,16 @@ Declarative query objects for filtering and sorting ActiveRecord and Mongoid sco
305
324
 
306
325
  ```ruby
307
326
  class Order::Query < Dex::Query
327
+ description "Search orders"
328
+
308
329
  scope { Order.all }
309
330
 
310
331
  prop? :status, String
311
332
  prop? :customer, _Ref(Customer)
312
333
  prop? :total_min, Integer
334
+ prop? :tenant, String
335
+
336
+ context tenant: :current_tenant
313
337
 
314
338
  filter :status
315
339
  filter :customer
@@ -323,6 +347,10 @@ orders = Order::Query.call(status: "pending", sort: "-total")
323
347
 
324
348
  ### What you get out of the box
325
349
 
350
+ **Registry, description, and context** — same ecosystem as Operation, Event, and Form. `Dex::Query.registry` discovers all query classes, `description` documents intent, and `context` auto-fills props from `Dex.with_context`.
351
+
352
+ **Export** — `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)` for introspection and bulk export.
353
+
326
354
  **11 built-in filter strategies** — `:eq`, `:not_eq`, `:contains`, `:starts_with`, `:ends_with`, `:gt`, `:gte`, `:lt`, `:lte`, `:in`, `:not_in`. Custom blocks for complex logic.
327
355
 
328
356
  **Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- dexkit (0.8.0)
4
+ dexkit (0.10.0)
5
5
  activemodel (>= 6.1)
6
6
  literal (~> 1.9)
7
7
  zeitwerk (~> 2.6)
@@ -180,7 +180,7 @@ CHECKSUMS
180
180
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
181
181
  crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
182
182
  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
183
- dexkit (0.8.0)
183
+ dexkit (0.10.0)
184
184
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
185
185
  erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
186
186
  erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
data/guides/llm/EVENT.md CHANGED
@@ -30,9 +30,9 @@ class UserCreated < Dex::Event
30
30
  end
31
31
  ```
32
32
 
33
- Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `context`, `publish`, `metadata`, `sync`.
33
+ Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `event_ancestry`, `context`, `publish`, `metadata`, `sync`.
34
34
 
35
- Events are frozen after creation. Each gets auto-generated `id` (UUID), `timestamp` (UTC), `trace_id`, and optional `caused_by_id`.
35
+ Events are frozen after creation. Each gets auto-generated `id` (`ev_...`), `timestamp` (UTC), `trace_id` (`tr_...` by default), optional `caused_by_id`, and `event_ancestry` (ordered ancestor event IDs).
36
36
 
37
37
  ### Literal Types Cheatsheet
38
38
 
@@ -72,10 +72,11 @@ class NotifyWarehouse < Dex::Event::Handler
72
72
  def perform
73
73
  event # accessor — the event instance
74
74
  event.order_id # typed props
75
- event.id # UUID
75
+ event.id # prefixed event ID (ev_...)
76
76
  event.timestamp # Time (UTC)
77
77
  event.caused_by_id # parent event ID (if traced)
78
- event.trace_id # shared trace ID across causal chain
78
+ event.trace_id # shared trace / correlation ID
79
+ event.event_ancestry # ordered ancestor event IDs
79
80
  end
80
81
  end
81
82
  ```
@@ -179,22 +180,20 @@ Default handler pipeline: `[:transaction, :callback]`.
179
180
 
180
181
  ## Tracing (Causality)
181
182
 
182
- Link events in a causal chain. All events in a chain share the same `trace_id`.
183
+ Events participate in the unified `Dex::Trace` used by operations and handlers. All events in a trace share the same `trace_id`.
183
184
 
184
185
  ```ruby
185
- order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
186
-
187
- # Option 1: trace block
188
- order_placed.trace do
189
- InventoryReserved.publish(order_id: 1) # caused_by_id = order_placed.id
190
- ShippingRequested.publish(order_id: 1) # same trace_id
186
+ # Start a trace at the request/job boundary
187
+ Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
188
+ OrderPlaced.publish(order_id: 1, total: 99.99)
191
189
  end
192
190
 
193
- # Option 2: caused_by keyword
191
+ # Explicit event-to-event causality
192
+ order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
194
193
  InventoryReserved.publish(order_id: 1, caused_by: order_placed)
195
194
  ```
196
195
 
197
- Nesting works each child gets the nearest parent's `id` as `caused_by_id`, and the root's `trace_id`.
196
+ When an event is published inside a handler, the handler's event becomes the cause automatically. Explicit `caused_by:` sets `caused_by_id` and appends to `event_ancestry`.
198
197
 
199
198
  ---
200
199
 
@@ -218,12 +217,16 @@ Store events to database when configured:
218
217
 
219
218
  ```ruby
220
219
  Dex.configure do |c|
221
- c.event_store = EventRecord # any model with create!(event_type:, payload:, metadata:)
220
+ c.event_store = EventRecord # Dex passes trace fields when the model supports them
222
221
  end
223
222
  ```
224
223
 
225
224
  ```ruby
226
- create_table :event_records do |t|
225
+ create_table :event_records, id: :string do |t|
226
+ t.string :trace_id
227
+ t.string :actor_type
228
+ t.string :actor_id
229
+ t.jsonb :trace
227
230
  t.string :event_type
228
231
  t.jsonb :payload
229
232
  t.jsonb :metadata
@@ -238,6 +241,11 @@ class EventRecord
238
241
  include Mongoid::Document
239
242
  include Mongoid::Timestamps
240
243
 
244
+ field :_id, type: String
245
+ field :trace_id, type: String
246
+ field :actor_type, type: String
247
+ field :actor_id, type: String
248
+ field :trace, type: Array
241
249
  field :event_type, type: String
242
250
  field :payload, type: Hash
243
251
  field :metadata, type: Hash
@@ -381,10 +389,7 @@ class CreateOrderTest < Minitest::Test
381
389
  def test_trace_chain
382
390
  capture_events do
383
391
  parent = OrderPlaced.new(order_id: 1, total: 99.99)
384
-
385
- parent.trace do
386
- InventoryReserved.publish(order_id: 1)
387
- end
392
+ InventoryReserved.publish(order_id: 1, caused_by: parent)
388
393
 
389
394
  child = _dex_published_events.last
390
395
  assert_event_trace(parent, child)
data/guides/llm/FORM.md CHANGED
@@ -10,58 +10,80 @@ All examples below build on this form unless noted otherwise:
10
10
 
11
11
  ```ruby
12
12
  class OnboardingForm < Dex::Form
13
+ description "Employee onboarding"
13
14
  model User
14
15
 
15
- attribute :first_name, :string
16
- attribute :last_name, :string
17
- attribute :email, :string
18
- attribute :department, :string
19
- attribute :start_date, :date
16
+ field :first_name, :string
17
+ field :last_name, :string
18
+ field :email, :string
19
+ field :department, :string
20
+ field :start_date, :date
21
+ field :locale, :string
22
+ field? :notes, :string
23
+
24
+ context :locale
20
25
 
21
26
  normalizes :email, with: -> { _1&.strip&.downcase.presence }
22
27
 
23
- validates :email, presence: true, uniqueness: true
24
- validates :first_name, :last_name, :department, presence: true
25
- validates :start_date, presence: true
28
+ validates :email, uniqueness: true
26
29
 
27
30
  nested_one :address do
28
- attribute :street, :string
29
- attribute :city, :string
30
- attribute :postal_code, :string
31
- attribute :country, :string
32
-
33
- validates :street, :city, :country, presence: true
31
+ field :street, :string
32
+ field :city, :string
33
+ field :postal_code, :string
34
+ field :country, :string
35
+ field? :apartment, :string
34
36
  end
35
37
 
36
38
  nested_many :documents do
37
- attribute :document_type, :string
38
- attribute :document_number, :string
39
-
40
- validates :document_type, :document_number, presence: true
39
+ field :document_type, :string
40
+ field :document_number, :string
41
41
  end
42
42
  end
43
43
  ```
44
44
 
45
45
  ---
46
46
 
47
- ## Defining Forms
47
+ ## Declaring Fields
48
48
 
49
- Forms use ActiveModel under the hood. Attributes are declared with `attribute` (same as ActiveModel::Attributes).
49
+ ### `field` required
50
+
51
+ Declares a required field. Auto-adds presence validation. Unconditional `validates :attr, presence: true` deduplicates with it; scoped or conditional presence validators do not make the field optional outside those cases.
50
52
 
51
53
  ```ruby
52
- class ProfileForm < Dex::Form
53
- attribute :name, :string
54
- attribute :age, :integer
55
- attribute :bio, :string
56
- attribute :active, :boolean, default: true
57
- attribute :born_on, :date
58
- end
54
+ field :name, :string
55
+ field :email, :string, desc: "Work email"
56
+ field :currency, :string, default: "USD"
57
+ ```
58
+
59
+ ### `field?` — optional
60
+
61
+ Declares an optional field. Defaults to `nil` unless overridden.
62
+
63
+ ```ruby
64
+ field? :notes, :string
65
+ field? :priority, :integer, default: 0
59
66
  ```
60
67
 
68
+ ### Options
69
+
70
+ | Option | Description |
71
+ |--------|-------------|
72
+ | `desc:` | Human-readable description (for introspection and JSON Schema) |
73
+ | `default:` | Default value (forwarded to ActiveModel) |
74
+
61
75
  ### Available types
62
76
 
63
77
  `:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:time`.
64
78
 
79
+ ### `attribute` escape hatch
80
+
81
+ Raw ActiveModel `attribute` is still available. Not tracked in field registry, no auto-presence, not in exports.
82
+
83
+ ### Boolean fields
84
+
85
+ `field :active, :boolean` checks for `nil` (not `blank?`), so `false` is valid.
86
+
65
87
  ### `model(klass)`
66
88
 
67
89
  Declares the backing model class. Used by:
@@ -93,11 +115,11 @@ normalizes :name, :email, with: -> { _1&.strip.presence } # multiple attrs
93
115
 
94
116
  ## Validation
95
117
 
96
- Full ActiveModel validation DSL:
118
+ Full ActiveModel validation DSL. Required fields auto-validate presence — no need to add `validates :name, presence: true` when using `field :name, :string`.
97
119
 
98
120
  ```ruby
99
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
100
- validates :name, presence: true, length: { minimum: 2, maximum: 100 }
121
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
122
+ validates :name, length: { minimum: 2, maximum: 100 }
101
123
  validates :role, inclusion: { in: %w[admin user] }
102
124
  validates :email, uniqueness: true # checks database
103
125
  validate :custom_validation_method
@@ -111,6 +133,15 @@ form.errors[:email] # => ["can't be blank"]
111
133
  form.errors.full_messages # => ["Email can't be blank"]
112
134
  ```
113
135
 
136
+ ### Contextual requirements
137
+
138
+ Use `field?` with an explicit validation context:
139
+
140
+ ```ruby
141
+ field? :published_at, :datetime
142
+ validates :published_at, presence: true, on: :publish
143
+ ```
144
+
114
145
  ### ValidationError
115
146
 
116
147
  ```ruby
@@ -121,6 +152,94 @@ error.form # => the form instance
121
152
 
122
153
  ---
123
154
 
155
+ ## Ambient Context
156
+
157
+ Auto-fill fields from `Dex.context` – same DSL as Operation and Event:
158
+
159
+ ```ruby
160
+ class Order::Form < Dex::Form
161
+ field :locale, :string
162
+ field :currency, :string
163
+
164
+ context :locale # shorthand: field name = context key
165
+ context currency: :default_currency # explicit: field name → context key
166
+ end
167
+
168
+ Dex.with_context(locale: "en", default_currency: "USD") do
169
+ form = Order::Form.new
170
+ form.locale # => "en"
171
+ form.currency # => "USD"
172
+ end
173
+ ```
174
+
175
+ Explicit values always win. Context references must point to declared fields or attributes.
176
+
177
+ ---
178
+
179
+ ## Registry & Export
180
+
181
+ ### Description
182
+
183
+ ```ruby
184
+ class OnboardingForm < Dex::Form
185
+ description "Employee onboarding"
186
+ end
187
+
188
+ OnboardingForm.description # => "Employee onboarding"
189
+ ```
190
+
191
+ ### Registry
192
+
193
+ ```ruby
194
+ Dex::Form.registry # => #<Set: {OnboardingForm, ...}>
195
+ ```
196
+
197
+ ### Class-level `to_h`
198
+
199
+ ```ruby
200
+ OnboardingForm.to_h
201
+ # => {
202
+ # name: "OnboardingForm",
203
+ # description: "Employee onboarding",
204
+ # fields: {
205
+ # first_name: { type: :string, required: true },
206
+ # email: { type: :string, required: true },
207
+ # notes: { type: :string, required: false },
208
+ # ...
209
+ # },
210
+ # nested: {
211
+ # address: { type: :one, fields: { ... }, nested: { ... } },
212
+ # documents: { type: :many, fields: { ... } }
213
+ # }
214
+ # }
215
+ ```
216
+
217
+ ### `to_json_schema`
218
+
219
+ ```ruby
220
+ OnboardingForm.to_json_schema
221
+ # => {
222
+ # "$schema": "https://json-schema.org/draft/2020-12/schema",
223
+ # type: "object",
224
+ # title: "OnboardingForm",
225
+ # description: "Employee onboarding",
226
+ # properties: { email: { type: "string" }, ... },
227
+ # required: ["first_name", "last_name", "email", ...],
228
+ # additionalProperties: false
229
+ # }
230
+ ```
231
+
232
+ ### Global export
233
+
234
+ ```ruby
235
+ Dex::Form.export(format: :json_schema)
236
+ # => [{ ... OnboardingForm schema ... }, ...]
237
+ ```
238
+
239
+ Bulk export returns top-level named forms only. Nested helper classes generated by `nested_one` and `nested_many` stay embedded in their parent export instead of appearing as separate entries.
240
+
241
+ ---
242
+
124
243
  ## Nested Forms
125
244
 
126
245
  ### `nested_one`
@@ -129,9 +248,9 @@ One-to-one nested form. Automatically coerces Hash input.
129
248
 
130
249
  ```ruby
131
250
  nested_one :address do
132
- attribute :street, :string
133
- attribute :city, :string
134
- validates :street, :city, presence: true
251
+ field :street, :string
252
+ field :city, :string
253
+ field? :apartment, :string
135
254
  end
136
255
  ```
137
256
 
@@ -152,9 +271,8 @@ One-to-many nested form. Handles Array, Rails numbered Hash, and `_destroy`.
152
271
 
153
272
  ```ruby
154
273
  nested_many :documents do
155
- attribute :document_type, :string
156
- attribute :document_number, :string
157
- validates :document_type, :document_number, presence: true
274
+ field :document_type, :string
275
+ field :document_number, :string
158
276
  end
159
277
  ```
160
278
 
@@ -190,7 +308,7 @@ Override the auto-generated constant name:
190
308
 
191
309
  ```ruby
192
310
  nested_one :address, class_name: "HomeAddress" do
193
- attribute :street, :string
311
+ field :street, :string
194
312
  end
195
313
  # Creates MyForm::HomeAddress instead of MyForm::Address
196
314
  ```
@@ -306,7 +424,7 @@ form.to_hash # alias for to_h
306
424
 
307
425
  ### Controller pattern
308
426
 
309
- Strong parameters (`permit`) are not required — the form's attribute declarations are the whitelist. Just `require` the top-level key:
427
+ Strong parameters (`permit`) are not required — the form's field declarations are the whitelist. Just `require` the top-level key:
310
428
 
311
429
  ```ruby
312
430
  class OnboardingController < ApplicationController
@@ -405,33 +523,34 @@ A form spanning User, Employee, and Address — the core reason form objects exi
405
523
 
406
524
  ```ruby
407
525
  class OnboardingForm < Dex::Form
408
- attribute :first_name, :string
409
- attribute :last_name, :string
410
- attribute :email, :string
411
- attribute :department, :string
412
- attribute :position, :string
413
- attribute :start_date, :date
526
+ description "Employee onboarding"
527
+
528
+ field :first_name, :string
529
+ field :last_name, :string
530
+ field :email, :string
531
+ field :department, :string
532
+ field :position, :string
533
+ field :start_date, :date
534
+ field :locale, :string
535
+ field? :notes, :string
536
+
537
+ context :locale
414
538
 
415
539
  normalizes :email, with: -> { _1&.strip&.downcase.presence }
416
540
 
417
- validates :email, presence: true, uniqueness: { model: User }
418
- validates :first_name, :last_name, :department, :position, presence: true
419
- validates :start_date, presence: true
541
+ validates :email, uniqueness: { model: User }
420
542
 
421
543
  nested_one :address do
422
- attribute :street, :string
423
- attribute :city, :string
424
- attribute :postal_code, :string
425
- attribute :country, :string
426
-
427
- validates :street, :city, :country, presence: true
544
+ field :street, :string
545
+ field :city, :string
546
+ field :postal_code, :string
547
+ field :country, :string
548
+ field? :apartment, :string
428
549
  end
429
550
 
430
551
  nested_many :documents do
431
- attribute :document_type, :string
432
- attribute :document_number, :string
433
-
434
- validates :document_type, :document_number, presence: true
552
+ field :document_type, :string
553
+ field :document_number, :string
435
554
  end
436
555
 
437
556
  def self.for(user)
@@ -484,23 +603,38 @@ Forms are standard ActiveModel objects. Test them with plain Minitest — no spe
484
603
 
485
604
  ```ruby
486
605
  class OnboardingFormTest < Minitest::Test
487
- def test_validates_required_fields
606
+ def test_required_fields_validated
488
607
  form = OnboardingForm.new
489
608
  assert form.invalid?
490
609
  assert form.errors[:email].any?
491
610
  assert form.errors[:first_name].any?
492
611
  end
493
612
 
613
+ def test_optional_fields_allowed_blank
614
+ form = OnboardingForm.new(
615
+ first_name: "Alice", last_name: "Smith",
616
+ email: "alice@example.com", department: "Eng",
617
+ position: "Dev", start_date: Date.today, locale: "en"
618
+ )
619
+ assert form.valid?
620
+ assert_nil form.notes
621
+ end
622
+
494
623
  def test_normalizes_email
495
624
  form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ")
496
625
  assert_equal "alice@example.com", form.email
497
626
  end
498
627
 
628
+ def test_context_fills_locale
629
+ form = Dex.with_context(locale: "en") { OnboardingForm.new }
630
+ assert_equal "en", form.locale
631
+ end
632
+
499
633
  def test_nested_validation_propagation
500
634
  form = OnboardingForm.new(
501
635
  first_name: "Alice", last_name: "Smith",
502
636
  email: "alice@example.com", department: "Eng",
503
- position: "Developer", start_date: Date.today,
637
+ position: "Developer", start_date: Date.today, locale: "en",
504
638
  address: { street: "", city: "", country: "" }
505
639
  )
506
640
  assert form.invalid?
@@ -516,5 +650,12 @@ class OnboardingFormTest < Minitest::Test
516
650
  assert_equal "Alice", h[:first_name]
517
651
  assert_equal "123 Main", h[:address][:street]
518
652
  end
653
+
654
+ def test_json_schema_export
655
+ schema = OnboardingForm.to_json_schema
656
+ assert_equal "object", schema[:type]
657
+ assert_includes schema[:required], "first_name"
658
+ refute_includes schema[:required], "notes"
659
+ end
519
660
  end
520
661
  ```