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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +106 -1
- data/README.md +142 -350
- 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 +162 -0
- data/lib/standard_ledger/projection.rb +17 -24
- data/lib/standard_ledger/result.rb +2 -2
- data/lib/standard_ledger/rspec/matchers.rb +53 -3
- data/lib/standard_ledger/rspec.rb +6 -28
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +67 -505
- metadata +9 -48
- data/lib/standard_ledger/engine.rb +0 -26
- 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,419 +1,212 @@
|
|
|
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
|
-
)
|
|
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(
|
|
125
|
-
|
|
90
|
+
def rebuild(profile)
|
|
91
|
+
profile.update!(successful_loans_count: profile.validations.successful.count)
|
|
126
92
|
end
|
|
127
93
|
end
|
|
128
94
|
|
|
129
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
139
|
+
gem "standard_ledger", "~> 0.6"
|
|
290
140
|
```
|
|
291
141
|
|
|
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:
|
|
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
|
|
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:
|
|
149
|
+
This writes `config/initializers/standard_ledger.rb` with every setting
|
|
150
|
+
commented out. A typical configuration:
|
|
309
151
|
|
|
310
152
|
```ruby
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
166
|
+
The adapter always receives all six keywords. `projections:` is `{}` for
|
|
167
|
+
`post` and `{ refreshed: [...] }` for `refresh!`.
|
|
325
168
|
|
|
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.
|
|
169
|
+
## Events
|
|
332
170
|
|
|
333
|
-
|
|
334
|
-
|
|
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:` (
|
|
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.
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
- **`
|
|
431
|
-
|
|
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
|
|
436
|
-
|
|
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
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
13
|
+
# Materialized view refresh
|
|
15
14
|
# ---------------------------------------------------------------------------
|
|
16
15
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
|
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.
|
|
31
|
+
# <prefix>.projection.refreshed
|
|
48
32
|
# <prefix>.projection.failed
|
|
49
33
|
# c.notification_namespace = "standard_ledger"
|
|
50
34
|
|