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,620 @@
1
+ require "active_support"
2
+ require "active_support/notifications"
3
+ require "active_support/core_ext/string/inflections"
4
+ require "concurrent"
5
+
6
+ require "standard_ledger/version"
7
+ require "standard_ledger/errors"
8
+ require "standard_ledger/event_emitter"
9
+ require "standard_ledger/result"
10
+ require "standard_ledger/config"
11
+ require "standard_ledger/entry"
12
+ require "standard_ledger/projection"
13
+ require "standard_ledger/projector"
14
+ require "standard_ledger/modes/inline"
15
+ require "standard_ledger/modes/sql"
16
+ require "standard_ledger/modes/matview"
17
+ require "standard_ledger/jobs/matview_refresh_job"
18
+ require "standard_ledger/engine" if defined?(::Rails::Engine)
19
+
20
+ # StandardLedger captures the recurring "immutable journal entry → N
21
+ # aggregate projections" pattern as a declarative DSL on host ActiveRecord
22
+ # models. See `standard_ledger-design.md` in the workspace root for the
23
+ # full design discussion.
24
+ #
25
+ # Public surface:
26
+ #
27
+ # StandardLedger.configure { |c| ... } # configure once at boot
28
+ # StandardLedger.config # read configured values
29
+ # StandardLedger.post(EntryClass, ...) # write an entry + project
30
+ # StandardLedger.rebuild!(EntryClass) # recompute projections from log
31
+ # StandardLedger.refresh!(:view_name) # ad-hoc matview refresh
32
+ # StandardLedger.reset! # full test helper (wipes config + overrides)
33
+ # StandardLedger.reset_mode_overrides! # clears only the with_modes thread-local
34
+ module StandardLedger
35
+ class << self
36
+ # Configure the gem once per app, typically from
37
+ # `config/initializers/standard_ledger.rb`. Yields the `Config` instance.
38
+ def configure
39
+ yield config
40
+ config
41
+ end
42
+
43
+ def config
44
+ @config ||= Config.new
45
+ end
46
+
47
+ # Full reset: clears the cached `Config` AND any thread-local `with_modes`
48
+ # overrides. Use this when a spec needs to verify the gem's boot path or
49
+ # when the host has *not* installed a Rails initializer (so wiping
50
+ # `@config` is harmless). Hosts that *do* configure the gem in an
51
+ # initializer should not call this between examples — use
52
+ # `reset_mode_overrides!` instead, which the auto-cleanup hook already
53
+ # invokes.
54
+ def reset!
55
+ @config = nil
56
+ reset_mode_overrides!
57
+ end
58
+
59
+ # Test-friendly reset that only clears the thread-local `with_modes`
60
+ # override map, leaving `Config` intact. The `standard_ledger/rspec`
61
+ # auto-cleanup hook calls this in `before(:each)` so a host's initializer
62
+ # config (e.g. a configured `result_adapter`) survives across examples
63
+ # while per-example mode overrides still get torn down cleanly.
64
+ def reset_mode_overrides!
65
+ Thread.current[:standard_ledger_mode_overrides] = nil
66
+ end
67
+
68
+ # Sugar over `EntryClass.create!` that maps `targets:` onto the entry's
69
+ # `belongs_to` foreign keys. Equivalent to calling `create!` directly
70
+ # with the assignments folded together — the inline projection callback
71
+ # fires from the same code path either way.
72
+ #
73
+ # @example
74
+ # StandardLedger.post(VoucherRecord,
75
+ # kind: :grant,
76
+ # targets: { voucher_scheme: scheme, customer_profile: profile },
77
+ # attrs: { serial_no: "v-123", organisation_id: org.id })
78
+ #
79
+ # @example pass an id via attrs when you don't have a model instance
80
+ # StandardLedger.post(VoucherRecord,
81
+ # kind: :grant,
82
+ # attrs: { voucher_scheme_id: 42, organisation_id: org.id, serial_no: "v-1" })
83
+ #
84
+ # @param entry_class [Class] an `ActiveRecord::Base` subclass that
85
+ # includes `StandardLedger::Entry`.
86
+ # @param kind [Symbol, String] value for the entry's configured kind
87
+ # column (read from `entry_class.standard_ledger_entry_config[:kind]`).
88
+ # @param targets [Hash{Symbol => ActiveRecord::Base}] association name ->
89
+ # model instance. Each is assigned via the matching `belongs_to`
90
+ # setter. To assign by id without loading the record, pass the
91
+ # foreign key directly via `attrs:` (e.g. `voucher_scheme_id: 42`).
92
+ # @param attrs [Hash] additional attributes merged into the create call.
93
+ # @return [StandardLedger::Result, Object] the gem's Result, or the
94
+ # host's Result type when `Config#custom_result?` is true. The Result's
95
+ # `projections[:inline]` contains the target_association names of the
96
+ # inline projections that actually ran for this entry — projections
97
+ # skipped by an `if:` guard are excluded, and an idempotent retry
98
+ # returns an empty array (no projections fire on the rescue path).
99
+ def post(entry_class, kind:, targets: {}, attrs: {})
100
+ kind_column = resolve_kind_column(entry_class)
101
+ create_attrs = build_create_attrs(entry_class, kind_column, kind, targets, attrs)
102
+
103
+ entry = entry_class.create!(create_attrs)
104
+
105
+ build_result(
106
+ success: true,
107
+ entry: entry,
108
+ idempotent: entry.respond_to?(:idempotent?) && entry.idempotent?,
109
+ projections: { inline: applied_projections_for(entry) }
110
+ )
111
+ rescue ActiveRecord::RecordInvalid => e
112
+ build_result(success: false, entry: e.record, errors: e.record.errors.full_messages)
113
+ end
114
+
115
+ # Force specific entry classes' projections to run in the supplied mode
116
+ # for the duration of the block. Intended for tests that want to drive an
117
+ # async-mode projection inline so the spec doesn't need a job runner.
118
+ #
119
+ # The override map is stored thread-locally so concurrent specs (or the
120
+ # gem's own `:async` workers) don't observe each other's overrides. Mode
121
+ # strategies consult `StandardLedger.mode_override_for(entry_class)`
122
+ # before falling back to the projection's declared mode.
123
+ #
124
+ # The block's prior override map is restored on exit, including on
125
+ # exception, so nested `with_modes` calls compose cleanly: the inner
126
+ # block's keys win during its scope, then the outer map is restored
127
+ # untouched.
128
+ #
129
+ # Today only `:inline` exists as a real mode, so this is a no-op for
130
+ # already-inline projections. The hook lands now so async projections
131
+ # can opt into the inline path the moment `Modes::Async` ships.
132
+ #
133
+ # @example
134
+ # StandardLedger.with_modes(PaymentRecord => :inline) do
135
+ # Orders::CheckoutOperation.call(...)
136
+ # end
137
+ #
138
+ # @example string keys (resolved via const_get)
139
+ # StandardLedger.with_modes("PaymentRecord" => :inline) do
140
+ # ...
141
+ # end
142
+ #
143
+ # @param overrides [Hash{Class, String, Symbol => Symbol}] entry class (or
144
+ # constant name / underscored symbol) → forced mode symbol.
145
+ def with_modes(overrides)
146
+ resolved = resolve_mode_overrides(overrides)
147
+
148
+ prior = Thread.current[:standard_ledger_mode_overrides]
149
+ merged = (prior || {}).merge(resolved)
150
+ Thread.current[:standard_ledger_mode_overrides] = merged
151
+
152
+ yield
153
+ ensure
154
+ Thread.current[:standard_ledger_mode_overrides] = prior
155
+ end
156
+
157
+ # Read the active override (if any) for `entry_class`. Mode strategies
158
+ # call this in their `install!` / `#call` paths before deciding whether
159
+ # to dispatch to the declared mode or the override mode. Returns `nil`
160
+ # outside any `with_modes` block.
161
+ #
162
+ # @param entry_class [Class] the host entry class.
163
+ # @return [Symbol, nil] the override mode, or `nil` for "no override".
164
+ def mode_override_for(entry_class)
165
+ overrides = Thread.current[:standard_ledger_mode_overrides]
166
+ return nil if overrides.nil?
167
+
168
+ overrides[entry_class]
169
+ end
170
+
171
+ # Recompute projections from the entry log for one or more targets.
172
+ # The deterministic counterpart to `post`: instead of applying the
173
+ # delta from a single new entry, this replays the full log onto the
174
+ # target by delegating to the projector class's `rebuild(target)`.
175
+ #
176
+ # Scope (mutually exclusive — pass at most one):
177
+ #
178
+ # - `target:` — rebuild every projection whose `target_association`
179
+ # resolves to `target.class`, for that single instance.
180
+ # - `target_class:` — rebuild every matching projection for every
181
+ # target referenced by the log for that AR class, in `find_each`
182
+ # batches. Targets with zero log entries are skipped (rebuilding a
183
+ # target the log never touched would zero its counters — destructive
184
+ # rather than corrective).
185
+ # - neither — rebuild every projection on `entry_class` for every
186
+ # target referenced by the log.
187
+ #
188
+ # Per-mode rules:
189
+ #
190
+ # - `:inline` projections must be class-form (`via: ProjectorClass`)
191
+ # AND that class must implement `rebuild`. Block-form projections
192
+ # are delta-based — they cannot be reconstructed from the log
193
+ # without the host providing a recompute path — so they raise
194
+ # `StandardLedger::NotRebuildable` here.
195
+ # - `:matview` projections rebuild by issuing a single
196
+ # `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view>` — for matview,
197
+ # refresh *is* rebuild. Postgres has no partial-refresh primitive,
198
+ # so `target:` / `target_class:` scope arguments are ignored for
199
+ # `:matview` projections and the full view is always refreshed.
200
+ # - `:async`, `:sql`, `:trigger` modes are not yet supported by
201
+ # `rebuild!`; they raise `StandardLedger::Error`. Each lands with
202
+ # its mode's own PR.
203
+ #
204
+ # Atomicity: each (target, projection) pair runs in its own
205
+ # transaction. A failure mid-loop is **not** rolled back — earlier
206
+ # successful rebuilds remain applied. Concurrent posts to the entry
207
+ # log during rebuild produce eventually-correct state: the rebuild
208
+ # operates on a snapshot of the log up to the projector's own
209
+ # SELECT, and any entries written after that snapshot project
210
+ # normally via the entry's own callback path. See design doc §5.5.
211
+ #
212
+ # @example rebuild a single target
213
+ # StandardLedger.rebuild!(VoucherRecord, target: scheme)
214
+ #
215
+ # @example rebuild every scheme
216
+ # StandardLedger.rebuild!(VoucherRecord, target_class: VoucherScheme)
217
+ #
218
+ # @example rebuild every projection across every target
219
+ # StandardLedger.rebuild!(VoucherRecord)
220
+ #
221
+ # @param entry_class [Class] an `ActiveRecord::Base` subclass that
222
+ # includes `StandardLedger::Projector`.
223
+ # @param target [ActiveRecord::Base, nil] one specific projection
224
+ # target instance.
225
+ # @param target_class [Class, nil] rebuild for every target of this
226
+ # AR class that the log references. Targets with zero log entries
227
+ # are skipped.
228
+ # @param batch_size [Integer] passed to `find_each` when iterating
229
+ # targets. Default 1000.
230
+ # @return [StandardLedger::Result, Object] success result with
231
+ # `projections[:rebuilt] = [{ target_class:, target_id:,
232
+ # projection: }, ...]`, one entry per (target, projection) pair
233
+ # that ran. Failure result with `errors:` when any rebuild raises.
234
+ # Returns the host's Result type when `Config#custom_result?` is
235
+ # true, otherwise `StandardLedger::Result`.
236
+ # @raise [StandardLedger::NotRebuildable] when an applicable
237
+ # projection has no rebuildable projector (block-form, or class
238
+ # form whose `rebuild` raises `NotRebuildable`).
239
+ # @raise [StandardLedger::Error] when an applicable projection
240
+ # declares a mode `rebuild!` does not yet support.
241
+ # @raise [ArgumentError] when both `target:` and `target_class:`
242
+ # are supplied, when the entry class does not respond to
243
+ # `standard_ledger_projections`, or when a non-nil scope
244
+ # (`target:` / `target_class:`) matches no registered projection.
245
+ # @note Memory: when neither `target:` nor `target_class:` is given,
246
+ # the no-scope and `target_class:` paths first load every distinct
247
+ # foreign-key value from the log into memory via `distinct.pluck`
248
+ # before batching the targets themselves. For very large logs,
249
+ # prefer `target:` to scope to a single target rather than
250
+ # rebuilding the full set.
251
+ def rebuild!(entry_class, target: nil, target_class: nil, batch_size: 1000)
252
+ if target && target_class
253
+ raise ArgumentError,
254
+ "rebuild! accepts at most one of `target:` or `target_class:` — got both"
255
+ end
256
+
257
+ unless entry_class.respond_to?(:standard_ledger_projections)
258
+ raise ArgumentError,
259
+ "#{entry_class.name || entry_class.inspect} does not include StandardLedger::Projector; " \
260
+ "rebuild! requires registered projections"
261
+ end
262
+
263
+ definitions = applicable_definitions_for_rebuild(entry_class, target: target, target_class: target_class)
264
+ validate_definitions_present!(entry_class, definitions, target: target, target_class: target_class)
265
+ rebuilt = []
266
+
267
+ definitions.each do |definition|
268
+ validate_rebuildable_mode!(entry_class, definition)
269
+
270
+ if definition.mode == :matview
271
+ rebuild_matview_definition(definition)
272
+ rebuilt << {
273
+ target_class: nil,
274
+ target_id: nil,
275
+ projection: definition.target_association,
276
+ view: definition.view
277
+ }
278
+ next
279
+ end
280
+
281
+ validate_rebuildable_projector!(entry_class, definition)
282
+
283
+ each_rebuild_target(entry_class, definition, target: target, batch_size: batch_size) do |t|
284
+ if definition.mode == :sql
285
+ rebuild_one_sql(entry_class, definition, t)
286
+ else
287
+ rebuild_one(entry_class, definition, t)
288
+ end
289
+ rebuilt << { target_class: t.class, target_id: t.id, projection: definition.target_association }
290
+ end
291
+ end
292
+
293
+ build_result(success: true, projections: { rebuilt: rebuilt })
294
+ rescue StandardLedger::Error, ArgumentError
295
+ # Programmer-error / unsupported-mode / not-rebuildable raises bubble
296
+ # up unchanged — these are deterministic, not data-dependent failures.
297
+ raise
298
+ rescue StandardError => e
299
+ # A projector raised mid-rebuild. Earlier successful rebuilds are
300
+ # NOT unwound (the contract is per-target transactional, not
301
+ # cross-target atomic) — we surface the failure but return.
302
+ build_result(success: false, errors: [ e.message ], projections: { rebuilt: rebuilt })
303
+ end
304
+
305
+ # Refresh a host-owned materialized view. Issues
306
+ # `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` against the
307
+ # active connection and emits the standard `<prefix>.projection.refreshed`
308
+ # notification on success (or `<prefix>.projection.failed` on raise,
309
+ # before re-raising — the host's scheduler / job runner needs to see the
310
+ # failure to drive its retry path).
311
+ #
312
+ # Two callers reach for this:
313
+ #
314
+ # - **Hosts**, after a critical write that needs read-your-write semantics
315
+ # on a `:matview` projection (e.g. luminality's `PromptPacks::DrawOperation`
316
+ # refreshes `user_prompt_inventories` at the end of the operation so the
317
+ # user sees their post-draw count immediately, instead of waiting for
318
+ # the next scheduled refresh).
319
+ # - **`StandardLedger::MatviewRefreshJob`**, the ActiveJob class hosts
320
+ # point their scheduler at; that job is a thin wrapper around this
321
+ # method.
322
+ #
323
+ # @param view_name [String, Symbol] the materialized view to refresh.
324
+ # @param concurrently [Boolean, nil] `nil` (default — read
325
+ # `Config#matview_refresh_strategy`), `true` (force CONCURRENTLY), or
326
+ # `false` (force a blocking refresh).
327
+ # @return [StandardLedger::Result, Object] success result on completion;
328
+ # the host's Result type when `Config#custom_result?` is true. On SQL
329
+ # failure the underlying exception propagates after the
330
+ # `<prefix>.projection.failed` event fires.
331
+ def refresh!(view_name, concurrently: nil)
332
+ effective = effective_concurrent_flag(concurrently)
333
+ Modes::Matview.refresh!(view_name, concurrently: effective)
334
+ build_result(
335
+ success: true,
336
+ projections: { refreshed: [ { view: view_name.to_s, concurrently: effective } ] }
337
+ )
338
+ end
339
+
340
+ private
341
+
342
+ # Resolve the kind column name for an entry class. Falls back to `:kind`
343
+ # when the host hasn't called `ledger_entry` yet — `post` is still useful
344
+ # for plain Entry-shaped models.
345
+ def resolve_kind_column(entry_class)
346
+ config = entry_class.respond_to?(:standard_ledger_entry_config) ? entry_class.standard_ledger_entry_config : nil
347
+ config ? config[:kind] : :kind
348
+ end
349
+
350
+ # Translate `targets:` into the matching foreign-key assignments by
351
+ # routing each value through the entry's `belongs_to` setter (after
352
+ # confirming via `reflect_on_association` that the key is a real
353
+ # association). Targets must be ActiveRecord instances; raw foreign-key
354
+ # ids should be passed via `attrs:` instead (`<assoc>_id: ...`).
355
+ def build_create_attrs(entry_class, kind_column, kind, targets, attrs)
356
+ assigned = { kind_column => kind }
357
+
358
+ if entry_class.respond_to?(:reflect_on_association)
359
+ targets.each do |assoc_name, target|
360
+ reflection = entry_class.reflect_on_association(assoc_name)
361
+ if reflection.nil?
362
+ raise ArgumentError,
363
+ "#{entry_class.name} has no association :#{assoc_name}; " \
364
+ "`targets:` keys must match `belongs_to` associations"
365
+ end
366
+ assigned[assoc_name] = target
367
+ end
368
+ else
369
+ assigned.merge!(targets)
370
+ end
371
+
372
+ assigned.merge(attrs)
373
+ end
374
+
375
+ # Filter the entry class's registered projections down to the set
376
+ # whose target association class matches the requested scope.
377
+ # When neither `target:` nor `target_class:` is supplied, every
378
+ # registered projection is in scope.
379
+ #
380
+ # @return [Array<Projector::Definition>]
381
+ def applicable_definitions_for_rebuild(entry_class, target:, target_class:)
382
+ requested_class = target_class || target&.class
383
+ return entry_class.standard_ledger_projections.dup if requested_class.nil?
384
+
385
+ entry_class.standard_ledger_projections.select do |definition|
386
+ association_target_class(entry_class, definition) == requested_class
387
+ end
388
+ end
389
+
390
+ # An empty `definitions` set means either (a) the host called
391
+ # `rebuild!` on an entry class that has no `projects_onto`
392
+ # declarations at all, or (b) a non-nil scope (`target:` /
393
+ # `target_class:`) was passed but no registered projection points at
394
+ # that AR class. Both are programmer errors — silently returning
395
+ # `Result.success` with `rebuilt: []` would let the mistake go
396
+ # undetected. Raise so the caller hears about it.
397
+ def validate_definitions_present!(entry_class, definitions, target:, target_class:)
398
+ return unless definitions.empty?
399
+
400
+ requested_class = target_class || target&.class
401
+
402
+ if requested_class
403
+ raise ArgumentError,
404
+ "#{entry_class.name} has no projections matching #{requested_class.name}; " \
405
+ "check the `projects_onto` declarations on #{entry_class.name}."
406
+ else
407
+ raise ArgumentError,
408
+ "#{entry_class.name} has no projections registered; " \
409
+ "add a `projects_onto` declaration before calling rebuild!."
410
+ end
411
+ end
412
+
413
+ # Resolve the AR class on the far side of a projection's
414
+ # `target_association`. Used to match `target:` / `target_class:`
415
+ # against registered projections.
416
+ def association_target_class(entry_class, definition)
417
+ reflection = entry_class.reflect_on_association(definition.target_association)
418
+ return nil if reflection.nil?
419
+
420
+ reflection.klass
421
+ end
422
+
423
+ # Refuse to rebuild for modes that don't yet implement the
424
+ # log-replay path. `:inline`, `:sql`, and `:matview` are the supported
425
+ # modes today; `:async` and `:trigger` land with their own mode PRs.
426
+ def validate_rebuildable_mode!(entry_class, definition)
427
+ return if definition.mode == :inline
428
+ return if definition.mode == :sql
429
+ return if definition.mode == :matview
430
+
431
+ raise StandardLedger::Error,
432
+ "rebuild! does not yet support mode: #{definition.mode.inspect} " \
433
+ "on #{entry_class.name}##{definition.target_association}; " \
434
+ "this mode's rebuild path lands in its own PR"
435
+ end
436
+
437
+ # Rebuild a `:matview` projection by issuing a single REFRESH against
438
+ # the registered view. There's no per-target loop — the matview holds
439
+ # state for every target in a single relation, so one refresh is the
440
+ # entire rebuild.
441
+ def rebuild_matview_definition(definition)
442
+ concurrently = definition.refresh_options[:concurrently]
443
+ effective = effective_concurrent_flag(concurrently)
444
+ Modes::Matview.refresh!(definition.view, concurrently: effective)
445
+ end
446
+
447
+ # Reduce the public `concurrently:` parameter to a Boolean by reading
448
+ # `Config#matview_refresh_strategy` only when the caller passed `nil`.
449
+ # `true`/`false` are honored verbatim so callers can override the
450
+ # default per-call (e.g. an ad-hoc blocking refresh on a view whose
451
+ # default is concurrent).
452
+ def effective_concurrent_flag(concurrently)
453
+ return concurrently unless concurrently.nil?
454
+
455
+ config.matview_refresh_strategy == :concurrent
456
+ end
457
+
458
+ # Block-form `:inline` projections register per-kind handlers
459
+ # (e.g. `on(:grant) { increment(...) }`) that describe a delta.
460
+ # There's no general way to recompute the aggregate from the log
461
+ # without the host providing a recompute path — so we refuse
462
+ # rather than guess. Hosts who want this projection to be
463
+ # rebuildable should extract a `Projection` subclass and implement
464
+ # `rebuild(target)`.
465
+ def validate_rebuildable_projector!(entry_class, definition)
466
+ # `:sql` mode carries its rebuild path in the recompute SQL itself —
467
+ # no projector class is required (and `via:` is rejected at
468
+ # registration). Skip the class-form preflight checks below.
469
+ return if definition.mode == :sql
470
+
471
+ if definition.projector_class.nil?
472
+ raise StandardLedger::NotRebuildable,
473
+ "#{entry_class.name}##{definition.target_association} is a block-form projection " \
474
+ "and cannot be rebuilt from the entry log. Implement a Projection subclass with " \
475
+ "`rebuild(target)` and pass it via `via:` to make this projection rebuildable."
476
+ end
477
+
478
+ # Best-effort early detection: catches the common "host forgot to
479
+ # override `rebuild`" case before we iterate any targets. The owner
480
+ # check is fragile for projectors that inherit `rebuild` from an
481
+ # intermediate mixin/superclass — the inherited `rebuild` may still
482
+ # raise `NotRebuildable` at runtime. The authoritative gate is the
483
+ # base `Projection#rebuild` implementation, which raises
484
+ # `NotRebuildable` itself; the rescue clause in `rebuild!` re-raises
485
+ # it unchanged. So a fragility miss here just means the failure
486
+ # surfaces at iteration-time instead of pre-flight, which is
487
+ # acceptable for v0.1.
488
+ return if definition.projector_class.instance_method(:rebuild).owner != StandardLedger::Projection
489
+
490
+ raise StandardLedger::NotRebuildable,
491
+ "#{definition.projector_class.name}#rebuild is not implemented; " \
492
+ "override it to recompute #{entry_class.name}##{definition.target_association} " \
493
+ "from the entry log."
494
+ end
495
+
496
+ # Yield each target in scope for this projection's rebuild. With
497
+ # an explicit `target:` we yield once; with `target_class:` or no
498
+ # scope, we walk every distinct foreign-key value in the log and
499
+ # `find_each` the corresponding rows in batches.
500
+ def each_rebuild_target(entry_class, definition, target:, batch_size:)
501
+ if target
502
+ yield target
503
+ return
504
+ end
505
+
506
+ reflection = entry_class.reflect_on_association(definition.target_association)
507
+ target_klass = reflection.klass
508
+ foreign_key = reflection.foreign_key
509
+
510
+ # Pluck the distinct ids referenced by the log so we don't
511
+ # rebuild for targets that have no entries against them. Cast
512
+ # through `compact` to skip null FKs (legitimate when the entry
513
+ # has an `if:` guard that may not apply).
514
+ ids = entry_class.where.not(foreign_key => nil).distinct.pluck(foreign_key)
515
+ return if ids.empty?
516
+
517
+ target_klass.where(id: ids).find_each(batch_size: batch_size) do |t|
518
+ yield t
519
+ end
520
+ end
521
+
522
+ # Run a single (target, projection) rebuild inside its own
523
+ # transaction, then fire `<prefix>.projection.rebuilt` on success
524
+ # so observers can track per-target rebuild progress.
525
+ def rebuild_one(entry_class, definition, target)
526
+ target.class.transaction do
527
+ definition.projector_class.new.rebuild(target)
528
+ end
529
+
530
+ prefix = config.notification_namespace
531
+ StandardLedger::EventEmitter.emit(
532
+ "#{prefix}.projection.rebuilt",
533
+ entry_class: entry_class, target: target,
534
+ projection: definition.target_association, mode: definition.mode
535
+ )
536
+ end
537
+
538
+ # `:sql` mode rebuild path: run the same recompute SQL the
539
+ # `after_create` callback runs, just bound to this target's id rather
540
+ # than the entry's foreign key. The recompute SQL is the entire
541
+ # contract for `:sql` projections — there's no projector class to
542
+ # invoke; the after-create and rebuild paths share one statement.
543
+ def rebuild_one_sql(entry_class, definition, target)
544
+ target.class.transaction do
545
+ sql = ActiveRecord::Base.sanitize_sql_array([ definition.recompute_sql, { target_id: target.id } ])
546
+ entry_class.connection.exec_update(sql)
547
+ end
548
+
549
+ prefix = config.notification_namespace
550
+ StandardLedger::EventEmitter.emit(
551
+ "#{prefix}.projection.rebuilt",
552
+ entry_class: entry_class, target: target,
553
+ projection: definition.target_association, mode: definition.mode
554
+ )
555
+ end
556
+
557
+ # Names of the `:inline`-mode projections that actually ran for this
558
+ # entry — surfaced in `result.projections[:inline]` so callers can
559
+ # distinguish "applied now" from "queued" from "scheduled" (§7).
560
+ #
561
+ # `Modes::Inline#call` populates `@_standard_ledger_applied_projections`
562
+ # on the entry instance with the target_association names that ran
563
+ # (skipping projections whose `if:` guard returned false, whose target
564
+ # was nil, or whose permissive miss didn't hit a `:_` wildcard). When
565
+ # the ivar isn't present — e.g. an idempotent rescue returned an
566
+ # existing row without firing `after_create` — we report an empty
567
+ # list, which accurately reflects that no projections ran on this call.
568
+ def applied_projections_for(entry)
569
+ Array(entry.instance_variable_get(:@_standard_ledger_applied_projections))
570
+ end
571
+
572
+ # Construct a Result via the host's adapter when configured, otherwise
573
+ # the gem's built-in `StandardLedger::Result`. The adapter contract is
574
+ # documented on `Config#result_adapter`.
575
+ def build_result(success:, entry: nil, errors: [], idempotent: false, projections: {})
576
+ if config.custom_result?
577
+ config.result_adapter.call(
578
+ success: success, value: entry, errors: errors,
579
+ entry: entry, idempotent: idempotent, projections: projections
580
+ )
581
+ elsif success
582
+ Result.success(entry: entry, idempotent: idempotent, projections: projections)
583
+ else
584
+ Result.failure(errors: errors, entry: entry, projections: projections)
585
+ end
586
+ end
587
+
588
+ # Resolve override-map keys to actual class constants so callers can
589
+ # write `with_modes(PaymentRecord => :inline)` *or*
590
+ # `with_modes(:payment_record => :inline)`. The String/Symbol form uses
591
+ # `String#classify` then `Object.const_get`; the Class form is passed
592
+ # through verbatim. Anything else raises so the caller fixes the typo
593
+ # rather than silently storing a key that nothing will ever match.
594
+ #
595
+ # An unresolvable String/Symbol key (typo: `:payment_recrd`) is caught
596
+ # and re-raised as `ArgumentError` with a `with_modes:`-prefixed message
597
+ # naming the offending key, rather than leaking `const_get`'s bare
598
+ # `NameError: uninitialized constant ...`.
599
+ def resolve_mode_overrides(overrides)
600
+ overrides.each_with_object({}) do |(key, mode), memo|
601
+ klass =
602
+ case key
603
+ when Class
604
+ key
605
+ when String, Symbol
606
+ begin
607
+ Object.const_get(key.to_s.classify)
608
+ rescue NameError
609
+ raise ArgumentError,
610
+ "with_modes: could not resolve #{key.inspect} to a constant"
611
+ end
612
+ else
613
+ raise ArgumentError,
614
+ "with_modes: expected Class, String, or Symbol key; got #{key.inspect}"
615
+ end
616
+ memo[klass] = mode
617
+ end
618
+ end
619
+ end
620
+ end