rdux 0.9.1 โ 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +260 -24
- data/app/models/rdux/action.rb +2 -2
- data/lib/rdux/version.rb +1 -1
- data/lib/rdux.rb +8 -8
- metadata +16 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dbb84ff27b098d28ad7395b590852b257851825cef51837d01a48c353e2b528e
|
4
|
+
data.tar.gz: b6bab2fc5e8685bf8ec774e1b3c76e8b533fe0f5e3cbe85d44564f4f04067202
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bc9bd6c320bbbfe0f058234dee3c4729d64aa2a56d0568f26e896fc1fd9a63116a43d8e2634dc68995cb5f4fadb1cc6b30fdeabeee486e67aeedc3eb6e3c9294
|
7
|
+
data.tar.gz: 86eabdd326c7d46a3654b43f7e06faf79c4af7e00122ce25dda9b49ae1307df7436e536199c7ed31b4d6bdd08b9652abc3e7430eb18b72e936d5845b232b3467
|
data/README.md
CHANGED
@@ -1,54 +1,285 @@
|
|
1
|
-
# Rdux
|
2
|
-
Minimal take on event sourcing.
|
1
|
+
# Rdux - A Minimal Event Sourcing Plugin for Rails
|
3
2
|
|
4
|
-
|
3
|
+
![Logo](docs/logo.webp)
|
4
|
+
|
5
|
+
Rdux is a lightweight, minimalistic Rails plugin designed to introduce event sourcing and audit logging capabilities to your Rails application. With Rdux, you can efficiently track and store the history of actions performed within your app, offering transparency and traceability for key processes.
|
6
|
+
|
7
|
+
**Key Features**
|
8
|
+
|
9
|
+
* **Audit Logging** ๐ Rdux stores sanitized input data, the name of module or class (action) responsible for processing them, processing results, and additional metadata in the database.
|
10
|
+
* **Model Representation** ๐ Before action is executed it gets stored in the database through the `Rdux::Action` model. `Rdux::Action` is converted to the `Rdux::FailedAction` when it fails. These models can be nested, allowing for complex action structures.
|
11
|
+
* **Revert and Retry** ๐ `Rdux::Action` can be reverted or retried.
|
12
|
+
|
13
|
+
Rdux is designed to integrate seamlessly with your existing Rails application, offering a straightforward and powerful solution for managing and auditing key actions.
|
14
|
+
|
15
|
+
## ๐ฒ Instalation
|
16
|
+
|
17
|
+
Add this line to your application's Gemfile:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'rdux'
|
21
|
+
```
|
22
|
+
|
23
|
+
And then execute:
|
24
|
+
|
25
|
+
```bash
|
26
|
+
$ bundle
|
27
|
+
```
|
28
|
+
|
29
|
+
Or install it yourself as:
|
30
|
+
|
31
|
+
```bash
|
32
|
+
$ gem install rdux
|
33
|
+
```
|
34
|
+
|
35
|
+
Then install and run migrations:
|
5
36
|
|
6
37
|
```bash
|
7
38
|
$ bin/rails rdux:install:migrations
|
8
39
|
$ bin/rails db:migrate
|
9
40
|
```
|
10
41
|
|
11
|
-
|
42
|
+
โ ๏ธ Note: Rdux uses `JSONB` datatype instead of `text` for Postgres.
|
43
|
+
|
44
|
+
## ๐ฎ Usage
|
45
|
+
|
46
|
+
### ๐ Dispatching an action
|
47
|
+
|
48
|
+
To dispatch an action using Rdux, use the `dispatch` method (aliased as `perform`).
|
49
|
+
|
50
|
+
Definition:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
def dispatch(action_name, payload, opts = {}, meta: nil)
|
54
|
+
|
55
|
+
alias perform dispatch
|
56
|
+
```
|
57
|
+
|
58
|
+
Arguments:
|
59
|
+
|
60
|
+
* `action_name`: The name of the service, class, or module that will process the action. This is persisted as an instance of `Rdux::Action` in the database, with its `name` attribute set to `action_name`. The `action_name` should correspond to the class or module that implements the `call` or `up` method, referred to as "action" or โaction performer.โ
|
61
|
+
* `payload` (Hash): The input data passed as the first argument to the `call` or `up` method of the action performer. This is sanitized and stored in the database before being processed. The keys in the `payload` are stringified during deserialization.
|
62
|
+
* `opts` (Hash): Optional parameters passed as the second argument to the `call` or `up` method, if defined. This is useful when you want to avoid redundant database queries (e.g., if you already have an ActiveRecord object available). There is a helper that facitilates this use case. The implementation is clear enough IMO `(opts[:ars] || {}).each { |k, v| payload["#{k}_id"] = v.id }`. `:ars` means ActiveRecords. Note that `opts` is not stored in the database and `payload` should be fully sufficient to perform an **action**. `opts` provides an optimization.
|
63
|
+
* `meta` (Hash): Additional metadata stored in the database alongside the `action_name` and `payload`. The `stream` key is particularly useful for scoping actions during reversions. For example, you can construct a `stream` based on the owner of action.
|
12
64
|
|
13
|
-
|
65
|
+
Example:
|
14
66
|
|
15
67
|
```ruby
|
16
68
|
Rdux.perform(
|
17
|
-
|
18
|
-
{
|
19
|
-
{
|
69
|
+
Task::Create,
|
70
|
+
{ task: { name: 'Foo bar baz' } },
|
71
|
+
{ ars: { user: current_user } },
|
20
72
|
meta: {
|
21
|
-
stream: { user_id:
|
73
|
+
stream: { user_id: current_user.id, context: 'foo' },
|
74
|
+
bar: 'baz'
|
22
75
|
}
|
23
76
|
)
|
24
77
|
```
|
25
78
|
|
26
|
-
|
79
|
+
### ๐ Flow diagram
|
80
|
+
|
81
|
+
![Flow Diagram](docs/flow.png)
|
82
|
+
|
83
|
+
### ๐ช Action
|
84
|
+
|
85
|
+
An action in Rdux is a Plain Old Ruby Object (PORO) that implements a class or instance method `call` or `up`.
|
86
|
+
This method must return an `Rdux::Result` `struct`.
|
87
|
+
Optionally, an action can implement a class or instance method `down` to specify how to revert it.
|
88
|
+
|
89
|
+
#### Action Structure:
|
90
|
+
|
91
|
+
* `call` or `up` method: Accepts a required `payload` and an optional `opts` argument. This method processes the action and returns an `Rdux::Result`.
|
92
|
+
* `down` method: Accepts the deserialized `down_payload` which is one of arguments of the `Rdux::Result` `struct` returned by the `up` method on success and saved in DB. `down` method can optionally accept the 2nd argument (Hash) which `:nested` key contains nested `Rdux::Action`s
|
93
|
+
|
94
|
+
See [๐ Dispatching an action](#-dispatching-an-action) section.
|
95
|
+
|
96
|
+
Examples:
|
27
97
|
|
28
98
|
```ruby
|
29
|
-
|
99
|
+
# app/actions/task/create.rb
|
100
|
+
|
101
|
+
class Task
|
102
|
+
class Create
|
103
|
+
def up(payload, opts)
|
104
|
+
user = opts.dig(:ars, :user) || User.find(payload['user_id'])
|
105
|
+
task = user.tasks.new(payload['task'])
|
106
|
+
if task.save
|
107
|
+
Rdux::Result[ok: true, down_payload: { user_id: user.id, task_id: task.id }, val: { task: }]
|
108
|
+
else
|
109
|
+
Rdux::Result[false, { errors: task.errors }]
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def down(payload)
|
114
|
+
Delete.up(payload)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
30
118
|
```
|
31
119
|
|
32
|
-
|
33
|
-
|
120
|
+
```ruby
|
121
|
+
# app/actions/task/delete.rb
|
122
|
+
|
123
|
+
class Task
|
124
|
+
module Delete
|
125
|
+
def self.up(payload)
|
126
|
+
user = User.find(payload['user_id'])
|
127
|
+
task = user.tasks.find(payload['task_id'])
|
128
|
+
task.destroy
|
129
|
+
Rdux::Result[true, { task: task.attributes }]
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
```
|
134
|
+
|
135
|
+
#### Suggested Directory Structure:
|
136
|
+
|
137
|
+
The location that is often used for entities like actions accross code bases is `app/services`.
|
138
|
+
This directory is de facto the bag of random objects.
|
139
|
+
I'd recomment to place actions inside `app/actions` for better organization and consistency.
|
140
|
+
Actions are consistent in terms of structure, input and output data.
|
141
|
+
They are good canditates to create a new layer in Rails apps.
|
142
|
+
|
143
|
+
Structure:
|
144
|
+
|
145
|
+
```
|
146
|
+
.
|
147
|
+
โโโ app/actions/
|
148
|
+
โโโ activity/
|
149
|
+
โ โโโ common/
|
150
|
+
โ โ โโโ fetch.rb
|
151
|
+
โ โโโ create.rb
|
152
|
+
โ โโโ stop.rb
|
153
|
+
โ โโโ switch.rb
|
154
|
+
โโโ task/
|
155
|
+
โ โโโ create.rb
|
156
|
+
โ โโโ delete.rb
|
157
|
+
โโโ misc/
|
158
|
+
โโโ create_attachment.rb
|
159
|
+
```
|
160
|
+
|
161
|
+
The [dedicated page about actions](docs/ACTIONS.md) contains more arguments in favor of actions.
|
162
|
+
|
163
|
+
### โฉ๏ธ Returned `struct` `Rdux::Result`
|
164
|
+
|
165
|
+
Definition:
|
34
166
|
|
35
167
|
```ruby
|
36
|
-
|
168
|
+
module Rdux
|
169
|
+
Result = Struct.new(:ok, :down_payload, :val, :up_result, :save, :after_save, :nested, :action) do
|
170
|
+
def val
|
171
|
+
self[:val] || down_payload
|
172
|
+
end
|
173
|
+
|
174
|
+
def save_failed?
|
175
|
+
ok == false && save
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
37
179
|
```
|
38
180
|
|
39
|
-
|
40
|
-
|
41
|
-
|
181
|
+
Arguments:
|
182
|
+
|
183
|
+
* `ok` (Boolean): Indicates whether the action was successful. If `true`, the `Rdux::Action` is persisted in the database.
|
184
|
+
* `down_payload` (Hash): Passed to the actionโs `down` method during reversion (`down` method is called on `Rdux::Action`). It does not have to be defined if an action does not implement the `down` method. `down_payload` is saved in the DB.
|
185
|
+
* `val` (Hash): Contains any additional data to return besides down_payload.
|
186
|
+
* `up_result` (Hash): Stores data related to the actionโs execution, such as created record IDs, DB changes, responses from 3rd parties, etc.
|
187
|
+
* `save` (Boolean): If `true` and `ok` is `false`, the action is saved as a `Rdux::FailedAction`.
|
188
|
+
* `after_save` (Proc): Called just before the `dispatch` method returns the `Rdux::Result` with `Rdux::Action` or `Rdux::FailedAction` as an argument.
|
189
|
+
* `nested` (Array of `Rdux::Result`): `Rdux::Action` can be connected with other `rdux_actions`. `Rdux::FailedAction` can be connected with other `rdux_actions` and `rdux_failed_actions`. To establish an association, a given action must `Rdux.dispatch` other actions in the `up` or `call` method and add the returned by the `dispatch` value (`Rdux::Result`) to the `:nested` array
|
190
|
+
* `action`: Rdux assigns `Rdux::Action` or `Rdux::FailedAction` to this argument
|
191
|
+
|
192
|
+
### โฎ๏ธ Reverting an Action
|
193
|
+
|
194
|
+
To revert an action, call the `down` method on the persisted in DB `Rdux::Action` instance.
|
195
|
+
The `Rdux::Action` must have a `down_payload` defined and the action (action performer) must have the `down` method implemented.
|
196
|
+
|
197
|
+
![Revert action](docs/down.png)
|
198
|
+
|
199
|
+
The `down_at` attribute is set upon successful reversion. Actions cannot be reverted if there are newer, unreverted actions in the same stream (if defined) or in general. See `meta` in [๐ Dispatching an action](#-dispatching-an-action) section.
|
200
|
+
|
201
|
+
### ๐ฟ Data model
|
202
|
+
|
203
|
+
```ruby
|
204
|
+
payload = {
|
205
|
+
task: { 'name' => 'Foo bar baz' },
|
206
|
+
user_id: 159163583
|
207
|
+
}
|
208
|
+
|
209
|
+
res = Rdux.dispatch(Task::Create, payload)
|
210
|
+
|
211
|
+
res.action
|
212
|
+
# #<Rdux::Action:0x000000011c4d8e98
|
213
|
+
# id: 1,
|
214
|
+
# name: "Task::Create",
|
215
|
+
# up_payload: {"task"=>{"name"=>"Foo bar baz"}, "user_id"=>159163583},
|
216
|
+
# down_payload: {"task_id"=>207620945},
|
217
|
+
# down_at: nil,
|
218
|
+
# up_payload_sanitized: false,
|
219
|
+
# up_result: nil,
|
220
|
+
# meta: {},
|
221
|
+
# stream_hash: nil,
|
222
|
+
# rdux_action_id: nil,
|
223
|
+
# rdux_failed_action_id: nil,
|
224
|
+
# created_at: Fri, 28 Jun 2024 21:35:36.838898000 UTC +00:00,
|
225
|
+
# updated_at: Fri, 28 Jun 2024 21:35:36.839728000 UTC +00:00>>
|
226
|
+
|
227
|
+
res.action.down
|
42
228
|
```
|
43
229
|
|
44
|
-
|
45
|
-
|
46
|
-
|
230
|
+
### ๐ท Sanitization
|
231
|
+
|
232
|
+
When calling `Rdux.perform`, the `up_payload` is sanitized using `Rails.application.config.filter_parameters` before saving to the database.
|
233
|
+
The actionโs `up` or `call` method receives the unsanitized version.
|
234
|
+
Note that if the `up_payload` is sanitized, the `Rdux::Action` cannot be retried via calling the `#up` method.
|
235
|
+
|
236
|
+
### ๐ฃ๏ธ Queries
|
237
|
+
|
238
|
+
Most likely, it won't be needed to save a `Rdux::Action` for every request a Rails app receives.
|
239
|
+
The suggested approach is to save `Rdux::Action`s for Create, Update, and Delete (CUD) operations.
|
240
|
+
This approach organically creates a new layer - queries in addition to actions.
|
241
|
+
Thus, it is required to call `Rdux.perform` only for actions.
|
242
|
+
|
243
|
+
An example approach is to create the `perform` method that calls `Rdux.perform` or a query depending on the presence of `action` or `query` keywords.
|
244
|
+
This method can set `meta` attributes, fulfill params validation, etc.
|
245
|
+
|
246
|
+
Example:
|
247
|
+
|
248
|
+
```ruby
|
249
|
+
class TasksController < ApiController
|
250
|
+
def show
|
251
|
+
perform(
|
252
|
+
query: Task::Show,
|
253
|
+
payload: { id: params[:id] }
|
254
|
+
)
|
255
|
+
end
|
256
|
+
|
257
|
+
def create
|
258
|
+
perform(
|
259
|
+
action: Task::Create,
|
260
|
+
payload: create_task_params
|
261
|
+
)
|
262
|
+
end
|
263
|
+
end
|
47
264
|
```
|
48
265
|
|
49
|
-
|
266
|
+
### ๐ต๏ธ Indexing
|
50
267
|
|
51
|
-
|
268
|
+
Depending on your use case, create indices, especially when using PostgreSQL and querying based on JSONB columns.
|
269
|
+
Both `Rdux::Action` and `Rdux::FailedAction` are standard ActiveRecord models.
|
270
|
+
You can inherit from them and extend.
|
271
|
+
Depending on your use case, create indices, especially when using PostgreSQL and querying based on `JSONB` columns.
|
272
|
+
|
273
|
+
Example:
|
274
|
+
```ruby
|
275
|
+
class Action < Rdux::Action
|
276
|
+
include Actionable
|
277
|
+
end
|
278
|
+
```
|
279
|
+
|
280
|
+
## ๐ฉ๐ฝโ๐ฌ Testing
|
281
|
+
|
282
|
+
### ๐ Setup
|
52
283
|
|
53
284
|
```bash
|
54
285
|
$ cd test/dummy
|
@@ -57,12 +288,17 @@ $ DB=all bin/rails db:prepare
|
|
57
288
|
$ cd ../..
|
58
289
|
```
|
59
290
|
|
60
|
-
### Run tests
|
291
|
+
### ๐งช Run tests
|
61
292
|
|
62
293
|
```bash
|
63
294
|
$ DB=postgres bin/rails test
|
64
295
|
$ DB=sqlite bin/rails test
|
65
296
|
```
|
66
297
|
|
67
|
-
## License
|
298
|
+
## ๐ License
|
299
|
+
|
68
300
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
301
|
+
|
302
|
+
## ๐จโ๐ญ Author
|
303
|
+
|
304
|
+
Zbigniew Humeniuk from [Art of Code](https://artofcode.co)
|
data/app/models/rdux/action.rb
CHANGED
@@ -63,15 +63,15 @@ module Rdux
|
|
63
63
|
return if performer.nil?
|
64
64
|
|
65
65
|
if opts.any? || performer.method(meth).arity.abs == 2
|
66
|
-
performer.public_send(meth, payload, opts.merge(action: self))
|
66
|
+
performer.public_send(meth, payload, opts.merge!(action: self))
|
67
67
|
else
|
68
68
|
performer.public_send(meth, payload)
|
69
69
|
end
|
70
70
|
end
|
71
71
|
|
72
72
|
def build_opts
|
73
|
+
nested = rdux_actions.order(:created_at)
|
73
74
|
{}.tap do |h|
|
74
|
-
nested = rdux_actions.order(:created_at)
|
75
75
|
h[:nested] = nested if nested.any?
|
76
76
|
end
|
77
77
|
end
|
data/lib/rdux/version.rb
CHANGED
data/lib/rdux.rb
CHANGED
@@ -13,6 +13,7 @@ module Rdux
|
|
13
13
|
sanitize(action)
|
14
14
|
action.save!
|
15
15
|
res = call_call_or_up_on_action(action, opts)
|
16
|
+
res.up_result ||= opts[:up_result]
|
16
17
|
assign_and_persist(res, action)
|
17
18
|
res.after_save&.call(res.action)
|
18
19
|
res
|
@@ -31,7 +32,7 @@ module Rdux
|
|
31
32
|
|
32
33
|
action.up(opts)
|
33
34
|
rescue StandardError => e
|
34
|
-
handle_exception(e, action)
|
35
|
+
handle_exception(e, action, opts[:up_result])
|
35
36
|
end
|
36
37
|
|
37
38
|
def no_down(res)
|
@@ -80,14 +81,13 @@ module Rdux
|
|
80
81
|
action.up_payload = up_payload_sanitized
|
81
82
|
end
|
82
83
|
|
83
|
-
def handle_exception(exc, action)
|
84
|
+
def handle_exception(exc, action, up_result)
|
84
85
|
failed_action = action.to_failed_action
|
85
|
-
failed_action.up_result
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
}
|
86
|
+
failed_action.up_result ||= up_result || {}
|
87
|
+
failed_action.up_result.merge!({ 'Exception' => {
|
88
|
+
class: exc.class.name,
|
89
|
+
message: exc.message
|
90
|
+
} })
|
91
91
|
failed_action.save!
|
92
92
|
action.destroy
|
93
93
|
raise exc
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rdux
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.10.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Zbigniew Humeniuk
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-09-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -36,59 +36,60 @@ dependencies:
|
|
36
36
|
requirements:
|
37
37
|
- - ">="
|
38
38
|
- !ruby/object:Gem::Version
|
39
|
-
version: 1.5.
|
39
|
+
version: 1.5.8
|
40
40
|
type: :development
|
41
41
|
prerelease: false
|
42
42
|
version_requirements: !ruby/object:Gem::Requirement
|
43
43
|
requirements:
|
44
44
|
- - ">="
|
45
45
|
- !ruby/object:Gem::Version
|
46
|
-
version: 1.5.
|
46
|
+
version: 1.5.8
|
47
47
|
- !ruby/object:Gem::Dependency
|
48
48
|
name: rubocop
|
49
49
|
requirement: !ruby/object:Gem::Requirement
|
50
50
|
requirements:
|
51
51
|
- - ">="
|
52
52
|
- !ruby/object:Gem::Version
|
53
|
-
version: 1.
|
53
|
+
version: 1.66.1
|
54
54
|
type: :development
|
55
55
|
prerelease: false
|
56
56
|
version_requirements: !ruby/object:Gem::Requirement
|
57
57
|
requirements:
|
58
58
|
- - ">="
|
59
59
|
- !ruby/object:Gem::Version
|
60
|
-
version: 1.
|
60
|
+
version: 1.66.1
|
61
61
|
- !ruby/object:Gem::Dependency
|
62
62
|
name: rubocop-rails
|
63
63
|
requirement: !ruby/object:Gem::Requirement
|
64
64
|
requirements:
|
65
65
|
- - ">="
|
66
66
|
- !ruby/object:Gem::Version
|
67
|
-
version: 2.
|
67
|
+
version: 2.26.0
|
68
68
|
type: :development
|
69
69
|
prerelease: false
|
70
70
|
version_requirements: !ruby/object:Gem::Requirement
|
71
71
|
requirements:
|
72
72
|
- - ">="
|
73
73
|
- !ruby/object:Gem::Version
|
74
|
-
version: 2.
|
74
|
+
version: 2.26.0
|
75
75
|
- !ruby/object:Gem::Dependency
|
76
76
|
name: sqlite3
|
77
77
|
requirement: !ruby/object:Gem::Requirement
|
78
78
|
requirements:
|
79
79
|
- - ">="
|
80
80
|
- !ruby/object:Gem::Version
|
81
|
-
version: 1.
|
81
|
+
version: 2.1.0
|
82
82
|
type: :development
|
83
83
|
prerelease: false
|
84
84
|
version_requirements: !ruby/object:Gem::Requirement
|
85
85
|
requirements:
|
86
86
|
- - ">="
|
87
87
|
- !ruby/object:Gem::Version
|
88
|
-
version: 1.
|
89
|
-
description:
|
90
|
-
|
91
|
-
|
88
|
+
version: 2.1.0
|
89
|
+
description: "Rdux is a lightweight, minimalistic Rails plugin designed to introduce
|
90
|
+
event sourcing and audit logging capabilities to your Rails application. \nWith
|
91
|
+
Rdux, you can efficiently track and store the history of actions performed within
|
92
|
+
your app, offering transparency and traceability for key processes.\n"
|
92
93
|
email:
|
93
94
|
- hello@artofcode.co
|
94
95
|
executables: []
|
@@ -128,8 +129,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
128
129
|
- !ruby/object:Gem::Version
|
129
130
|
version: '0'
|
130
131
|
requirements: []
|
131
|
-
rubygems_version: 3.5.
|
132
|
+
rubygems_version: 3.5.16
|
132
133
|
signing_key:
|
133
134
|
specification_version: 4
|
134
|
-
summary:
|
135
|
+
summary: A Minimal Event Sourcing Plugin for Rails
|
135
136
|
test_files: []
|