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
|
@@ -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
|