acta 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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/CHANGELOG.md +210 -0
  4. data/LICENSE +21 -0
  5. data/PLAN.md +158 -0
  6. data/README.md +559 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/acta/web/application_controller.rb +10 -0
  9. data/app/controllers/acta/web/events_controller.rb +37 -0
  10. data/app/helpers/acta/web/application_helper.rb +106 -0
  11. data/app/views/acta/web/events/index.html.erb +312 -0
  12. data/app/views/acta/web/events/show.html.erb +72 -0
  13. data/app/views/layouts/acta/web/application.html.erb +594 -0
  14. data/config/routes.rb +4 -0
  15. data/lib/acta/actor.rb +34 -0
  16. data/lib/acta/adapters/base.rb +59 -0
  17. data/lib/acta/adapters/postgres.rb +73 -0
  18. data/lib/acta/adapters/sqlite.rb +58 -0
  19. data/lib/acta/adapters.rb +19 -0
  20. data/lib/acta/array_type.rb +30 -0
  21. data/lib/acta/command.rb +48 -0
  22. data/lib/acta/current.rb +10 -0
  23. data/lib/acta/errors.rb +102 -0
  24. data/lib/acta/event.rb +80 -0
  25. data/lib/acta/events_query.rb +73 -0
  26. data/lib/acta/handler.rb +9 -0
  27. data/lib/acta/model.rb +58 -0
  28. data/lib/acta/model_type.rb +32 -0
  29. data/lib/acta/projection.rb +64 -0
  30. data/lib/acta/projection_managed.rb +108 -0
  31. data/lib/acta/railtie.rb +65 -0
  32. data/lib/acta/reactor.rb +15 -0
  33. data/lib/acta/reactor_job.rb +19 -0
  34. data/lib/acta/record.rb +10 -0
  35. data/lib/acta/schema.rb +12 -0
  36. data/lib/acta/serializable.rb +48 -0
  37. data/lib/acta/testing/dsl.rb +90 -0
  38. data/lib/acta/testing/matchers.rb +77 -0
  39. data/lib/acta/testing.rb +50 -0
  40. data/lib/acta/types/encrypted_string.rb +63 -0
  41. data/lib/acta/version.rb +5 -0
  42. data/lib/acta/web/engine.rb +13 -0
  43. data/lib/acta/web/events_query.rb +81 -0
  44. data/lib/acta/web.rb +45 -0
  45. data/lib/acta.rb +296 -0
  46. data/lib/generators/acta/install/install_generator.rb +23 -0
  47. data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
  48. data/sig/acta.rbs +4 -0
  49. metadata +152 -0
data/README.md ADDED
@@ -0,0 +1,559 @@
1
+ # Acta
2
+
3
+ Lightweight event-driven and event-sourced primitives for Rails.
4
+
5
+ ## What it is
6
+
7
+ A small, opinionated set of primitives for Rails applications that want an
8
+ audit log, an event-driven architecture, or event sourcing — without taking
9
+ on a heavyweight framework. Apps compose the primitives à la carte:
10
+
11
+ - Plain event-driven with a persistent audit log
12
+ - Event-sourced aggregates with readonly projections
13
+ - Hybrid — some aggregates event-sourced, others conventional
14
+
15
+ What the library ships:
16
+
17
+ | Primitive | Role |
18
+ |---|---|
19
+ | `Acta::Event` | ActiveModel-backed event classes with typed payloads |
20
+ | `Acta::Handler` | Base primitive — "on event X, run this" |
21
+ | `Acta::Projection` | Sync + transactional + replayable (for ES aggregates) |
22
+ | `Acta::Reactor` | After-commit + async via ActiveJob (for side effects) |
23
+ | `Acta::Command` | Recommended write path with param validation & optimistic concurrency |
24
+ | `Acta::Testing` | RSpec matchers, given-when-then DSL, replay-determinism assertions |
25
+
26
+ Adapters: SQLite and Postgres, both first-class.
27
+
28
+ ## Installation
29
+
30
+ Not published to RubyGems. Install from git:
31
+
32
+ ```ruby
33
+ # Gemfile
34
+ gem "acta", git: "https://github.com/whoojemaflip/acta.git"
35
+ ```
36
+
37
+ Requires Rails 8.1+ and Ruby 3.4+.
38
+
39
+ Generate the events table migration:
40
+
41
+ ```bash
42
+ bin/rails generate acta:install
43
+ bin/rails db:migrate
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### 1. Define an event
49
+
50
+ ```ruby
51
+ # app/events/order_placed.rb
52
+ class OrderPlaced < Acta::Event
53
+ stream :order, key: :order_id
54
+
55
+ attribute :order_id, :string
56
+ attribute :customer_id, :string
57
+ attribute :total_cents, :integer
58
+
59
+ validates :order_id, :customer_id, :total_cents, presence: true
60
+ end
61
+ ```
62
+
63
+ ### 2. Emit it
64
+
65
+ Set the actor once at the request boundary:
66
+
67
+ ```ruby
68
+ # ApplicationController
69
+ before_action do
70
+ Acta::Current.actor = Acta::Actor.new(
71
+ type: "user",
72
+ id: current_user.id,
73
+ source: "web"
74
+ )
75
+ end
76
+
77
+ # somewhere in your code
78
+ Acta.emit(OrderPlaced.new(order_id: "o_1", customer_id: "c_1", total_cents: 4200))
79
+ ```
80
+
81
+ That's the minimum viable Acta app — you now have an append-only audit log
82
+ keyed by actor (who) and source (through what surface). Actor types and
83
+ sources are open strings; pick the vocabulary that fits your app.
84
+
85
+ ### 3. React to events (event-driven)
86
+
87
+ For side effects that should happen after each event is durably written:
88
+
89
+ ```ruby
90
+ # app/reactors/confirmation_email_reactor.rb
91
+ class ConfirmationEmailReactor < Acta::Reactor
92
+ on OrderPlaced do |event|
93
+ OrderMailer.confirmation(event.order_id).deliver_later
94
+ end
95
+ end
96
+ ```
97
+
98
+ Reactors run after-commit and default to async via ActiveJob. Use `sync!`
99
+ to run in the caller's thread (mostly useful for tests).
100
+
101
+ ### 4. Project state (event-sourced)
102
+
103
+ For aggregates where the event log is the source of truth and AR tables
104
+ are a derived view:
105
+
106
+ ```ruby
107
+ # app/projections/order_projection.rb
108
+ class OrderProjection < Acta::Projection
109
+ truncates Order
110
+
111
+ on OrderPlaced do |event|
112
+ Order.create!(
113
+ id: event.order_id,
114
+ customer_id: event.customer_id,
115
+ total_cents: event.total_cents,
116
+ status: "placed"
117
+ )
118
+ end
119
+
120
+ on OrderShipped do |event|
121
+ Order.find(event.order_id).update!(status: "shipped", shipped_at: event.occurred_at)
122
+ end
123
+ end
124
+ ```
125
+
126
+ `truncates Order` declares the AR classes this projection owns. Acta uses
127
+ the declaration both as the default `truncate!` (`delete_all` on each in
128
+ order) and as input to cross-projection ordering: when one projection's
129
+ tables FK-reference another's, the children are truncated first
130
+ regardless of registration order. List multiple in safe within-projection
131
+ order (children before parents):
132
+
133
+ ```ruby
134
+ class CatalogProjection < Acta::Projection
135
+ truncates Trail, Zone # Trail.zone_id → Zone.id, so Trail first
136
+ end
137
+ ```
138
+
139
+ Override `truncate!` directly when the default isn't enough — `truncates`
140
+ still drives global FK ordering, while the override provides whatever
141
+ custom teardown the projection needs.
142
+
143
+ Projections run synchronously inside the emit transaction. If they raise,
144
+ the entire emit rolls back — the event row isn't written, reactors don't
145
+ fire, base handlers don't fire.
146
+
147
+ Projections register themselves with Acta the first time their class is
148
+ loaded (via `Class.inherited`). Acta's Railtie eagerly loads everything
149
+ under `app/projections`, `app/handlers`, and `app/reactors` on each
150
+ `config.to_prepare`, so subscribers are wired up before the first request
151
+ — including in dev mode where Zeitwerk would otherwise wait until
152
+ something explicitly references the constant. If your subscribers live
153
+ elsewhere, point Acta at them:
154
+
155
+ ```ruby
156
+ # config/application.rb
157
+ config.acta.projection_paths = %w[app/projections app/read_models]
158
+ config.acta.handler_paths = %w[app/handlers]
159
+ config.acta.reactor_paths = %w[app/reactors]
160
+ ```
161
+
162
+ Set a path list to `[]` to disable auto-loading and manage subscriber
163
+ lifecycle yourself.
164
+
165
+ Replay at any time:
166
+
167
+ ```ruby
168
+ Acta.rebuild!
169
+ ```
170
+
171
+ Each projection's `truncate!` runs in FK-safe order (derived from the
172
+ `truncates` declarations), then the log is replayed through projections.
173
+ Reactors are skipped during replay (replay is a state operation, not a
174
+ notification one).
175
+
176
+ #### Guarding projection-owned tables
177
+
178
+ Once a model is maintained by a projection, *every* other write path
179
+ (controllers, console one-offs, rake tasks, callbacks on other models)
180
+ silently breaks the event log as the source of truth. Opt into a runtime
181
+ guard with `acta_managed!`:
182
+
183
+ ```ruby
184
+ class Order < ApplicationRecord
185
+ acta_managed! # writes outside an Acta::Projection raise ProjectionWriteError
186
+ end
187
+ ```
188
+
189
+ Inside an `Acta::Projection` `on EventClass do |e| ... end` block (and
190
+ during `Acta.rebuild!`'s truncate phase), `Acta::Projection.applying?`
191
+ is true and writes pass through. From a controller, console, or
192
+ unrelated callback, they raise:
193
+
194
+ ```ruby
195
+ Order.update_all(status: "cancelled")
196
+ # raise: Acta::ProjectionWriteError — Order is acta_managed!
197
+ # Emit an event so the projection can update the row, or wrap
198
+ # intentional out-of-band writes in
199
+ # `Acta::Projection.applying! { ... }` (fixtures, migrations,
200
+ # backfills).
201
+ ```
202
+
203
+ For incremental migration, demote violations to warnings:
204
+
205
+ ```ruby
206
+ acta_managed! on_violation: :warn
207
+ ```
208
+
209
+ Test fixtures, data migrations, and one-off backfills can wrap
210
+ intentional out-of-band writes in `Acta::Projection.applying! { ... }`
211
+ to bypass the safety net explicitly.
212
+
213
+ ### 5. Commands for validated writes
214
+
215
+ ```ruby
216
+ # app/commands/place_order.rb
217
+ class PlaceOrder < Acta::Command
218
+ param :customer_id, :string
219
+ param :total_cents, :integer
220
+
221
+ validates :customer_id, :total_cents, presence: true
222
+ validates :total_cents, numericality: { greater_than: 0 }
223
+
224
+ def call
225
+ order_id = "order_#{SecureRandom.uuid}"
226
+ emit OrderPlaced.new(order_id:, customer_id:, total_cents:)
227
+ end
228
+ end
229
+
230
+ cmd = PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
231
+ cmd.emitted_events.first.order_id # => "order_…"
232
+ ```
233
+
234
+ `Acta::Command.call` returns the command instance. The instance carries
235
+ the params, the `emitted_events` array (every event emitted during
236
+ `#call`, in order), and any state the command exposed via
237
+ `attr_reader`. Callers that don't care about the events ignore the
238
+ return value:
239
+
240
+ ```ruby
241
+ PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
242
+ ```
243
+
244
+ Commands can emit zero, one, or many events. The framework does not
245
+ invent a "primary" event — when a command emits more than one, the
246
+ caller (who knows the domain) picks what matters from
247
+ `cmd.emitted_events`.
248
+
249
+ ### Optimistic locking (high-water mark)
250
+
251
+ Every stream has a high-water mark — the `stream_sequence` of its most
252
+ recent event. `Acta.version_of` reads it; `Acta.emit(..., if_version: N)`
253
+ asserts it. Use the pair when you need optimistic locking against
254
+ concurrent writers to the same aggregate:
255
+
256
+ ```ruby
257
+ class RenameOrder < Acta::Command
258
+ param :order_id, :string
259
+ param :new_name, :string
260
+
261
+ def call
262
+ version = Acta.version_of(stream_type: :order, stream_key: order_id)
263
+ # ... do work that depends on the current state ...
264
+ emit OrderRenamed.new(order_id:, new_name:), if_version: version
265
+ end
266
+ end
267
+ ```
268
+
269
+ If another writer has appended to the stream between `version_of` and
270
+ `emit`, the emit raises `Acta::VersionConflict` — callers retry with
271
+ fresh state or surface the collision instead of silently clobbering it.
272
+ `if_version: 0` asserts a fresh stream (no events yet). Most commands
273
+ don't need this; reach for it when concurrent writes to the same
274
+ aggregate are realistic and lost-update would be a bug.
275
+
276
+ ## Identity: generate IDs in commands, never in projections
277
+
278
+ For event-sourced aggregates, aggregate IDs (typically UUIDs) must be
279
+ stable across `Acta.rebuild!` and must not drift if the projected tables
280
+ are truncated. The rule: **the command generates the ID once, the event
281
+ carries it in its payload, and the projection reads it back out**.
282
+
283
+ ```ruby
284
+ class CreateOrder < Acta::Command
285
+ param :customer_id, :string
286
+ param :total_cents, :integer
287
+
288
+ def call
289
+ order_id = "order_#{SecureRandom.uuid}" # generated here, once, forever
290
+ emit OrderCreated.new(order_id:, customer_id:, total_cents:)
291
+ end
292
+ end
293
+
294
+ class OrderCreated < Acta::Event
295
+ stream :order, key: :order_id
296
+ attribute :order_id, :string
297
+ attribute :customer_id, :string
298
+ attribute :total_cents, :integer
299
+ end
300
+
301
+ class OrderProjection < Acta::Projection
302
+ on OrderCreated do |event|
303
+ Order.insert!(id: event.order_id, customer_id: event.customer_id, ...)
304
+ end
305
+ end
306
+ ```
307
+
308
+ When `Acta.rebuild!` runs, it calls `OrderProjection.truncate!` (wiping
309
+ the `orders` table) and replays every event. The projection reads
310
+ `event.order_id` — which was written at the original command call — and
311
+ re-inserts the row with the same ID. **Rebuild never regenerates IDs.**
312
+
313
+ ### What to avoid
314
+
315
+ - **Generating IDs in projection code.** Non-deterministic — every
316
+ rebuild produces new IDs, orphaning any foreign references.
317
+ `SecureRandom` / `Time.current` / anything stateful has no place in a
318
+ projection.
319
+ - **Generating IDs in the event class's `initialize`.** Same problem:
320
+ if the event assigns a default ID when reconstructed from a row, old
321
+ events would decode with fresh IDs. Events should take an explicit
322
+ `order_id:` attribute and require it in the payload.
323
+ - **Dropping the events table.** The event log is the primary source
324
+ of IDs. Purging it regenerates all IDs on next write. Back it up and
325
+ treat it as production-critical — even more so if other systems (a
326
+ separate user DB, external services) reference your aggregates' IDs.
327
+
328
+ ### Why this matters
329
+
330
+ If anything outside the event-sourced aggregate references an ID —
331
+ `ratings.wine_id` in a separate user database, a webhook payload sent to
332
+ a third party, a URL that users have bookmarked — that reference must
333
+ stay valid across rebuilds. Keeping IDs in the event payload guarantees
334
+ it without any special deterministic-UUID schemes.
335
+
336
+ ## Event payloads with nested models
337
+
338
+ Payloads can carry arbitrary nested structures — either payload-only
339
+ `Acta::Model` classes or ActiveRecord classes that include
340
+ `Acta::Serializable`.
341
+
342
+ ```ruby
343
+ # payload-only class
344
+ class LineItem < Acta::Model
345
+ attribute :sku, :string
346
+ attribute :quantity, :integer
347
+ attribute :price_cents, :integer
348
+ end
349
+
350
+ # existing AR class — opt in as a payload type
351
+ class Address < ApplicationRecord
352
+ include Acta::Serializable
353
+ acta_serialize except: [:created_at, :updated_at]
354
+ end
355
+
356
+ class OrderSubmitted < Acta::Event
357
+ stream :order, key: :order_id
358
+
359
+ attribute :order_id, :string
360
+ attribute :shipping_address, Address # AR + Serializable
361
+ attribute :items, array_of: LineItem # Array<Acta::Model>
362
+ attribute :tags, array_of: String
363
+ end
364
+ ```
365
+
366
+ When embedded, AR instances are **snapshots**: `event.shipping_address.street`
367
+ returns the value at emit time, regardless of later changes. For the
368
+ current row, call `Address.find(event.shipping_address.id)`.
369
+
370
+ ## Encrypted attributes
371
+
372
+ Some events carry secrets — OAuth tokens, API keys, password reset
373
+ tokens — that shouldn't sit in `events.payload` as plaintext. Acta
374
+ supports per-attribute encryption that piggybacks on Rails'
375
+ `ActiveRecord::Encryption` (same keys, same rotation story).
376
+
377
+ ```ruby
378
+ class StravaCredentialIssued < Acta::Event
379
+ stream :strava_credential, key: :user_id
380
+
381
+ attribute :user_id, :string
382
+ attribute :access_token, :encrypted_string # encrypted in payload
383
+ attribute :refresh_token, :encrypted_string
384
+ attribute :expires_at, :datetime
385
+ end
386
+ ```
387
+
388
+ In memory the event behaves normally — `event.access_token` is the
389
+ plaintext you wrote. The encrypted form only appears in the serialized
390
+ payload that's written to the events table:
391
+
392
+ ```
393
+ events.payload → { "access_token": "{\"p\":\"…\",\"h\":{\"iv\":\"…\",\"at\":\"…\"}}", "user_id": "u_1", … }
394
+ ```
395
+
396
+ Encryption is **per-attribute**: leave queryable fields like `user_id`
397
+ plaintext, encrypt only the secrets.
398
+
399
+ ### Setup
400
+
401
+ Encrypted attributes use the same configuration as Rails AR encryption.
402
+ If you're already using `encrypts` on AR models, you have nothing to do.
403
+ Otherwise:
404
+
405
+ ```bash
406
+ bin/rails db:encryption:init
407
+ ```
408
+
409
+ Then store the printed keys in `Rails.application.credentials`:
410
+
411
+ ```yaml
412
+ active_record_encryption:
413
+ primary_key: ...
414
+ deterministic_key: ...
415
+ key_derivation_salt: ...
416
+ ```
417
+
418
+ ### Key rotation
419
+
420
+ To rotate, append a new primary key (Rails keeps the old keys for
421
+ decryption indefinitely):
422
+
423
+ ```yaml
424
+ active_record_encryption:
425
+ primary_key:
426
+ - new_primary_key # new writes use this
427
+ - old_primary_key # old payloads still decrypt
428
+ deterministic_key: ...
429
+ key_derivation_salt: ...
430
+ ```
431
+
432
+ Existing event rows stay decryptable. New emits use the new key. No
433
+ re-encryption migration is required — the audit log accretes ciphertext
434
+ under whichever key was current at write time.
435
+
436
+ ## Testing
437
+
438
+ ```ruby
439
+ # spec_helper.rb (or equivalent)
440
+ require "acta/testing"
441
+ require "acta/testing/matchers"
442
+
443
+ RSpec.configure do |config|
444
+ Acta::Testing.default_actor!(config)
445
+ config.include Acta::Testing::DSL
446
+
447
+ config.around(:each, :active_record) do |example|
448
+ Acta::Testing.test_mode { example.run }
449
+ end
450
+ end
451
+ ```
452
+
453
+ ### Default actor
454
+
455
+ `Acta.emit` requires `Acta::Current.actor` to be set — every event needs
456
+ a known author. `Acta::Testing.default_actor!(config)` adds a
457
+ `before(:each)` that sets a default `system / rspec / test` actor and an
458
+ `after(:each)` that resets it, so specs (and the commands they call)
459
+ don't trip `Acta::MissingActor`. Override any attribute to match your
460
+ project's vocabulary:
461
+
462
+ ```ruby
463
+ Acta::Testing.default_actor!(config, type: "user", id: "test-user-1", source: "spec")
464
+ ```
465
+
466
+ For an individual example that needs to attribute emissions to a
467
+ specific actor, scope an override with `with_actor`:
468
+
469
+ ```ruby
470
+ include Acta::Testing::DSL
471
+
472
+ it "records the user as the actor" do
473
+ with_actor(type: "user", id: user.id, source: "web") do
474
+ PlaceOrder.call(...)
475
+ end
476
+
477
+ expect(Acta::Record.last.actor_id).to eq(user.id)
478
+ end
479
+ ```
480
+
481
+ `with_actor` restores the surrounding actor when the block returns or
482
+ raises.
483
+
484
+ ### RSpec matchers
485
+
486
+ ```ruby
487
+ expect { PlaceOrder.call(order_id: "o_1", customer_id: "c_1", total_cents: 4200) }
488
+ .to emit(OrderPlaced).with(total_cents: 4200)
489
+
490
+ expect { some_noop }.not_to emit_any_events
491
+
492
+ expect { batched_import }
493
+ .to emit_events([OrderPlaced, OrderPlaced, OrderPlaced])
494
+ ```
495
+
496
+ ### Given/when/then DSL
497
+
498
+ ```ruby
499
+ include Acta::Testing::DSL
500
+
501
+ it "ships an order" do
502
+ given_events do
503
+ Acta.emit(OrderPlaced.new(order_id: "o_1", customer_id: "c_1", total_cents: 4200))
504
+ end
505
+
506
+ when_command ShipOrder.new(order_id: "o_1", tracking: "TRK123")
507
+
508
+ then_emitted OrderShipped, order_id: "o_1"
509
+ then_emitted_nothing_else
510
+ end
511
+ ```
512
+
513
+ Fixtures become narratives — prior state is declared as events, which
514
+ mirrors how state actually accumulates in an event-sourced system.
515
+
516
+ ### Replay determinism check
517
+
518
+ ```ruby
519
+ it "projects deterministically" do
520
+ # ... emit some events ...
521
+ ensure_replay_deterministic { Order.all.pluck(:id, :status) }
522
+ end
523
+ ```
524
+
525
+ Catches the common projection bugs (Time.current, rand, external API
526
+ calls) better than code review ever will.
527
+
528
+ ## Observability
529
+
530
+ Hook into `ActiveSupport::Notifications` for metrics, tracing, and
531
+ request correlation:
532
+
533
+ - `acta.event_emitted` — `{ event, event_type }`
534
+ - `acta.projection_applied` — `{ event, projection_class }`
535
+ - `acta.reactor_invoked` — `{ event, reactor_class, sync: true }`
536
+ - `acta.reactor_enqueued` — `{ event, reactor_class }`
537
+
538
+ ## Development
539
+
540
+ ```bash
541
+ bin/setup # install dependencies
542
+ bundle exec rspec # run the test suite (SQLite + Postgres if available)
543
+ bundle exec rake # tests + rubocop
544
+ ```
545
+
546
+ The Postgres adapter tests run if a local Postgres instance is reachable.
547
+ Configure via environment variables:
548
+
549
+ ```
550
+ ACTA_PG_DATABASE=acta_test
551
+ ACTA_PG_HOST=localhost
552
+ ACTA_PG_PORT=5432
553
+ ACTA_PG_USER=$USER
554
+ ACTA_PG_PASSWORD=
555
+ ```
556
+
557
+ ## License
558
+
559
+ MIT. See [LICENSE](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ module Web
5
+ class ApplicationController < Acta::Web.base_controller_class.constantize
6
+ layout "acta/web/application"
7
+ helper Acta::Web::ApplicationHelper
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "acta/web/events_query"
4
+
5
+ module Acta
6
+ module Web
7
+ class EventsController < ApplicationController
8
+ PER_PAGE = 40
9
+
10
+ def index
11
+ @base_count = Acta::Record.count
12
+
13
+ @facet_event_type = Acta::Record.group(:event_type).count.sort_by { |_, n| -n }.to_h
14
+ @facet_stream_type = Acta::Record.group(:stream_type).count.sort_by { |_, n| -n }.to_h
15
+ @facet_actor_id = Acta::Record.group(:actor_id).count.sort_by { |_, n| -n }.to_h
16
+
17
+ query = Acta::Web::EventsQuery.new(params)
18
+ @events_scope = query.scope
19
+ @filtered_count = @events_scope.count
20
+
21
+ @page = [ params[:page].to_i, 0 ].max
22
+ @total_pages = [ (@filtered_count / PER_PAGE.to_f).ceil, 1 ].max
23
+ @page = [ @page, @total_pages - 1 ].min
24
+
25
+ @events = @events_scope.order(id: :desc).offset(@page * PER_PAGE).limit(PER_PAGE)
26
+
27
+ @selected_event = Acta::Record.find_by(uuid: params[:selected]) if params[:selected].present?
28
+
29
+ @active_filters = query.active_filters
30
+ end
31
+
32
+ def show
33
+ @event = Acta::Record.find_by!(uuid: params[:id])
34
+ end
35
+ end
36
+ end
37
+ end