Module: StandardLedger
- Defined in:
- lib/standard_ledger.rb,
lib/standard_ledger/entry.rb,
lib/standard_ledger/rspec.rb,
lib/standard_ledger/config.rb,
lib/standard_ledger/engine.rb,
lib/standard_ledger/errors.rb,
lib/standard_ledger/result.rb,
lib/standard_ledger/version.rb,
lib/standard_ledger/modes/sql.rb,
lib/standard_ledger/projector.rb,
lib/standard_ledger/projection.rb,
lib/standard_ledger/modes/async.rb,
lib/standard_ledger/modes/inline.rb,
lib/standard_ledger/event_emitter.rb,
lib/standard_ledger/modes/matview.rb,
lib/standard_ledger/modes/trigger.rb,
lib/standard_ledger/rspec/helpers.rb,
lib/standard_ledger/jobs/projection_job.rb,
lib/standard_ledger/jobs/matview_refresh_job.rb,
lib/generators/standard_ledger/install/install_generator.rb
Overview
Opt-in test support for host apps. Hosts add this require to their ‘spec/rails_helper.rb` (or equivalent):
require "standard_ledger/rspec"
Loading this file:
-
Registers a ‘before(:each)` hook that calls `StandardLedger.reset_mode_overrides!` so the thread-local `with_modes` override map doesn’t leak between examples. We deliberately do not call the full ‘reset!` here: hosts often configure the gem from a Rails initializer (`StandardLedger.configure { |c| c.result_adapter = … }`), and wiping `@config` between examples would silently undo that configuration for every spec. The reset is wired via `RSpec.configure` rather than a custom shared context so it applies to every example group automatically.
-
Defines the ‘post_ledger_entry` matcher (see `StandardLedger::RSpec::Matchers`) for assertions of the form `expect { … }.to post_ledger_entry(EntryClass).with(kind: …)`.
-
Includes ‘StandardLedger::RSpec::Helpers` into every example group so specs can call `with_modes(…)` directly without the module prefix.
We intentionally avoid touching subscribers, AR connections, or any host state — the gem only owns its own configuration. Hosts that need additional cleanup wire their own hooks alongside this one.
Defined Under Namespace
Modules: Entry, EventEmitter, Generators, Modes, Projector, RSpec Classes: Config, Engine, Error, MatviewRefreshJob, MissingIdempotencyIndex, NotRebuildable, PartialFailure, Projection, ProjectionJob, Result, UnhandledKind
Constant Summary collapse
- VERSION =
"0.3.0"
Class Method Summary collapse
- .config ⇒ Object
-
.configure {|config| ... } ⇒ Object
Configure the gem once per app, typically from ‘config/initializers/standard_ledger.rb`.
-
.mode_override_for(entry_class) ⇒ Symbol?
Read the active override (if any) for ‘entry_class`.
-
.post(entry_class, kind:, targets: {}, attrs: {}) ⇒ StandardLedger::Result, Object
Sugar over ‘EntryClass.create!` that maps `targets:` onto the entry’s ‘belongs_to` foreign keys.
-
.rebuild!(entry_class, target: nil, target_class: nil, batch_size: 1000) ⇒ StandardLedger::Result, Object
Recompute projections from the entry log for one or more targets.
-
.refresh!(view_name, concurrently: nil) ⇒ StandardLedger::Result, Object
Refresh a host-owned materialized view.
-
.reset! ⇒ Object
Full reset: clears the cached ‘Config` AND any thread-local `with_modes` overrides.
-
.reset_mode_overrides! ⇒ Object
Test-friendly reset that only clears the thread-local ‘with_modes` override map, leaving `Config` intact.
-
.with_modes(overrides) ⇒ Object
Force specific entry classes’ projections to run in the supplied mode for the duration of the block.
Class Method Details
.config ⇒ Object
46 47 48 |
# File 'lib/standard_ledger.rb', line 46 def config @config ||= Config.new end |
.configure {|config| ... } ⇒ Object
Configure the gem once per app, typically from ‘config/initializers/standard_ledger.rb`. Yields the `Config` instance.
41 42 43 44 |
# File 'lib/standard_ledger.rb', line 41 def configure yield config config end |
.mode_override_for(entry_class) ⇒ Symbol?
Read the active override (if any) for ‘entry_class`. Mode strategies call this in their `install!` / `#call` paths before deciding whether to dispatch to the declared mode or the override mode. Returns `nil` outside any `with_modes` block.
167 168 169 170 171 172 |
# File 'lib/standard_ledger.rb', line 167 def mode_override_for(entry_class) overrides = Thread.current[:standard_ledger_mode_overrides] return nil if overrides.nil? overrides[entry_class] end |
.post(entry_class, kind:, targets: {}, attrs: {}) ⇒ StandardLedger::Result, Object
Sugar over ‘EntryClass.create!` that maps `targets:` onto the entry’s ‘belongs_to` foreign keys. Equivalent to calling `create!` directly with the assignments folded together — the inline projection callback fires from the same code path either way.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/standard_ledger.rb', line 102 def post(entry_class, kind:, targets: {}, attrs: {}) kind_column = resolve_kind_column(entry_class) create_attrs = build_create_attrs(entry_class, kind_column, kind, targets, attrs) entry = entry_class.create!(create_attrs) build_result( success: true, entry: entry, idempotent: entry.respond_to?(:idempotent?) && entry.idempotent?, projections: { inline: applied_projections_for(entry) } ) rescue ActiveRecord::RecordInvalid => e build_result(success: false, entry: e.record, errors: e.record.errors.) end |
.rebuild!(entry_class, target: nil, target_class: nil, batch_size: 1000) ⇒ StandardLedger::Result, Object
Memory: when neither ‘target:` nor `target_class:` is given, the no-scope and `target_class:` paths first load every distinct foreign-key value from the log into memory via `distinct.pluck` before batching the targets themselves. For very large logs, prefer `target:` to scope to a single target rather than rebuilding the full set.
Recompute projections from the entry log for one or more targets. The deterministic counterpart to ‘post`: instead of applying the delta from a single new entry, this replays the full log onto the target by delegating to the projector class’s ‘rebuild(target)`.
Scope (mutually exclusive — pass at most one):
-
‘target:` — rebuild every projection whose `target_association` resolves to `target.class`, for that single instance.
-
‘target_class:` — rebuild every matching projection for every target referenced by the log for that AR class, in `find_each` batches. Targets with zero log entries are skipped (rebuilding a target the log never touched would zero its counters — destructive rather than corrective).
-
neither — rebuild every projection on ‘entry_class` for every target referenced by the log.
Per-mode rules:
-
‘:inline` projections must be class-form (`via: ProjectorClass`) AND that class must implement `rebuild`. Block-form projections are delta-based — they cannot be reconstructed from the log without the host providing a recompute path — so they raise `StandardLedger::NotRebuildable` here.
-
‘:matview` projections rebuild by issuing a single `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view>` — for matview, refresh is rebuild. Postgres has no partial-refresh primitive, so `target:` / `target_class:` scope arguments are ignored for `:matview` projections and the full view is always refreshed.
-
‘:sql` and `:trigger` projections rebuild by running their recorded rebuild SQL with `:target_id` bound to each target’s id. For ‘:trigger`, the database trigger fires on entry INSERT; `rebuild!` runs the same logical recompute against each target the log references. The gem does NOT verify or recreate the trigger here — `standard_ledger:doctor` is the deploy-time check for trigger presence.
-
‘:async` projections rebuild via the same per-target semantics as `:inline` (delegates to `definition.projector_class.new.rebuild(target)`). The mode difference is only in the after-create path (in-transaction vs. post-commit job), not in the rebuild path, which always runs synchronously.
Atomicity: each (target, projection) pair runs in its own transaction. A failure mid-loop is not rolled back — earlier successful rebuilds remain applied. Concurrent posts to the entry log during rebuild produce eventually-correct state: the rebuild operates on a snapshot of the log up to the projector’s own SELECT, and any entries written after that snapshot project normally via the entry’s own callback path. See design doc §5.5.
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/standard_ledger.rb', line 263 def rebuild!(entry_class, target: nil, target_class: nil, batch_size: 1000) if target && target_class raise ArgumentError, "rebuild! accepts at most one of `target:` or `target_class:` — got both" end unless entry_class.respond_to?(:standard_ledger_projections) raise ArgumentError, "#{entry_class.name || entry_class.inspect} does not include StandardLedger::Projector; " \ "rebuild! requires registered projections" end definitions = applicable_definitions_for_rebuild(entry_class, target: target, target_class: target_class) validate_definitions_present!(entry_class, definitions, target: target, target_class: target_class) rebuilt = [] definitions.each do |definition| validate_rebuildable_mode!(entry_class, definition) if definition.mode == :matview rebuild_matview_definition(definition) rebuilt << { target_class: nil, target_id: nil, projection: definition.target_association, view: definition.view } next end validate_rebuildable_projector!(entry_class, definition) each_rebuild_target(entry_class, definition, target: target, batch_size: batch_size) do |t| if definition.mode == :sql || definition.mode == :trigger rebuild_one_sql(entry_class, definition, t) else rebuild_one(entry_class, definition, t) end rebuilt << { target_class: t.class, target_id: t.id, projection: definition.target_association } end end build_result(success: true, projections: { rebuilt: rebuilt }) rescue StandardLedger::Error, ArgumentError # Programmer-error / unsupported-mode / not-rebuildable raises bubble # up unchanged — these are deterministic, not data-dependent failures. raise rescue StandardError => e # A projector raised mid-rebuild. Earlier successful rebuilds are # NOT unwound (the contract is per-target transactional, not # cross-target atomic) — we surface the failure but return. build_result(success: false, errors: [ e. ], projections: { rebuilt: rebuilt }) end |
.refresh!(view_name, concurrently: nil) ⇒ StandardLedger::Result, Object
Refresh a host-owned materialized view. Issues ‘REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` against the active connection and emits the standard `<prefix>.projection.refreshed` notification on success (or `<prefix>.projection.failed` on raise, before re-raising — the host’s scheduler / job runner needs to see the failure to drive its retry path).
Two callers reach for this:
-
Hosts, after a critical write that needs read-your-write semantics on a ‘:matview` projection (e.g. luminality’s ‘PromptPacks::DrawOperation` refreshes `user_prompt_inventories` at the end of the operation so the user sees their post-draw count immediately, instead of waiting for the next scheduled refresh).
-
**‘StandardLedger::MatviewRefreshJob`**, the ActiveJob class hosts point their scheduler at; that job is a thin wrapper around this method.
343 344 345 346 347 348 349 350 |
# File 'lib/standard_ledger.rb', line 343 def refresh!(view_name, concurrently: nil) effective = effective_concurrent_flag(concurrently) Modes::Matview.refresh!(view_name, concurrently: effective) build_result( success: true, projections: { refreshed: [ { view: view_name.to_s, concurrently: effective } ] } ) end |
.reset! ⇒ Object
Full reset: clears the cached ‘Config` AND any thread-local `with_modes` overrides. Use this when a spec needs to verify the gem’s boot path or when the host has not installed a Rails initializer (so wiping ‘@config` is harmless). Hosts that do configure the gem in an initializer should not call this between examples — use `reset_mode_overrides!` instead, which the auto-cleanup hook already invokes.
57 58 59 60 |
# File 'lib/standard_ledger.rb', line 57 def reset! @config = nil reset_mode_overrides! end |
.reset_mode_overrides! ⇒ Object
Test-friendly reset that only clears the thread-local ‘with_modes` override map, leaving `Config` intact. The `standard_ledger/rspec` auto-cleanup hook calls this in `before(:each)` so a host’s initializer config (e.g. a configured ‘result_adapter`) survives across examples while per-example mode overrides still get torn down cleanly.
67 68 69 |
# File 'lib/standard_ledger.rb', line 67 def reset_mode_overrides! Thread.current[:standard_ledger_mode_overrides] = nil end |
.with_modes(overrides) ⇒ Object
Force specific entry classes’ projections to run in the supplied mode for the duration of the block. Intended for tests that want to drive an async-mode projection inline so the spec doesn’t need a job runner.
The override map is stored thread-locally so concurrent specs (or the gem’s own ‘:async` workers) don’t observe each other’s overrides. Mode strategies consult ‘StandardLedger.mode_override_for(entry_class)` before falling back to the projection’s declared mode.
The block’s prior override map is restored on exit, including on exception, so nested ‘with_modes` calls compose cleanly: the inner block’s keys win during its scope, then the outer map is restored untouched.
Today only ‘:inline` exists as a real mode, so this is a no-op for already-inline projections. The hook lands now so async projections can opt into the inline path the moment `Modes::Async` ships.
148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/standard_ledger.rb', line 148 def with_modes(overrides) resolved = resolve_mode_overrides(overrides) prior = Thread.current[:standard_ledger_mode_overrides] merged = (prior || {}).merge(resolved) Thread.current[:standard_ledger_mode_overrides] = merged yield ensure Thread.current[:standard_ledger_mode_overrides] = prior end |