standard_ledger 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c0d328d7c7f7c420d4b9abdcddeb6b1578b4c2798d33e0b53f44f36f3ed0e437
4
+ data.tar.gz: 481811ac4fb4b479d95e008798ce0c64182aa78c40f9d030866d8695d61bb953
5
+ SHA512:
6
+ metadata.gz: 5bc96a821455069e8717e4c5904bafb8498d27f57f9b4b58a76266a47fb132bb3b1ff83fc08c3f36e8f939d2e5e1da7ceb16fc6035de2b458b26d2ef891c640e
7
+ data.tar.gz: 0751a94baaacaf4423452add7215c6a67ad79b2b3e81074180314ec96ffcdf9c9b02a4843bebc964f1fa3191a293e62275f2a537cfb5617fae8dd1bd18a804c5
data/CHANGELOG.md ADDED
@@ -0,0 +1,260 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. The format
4
+ is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
+ project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ Nothing yet.
10
+
11
+ ## [0.2.0] - 2026-05-05
12
+
13
+ ### Added
14
+ - `StandardLedger::EventEmitter` — internal dispatcher that routes
15
+ gem events through `Rails.event.notify` on Rails 8.1+ and falls back
16
+ to `ActiveSupport::Notifications.instrument` on older Rails. Subscriber
17
+ exceptions are swallowed (printed via `warn`) so observability cannot
18
+ break the host's request path. All existing event names and payloads
19
+ are preserved — host subscribers via `ActiveSupport::Notifications.subscribe`
20
+ continue to work unchanged. Mirrors the `StandardCircuit::EventEmitter`
21
+ pattern for cross-gem consistency.
22
+ - `:matview` projection mode + ad-hoc refresh API. The host owns the
23
+ PostgreSQL materialized view (created in a migration via `scenic` or
24
+ hand-rolled SQL); the gem owns the refresh schedule and the ad-hoc
25
+ refresh primitive.
26
+ - `projects_onto :assoc, mode: :matview, view: "view_name", refresh: { every: 5.minutes, concurrently: true }`
27
+ declares a matview projection. The `view:` keyword is required;
28
+ `refresh:` is optional metadata for the host's scheduler. Block-DSL
29
+ is not accepted (matview projections have no per-kind handlers —
30
+ they refresh on a schedule). `Definition` gains `view` and
31
+ `refresh_options` fields, populated only for `:matview` mode.
32
+ - `StandardLedger::Modes::Matview` strategy — `install!` records the
33
+ matview registration on the entry class without installing any
34
+ `after_create` callback (matview is scheduled, not entry-driven);
35
+ `.refresh!(view_name, concurrently:)` issues `REFRESH MATERIALIZED
36
+ VIEW [CONCURRENTLY] <view_name>` against the active connection,
37
+ instruments `<prefix>.projection.refreshed` on success and
38
+ `<prefix>.projection.failed` on raise (re-raising so the host's
39
+ scheduler / job runner sees the failure). The `view_name` is
40
+ validated against `/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/` to refuse SQL
41
+ injection via crafted identifiers.
42
+ - `StandardLedger.refresh!(view_name, concurrently: nil)` — module-level
43
+ ad-hoc refresh API. `concurrently: nil` (default) consults
44
+ `Config#matview_refresh_strategy`; `true`/`false` overrides per call.
45
+ Returns a `Result` with `projections[:refreshed]` listing the view
46
+ refreshed; re-raises on SQL failure after firing the `failed` event.
47
+ Hosts call this at the end of read-your-write-critical operations
48
+ (e.g. luminality's `PromptPacks::DrawOperation` refreshing
49
+ `user_prompt_inventories` after a draw).
50
+ - `StandardLedger::MatviewRefreshJob` — thin `ActiveJob::Base` wrapper
51
+ around `StandardLedger.refresh!`. Hosts wire their scheduler
52
+ (SolidQueue Recurring Tasks, sidekiq-cron, etc.) at this job class
53
+ with `(view_name, concurrently:)` arguments. The gem deliberately
54
+ does not auto-schedule — schedule cadence and backend selection is a
55
+ host concern.
56
+ - `StandardLedger.rebuild!(EntryClass)` extends to `:matview`
57
+ projections: each registered matview projection triggers a single
58
+ `refresh!` (no per-target loop — the matview holds state for every
59
+ target in one relation). `target:` / `target_class:` scoping is
60
+ silently ignored for matviews (Postgres has no partial-refresh
61
+ primitive). `result.projections[:rebuilt]` includes a
62
+ `{ target_class: nil, target_id: nil, projection:, view: }` entry per
63
+ refreshed view.
64
+ - `:sql` mode: single-`UPDATE` recompute projections that bind
65
+ `:target_id` from the entry's foreign key. Block-DSL takes a single
66
+ `recompute "..."` clause instead of per-kind `on(:kind)` handlers; the
67
+ same SQL serves both the after-create path and `StandardLedger.rebuild!`.
68
+ Fires inside `after_create` (in the entry's transaction), so failures
69
+ roll back the entry alongside the projection. Skips silently on nil
70
+ FK or false `if:` guard. Notifications under the configured prefix:
71
+ `<prefix>.projection.applied` (mode: `:sql`, `target: nil`, includes
72
+ `duration_ms`) and `<prefix>.projection.failed` (re-raises after the
73
+ payload is published). Registration validates that `:target_id`
74
+ appears in the SQL, that no `via:`/`lock:`/`permissive:` are supplied
75
+ (none are meaningful for `:sql` mode — the recompute SQL is the whole
76
+ contract), and that the block actually called `recompute` exactly once.
77
+ `StandardLedger.rebuild!` runs the same statement against each target
78
+ the log references; `target:` / `target_class:` / no-arg scoping
79
+ works the same as for `:inline`.
80
+ - Integration specs for both new modes
81
+ (`spec/standard_ledger/sql_integration_spec.rb` and
82
+ `spec/standard_ledger/matview_integration_spec.rb`) cover the
83
+ end-to-end flows including registration validation, transactional
84
+ semantics, notifications, idempotent install, and rebuild paths.
85
+ - Install generator: `rails g standard_ledger:install` writes
86
+ `config/initializers/standard_ledger.rb` with commented-out examples
87
+ covering every public `Config` setting (async retries, scheduler,
88
+ matview strategy, notification namespace, host Result interop). The
89
+ generator is idempotent — re-running on an existing initializer skips
90
+ with a clear message; pass `--force` to overwrite.
91
+ - RSpec helpers behind an opt-in `require "standard_ledger/rspec"` (typically
92
+ loaded from the host's `spec/rails_helper.rb`):
93
+ - `post_ledger_entry(EntryClass).with(kind:, targets:, attrs:)` block
94
+ matcher — subscribes to `<namespace>.entry.created` for the duration of
95
+ the block and asserts that a matching event fired (or, in the negated
96
+ form, that none did). Honors a custom `notification_namespace`.
97
+ - `StandardLedger.with_modes(EntryClass => :inline) { ... }` block —
98
+ captures a thread-local override map so future async-mode projections
99
+ can be forced inline inside the block. Restored on block exit
100
+ (including on exception); nested blocks compose;
101
+ `StandardLedger.reset_mode_overrides!` clears the map (the auto-cleanup
102
+ hook calls this so host-configured Config survives between examples).
103
+ `StandardLedger.mode_override_for(entry_class)` reads the active
104
+ override for use by mode strategies as they ship. The `with_modes`
105
+ sugar is auto-included into RSpec example groups via
106
+ `StandardLedger::RSpec::Helpers`.
107
+ - Auto-cleanup hook (`RSpec.configure { before(:each) { StandardLedger.reset_mode_overrides! } }`)
108
+ so per-spec override state doesn't leak between examples without
109
+ clobbering the host's initializer-level Config.
110
+ - `StandardLedger::Entry` runtime: read-only enforcement after persistence
111
+ (`save`/`update`/`destroy` raise `ActiveRecord::ReadOnlyRecord` when
112
+ `immutable: true`, the default). `immutable: false` opts out.
113
+ - `StandardLedger::Entry` idempotency rescue: `create!` traps
114
+ `ActiveRecord::RecordNotUnique` against the configured
115
+ `[*scope, idempotency_key]` unique index, looks up the existing row,
116
+ and returns it with `idempotent? == true`. `idempotency_key: nil` skips
117
+ the rescue and behaves as a regular `create!`.
118
+ - Lazy boot-time index validation: the first idempotent `create!` call on
119
+ an Entry verifies a unique index covers exactly `[*scope,
120
+ idempotency_key]` (set equality, order-insensitive); raises
121
+ `MissingIdempotencyIndex` with a clear message if missing or if the
122
+ index covers extra columns. Cached per-class.
123
+ - `spec/dummy/` minimal Rails-free AR harness backed by SQLite
124
+ `:memory:`, loaded from `spec/spec_helper.rb` so AR-backed integration
125
+ tests can run without a host app.
126
+ - `Projector#apply_projection!(definition)` — runtime evaluator that resolves
127
+ the target association, evaluates the optional `if:` guard against the
128
+ entry, looks up the per-kind handler (with `:_` wildcard fallback when
129
+ `permissive: true`), and invokes the handler or `via:` projector class.
130
+ Wraps the call in `target.with_lock { ... }` when `lock: :pessimistic`.
131
+ Skips silently when the target is `nil`; raises
132
+ `StandardLedger::UnhandledKind` when no handler matches and the projection
133
+ is non-permissive; raises `StandardLedger::Error` when the entry's kind
134
+ column is `nil`.
135
+ - `Projector.standard_ledger_projections_for(mode)` — class-side filter that
136
+ returns the registered definitions whose `mode` matches the argument, for
137
+ the per-mode strategy classes (`Modes::Inline`, future `Modes::Async`,
138
+ ...) to discover which projections they own.
139
+ - `projects_onto` registration validation: now raises `ArgumentError` when a
140
+ block and `via:` are both given (mutually exclusive), when the block is
141
+ empty (no `on(:kind)` calls), or when neither a block nor `via:` is
142
+ supplied.
143
+ - `StandardLedger::Modes::Inline` runtime: applies inline-mode projections
144
+ inside `after_create`, transactional with the entry insert. A single
145
+ `after_create` callback is installed once per entry class on first
146
+ `:inline` registration (`Modes::Inline.install!`), and dispatches to
147
+ every `:inline` definition via `entry.apply_projection!`. Multiple
148
+ projections targeting the same association coalesce into a single
149
+ UPDATE per (entry, target): handlers run in declared order, then
150
+ `target.save!` persists the accumulated in-memory mutations once.
151
+ When any projection in a per-target group declares `lock: :pessimistic`,
152
+ the entire apply-then-save cycle is wrapped in `target.with_lock`, so
153
+ the row lock spans both handler invocation and the coalesced save —
154
+ closing the lost-update window that an inner-only lock would leave open
155
+ between lock release and save. Lock interpretation is the mode's
156
+ responsibility; `Projector#apply_projection!` no longer wraps in
157
+ `with_lock` itself. `:inline` mode now refuses to install on a non-AR
158
+ entry class — `Modes::Inline.install!` raises `ArgumentError` instead
159
+ of silently no-op-ing.
160
+ - `StandardLedger.post(EntryClass, kind:, targets:, attrs:)` module API —
161
+ sugar over `EntryClass.create!` that maps `targets:` onto the entry's
162
+ `belongs_to` setters via `reflect_on_association`. Returns a
163
+ `StandardLedger::Result` (or the host's Result type when
164
+ `Config#custom_result?` is true). Wraps `ActiveRecord::RecordInvalid`
165
+ into `Result.failure(errors:)`; lets every other exception propagate so
166
+ the entry's transaction rolls back. `targets:` accepts model instances
167
+ only; pass foreign keys via `attrs:` (e.g. `voucher_scheme_id: 42`)
168
+ when an instance isn't on hand. `result.projections[:inline]` reflects
169
+ the projections that *actually* ran for this entry — projections
170
+ short-circuited by an `if:` guard, a nil target, or a permissive
171
+ no-handler miss are excluded, and an idempotent retry returns
172
+ `[]` (no projections fire on the rescue path).
173
+ - ActiveSupport::Notifications instrumentation under the configured
174
+ `notification_namespace` prefix (default `"standard_ledger"`):
175
+ - `<prefix>.entry.created` — `after_commit on: :create`. Payload
176
+ `{ entry:, kind:, targets: { name => target } }`. Targets are
177
+ discovered from the entry's non-polymorphic `belongs_to` reflections.
178
+ - `<prefix>.projection.applied` — fired per inline projection on
179
+ success. Payload `{ entry:, target:, projection:, mode: :inline,
180
+ duration_ms: }`.
181
+ - `<prefix>.projection.failed` — fired per inline projection on raise,
182
+ before re-raising so the entry's transaction rolls back. Payload
183
+ `{ entry:, target:, projection:, error: }`.
184
+ - Host Result interop in `StandardLedger.post`: when both
185
+ `config.result_class` and `config.result_adapter` are set, the adapter
186
+ is invoked with `success:, value:, errors:, entry:, idempotent:,
187
+ projections:` and its return value is returned as-is. Falls back to
188
+ `StandardLedger::Result` otherwise.
189
+ - Integration spec (`spec/standard_ledger/inline_integration_spec.rb`)
190
+ exercising the end-to-end flow against the `spec/dummy/` SQLite
191
+ harness: multi-target fan-out, transactional rollback on projector
192
+ raise, idempotent-retry projection skip, all three notifications,
193
+ `lock: :pessimistic`, multi-counter coalescing, and Result interop.
194
+ - `StandardLedger.rebuild!(EntryClass, target:, target_class:,
195
+ batch_size:)` log-replay path. Recomputes projections from the
196
+ entry log by delegating to the registered projector class's
197
+ `rebuild(target)`. Scope is one of: a single `target:` instance,
198
+ every row of `target_class:`, or (with neither) every projection
199
+ on the entry class for every target referenced by the log. Each
200
+ (target, projection) rebuild runs in its own transaction; per
201
+ design doc §5.5, failures mid-loop are NOT unwound across earlier
202
+ successes. Refuses block-form (delta) projections with
203
+ `StandardLedger::NotRebuildable`, and modes other than `:inline`
204
+ with `StandardLedger::Error` until their respective mode PRs land.
205
+ Returns a Result (or host's Result via the adapter) with
206
+ `projections[:rebuilt]` listing each (target_class, target_id,
207
+ projection) that was rebuilt.
208
+ - `<prefix>.projection.rebuilt` notification fires for each
209
+ successful target rebuild. Payload `{ entry_class:, target:,
210
+ projection:, mode: }`. Joins the existing `entry.created`,
211
+ `projection.applied`, and `projection.failed` events under the
212
+ configured `notification_namespace`.
213
+ - Integration spec
214
+ (`spec/standard_ledger/rebuild_integration_spec.rb`) covers the
215
+ end-to-end rebuild flow: 50-entry log replay restoring counters
216
+ after truncation, `target:` / `target_class:` / no-arg scoping,
217
+ block-form / no-`rebuild` / unsupported-mode raises, the
218
+ `projection.rebuilt` notification, and host Result interop.
219
+
220
+ ## [0.1.0] - 2026-05-04
221
+
222
+ Initial scaffold. Establishes the gem layout, public API surface stubs, and the
223
+ build/test pipeline. **Not yet usable in production** — see the design doc
224
+ (`standard_ledger-design.md` in the workspace root) for the full v0.1 → v0.2
225
+ roadmap.
226
+
227
+ ### Added
228
+ - Rails Engine with `isolate_namespace StandardLedger`, no routes, no tables.
229
+ - `StandardLedger.configure { |c| ... }` block with `Config` settings:
230
+ `default_async_job`, `default_async_retries`, `scheduler`,
231
+ `matview_refresh_strategy`, `result_class`, `result_adapter`,
232
+ `notification_namespace`.
233
+ - `StandardLedger::Result` with `success?` / `failure?` / `idempotent?` /
234
+ `entry` / `value` / `errors` / `projections`. `Config#custom_result?`
235
+ governs whether the gem returns its own type or delegates to the host's via
236
+ `result_adapter`.
237
+ - `StandardLedger::Entry` concern: `ledger_entry kind:, idempotency_key:,
238
+ scope:, immutable:` class macro stores configuration on the host model.
239
+ Read-only enforcement and idempotency rescue land in the next PR.
240
+ - `StandardLedger::Projector` concern: `projects_onto target, mode:, via:,
241
+ if:, lock:, permissive:` class macro with block-DSL `on(:kind) { ... }`
242
+ registration. Stores `Definition` structs on the host model.
243
+ - `StandardLedger::Projection` base class for class-form projectors with
244
+ `apply` and `rebuild` interface.
245
+ - `StandardLedger::Modes::Inline` strategy class skeleton; `#call` raises
246
+ `NotImplementedError` until the nutripod vouchers integration lands.
247
+ - Error hierarchy: `Error`, `UnhandledKind`, `NotRebuildable`,
248
+ `MissingIdempotencyIndex`, `PartialFailure`.
249
+ - RSpec suite with passing specs for version, configure, reset, Config
250
+ defaults, and Result success/failure helpers.
251
+ - GitHub Actions CI on Ruby 3.4.4 running RSpec + RuboCop.
252
+
253
+ ### Pending (tracked in design doc, lands in subsequent PRs)
254
+ - Remaining mode implementations: `:async` (`ProjectionJob` + `with_lock`)
255
+ and `:trigger` (host-owned, gem records rebuild SQL).
256
+ - `standard_ledger:doctor` rake task (verifies trigger presence, etc.).
257
+
258
+ [Unreleased]: https://github.com/rarebit-one/standard_ledger/compare/v0.2.0...HEAD
259
+ [0.2.0]: https://github.com/rarebit-one/standard_ledger/compare/v0.1.0...v0.2.0
260
+ [0.1.0]: https://github.com/rarebit-one/standard_ledger/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaryl Sim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # standard_ledger
2
+
3
+ Immutable journal entries with declarative aggregate projections for Rails apps.
4
+
5
+ > **Status: v0.2.0** — production-ready for `:inline`, `:sql`, and `:matview`
6
+ > projections (covering luminality-web, fundbright-web, and sidekick-web's
7
+ > needs). `:async` and `:trigger` modes ship in subsequent PRs ahead of
8
+ > nutripod-web adoption. See [`standard_ledger-design.md`](https://github.com/rarebit-one/standard_ledger/blob/main/standard_ledger-design.md)
9
+ > for the full design and rollout plan.
10
+
11
+ ## What it is
12
+
13
+ Across our four Rails apps (nutripod-web, luminality-web, fundbright-web,
14
+ sidekick-web) we keep building the same thing: an immutable journal table
15
+ whose rows update one or more cached aggregates on parent records. Inventory
16
+ movements, voucher issuance, payment records, fulfillment records, prompt
17
+ transactions, entitlement grants, validation outcomes, device firmware
18
+ updates — same shape, eight different ad-hoc implementations.
19
+
20
+ `standard_ledger` extracts the pattern into a single declarative DSL that
21
+ lives on top of the host's existing ActiveRecord models. The gem **does not
22
+ own the schema** — host apps already have entry tables and aggregate columns,
23
+ and the gem adapts to them rather than replacing them.
24
+
25
+ ## Sketch
26
+
27
+ ```ruby
28
+ class VoucherRecord < ApplicationRecord
29
+ include StandardLedger::Entry
30
+ include StandardLedger::Projector
31
+
32
+ ledger_entry kind: :action,
33
+ idempotency_key: :serial_no,
34
+ scope: :organisation_id
35
+
36
+ projects_onto :voucher_scheme, mode: :inline do
37
+ on(:grant) { |scheme, _| scheme.increment(:granted_vouchers_count) }
38
+ on(:redeem) { |scheme, _| scheme.increment(:redeemed_vouchers_count) }
39
+ on(:consume) { |scheme, _| scheme.increment(:consumed_vouchers_count) }
40
+ on(:clawback) { |scheme, _| scheme.increment(:clawed_back_vouchers_count) }
41
+ end
42
+
43
+ projects_onto :customer_profile,
44
+ mode: :inline,
45
+ if: -> { customer_profile_id.present? } do
46
+ on(:grant) { |profile, _| profile.increment(:granted_vouchers_count) }
47
+ on(:redeem) { |profile, _| profile.increment(:redeemed_vouchers_count) }
48
+ on(:consume) { |profile, _| profile.increment(:consumed_vouchers_count) }
49
+ on(:clawback) { |profile, _| profile.increment(:clawed_back_vouchers_count) }
50
+ end
51
+ end
52
+ ```
53
+
54
+ Post an entry with the module API (sugar over `VoucherRecord.create!`):
55
+
56
+ ```ruby
57
+ result = StandardLedger.post(VoucherRecord,
58
+ kind: :grant,
59
+ targets: { voucher_scheme: scheme, customer_profile: profile },
60
+ attrs: { organisation_id: org.id, serial_no: "v-2025-1" })
61
+
62
+ result.success? # => true
63
+ result.entry # => the persisted VoucherRecord
64
+ result.idempotent? # => false (true on retry against the same serial_no)
65
+ result.projections # => { inline: [:voucher_scheme, :customer_profile] }
66
+ ```
67
+
68
+ Counters on both targets are incremented inside the same transaction as
69
+ the INSERT — if any projection raises, the entry rolls back too. Posting
70
+ twice with the same `serial_no` returns the original entry (with
71
+ `idempotent? == true`) and skips the projection.
72
+
73
+ Rebuild a target's projection from the log when its counters drift
74
+ or a projection bug needs replaying — extract a `Projection` subclass
75
+ that implements `rebuild(target)` and pass it via `via:`:
76
+
77
+ ```ruby
78
+ class SchemeProjector < StandardLedger::Projection
79
+ def apply(scheme, entry)
80
+ scheme.increment(:"#{entry.action}_vouchers_count")
81
+ scheme.save!
82
+ end
83
+
84
+ def rebuild(scheme)
85
+ records = VoucherRecord.where(voucher_scheme_id: scheme.id)
86
+ scheme.update!(
87
+ granted_vouchers_count: records.where(action: "grant").count,
88
+ redeemed_vouchers_count: records.where(action: "redeem").count
89
+ )
90
+ end
91
+ end
92
+
93
+ # Single target, single class, or every target across every projection.
94
+ StandardLedger.rebuild!(VoucherRecord, target: scheme)
95
+ StandardLedger.rebuild!(VoucherRecord, target_class: VoucherScheme)
96
+ StandardLedger.rebuild!(VoucherRecord)
97
+ ```
98
+
99
+ Each (target, projection) pair runs in its own transaction; failures
100
+ mid-loop are not unwound. Block-form (delta) projections raise
101
+ `NotRebuildable` because they cannot be reconstructed from the log
102
+ without a host-supplied recompute path.
103
+
104
+ For projections expressible as a single `UPDATE` over an aggregate of the
105
+ log, use `mode: :sql` — no Ruby-side handlers, no AR object loads, just
106
+ a recompute statement that runs in the entry's `after_create`:
107
+
108
+ ```ruby
109
+ class VoucherRecord < ApplicationRecord
110
+ include StandardLedger::Entry
111
+ include StandardLedger::Projector
112
+
113
+ ledger_entry kind: :action, idempotency_key: :serial_no, scope: :organisation_id
114
+
115
+ belongs_to :voucher_scheme
116
+
117
+ projects_onto :voucher_scheme, mode: :sql do
118
+ recompute <<~SQL
119
+ UPDATE voucher_schemes SET
120
+ granted_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'grant'),
121
+ redeemed_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'redeem'),
122
+ consumed_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'consume'),
123
+ clawed_back_vouchers_count = (SELECT COUNT(*) FROM voucher_records WHERE voucher_scheme_id = :target_id AND action = 'clawback')
124
+ WHERE id = :target_id
125
+ SQL
126
+ end
127
+ end
128
+ ```
129
+
130
+ The gem binds `:target_id` from the entry's foreign key. The recompute
131
+ SQL is the entire contract — `:sql` projections are naturally
132
+ rebuildable: `StandardLedger.rebuild!` runs the same statement against
133
+ every target the log references.
134
+
135
+ Refresh a `:matview` projection ad-hoc when the host needs immediate
136
+ read-your-write semantics (e.g. at the end of a draw operation, before
137
+ the next scheduled refresh would otherwise show stale counts):
138
+
139
+ ```ruby
140
+ class PromptTxn < ApplicationRecord
141
+ include StandardLedger::Entry
142
+ include StandardLedger::Projector
143
+
144
+ belongs_to :user_profile
145
+
146
+ ledger_entry kind: :event, idempotency_key: nil
147
+
148
+ projects_onto :user_profile,
149
+ mode: :matview,
150
+ view: "user_prompt_inventories",
151
+ refresh: { every: 5.minutes, concurrently: true }
152
+ end
153
+
154
+ # Schedule the recurring refresh from the host (SolidQueue Recurring
155
+ # Tasks, sidekiq-cron, etc.) targeting:
156
+ # StandardLedger::MatviewRefreshJob
157
+ # args: ["user_prompt_inventories", { concurrently: true }]
158
+
159
+ # Ad-hoc refresh after a critical write:
160
+ StandardLedger.refresh!(:user_prompt_inventories) # honors Config#matview_refresh_strategy
161
+ StandardLedger.refresh!("user_prompt_inventories", concurrently: true)
162
+ ```
163
+
164
+ `StandardLedger.rebuild!(PromptTxn)` is equivalent to refreshing every
165
+ `:matview` projection on the entry class — for matview, refresh *is*
166
+ rebuild. Postgres has no partial-refresh primitive, so `target:` /
167
+ `target_class:` scope arguments are ignored for `:matview` projections
168
+ and the full view is always refreshed.
169
+
170
+ Note: the default `:concurrent` strategy (and `concurrently: true`) requires
171
+ a unique index on the matview — Postgres rejects `REFRESH MATERIALIZED VIEW
172
+ CONCURRENTLY` otherwise. Add a unique index in the host migration that
173
+ creates the view, or set `Config#matview_refresh_strategy = :blocking` (or
174
+ pass `concurrently: false` per-call) if a unique index isn't an option.
175
+
176
+ Five projection modes — pick per declaration:
177
+
178
+ | Mode | Where the work runs | Transactional? | Rebuildable? |
179
+ |---|---|---|---|
180
+ | `:inline` | `after_create`, in the entry's transaction | yes | yes (if projector implements `rebuild`) |
181
+ | `:async` | `after_create_commit` job, `with_lock` | no | yes (if projector implements `rebuild`) |
182
+ | `:sql` | `after_create`, single `UPDATE ... FROM (SELECT ...)` | yes | yes (rebuild = same SQL) |
183
+ | `:trigger` | the database, on INSERT | yes (same statement) | yes (host-owned trigger; gem records rebuild SQL) |
184
+ | `:matview` | scheduled `REFRESH MATERIALIZED VIEW CONCURRENTLY` | no | trivially (refresh = rebuild) |
185
+
186
+ ## Installation
187
+
188
+ The gem is private during incubation. Pin from git:
189
+
190
+ ```ruby
191
+ gem "standard_ledger", git: "https://github.com/rarebit-one/standard_ledger", ref: "<sha>"
192
+ ```
193
+
194
+ Then run the install generator to drop a configured initializer in place:
195
+
196
+ ```bash
197
+ bin/rails g standard_ledger:install
198
+ ```
199
+
200
+ This writes `config/initializers/standard_ledger.rb` with commented-out
201
+ examples covering every public `Config` setting — uncomment and edit only
202
+ what you want to override. The generator is idempotent; re-running on an
203
+ existing initializer skips with a clear message (pass `--force` to
204
+ overwrite).
205
+
206
+ A typical configuration looks like:
207
+
208
+ ```ruby
209
+ StandardLedger.configure do |c|
210
+ c.default_async_retries = 3
211
+ c.scheduler = :solid_queue
212
+ c.matview_refresh_strategy = :concurrent
213
+
214
+ # Optional — return the host's Result type from StandardLedger.post:
215
+ c.result_class = ApplicationOperation::Result
216
+ c.result_adapter = ->(success:, value:, errors:, entry:, idempotent:, projections:) {
217
+ ApplicationOperation::Result.new(success:, value: value || entry, errors:)
218
+ }
219
+ end
220
+ ```
221
+
222
+ ## Testing
223
+
224
+ The gem ships an opt-in RSpec support file. Hosts add this to their
225
+ `spec/rails_helper.rb`:
226
+
227
+ ```ruby
228
+ require "standard_ledger/rspec"
229
+ ```
230
+
231
+ That registers a `before(:each)` hook that calls `StandardLedger.reset!`
232
+ between examples (so per-spec configuration doesn't leak), and exposes:
233
+
234
+ - `post_ledger_entry(EntryClass).with(...)` — a block matcher that
235
+ subscribes to the `<namespace>.entry.created` notification for the
236
+ duration of the block and asserts an entry of the expected class was
237
+ written (with optional `kind:`/`targets:`/`attrs:` constraints).
238
+
239
+ ```ruby
240
+ it "records a voucher grant" do
241
+ expect {
242
+ Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
243
+ }.to post_ledger_entry(VoucherRecord).with(
244
+ kind: :grant,
245
+ targets: { voucher_scheme: scheme, customer_profile: profile },
246
+ attrs: { serial_no: "v-2025-1" }
247
+ )
248
+ end
249
+ ```
250
+
251
+ - `with_modes(EntryClass => :inline) { ... }` — forces specific entry
252
+ classes' projections to run inline for the duration of the block. The
253
+ override is thread-local and restored on block exit, so async-mode
254
+ projections can be exercised end-to-end in a unit spec without a job
255
+ runner.
256
+
257
+ ```ruby
258
+ it "fast-runs an async projection inline" do
259
+ with_modes(PaymentRecord => :inline) do
260
+ Orders::CheckoutOperation.call(...)
261
+ end
262
+ end
263
+ ```
264
+
265
+ ## Development
266
+
267
+ ```bash
268
+ bundle install
269
+ bundle exec rspec
270
+ bundle exec rubocop
271
+ ```
272
+
273
+ ## Relationship to standard_audit
274
+
275
+ Different gems, different concerns:
276
+
277
+ - **`standard_audit`** — "user X took action Y on target Z," free-form
278
+ metadata, no projection.
279
+ - **`standard_ledger`** — "this delta updates these targets," typed kind,
280
+ mandatory projection.
281
+
282
+ A single host operation typically writes one of each, in one transaction.
283
+ Neither subsumes the other.
284
+
285
+ ## License
286
+
287
+ MIT. See [MIT-LICENSE](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,34 @@
1
+ require "rails/generators"
2
+
3
+ module StandardLedger
4
+ module Generators
5
+ # Installs StandardLedger in a host Rails application.
6
+ #
7
+ # Writes config/initializers/standard_ledger.rb with commented-out
8
+ # examples covering the public Config DSL.
9
+ #
10
+ # Idempotent: re-running on an existing initializer logs and skips.
11
+ # Pass +--force+ to overwrite.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc <<~DESC
16
+ Installs StandardLedger. Writes config/initializers/standard_ledger.rb
17
+ with commented-out examples covering the public Config DSL.
18
+
19
+ The generator is idempotent — already-installed initializer is skipped
20
+ with a clear message. Pass --force to overwrite.
21
+ DESC
22
+
23
+ def create_initializer_file
24
+ path = "config/initializers/standard_ledger.rb"
25
+ if File.exist?(File.join(destination_root, path)) && !options.force?
26
+ say_status("skip", "#{path} already present, skipping (use --force to overwrite)", :yellow)
27
+ return
28
+ end
29
+
30
+ template "initializer.rb.tt", path
31
+ end
32
+ end
33
+ end
34
+ end