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.
@@ -0,0 +1,361 @@
1
+ require "active_support/concern"
2
+ require "active_support/core_ext/class/attribute"
3
+
4
+ module StandardLedger
5
+ # Adds the `projects_onto` DSL to an Entry class. Each `projects_onto`
6
+ # declaration registers a single (target_association, mode, projector)
7
+ # tuple; multi-target fan-out is two declarations.
8
+ #
9
+ # @example block form
10
+ # class VoucherRecord < ApplicationRecord
11
+ # include StandardLedger::Entry
12
+ # include StandardLedger::Projector
13
+ #
14
+ # ledger_entry kind: :action, idempotency_key: :serial_no, scope: :organisation_id
15
+ #
16
+ # projects_onto :voucher_scheme, mode: :inline do
17
+ # on(:grant) { |scheme, _| scheme.increment(:granted_vouchers_count) }
18
+ # on(:redeem) { |scheme, _| scheme.increment(:redeemed_vouchers_count) }
19
+ # on(:consume) { |scheme, _| scheme.increment(:consumed_vouchers_count) }
20
+ # on(:clawback) { |scheme, _| scheme.increment(:clawed_back_vouchers_count) }
21
+ # end
22
+ # end
23
+ #
24
+ # @example class form
25
+ # projects_onto :order, mode: :async, via: Orders::FulfillableProjector
26
+ module Projector
27
+ extend ActiveSupport::Concern
28
+
29
+ # Captures the per-projection configuration declared by `projects_onto`.
30
+ # Stored on the entry class so `StandardLedger.post` and
31
+ # `StandardLedger.rebuild!` can iterate over them at runtime.
32
+ #
33
+ # `:view` and `:refresh_options` are populated only for `:matview`-mode
34
+ # projections; they're `nil` for every other mode.
35
+ Definition = Struct.new(
36
+ :target_association, :mode, :projector_class, :handlers, :guard, :lock, :permissive,
37
+ :recompute_sql, :view, :refresh_options, :options,
38
+ keyword_init: true
39
+ )
40
+
41
+ class_methods do
42
+ # Declare a projection from this entry onto a single target.
43
+ #
44
+ # @param target_association [Symbol] the `belongs_to` association name on
45
+ # this entry pointing at the projection target.
46
+ # @param mode [Symbol] one of `:inline`, `:async`, `:sql`, `:trigger`,
47
+ # `:matview`. See the design doc §5.3 for selection guidance.
48
+ # @param via [Class, nil] optional `Projection` subclass; required for
49
+ # `:async`/`:trigger`/`:sql` modes when the projector is non-trivial,
50
+ # optional for `:inline` when a block is given.
51
+ # @param if [Proc, nil] optional guard; the projection is skipped when
52
+ # the proc (evaluated in the entry's instance context) returns false.
53
+ # @param lock [Symbol, nil] `:pessimistic` to wrap inline updates in
54
+ # `target.with_lock { ... }`. Default: `nil` (optimistic).
55
+ # @param permissive [Boolean] when true, an entry with a kind not
56
+ # handled by `on(:kind)` is silently skipped instead of raising
57
+ # `UnhandledKind`. Default: false.
58
+ # @param view [String, Symbol, nil] for `mode: :matview` only — the name
59
+ # of the host-owned materialized view. Required when mode is
60
+ # `:matview`; ignored otherwise.
61
+ # @param refresh [Hash, nil] for `mode: :matview` only — refresh
62
+ # metadata, e.g. `{ every: 5.minutes, concurrently: true }`. The
63
+ # gem records this on the Definition for hosts to read when wiring
64
+ # their scheduler; the gem does NOT auto-schedule.
65
+ # @yield optional block-DSL form: register per-kind handlers via
66
+ # `on(:kind) { |target, entry| ... }`. Not allowed for `mode: :matview`.
67
+ # @return [Definition] the registered projection.
68
+ def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false, view: nil, refresh: nil, **options, &block)
69
+ guard = binding.local_variable_get(:if) # `if:` is a reserved keyword
70
+
71
+ if mode == :sql
72
+ if via
73
+ raise ArgumentError,
74
+ "projects_onto :#{target_association} got `via:` with mode: :sql; " \
75
+ "`:sql` mode's contract is the recompute SQL itself, declared via `recompute \"...\"` " \
76
+ "in the block — use a different mode if you want a `via:` projector class"
77
+ end
78
+
79
+ unless lock.nil?
80
+ raise ArgumentError,
81
+ "projects_onto :#{target_association} got `lock:` with mode: :sql; " \
82
+ "`lock:` is not supported by :sql mode — recompute SQL doesn't dispatch through " \
83
+ "with_lock; use :inline mode if you need pessimistic locking"
84
+ end
85
+
86
+ if permissive
87
+ raise ArgumentError,
88
+ "projects_onto :#{target_association} got `permissive:` with mode: :sql; " \
89
+ "`permissive:` is not supported by :sql mode — recompute SQL doesn't dispatch " \
90
+ "through per-kind handlers; use :inline mode if you need permissive dispatch"
91
+ end
92
+
93
+ unless block
94
+ raise ArgumentError,
95
+ "projects_onto :#{target_association} requires a block with `recompute \"...\"` for mode: :sql"
96
+ end
97
+
98
+ dsl = SqlDsl.new
99
+ dsl.instance_eval(&block)
100
+
101
+ if dsl.recompute_sql.nil?
102
+ raise ArgumentError,
103
+ "projects_onto :#{target_association} block is empty; mode: :sql requires a `recompute \"...\"` clause"
104
+ end
105
+
106
+ unless dsl.recompute_sql.include?(":target_id")
107
+ raise ArgumentError,
108
+ "projects_onto :#{target_association} recompute SQL must include the `:target_id` placeholder; " \
109
+ "it's bound to the entry's foreign key for this projection's target"
110
+ end
111
+
112
+ definition = Definition.new(
113
+ target_association: target_association,
114
+ mode: mode,
115
+ projector_class: nil,
116
+ handlers: {},
117
+ guard: guard,
118
+ lock: lock,
119
+ permissive: permissive,
120
+ recompute_sql: dsl.recompute_sql,
121
+ view: nil,
122
+ refresh_options: nil,
123
+ options: options
124
+ )
125
+
126
+ self.standard_ledger_projections = standard_ledger_projections + [ definition ]
127
+ install_mode_callbacks_for(definition)
128
+ return definition
129
+ end
130
+
131
+ if mode == :matview
132
+ if block
133
+ raise ArgumentError,
134
+ "projects_onto :#{target_association} mode: :matview does not accept a block; " \
135
+ "matview projections have no per-kind handlers — they refresh on a schedule"
136
+ end
137
+
138
+ if view.nil?
139
+ raise ArgumentError,
140
+ "projects_onto :#{target_association} mode: :matview requires `view: \"name\"` " \
141
+ "(the materialized view name to refresh)"
142
+ end
143
+
144
+ definition = Definition.new(
145
+ target_association: target_association,
146
+ mode: mode,
147
+ projector_class: nil,
148
+ handlers: {},
149
+ guard: guard,
150
+ lock: lock,
151
+ permissive: permissive,
152
+ recompute_sql: nil,
153
+ view: view.to_s,
154
+ refresh_options: refresh || {},
155
+ options: options
156
+ )
157
+
158
+ self.standard_ledger_projections = standard_ledger_projections + [ definition ]
159
+ install_mode_callbacks_for(definition)
160
+ return definition
161
+ end
162
+
163
+ if block && via
164
+ raise ArgumentError,
165
+ "projects_onto :#{target_association} got both a block and `via:`; the two forms are mutually exclusive"
166
+ end
167
+
168
+ unless block || via
169
+ raise ArgumentError,
170
+ "projects_onto :#{target_association} requires either a block of `on(:kind) { ... }` handlers or `via: ProjectorClass`"
171
+ end
172
+
173
+ if via && permissive
174
+ raise ArgumentError,
175
+ "projects_onto :#{target_association} got `permissive: true` with `via:`; " \
176
+ "`permissive:` is only meaningful with the block form, where it falls back " \
177
+ "to the `:_` wildcard handler when no specific-kind handler matches. Class " \
178
+ "form (`via: ProjectorClass`) runs `apply(target, entry)` unconditionally, " \
179
+ "so there's nothing to be permissive about."
180
+ end
181
+
182
+ handlers = {}
183
+ if block
184
+ dsl = HandlerDsl.new
185
+ dsl.instance_eval(&block)
186
+ handlers = dsl.handlers
187
+
188
+ if handlers.empty?
189
+ raise ArgumentError,
190
+ "projects_onto :#{target_association} block is empty; at least one `on(:kind) { ... }` handler is required"
191
+ end
192
+ end
193
+
194
+ definition = Definition.new(
195
+ target_association: target_association,
196
+ mode: mode,
197
+ projector_class: via,
198
+ handlers: handlers,
199
+ guard: guard,
200
+ lock: lock,
201
+ permissive: permissive,
202
+ recompute_sql: nil,
203
+ view: nil,
204
+ refresh_options: nil,
205
+ options: options
206
+ )
207
+
208
+ self.standard_ledger_projections = standard_ledger_projections + [ definition ]
209
+ install_mode_callbacks_for(definition)
210
+ definition
211
+ end
212
+
213
+ # Delegate per-mode callback installation to the matching strategy
214
+ # class. Keeps `Modes::*` from reaching into `Projector`'s internals
215
+ # — each strategy gets a chance to wire up `after_create`,
216
+ # `after_create_commit`, or whatever lifecycle hook it needs the first
217
+ # time a projection of its mode is registered on this class.
218
+ #
219
+ # @param definition [Definition]
220
+ # @return [void]
221
+ def install_mode_callbacks_for(definition)
222
+ case definition.mode
223
+ when :inline
224
+ StandardLedger::Modes::Inline.install!(self)
225
+ when :sql
226
+ StandardLedger::Modes::Sql.install!(self)
227
+ when :matview
228
+ StandardLedger::Modes::Matview.install!(self)
229
+ end
230
+ end
231
+
232
+ # Filter the registered projections by mode. Used by the per-mode
233
+ # strategy classes (`Modes::Inline`, `Modes::Async`, ...) to discover
234
+ # which projections they own for a given entry class.
235
+ #
236
+ # @param mode [Symbol] one of `:inline`, `:async`, `:sql`, `:trigger`,
237
+ # `:matview`.
238
+ # @return [Array<Definition>] the matching definitions, in declared order.
239
+ def standard_ledger_projections_for(mode)
240
+ standard_ledger_projections.select { |definition| definition.mode == mode }
241
+ end
242
+ end
243
+
244
+ included do
245
+ class_attribute :standard_ledger_projections, instance_writer: false
246
+ self.standard_ledger_projections = []
247
+ end
248
+
249
+ # Apply a single projection definition to this entry. Resolves the
250
+ # target association, evaluates the optional `if:` guard, looks up the
251
+ # per-kind handler (or falls back to the projector class), and invokes
252
+ # it.
253
+ #
254
+ # `lock:` is interpreted by the mode strategies (`Modes::Inline`, ...)
255
+ # — not here. The inline strategy needs the lock to span both the
256
+ # handler invocation *and* the coalesced `target.save!`, so it wraps
257
+ # an entire per-target group rather than a single `apply_projection!`
258
+ # call. See `Modes::Inline#call` for the lock-spans-save guarantee.
259
+ #
260
+ # The mode strategies call this method; hosts typically do not call it
261
+ # directly.
262
+ #
263
+ # @param definition [Definition] one of the entry class's registered
264
+ # projections.
265
+ # @return [Boolean] `true` when the handler (or projector class) ran;
266
+ # `false` when the projection short-circuited (guard returned false,
267
+ # target was nil, or permissive mode found no matching handler).
268
+ # @raise [StandardLedger::Error] when the entry's kind column is nil.
269
+ # @raise [StandardLedger::UnhandledKind] when no handler matches and
270
+ # `permissive: false`.
271
+ def apply_projection!(definition)
272
+ if definition.mode == :sql
273
+ raise Error,
274
+ "apply_projection! is not supported for mode: :sql; " \
275
+ "the recompute SQL runs through `Modes::Sql#call` directly with no per-kind dispatch"
276
+ end
277
+
278
+ return false if definition.guard && !instance_exec(&definition.guard)
279
+
280
+ target = public_send(definition.target_association)
281
+ return false if target.nil?
282
+
283
+ if definition.projector_class
284
+ definition.projector_class.new.apply(target, self)
285
+ return true
286
+ end
287
+
288
+ kind = resolve_kind!
289
+ handler = definition.handlers[kind.to_sym]
290
+
291
+ if handler.nil?
292
+ if definition.permissive
293
+ handler = definition.handlers[:_]
294
+ return false if handler.nil?
295
+ else
296
+ raise UnhandledKind,
297
+ "#{self.class.name} has no handler for kind=#{kind.inspect} on projection :#{definition.target_association}"
298
+ end
299
+ end
300
+
301
+ handler.call(target, self)
302
+ true
303
+ end
304
+
305
+ private
306
+
307
+ def resolve_kind!
308
+ unless self.class.respond_to?(:standard_ledger_entry_config)
309
+ raise Error,
310
+ "#{self.class.name} includes Projector but not Entry; " \
311
+ "`ledger_entry` must be called before apply_projection! can dispatch"
312
+ end
313
+
314
+ kind_column = self.class.standard_ledger_entry_config&.[](:kind) || :kind
315
+ kind = public_send(kind_column)
316
+ if kind.nil?
317
+ raise Error,
318
+ "#{self.class.name} entry has nil kind (column #{kind_column.inspect}); cannot dispatch projection"
319
+ end
320
+ kind
321
+ end
322
+
323
+ # Internal collector for the block-DSL form. Captures `on(:kind)` calls
324
+ # into a hash keyed by kind. Wildcard `on(:_)` is reserved as a catch-all
325
+ # — its handler runs only when no specific-kind handler matched.
326
+ class HandlerDsl
327
+ attr_reader :handlers
328
+
329
+ def initialize
330
+ @handlers = {}
331
+ end
332
+
333
+ def on(kind, &block)
334
+ raise ArgumentError, "on(:#{kind}) requires a block" unless block
335
+ @handlers[kind.to_sym] = block
336
+ end
337
+ end
338
+
339
+ # Internal collector for the `:sql`-mode block-DSL form. Captures the
340
+ # `recompute "..."` clause's SQL string. Unlike `HandlerDsl` there are
341
+ # no per-kind handlers — the recompute SQL is the entire contract: it
342
+ # must be expressible as a single statement with `:target_id` bound
343
+ # from the entry's foreign key, and it serves both the after-create
344
+ # path and `StandardLedger.rebuild!`.
345
+ class SqlDsl
346
+ attr_reader :recompute_sql
347
+
348
+ def recompute(sql)
349
+ unless sql.is_a?(String)
350
+ raise ArgumentError, "recompute requires a SQL string; got #{sql.class}"
351
+ end
352
+ unless @recompute_sql.nil?
353
+ raise ArgumentError,
354
+ "recompute called more than once in the same projects_onto block; " \
355
+ ":sql mode supports exactly one recompute clause per projection"
356
+ end
357
+ @recompute_sql = sql
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,51 @@
1
+ module StandardLedger
2
+ # The gem's default result type, returned by `StandardLedger.post` and
3
+ # `StandardLedger.rebuild!` when the host has not configured an adapter
4
+ # for its own Result class.
5
+ #
6
+ # Hosts with their own Result type (e.g. `ApplicationOperation::Result`)
7
+ # register a translator via `StandardLedger.config.result_adapter` so the
8
+ # gem returns the host's type instead — see `Config#result_adapter`.
9
+ class Result
10
+ attr_reader :value, :errors, :entry, :projections
11
+
12
+ # @param success [Boolean]
13
+ # @param value [Object, nil] typically the persisted entry, or whatever the
14
+ # host operation wishes to surface.
15
+ # @param errors [Array<String>] human-readable error messages.
16
+ # @param entry [ActiveRecord::Base, nil] the persisted entry record.
17
+ # @param idempotent [Boolean] true when the create was a no-op because an
18
+ # existing row already satisfied the idempotency key.
19
+ # @param projections [Hash] split by mode: `{ inline: [...], async: [...], matview: [...] }`.
20
+ def initialize(success:, value: nil, errors: [], entry: nil, idempotent: false, projections: {})
21
+ @success = success
22
+ @value = value
23
+ @errors = errors
24
+ @entry = entry
25
+ @idempotent = idempotent
26
+ @projections = projections
27
+ end
28
+
29
+ def success?
30
+ @success
31
+ end
32
+
33
+ def failure?
34
+ !@success
35
+ end
36
+
37
+ def idempotent?
38
+ @idempotent
39
+ end
40
+
41
+ # Build a successful result. Convenience for `StandardLedger.post` and
42
+ # internal callers; not intended as the host's primary construction path.
43
+ def self.success(entry:, idempotent: false, projections: {})
44
+ new(success: true, value: entry, entry: entry, idempotent: idempotent, projections: projections)
45
+ end
46
+
47
+ def self.failure(errors:, entry: nil, projections: {})
48
+ new(success: false, errors: Array(errors), entry: entry, projections: projections)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module StandardLedger
2
+ module RSpec
3
+ # Convenience methods auto-included into every RSpec example group when
4
+ # the host loads `require "standard_ledger/rspec"`. The actual override
5
+ # map lives on `StandardLedger` itself so non-RSpec callers (e.g. a
6
+ # background job spec running outside RSpec) can use the same API.
7
+ module Helpers
8
+ # Forwards to `StandardLedger.with_modes` so specs can write
9
+ # `with_modes(...) { ... }` instead of the fully-qualified form.
10
+ def with_modes(overrides, &block)
11
+ StandardLedger.with_modes(overrides, &block)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,148 @@
1
+ require "rspec/expectations"
2
+ require "active_support/notifications"
3
+
4
+ # `post_ledger_entry` — assert that a block of code wrote a ledger entry.
5
+ #
6
+ # expect {
7
+ # Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
8
+ # }.to post_ledger_entry(VoucherRecord)
9
+ #
10
+ # expect {
11
+ # Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
12
+ # }.to post_ledger_entry(VoucherRecord).with(kind: :grant)
13
+ #
14
+ # expect {
15
+ # Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
16
+ # }.to post_ledger_entry(VoucherRecord).with(
17
+ # kind: :grant,
18
+ # targets: { voucher_scheme: scheme },
19
+ # attrs: { serial_no: "v-123" }
20
+ # )
21
+ #
22
+ # expect { ... }.to_not post_ledger_entry(VoucherRecord)
23
+ #
24
+ # The matcher subscribes to `<namespace>.entry.created` for the duration of
25
+ # the block, captures every fired event, and asserts that at least one event
26
+ # matched the expected class (and, when chained, the expected `kind`,
27
+ # `targets`, and `attrs`). The notification namespace is read from
28
+ # `StandardLedger.config.notification_namespace`, so a host that customised
29
+ # the namespace before the block runs is honored automatically.
30
+ RSpec::Matchers.define :post_ledger_entry do |entry_class|
31
+ supports_block_expectations
32
+
33
+ chain :with do |options = {}|
34
+ @expected_kind = options[:kind] if options.key?(:kind)
35
+ @expected_targets = options[:targets] if options.key?(:targets)
36
+ @expected_attrs = options[:attrs] if options.key?(:attrs)
37
+ end
38
+
39
+ match do |block|
40
+ @expected_class = entry_class
41
+ @captured_events = capture_entry_created_events(&block)
42
+
43
+ @captured_events.any? { |payload| event_matches?(payload) }
44
+ end
45
+
46
+ match_when_negated do |block|
47
+ @expected_class = entry_class
48
+ @captured_events = capture_entry_created_events(&block)
49
+
50
+ @captured_events.none? { |payload| event_matches?(payload) }
51
+ end
52
+
53
+ failure_message do
54
+ if @captured_events.empty?
55
+ "expected block to post a #{@expected_class} ledger entry, but no " \
56
+ "`#{notification_event_name}` events fired"
57
+ else
58
+ "expected block to post a #{@expected_class} ledger entry " \
59
+ "#{describe_expectations}, but got: #{describe_captured_events}"
60
+ end
61
+ end
62
+
63
+ failure_message_when_negated do
64
+ matched = @captured_events.select { |payload| event_matches?(payload) }
65
+ "expected block not to post a #{@expected_class} ledger entry " \
66
+ "#{describe_expectations}, but #{matched.size} matching event(s) fired: " \
67
+ "#{describe_captured_events(matched)}"
68
+ end
69
+
70
+ # ----------------------------------------------------------------------
71
+ # Helpers
72
+ # ----------------------------------------------------------------------
73
+
74
+ def capture_entry_created_events(&block)
75
+ events = []
76
+ name = notification_event_name
77
+ subscriber = ActiveSupport::Notifications.subscribe(name) do |*args|
78
+ events << ActiveSupport::Notifications::Event.new(*args).payload
79
+ end
80
+
81
+ begin
82
+ block.call
83
+ ensure
84
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
85
+ end
86
+
87
+ events
88
+ end
89
+
90
+ def notification_event_name
91
+ "#{StandardLedger.config.notification_namespace}.entry.created"
92
+ end
93
+
94
+ def event_matches?(payload)
95
+ entry = payload[:entry]
96
+ return false unless entry.is_a?(@expected_class)
97
+
98
+ if defined?(@expected_kind)
99
+ return false unless kind_matches?(payload[:kind], @expected_kind)
100
+ end
101
+
102
+ if defined?(@expected_targets)
103
+ return false unless hash_includes?(payload[:targets] || {}, @expected_targets)
104
+ end
105
+
106
+ if defined?(@expected_attrs)
107
+ return false unless attrs_match?(entry, @expected_attrs)
108
+ end
109
+
110
+ true
111
+ end
112
+
113
+ # `kind` arrives in the payload as whatever the entry stored — usually a
114
+ # string, since that's what a string column reads back as. Allow specs to
115
+ # pass either symbol or string and compare loosely.
116
+ def kind_matches?(actual, expected)
117
+ actual.to_s == expected.to_s
118
+ end
119
+
120
+ def hash_includes?(actual, expected)
121
+ expected.all? { |key, value| actual[key] == value }
122
+ end
123
+
124
+ def attrs_match?(entry, expected)
125
+ expected.all? do |key, value|
126
+ next false unless entry.respond_to?(key)
127
+
128
+ entry.public_send(key) == value
129
+ end
130
+ end
131
+
132
+ def describe_expectations
133
+ parts = []
134
+ parts << "with kind: #{@expected_kind.inspect}" if defined?(@expected_kind)
135
+ parts << "targets: #{@expected_targets.inspect}" if defined?(@expected_targets)
136
+ parts << "attrs: #{@expected_attrs.inspect}" if defined?(@expected_attrs)
137
+ parts.empty? ? "" : "(#{parts.join(', ')})"
138
+ end
139
+
140
+ def describe_captured_events(events = @captured_events)
141
+ return "(none)" if events.empty?
142
+
143
+ events.map { |payload|
144
+ "<#{payload[:entry].class}: kind=#{payload[:kind].inspect}, " \
145
+ "targets=#{(payload[:targets] || {}).keys.inspect}>"
146
+ }.join(", ")
147
+ end
148
+ end
@@ -0,0 +1,44 @@
1
+ require "rspec/core"
2
+ require "rspec/expectations"
3
+
4
+ require "standard_ledger"
5
+ require "standard_ledger/rspec/matchers"
6
+ require "standard_ledger/rspec/helpers"
7
+
8
+ # Opt-in test support for host apps. Hosts add this require to their
9
+ # `spec/rails_helper.rb` (or equivalent):
10
+ #
11
+ # require "standard_ledger/rspec"
12
+ #
13
+ # Loading this file:
14
+ #
15
+ # - Registers a `before(:each)` hook that calls
16
+ # `StandardLedger.reset_mode_overrides!` so the thread-local `with_modes`
17
+ # override map doesn't leak between examples. We deliberately do *not* call
18
+ # the full `reset!` here: hosts often configure the gem from a Rails
19
+ # initializer (`StandardLedger.configure { |c| c.result_adapter = ... }`),
20
+ # and wiping `@config` between examples would silently undo that
21
+ # configuration for every spec. The reset is wired via `RSpec.configure`
22
+ # rather than a custom shared context so it applies to every example group
23
+ # automatically.
24
+ # - Defines the `post_ledger_entry` matcher (see
25
+ # `StandardLedger::RSpec::Matchers`) for assertions of the form
26
+ # `expect { ... }.to post_ledger_entry(EntryClass).with(kind: ...)`.
27
+ # - Includes `StandardLedger::RSpec::Helpers` into every example group so
28
+ # specs can call `with_modes(...)` directly without the module prefix.
29
+ #
30
+ # We intentionally avoid touching subscribers, AR connections, or any host
31
+ # state — the gem only owns its own configuration. Hosts that need additional
32
+ # cleanup wire their own hooks alongside this one.
33
+ module StandardLedger
34
+ module RSpec
35
+ end
36
+ end
37
+
38
+ ::RSpec.configure do |config|
39
+ config.before(:each) do
40
+ StandardLedger.reset_mode_overrides!
41
+ end
42
+
43
+ config.include StandardLedger::RSpec::Helpers
44
+ end
@@ -0,0 +1,3 @@
1
+ module StandardLedger
2
+ VERSION = "0.2.0"
3
+ end