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