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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 459fcf11b24e60ad73c40d07162b3580da26c0a219330fa07a14a566274f1bb3
4
- data.tar.gz: 8d570d1bfa61aa1f6f8d998d1003fcccf2589e46b56f18bc1f956f122915c027
3
+ metadata.gz: 79bc03b7d7e515cc20fdf05a7c3c4337d4f648f3acec6566874a6e09ba39da35
4
+ data.tar.gz: f0c2c3f704e8894be3faaca3f4650d02fd56bcf0944c9a502f29960832996425
5
5
  SHA512:
6
- metadata.gz: f3dd0d8b9a41458c621dc6bce7de3f01982a321f07c5979a1246d726e9c18ab34330a203566f85d8635288984fc03fb030153d495d2d0b9d38f69a15cff4f685
7
- data.tar.gz: fbe268a31631a812682d10c26370e0a853dc83b148b2b9ca8097c2fc7b33d70cb7051f01c5f935524394b27b4df049d527fed9a981c457eccd87a2ffe49e1be4
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, command_bus)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyEventStore
4
4
  module ProcessManager
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  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
- @state = initial_state
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]).each { |ev| @state = apply(ev) }
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.is_a?(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.1.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: 2026-07-15 00:00:00.000000000 Z
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: 3.6.2
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: []