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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f475c7d974c06b8ea3700459a6275484b805cab6b5e5ca1822eb33a09bb5b92e
4
- data.tar.gz: 1ee86eb626084ddf73cfb3921abf3e4a68ab823ad87ff09fd890788ced2063fc
3
+ metadata.gz: cc100a1d70358da39c0ae079d3cc028644b205b39d031299deab2e6a0486f1da
4
+ data.tar.gz: cc3a1cb99e508e2094cf9a10bc7dd184100fbfc3d688211f46624c3960b8be71
5
5
  SHA512:
6
- metadata.gz: 5bc8898e1014eee4e865c6b1747f640a70f5cbf9336d5356eaa52ef9a557845764ac831ad54d1c5e3e777104c14b44aa5d737be3fb9f12597e5550e4280c4481
7
- data.tar.gz: bbee4693d75aa24d31133e3738876d6ee0906cbde3fef4eb915d47d0ae74dad5ec91f8787d0eb2293660b9a52d632fa5af44b973a3b22ddcdd685dee7875d9dc
6
+ metadata.gz: c11d561ee5735da822aac0cb302b325c0ee9e803f175304cc6ce4885d795affbc33264b094b0ac8d497a3f5f4a674166a5b44055017c82e0477d1238b2cfeb77
7
+ data.tar.gz: f321c1ec7ce53e2f2e36795a6716bbbe51d979e777ec969bd052dc9cc4dee2b467c5899b5cabf790dfffbf491e91e760c79e8ffe2aafe8c31c7213164587b09c
data/README.md CHANGED
@@ -1,17 +1,15 @@
1
1
  # Funes
2
2
 
3
- Event sourcing for Ruby on Rails append-only events as your source of truth, with flexible projections for reads.
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
- ## Event Sourcing?
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
- Traditional Rails apps update state in place. You `update!` a record and the previous value is gone. Event sourcing takes a different approach: store *what happened* as immutable events, then derive current state by replaying them.
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
- This gives you:
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
- - **Complete audit trail** every state change is recorded, forever
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
- ## Three-Tier Consistency Model
30
+ ## Core concepts
33
31
 
34
- Funes gives you fine-grained control over when and how projections run:
32
+ Funes bridges the gap between event sourcing theory and the Rails tools you already know (`ActiveModel`, `ActiveRecord`, `ActiveJob`).
35
33
 
36
- | Tier | When it runs | Use case |
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
+ ![core concepts](https://raw.github.com/funes-org/funes/main/concepts.png)
41
35
 
42
- ### Consistency Projection
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
- Runs before the event is saved. If the resulting state is invalid, the event is rejected:
40
+ For a full walkthrough of each concept, see the [guides](https://docs.funes.org).
45
41
 
46
- ```ruby
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
- ```ruby
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
- ## Testing
50
+ Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
149
51
 
150
- Funes provides helpers for testing projections in isolation:
52
+ ## Compatibility
151
53
 
152
- ```ruby
153
- class InventorySnapshotProjectionTest < ActiveSupport::TestCase
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
- test "receiving items increases quantity on hand" do
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 for current version"
11
+ desc "Generate YARD documentation"
12
12
  task :generate do
13
- # Allow version override via environment variable (for CI)
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 for version #{version}..."
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
- desc "Build version selector index page"
36
- task :build_index do
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
- if versions.empty?
42
- puts "No versions found. Run 'rake docs:generate' first."
43
- exit 1
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 "List all documented versions"
65
- task :list do
66
- versions = Dir.glob("docs/v*").map { |d| File.basename(d) }.sort.reverse
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
- if versions.empty?
69
- puts "No versions documented yet."
70
- else
71
- puts "Documented versions:"
72
- versions.each { |v| puts " - #{v}" }
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