Module: StandardLedger::Entry

Extended by:
ActiveSupport::Concern
Defined in:
lib/standard_ledger/entry.rb

Overview

Marks an ActiveRecord model as a ledger entry: an immutable, append-only row that may project onto one or more aggregate targets.

Including this concern installs:

- the `ledger_entry` class macro (declares immutability + idempotency)
- read-only behavior post-creation (when `immutable: true`, the default)
- idempotency-by-unique-index (when `idempotency_key:` is non-nil)

Projection registration happens via the separate ‘Projector` concern —the two are decoupled so that an entry can be marked immutable without also opting into projections, and vice versa.

Examples:

class VoucherRecord < ApplicationRecord
  include StandardLedger::Entry

  ledger_entry kind:            :action,
               idempotency_key: :serial_no,
               scope:           :organisation_id
end

Instance Method Summary collapse

Instance Method Details

#destroyObject

Wrap ‘destroy` so it can bypass the `readonly?` guard when the entry has opted in via `allow_destroy: true`. This applies to `destroy`, `destroy!`, and `dependent: :destroy` cascades from a parent record (all routes call through `#destroy`).



265
266
267
268
269
270
271
272
273
274
275
# File 'lib/standard_ledger/entry.rb', line 265

def destroy
  return super unless self.class.respond_to?(:standard_ledger_entry_config)

  config = self.class.standard_ledger_entry_config
  return super if config.nil? || !config[:immutable] || !config[:allow_destroy]

  @_standard_ledger_destroying = true
  super
ensure
  @_standard_ledger_destroying = false
end

#idempotent?Boolean

Returns true when this row was returned from an idempotent ‘create!` rescue — i.e. an existing row matched the unique constraint and was returned instead of inserted.

Returns:

  • (Boolean)


239
240
241
# File 'lib/standard_ledger/entry.rb', line 239

def idempotent?
  !!@_standard_ledger_idempotent
end

#readonly?Boolean

AR consults ‘readonly?` from `save`/`update`/`destroy` paths; raising ReadOnlyRecord here matches the ActiveRecord contract for persisted immutable rows. New, unpersisted instances stay writable so the initial INSERT can land.

When ‘allow_destroy: true` is set, `#destroy` toggles `@_standard_ledger_destroying` so `readonly?` returns false for the duration of the destroy call (and the duration of any cascade destroys that fire from its `dependent: :destroy` associations). The save/update path is unaffected — those still raise on persisted rows.

Returns:

  • (Boolean)


254
255
256
257
258
259
# File 'lib/standard_ledger/entry.rb', line 254

def readonly?
  return super unless standard_ledger_immutable?
  return false if @_standard_ledger_destroying

  !new_record?
end

#standard_ledger_targetsHash{Symbol => ActiveRecord::Base}

Returns the entry’s belongs_to targets keyed by association name. Used by the ‘entry.created` notification payload and by `StandardLedger.post`’s telemetry. Skips polymorphic and missing associations so the payload only includes what’s actually present.

Performance trade-off: this fires from ‘after_commit`, where AR may have cleared the association cache. Each `public_send(reflection.name)` can therefore issue a SELECT to reload the cached target. For the typical 1–2 belongs_to entry, that’s negligible. If profiling on a high-cardinality entry shows this matters, capture targets earlier (e.g. in ‘before_create`) and stash them on the instance — deferred to a future PR. Notably, an inline-mode caller has already resolved these targets by the time `after_commit` runs, so the SELECTs would only happen for entries with belongs_to associations that are not registered as projection targets.

Returns:

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


294
295
296
297
298
299
300
301
302
303
# File 'lib/standard_ledger/entry.rb', line 294

def standard_ledger_targets
  return {} unless self.class.respond_to?(:reflect_on_all_associations)

  self.class.reflect_on_all_associations(:belongs_to).each_with_object({}) do |reflection, memo|
    next if reflection.polymorphic?

    target = public_send(reflection.name)
    memo[reflection.name] = target unless target.nil?
  end
end