standard_ledger 0.5.0 → 0.6.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.
data/README.md CHANGED
@@ -1,419 +1,212 @@
1
1
  # standard_ledger
2
2
 
3
- Immutable journal entries with declarative aggregate projections for Rails apps.
3
+ Immutable, append-only journal entries for Rails apps, with a `post` helper
4
+ and a materialized-view refresh wrapper.
4
5
 
5
- > **Status: v0.3.0** — feature-complete across all five projection modes
6
- > (`:inline`, `:async`, `:sql`, `:matview`, `:trigger`) plus
7
- > `StandardLedger.rebuild!` log-replay, `StandardLedger.refresh!` ad-hoc
8
- > matview refresh, and the `standard_ledger:doctor` rake task. Ready for
9
- > adoption in luminality-web, fundbright-web, sidekick-web, and
10
- > nutripod-web. See [`standard_ledger-design.md`](https://github.com/rarebit-one/standard_ledger/blob/main/standard_ledger-design.md)
11
- > for the full design and rollout plan.
6
+ > **Status: v0.6.0.** 0.6.0 removed the declarative projection engine
7
+ > (`projects_onto`, the `:inline`/`:async`/`:sql`/`:trigger`/`:matview`/`:manual`
8
+ > modes, `rebuild!`, `with_modes`, the jobs and the `standard_ledger:doctor`
9
+ > task) because no consuming app used it. See the
10
+ > [CHANGELOG](CHANGELOG.md#060---2026-09-24) for the upgrade note and
11
+ > [`standard_ledger-design.md`](standard_ledger-design.md) for the design.
12
12
 
13
13
  ## What it is
14
14
 
15
- Across our four Rails apps (nutripod-web, luminality-web, fundbright-web,
16
- sidekick-web) we keep building the same thing: an immutable journal table
17
- whose rows update one or more cached aggregates on parent records. Inventory
18
- movements, voucher issuance, payment records, fulfillment records, prompt
19
- transactions, entitlement grants, validation outcomes, device firmware
20
- updates — same shape, eight different ad-hoc implementations.
15
+ Our apps keep building the same thing: an immutable journal table (voucher
16
+ issuance, commission entries, runner usage, device events, prompt
17
+ transactions) whose rows are permanent facts. `standard_ledger` gives those
18
+ tables one shared contract on top of the host's existing ActiveRecord models.
19
+ The gem **does not own the schema**: hosts keep their tables and the gem
20
+ adapts to them.
21
21
 
22
- `standard_ledger` extracts the pattern into a single declarative DSL that
23
- lives on top of the host's existing ActiveRecord models. The gem **does not
24
- own the schema** — host apps already have entry tables and aggregate columns,
25
- and the gem adapts to them rather than replacing them.
22
+ What you get:
26
23
 
27
- ## Sketch
24
+ | Piece | What it does |
25
+ |---|---|
26
+ | `StandardLedger::Entry` | Makes persisted rows read-only, blocks `destroy` (opt out with `allow_destroy:`), and gives `create!` idempotency-by-unique-index. |
27
+ | `StandardLedger.post` | Sugar over `create!` that maps `targets:` onto `belongs_to` associations and returns a Result (the gem's, or your app's own). |
28
+ | `StandardLedger::Projection` | An optional base class for host-side projector objects you call yourself (`apply` / `rebuild`). |
29
+ | `StandardLedger.refresh!` | `REFRESH MATERIALIZED VIEW [CONCURRENTLY]` for host-owned views, with `concurrently: :auto`. |
30
+ | `post_ledger_entry` | An RSpec block matcher. |
31
+
32
+ The gem does **not** keep aggregates up to date for you. Updating derived
33
+ state is the host's job: a projector it calls from its own operation, a
34
+ counter update in the same transaction, or a materialized view refreshed on a
35
+ schedule.
36
+
37
+ ## Entries
28
38
 
29
39
  ```ruby
30
40
  class VoucherRecord < ApplicationRecord
31
41
  include StandardLedger::Entry
32
- include StandardLedger::Projector
33
-
34
- ledger_entry kind: :action,
35
- idempotency_key: :serial_no,
36
- scope: :organisation_id
37
42
 
38
- projects_onto :voucher_scheme, mode: :inline do
39
- on(:grant) { |scheme, _| scheme.increment(:granted_vouchers_count) }
40
- on(:redeem) { |scheme, _| scheme.increment(:redeemed_vouchers_count) }
41
- on(:consume) { |scheme, _| scheme.increment(:consumed_vouchers_count) }
42
- on(:clawback) { |scheme, _| scheme.increment(:clawed_back_vouchers_count) }
43
- end
43
+ belongs_to :voucher_scheme
44
+ belongs_to :customer_profile
44
45
 
45
- projects_onto :customer_profile,
46
- mode: :inline,
47
- if: -> { customer_profile_id.present? } do
48
- on(:grant) { |profile, _| profile.increment(:granted_vouchers_count) }
49
- on(:redeem) { |profile, _| profile.increment(:redeemed_vouchers_count) }
50
- on(:consume) { |profile, _| profile.increment(:consumed_vouchers_count) }
51
- on(:clawback) { |profile, _| profile.increment(:clawed_back_vouchers_count) }
52
- end
46
+ ledger_entry kind: :action, # column holding the kind
47
+ idempotency_key: :serial_no, # nil = not idempotent
48
+ scope: :organisation_id # unique index is [organisation_id, serial_no]
53
49
  end
54
50
  ```
55
51
 
56
- Post an entry with the module API (sugar over `VoucherRecord.create!`):
52
+ - After a row is persisted, `save`/`update` raise `ActiveRecord::ReadOnlyRecord`.
53
+ New, unsaved instances stay writable.
54
+ - `destroy` raises unless `ledger_entry ..., allow_destroy: true`. That option
55
+ exists so an owner's `dependent: :destroy` cascade can clean up; it doesn't
56
+ make entries editable.
57
+ - With `idempotency_key:`, a `create!` that trips the matching unique index
58
+ returns the existing row with `idempotent? == true` instead of raising. The
59
+ index must exist; the gem raises `StandardLedger::MissingIdempotencyIndex` on
60
+ first use if it doesn't.
61
+ - `<prefix>.entry.created` fires after commit (see [Events](#events)).
62
+
63
+ ## Posting
57
64
 
58
65
  ```ruby
59
66
  result = StandardLedger.post(VoucherRecord,
60
- kind: :grant,
61
- targets: { voucher_scheme: scheme, customer_profile: profile },
62
- attrs: { organisation_id: org.id, serial_no: "v-2025-1" })
67
+ kind: :grant,
68
+ targets: { voucher_scheme: scheme, customer_profile: profile },
69
+ attrs: { serial_no: "v-123", organisation_id: org.id })
63
70
 
64
- result.success? # => true
65
- result.entry # => the persisted VoucherRecord
66
- result.idempotent? # => false (true on retry against the same serial_no)
67
- result.projections # => { inline: [:voucher_scheme, :customer_profile] }
71
+ result.success? # false when the entry failed validation (errors in result.errors)
72
+ result.idempotent? # true when the idempotency key matched an existing row
73
+ result.entry # the persisted (or existing) entry
68
74
  ```
69
75
 
70
- Counters on both targets are incremented inside the same transaction as
71
- the INSERT — if any projection raises, the entry rolls back too. Posting
72
- twice with the same `serial_no` returns the original entry (with
73
- `idempotent? == true`) and skips the projection.
74
-
75
- Rebuild a target's projection from the log when its counters drift
76
- or a projection bug needs replaying — extract a `Projection` subclass
77
- that implements `rebuild(target)` and pass it via `via:`:
76
+ Pass foreign keys through `attrs:` (`voucher_scheme_id: 42`) when you don't
77
+ have a loaded record. A `targets:` key that isn't an association raises
78
+ `ArgumentError`.
78
79
 
79
- ```ruby
80
- class SchemeProjector < StandardLedger::Projection
81
- def apply(scheme, entry)
82
- scheme.increment(:"#{entry.action}_vouchers_count")
83
- scheme.save!
84
- end
80
+ ## Projectors
85
81
 
86
- def rebuild(scheme)
87
- records = VoucherRecord.where(voucher_scheme_id: scheme.id)
88
- scheme.update!(
89
- granted_vouchers_count: records.where(action: "grant").count,
90
- redeemed_vouchers_count: records.where(action: "redeem").count
91
- )
92
- end
93
- end
94
-
95
- # Single target, single class, or every target across every projection.
96
- StandardLedger.rebuild!(VoucherRecord, target: scheme)
97
- StandardLedger.rebuild!(VoucherRecord, target_class: VoucherScheme)
98
- StandardLedger.rebuild!(VoucherRecord)
99
- ```
100
-
101
- Each (target, projection) pair runs in its own transaction; failures
102
- mid-loop are not unwound. Block-form (delta) projections raise
103
- `NotRebuildable` because they cannot be reconstructed from the log
104
- without a host-supplied recompute path.
105
-
106
- For projections too expensive or stateful to run inside the entry's
107
- transaction (jsonb rebuild, multi-row aggregate), use `mode: :async` —
108
- the strategy enqueues `StandardLedger::ProjectionJob` from
109
- `after_create_commit`, and the job runs `target.with_lock { projector.apply(target, entry) }`
110
- on the configured ActiveJob backend:
82
+ Subclass `StandardLedger::Projection` for a projector your code calls itself:
111
83
 
112
84
  ```ruby
113
- class Orders::FulfillableProjector < StandardLedger::Projection
114
- # Recompute the jsonb balance from the full log inside with_lock.
115
- # `:async` projectors must be retry-safe — async retries can run
116
- # `apply` more than once, so block-form per-kind handlers
117
- # (incrementing counters) are rejected at registration time.
118
- def apply(order, _entry)
119
- order.update!(
120
- fulfillable_balance: order.fulfillment_records.group(:key).sum(:amount)
121
- )
85
+ class Validations::ProfileProjector < StandardLedger::Projection
86
+ def apply(profile, validation)
87
+ profile.increment!(:successful_loans_count) if validation.successful?
122
88
  end
123
89
 
124
- def rebuild(order)
125
- apply(order, nil)
90
+ def rebuild(profile)
91
+ profile.update!(successful_loans_count: profile.validations.successful.count)
126
92
  end
127
93
  end
128
94
 
129
- class FulfillmentRecord < ApplicationRecord
130
- include StandardLedger::Entry
131
- include StandardLedger::Projector
132
-
133
- belongs_to :order
134
-
135
- ledger_entry kind: :action, idempotency_key: :external_ref, scope: :organisation_id
136
-
137
- projects_onto :order, mode: :async, via: Orders::FulfillableProjector
138
- end
95
+ Validations::ProfileProjector.new.apply(profile, validation)
139
96
  ```
140
97
 
141
- Retries are capped by `Config#default_async_retries` (default 3); the
142
- job emits `<prefix>.projection.applied` and `<prefix>.projection.failed`
143
- events with an additional `attempt:` key so subscribers can tell
144
- first-try success from retry success. Tests can force async projections
145
- to run inline via `StandardLedger.with_modes(FulfillmentRecord => :inline) { ... }`
146
- — the strategy short-circuits the enqueue and runs the projector
147
- synchronously inside `with_lock`, so end-to-end coverage works without a
148
- job runner.
98
+ Unimplemented `apply` raises `NotImplementedError`. Unimplemented `rebuild`
99
+ raises `StandardLedger::NotRebuildable`.
149
100
 
150
- For projections expressible as a single `UPDATE` over an aggregate of the
151
- log, use `mode: :sql` — no Ruby-side handlers, no AR object loads, just
152
- a recompute statement that runs in the entry's `after_create`:
101
+ ## Materialized views
153
102
 
154
- ```ruby
155
- class VoucherRecord < ApplicationRecord
156
- include StandardLedger::Entry
157
- include StandardLedger::Projector
158
-
159
- ledger_entry kind: :action, idempotency_key: :serial_no, scope: :organisation_id
160
-
161
- belongs_to :voucher_scheme
162
-
163
- projects_onto :voucher_scheme, mode: :sql do
164
- recompute <<~SQL
165
- UPDATE voucher_schemes SET
166
- granted_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'grant'),
167
- redeemed_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'redeem'),
168
- consumed_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'consume'),
169
- clawed_back_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'clawback')
170
- WHERE id = :target_id
171
- SQL
172
- end
173
- end
174
- ```
175
-
176
- The gem binds `:target_id` from the entry's foreign key. The recompute
177
- SQL is the entire contract — `:sql` projections are naturally
178
- rebuildable: `StandardLedger.rebuild!` runs the same statement against
179
- every target the log references.
180
-
181
- When the host **already has** a database trigger that updates the
182
- projection target on every entry INSERT, register it with `mode: :trigger`
183
- so the gem records the trigger's name and the equivalent rebuild SQL —
184
- without taking ownership of the trigger DDL. The host writes the trigger
185
- in a Rails migration; the gem only consumes the metadata.
103
+ The host creates and owns the view (e.g. with a `scenic` migration) and
104
+ schedules refreshes itself (SolidQueue recurring task, cron, …). The gem
105
+ issues the SQL and emits events. PostgreSQL only.
186
106
 
187
107
  ```ruby
188
- class InventoryRecord < ApplicationRecord
189
- include StandardLedger::Entry
190
- include StandardLedger::Projector
191
-
192
- belongs_to :sku
193
-
194
- ledger_entry kind: :action, idempotency_key: :serial_no, scope: :organisation_id
195
-
196
- projects_onto :sku, mode: :trigger,
197
- trigger_name: "inventory_records_apply_to_skus" do
198
- rebuild_sql <<~SQL
199
- UPDATE skus SET
200
- total_count = c.total_count,
201
- reserved_count = c.reserved_count,
202
- free_count = c.total_count - c.reserved_count
203
- FROM (
204
- SELECT sku_id,
205
- COUNT(*) FILTER (WHERE action IN ('grant','adjust_in')) AS total_count,
206
- COUNT(*) FILTER (WHERE action = 'reserve') AS reserved_count
207
- FROM inventory_records
208
- WHERE sku_id = :target_id
209
- GROUP BY sku_id
210
- ) c
211
- WHERE skus.id = :target_id AND skus.id = c.sku_id
212
- SQL
213
- end
214
- end
108
+ StandardLedger.refresh!(:device_fleet_stats) # Config#matview_refresh_strategy
109
+ StandardLedger.refresh!(:device_fleet_stats, concurrently: :auto) # concurrent when it can be
110
+ StandardLedger.refresh!(:device_fleet_stats, concurrently: true) # always CONCURRENTLY
111
+ StandardLedger.refresh!(:device_fleet_stats, concurrently: false) # always plain (blocking)
215
112
  ```
216
113
 
217
- The trigger continues to fire on every `INSERT` (the host owns the DDL);
218
- the gem records the trigger name + rebuild SQL for two purposes:
219
-
220
- - `StandardLedger.rebuild!(InventoryRecord, target: sku)` runs the
221
- recorded `rebuild_sql` with `:target_id` bound to each target's id.
222
- - `bin/rails standard_ledger:doctor` verifies that every registered
223
- `:trigger` projection's named trigger exists in the connected schema
224
- (queries `pg_trigger`). Run this as a deploy-time check — migration
225
- drift surfaces immediately rather than at runtime. **Postgres-only**;
226
- the task raises on non-Postgres connections.
114
+ `REFRESH MATERIALIZED VIEW CONCURRENTLY` has three preconditions. Postgres
115
+ rejects it:
227
116
 
228
- Registration rejects `via:`, `lock:`, and `permissive:` (none are
229
- meaningful when the trigger itself is the contract). The `trigger_name:`
230
- keyword is required; the block must call `rebuild_sql "..."` exactly
231
- once with a SQL string containing the `:target_id` placeholder.
117
+ - inside a transaction block (the gem raises `StandardLedger::RefreshInsideTransaction` before sending SQL),
118
+ - on a view that has never been populated (e.g. created `WITH NO DATA`, or empty right after deploy),
119
+ - on a view without a unique index that uses only column names and has no `WHERE` clause.
232
120
 
233
- Refresh a `:matview` projection ad-hoc when the host needs immediate
234
- read-your-write semantics (e.g. at the end of a draw operation, before
235
- the next scheduled refresh would otherwise show stale counts):
121
+ `concurrently: :auto` checks all three first: it looks for an open
122
+ transaction, then reads `pg_class.relispopulated` and `pg_index` for the
123
+ view. It refreshes `CONCURRENTLY` only when all three are met and falls back
124
+ to a plain refresh otherwise. If the catalog lookup itself fails, the error is
125
+ reported to `Rails.error` (`handled: true`, `source: "standard_ledger"`) and a
126
+ plain refresh runs. A job that previously caught
127
+ `PG::ObjectNotInPrerequisiteState` and `RefreshInsideTransaction` to retry
128
+ with `concurrently: false` can pass `concurrently: :auto` instead.
236
129
 
237
- ```ruby
238
- class PromptTxn < ApplicationRecord
239
- include StandardLedger::Entry
240
- include StandardLedger::Projector
241
-
242
- belongs_to :user_profile
243
-
244
- ledger_entry kind: :event, idempotency_key: nil
245
-
246
- projects_onto :user_profile,
247
- mode: :matview,
248
- view: "user_prompt_inventories",
249
- refresh: { every: 5.minutes, concurrently: true }
250
- end
251
-
252
- # Schedule the recurring refresh from the host (SolidQueue Recurring
253
- # Tasks, sidekiq-cron, etc.) targeting:
254
- # StandardLedger::MatviewRefreshJob
255
- # args: ["user_prompt_inventories", { concurrently: true }]
256
-
257
- # Ad-hoc refresh after a critical write:
258
- StandardLedger.refresh!(:user_prompt_inventories) # honors Config#matview_refresh_strategy
259
- StandardLedger.refresh!("user_prompt_inventories", concurrently: true)
260
- ```
261
-
262
- `StandardLedger.rebuild!(PromptTxn)` is equivalent to refreshing every
263
- `:matview` projection on the entry class — for matview, refresh *is*
264
- rebuild. Postgres has no partial-refresh primitive, so `target:` /
265
- `target_class:` scope arguments are ignored for `:matview` projections
266
- and the full view is always refreshed.
267
-
268
- Note: the default `:concurrent` strategy (and `concurrently: true`) requires
269
- a unique index on the matview — Postgres rejects `REFRESH MATERIALIZED VIEW
270
- CONCURRENTLY` otherwise. Add a unique index in the host migration that
271
- creates the view, or set `Config#matview_refresh_strategy = :blocking` (or
272
- pass `concurrently: false` per-call) if a unique index isn't an option.
273
-
274
- Five projection modes — pick per declaration:
275
-
276
- | Mode | Where the work runs | Transactional? | Rebuildable? |
277
- |---|---|---|---|
278
- | `:inline` | `after_create`, in the entry's transaction | yes | yes (if projector implements `rebuild`) |
279
- | `:async` | `after_create_commit` job, `with_lock` | no | yes (if projector implements `rebuild`) |
280
- | `:sql` | `after_create`, single `UPDATE ... FROM (SELECT ...)` | yes | yes (rebuild = same SQL) |
281
- | `:trigger` | the database, on INSERT | yes (same statement) | yes (host-owned trigger; gem records rebuild SQL) |
282
- | `:matview` | scheduled `REFRESH MATERIALIZED VIEW CONCURRENTLY` | no | trivially (refresh = rebuild) |
130
+ `refresh!` returns a success Result with
131
+ `projections[:refreshed] = [{ view:, concurrently: }]` (the concurrency mode
132
+ that was actually used). If the SQL fails, it emits `projection.failed` and
133
+ re-raises so your job runner can retry. View names must be bare or
134
+ `schema.view` identifiers. Anything else raises `ArgumentError`.
283
135
 
284
136
  ## Installation
285
137
 
286
- The gem is published on RubyGems:
287
-
288
138
  ```ruby
289
- gem "standard_ledger", "~> 0.4"
139
+ gem "standard_ledger", "~> 0.6"
290
140
  ```
291
141
 
292
- (It was git-pinned during incubation. Don't reintroduce a `git:` reference —
293
- it makes a bare `bundle install` a prerequisite for every other command in a
294
- fresh checkout, blocking `rubocop`/`rspec` until it has been run.)
295
-
296
- Then run the install generator to drop a configured initializer in place:
142
+ (Don't reintroduce a `git:` reference. It makes a bare `bundle install` a
143
+ prerequisite for every other command in a fresh checkout.)
297
144
 
298
145
  ```bash
299
146
  bin/rails g standard_ledger:install
300
147
  ```
301
148
 
302
- This writes `config/initializers/standard_ledger.rb` with commented-out
303
- examples covering every public `Config` setting — uncomment and edit only
304
- what you want to override. The generator is idempotent; re-running on an
305
- existing initializer skips with a clear message (pass `--force` to
306
- overwrite).
307
-
308
- A typical configuration looks like:
149
+ This writes `config/initializers/standard_ledger.rb` with every setting
150
+ commented out. A typical configuration:
309
151
 
310
152
  ```ruby
311
- StandardLedger.configure do |c|
312
- c.default_async_retries = 3
313
- c.scheduler = :solid_queue
314
- c.matview_refresh_strategy = :concurrent
315
-
316
- # Optional — return the host's Result type from StandardLedger.post:
317
- c.result_class = ApplicationOperation::Result
318
- c.result_adapter = ->(success:, value:, errors:, entry:, idempotent:, projections:) {
319
- ApplicationOperation::Result.new(success:, value: value || entry, errors:)
320
- }
153
+ Rails.application.config.to_prepare do
154
+ StandardLedger.configure do |c|
155
+ c.matview_refresh_strategy = :auto # :concurrent (default) | :blocking | :auto
156
+
157
+ # Optional — return the host's Result type from post / refresh!:
158
+ c.result_class = ApplicationOperation::Result
159
+ c.result_adapter = ->(success:, value:, errors:, entry:, idempotent:, projections:) {
160
+ ApplicationOperation::Result.new(success:, value: { entry: value || entry, idempotent:, projections: }, errors:)
161
+ }
162
+ end
321
163
  end
322
164
  ```
323
165
 
324
- ## Events
166
+ The adapter always receives all six keywords. `projections:` is `{}` for
167
+ `post` and `{ refreshed: [...] }` for `refresh!`.
325
168
 
326
- The gem emits five lifecycle events. On Rails 8.1+ they go through
327
- `Rails.event.notify(name, **payload)`; on older Rails (or any host without the
328
- structured reporter) they fall back to
329
- `ActiveSupport::Notifications.instrument(name, payload)`. The backend is
330
- detected per call, not cached at load — the gem is required before Rails has
331
- finished booting.
169
+ ## Events
332
170
 
333
- Every name is prefixed with `Config#notification_namespace` (default
334
- `standard_ledger`), so a host that renames the namespace renames all five.
171
+ On Rails 8.1+ events go through `Rails.event.notify(name, **payload)`. On
172
+ older Rails they fall back to
173
+ `ActiveSupport::Notifications.instrument(name, payload)`. Names are prefixed
174
+ with `Config#notification_namespace` (default `standard_ledger`).
335
175
 
336
176
  | Event | Fired when | Payload |
337
177
  |---|---|---|
338
- | `<prefix>.entry.created` | after the entry's transaction commits | `entry:`, `kind:`, `targets:` (a `{ name => target }` hash) |
339
- | `<prefix>.projection.applied` | a projection wrote successfully | `entry:`, `target:`, `projection:`, `mode:`, `duration_ms:` — plus `attempt:` in `:async` mode |
340
- | `<prefix>.projection.failed` | a projection raised | the `applied` payload plus `error:` (the exception) |
178
+ | `<prefix>.entry.created` | after the entry's transaction commits (not on idempotent returns) | `entry:`, `kind:`, `targets:` (`{ name => record }` for non-nil `belongs_to`) |
341
179
  | `<prefix>.projection.refreshed` | a matview refresh succeeded | `view:`, `concurrently:`, `duration_ms:` |
342
- | `<prefix>.projection.rebuilt` | `StandardLedger.rebuild!` finished one target | `entry_class:`, `target:`, `projection:`, `mode:` |
343
-
344
- Four payload shapes are worth knowing before you write a subscriber that
345
- assumes a key is always present:
346
-
347
- - **`:sql` mode sends `target: nil`.** The recompute statement is bound by
348
- `:target_id` and never loads the record, so there is no object to hand you.
349
- - **`:matview` events carry `view:`/`concurrently:` instead of
350
- `entry:`/`target:`.** A refresh is view-wide — Postgres has no partial-refresh
351
- primitive — so no single entry or target caused it. The matview variant of
352
- `projection.failed` also carries `mode: :matview`, while
353
- `projection.refreshed` carries no `mode:` at all.
354
- - **`projection.rebuilt` carries `entry_class:`, not `entry:`.** Rebuild is
355
- log replay across an entire class; there is no originating entry.
356
- - **`projection.failed` is not fired for input errors.** An `ArgumentError`
357
- from the matview name validator, or `RefreshInsideTransaction` from the
358
- boundary check, propagates without an event — the SQL was never issued, so
359
- nothing failed to project.
180
+ | `<prefix>.projection.failed` | the `REFRESH` SQL raised | `view:`, `concurrently:`, `mode: :matview`, `error:` |
360
181
 
361
- **Subscriber exceptions are swallowed** (warned to stderr, not re-raised).
362
- Ledger observability must never take down a host's request path: by emit time
363
- the projection has already either succeeded or been rolled back, so there is
364
- nothing a raising subscriber could usefully abort. Don't put work in a
365
- subscriber that you need to have happened.
366
-
367
- Retries: `:async` projections are capped by `Config#default_async_retries`
368
- (default 3), and both `applied` and `failed` carry `attempt:` so subscribers
369
- can tell first-try success from retry success.
182
+ `projection.failed` doesn't fire for input errors (an invalid view name, or
183
+ `RefreshInsideTransaction`) because no SQL was sent. The event names keep
184
+ their pre-0.6 `projection.*` spelling so existing subscribers keep working.
370
185
 
371
- `standard_audit` consumers can subscribe to `entry.created` to write an audit
372
- row; the gem itself never calls into audit. That coupling is deliberately the
373
- host's to opt into — see "Relationship to standard_audit" below.
186
+ **Subscriber exceptions are swallowed** (warned to stderr, not re-raised).
187
+ Ledger observability must never take down a request.
374
188
 
375
189
  ## Testing
376
190
 
377
- The gem ships an opt-in RSpec support file. Hosts add this to their
378
- `spec/rails_helper.rb`:
379
-
380
191
  ```ruby
192
+ # spec/rails_helper.rb
381
193
  require "standard_ledger/rspec"
382
194
  ```
383
195
 
384
- That registers a `before(:each)` hook that calls `StandardLedger.reset!`
385
- between examples (so per-spec configuration doesn't leak), and exposes:
386
-
387
- - `post_ledger_entry(EntryClass).with(...)` — a block matcher that
388
- subscribes to the `<namespace>.entry.created` notification for the
389
- duration of the block and asserts an entry of the expected class was
390
- written (with optional `kind:`/`targets:`/`attrs:` constraints).
391
-
392
- ```ruby
393
- it "records a voucher grant" do
394
- expect {
395
- Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
396
- }.to post_ledger_entry(VoucherRecord).with(
397
- kind: :grant,
398
- targets: { voucher_scheme: scheme, customer_profile: profile },
399
- attrs: { serial_no: "v-2025-1" }
400
- )
401
- end
402
- ```
403
-
404
- - `with_modes(EntryClass => :inline) { ... }` — forces specific entry
405
- classes' projections to run inline for the duration of the block. The
406
- override is thread-local and restored on block exit, so async-mode
407
- projections can be exercised end-to-end in a unit spec without a job
408
- runner.
409
-
410
- ```ruby
411
- it "fast-runs an async projection inline" do
412
- with_modes(PaymentRecord => :inline) do
413
- Orders::CheckoutOperation.call(...)
414
- end
415
- end
416
- ```
196
+ This defines the `post_ledger_entry` block matcher. It listens on the channel
197
+ the gem emits through, so it works on Rails 8.1+ as well:
198
+
199
+ ```ruby
200
+ expect {
201
+ Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
202
+ }.to post_ledger_entry(VoucherRecord).with(
203
+ kind: :grant,
204
+ targets: { voucher_scheme: scheme },
205
+ attrs: { serial_no: "v-2025-1" }
206
+ )
207
+ ```
208
+
209
+ It registers no hooks and never resets your `Config`.
417
210
 
418
211
  ## Development
419
212
 
@@ -425,15 +218,14 @@ bundle exec rubocop
425
218
 
426
219
  ## Relationship to standard_audit
427
220
 
428
- Different gems, different concerns:
429
-
430
- - **`standard_audit`** — "user X took action Y on target Z," free-form
431
- metadata, no projection.
432
- - **`standard_ledger`** — "this delta updates these targets," typed kind,
433
- mandatory projection.
221
+ - **`standard_audit`**: "user X took action Y on target Z", with free-form
222
+ metadata.
223
+ - **`standard_ledger`**: typed, immutable, idempotent journal rows that other
224
+ state is derived from.
434
225
 
435
- A single host operation typically writes one of each, in one transaction.
436
- Neither subsumes the other.
226
+ A single host operation often writes one of each. Subscribe to
227
+ `entry.created` if you want an audit row per entry. The gem never calls into
228
+ audit itself.
437
229
 
438
230
  ## License
439
231
 
@@ -1,50 +1,34 @@
1
1
  # StandardLedger configuration
2
2
  # Generated by: rails g standard_ledger:install
3
3
  #
4
- # StandardLedger captures the recurring "immutable journal entry → N
5
- # aggregate projections" pattern as a declarative DSL on host ActiveRecord
6
- # models. See https://github.com/rarebit-one/standard_ledger for the gem
7
- # README.
4
+ # StandardLedger marks host ActiveRecord models as immutable, append-only
5
+ # journal entries and wraps materialized-view refreshes. See
6
+ # https://github.com/rarebit-one/standard_ledger for the gem README.
8
7
  #
9
8
  # All commented-out lines below show the public DSL — uncomment and edit
10
9
  # the ones that apply to your app.
11
10
 
12
11
  StandardLedger.configure do |c|
13
12
  # ---------------------------------------------------------------------------
14
- # Async mode
13
+ # Materialized view refresh
15
14
  # ---------------------------------------------------------------------------
16
15
 
17
- # The ActiveJob class used by `:async` mode projections. Defaults to
18
- # `StandardLedger::ProjectionJob`. Hosts can supply their own job class
19
- # (e.g. for custom queue routing or per-projection telemetry).
20
- # Default: nil (resolved lazily to StandardLedger::ProjectionJob)
21
- # c.default_async_job = Orders::FulfillableProjectionJob
22
-
23
- # Number of times an `:async` projection will retry before dead-lettering.
24
- # Default: 3.
25
- # c.default_async_retries = 3
26
-
27
- # ---------------------------------------------------------------------------
28
- # Matview mode
29
- # ---------------------------------------------------------------------------
30
-
31
- # Scheduler backend for `:matview` refresh jobs.
32
- # One of :solid_queue, :sidekiq_cron, :custom. Default: :solid_queue.
33
- # c.scheduler = :solid_queue
34
-
35
- # Default refresh strategy for `:matview` projections.
36
- # :concurrent (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a unique
37
- # index on the view) or :blocking. Default: :concurrent.
16
+ # Default for StandardLedger.refresh!(:view) calls that omit `concurrently:`.
17
+ # :concurrent — REFRESH MATERIALIZED VIEW CONCURRENTLY (needs a populated
18
+ # view with a unique index, outside a transaction)
19
+ # :blocking — plain REFRESH MATERIALIZED VIEW
20
+ # :auto — concurrent when possible, plain otherwise
21
+ # Default: :concurrent.
38
22
  # c.matview_refresh_strategy = :concurrent
39
23
 
40
24
  # ---------------------------------------------------------------------------
41
25
  # Notifications
42
26
  # ---------------------------------------------------------------------------
43
27
 
44
- # Prefix for ActiveSupport::Notifications events emitted by the gem.
45
- # Default: "standard_ledger". Events:
28
+ # Prefix for events emitted by the gem (Rails.event on Rails 8.1+,
29
+ # ActiveSupport::Notifications otherwise). Default: "standard_ledger". Events:
46
30
  # <prefix>.entry.created
47
- # <prefix>.projection.applied
31
+ # <prefix>.projection.refreshed
48
32
  # <prefix>.projection.failed
49
33
  # c.notification_namespace = "standard_ledger"
50
34