yes 0.0.1 → 1.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 +7 -0
- data/CHANGELOG.md +64 -0
- data/LICENSE.txt +21 -0
- data/README.md +2256 -0
- data/lib/yes/version.rb +5 -0
- data/lib/yes.rb +7 -35
- metadata +93 -98
- data/.ruby +0 -41
- data/.yardopts +0 -8
- data/HISTORY.rdoc +0 -31
- data/LICENSE.rdoc +0 -35
- data/QED.rdoc +0 -1036
- data/README.rdoc +0 -144
- data/THANKS.rdoc +0 -10
- data/bin/yes-lint +0 -4
- data/data/yes/yes.yes +0 -21
- data/lib/yes/cli.rb +0 -20
- data/lib/yes/constraints/abstract_constraint.rb +0 -121
- data/lib/yes/constraints/choice.rb +0 -39
- data/lib/yes/constraints/count.rb +0 -38
- data/lib/yes/constraints/exclusive.rb +0 -48
- data/lib/yes/constraints/fnmatch.rb +0 -42
- data/lib/yes/constraints/inclusive.rb +0 -50
- data/lib/yes/constraints/key.rb +0 -55
- data/lib/yes/constraints/kind.rb +0 -34
- data/lib/yes/constraints/length.rb +0 -46
- data/lib/yes/constraints/node_constraint.rb +0 -55
- data/lib/yes/constraints/range.rb +0 -43
- data/lib/yes/constraints/regexp.rb +0 -52
- data/lib/yes/constraints/required.rb +0 -45
- data/lib/yes/constraints/requires.rb +0 -57
- data/lib/yes/constraints/tag.rb +0 -55
- data/lib/yes/constraints/tree_constraint.rb +0 -14
- data/lib/yes/constraints/type.rb +0 -91
- data/lib/yes/constraints/value.rb +0 -62
- data/lib/yes/genclass.rb +0 -2
- data/lib/yes/lint.rb +0 -101
- data/lib/yes/logical_and.rb +0 -13
data/README.md
ADDED
|
@@ -0,0 +1,2256 @@
|
|
|
1
|
+
# Yes
|
|
2
|
+
|
|
3
|
+
Yes is a framework for building event-sourced systems, originally developed to power Switzerland's leading apprenticeship platform [yousty.ch](https://www.yousty.ch/de-CH) and its younger sibling [professional.ch](https://www.professional.ch/). It is designed to be used within Rails applications and relies on [PgEventstore](https://github.com/yousty/pg_eventstore) for event storage, which provides a robust PostgreSQL-based event store implementation.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Quick Start](#quick-start)
|
|
8
|
+
- [Naming Conventions](#naming-conventions)
|
|
9
|
+
- [Aggregate DSL](#aggregate-dsl)
|
|
10
|
+
- [attribute](#attribute)
|
|
11
|
+
- [command](#command)
|
|
12
|
+
- [Attribute Details](#attribute-details)
|
|
13
|
+
- [Command Details](#command-details)
|
|
14
|
+
- [Guards](#guards)
|
|
15
|
+
- [Command Groups](#command-groups)
|
|
16
|
+
- [Read Models](#read-models)
|
|
17
|
+
- [Parent Aggregates](#parent-aggregates)
|
|
18
|
+
- [Primary Context](#primary-context)
|
|
19
|
+
- [Removable](#removable)
|
|
20
|
+
- [Draftable](#draftable)
|
|
21
|
+
- [Authorization](#authorization)
|
|
22
|
+
- [Auth Adapter](#auth-adapter)
|
|
23
|
+
- [Aggregate Authorization](#aggregate-authorization)
|
|
24
|
+
- [Command Authorization](#command-authorization)
|
|
25
|
+
- [Cerbos Authorization](#cerbos-authorization)
|
|
26
|
+
- [Command API](#command-api)
|
|
27
|
+
- [Command API Installation](#command-api-installation)
|
|
28
|
+
- [Request Format](#request-format)
|
|
29
|
+
- [Command Class Resolution](#command-class-resolution)
|
|
30
|
+
- [Processing Pipeline](#processing-pipeline)
|
|
31
|
+
- [Using Commands Without the DSL](#using-commands-without-the-dsl)
|
|
32
|
+
- [Real-Time Command Notifications](#real-time-command-notifications)
|
|
33
|
+
- [Read API](#read-api)
|
|
34
|
+
- [Read API Installation](#read-api-installation)
|
|
35
|
+
- [Basic Queries](#basic-queries)
|
|
36
|
+
- [Advanced Queries](#advanced-queries)
|
|
37
|
+
- [Filters](#filters)
|
|
38
|
+
- [Read API Authorization](#read-api-authorization)
|
|
39
|
+
- [Serializers](#serializers)
|
|
40
|
+
- [Event Processing](#event-processing)
|
|
41
|
+
- [Subscriptions](#subscriptions)
|
|
42
|
+
- [Process Managers](#process-managers)
|
|
43
|
+
- [Configuration Reference](#configuration-reference)
|
|
44
|
+
- [Testing](#testing)
|
|
45
|
+
- [Aggregate Test DSL](#aggregate-test-dsl)
|
|
46
|
+
- [DSL Methods](#dsl-methods)
|
|
47
|
+
- [Command Group Test DSL](#command-group-test-dsl)
|
|
48
|
+
- [Event Helpers](#event-helpers)
|
|
49
|
+
- [Aggregate Matchers](#aggregate-matchers)
|
|
50
|
+
- [Development](#development)
|
|
51
|
+
- [Example Usage](#example-usage)
|
|
52
|
+
- [Testing the APIs](#testing-the-apis)
|
|
53
|
+
- [Running Specs](#running-specs)
|
|
54
|
+
- [Gem Installation and Release](#gem-installation-and-release)
|
|
55
|
+
- [Contributing](#contributing)
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
### Installation
|
|
60
|
+
|
|
61
|
+
Add this line to your application's Gemfile to pull in the whole framework (`yes-core`, `yes-auth`, `yes-command-api`, `yes-read-api`):
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
gem 'yes'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or depend on individual sub-gems if you only need parts of the framework:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
gem 'yes-core' # aggregate DSL, events, read models
|
|
71
|
+
gem 'yes-auth' # authorization principals + Cerbos integration
|
|
72
|
+
gem 'yes-command-api' # HTTP command endpoint
|
|
73
|
+
gem 'yes-read-api' # HTTP read endpoints
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then execute:
|
|
77
|
+
```bash
|
|
78
|
+
bundle install
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
> **Note on the gem name:** versions `0.0.x` of `yes` on RubyGems were an unrelated project (a small CLI). Starting with `1.x` the gem name belongs to this framework. If you previously had `gem 'yes'` in a Gemfile and want the old project, pin to `'< 1.0'`.
|
|
82
|
+
|
|
83
|
+
### Basic Usage
|
|
84
|
+
|
|
85
|
+
At the core of Yes is the `Yes::Core::Aggregate` class, which provides a DSL for defining event-sourced aggregates:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
module Users
|
|
89
|
+
module User
|
|
90
|
+
class Aggregate < Yes::Core::Aggregate
|
|
91
|
+
# Link to a parent aggregate — generates an `assign_company` command
|
|
92
|
+
# and a `company_id` attribute automatically
|
|
93
|
+
parent :company
|
|
94
|
+
|
|
95
|
+
# `change` commands are the most common — use the `:change` shortcut
|
|
96
|
+
command :change, :name # the type defaults to :string
|
|
97
|
+
command :change, :email, :email
|
|
98
|
+
|
|
99
|
+
attribute :email_confirmed, :boolean
|
|
100
|
+
|
|
101
|
+
# A custom command: its own payload, guard, and state update
|
|
102
|
+
command :confirm_email do
|
|
103
|
+
payload token: :string
|
|
104
|
+
|
|
105
|
+
guard :token_valid do
|
|
106
|
+
EmailConfirmationService.valid?(payload.token, id)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
update_state do
|
|
110
|
+
email_confirmed { true }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Usage
|
|
118
|
+
user = Users::User::Aggregate.new
|
|
119
|
+
user.assign_company(company_id: "123e4567-e89b-12d3-a456-426614174000")
|
|
120
|
+
user.change_name("John Doe")
|
|
121
|
+
user.confirm_email(token: "abc123")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
See [Command shortcuts](#command-shortcuts) for the full list of shortcut forms (`:change`, `:enable`/`:disable`, `:activate`, `:publish`, …).
|
|
125
|
+
|
|
126
|
+
## Naming Conventions
|
|
127
|
+
|
|
128
|
+
When defining an aggregate, use the following namespacing pattern:
|
|
129
|
+
`<Context>::<AggregateName>::Aggregate`
|
|
130
|
+
|
|
131
|
+
For example: `Users::User::Aggregate` or `Companies::Company::Aggregate`
|
|
132
|
+
|
|
133
|
+
## Aggregate DSL
|
|
134
|
+
|
|
135
|
+
### `attribute`
|
|
136
|
+
|
|
137
|
+
The `attribute` method defines properties of your aggregate:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
module Users
|
|
141
|
+
module User
|
|
142
|
+
class Aggregate < Yes::Core::Aggregate
|
|
143
|
+
# Plain attributes — accessors only, no change command
|
|
144
|
+
attribute :name, :string
|
|
145
|
+
attribute :email, :email
|
|
146
|
+
attribute :company_id, :uuid
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Plain `attribute` declarations define accessors on the aggregate and columns on the read model. They do **not** generate a change command.
|
|
153
|
+
|
|
154
|
+
To generate a change command along with the attribute, use the [`command :change` shortcut](#change-command-with-attribute):
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
command :change, :age, :integer
|
|
158
|
+
command :change, :bio, :string
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `command`
|
|
162
|
+
|
|
163
|
+
The `command` method defines custom operations on your aggregate:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
module Companies
|
|
167
|
+
module Company
|
|
168
|
+
class Aggregate < Yes::Core::Aggregate
|
|
169
|
+
# Define attributes that will be updated by the command
|
|
170
|
+
attribute :user_ids, :uuids
|
|
171
|
+
|
|
172
|
+
command :assign_user do
|
|
173
|
+
# Define payload attributes
|
|
174
|
+
payload user_id: :uuid
|
|
175
|
+
|
|
176
|
+
guard :user_not_already_assigned do
|
|
177
|
+
!user_ids.include?(payload.user_id)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Custom state update logic
|
|
181
|
+
update_state do
|
|
182
|
+
user_ids { (user_ids || []) + [payload.user_id] }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Attribute Details
|
|
191
|
+
|
|
192
|
+
Attributes are the core properties of your aggregates.
|
|
193
|
+
|
|
194
|
+
#### Available Types
|
|
195
|
+
|
|
196
|
+
The attribute system supports various types:
|
|
197
|
+
- `:string` - Text values
|
|
198
|
+
- `:email` - Email addresses with validation
|
|
199
|
+
- `:uuid` - UUID values
|
|
200
|
+
- `:integer` - Numeric values
|
|
201
|
+
- `:boolean` - True/false values
|
|
202
|
+
- `:date` - Date values
|
|
203
|
+
- `:uuids` - Arrays of UUIDs
|
|
204
|
+
|
|
205
|
+
For the complete list, see [yes-core/lib/yes/core/type_lookup.rb](yes-core/lib/yes/core/type_lookup.rb)
|
|
206
|
+
|
|
207
|
+
#### Custom Types
|
|
208
|
+
|
|
209
|
+
You can register application-specific types using the type registry:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# config/initializers/yes_types.rb
|
|
213
|
+
Yes::Core::Types.register(:subscription_type, Yes::Core::Types::String.enum('premium', 'basic'))
|
|
214
|
+
Yes::Core::Types.register(:team_role, Yes::Core::Types::String.enum('lead', 'member'))
|
|
215
|
+
Yes::Core::Types.register(:training_year, Yes::Core::Types::Coercible::Integer.constrained(gteq: 1, lteq: 4))
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Registered types can then be used in aggregate definitions:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
attribute :role, :team_role
|
|
222
|
+
# or, to also generate a change command:
|
|
223
|
+
command :change, :role, :team_role
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### Attribute Commands
|
|
227
|
+
|
|
228
|
+
When you generate a change command for an attribute (via the [`command :change` shortcut](#change-command-with-attribute), or the legacy `attribute ..., command: true` option), Yes generates:
|
|
229
|
+
|
|
230
|
+
##### `change_<attribute>` Method
|
|
231
|
+
|
|
232
|
+
Changes the attribute's value through an event:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
user.change_age(30)
|
|
236
|
+
user.change_bio("Software developer")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
You can also pass parameters as a hash:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
user.change_age(age: 30)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
##### `can_change_<attribute>?` Method
|
|
246
|
+
|
|
247
|
+
Validates a potential change without applying it:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# Valid change
|
|
251
|
+
if user.can_change_email?("user@example.com")
|
|
252
|
+
user.change_email("user@example.com")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Invalid change
|
|
256
|
+
user.can_change_email?("invalid-email") # => false
|
|
257
|
+
user.email_change_error # Contains the error message
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Command Details
|
|
261
|
+
|
|
262
|
+
Commands define operations that can be performed on your aggregate.
|
|
263
|
+
|
|
264
|
+
#### Command Configuration Options
|
|
265
|
+
|
|
266
|
+
##### Payload
|
|
267
|
+
|
|
268
|
+
Define the input data for your command:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
command :register_apprenticeship do
|
|
272
|
+
payload title: :string,
|
|
273
|
+
start_date: :date,
|
|
274
|
+
location_id: :uuid
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Make sure the payload keys are all defined as attributes on the aggregate if you don't supply an `update_state` block.
|
|
279
|
+
|
|
280
|
+
**Optional and Nullable Attributes**
|
|
281
|
+
|
|
282
|
+
You can mark payload attributes as optional (key can be omitted) or nullable (value can be nil) using hash syntax:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
command :update_profile do
|
|
286
|
+
# Optional key - attribute can be omitted from payload
|
|
287
|
+
payload phone: { type: :string, optional: true },
|
|
288
|
+
# Nullable value - attribute must be present but can be nil
|
|
289
|
+
max_travel_time: { type: :integer, nullable: true },
|
|
290
|
+
# Both optional key and nullable value
|
|
291
|
+
email: { type: :email, optional: true, nullable: true }
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- `optional: true` - The key can be omitted from the command payload (for commands) or event data (for events)
|
|
296
|
+
- `nullable: true` - The value can be `nil` (wraps the type with `.maybe` for commands, uses `.maybe()` for events)
|
|
297
|
+
|
|
298
|
+
**Note**: For commands, nullable attributes are automatically unwrapped from `Dry::Monads::Maybe::Some/None` when accessing `command.payload` to ensure compatibility with event creation.
|
|
299
|
+
|
|
300
|
+
##### Guards
|
|
301
|
+
|
|
302
|
+
Add validation rules with guards:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
command :publish do
|
|
306
|
+
guard :all_required_fields_present do
|
|
307
|
+
title.present? && description.present?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
guard :not_already_published do
|
|
311
|
+
!published
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
##### Custom Event Names
|
|
317
|
+
|
|
318
|
+
Customize the generated event name:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
command :publish do
|
|
322
|
+
event :apprenticeship_published
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
When no custom event name is provided, *Yes* automatically generates an event name based on the command name. Currently, only standard command prefixes are supported. If you use a command that doesn't start with a supported prefix, you must specify the event name explicitly. For a list of supported prefixes, see [lib/yes/core/utils/event_name_resolver.rb](yes-core/lib/yes/core/utils/event_name_resolver.rb).
|
|
327
|
+
|
|
328
|
+
##### Encrypting Event Payload Attributes
|
|
329
|
+
|
|
330
|
+
Yes supports encrypting sensitive data in events. You can mark payload attributes for encryption using three approaches:
|
|
331
|
+
|
|
332
|
+
**1. Inline Encryption Declaration (Recommended for mixed payloads)**
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
command :update_contact_info do
|
|
336
|
+
payload email: { type: :email, encrypt: true },
|
|
337
|
+
phone: { type: :phone, encrypt: true },
|
|
338
|
+
address: :string # not encrypted
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**2. Separate `encrypt` Method (Recommended for multiple encrypted fields)**
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
command :update_sensitive_data do
|
|
346
|
+
payload ssn: :string, email: :email, phone: :phone
|
|
347
|
+
encrypt :ssn, :email, :phone
|
|
348
|
+
end
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**3. Command Shortcut with `encrypt` Option**
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
# For simple attribute commands
|
|
355
|
+
command :change, :ssn, :string, encrypt: true
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Important Notes:**
|
|
359
|
+
- Encryption applies to the event payload stored in the event store, not to the aggregate state or read models
|
|
360
|
+
- Encrypted attributes are tracked in the generated event class via an `encryption_schema` class method
|
|
361
|
+
- You can combine inline and separate encryption declarations in the same command
|
|
362
|
+
- The encryption key is automatically derived from the aggregate ID
|
|
363
|
+
|
|
364
|
+
###### Required Setup: Key Repository
|
|
365
|
+
|
|
366
|
+
Encryption is performed by a PgEventstore middleware that delegates the actual key management and cryptography to a `key_repository` object you provide. *Yes* does not ship a concrete implementation — you plug in any object that satisfies the interface below.
|
|
367
|
+
|
|
368
|
+
Register the middleware:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
PgEventstore.configure do |config|
|
|
372
|
+
config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
|
|
373
|
+
end
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The `key_repository` must respond to the following methods, each returning a [`Dry::Monads::Result`](https://dry-rb.org/gems/dry-monads/) (or any object responding to `success?`, `failure?`, and `value!`):
|
|
377
|
+
|
|
378
|
+
| Method | Purpose | Returns (on success) |
|
|
379
|
+
| --- | --- | --- |
|
|
380
|
+
| `find(key_id)` | Look up an existing key by its identifier (the aggregate ID). | A key object responding to `attributes[:iv]`. |
|
|
381
|
+
| `create(key_id)` | Create a new key for the given identifier. Called when `find` returns a failure. | A key object responding to `attributes[:iv]`. |
|
|
382
|
+
| `encrypt(key:, message:)` | Encrypt the serialized JSON of the attributes marked for encryption. | An object responding to `attributes[:message]` containing the ciphertext. |
|
|
383
|
+
| `decrypt(key:, message:)` | Decrypt a previously encrypted payload. | An object responding to `attributes[:message]` containing the plaintext JSON. |
|
|
384
|
+
|
|
385
|
+
This interface intentionally decouples *Yes* from any specific key management or crypto backend. You can back it with AWS KMS, HashiCorp Vault, libsodium, ActiveRecord::Encryption, or any other solution that fits your deployment.
|
|
386
|
+
|
|
387
|
+
For a minimal reference implementation used in the test suite (Base64 + in-memory store, not production-ready), see [`yes-core/spec/support/dummy_repository.rb`](yes-core/spec/support/dummy_repository.rb).
|
|
388
|
+
|
|
389
|
+
##### Custom State Updates
|
|
390
|
+
|
|
391
|
+
Define exactly how state should change:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
command :add_tag do
|
|
395
|
+
payload tag: :string
|
|
396
|
+
|
|
397
|
+
update_state do
|
|
398
|
+
tags { (tags || []) + [payload.tag] }
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
You can also use the `update_state` method to update multiple attributes at once:
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
update_state do
|
|
407
|
+
name { payload.name }
|
|
408
|
+
email { payload.email }
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Make sure the attributes updated in the `update_state` block are all defined on the aggregate.
|
|
413
|
+
|
|
414
|
+
For commands whose work is side-effect-only (e.g. writing to a related ActiveRecord model) and does not assign to any aggregate attribute, use `update_state custom: true` — see [Side-Effect State Updates](#3-side-effect-state-updates-update_state-custom-true).
|
|
415
|
+
|
|
416
|
+
#### State Update Behavior
|
|
417
|
+
|
|
418
|
+
Commands update the aggregate state in one of two ways:
|
|
419
|
+
|
|
420
|
+
##### 1. Automatic State Updates (Without `update_state` Block)
|
|
421
|
+
|
|
422
|
+
If you don't define an `update_state` block, the command will automatically update the aggregate's attributes based on the payload:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
module Companies
|
|
426
|
+
module Company
|
|
427
|
+
class Aggregate < Yes::Core::Aggregate
|
|
428
|
+
# Define attributes that match the payload keys
|
|
429
|
+
attribute :name, :string
|
|
430
|
+
attribute :description, :string
|
|
431
|
+
|
|
432
|
+
command :update_details do
|
|
433
|
+
# Payload keys must match attribute names
|
|
434
|
+
payload name: :string,
|
|
435
|
+
description: :string
|
|
436
|
+
# No update_state block needed - automatic update
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
company = Companies::Company::Aggregate.new
|
|
443
|
+
company.update_details(name: "Acme Inc", description: "Manufacturing company")
|
|
444
|
+
# Both name and description attributes will be updated automatically
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**Important**: When not using an `update_state` block:
|
|
448
|
+
- All payload keys must be defined as attributes on the aggregate
|
|
449
|
+
- The system will validate this and raise an error if there's a mismatch
|
|
450
|
+
- The attribute values will be updated directly from the payload values
|
|
451
|
+
|
|
452
|
+
##### 2. Custom State Updates (With `update_state` Block)
|
|
453
|
+
|
|
454
|
+
When you define an `update_state` block, you have complete control over how attributes are updated:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
module Articles
|
|
458
|
+
module Article
|
|
459
|
+
class Aggregate < Yes::Core::Aggregate
|
|
460
|
+
attribute :title, :string
|
|
461
|
+
attribute :tags, :array
|
|
462
|
+
attribute :status, :string
|
|
463
|
+
|
|
464
|
+
command :publish do
|
|
465
|
+
payload title: :string
|
|
466
|
+
|
|
467
|
+
update_state do
|
|
468
|
+
# You can reference payload values
|
|
469
|
+
title { payload.title }
|
|
470
|
+
# Or set static values
|
|
471
|
+
status { "published" }
|
|
472
|
+
# Or combine existing data with payload
|
|
473
|
+
tags { (tags || []) + ["published"] }
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
**Important**: When using an `update_state` block:
|
|
482
|
+
- Payload keys don't need to match attribute names
|
|
483
|
+
- However, all attributes updated in the block must be defined on the aggregate
|
|
484
|
+
- The system will validate this and raise an error if an undefined attribute is updated
|
|
485
|
+
- You have full control over transformation logic
|
|
486
|
+
|
|
487
|
+
##### 3. Side-Effect State Updates (`update_state custom: true`)
|
|
488
|
+
|
|
489
|
+
Sometimes a command needs to perform work that cannot be expressed as attribute assignments on the aggregate — for example, creating or updating related ActiveRecord records, writing to an associated read model, or otherwise producing side effects outside the aggregate itself. In those cases, pass `custom: true` to `update_state`:
|
|
490
|
+
|
|
491
|
+
```ruby
|
|
492
|
+
command :assign_ambassador do
|
|
493
|
+
payload team_member_id: :uuid
|
|
494
|
+
|
|
495
|
+
update_state custom: true do
|
|
496
|
+
attrs = { team_member_id: payload.team_member_id, apprenticeship_id: id }
|
|
497
|
+
ApprenticeshipTeamMemberAssociation.find_or_create_by(attrs).update(removed_at: nil)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The `custom: true` flag changes how Yes treats the block:
|
|
503
|
+
|
|
504
|
+
- The block analyzer is skipped, so Yes does not scan it for attribute assignments like `name { payload.name }`.
|
|
505
|
+
- No aggregate attributes are recorded as updated by this command.
|
|
506
|
+
- You are responsible for performing whatever side effects the command needs — Yes will not validate or track what happens inside.
|
|
507
|
+
|
|
508
|
+
This also works with the [`command :change` shortcut](#change-command-with-attribute) when you want to override the default attribute assignment with custom side-effect logic:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
command :change, :status do # `:string` is the default type, so it can be omitted
|
|
512
|
+
update_state custom: true do
|
|
513
|
+
StatusRecord.find_or_create_by(aggregate_id: id).update(status: payload.status)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Prefer regular `update_state` blocks whenever the change can be expressed as attribute updates on the aggregate — reach for `custom: true` only when side effects outside the aggregate are genuinely required.
|
|
519
|
+
|
|
520
|
+
#### Generated Command Methods
|
|
521
|
+
|
|
522
|
+
For each command, Yes generates:
|
|
523
|
+
|
|
524
|
+
##### Command Method
|
|
525
|
+
|
|
526
|
+
Executes the command:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
company.assign_user(user_id: "123e4567-e89b-12d3-a456-426614174000")
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
##### Can Command Method
|
|
533
|
+
|
|
534
|
+
Validates if the command would succeed:
|
|
535
|
+
|
|
536
|
+
```ruby
|
|
537
|
+
if company.can_assign_user?(user_id: "123e4567-e89b-12d3-a456-426614174000")
|
|
538
|
+
company.assign_user(user_id: "123e4567-e89b-12d3-a456-426614174000")
|
|
539
|
+
else
|
|
540
|
+
puts company.assign_user_error
|
|
541
|
+
end
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
#### Command shortcuts
|
|
545
|
+
|
|
546
|
+
For the most frequently used cases *Yes* DSL allows to use shortcuts in `command` definitions.
|
|
547
|
+
|
|
548
|
+
##### Change command with attribute
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
command :change, :age, :integer, localized: true
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
is expanded to
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
attribute :age, :integer, localized: true
|
|
558
|
+
command :change_age do
|
|
559
|
+
payload age: :integer, locale: :locale
|
|
560
|
+
guard(:no_change) { value_changed?(send(attribute_name), payload.send(attribute_name)) }
|
|
561
|
+
end
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
The type defaults to `:string`, so for string attributes it can be omitted:
|
|
565
|
+
|
|
566
|
+
```ruby
|
|
567
|
+
command :change, :name # equivalent to `command :change, :name, :string`
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
You can overwrite the default no change guard by providing a custom one:
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
command :change, :age, :integer do
|
|
574
|
+
payload fantastic_new_age: :integer
|
|
575
|
+
guard(:no_change) { age != payload.fantastic_new_age }
|
|
576
|
+
end
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
##### Boolean attribute command
|
|
580
|
+
|
|
581
|
+
`:enable` and `:activate` command names are triggering this shortcut.
|
|
582
|
+
|
|
583
|
+
```ruby
|
|
584
|
+
command :activate, :dropout, attribute: :dropout_enabled
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
is expanded to
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
attribute :dropout_enabled, :boolean
|
|
591
|
+
command :activate_dropout do
|
|
592
|
+
guard(:no_change) { !dropout_enabled }
|
|
593
|
+
update_state { dropout_enabled { true } }
|
|
594
|
+
end
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
##### Toggle commands
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
command [:enable, :disable], :dropout
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
is expanded to
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
attribute :dropout, :boolean
|
|
607
|
+
command :enable_dropout do
|
|
608
|
+
guard(:no_change) { !dropout }
|
|
609
|
+
update_state { dropout { true } }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
command :disable_dropout do
|
|
613
|
+
guard(:no_change) { dropout }
|
|
614
|
+
update_state { dropout { false } }
|
|
615
|
+
end
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
##### Publish command
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
command :publish
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
is expanded to
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
attribute :published, :boolean
|
|
628
|
+
command :publish do
|
|
629
|
+
guard(:no_change) { !published }
|
|
630
|
+
update_state { published { true } }
|
|
631
|
+
end
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### Guards
|
|
635
|
+
|
|
636
|
+
Guards are powerful validation mechanisms that enforce business rules by controlling when commands and attribute changes are permitted to execute. They act as gatekeepers that ensure all operations maintain the integrity of your domain logic.
|
|
637
|
+
|
|
638
|
+
#### Default Guards
|
|
639
|
+
|
|
640
|
+
Both commands and attributes automatically include a `:no_change` guard that ensures the aggregate's state would actually change when applying the command. For commands, this default guard is only active when there is no `update_state` block present in the command definition.
|
|
641
|
+
|
|
642
|
+
#### Adding Guards to Attribute Change Commands
|
|
643
|
+
|
|
644
|
+
When defining an attribute with a change command, you can add guards to implement validation by passing a block to the `command :change` shortcut:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
command :change, :email, :email do
|
|
648
|
+
guard :check_email_domain do
|
|
649
|
+
payload.email.end_with?('@example.com')
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
#### Adding Guards to Commands
|
|
655
|
+
|
|
656
|
+
Similarly, you can add guards to commands to control when they can execute:
|
|
657
|
+
|
|
658
|
+
```ruby
|
|
659
|
+
command :publish do
|
|
660
|
+
guard :all_required_fields_present do
|
|
661
|
+
title.present? && description.present?
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
guard :not_already_published do
|
|
665
|
+
!published
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Inside any guard block you can access:
|
|
671
|
+
- `payload` - The command payload with access to both data and metadata
|
|
672
|
+
- Any aggregate attribute directly by name
|
|
673
|
+
|
|
674
|
+
##### Accessing Metadata in Guards
|
|
675
|
+
|
|
676
|
+
The payload object in guards provides access to command metadata alongside the regular payload data. This metadata can contain useful contextual information like user information, or tracking data.
|
|
677
|
+
|
|
678
|
+
You can access metadata in two ways:
|
|
679
|
+
|
|
680
|
+
```ruby
|
|
681
|
+
command :update_status do
|
|
682
|
+
payload status: :string
|
|
683
|
+
|
|
684
|
+
guard :valid_response do
|
|
685
|
+
# Method-style access
|
|
686
|
+
payload.metadata.response_id.present?
|
|
687
|
+
|
|
688
|
+
# Hash-style access
|
|
689
|
+
payload.metadata[:response_id].present?
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
guard :authorized_user do
|
|
693
|
+
# If a metadata key doesn't exist, nil is returned
|
|
694
|
+
payload.metadata.user_role == 'admin' # returns nil if user_role is not in metadata
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
This allows guards to make decisions based on both the command's data payload and any additional contextual metadata that was provided when the command was issued.
|
|
700
|
+
|
|
701
|
+
#### Guard Error Types
|
|
702
|
+
|
|
703
|
+
Guards have two distinct behaviors based on their name:
|
|
704
|
+
|
|
705
|
+
- Guards named `:no_change` trigger a **no-change transition** error when they fail. This indicates that the operation would not modify the aggregate's state.
|
|
706
|
+
- All other guard names trigger an **invalid transition** error when they fail. This indicates that the operation is not allowed in the current state.
|
|
707
|
+
|
|
708
|
+
```ruby
|
|
709
|
+
command :update_profile do
|
|
710
|
+
payload bio: :string
|
|
711
|
+
|
|
712
|
+
# Will trigger a no-change transition error if bio hasn't changed
|
|
713
|
+
guard :no_change do
|
|
714
|
+
payload.bio != bio
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Will trigger an invalid transition error if bio contains prohibited words
|
|
718
|
+
guard :appropriate_content do
|
|
719
|
+
!payload.bio.include?("prohibited content")
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
#### Custom Error Messages
|
|
725
|
+
|
|
726
|
+
You can provide custom localized error messages for guards using I18n translation files:
|
|
727
|
+
|
|
728
|
+
```yaml
|
|
729
|
+
# config/locales/en.yml
|
|
730
|
+
en:
|
|
731
|
+
aggregates:
|
|
732
|
+
test: # context
|
|
733
|
+
apprenticeship: # aggregate
|
|
734
|
+
commands:
|
|
735
|
+
change_location: # command
|
|
736
|
+
guards:
|
|
737
|
+
location_published: # guard
|
|
738
|
+
error: "Location is not published"
|
|
739
|
+
company_matches:
|
|
740
|
+
error: "Location company does not match apprenticeship company"
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
This allows you to define human-readable error messages that can be easily translated to different languages. These messages will be used instead of the default error messages when a guard fails.
|
|
744
|
+
|
|
745
|
+
### Command Groups
|
|
746
|
+
|
|
747
|
+
A `command_group` is a compound action that runs several existing aggregate commands as a single atomic unit. It's useful when multiple commands are always executed together and the per-command guards would be redundant or too restrictive for the compound flow.
|
|
748
|
+
|
|
749
|
+
```ruby
|
|
750
|
+
module Companies
|
|
751
|
+
module Apprenticeship
|
|
752
|
+
class Aggregate < Yes::Core::Aggregate
|
|
753
|
+
attribute :name, :string, command: true
|
|
754
|
+
attribute :description, :string, command: true
|
|
755
|
+
parent :company
|
|
756
|
+
parent :user
|
|
757
|
+
draftable
|
|
758
|
+
command :publish
|
|
759
|
+
|
|
760
|
+
command_group :create_apprenticeship do
|
|
761
|
+
command :assign_company
|
|
762
|
+
command :assign_user
|
|
763
|
+
command :change_name
|
|
764
|
+
command :change_description
|
|
765
|
+
command :publish
|
|
766
|
+
|
|
767
|
+
guard(:company_assigned) { payload.company_id.present? }
|
|
768
|
+
guard(:user_assigned) { payload.user_id.present? }
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
aggregate.create_apprenticeship(
|
|
775
|
+
company_id:, user_id:, name:, description:
|
|
776
|
+
)
|
|
777
|
+
# => Yes::Core::Commands::CommandGroupResponse(cmd:, events: [...], error: nil)
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
**How it works:**
|
|
781
|
+
|
|
782
|
+
- `command :sub_name` inside the block lists existing aggregate commands by symbol. Order is preserved as execution order.
|
|
783
|
+
- `guard(:name) { … }` declares group-level guards using the same DSL as per-command guards. They run against the aggregate's current state at invocation time.
|
|
784
|
+
- Sub-command symbols are resolved lazily — declare the group before or after the individual commands, the framework checks consistency at the end of the class body.
|
|
785
|
+
- When invoked, the group:
|
|
786
|
+
1. Evaluates only the group's guards (sub-command guards are fully skipped).
|
|
787
|
+
2. Publishes one event per sub-command, in declaration order, inside a single `PgEventstore.client.multiple` transaction at serializable isolation — either all events commit or none do.
|
|
788
|
+
3. Updates the read model after the eventstore commit, in declaration order, so each sub-command's state-updater sees the cumulative state from the previous ones.
|
|
789
|
+
- The first sub-event uses `expected_revision` + external-aggregate revision verification (same optimistic-concurrency machinery as the per-command flow), so a concurrent writer that committed between guard evaluation and publish raises `WrongExpectedRevisionError` and the executor retries with fresh guard evaluation.
|
|
790
|
+
- Subsequent sub-events within the transaction use `expected_revision: :any` — atomicity and sequencing are guaranteed by the surrounding `multiple` block.
|
|
791
|
+
|
|
792
|
+
**Payload model:**
|
|
793
|
+
|
|
794
|
+
`command_group` accepts a flat hash (most common, single-aggregate case), subject-nested form, or context-nested form — same three-form normalization as the legacy `Yes::Core::Commands::Group`. The flat form distributes attributes to each sub-command by name match:
|
|
795
|
+
|
|
796
|
+
```ruby
|
|
797
|
+
# Flat — recommended for single-aggregate groups
|
|
798
|
+
aggregate.create_apprenticeship(
|
|
799
|
+
company_id: '...', user_id: '...', name: 'Acme', description: 'Best'
|
|
800
|
+
)
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
Each sub-command receives the subset of keys it declares as payload attributes. The aggregate's `<aggregate>_id` is injected automatically.
|
|
804
|
+
|
|
805
|
+
**`can_<group_name>?`:**
|
|
806
|
+
|
|
807
|
+
For every `command_group`, the aggregate also gets a predicate that runs the group's guards without publishing events:
|
|
808
|
+
|
|
809
|
+
```ruby
|
|
810
|
+
aggregate.can_create_apprenticeship?(company_id:, user_id:, name:, description:)
|
|
811
|
+
# => true / false
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
**Response shape:**
|
|
815
|
+
|
|
816
|
+
```ruby
|
|
817
|
+
response = aggregate.create_apprenticeship(payload)
|
|
818
|
+
response.success? # => true / false
|
|
819
|
+
response.events # => Array<PgEventstore::Event> in declaration order
|
|
820
|
+
response.error # => the GuardEvaluator::TransitionError if any (nil on success)
|
|
821
|
+
response.cmd # => the CommandGroup instance
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
**Generated artifacts:**
|
|
825
|
+
|
|
826
|
+
A `command_group :foo` macro on `Context::Aggregate` generates:
|
|
827
|
+
|
|
828
|
+
- `Context::Aggregate::CommandGroups::Foo::Command` — a `Yes::Core::Commands::CommandGroup` subclass
|
|
829
|
+
- `Context::Aggregate::CommandGroups::Foo::GuardEvaluator` — a `Yes::Core::CommandHandling::GuardEvaluator` subclass holding the group's guards
|
|
830
|
+
- `Aggregate#foo(payload, guards:, metadata:)` — the invocation method
|
|
831
|
+
- `Aggregate#can_foo?(payload)` — the predicate
|
|
832
|
+
- `Aggregate#foo_error` accessor — mirrors the per-command error accessor pattern
|
|
833
|
+
|
|
834
|
+
The legacy stateless `Yes::Core::Commands::Group` / `Yes::Core::Commands::Stateless::GroupHandler` are untouched and continue to serve cross-aggregate use cases declared outside the aggregate DSL.
|
|
835
|
+
|
|
836
|
+
### Read Models
|
|
837
|
+
|
|
838
|
+
Each aggregate automatically gets a corresponding read model (ActiveRecord model) that persists its current state. This is how you access attribute values from an aggregate.
|
|
839
|
+
|
|
840
|
+
```ruby
|
|
841
|
+
user = Users::User::Aggregate.new
|
|
842
|
+
user.change_name("Jane Doe")
|
|
843
|
+
user.name # => "Jane Doe" (reads from the read model)
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
#### Default Naming
|
|
847
|
+
|
|
848
|
+
By default, the read model's name is derived from the aggregate's context and name:
|
|
849
|
+
|
|
850
|
+
```ruby
|
|
851
|
+
# For Users::User::Aggregate
|
|
852
|
+
# The read model class will be UsersUser
|
|
853
|
+
# And the database table will be users_users
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
#### Customizing Read Models
|
|
857
|
+
|
|
858
|
+
You can customize the read model name and visibility using the `read_model` method:
|
|
859
|
+
|
|
860
|
+
```ruby
|
|
861
|
+
module Users
|
|
862
|
+
module User
|
|
863
|
+
class Aggregate < Yes::Core::Aggregate
|
|
864
|
+
# Use a custom read model name
|
|
865
|
+
read_model 'custom_user', public: false
|
|
866
|
+
|
|
867
|
+
command :change, :email, :email
|
|
868
|
+
attribute :name, :string
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
In this example:
|
|
875
|
+
- The read model class will be `CustomUser` instead of `UsersUser`
|
|
876
|
+
- The database table will be `custom_users`
|
|
877
|
+
- `public: false` means this read model won't be accessible via the read API
|
|
878
|
+
|
|
879
|
+
#### Read Model Schema Generator
|
|
880
|
+
|
|
881
|
+
When you add or remove aggregates or attributes, you need to update your database schema. Yes provides a Rails generator for this:
|
|
882
|
+
|
|
883
|
+
```shell
|
|
884
|
+
rails generate yes:core:read_models:update
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
This will:
|
|
888
|
+
1. Find all aggregates in your application
|
|
889
|
+
2. Create migration files that update read model tables to match your aggregate definitions
|
|
890
|
+
3. Add, modify, or remove columns as needed
|
|
891
|
+
|
|
892
|
+
Example generated migration:
|
|
893
|
+
|
|
894
|
+
```ruby
|
|
895
|
+
class UpdateReadModels < ActiveRecord::Migration[7.1]
|
|
896
|
+
def change
|
|
897
|
+
create_table :users do |t|
|
|
898
|
+
t.string :name
|
|
899
|
+
t.string :email
|
|
900
|
+
t.integer :age
|
|
901
|
+
t.integer :revision, null: false, default: -1
|
|
902
|
+
t.timestamps
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
add_column :companies, :name, :string
|
|
906
|
+
remove_column :companies, :old_field
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
##### Type Mapping
|
|
912
|
+
|
|
913
|
+
Attribute types are mapped to database column types as follows:
|
|
914
|
+
- `:string`, `:email`, `:url` → `:string`
|
|
915
|
+
- `:integer` → `:integer`
|
|
916
|
+
- `:uuid` → `:uuid`
|
|
917
|
+
- `:boolean` → `:boolean`
|
|
918
|
+
- `:hash` → `:jsonb`
|
|
919
|
+
- `:aggregate` → `:uuid` (stored as `<attribute_name>_id`)
|
|
920
|
+
|
|
921
|
+
#### Pending Update Tracking Generator
|
|
922
|
+
|
|
923
|
+
To ensure read model consistency and enable recovery from failures during event processing, Yes provides a generator that adds pending update tracking to your read models:
|
|
924
|
+
|
|
925
|
+
```shell
|
|
926
|
+
rails generate yes:core:read_models:add_pending_update_tracking
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
This generator creates a migration that:
|
|
930
|
+
1. Adds a `pending_update_since` column to all read model tables
|
|
931
|
+
2. Creates indexes to efficiently track and recover stale pending updates
|
|
932
|
+
3. Automatically handles PostgreSQL's 63-character index name limit by truncating long names
|
|
933
|
+
|
|
934
|
+
##### What It Does
|
|
935
|
+
|
|
936
|
+
The pending update tracking system helps prevent read models from getting stuck in an inconsistent state by:
|
|
937
|
+
- Marking read models as "pending" before event publication
|
|
938
|
+
- Clearing the pending state after successful updates
|
|
939
|
+
- Allowing automatic recovery of stale pending states (default timeout: 5 minutes)
|
|
940
|
+
|
|
941
|
+
##### Generated Migration Example
|
|
942
|
+
|
|
943
|
+
```ruby
|
|
944
|
+
class AddPendingUpdateTrackingToReadModels < ActiveRecord::Migration[7.1]
|
|
945
|
+
def up
|
|
946
|
+
read_model_tables = Yes::Core.configuration.all_read_model_table_names
|
|
947
|
+
|
|
948
|
+
read_model_tables.each do |table_name|
|
|
949
|
+
next unless ActiveRecord::Base.connection.table_exists?(table_name)
|
|
950
|
+
|
|
951
|
+
add_column table_name, :pending_update_since, :datetime
|
|
952
|
+
|
|
953
|
+
# Unique index to prevent concurrent updates to same aggregate
|
|
954
|
+
add_index table_name, :id,
|
|
955
|
+
unique: true,
|
|
956
|
+
where: 'pending_update_since IS NOT NULL',
|
|
957
|
+
name: truncate_index_name("idx_#{table_name}_one_pending_per_aggregate")
|
|
958
|
+
|
|
959
|
+
# Index for efficient recovery queries
|
|
960
|
+
add_index table_name, :pending_update_since,
|
|
961
|
+
where: 'pending_update_since IS NOT NULL',
|
|
962
|
+
name: truncate_index_name("idx_#{table_name}_pending_recovery")
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
##### Recovery Job
|
|
969
|
+
|
|
970
|
+
You can schedule a background job to automatically recover stale pending updates:
|
|
971
|
+
|
|
972
|
+
```ruby
|
|
973
|
+
# app/jobs/read_model_recovery_job.rb
|
|
974
|
+
class ReadModelRecoveryJob < ApplicationJob
|
|
975
|
+
def perform
|
|
976
|
+
Yes::Core::Jobs::ReadModelRecoveryJob.new.perform
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# Schedule it to run periodically (e.g., every 5 minutes)
|
|
981
|
+
# In your scheduler (whenever, sidekiq-cron, etc.):
|
|
982
|
+
ReadModelRecoveryJob.perform_later
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
##### Manual Recovery
|
|
986
|
+
|
|
987
|
+
You can also manually trigger recovery for specific read models:
|
|
988
|
+
|
|
989
|
+
```ruby
|
|
990
|
+
# Recover a specific read model instance
|
|
991
|
+
read_model = UserReadModel.find(id)
|
|
992
|
+
Yes::Core::CommandHandling::ReadModelRecoveryService.recover(read_model)
|
|
993
|
+
|
|
994
|
+
# Recover all stale pending updates (older than 5 minutes by default)
|
|
995
|
+
Yes::Core::CommandHandling::ReadModelRecoveryService.recover_all_stale
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### Parent Aggregates
|
|
999
|
+
|
|
1000
|
+
Link aggregates in a hierarchy:
|
|
1001
|
+
|
|
1002
|
+
```ruby
|
|
1003
|
+
module Companies
|
|
1004
|
+
module Location
|
|
1005
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1006
|
+
parent :company
|
|
1007
|
+
|
|
1008
|
+
command :change, :name, :string
|
|
1009
|
+
command :change, :address, :string
|
|
1010
|
+
end
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
The parent method defines an assign command with its attribute by default.
|
|
1016
|
+
For the above example it will be `assign_company` with `company_id` attribute.
|
|
1017
|
+
|
|
1018
|
+
#### command option
|
|
1019
|
+
|
|
1020
|
+
Set parent command option to false to skip defining assign command:
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
parent :company, command: false
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
### Primary Context
|
|
1027
|
+
|
|
1028
|
+
Specify the main context:
|
|
1029
|
+
|
|
1030
|
+
```ruby
|
|
1031
|
+
module Users
|
|
1032
|
+
module User
|
|
1033
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1034
|
+
primary_context :users
|
|
1035
|
+
|
|
1036
|
+
command :change, :name, :string
|
|
1037
|
+
end
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### Removable
|
|
1043
|
+
|
|
1044
|
+
Define a default removal behavior for an aggregate:
|
|
1045
|
+
|
|
1046
|
+
```ruby
|
|
1047
|
+
module Users
|
|
1048
|
+
module User
|
|
1049
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1050
|
+
removable
|
|
1051
|
+
end
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
It defines a `remove` command which works with the `removed_at` attribute by default and
|
|
1057
|
+
applies a default removal behavior.
|
|
1058
|
+
|
|
1059
|
+
The `removable` method accepts a custom name for an attribute which will also be used for
|
|
1060
|
+
the removal behavior. You can see an example below.
|
|
1061
|
+
|
|
1062
|
+
```ruby
|
|
1063
|
+
module Users
|
|
1064
|
+
module User
|
|
1065
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1066
|
+
removable(attr_name: :deleted_at)
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
You can also define additional guards or custom behavior:
|
|
1073
|
+
|
|
1074
|
+
```ruby
|
|
1075
|
+
module Users
|
|
1076
|
+
module User
|
|
1077
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1078
|
+
removable do
|
|
1079
|
+
guard(:published) { published? }
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
#### Auto-injected `:not_removed` guard
|
|
1087
|
+
|
|
1088
|
+
Calling `removable` does more than define the `remove` command: by default it also auto-blocks
|
|
1089
|
+
every other command on the aggregate while the removal attribute is set. The check fires
|
|
1090
|
+
*before* any registered guard (including the auto-injected `:no_change`), so post-remove
|
|
1091
|
+
mutations consistently raise `Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition`
|
|
1092
|
+
with the i18n message under
|
|
1093
|
+
`aggregates.<context>.<aggregate>.commands.<command>.guards.not_removed.error`.
|
|
1094
|
+
|
|
1095
|
+
```ruby
|
|
1096
|
+
module Users
|
|
1097
|
+
module User
|
|
1098
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1099
|
+
removable
|
|
1100
|
+
|
|
1101
|
+
command :change, :name, :string
|
|
1102
|
+
end
|
|
1103
|
+
end
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
agg = Users::User::Aggregate.new
|
|
1107
|
+
agg.change_name(name: 'Alice') # => success
|
|
1108
|
+
agg.remove
|
|
1109
|
+
agg.change_name(name: 'Bob') # => blocked: InvalidTransition (:not_removed)
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
The `:remove` command itself is exempt — it remains gated only by its existing `:no_change`
|
|
1113
|
+
guard, so calling `remove` twice still raises `NoChangeTransition` as before.
|
|
1114
|
+
|
|
1115
|
+
The check is order-independent: `removable` may be declared before or after the other
|
|
1116
|
+
commands on the aggregate.
|
|
1117
|
+
|
|
1118
|
+
##### Opting out at the aggregate level
|
|
1119
|
+
|
|
1120
|
+
Pass `not_removed_guards: false` to disable the auto-block for the entire aggregate (commands
|
|
1121
|
+
will continue to fire normally after `remove`):
|
|
1122
|
+
|
|
1123
|
+
```ruby
|
|
1124
|
+
module Users
|
|
1125
|
+
module User
|
|
1126
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1127
|
+
removable(not_removed_guards: false)
|
|
1128
|
+
|
|
1129
|
+
command :change, :name, :string
|
|
1130
|
+
end
|
|
1131
|
+
end
|
|
1132
|
+
end
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
##### Opting out per command
|
|
1136
|
+
|
|
1137
|
+
Pass `skip_default_guards: %i[not_removed]` to a single `command` or `parent` to exempt just
|
|
1138
|
+
that command:
|
|
1139
|
+
|
|
1140
|
+
```ruby
|
|
1141
|
+
module Users
|
|
1142
|
+
module User
|
|
1143
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1144
|
+
removable
|
|
1145
|
+
|
|
1146
|
+
# Bypass the auto-block for this one command.
|
|
1147
|
+
command :restore, skip_default_guards: %i[not_removed] do
|
|
1148
|
+
guard(:no_change) { removed_at.present? }
|
|
1149
|
+
update_state { removed_at { nil } }
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
parent :tenant, skip_default_guards: %i[not_removed]
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
### Draftable
|
|
1159
|
+
|
|
1160
|
+
The `draftable` feature allows aggregates to be created and modified in a draft state before being published. This is useful when you want to prepare changes without immediately making them live.
|
|
1161
|
+
|
|
1162
|
+
```ruby
|
|
1163
|
+
module Articles
|
|
1164
|
+
module Article
|
|
1165
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1166
|
+
# Makes aggregate draftable by connecting it to a draft aggregate for managing the draft state.
|
|
1167
|
+
# The draft aggregate has to exist already. The default draft aggregate is <CurrentAggregateContext>::<CurrentAggregateName>Draft.
|
|
1168
|
+
# Also configures a changes read model (defaults to "<read_model>_change")
|
|
1169
|
+
draftable
|
|
1170
|
+
|
|
1171
|
+
# Draftable with custom parameters
|
|
1172
|
+
# draftable draft_aggregate: { context: 'ArticleDrafts', aggregate: 'ArticleDraft' }, changes_read_model: :article_change
|
|
1173
|
+
|
|
1174
|
+
command :change, :title, :string
|
|
1175
|
+
command :change, :content, :string
|
|
1176
|
+
end
|
|
1177
|
+
end
|
|
1178
|
+
end
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
#### Method Parameters
|
|
1182
|
+
|
|
1183
|
+
The `draftable` method accepts two optional parameters:
|
|
1184
|
+
|
|
1185
|
+
- `draft_aggregate`: A hash containing the draft aggregate configuration
|
|
1186
|
+
- `context`: The context name for the draft version (defaults to the same context as the main aggregate)
|
|
1187
|
+
- `aggregate`: The aggregate name for the draft version (defaults to the main aggregate name with "Draft" suffix)
|
|
1188
|
+
- `changes_read_model`: The name for the changes read model (defaults to the main read model name with "_change" appended)
|
|
1189
|
+
|
|
1190
|
+
#### Example Usage
|
|
1191
|
+
|
|
1192
|
+
```ruby
|
|
1193
|
+
# Use all defaults
|
|
1194
|
+
draftable
|
|
1195
|
+
|
|
1196
|
+
# Custom context only
|
|
1197
|
+
draftable draft_aggregate: { context: 'DraftContext' }
|
|
1198
|
+
|
|
1199
|
+
# Custom aggregate name only
|
|
1200
|
+
draftable draft_aggregate: { aggregate: 'MyDraft' }
|
|
1201
|
+
|
|
1202
|
+
# Both context and aggregate
|
|
1203
|
+
draftable draft_aggregate: { context: 'DraftContext', aggregate: 'MyDraft' }
|
|
1204
|
+
|
|
1205
|
+
# Custom changes read model only
|
|
1206
|
+
draftable changes_read_model: :custom_changes
|
|
1207
|
+
|
|
1208
|
+
# All custom parameters
|
|
1209
|
+
draftable draft_aggregate: { context: 'DraftContext', aggregate: 'MyDraft' }, changes_read_model: :my_changes
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
When `changes_read_model` is not specified, it defaults to using the main read model name with "_change" appended (e.g., if the read model is "article", the changes read model becomes "article_change").
|
|
1213
|
+
|
|
1214
|
+
## Authorization
|
|
1215
|
+
|
|
1216
|
+
### Auth Adapter
|
|
1217
|
+
|
|
1218
|
+
Both the [Command API](#command-api) and [Read API](#read-api) delegate authentication to a configurable adapter. Configure it in an initializer:
|
|
1219
|
+
|
|
1220
|
+
```ruby
|
|
1221
|
+
# config/initializers/yes.rb
|
|
1222
|
+
Yes::Core.configure do |config|
|
|
1223
|
+
config.auth_adapter = MyAuthAdapter.new
|
|
1224
|
+
end
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
The adapter must implement three methods:
|
|
1228
|
+
|
|
1229
|
+
| Method | Purpose | Called by |
|
|
1230
|
+
|--------|---------|----------|
|
|
1231
|
+
| `authenticate(request)` | Verify the JWT token and return an auth data hash. Raise a `Yes::Core::AuthenticationError` subclass on failure. | Both API controllers (before every request) |
|
|
1232
|
+
| `verify_token(token)` | Decode a raw JWT token string. Return an object responding to `.token` that returns `[decoded_payload_hash]`. | MessageBus user identification |
|
|
1233
|
+
| `error_classes` | Return an array of exception classes that represent authentication failures. | Command API controller (to rescue and render 401) |
|
|
1234
|
+
|
|
1235
|
+
#### How It Works
|
|
1236
|
+
|
|
1237
|
+
1. On every request, the controller calls `adapter.authenticate(request)`.
|
|
1238
|
+
2. The returned hash is stored as `auth_data` and passed to command authorizers, read request authorizers, and read model authorizers throughout the request lifecycle.
|
|
1239
|
+
3. The hash must include at minimum an `:identity_id` key, which is used for command metadata, MessageBus channel defaults, and authorization.
|
|
1240
|
+
|
|
1241
|
+
#### Example Implementation
|
|
1242
|
+
|
|
1243
|
+
```ruby
|
|
1244
|
+
class MyAuthAdapter
|
|
1245
|
+
AuthError = Class.new(Yes::Core::AuthenticationError)
|
|
1246
|
+
|
|
1247
|
+
# @param request [ActionDispatch::Request]
|
|
1248
|
+
# @raise [AuthError] if the token is missing or invalid
|
|
1249
|
+
# @return [Hash] auth data passed to authorizers as auth_data
|
|
1250
|
+
def authenticate(request)
|
|
1251
|
+
token = request.headers['Authorization']&.delete_prefix('Bearer ')
|
|
1252
|
+
raise AuthError, 'Token missing' unless token
|
|
1253
|
+
|
|
1254
|
+
payload = JWT.decode(token, public_key, true, algorithm: 'RS256').first
|
|
1255
|
+
{ identity_id: payload['sub'], host: request.host }.merge(payload.symbolize_keys)
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
# @param token [String] raw JWT token (extracted from Authorization header)
|
|
1259
|
+
# @return [OpenStruct] object with .token returning [decoded_payload_hash]
|
|
1260
|
+
def verify_token(token)
|
|
1261
|
+
decoded = JWT.decode(token, public_key, true, algorithm: 'RS256')
|
|
1262
|
+
OpenStruct.new(token: decoded)
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
# @return [Array<Class>] exception classes the controller rescues as 401
|
|
1266
|
+
def error_classes
|
|
1267
|
+
[AuthError, JWT::DecodeError]
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
private
|
|
1271
|
+
|
|
1272
|
+
def public_key
|
|
1273
|
+
OpenSSL::PKey::RSA.new(ENV.fetch('JWT_PUBLIC_KEY'))
|
|
1274
|
+
end
|
|
1275
|
+
end
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
### Aggregate Authorization
|
|
1279
|
+
|
|
1280
|
+
To make aggregates available via the command API, you must define an authorization scheme at the aggregate level. This controls who can execute commands on the aggregate.
|
|
1281
|
+
|
|
1282
|
+
#### Simple Authorization
|
|
1283
|
+
|
|
1284
|
+
The simplest authorization simply allows all commands to be executed:
|
|
1285
|
+
|
|
1286
|
+
```ruby
|
|
1287
|
+
module Users
|
|
1288
|
+
module User
|
|
1289
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1290
|
+
# Allow all commands
|
|
1291
|
+
authorize do
|
|
1292
|
+
true
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
command :change, :name, :string
|
|
1296
|
+
end
|
|
1297
|
+
end
|
|
1298
|
+
end
|
|
1299
|
+
```
|
|
1300
|
+
|
|
1301
|
+
Inside the `authorize` block, you can access:
|
|
1302
|
+
- `command` - The command being executed
|
|
1303
|
+
- `auth_data` - The decoded data from the JWT authentication token
|
|
1304
|
+
|
|
1305
|
+
This allows for custom authorization logic:
|
|
1306
|
+
|
|
1307
|
+
```ruby
|
|
1308
|
+
authorize do
|
|
1309
|
+
# Only allow commands if the authenticated identity matches the user
|
|
1310
|
+
command.user_id == auth_data[:identity_id]
|
|
1311
|
+
end
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
### Command Authorization
|
|
1315
|
+
|
|
1316
|
+
Commands can define per-command authorization that extends or overrides the [aggregate-level authorizer](#aggregate-authorization).
|
|
1317
|
+
|
|
1318
|
+
```ruby
|
|
1319
|
+
# First define an aggregate level authorizer
|
|
1320
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1321
|
+
authorize do
|
|
1322
|
+
# Base level authorization logic
|
|
1323
|
+
auth_data[:identity_id].present?
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Then add command-specific refinements
|
|
1327
|
+
command :publish do
|
|
1328
|
+
payload user_id: :uuid
|
|
1329
|
+
|
|
1330
|
+
# Command-specific authorization logic
|
|
1331
|
+
authorize do
|
|
1332
|
+
# Has access to the command and auth_data
|
|
1333
|
+
command.user_id == auth_data[:user_id]
|
|
1334
|
+
end
|
|
1335
|
+
end
|
|
1336
|
+
end
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
When an aggregate has declared `authorize` at the class level, commands can define their own
|
|
1340
|
+
authorization logic that inherits from the aggregate-level authorizer. Each command with an
|
|
1341
|
+
`authorize` block automatically receives its own `Authorizer` subclass that inherits from
|
|
1342
|
+
the aggregate-level authorizer.
|
|
1343
|
+
|
|
1344
|
+
Command authorizers are registered in the configuration and can be retrieved with:
|
|
1345
|
+
|
|
1346
|
+
```ruby
|
|
1347
|
+
Yes::Core.configuration.aggregate_class('Context', 'Aggregate', :publish, :authorizer)
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
### Cerbos Authorization
|
|
1351
|
+
|
|
1352
|
+
For more complex authorization needs, Yes integrates with [Cerbos](https://www.cerbos.dev/), a powerful authorization engine:
|
|
1353
|
+
|
|
1354
|
+
```ruby
|
|
1355
|
+
module Users
|
|
1356
|
+
module User
|
|
1357
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1358
|
+
authorize cerbos: true
|
|
1359
|
+
|
|
1360
|
+
command :change, :name, :string
|
|
1361
|
+
end
|
|
1362
|
+
end
|
|
1363
|
+
end
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
When using Cerbos, you can specify additional parameters:
|
|
1367
|
+
|
|
1368
|
+
- `read_model_class` - The class used to load the read model for authorization checks (defaults to the aggregate's read model)
|
|
1369
|
+
- `resource_name` - The resource name used in Cerbos policies (defaults to the underscored aggregate name)
|
|
1370
|
+
|
|
1371
|
+
```ruby
|
|
1372
|
+
module Companies
|
|
1373
|
+
module CompanySettings
|
|
1374
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1375
|
+
# Custom read model and resource name
|
|
1376
|
+
authorize cerbos: true,
|
|
1377
|
+
read_model_class: CustomCompanySettings,
|
|
1378
|
+
resource_name: 'company_settings'
|
|
1379
|
+
|
|
1380
|
+
command :change, :name, :string
|
|
1381
|
+
end
|
|
1382
|
+
end
|
|
1383
|
+
end
|
|
1384
|
+
```
|
|
1385
|
+
|
|
1386
|
+
When using custom read models with Cerbos, the model must implement an `auth_attributes` method that returns a hash of attributes for authorization:
|
|
1387
|
+
|
|
1388
|
+
```ruby
|
|
1389
|
+
class CustomCompanySettings < ApplicationRecord
|
|
1390
|
+
def auth_attributes
|
|
1391
|
+
{ company_id: company_id || '' }
|
|
1392
|
+
end
|
|
1393
|
+
end
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
These attributes are passed to Cerbos for making authorization decisions based on your policies.
|
|
1397
|
+
|
|
1398
|
+
#### Customizing Cerbos Integration
|
|
1399
|
+
|
|
1400
|
+
For advanced use cases, you can customize how Yes interacts with Cerbos by overriding the `resource_attributes` and `cerbos_payload` methods in your authorization block. Currently, this customization is only available within command-level authorization blocks, not at the aggregate level:
|
|
1401
|
+
|
|
1402
|
+
```ruby
|
|
1403
|
+
module Universe
|
|
1404
|
+
module Star
|
|
1405
|
+
class Aggregate < Yes::Core::Aggregate
|
|
1406
|
+
# Base aggregate-level Cerbos authorization
|
|
1407
|
+
authorize cerbos: true
|
|
1408
|
+
|
|
1409
|
+
command :change, :name, :string
|
|
1410
|
+
|
|
1411
|
+
# Command with customized Cerbos integration
|
|
1412
|
+
command :update_details do
|
|
1413
|
+
payload details: :string
|
|
1414
|
+
|
|
1415
|
+
# Command-level authorization with custom Cerbos integration
|
|
1416
|
+
authorize do
|
|
1417
|
+
# Override resource attributes sent to Cerbos
|
|
1418
|
+
resource_attributes { { owner_id: 'test-user-id' } }
|
|
1419
|
+
|
|
1420
|
+
# Override the entire Cerbos payload
|
|
1421
|
+
cerbos_payload { { principal: auth_data, resource_id: 'test-id' } }
|
|
1422
|
+
end
|
|
1423
|
+
end
|
|
1424
|
+
end
|
|
1425
|
+
end
|
|
1426
|
+
end
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
Inside the `resource_attributes` block, you can access:
|
|
1430
|
+
- `command` - The command being executed
|
|
1431
|
+
- `resource` - The read model instance for the aggregate
|
|
1432
|
+
|
|
1433
|
+
Inside the `cerbos_payload` block, you can access:
|
|
1434
|
+
- `command` - The command being executed
|
|
1435
|
+
- `resource` - The read model instance for the aggregate
|
|
1436
|
+
- `auth_data` - The decoded data from the JWT authentication token
|
|
1437
|
+
|
|
1438
|
+
These blocks allow you to precisely control what data is sent to Cerbos for authorization decisions on a per-command basis.
|
|
1439
|
+
|
|
1440
|
+
## Command API
|
|
1441
|
+
|
|
1442
|
+
The Command API (`yes-command-api`) provides an HTTP endpoint for executing commands as JSON batches. It is a standalone Rails engine that does **not** depend on the aggregate DSL — it works with any command class that follows one of the supported naming conventions.
|
|
1443
|
+
|
|
1444
|
+
### Command API Installation
|
|
1445
|
+
|
|
1446
|
+
Add the gem and mount the engine:
|
|
1447
|
+
|
|
1448
|
+
```ruby
|
|
1449
|
+
# Gemfile
|
|
1450
|
+
gem 'yes-command-api'
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
```ruby
|
|
1454
|
+
# config/routes.rb
|
|
1455
|
+
mount Yes::Command::Api::Engine => '/v1/commands'
|
|
1456
|
+
```
|
|
1457
|
+
|
|
1458
|
+
### Request Format
|
|
1459
|
+
|
|
1460
|
+
Send a `POST` request with a JSON body containing a `commands` array:
|
|
1461
|
+
|
|
1462
|
+
```json
|
|
1463
|
+
{
|
|
1464
|
+
"commands": [
|
|
1465
|
+
{
|
|
1466
|
+
"context": "Users",
|
|
1467
|
+
"subject": "User",
|
|
1468
|
+
"command": "ChangeName",
|
|
1469
|
+
"data": {
|
|
1470
|
+
"user_id": "47330036-7246-40b4-a3c7-7038df508774",
|
|
1471
|
+
"name": "Jane Doe"
|
|
1472
|
+
},
|
|
1473
|
+
"metadata": {}
|
|
1474
|
+
}
|
|
1475
|
+
],
|
|
1476
|
+
"channel": "my-notifications"
|
|
1477
|
+
}
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
Each command requires `context`, `subject`, `command`, and `data`. The optional `channel` parameter controls which MessageBus channel receives notifications (defaults to the authenticated user's `identity_id`).
|
|
1481
|
+
|
|
1482
|
+
Set `async=true` or `async=false` as a query parameter to override the default processing mode (`Yes::Core.configuration.process_commands_inline`).
|
|
1483
|
+
|
|
1484
|
+
### Command Class Resolution
|
|
1485
|
+
|
|
1486
|
+
The deserializer resolves command classes by trying three naming conventions in order:
|
|
1487
|
+
|
|
1488
|
+
| Priority | Convention | Class pattern | Typical use |
|
|
1489
|
+
|----------|-----------|---------------|-------------|
|
|
1490
|
+
| 1 | Command Group | `CommandGroups::<Command>::Command` | Composed commands |
|
|
1491
|
+
| 2 | V2 | `<Context>::<Subject>::Commands::<Command>::Command` | DSL-generated commands |
|
|
1492
|
+
| 3 | V1 | `<Context>::Commands::<Subject>::<Command>` | Manually created commands |
|
|
1493
|
+
|
|
1494
|
+
The first matching constant wins. This means you can use the API with DSL-generated commands, manually created commands, or both.
|
|
1495
|
+
|
|
1496
|
+
### Processing Pipeline
|
|
1497
|
+
|
|
1498
|
+
When a request arrives, it passes through these stages:
|
|
1499
|
+
|
|
1500
|
+
1. **Authentication** — the [auth adapter](#auth-adapter) verifies the JWT token
|
|
1501
|
+
2. **Params validation** — checks that each command hash contains `context`, `subject`, `command`, and `data`
|
|
1502
|
+
3. **Deserialization** — resolves class names and instantiates command objects
|
|
1503
|
+
4. **Expansion** — flattens command groups into individual commands
|
|
1504
|
+
5. **Authorization** — each command's authorizer is looked up and called with `auth_data`
|
|
1505
|
+
6. **Validation** — optional per-command validators are called
|
|
1506
|
+
7. **Command bus** — commands are dispatched (inline or via ActiveJob)
|
|
1507
|
+
|
|
1508
|
+
### Using Commands Without the DSL
|
|
1509
|
+
|
|
1510
|
+
You can create command classes manually and use them with the Command API. A complete command requires four parts: a **command**, a **handler**, an **event**, and an **authorizer**. The file structure follows a convention:
|
|
1511
|
+
|
|
1512
|
+
```
|
|
1513
|
+
app/contexts/
|
|
1514
|
+
billing/
|
|
1515
|
+
invoice/
|
|
1516
|
+
commands/
|
|
1517
|
+
authorizer.rb # shared base authorizer (optional)
|
|
1518
|
+
create/
|
|
1519
|
+
command.rb # command definition
|
|
1520
|
+
handler.rb # command handler
|
|
1521
|
+
authorizer.rb # per-command authorizer
|
|
1522
|
+
events/
|
|
1523
|
+
created.rb # event definition
|
|
1524
|
+
```
|
|
1525
|
+
|
|
1526
|
+
#### Command
|
|
1527
|
+
|
|
1528
|
+
Defines the payload attributes and identifies the aggregate:
|
|
1529
|
+
|
|
1530
|
+
```ruby
|
|
1531
|
+
# app/contexts/billing/invoice/commands/create/command.rb
|
|
1532
|
+
module Billing
|
|
1533
|
+
module Invoice
|
|
1534
|
+
module Commands
|
|
1535
|
+
module Create
|
|
1536
|
+
class Command < Yes::Core::Command
|
|
1537
|
+
attribute :invoice_id, Yes::Core::Types::UUID
|
|
1538
|
+
attribute :amount, Yes::Core::Types::Integer
|
|
1539
|
+
attribute :currency, Yes::Core::Types::String
|
|
1540
|
+
|
|
1541
|
+
alias aggregate_id invoice_id
|
|
1542
|
+
end
|
|
1543
|
+
end
|
|
1544
|
+
end
|
|
1545
|
+
end
|
|
1546
|
+
end
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
#### Handler
|
|
1550
|
+
|
|
1551
|
+
Processes the command and publishes the event. The handler inherits from `Yes::Core::Commands::Stateless::Handler` and declares which event to emit:
|
|
1552
|
+
|
|
1553
|
+
```ruby
|
|
1554
|
+
# app/contexts/billing/invoice/commands/create/handler.rb
|
|
1555
|
+
module Billing
|
|
1556
|
+
module Invoice
|
|
1557
|
+
module Commands
|
|
1558
|
+
module Create
|
|
1559
|
+
class Handler < Yes::Core::Commands::Stateless::Handler
|
|
1560
|
+
self.event_name = 'Created'
|
|
1561
|
+
|
|
1562
|
+
def call
|
|
1563
|
+
# Add guard logic here, e.g.:
|
|
1564
|
+
# no_change_transition('Already exists') if already_exists?
|
|
1565
|
+
|
|
1566
|
+
super # publishes the event
|
|
1567
|
+
end
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
end
|
|
1571
|
+
end
|
|
1572
|
+
end
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
#### Event
|
|
1576
|
+
|
|
1577
|
+
Defines the event schema for validation when writing to the event store:
|
|
1578
|
+
|
|
1579
|
+
```ruby
|
|
1580
|
+
# app/contexts/billing/invoice/events/created.rb
|
|
1581
|
+
module Billing
|
|
1582
|
+
module Invoice
|
|
1583
|
+
module Events
|
|
1584
|
+
class Created < Yes::Core::Event
|
|
1585
|
+
def schema
|
|
1586
|
+
Dry::Schema.Params do
|
|
1587
|
+
required(:invoice_id).value(Yes::Core::Types::UUID)
|
|
1588
|
+
required(:amount).value(:integer)
|
|
1589
|
+
required(:currency).value(:string)
|
|
1590
|
+
end
|
|
1591
|
+
end
|
|
1592
|
+
end
|
|
1593
|
+
end
|
|
1594
|
+
end
|
|
1595
|
+
end
|
|
1596
|
+
```
|
|
1597
|
+
|
|
1598
|
+
#### Authorizer
|
|
1599
|
+
|
|
1600
|
+
Controls who can execute the command. You can define a shared base authorizer for the aggregate and inherit from it:
|
|
1601
|
+
|
|
1602
|
+
```ruby
|
|
1603
|
+
# app/contexts/billing/invoice/commands/authorizer.rb
|
|
1604
|
+
module Billing
|
|
1605
|
+
module Invoice
|
|
1606
|
+
module Commands
|
|
1607
|
+
class Authorizer < Yes::Core::Authorization::CommandAuthorizer
|
|
1608
|
+
def self.call(_command, auth_data)
|
|
1609
|
+
raise CommandNotAuthorized, 'Not allowed' unless auth_data[:identity_id].present?
|
|
1610
|
+
end
|
|
1611
|
+
end
|
|
1612
|
+
end
|
|
1613
|
+
end
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# app/contexts/billing/invoice/commands/create/authorizer.rb
|
|
1617
|
+
module Billing
|
|
1618
|
+
module Invoice
|
|
1619
|
+
module Commands
|
|
1620
|
+
module Create
|
|
1621
|
+
class Authorizer < Billing::Invoice::Commands::Authorizer
|
|
1622
|
+
# Inherits base authorization; add command-specific checks here
|
|
1623
|
+
end
|
|
1624
|
+
end
|
|
1625
|
+
end
|
|
1626
|
+
end
|
|
1627
|
+
end
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
This command can then be executed via the API:
|
|
1631
|
+
|
|
1632
|
+
```json
|
|
1633
|
+
{
|
|
1634
|
+
"context": "Billing",
|
|
1635
|
+
"subject": "Invoice",
|
|
1636
|
+
"command": "Create",
|
|
1637
|
+
"data": {
|
|
1638
|
+
"invoice_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
1639
|
+
"amount": 10000,
|
|
1640
|
+
"currency": "CHF"
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
### Real-Time Command Notifications
|
|
1646
|
+
|
|
1647
|
+
For performance and reliability, WebSocket-based notifications are the preferred way to inform frontends about command execution status. The Command API ships with two built-in notifiers and supports custom implementations.
|
|
1648
|
+
|
|
1649
|
+
Notifiers are configured globally and broadcast three event types per command batch:
|
|
1650
|
+
|
|
1651
|
+
| Event | When | Payload includes |
|
|
1652
|
+
|-------|------|-----------------|
|
|
1653
|
+
| `batch_started` | Before processing begins | `batch_id`, commands list |
|
|
1654
|
+
| Per-command response | After each command completes | Command result or error |
|
|
1655
|
+
| `batch_finished` | After all commands complete | `batch_id`, failed commands (if any) |
|
|
1656
|
+
|
|
1657
|
+
#### Configuration
|
|
1658
|
+
|
|
1659
|
+
Register one or more notifier classes in the initializer:
|
|
1660
|
+
|
|
1661
|
+
```ruby
|
|
1662
|
+
Yes::Core.configure do |config|
|
|
1663
|
+
config.command_notifier_classes = [
|
|
1664
|
+
Yes::Command::Api::Commands::Notifiers::ActionCable,
|
|
1665
|
+
Yes::Command::Api::Commands::Notifiers::MessageBus
|
|
1666
|
+
]
|
|
1667
|
+
end
|
|
1668
|
+
```
|
|
1669
|
+
|
|
1670
|
+
The `channel` parameter from the API request (or the authenticated user's `identity_id` as fallback) is passed to each notifier, so clients only receive notifications for their own commands.
|
|
1671
|
+
|
|
1672
|
+
#### ActionCable Notifier
|
|
1673
|
+
|
|
1674
|
+
Broadcasts notifications via `ActionCable.server.broadcast`. This is well suited for use with a dedicated WebSocket gateway service that connects to the same Redis backend:
|
|
1675
|
+
|
|
1676
|
+
```ruby
|
|
1677
|
+
config.command_notifier_classes = [Yes::Command::Api::Commands::Notifiers::ActionCable]
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
The frontend subscribes to the channel and receives JSON messages:
|
|
1681
|
+
|
|
1682
|
+
```json
|
|
1683
|
+
{ "type": "batch_started", "batch_id": "abc-123", "published_at": 1711540800, "commands": [...] }
|
|
1684
|
+
{ "type": "batch_finished", "batch_id": "abc-123", "published_at": 1711540801, "failed_commands": [] }
|
|
1685
|
+
```
|
|
1686
|
+
|
|
1687
|
+
#### MessageBus Notifier
|
|
1688
|
+
|
|
1689
|
+
Uses the [MessageBus](https://github.com/discourse/message_bus) gem for long-polling or WebSocket delivery. Messages are scoped to the authenticated user via `user_ids`:
|
|
1690
|
+
|
|
1691
|
+
```ruby
|
|
1692
|
+
config.command_notifier_classes = [Yes::Command::Api::Commands::Notifiers::MessageBus]
|
|
1693
|
+
```
|
|
1694
|
+
|
|
1695
|
+
The auth adapter's `verify_token` method is used by MessageBus to identify subscribers by their `identity_id`.
|
|
1696
|
+
|
|
1697
|
+
#### Custom Notifiers
|
|
1698
|
+
|
|
1699
|
+
You can implement your own notifier by subclassing `Yes::Core::Commands::Notifier`:
|
|
1700
|
+
|
|
1701
|
+
```ruby
|
|
1702
|
+
class SlackNotifier < Yes::Core::Commands::Notifier
|
|
1703
|
+
def notify_batch_started(batch_id, transaction = nil, commands = nil)
|
|
1704
|
+
# ...
|
|
1705
|
+
end
|
|
1706
|
+
|
|
1707
|
+
def notify_batch_finished(batch_id, transaction = nil, responses = nil)
|
|
1708
|
+
# ...
|
|
1709
|
+
end
|
|
1710
|
+
|
|
1711
|
+
def notify_command_response(cmd_response)
|
|
1712
|
+
# ...
|
|
1713
|
+
end
|
|
1714
|
+
end
|
|
1715
|
+
```
|
|
1716
|
+
|
|
1717
|
+
## Read API
|
|
1718
|
+
|
|
1719
|
+
The Read API (`yes-read-api`) provides an HTTP endpoint for querying read models with filtering, pagination, and authorization. Like the Command API, it is a standalone Rails engine that does **not** depend on the aggregate DSL — it works with any ActiveRecord model that has a matching serializer.
|
|
1720
|
+
|
|
1721
|
+
### Read API Installation
|
|
1722
|
+
|
|
1723
|
+
Add the gem and mount the engine:
|
|
1724
|
+
|
|
1725
|
+
```ruby
|
|
1726
|
+
# Gemfile
|
|
1727
|
+
gem 'yes-read-api'
|
|
1728
|
+
```
|
|
1729
|
+
|
|
1730
|
+
```ruby
|
|
1731
|
+
# config/routes.rb
|
|
1732
|
+
mount Yes::Read::Api::Engine => '/queries'
|
|
1733
|
+
```
|
|
1734
|
+
|
|
1735
|
+
### Basic Queries
|
|
1736
|
+
|
|
1737
|
+
Send a `GET` request with the read model name as the path and optional query parameters:
|
|
1738
|
+
|
|
1739
|
+
```
|
|
1740
|
+
GET /queries/users?filters[ids]=1,2,3&order[name]=asc&page[number]=1&page[size]=20&include=company
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
- `filters[<key>]` — filter by attribute (handled by the model's filter class)
|
|
1744
|
+
- `order[<key>]` — sort direction (`asc` or `desc`)
|
|
1745
|
+
- `page[number]` and `page[size]` — pagination
|
|
1746
|
+
- `include` — comma-separated list of associations to include in the response
|
|
1747
|
+
|
|
1748
|
+
### Advanced Queries
|
|
1749
|
+
|
|
1750
|
+
Send a `POST` request for complex filtering with AND/OR logic:
|
|
1751
|
+
|
|
1752
|
+
```json
|
|
1753
|
+
{
|
|
1754
|
+
"model": "users",
|
|
1755
|
+
"filter_definition": {
|
|
1756
|
+
"type": "filter_set",
|
|
1757
|
+
"logical_operator": "and",
|
|
1758
|
+
"filters": [
|
|
1759
|
+
{
|
|
1760
|
+
"type": "filter",
|
|
1761
|
+
"attribute": "name",
|
|
1762
|
+
"operator": "is",
|
|
1763
|
+
"value": "Jane"
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
"type": "filter",
|
|
1767
|
+
"attribute": "status",
|
|
1768
|
+
"operator": "is_not",
|
|
1769
|
+
"value": "archived"
|
|
1770
|
+
}
|
|
1771
|
+
]
|
|
1772
|
+
},
|
|
1773
|
+
"order": { "name": "asc" },
|
|
1774
|
+
"page": { "number": 1, "size": 20 }
|
|
1775
|
+
}
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
### Filters
|
|
1779
|
+
|
|
1780
|
+
Filters are optional per-model classes that define available filter scopes. If no custom filter exists, the base `Yes::Core::ReadModel::Filter` is used.
|
|
1781
|
+
|
|
1782
|
+
```ruby
|
|
1783
|
+
module ReadModels
|
|
1784
|
+
module User
|
|
1785
|
+
class Filter < Yes::Core::ReadModel::Filter
|
|
1786
|
+
has_scope :name do |_controller, scope, value|
|
|
1787
|
+
scope.where(name: value)
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
has_scope :ids do |_controller, scope, value|
|
|
1791
|
+
scope.where(id: value.split(','))
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1794
|
+
private
|
|
1795
|
+
|
|
1796
|
+
def read_model_class
|
|
1797
|
+
::UserReadModel
|
|
1798
|
+
end
|
|
1799
|
+
end
|
|
1800
|
+
end
|
|
1801
|
+
end
|
|
1802
|
+
```
|
|
1803
|
+
|
|
1804
|
+
### Read API Authorization
|
|
1805
|
+
|
|
1806
|
+
The Read API enforces two levels of authorization:
|
|
1807
|
+
|
|
1808
|
+
1. **Request authorizer** — controls whether a user can query a given model at all. Looked up as `ReadModels::<Model>::RequestAuthorizer`.
|
|
1809
|
+
|
|
1810
|
+
```ruby
|
|
1811
|
+
module ReadModels
|
|
1812
|
+
module User
|
|
1813
|
+
class RequestAuthorizer
|
|
1814
|
+
def self.call(filter_options, auth_data)
|
|
1815
|
+
unless auth_data[:identity_id].present?
|
|
1816
|
+
raise Yes::Core::Authorization::ReadRequestAuthorizer::NotAuthorized, 'Not allowed'
|
|
1817
|
+
end
|
|
1818
|
+
end
|
|
1819
|
+
end
|
|
1820
|
+
end
|
|
1821
|
+
end
|
|
1822
|
+
```
|
|
1823
|
+
|
|
1824
|
+
2. **Read model authorizer** — filters returned records based on what the user can access. Configured via `Yes::Core::Authorization::ReadModelsAuthorizer`.
|
|
1825
|
+
|
|
1826
|
+
### Serializers
|
|
1827
|
+
|
|
1828
|
+
Each read model requires a serializer class following the convention `ReadModels::<Model>::Serializers::<Model>`. The serializer receives `auth_data` and filter options, allowing it to customize the response based on the authenticated user.
|
|
1829
|
+
|
|
1830
|
+
## Event Processing
|
|
1831
|
+
|
|
1832
|
+
### Subscriptions
|
|
1833
|
+
|
|
1834
|
+
Yes wraps [PgEventstore](https://github.com/yousty/pg_eventstore) subscriptions for processing events in real-time.
|
|
1835
|
+
|
|
1836
|
+
#### Setting Up Subscriptions
|
|
1837
|
+
|
|
1838
|
+
```ruby
|
|
1839
|
+
# lib/tasks/eventstore.rb
|
|
1840
|
+
subscriptions = Yes::Core::Subscriptions.new
|
|
1841
|
+
|
|
1842
|
+
subscriptions.subscribe_to_all(
|
|
1843
|
+
MyReadModel::Builder.new,
|
|
1844
|
+
{ event_types: ['MyContext::SomethingHappened', 'MyContext::SomethingElseHappened'] }
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
subscriptions.start
|
|
1848
|
+
```
|
|
1849
|
+
|
|
1850
|
+
Start subscriptions via the PgEventstore CLI:
|
|
1851
|
+
|
|
1852
|
+
```shell
|
|
1853
|
+
bundle exec pg-eventstore subscriptions start -r ./lib/tasks/eventstore.rb
|
|
1854
|
+
```
|
|
1855
|
+
|
|
1856
|
+
#### Heartbeat
|
|
1857
|
+
|
|
1858
|
+
Configure a heartbeat URL for monitoring subscription health:
|
|
1859
|
+
|
|
1860
|
+
```ruby
|
|
1861
|
+
Yes::Core.configure do |config|
|
|
1862
|
+
config.subscriptions_heartbeat_url = ENV['SUBSCRIPTIONS_HEARTBEAT_URL']
|
|
1863
|
+
config.subscriptions_heartbeat_interval = 30 # seconds
|
|
1864
|
+
end
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
### Process Managers
|
|
1868
|
+
|
|
1869
|
+
Process managers coordinate commands across services via HTTP.
|
|
1870
|
+
|
|
1871
|
+
#### ServiceClient
|
|
1872
|
+
|
|
1873
|
+
Sends commands to another service's command API:
|
|
1874
|
+
|
|
1875
|
+
```ruby
|
|
1876
|
+
client = Yes::Core::ProcessManagers::ServiceClient.new('media')
|
|
1877
|
+
# Resolves to MEDIA_SERVICE_URL env var or http://media-cluster-ip-service:3000
|
|
1878
|
+
|
|
1879
|
+
client.call(access_token: token, commands_data: [...], channel: '/notifications')
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
#### CommandRunner
|
|
1883
|
+
|
|
1884
|
+
Base class for process managers that publish commands to external services:
|
|
1885
|
+
|
|
1886
|
+
```ruby
|
|
1887
|
+
class MyProcessManager < Yes::Core::ProcessManagers::CommandRunner
|
|
1888
|
+
def call(event)
|
|
1889
|
+
publish(
|
|
1890
|
+
client_id: ENV['MY_CLIENT_ID'],
|
|
1891
|
+
client_secret: ENV['MY_CLIENT_SECRET'],
|
|
1892
|
+
commands_data: build_commands(event)
|
|
1893
|
+
)
|
|
1894
|
+
end
|
|
1895
|
+
end
|
|
1896
|
+
```
|
|
1897
|
+
|
|
1898
|
+
#### State
|
|
1899
|
+
|
|
1900
|
+
Reconstructs entity state from events for use in process managers:
|
|
1901
|
+
|
|
1902
|
+
```ruby
|
|
1903
|
+
class UserState < Yes::Core::ProcessManagers::State
|
|
1904
|
+
RELEVANT_EVENTS = ['Auth::UserCreated', 'Auth::UserNameChanged'].freeze
|
|
1905
|
+
|
|
1906
|
+
attr_reader :name
|
|
1907
|
+
|
|
1908
|
+
private
|
|
1909
|
+
|
|
1910
|
+
def stream
|
|
1911
|
+
PgEventstore::Stream.new(context: 'Auth', stream_name: 'User', stream_id: @id)
|
|
1912
|
+
end
|
|
1913
|
+
|
|
1914
|
+
def required_attributes
|
|
1915
|
+
[:name]
|
|
1916
|
+
end
|
|
1917
|
+
|
|
1918
|
+
def apply_user_name_changed(event)
|
|
1919
|
+
@name = event.data['name']
|
|
1920
|
+
end
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
state = UserState.load(user_id)
|
|
1924
|
+
state.valid? # true if all required_attributes are present
|
|
1925
|
+
```
|
|
1926
|
+
|
|
1927
|
+
## Configuration Reference
|
|
1928
|
+
|
|
1929
|
+
```ruby
|
|
1930
|
+
Yes::Core.configure do |config|
|
|
1931
|
+
# Command processing
|
|
1932
|
+
config.process_commands_inline = true # Process commands synchronously (default: true)
|
|
1933
|
+
config.command_notifier_classes = [] # Array of notifier classes for command batch notifications
|
|
1934
|
+
|
|
1935
|
+
# Authentication
|
|
1936
|
+
config.auth_adapter = nil # Auth adapter instance (required for command/read APIs)
|
|
1937
|
+
|
|
1938
|
+
# Cerbos Authorization
|
|
1939
|
+
config.cerbos_url = ENV['CERBOS_URL'] # Cerbos server URL (default from env var)
|
|
1940
|
+
config.cerbos_principal_data_builder = -> {} # Lambda to build Cerbos principal data for commands
|
|
1941
|
+
config.cerbos_read_principal_data_builder = nil # Lambda for read requests (falls back to above)
|
|
1942
|
+
config.cerbos_commands_authorizer_include_metadata = false
|
|
1943
|
+
config.cerbos_read_authorizer_include_metadata = false
|
|
1944
|
+
config.cerbos_read_authorizer_actions = %w[read]
|
|
1945
|
+
config.cerbos_read_authorizer_resource_id_prefix = 'read-'
|
|
1946
|
+
config.cerbos_read_authorizer_principal_anonymous_id = 'anonymous'
|
|
1947
|
+
config.super_admin_check = ->(_auth_data) { false }
|
|
1948
|
+
|
|
1949
|
+
# Subscriptions
|
|
1950
|
+
config.subscriptions_heartbeat_url = nil # URL to ping for subscription health monitoring
|
|
1951
|
+
config.subscriptions_heartbeat_interval = 30 # Heartbeat interval in seconds
|
|
1952
|
+
|
|
1953
|
+
# Observability
|
|
1954
|
+
config.otl_tracer = nil # OpenTelemetry tracer instance
|
|
1955
|
+
config.logger = Rails.logger # Logger instance
|
|
1956
|
+
|
|
1957
|
+
# Error reporting
|
|
1958
|
+
config.error_reporter = nil # Callable for error reporting (e.g. Sentry)
|
|
1959
|
+
end
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1962
|
+
## Testing
|
|
1963
|
+
|
|
1964
|
+
yes-core ships with a test DSL for writing concise aggregate command specs. Add to your `spec_helper.rb` or `rails_helper.rb`:
|
|
1965
|
+
|
|
1966
|
+
```ruby
|
|
1967
|
+
require 'yes/core/test_support'
|
|
1968
|
+
|
|
1969
|
+
RSpec.configure do |config|
|
|
1970
|
+
config.include Yes::Core::TestSupport::EventHelpers
|
|
1971
|
+
end
|
|
1972
|
+
```
|
|
1973
|
+
|
|
1974
|
+
### Aggregate Test DSL
|
|
1975
|
+
|
|
1976
|
+
Specs with `type: :aggregate` automatically get the command test DSL:
|
|
1977
|
+
|
|
1978
|
+
```ruby
|
|
1979
|
+
RSpec.describe MyContext::Order::Aggregate, type: :aggregate do
|
|
1980
|
+
it { is_expected.to have_cerbos_authorizer.with_read_model_class(Order) }
|
|
1981
|
+
it { is_expected.to have_read_model_class(Order) }
|
|
1982
|
+
it { is_expected.to have_parent('customer').with_context('CustomerManagement') }
|
|
1983
|
+
|
|
1984
|
+
command 'confirm' do
|
|
1985
|
+
let(:command_data) { { confirmed_at: Time.current } }
|
|
1986
|
+
let(:success_attributes) { { confirmed: true } }
|
|
1987
|
+
|
|
1988
|
+
# Tests successful execution, state change, and event publishing
|
|
1989
|
+
success
|
|
1990
|
+
|
|
1991
|
+
# Tests with custom setup
|
|
1992
|
+
success 'when order was previously cancelled' do
|
|
1993
|
+
setup do
|
|
1994
|
+
aggregate.confirm
|
|
1995
|
+
aggregate.cancel
|
|
1996
|
+
end
|
|
1997
|
+
end
|
|
1998
|
+
|
|
1999
|
+
# Tests that guard raises InvalidTransition
|
|
2000
|
+
invalid 'order has been removed' do
|
|
2001
|
+
setup { aggregate.remove }
|
|
2002
|
+
end
|
|
2003
|
+
|
|
2004
|
+
# Executes command twice — second time should raise NoChangeTransition
|
|
2005
|
+
no_change
|
|
2006
|
+
end
|
|
2007
|
+
end
|
|
2008
|
+
```
|
|
2009
|
+
|
|
2010
|
+
The `command` block automatically defines:
|
|
2011
|
+
- `aggregate` — a new instance of the described class
|
|
2012
|
+
- `subject` — executes the command with `command_data`
|
|
2013
|
+
- `expected_event_type` — derived from context, aggregate, and command name
|
|
2014
|
+
- `success_attributes` — defaults to `command_data` (override as needed)
|
|
2015
|
+
|
|
2016
|
+
### DSL Methods
|
|
2017
|
+
|
|
2018
|
+
| Method | Description |
|
|
2019
|
+
|--------|-------------|
|
|
2020
|
+
| `command 'name'` | Defines a command test block with aggregate, subject, and default lets |
|
|
2021
|
+
| `success` | Asserts command changes state and publishes expected event |
|
|
2022
|
+
| `invalid 'reason'` | Asserts command raises `InvalidTransition` error |
|
|
2023
|
+
| `no_change` | Asserts duplicate command raises `NoChangeTransition` error |
|
|
2024
|
+
| `setup { ... }` | Alias for `before` — sets up aggregate state before assertions |
|
|
2025
|
+
|
|
2026
|
+
For draft commands, pass `draft: true`:
|
|
2027
|
+
|
|
2028
|
+
```ruby
|
|
2029
|
+
command 'change_name', draft: true do
|
|
2030
|
+
let(:command_data) { { name: 'New Name' } }
|
|
2031
|
+
success
|
|
2032
|
+
end
|
|
2033
|
+
```
|
|
2034
|
+
|
|
2035
|
+
### Command Group Test DSL
|
|
2036
|
+
|
|
2037
|
+
Command groups have a parallel set of helpers — `command_group`, `success_group`, `invalid_group`, `no_change_group` — that mirror the per-command DSL but produce assertions about the `CommandGroupResponse` (multiple events, cumulative read-model state).
|
|
2038
|
+
|
|
2039
|
+
```ruby
|
|
2040
|
+
RSpec.describe Companies::Apprenticeship::Aggregate, type: :aggregate do
|
|
2041
|
+
command_group 'create_apprenticeship' do
|
|
2042
|
+
let(:command_data) do
|
|
2043
|
+
{
|
|
2044
|
+
company_id: SecureRandom.uuid,
|
|
2045
|
+
user_id: SecureRandom.uuid,
|
|
2046
|
+
name: 'Acme Apprenticeship',
|
|
2047
|
+
description: 'Software dev role'
|
|
2048
|
+
}
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
let(:success_attributes) do
|
|
2052
|
+
{ name: 'Acme Apprenticeship', description: 'Software dev role' }
|
|
2053
|
+
end
|
|
2054
|
+
|
|
2055
|
+
# Asserts: response is success, all sub-events publish in declaration order,
|
|
2056
|
+
# read model reflects the cumulative state.
|
|
2057
|
+
success_group
|
|
2058
|
+
|
|
2059
|
+
# Asserts: response is failure, error is InvalidTransition, events array is empty.
|
|
2060
|
+
invalid_group 'company_id is missing' do
|
|
2061
|
+
let(:command_data) { super().merge(company_id: nil) }
|
|
2062
|
+
end
|
|
2063
|
+
|
|
2064
|
+
# Asserts: NoChangeTransition when running the group twice.
|
|
2065
|
+
no_change_group
|
|
2066
|
+
end
|
|
2067
|
+
end
|
|
2068
|
+
```
|
|
2069
|
+
|
|
2070
|
+
The `command_group` block automatically defines:
|
|
2071
|
+
- `aggregate` — a new instance of the described class
|
|
2072
|
+
- `subject` — executes the group with `command_data`
|
|
2073
|
+
- `expected_event_types` — array of expected event types in declaration order, derived from each sub-command's `event_name` and the aggregate's context/name (with draft prefix handling)
|
|
2074
|
+
- `success_attributes` — defaults to `command_data` (override as needed)
|
|
2075
|
+
|
|
2076
|
+
| Method | Description |
|
|
2077
|
+
|--------|-------------|
|
|
2078
|
+
| `command_group 'name'` | Defines a command_group test block with aggregate, subject, and default lets |
|
|
2079
|
+
| `success_group` | Asserts the group publishes all expected events and read model reflects cumulative state |
|
|
2080
|
+
| `invalid_group 'reason'` | Asserts the group's guard fails with `InvalidTransition` and no events publish |
|
|
2081
|
+
| `no_change_group` | Asserts a duplicate group invocation raises `NoChangeTransition` |
|
|
2082
|
+
| `setup { ... }` | Alias for `before` — works inside `command_group` too |
|
|
2083
|
+
|
|
2084
|
+
For draftable aggregates, pass `draft: true` exactly like the per-command form:
|
|
2085
|
+
|
|
2086
|
+
```ruby
|
|
2087
|
+
command_group 'create_apprenticeship', draft: true do
|
|
2088
|
+
let(:command_data) { { ... } }
|
|
2089
|
+
success_group
|
|
2090
|
+
end
|
|
2091
|
+
```
|
|
2092
|
+
|
|
2093
|
+
### Event Helpers
|
|
2094
|
+
|
|
2095
|
+
Available via `Yes::Core::TestSupport::EventHelpers`:
|
|
2096
|
+
|
|
2097
|
+
```ruby
|
|
2098
|
+
# Append events from other contexts for cross-aggregate setup
|
|
2099
|
+
given_events do
|
|
2100
|
+
[{ context: 'Shipping', aggregate: 'Shipment', event: 'Dispatched', data: { shipment_id: id } }]
|
|
2101
|
+
end
|
|
2102
|
+
|
|
2103
|
+
# Low-level event operations
|
|
2104
|
+
append_event(stream, event)
|
|
2105
|
+
append_and_reload_event(stream, event)
|
|
2106
|
+
read_events(stream) # returns [] if stream not found
|
|
2107
|
+
```
|
|
2108
|
+
|
|
2109
|
+
### Aggregate Matchers
|
|
2110
|
+
|
|
2111
|
+
```ruby
|
|
2112
|
+
# Check authorizer configuration
|
|
2113
|
+
it { is_expected.to have_authorizer }
|
|
2114
|
+
it { is_expected.to have_cerbos_authorizer }
|
|
2115
|
+
it { is_expected.to have_cerbos_authorizer.with_read_model_class(Order) }
|
|
2116
|
+
it { is_expected.to have_cerbos_authorizer.with_resource_name('order') }
|
|
2117
|
+
|
|
2118
|
+
# Check read model
|
|
2119
|
+
it { is_expected.to have_read_model_class(Order) }
|
|
2120
|
+
|
|
2121
|
+
# Check parent aggregates
|
|
2122
|
+
it { is_expected.to have_parent('company') }
|
|
2123
|
+
it { is_expected.to have_parent('company').with_context('CompanyManagement') }
|
|
2124
|
+
```
|
|
2125
|
+
|
|
2126
|
+
## Development
|
|
2127
|
+
|
|
2128
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
|
2129
|
+
|
|
2130
|
+
Start PG EventStore using Docker:
|
|
2131
|
+
|
|
2132
|
+
```shell
|
|
2133
|
+
docker compose up
|
|
2134
|
+
```
|
|
2135
|
+
|
|
2136
|
+
Setup databases:
|
|
2137
|
+
|
|
2138
|
+
```shell
|
|
2139
|
+
./bin/setup_db
|
|
2140
|
+
```
|
|
2141
|
+
|
|
2142
|
+
Enter a development console (from a gem's `spec/dummy` directory):
|
|
2143
|
+
|
|
2144
|
+
```shell
|
|
2145
|
+
bundle exec rails c
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
### Example Usage
|
|
2149
|
+
|
|
2150
|
+
```ruby
|
|
2151
|
+
user = Test::User::Aggregate.new
|
|
2152
|
+
user.change_name(name: "John Doe")
|
|
2153
|
+
user.name # => "John Doe"
|
|
2154
|
+
TestUser.last.name # => "John Doe"
|
|
2155
|
+
```
|
|
2156
|
+
|
|
2157
|
+
### Testing the APIs
|
|
2158
|
+
|
|
2159
|
+
The dummy app includes mounted command and read APIs for testing. Start the server from one of the gem dummy apps:
|
|
2160
|
+
|
|
2161
|
+
```shell
|
|
2162
|
+
cd yes-core/spec/dummy
|
|
2163
|
+
bundle exec rails s
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
#### Authentication
|
|
2167
|
+
|
|
2168
|
+
The dummy app uses a simple Base64-encoded auth adapter for development. Generate a token:
|
|
2169
|
+
|
|
2170
|
+
```ruby
|
|
2171
|
+
require 'base64'
|
|
2172
|
+
user_id = "47330036-7246-40b4-a3c7-7038df508774"
|
|
2173
|
+
token = Base64.strict_encode64({ identity_id: user_id, user_id: user_id }.to_json)
|
|
2174
|
+
```
|
|
2175
|
+
|
|
2176
|
+
Or from the command line:
|
|
2177
|
+
|
|
2178
|
+
```shell
|
|
2179
|
+
TOKEN=$(echo -n '{"identity_id":"47330036-7246-40b4-a3c7-7038df508774","user_id":"47330036-7246-40b4-a3c7-7038df508774"}' | base64)
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
#### Testing Command API
|
|
2183
|
+
|
|
2184
|
+
Execute a command with curl:
|
|
2185
|
+
|
|
2186
|
+
```shell
|
|
2187
|
+
curl --location 'http://127.0.0.1:3000/commands' \
|
|
2188
|
+
--header 'Content-Type: application/json' \
|
|
2189
|
+
--header "Authorization: Bearer $TOKEN" \
|
|
2190
|
+
--data '{
|
|
2191
|
+
"commands": [{
|
|
2192
|
+
"subject": "User",
|
|
2193
|
+
"context": "Test",
|
|
2194
|
+
"command": "ChangeName",
|
|
2195
|
+
"data": {
|
|
2196
|
+
"user_id": "47330036-7246-40b4-a3c7-7038df508774",
|
|
2197
|
+
"name": "Judydoody Doodle"
|
|
2198
|
+
}
|
|
2199
|
+
}],
|
|
2200
|
+
"channel": "test-notifications"
|
|
2201
|
+
}'
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
#### Testing Read API
|
|
2205
|
+
|
|
2206
|
+
Query the read models:
|
|
2207
|
+
|
|
2208
|
+
```shell
|
|
2209
|
+
curl --location 'http://127.0.0.1:3000/queries/test_users' \
|
|
2210
|
+
--header 'Content-Type: application/json' \
|
|
2211
|
+
--header "Authorization: Bearer $TOKEN"
|
|
2212
|
+
```
|
|
2213
|
+
|
|
2214
|
+
### Running Specs
|
|
2215
|
+
|
|
2216
|
+
Each gem has its own test suite that runs in isolation with its own bundle context.
|
|
2217
|
+
|
|
2218
|
+
Run specs for a single gem:
|
|
2219
|
+
|
|
2220
|
+
```shell
|
|
2221
|
+
rake yes_core:spec
|
|
2222
|
+
rake yes_command_api:spec
|
|
2223
|
+
rake yes_read_api:spec
|
|
2224
|
+
```
|
|
2225
|
+
|
|
2226
|
+
Run specs for all gems:
|
|
2227
|
+
|
|
2228
|
+
```shell
|
|
2229
|
+
rake spec
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
You can also run specs directly from within a gem directory:
|
|
2233
|
+
|
|
2234
|
+
```shell
|
|
2235
|
+
cd yes-core && bundle exec rspec spec
|
|
2236
|
+
```
|
|
2237
|
+
|
|
2238
|
+
### Gem Installation
|
|
2239
|
+
|
|
2240
|
+
Install the gem locally:
|
|
2241
|
+
|
|
2242
|
+
```shell
|
|
2243
|
+
bundle exec rake install
|
|
2244
|
+
```
|
|
2245
|
+
|
|
2246
|
+
## Contributing
|
|
2247
|
+
|
|
2248
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/yousty/yes. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
|
|
2249
|
+
|
|
2250
|
+
## Changelog
|
|
2251
|
+
|
|
2252
|
+
See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
|
|
2253
|
+
|
|
2254
|
+
## License
|
|
2255
|
+
|
|
2256
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|