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 +7 -0
- data/CHANGELOG.md +260 -0
- data/MIT-LICENSE +21 -0
- data/README.md +287 -0
- data/Rakefile +6 -0
- data/lib/generators/standard_ledger/install/install_generator.rb +34 -0
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +66 -0
- data/lib/standard_ledger/config.rb +62 -0
- data/lib/standard_ledger/engine.rb +19 -0
- data/lib/standard_ledger/entry.rb +253 -0
- data/lib/standard_ledger/errors.rb +33 -0
- data/lib/standard_ledger/event_emitter.rb +50 -0
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +28 -0
- data/lib/standard_ledger/modes/inline.rb +180 -0
- data/lib/standard_ledger/modes/matview.rb +115 -0
- data/lib/standard_ledger/modes/sql.rb +132 -0
- data/lib/standard_ledger/projection.rb +41 -0
- data/lib/standard_ledger/projector.rb +361 -0
- data/lib/standard_ledger/result.rb +51 -0
- data/lib/standard_ledger/rspec/helpers.rb +15 -0
- data/lib/standard_ledger/rspec/matchers.rb +148 -0
- data/lib/standard_ledger/rspec.rb +44 -0
- data/lib/standard_ledger/version.rb +3 -0
- data/lib/standard_ledger.rb +620 -0
- metadata +184 -0
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,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
|