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

Class Method Details

.configObject



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.

Yields:



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.

Parameters:

  • entry_class (Class)

    the host entry class.

Returns:

  • (Symbol, nil)

    the override mode, or ‘nil` for “no override”.



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.

Examples:

StandardLedger.post(VoucherRecord,
                    kind:    :grant,
                    targets: { voucher_scheme: scheme, customer_profile: profile },
                    attrs:   { serial_no: "v-123", organisation_id: org.id })

pass an id via attrs when you don’t have a model instance

StandardLedger.post(VoucherRecord,
                    kind:  :grant,
                    attrs: { voucher_scheme_id: 42, organisation_id: org.id, serial_no: "v-1" })

Parameters:

  • entry_class (Class)

    an ‘ActiveRecord::Base` subclass that includes `StandardLedger::Entry`.

  • kind (Symbol, String)

    value for the entry’s configured kind column (read from ‘entry_class.standard_ledger_entry_config`).

  • targets (Hash{Symbol => ActiveRecord::Base}) (defaults to: {})

    association name -> model instance. Each is assigned via the matching ‘belongs_to` setter. To assign by id without loading the record, pass the foreign key directly via `attrs:` (e.g. `voucher_scheme_id: 42`).

  • attrs (Hash) (defaults to: {})

    additional attributes merged into the create call.

Returns:

  • (StandardLedger::Result, Object)

    the gem’s Result, or the host’s Result type when ‘Config#custom_result?` is true. The Result’s ‘projections` contains the target_association names of the inline projections that actually ran for this entry — projections skipped by an `if:` guard are excluded, and an idempotent retry returns an empty array (no projections fire on the rescue path).



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.full_messages)
end

.rebuild!(entry_class, target: nil, target_class: nil, batch_size: 1000) ⇒ StandardLedger::Result, Object

Note:

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.

Examples:

rebuild a single target

StandardLedger.rebuild!(VoucherRecord, target: scheme)

rebuild every scheme

StandardLedger.rebuild!(VoucherRecord, target_class: VoucherScheme)

rebuild every projection across every target

StandardLedger.rebuild!(VoucherRecord)

Parameters:

  • entry_class (Class)

    an ‘ActiveRecord::Base` subclass that includes `StandardLedger::Projector`.

  • target (ActiveRecord::Base, nil) (defaults to: nil)

    one specific projection target instance.

  • target_class (Class, nil) (defaults to: nil)

    rebuild for every target of this AR class that the log references. Targets with zero log entries are skipped.

  • batch_size (Integer) (defaults to: 1000)

    passed to ‘find_each` when iterating targets. Default 1000.

Returns:

  • (StandardLedger::Result, Object)

    success result with ‘projections = [{ target_class:, target_id:, projection: }, …]`, one entry per (target, projection) pair that ran. Failure result with `errors:` when any rebuild raises. Returns the host’s Result type when ‘Config#custom_result?` is true, otherwise `StandardLedger::Result`.

Raises:

  • (StandardLedger::NotRebuildable)

    when an applicable projection has no rebuildable projector (block-form, or class form whose ‘rebuild` raises `NotRebuildable`).

  • (StandardLedger::Error)

    when an applicable projection declares a mode ‘rebuild!` does not yet support.

  • (ArgumentError)

    when both ‘target:` and `target_class:` are supplied, when the entry class does not respond to `standard_ledger_projections`, or when a non-nil scope (`target:` / `target_class:`) matches no registered projection.



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.message ], 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.

Parameters:

  • view_name (String, Symbol)

    the materialized view to refresh.

  • concurrently (Boolean, nil) (defaults to: nil)

    ‘nil` (default — read `Config#matview_refresh_strategy`), `true` (force CONCURRENTLY), or `false` (force a blocking refresh).

Returns:

  • (StandardLedger::Result, Object)

    success result on completion; the host’s Result type when ‘Config#custom_result?` is true. On SQL failure the underlying exception propagates after the `<prefix>.projection.failed` event fires.



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.

Examples:

StandardLedger.with_modes(PaymentRecord => :inline) do
  Orders::CheckoutOperation.call(...)
end

string keys (resolved via const_get)

StandardLedger.with_modes("PaymentRecord" => :inline) do
  ...
end

Parameters:

  • overrides (Hash{Class, String, Symbol => Symbol})

    entry class (or constant name / underscored symbol) → forced mode symbol.



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