standard_ledger 0.5.1 → 0.7.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/CHANGELOG.md +130 -1
- data/README.md +152 -356
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +13 -29
- data/lib/standard_ledger/config.rb +14 -29
- data/lib/standard_ledger/entry.rb +6 -10
- data/lib/standard_ledger/errors.rb +6 -26
- data/lib/standard_ledger/event_emitter.rb +2 -2
- data/lib/standard_ledger/matview.rb +163 -0
- data/lib/standard_ledger/projection.rb +17 -24
- data/lib/standard_ledger/result.rb +2 -2
- data/lib/standard_ledger/rspec.rb +6 -28
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +92 -506
- metadata +9 -48
- data/lib/standard_ledger/engine.rb +0 -28
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +0 -28
- data/lib/standard_ledger/jobs/projection_job.rb +0 -94
- data/lib/standard_ledger/modes/async.rb +0 -141
- data/lib/standard_ledger/modes/inline.rb +0 -180
- data/lib/standard_ledger/modes/matview.rb +0 -146
- data/lib/standard_ledger/modes/sql.rb +0 -132
- data/lib/standard_ledger/modes/trigger.rb +0 -50
- data/lib/standard_ledger/projector.rb +0 -638
- data/lib/standard_ledger/rspec/helpers.rb +0 -15
- data/lib/tasks/standard_ledger.rake +0 -60
data/README.md
CHANGED
|
@@ -1,422 +1,219 @@
|
|
|
1
1
|
# standard_ledger
|
|
2
2
|
|
|
3
|
-
Immutable journal entries
|
|
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.
|
|
6
|
-
> (
|
|
7
|
-
> `
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
>
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
whose rows
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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? #
|
|
65
|
-
result.
|
|
66
|
-
result.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def rebuild(order)
|
|
125
|
-
apply(order, nil)
|
|
85
|
+
class Validations::ProfileProjector < StandardLedger::Projection
|
|
86
|
+
def apply(profile, validation)
|
|
87
|
+
profile.increment!(:successful_loans_count) if validation.successful?
|
|
126
88
|
end
|
|
127
|
-
end
|
|
128
|
-
|
|
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
|
|
139
|
-
```
|
|
140
|
-
|
|
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.
|
|
149
|
-
|
|
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`:
|
|
153
|
-
|
|
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
89
|
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
90
|
+
def rebuild(profile)
|
|
91
|
+
profile.update!(successful_loans_count: profile.validations.successful.count)
|
|
172
92
|
end
|
|
173
93
|
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.
|
|
186
94
|
|
|
187
|
-
|
|
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
|
|
95
|
+
Validations::ProfileProjector.new.apply(profile, validation)
|
|
215
96
|
```
|
|
216
97
|
|
|
217
|
-
|
|
218
|
-
|
|
98
|
+
Unimplemented `apply` raises `NotImplementedError`. Unimplemented `rebuild`
|
|
99
|
+
raises `StandardLedger::NotRebuildable`.
|
|
219
100
|
|
|
220
|
-
|
|
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.
|
|
101
|
+
## Materialized views
|
|
227
102
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
once with a SQL string containing the `:target_id` placeholder.
|
|
232
|
-
|
|
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):
|
|
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.
|
|
236
106
|
|
|
237
107
|
```ruby
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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)
|
|
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)
|
|
260
112
|
```
|
|
261
113
|
|
|
262
|
-
`
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
114
|
+
`REFRESH MATERIALIZED VIEW CONCURRENTLY` has three preconditions. Postgres
|
|
115
|
+
rejects it:
|
|
116
|
+
|
|
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.
|
|
120
|
+
|
|
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.
|
|
129
|
+
|
|
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`,
|
|
133
|
+
reports the error to `Rails.error` (`handled: false`, `severity: :error`,
|
|
134
|
+
`context: { view:, concurrently: }`, `source: "standard_ledger"`), and
|
|
135
|
+
re-raises so your job runner can retry. Since 0.7 you don't need a
|
|
136
|
+
report-and-re-raise rescue around `refresh!` in your job. An existing one is
|
|
137
|
+
harmless: Rails skips an exception object it has already reported, so the
|
|
138
|
+
failure is reported once. Input errors (an invalid view name, an unknown
|
|
139
|
+
`concurrently:`, `RefreshInsideTransaction`) are raised before any SQL runs
|
|
140
|
+
and are not reported by the gem. View names must be bare or `schema.view`
|
|
141
|
+
identifiers. Anything else raises `ArgumentError`.
|
|
283
142
|
|
|
284
143
|
## Installation
|
|
285
144
|
|
|
286
|
-
The gem is published on RubyGems:
|
|
287
|
-
|
|
288
145
|
```ruby
|
|
289
|
-
gem "standard_ledger", "~> 0.
|
|
146
|
+
gem "standard_ledger", "~> 0.7"
|
|
290
147
|
```
|
|
291
148
|
|
|
292
|
-
(
|
|
293
|
-
|
|
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:
|
|
149
|
+
(Don't reintroduce a `git:` reference. It makes a bare `bundle install` a
|
|
150
|
+
prerequisite for every other command in a fresh checkout.)
|
|
297
151
|
|
|
298
152
|
```bash
|
|
299
153
|
bin/rails g standard_ledger:install
|
|
300
154
|
```
|
|
301
155
|
|
|
302
|
-
This writes `config/initializers/standard_ledger.rb` with
|
|
303
|
-
|
|
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:
|
|
156
|
+
This writes `config/initializers/standard_ledger.rb` with every setting
|
|
157
|
+
commented out. A typical configuration:
|
|
309
158
|
|
|
310
159
|
```ruby
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
160
|
+
Rails.application.config.to_prepare do
|
|
161
|
+
StandardLedger.configure do |c|
|
|
162
|
+
c.matview_refresh_strategy = :auto # :concurrent (default) | :blocking | :auto
|
|
163
|
+
|
|
164
|
+
# Optional — return the host's Result type from post / refresh!:
|
|
165
|
+
c.result_class = ApplicationOperation::Result
|
|
166
|
+
c.result_adapter = ->(success:, value:, errors:, entry:, idempotent:, projections:) {
|
|
167
|
+
ApplicationOperation::Result.new(success:, value: { entry: value || entry, idempotent:, projections: }, errors:)
|
|
168
|
+
}
|
|
169
|
+
end
|
|
321
170
|
end
|
|
322
171
|
```
|
|
323
172
|
|
|
324
|
-
|
|
173
|
+
The adapter always receives all six keywords. `projections:` is `{}` for
|
|
174
|
+
`post` and `{ refreshed: [...] }` for `refresh!`.
|
|
325
175
|
|
|
326
|
-
|
|
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.
|
|
176
|
+
## Events
|
|
332
177
|
|
|
333
|
-
|
|
334
|
-
|
|
178
|
+
On Rails 8.1+ events go through `Rails.event.notify(name, **payload)`. On
|
|
179
|
+
older Rails they fall back to
|
|
180
|
+
`ActiveSupport::Notifications.instrument(name, payload)`. Names are prefixed
|
|
181
|
+
with `Config#notification_namespace` (default `standard_ledger`).
|
|
335
182
|
|
|
336
183
|
| Event | Fired when | Payload |
|
|
337
184
|
|---|---|---|
|
|
338
|
-
| `<prefix>.entry.created` | after the entry's transaction commits | `entry:`, `kind:`, `targets:` (
|
|
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) |
|
|
185
|
+
| `<prefix>.entry.created` | after the entry's transaction commits (not on idempotent returns) | `entry:`, `kind:`, `targets:` (`{ name => record }` for non-nil `belongs_to`) |
|
|
341
186
|
| `<prefix>.projection.refreshed` | a matview refresh succeeded | `view:`, `concurrently:`, `duration_ms:` |
|
|
342
|
-
| `<prefix>.projection.
|
|
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.
|
|
360
|
-
|
|
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.
|
|
187
|
+
| `<prefix>.projection.failed` | the `REFRESH` SQL raised | `view:`, `concurrently:`, `mode: :matview`, `error:` |
|
|
366
188
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
189
|
+
`projection.failed` doesn't fire for input errors (an invalid view name, or
|
|
190
|
+
`RefreshInsideTransaction`) because no SQL was sent. The event names keep
|
|
191
|
+
their pre-0.6 `projection.*` spelling so existing subscribers keep working.
|
|
370
192
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
host's to opt into — see "Relationship to standard_audit" below.
|
|
193
|
+
**Subscriber exceptions are swallowed** (warned to stderr, not re-raised).
|
|
194
|
+
Ledger observability must never take down a request.
|
|
374
195
|
|
|
375
196
|
## Testing
|
|
376
197
|
|
|
377
|
-
The gem ships an opt-in RSpec support file. Hosts add this to their
|
|
378
|
-
`spec/rails_helper.rb`:
|
|
379
|
-
|
|
380
198
|
```ruby
|
|
199
|
+
# spec/rails_helper.rb
|
|
381
200
|
require "standard_ledger/rspec"
|
|
382
201
|
```
|
|
383
202
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
|
|
399
|
-
}.to post_ledger_entry(VoucherRecord).with(
|
|
400
|
-
kind: :grant,
|
|
401
|
-
targets: { voucher_scheme: scheme, customer_profile: profile },
|
|
402
|
-
attrs: { serial_no: "v-2025-1" }
|
|
403
|
-
)
|
|
404
|
-
end
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
- `with_modes(EntryClass => :inline) { ... }` — forces specific entry
|
|
408
|
-
classes' projections to run inline for the duration of the block. The
|
|
409
|
-
override is thread-local and restored on block exit, so async-mode
|
|
410
|
-
projections can be exercised end-to-end in a unit spec without a job
|
|
411
|
-
runner.
|
|
412
|
-
|
|
413
|
-
```ruby
|
|
414
|
-
it "fast-runs an async projection inline" do
|
|
415
|
-
with_modes(PaymentRecord => :inline) do
|
|
416
|
-
Orders::CheckoutOperation.call(...)
|
|
417
|
-
end
|
|
418
|
-
end
|
|
419
|
-
```
|
|
203
|
+
This defines the `post_ledger_entry` block matcher. It listens on the channel
|
|
204
|
+
the gem emits through, so it works on Rails 8.1+ as well:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
expect {
|
|
208
|
+
Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
|
|
209
|
+
}.to post_ledger_entry(VoucherRecord).with(
|
|
210
|
+
kind: :grant,
|
|
211
|
+
targets: { voucher_scheme: scheme },
|
|
212
|
+
attrs: { serial_no: "v-2025-1" }
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
It registers no hooks and never resets your `Config`.
|
|
420
217
|
|
|
421
218
|
## Development
|
|
422
219
|
|
|
@@ -428,15 +225,14 @@ bundle exec rubocop
|
|
|
428
225
|
|
|
429
226
|
## Relationship to standard_audit
|
|
430
227
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
- **`
|
|
434
|
-
|
|
435
|
-
- **`standard_ledger`** — "this delta updates these targets," typed kind,
|
|
436
|
-
mandatory projection.
|
|
228
|
+
- **`standard_audit`**: "user X took action Y on target Z", with free-form
|
|
229
|
+
metadata.
|
|
230
|
+
- **`standard_ledger`**: typed, immutable, idempotent journal rows that other
|
|
231
|
+
state is derived from.
|
|
437
232
|
|
|
438
|
-
A single host operation
|
|
439
|
-
|
|
233
|
+
A single host operation often writes one of each. Subscribe to
|
|
234
|
+
`entry.created` if you want an audit row per entry. The gem never calls into
|
|
235
|
+
audit itself.
|
|
440
236
|
|
|
441
237
|
## License
|
|
442
238
|
|