ruby_event_store-process_manager 0.1.0 → 0.2.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 +4 -4
- data/README.md +32 -1
- data/lib/ruby_event_store/process_manager/browser_extension.rb +41 -0
- data/lib/ruby_event_store/process_manager/public/ruby_event_store_process_manager.css +2 -0
- data/lib/ruby_event_store/process_manager/state_replay.rb +24 -0
- data/lib/ruby_event_store/process_manager/version.rb +1 -1
- data/lib/ruby_event_store/process_manager/views/process_managers/show.html.erb +62 -0
- data/lib/ruby_event_store/process_manager.rb +40 -8
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79bc03b7d7e515cc20fdf05a7c3c4337d4f648f3acec6566874a6e09ba39da35
|
|
4
|
+
data.tar.gz: f0c2c3f704e8894be3faaca3f4650d02fd56bcf0944c9a502f29960832996425
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e152cdc55c9b44d9569702650487eeb890352b184d4edcc1a4b118aeee8850d3cdec4b054eaa062539af4bc2a3609fd0f33ec20a4948845a18f12275bbcac6a
|
|
7
|
+
data.tar.gz: 50caa4951feadbdcf35e9e2ef64dcf646676cc57508abe523fc826d111dac85fa698e63c656c83aac162cd1e7cf2ae753d7da21d79fc150b0e1d3e5b25cf6dae
|
data/README.md
CHANGED
|
@@ -60,7 +60,7 @@ end
|
|
|
60
60
|
Subscribe it to the event store:
|
|
61
61
|
|
|
62
62
|
```ruby
|
|
63
|
-
process = ReleasePaymentOnOrderExpiration.new(event_store
|
|
63
|
+
process = ReleasePaymentOnOrderExpiration.new.with(event_store:, command_bus:)
|
|
64
64
|
|
|
65
65
|
event_store.subscribe(
|
|
66
66
|
process,
|
|
@@ -70,6 +70,37 @@ event_store.subscribe(
|
|
|
70
70
|
|
|
71
71
|
`fetch_id` identifies the process instance, `apply` evolves its state, and `act` issues commands based on the rebuilt state. The state class must support a no-argument constructor. `apply` must always return the next state, including for events that do not change it.
|
|
72
72
|
|
|
73
|
+
A process manager class has no constructor of its own, so it can be subclassed from frameworks that instantiate it without arguments — for example a Rails `ActiveJob`, whose infrastructure calls `Job.new` with no arguments when deserializing a job:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class ReleasePaymentOnOrderExpirationJob < ActiveJob::Base
|
|
77
|
+
include RubyEventStore::ProcessManager.with_state { ProcessState }
|
|
78
|
+
|
|
79
|
+
def perform(event)
|
|
80
|
+
with(event_store: Rails.configuration.event_store, command_bus: Rails.configuration.command_bus).call(event)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ... fetch_id, apply, act, ProcessState as above
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Forgetting to call `with` before `call` is reported with a clear error naming the missing dependencies.
|
|
88
|
+
|
|
89
|
+
## Browser integration
|
|
90
|
+
|
|
91
|
+
The gem ships an extension for [RubyEventStore::Browser](https://railseventstore.org/docs/advanced-topics/browser/). Streams following the `ProcessClassName$id` naming convention get a "Process state" link, leading to a view that replays the process state step by step — showing the state after each event, next to the current state of the process.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
require "ruby_event_store/process_manager/browser_extension"
|
|
95
|
+
|
|
96
|
+
RubyEventStore::Browser::App.for(
|
|
97
|
+
event_store_locator: -> { Rails.configuration.event_store },
|
|
98
|
+
extensions: [RubyEventStore::ProcessManager::BrowserExtension.new]
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The view rebuilds the state through the public `replay(events)` API every process manager exposes — it folds `apply` over the events starting from `initial_state` and returns the successive states. `act` is never invoked, so browsing the process state has no side effects.
|
|
103
|
+
|
|
73
104
|
## License
|
|
74
105
|
|
|
75
106
|
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "state_replay"
|
|
4
|
+
|
|
5
|
+
module RubyEventStore
|
|
6
|
+
module ProcessManager
|
|
7
|
+
class BrowserExtension
|
|
8
|
+
VIEWS_ROOT = File.expand_path("views", __dir__).freeze
|
|
9
|
+
ASSETS_ROOT = File.expand_path("public", __dir__).freeze
|
|
10
|
+
|
|
11
|
+
def views_root = VIEWS_ROOT
|
|
12
|
+
|
|
13
|
+
def assets_root = ASSETS_ROOT
|
|
14
|
+
|
|
15
|
+
def register_routes(router, context)
|
|
16
|
+
router.add_route("GET", "/process_managers/:stream_name") do |params, urls|
|
|
17
|
+
stream_name = params.fetch("stream_name")
|
|
18
|
+
process_class, process_id = ProcessManager.parse_stream_name(stream_name)
|
|
19
|
+
next context.not_found(urls) unless process_class
|
|
20
|
+
|
|
21
|
+
replay = StateReplay.new(event_store: context.event_store).call(process_class, stream_name)
|
|
22
|
+
context.render(
|
|
23
|
+
"process_managers/show",
|
|
24
|
+
urls: urls,
|
|
25
|
+
stream_name: stream_name,
|
|
26
|
+
process_class: process_class,
|
|
27
|
+
process_id: process_id,
|
|
28
|
+
steps: replay.steps,
|
|
29
|
+
current_state: replay.current_state,
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stream_links(stream_name, urls)
|
|
35
|
+
return [] unless ProcessManager.parse_stream_name(stream_name)
|
|
36
|
+
|
|
37
|
+
[{ label: "Process state", url: urls.app_url_for("process_managers", stream_name) }]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial}}}:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-700:oklch(50.5% .213 27.518);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-700:oklch(37.3% .034 259.733);--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-bold:700;--leading-tight:1.25}.sticky{position:sticky}.top-0{top:0}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.w-full{width:100%}.cursor-help{cursor:help}.overflow-x-auto{overflow-x:auto}.overflow-x-scroll{overflow-x:scroll}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-50{border-color:var(--color-gray-50)}.border-gray-400{border-color:var(--color-gray-400)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pr-4{padding-right:calc(var(--spacing) * 4)}.text-left{text-align:left}.align-bottom{vertical-align:bottom}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.break-words{overflow-wrap:break-word}.text-gray-500{color:var(--color-gray-500)}.text-gray-700{color:var(--color-gray-700)}.text-red-700{color:var(--color-red-700)}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}@media (hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media (min-width:40rem){.sm\:overflow-visible{overflow:visible}}@media (min-width:64rem){.lg\:w-12{width:calc(var(--spacing) * 12)}.lg\:w-60{width:calc(var(--spacing) * 60)}.lg\:table-fixed{table-layout:fixed}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module ProcessManager
|
|
5
|
+
class StateReplay
|
|
6
|
+
Step = Struct.new(:event, :state, keyword_init: true)
|
|
7
|
+
Result = Struct.new(:steps, :current_state, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(event_store:)
|
|
10
|
+
@event_store = event_store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(process_class, stream_name)
|
|
14
|
+
process = process_class.new.with(event_store: @event_store, command_bus: nil)
|
|
15
|
+
events = @event_store.read.stream(stream_name).to_a
|
|
16
|
+
states = process.replay(events)
|
|
17
|
+
Result.new(
|
|
18
|
+
steps: events.zip(states).map { |event, state| Step.new(event: event, state: state) },
|
|
19
|
+
current_state: process.state,
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<div class="py-8 container mx-auto">
|
|
2
|
+
<h1 class="font-bold text-2xl">Process <%= h(process_class.name) %></h1>
|
|
3
|
+
<p class="text-gray-500 text-sm mt-1">
|
|
4
|
+
Instance <span class="font-mono font-medium text-gray-700"><%= h(process_id) %></span>,
|
|
5
|
+
state rebuilt from <a class="text-red-700 no-underline" href="<%= urls.stream_url(stream_name) %>"><%= h(stream_name) %></a>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<section class="mt-8">
|
|
9
|
+
<h2 class="text-gray-500 uppercase font-bold text-xs mb-2">Current state</h2>
|
|
10
|
+
<div class="overflow-x-auto">
|
|
11
|
+
<% if current_state.respond_to?(:to_h) -%>
|
|
12
|
+
<%= render("_json_tree", hash: current_state.to_h) %>
|
|
13
|
+
<% else -%>
|
|
14
|
+
<div class="font-mono text-sm"><%= h(current_state.inspect) %></div>
|
|
15
|
+
<% end -%>
|
|
16
|
+
</div>
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
<section class="mt-8">
|
|
20
|
+
<h2 class="text-gray-500 uppercase font-bold text-xs mb-2">Step by step</h2>
|
|
21
|
+
<% if steps.empty? -%>
|
|
22
|
+
<p class="text-gray-500 text-sm">No events linked to this process stream yet.</p>
|
|
23
|
+
<% else -%>
|
|
24
|
+
<div class="overflow-x-scroll sm:overflow-visible w-full">
|
|
25
|
+
<table class="w-full lg:table-fixed text-left">
|
|
26
|
+
<thead class="align-bottom leading-tight sticky top-0 bg-white text-gray-500 uppercase text-xs">
|
|
27
|
+
<tr class="border-gray-400 border-b">
|
|
28
|
+
<th class="p-4 lg:w-12">#</th>
|
|
29
|
+
<th class="py-4 pr-4">Event</th>
|
|
30
|
+
<th class="py-4 pr-4 lg:w-60"><span class="cursor-help" data-timezone-target="zone" title="UTC">Created at</span></th>
|
|
31
|
+
<th class="py-4 pr-4">State after</th>
|
|
32
|
+
</tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody class="align-top">
|
|
35
|
+
<% steps.to_enum.with_index(1).reverse_each do |step, number| -%>
|
|
36
|
+
<% href = urls.event_url(step.event.event_id) -%>
|
|
37
|
+
<tr class="border-gray-50 border-b hover:bg-gray-100">
|
|
38
|
+
<td class="p-4 font-mono text-sm text-gray-500"><%= number %></td>
|
|
39
|
+
<td class="py-4 pr-4">
|
|
40
|
+
<a class="text-red-700 no-underline break-words" href="<%= href %>"><%= h(step.event.event_type) %></a>
|
|
41
|
+
<div class="font-mono text-sm text-gray-500 break-words"><%= h(step.event.event_id) %></div>
|
|
42
|
+
</td>
|
|
43
|
+
<td class="py-4 pr-4 font-mono text-sm">
|
|
44
|
+
<span data-timezone-target="time" data-iso="<%= step.event.metadata[:timestamp].utc.iso8601(6) %>" title="UTC"><%= step.event.metadata[:timestamp].utc.strftime("%Y-%m-%dT%H:%M:%S.%3N") %></span>
|
|
45
|
+
</td>
|
|
46
|
+
<td class="py-4 pr-4 text-sm">
|
|
47
|
+
<div class="overflow-x-auto">
|
|
48
|
+
<% if step.state.respond_to?(:to_h) -%>
|
|
49
|
+
<%= render("_json_tree", hash: step.state.to_h) %>
|
|
50
|
+
<% else -%>
|
|
51
|
+
<span class="font-mono"><%= h(step.state.inspect) %></span>
|
|
52
|
+
<% end -%>
|
|
53
|
+
</div>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
<% end -%>
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</div>
|
|
60
|
+
<% end -%>
|
|
61
|
+
</section>
|
|
62
|
+
</div>
|
|
@@ -6,28 +6,43 @@ require_relative "process_manager/retry"
|
|
|
6
6
|
module RubyEventStore
|
|
7
7
|
module ProcessManager
|
|
8
8
|
module ProcessMethods
|
|
9
|
-
def initialize(event_store, command_bus)
|
|
10
|
-
@event_store = event_store
|
|
11
|
-
@command_bus = command_bus
|
|
12
|
-
end
|
|
13
|
-
|
|
14
9
|
def call(event)
|
|
15
|
-
|
|
10
|
+
ensure_configured!
|
|
16
11
|
@id = fetch_id(event)
|
|
17
12
|
build_state(event)
|
|
18
13
|
act
|
|
19
14
|
end
|
|
20
15
|
|
|
16
|
+
def replay(events)
|
|
17
|
+
@state = initial_state
|
|
18
|
+
events.map { |event| @state = apply(event) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with(event_store:, command_bus:)
|
|
22
|
+
@event_store = event_store
|
|
23
|
+
@command_bus = command_bus
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
private
|
|
22
28
|
|
|
23
29
|
attr_reader :event_store, :command_bus, :id
|
|
24
30
|
|
|
31
|
+
def ensure_configured!
|
|
32
|
+
missing = []
|
|
33
|
+
missing << "event_store" unless @event_store
|
|
34
|
+
missing << "command_bus" unless @command_bus
|
|
35
|
+
return if missing.empty?
|
|
36
|
+
|
|
37
|
+
raise "#{self.class} is missing #{missing.join(" and ")}, call #with(event_store:, command_bus:) first"
|
|
38
|
+
end
|
|
39
|
+
|
|
25
40
|
def build_state(event)
|
|
26
41
|
with_retry do
|
|
27
42
|
past_events = event_store.read.stream(stream_name).to_a
|
|
28
43
|
last_stored = past_events.size - 1
|
|
29
44
|
event_store.link(event.event_id, stream_name:, expected_version: last_stored)
|
|
30
|
-
(past_events + [event])
|
|
45
|
+
replay(past_events + [event])
|
|
31
46
|
end
|
|
32
47
|
end
|
|
33
48
|
|
|
@@ -48,6 +63,22 @@ module RubyEventStore
|
|
|
48
63
|
attr_reader :subscribed_events
|
|
49
64
|
end
|
|
50
65
|
|
|
66
|
+
@registered_process_managers = {}
|
|
67
|
+
|
|
68
|
+
def self.register(process_class)
|
|
69
|
+
@registered_process_managers[process_class.name] = process_class if process_class.name
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.parse_stream_name(stream_name)
|
|
73
|
+
class_name, _, id = stream_name.to_s.partition("$")
|
|
74
|
+
return if id.empty?
|
|
75
|
+
|
|
76
|
+
process_class = @registered_process_managers[class_name]
|
|
77
|
+
return unless process_class
|
|
78
|
+
|
|
79
|
+
[process_class, id]
|
|
80
|
+
end
|
|
81
|
+
|
|
51
82
|
def self.with_state(&state_class_block)
|
|
52
83
|
unless block_given?
|
|
53
84
|
raise ArgumentError, "A block returning the state class is required."
|
|
@@ -61,7 +92,7 @@ module RubyEventStore
|
|
|
61
92
|
raise "State definition block not found on #{self.class}" unless block
|
|
62
93
|
|
|
63
94
|
state_class = block.call
|
|
64
|
-
raise "State definition block did not return a Class" unless state_class.
|
|
95
|
+
raise "State definition block did not return a Class" unless state_class.instance_of?(Class)
|
|
65
96
|
|
|
66
97
|
state_class.new
|
|
67
98
|
end
|
|
@@ -76,6 +107,7 @@ module RubyEventStore
|
|
|
76
107
|
host_class.include(ProcessMethods)
|
|
77
108
|
host_class.include(Retry)
|
|
78
109
|
host_class.extend(Subscriptions)
|
|
110
|
+
ProcessManager.register(host_class)
|
|
79
111
|
end
|
|
80
112
|
end
|
|
81
113
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_event_store-process_manager
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arkency
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ruby_event_store
|
|
@@ -37,8 +37,12 @@ extra_rdoc_files:
|
|
|
37
37
|
files:
|
|
38
38
|
- README.md
|
|
39
39
|
- lib/ruby_event_store/process_manager.rb
|
|
40
|
+
- lib/ruby_event_store/process_manager/browser_extension.rb
|
|
41
|
+
- lib/ruby_event_store/process_manager/public/ruby_event_store_process_manager.css
|
|
40
42
|
- lib/ruby_event_store/process_manager/retry.rb
|
|
43
|
+
- lib/ruby_event_store/process_manager/state_replay.rb
|
|
41
44
|
- lib/ruby_event_store/process_manager/version.rb
|
|
45
|
+
- lib/ruby_event_store/process_manager/views/process_managers/show.html.erb
|
|
42
46
|
homepage: https://railseventstore.org
|
|
43
47
|
licenses:
|
|
44
48
|
- MIT
|
|
@@ -61,7 +65,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
61
65
|
- !ruby/object:Gem::Version
|
|
62
66
|
version: '0'
|
|
63
67
|
requirements: []
|
|
64
|
-
rubygems_version:
|
|
68
|
+
rubygems_version: 4.0.16
|
|
65
69
|
specification_version: 4
|
|
66
70
|
summary: Stateful process manager with event-sourced state for RubyEventStore
|
|
67
71
|
test_files: []
|