funes-rails 0.1.1 → 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 +21 -141
- data/Rakefile +26 -50
- data/app/event_streams/funes/event_stream.rb +255 -57
- data/app/helpers/funes/projection_test_helper.rb +61 -3
- data/app/jobs/funes/persist_projection_job.rb +4 -3
- data/app/models/funes/event.rb +127 -25
- data/app/models/funes/event_entry.rb +3 -1
- data/app/models/funes/event_metainformation.rb +77 -0
- data/app/projections/funes/projection.rb +61 -29
- data/lib/funes/configuration.rb +32 -0
- data/lib/funes/conflicting_actual_time_error.rb +13 -0
- data/lib/funes/engine.rb +16 -1
- data/lib/funes/event_metainformation_builder.rb +54 -0
- data/lib/funes/inspection.rb +197 -0
- data/lib/funes/invalid_event_metainformation.rb +17 -0
- data/lib/funes/missing_actual_time_attribute_error.rb +17 -0
- data/lib/funes/unknown_event.rb +2 -2
- data/lib/funes/unknown_materialization_model.rb +3 -3
- data/lib/funes/version.rb +1 -1
- data/lib/funes-rails.rb +1 -0
- data/lib/funes.rb +28 -2
- data/lib/generators/funes/initializer_generator.rb +14 -0
- data/lib/generators/funes/install_generator.rb +9 -10
- data/lib/generators/funes/materialization_table_generator.rb +34 -0
- data/lib/generators/funes/templates/initializer.rb.tt +18 -0
- data/lib/generators/funes/templates/materialization_table.rb.tt +12 -0
- data/lib/generators/funes/templates/migration.rb.tt +9 -7
- metadata +143 -11
- data/lib/funes/transactional_projection_failed.rb +0 -17
- data/lib/templates/docs_index.html.erb +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc100a1d70358da39c0ae079d3cc028644b205b39d031299deab2e6a0486f1da
|
|
4
|
+
data.tar.gz: cc3a1cb99e508e2094cf9a10bc7dd184100fbfc3d688211f46624c3960b8be71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c11d561ee5735da822aac0cb302b325c0ee9e803f175304cc6ce4885d795affbc33264b094b0ac8d497a3f5f4a674166a5b44055017c82e0477d1238b2cfeb77
|
|
7
|
+
data.tar.gz: f321c1ec7ce53e2f2e36795a6716bbbe51d979e777ec969bd052dc9cc4dee2b467c5899b5cabf790dfffbf491e91e760c79e8ffe2aafe8c31c7213164587b09c
|
data/README.md
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# Funes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An event sourcing meta-framework designed to provide a frictionless experience for RoR developers to build and operate systems where history is as important as the present. Built with the one-person framework philosophy in mind, it honors the Rails doctrine by providing deep **conceptual compression** over what is usually a complex architectural pattern.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
By distilling the mechanics of event sourcing into just three core concepts — **Events**, **Streams**, and **Projections** — Funes handles the underlying complexity of persistence and state reconstruction for you. It feels like the Rails you already know, giving you the power of a permanent source of truth with the same ease of use as a standard ActiveRecord model.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Unlike traditional event sourcing frameworks that require a total shift in how you build, Funes is designed for **progressive adoption**. It is a _"good neighbor"_ that coexists seamlessly with your existing ActiveRecord models and standard controllers. You can use Funes for a single mission-critical feature — like a single complex state machine — while keeping the rest of your app in "plain old Rails."
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
> [!WARNING]
|
|
10
|
+
> **Not Production Ready:** Funes is currently under active development and is not yet ready for production use. The API may change without notice, and features may be incomplete or unstable. Use at your own risk in development and testing environments only.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
- **Temporal queries** — "what was the balance on December 1st?"
|
|
13
|
-
- **Multiple read models** — same events, different projections for different use cases
|
|
14
|
-
- **Safer refactoring** — rebuild any projection from the event log
|
|
12
|
+
> Named after Funes the Memorious, the Borges character who could forget nothing, this framework embodies the principle that in some systems, remembering everything matters.
|
|
15
13
|
|
|
16
14
|
## Installation
|
|
17
15
|
|
|
@@ -29,152 +27,34 @@ $ bin/rails generate funes:install
|
|
|
29
27
|
$ bin/rails db:migrate
|
|
30
28
|
```
|
|
31
29
|
|
|
32
|
-
##
|
|
30
|
+
## Core concepts
|
|
33
31
|
|
|
34
|
-
Funes
|
|
32
|
+
Funes bridges the gap between event sourcing theory and the Rails tools you already know (`ActiveModel`, `ActiveRecord`, `ActiveJob`).
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|:--------------------------|:-----------------------------|:------------------------------------------------|
|
|
38
|
-
| Consistency Projection | Before event is persisted | Validate business rules against resulting state |
|
|
39
|
-
| Transactional Projections | Same DB transaction as event | Critical read models needing strong consistency |
|
|
40
|
-
| Async Projections | Background job (ActiveJob) | Reports, analytics, non-critical read models |
|
|
34
|
+

|
|
41
35
|
|
|
42
|
-
|
|
36
|
+
- **Events** — immutable `ActiveModel` objects that record what happened, with built-in validation and no schema migrations
|
|
37
|
+
- **Projections** — transform a stream of events into a materialized state, either in-memory (`ActiveModel`) or persisted (`ActiveRecord`)
|
|
38
|
+
- **Event Streams** — orchestrate writes, run double validation, and control when projections update (synchronously or via `ActiveJob`)
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
For a full walkthrough of each concept, see the [guides](https://docs.funes.org).
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
class InventoryEventStream < Funes::EventStream
|
|
48
|
-
consistency_projection InventorySnapshotProjection
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
class InventorySnapshot
|
|
52
|
-
include ActiveModel::Model
|
|
53
|
-
include ActiveModel::Attributes
|
|
54
|
-
|
|
55
|
-
attribute :quantity_on_hand, :integer, default: 0
|
|
56
|
-
|
|
57
|
-
validates :quantity_on_hand, numericality: { greater_than_or_equal_to: 0 }
|
|
58
|
-
end
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
Now if someone tries to ship more than available:
|
|
62
|
-
|
|
63
|
-
```ruby
|
|
64
|
-
|
|
65
|
-
event = stream.append!(Inventory::ItemShipped.new(quantity: 9999))
|
|
66
|
-
event.valid? # => false
|
|
67
|
-
event.errors[:quantity_on_hand] # => ["must be greater than or equal to 0"]
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
The event is never persisted. Your invariants are protected.
|
|
71
|
-
|
|
72
|
-
### Transactional Projections
|
|
73
|
-
|
|
74
|
-
Update read models in the same database transaction. If anything fails, everything rolls back:
|
|
75
|
-
|
|
76
|
-
```ruby
|
|
77
|
-
add_transactional_projection InventoryLedgerProjection
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### Async Projections
|
|
81
|
-
|
|
82
|
-
Schedule background jobs with full ActiveJob options:
|
|
83
|
-
|
|
84
|
-
```ruby
|
|
85
|
-
add_async_projection ReportingProjection, queue: :low, wait: 5.minutes
|
|
86
|
-
add_async_projection AnalyticsProjection, wait_until: Date.tomorrow.midnight
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
#### Controlling the `as_of` Timestamp
|
|
90
|
-
|
|
91
|
-
By default, async projections use the creation time of the last event. You can customize this behavior:
|
|
92
|
-
|
|
93
|
-
```ruby
|
|
94
|
-
# Use job execution time instead of event time
|
|
95
|
-
add_async_projection RealtimeProjection, as_of: :job_time
|
|
96
|
-
|
|
97
|
-
# Custom logic with a proc
|
|
98
|
-
add_async_projection EndOfDayProjection,
|
|
99
|
-
as_of: ->(last_event) { last_event.created_at.beginning_of_day }
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Available `as_of` strategies:
|
|
103
|
-
- `:last_event_time` (default) — Uses the creation time of the last event
|
|
104
|
-
- `:job_time` — Uses `Time.current` when the job executes
|
|
105
|
-
- `Proc/Lambda` — Custom logic that receives the last event and returns a `Time` object
|
|
106
|
-
|
|
107
|
-
## Temporal Queries
|
|
108
|
-
|
|
109
|
-
Every event is timestamped. Query your stream at any point in time:
|
|
110
|
-
|
|
111
|
-
### Current state
|
|
112
|
-
```ruby
|
|
113
|
-
stream = InventoryEventStream.for("sku-12345")
|
|
114
|
-
stream.events # => all events
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### State as of last month
|
|
118
|
-
```ruby
|
|
119
|
-
stream = InventoryEventStream.for("sku-12345", 1.month.ago)
|
|
120
|
-
stream.events # => events up to that timestamp
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
Projections receive the as_of parameter, so you can build point-in-time snapshots:
|
|
124
|
-
|
|
125
|
-
```ruby
|
|
126
|
-
# in your projection
|
|
127
|
-
interpretation_for(Inventory::ItemReceived) do |state, event, as_of|
|
|
128
|
-
# as_of is available if you need temporal logic
|
|
129
|
-
state.quantity_on_hand += event.quantity
|
|
130
|
-
state
|
|
131
|
-
end
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
## Concurrency
|
|
42
|
+
## Optimistic concurrency control
|
|
135
43
|
|
|
136
44
|
Funes uses optimistic concurrency control. Each event in a stream gets an incrementing version number with a unique constraint on (idx, version).
|
|
137
45
|
|
|
138
|
-
If two processes try to append to the same stream simultaneously, one succeeds and the other gets a validation error — no locks, no blocking
|
|
46
|
+
If two processes try to append to the same stream simultaneously, one succeeds and the other gets a validation error — no locks, no blocking.
|
|
139
47
|
|
|
140
|
-
|
|
141
|
-
event = stream.append!(SomeEvent.new)
|
|
142
|
-
unless event.valid?
|
|
143
|
-
event.errors[:version] # => ["has already been taken"]
|
|
144
|
-
# Reload and retry your business logic
|
|
145
|
-
end
|
|
146
|
-
```
|
|
48
|
+
## Documentation
|
|
147
49
|
|
|
148
|
-
|
|
50
|
+
Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
|
|
149
51
|
|
|
150
|
-
|
|
52
|
+
## Compatibility
|
|
151
53
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
include Funes::ProjectionTestHelper
|
|
54
|
+
- **Ruby:** 3.1, 3.2, 3.3, 3.4
|
|
55
|
+
- **Rails:** 7.1, 7.2, 8.0, 8.1
|
|
155
56
|
|
|
156
|
-
|
|
157
|
-
initial_state = InventorySnapshot.new(quantity_on_hand: 10)
|
|
158
|
-
event = Inventory::ItemReceived.new(quantity: 5, unit_cost: 9.99)
|
|
159
|
-
|
|
160
|
-
result = interpret_event_based_on(InventorySnapshotProjection, event, initial_state)
|
|
161
|
-
|
|
162
|
-
assert_equal 15, result.quantity_on_hand
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
## Strict Mode
|
|
168
|
-
|
|
169
|
-
By default, projections ignore events they don't have interpretations for. Enable strict mode to catch missing handlers:
|
|
170
|
-
|
|
171
|
-
```ruby
|
|
172
|
-
class StrictProjection < Funes::Projection
|
|
173
|
-
raise_on_unknown_events
|
|
174
|
-
|
|
175
|
-
# Now forgetting to handle an event type raises Funes::UnknownEvent
|
|
176
|
-
end
|
|
177
|
-
```
|
|
57
|
+
Rails 8.0+ requires Ruby 3.2 or higher.
|
|
178
58
|
|
|
179
59
|
## License
|
|
180
60
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
|
@@ -8,68 +8,44 @@ load "rails/tasks/statistics.rake"
|
|
|
8
8
|
require "bundler/gem_tasks"
|
|
9
9
|
|
|
10
10
|
namespace :docs do
|
|
11
|
-
desc "Generate YARD documentation
|
|
11
|
+
desc "Generate YARD documentation"
|
|
12
12
|
task :generate do
|
|
13
|
-
|
|
14
|
-
version = ENV["VERSION"] || begin
|
|
15
|
-
require_relative "lib/funes/version"
|
|
16
|
-
Funes::VERSION
|
|
17
|
-
end
|
|
18
|
-
output_dir = "docs/v#{version}"
|
|
13
|
+
output_dir = "docs"
|
|
19
14
|
|
|
20
|
-
puts "Generating documentation
|
|
15
|
+
puts "Generating documentation..."
|
|
21
16
|
system("yard doc --output-dir #{output_dir}") || abort("Failed to generate documentation")
|
|
22
17
|
|
|
23
|
-
# Copy assets to root docs directory
|
|
24
|
-
FileUtils.mkdir_p("docs")
|
|
25
|
-
%w[css js].each do |asset_dir|
|
|
26
|
-
if Dir.exist?("#{output_dir}/#{asset_dir}")
|
|
27
|
-
FileUtils.cp_r("#{output_dir}/#{asset_dir}", "docs/#{asset_dir}")
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
18
|
puts "Documentation generated in #{output_dir}/"
|
|
32
|
-
Rake::Task["docs:build_index"].invoke
|
|
33
19
|
end
|
|
20
|
+
end
|
|
34
21
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
require "erb"
|
|
38
|
-
|
|
39
|
-
versions = Dir.glob("docs/v*").map { |d| File.basename(d) }.sort.reverse
|
|
22
|
+
namespace :guides do
|
|
23
|
+
guides_dir = File.expand_path("guides", __dir__)
|
|
40
24
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
25
|
+
desc "Install Jekyll dependencies for guides (one-time setup)"
|
|
26
|
+
task :setup do
|
|
27
|
+
Bundler.with_unbundled_env do
|
|
28
|
+
Dir.chdir(guides_dir) do
|
|
29
|
+
system("bundle install") || abort("Failed to install guides dependencies")
|
|
30
|
+
end
|
|
44
31
|
end
|
|
45
|
-
|
|
46
|
-
latest_version = versions.first
|
|
47
|
-
template_path = File.expand_path("lib/templates/docs_index.html.erb", __dir__)
|
|
48
|
-
template = ERB.new(File.read(template_path))
|
|
49
|
-
html = template.result(binding)
|
|
50
|
-
|
|
51
|
-
File.write("docs/index.html", html)
|
|
52
|
-
puts "Version index page created at docs/index.html"
|
|
53
|
-
|
|
54
|
-
# Create CNAME file for GitHub Pages custom domain
|
|
55
|
-
File.write("docs/CNAME", "docs.funes.org\n")
|
|
56
|
-
puts "CNAME file created for docs.funes.org"
|
|
57
|
-
|
|
58
|
-
# Create .nojekyll file to bypass Jekyll processing
|
|
59
|
-
# This is required for YARD's _index.html file to work properly
|
|
60
|
-
File.write("docs/.nojekyll", "")
|
|
61
|
-
puts ".nojekyll file created to bypass Jekyll processing"
|
|
62
32
|
end
|
|
63
33
|
|
|
64
|
-
desc "
|
|
65
|
-
task :
|
|
66
|
-
|
|
34
|
+
desc "Build the guides site into guides/_site/"
|
|
35
|
+
task :build do
|
|
36
|
+
Bundler.with_unbundled_env do
|
|
37
|
+
Dir.chdir(guides_dir) do
|
|
38
|
+
system("bundle exec jekyll build") || abort("Failed to build guides")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
67
42
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
43
|
+
desc "Start Jekyll dev server with live reload at localhost:4000/guides/"
|
|
44
|
+
task :serve do
|
|
45
|
+
Bundler.with_unbundled_env do
|
|
46
|
+
Dir.chdir(guides_dir) do
|
|
47
|
+
system("bundle exec jekyll serve --livereload")
|
|
48
|
+
end
|
|
73
49
|
end
|
|
74
50
|
end
|
|
75
51
|
end
|