standard_ledger 0.5.1 → 0.6.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 +4 -4
- data/CHANGELOG.md +85 -1
- data/README.md +142 -353
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +13 -29
- data/lib/standard_ledger/config.rb +14 -29
- data/lib/standard_ledger/entry.rb +6 -10
- data/lib/standard_ledger/errors.rb +6 -26
- data/lib/standard_ledger/event_emitter.rb +2 -2
- data/lib/standard_ledger/matview.rb +162 -0
- data/lib/standard_ledger/projection.rb +17 -24
- data/lib/standard_ledger/result.rb +2 -2
- data/lib/standard_ledger/rspec.rb +6 -28
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +67 -505
- metadata +9 -48
- data/lib/standard_ledger/engine.rb +0 -28
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +0 -28
- data/lib/standard_ledger/jobs/projection_job.rb +0 -94
- data/lib/standard_ledger/modes/async.rb +0 -141
- data/lib/standard_ledger/modes/inline.rb +0 -180
- data/lib/standard_ledger/modes/matview.rb +0 -146
- data/lib/standard_ledger/modes/sql.rb +0 -132
- data/lib/standard_ledger/modes/trigger.rb +0 -50
- data/lib/standard_ledger/projector.rb +0 -638
- data/lib/standard_ledger/rspec/helpers.rb +0 -15
- data/lib/tasks/standard_ledger.rake +0 -60
|
@@ -5,30 +5,19 @@ module StandardLedger
|
|
|
5
5
|
#
|
|
6
6
|
# @see StandardLedger.configure
|
|
7
7
|
class Config
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# `
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# Matches ActiveJob's `retry_on attempts:` semantics.
|
|
17
|
-
attr_accessor :default_async_retries
|
|
18
|
-
|
|
19
|
-
# Scheduler backend for `:matview` refresh jobs. One of
|
|
20
|
-
# `:solid_queue`, `:sidekiq_cron`, `:custom`. Default: `:solid_queue`
|
|
21
|
-
# (matches all four consuming apps).
|
|
22
|
-
attr_accessor :scheduler
|
|
23
|
-
|
|
24
|
-
# Default refresh strategy for `:matview` projections. Either
|
|
25
|
-
# `:concurrent` (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a
|
|
26
|
-
# unique index on the view) or `:blocking`. Default: `:concurrent`.
|
|
8
|
+
# Default for `StandardLedger.refresh!` calls that omit `concurrently:`.
|
|
9
|
+
# One of:
|
|
10
|
+
#
|
|
11
|
+
# - `:concurrent` (default) — `REFRESH MATERIALIZED VIEW CONCURRENTLY`;
|
|
12
|
+
# requires a populated view with a unique index, outside a transaction.
|
|
13
|
+
# - `:blocking` — plain `REFRESH MATERIALIZED VIEW`.
|
|
14
|
+
# - `:auto` — concurrent when possible, plain otherwise (see
|
|
15
|
+
# `StandardLedger.refresh!`).
|
|
27
16
|
attr_accessor :matview_refresh_strategy
|
|
28
17
|
|
|
29
18
|
# Optional: the host application's Result class. When set together with
|
|
30
|
-
# `result_adapter`, `StandardLedger.post`
|
|
31
|
-
# instead of `StandardLedger::Result`.
|
|
19
|
+
# `result_adapter`, `StandardLedger.post` and `StandardLedger.refresh!`
|
|
20
|
+
# return instances of this class instead of `StandardLedger::Result`.
|
|
32
21
|
attr_accessor :result_class
|
|
33
22
|
|
|
34
23
|
# Optional: a callable that translates the gem's result fields into the
|
|
@@ -37,17 +26,13 @@ module StandardLedger
|
|
|
37
26
|
# Required when `result_class` is set.
|
|
38
27
|
attr_accessor :result_adapter
|
|
39
28
|
|
|
40
|
-
# Prefix for
|
|
41
|
-
# Default: `"standard_ledger"`.
|
|
42
|
-
# `<prefix>.entry.created`, `<prefix>.projection.
|
|
43
|
-
# `<prefix>.projection.failed
|
|
44
|
-
# `<prefix>.projection.rebuilt`.
|
|
29
|
+
# Prefix for events emitted by the gem (via `Rails.event` on Rails 8.1+,
|
|
30
|
+
# `ActiveSupport::Notifications` otherwise). Default: `"standard_ledger"`.
|
|
31
|
+
# Events: `<prefix>.entry.created`, `<prefix>.projection.refreshed`,
|
|
32
|
+
# `<prefix>.projection.failed`.
|
|
45
33
|
attr_accessor :notification_namespace
|
|
46
34
|
|
|
47
35
|
def initialize
|
|
48
|
-
@default_async_job = nil # resolved lazily to avoid loading the job constant before Rails boots
|
|
49
|
-
@default_async_retries = 3
|
|
50
|
-
@scheduler = :solid_queue
|
|
51
36
|
@matview_refresh_strategy = :concurrent
|
|
52
37
|
@result_class = nil
|
|
53
38
|
@result_adapter = nil
|
|
@@ -2,16 +2,16 @@ require "active_support/concern"
|
|
|
2
2
|
|
|
3
3
|
module StandardLedger
|
|
4
4
|
# Marks an ActiveRecord model as a ledger entry: an immutable, append-only
|
|
5
|
-
# row
|
|
5
|
+
# journal row.
|
|
6
6
|
#
|
|
7
7
|
# Including this concern installs:
|
|
8
8
|
# - the `ledger_entry` class macro (declares immutability + idempotency)
|
|
9
9
|
# - read-only behavior post-creation (when `immutable: true`, the default)
|
|
10
10
|
# - idempotency-by-unique-index (when `idempotency_key:` is non-nil)
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# Aggregates derived from entries are the host's concern — a plain
|
|
13
|
+
# `StandardLedger::Projection` subclass or a materialized view refreshed via
|
|
14
|
+
# `StandardLedger.refresh!`.
|
|
15
15
|
#
|
|
16
16
|
# @example
|
|
17
17
|
# class VoucherRecord < ApplicationRecord
|
|
@@ -26,8 +26,7 @@ module StandardLedger
|
|
|
26
26
|
|
|
27
27
|
class_methods do
|
|
28
28
|
# Declare the entry's contract. Stores the configuration on the class
|
|
29
|
-
# for later inspection by `StandardLedger.post
|
|
30
|
-
# and the `standard_ledger:doctor` rake task.
|
|
29
|
+
# for later inspection by `StandardLedger.post` and host specs.
|
|
31
30
|
#
|
|
32
31
|
# @param kind [Symbol] the column holding the entry's kind/action
|
|
33
32
|
# discriminator. Defaults to `:kind`.
|
|
@@ -285,10 +284,7 @@ module StandardLedger
|
|
|
285
284
|
# typical 1–2 belongs_to entry, that's negligible. If profiling on a
|
|
286
285
|
# high-cardinality entry shows this matters, capture targets earlier
|
|
287
286
|
# (e.g. in `before_create`) and stash them on the instance — deferred
|
|
288
|
-
# to a future PR.
|
|
289
|
-
# these targets by the time `after_commit` runs, so the SELECTs would
|
|
290
|
-
# only happen for entries with belongs_to associations that are *not*
|
|
291
|
-
# registered as projection targets.
|
|
287
|
+
# to a future PR.
|
|
292
288
|
#
|
|
293
289
|
# @return [Hash{Symbol => ActiveRecord::Base}]
|
|
294
290
|
def standard_ledger_targets
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
module StandardLedger
|
|
2
2
|
class Error < StandardError; end
|
|
3
3
|
|
|
4
|
-
# Raised
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# a handler for. Use `permissive: true` on the projection to opt out.
|
|
8
|
-
class UnhandledKind < Error; end
|
|
9
|
-
|
|
10
|
-
# Raised by `StandardLedger.rebuild!` when the projector does not implement
|
|
11
|
-
# `rebuild` and is therefore not replayable from the entry log. Delta-based
|
|
12
|
-
# projectors (e.g. `increment_counter`-flavored) typically raise this
|
|
13
|
-
# because they cannot be reconstructed without summing the full log.
|
|
4
|
+
# Raised by `StandardLedger::Projection#rebuild` when a projector subclass
|
|
5
|
+
# does not override it — i.e. the projector cannot be recomputed from the
|
|
6
|
+
# entry log.
|
|
14
7
|
class NotRebuildable < Error; end
|
|
15
8
|
|
|
16
9
|
# Raised at boot when an Entry declares `idempotency_key:` but no matching
|
|
@@ -18,26 +11,13 @@ module StandardLedger
|
|
|
18
11
|
# admitting duplicates at runtime.
|
|
19
12
|
class MissingIdempotencyIndex < Error; end
|
|
20
13
|
|
|
21
|
-
# Raised by `StandardLedger.post` when the inline portion of fan-out
|
|
22
|
-
# succeeded but enqueuing one or more async projections failed. The entry
|
|
23
|
-
# itself is durable; callers may choose to roll back or accept-and-log.
|
|
24
|
-
class PartialFailure < Error
|
|
25
|
-
attr_reader :enqueued, :failed
|
|
26
|
-
|
|
27
|
-
def initialize(enqueued:, failed:)
|
|
28
|
-
@enqueued = enqueued
|
|
29
|
-
@failed = failed
|
|
30
|
-
super("Enqueued #{enqueued.size} projections; #{failed.size} failed to enqueue")
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
14
|
# Raised when `StandardLedger.refresh!(view, concurrently: true)` is called
|
|
35
15
|
# inside an open transaction. PostgreSQL rejects
|
|
36
16
|
# `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside transaction blocks; the
|
|
37
17
|
# gem catches this at the boundary so the failure is a clear,
|
|
38
18
|
# gem-attributable error instead of a raw `PG::ActiveSqlTransaction`.
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
19
|
+
# Pass `concurrently: :auto` to fall back to a plain refresh instead, move
|
|
20
|
+
# the refresh outside the transaction, or defer it with
|
|
21
|
+
# `connection.add_transaction_record { ... }`.
|
|
42
22
|
class RefreshInsideTransaction < Error; end
|
|
43
23
|
end
|
|
@@ -15,8 +15,8 @@ module StandardLedger
|
|
|
15
15
|
|
|
16
16
|
# Emit a single event. Both backends are best-effort: any exception raised
|
|
17
17
|
# by a subscriber is swallowed so ledger observability never takes down a
|
|
18
|
-
# host's request path (the
|
|
19
|
-
#
|
|
18
|
+
# host's request path (the entry is already committed, or the refresh
|
|
19
|
+
# already ran, by the time we emit).
|
|
20
20
|
def emit(event_name, payload)
|
|
21
21
|
if (bus = rails_event_bus)
|
|
22
22
|
bus.notify(event_name, **payload)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require "active_support/core_ext/string/filters"
|
|
2
|
+
|
|
3
|
+
module StandardLedger
|
|
4
|
+
# Materialized-view refresh primitive behind `StandardLedger.refresh!`.
|
|
5
|
+
# The host creates and owns the view (via a `scenic` or hand-rolled
|
|
6
|
+
# migration) and schedules refreshes itself; this module only issues the
|
|
7
|
+
# `REFRESH MATERIALIZED VIEW` SQL, decides whether `CONCURRENTLY` is safe
|
|
8
|
+
# when asked to (`concurrently: :auto`), and emits notifications.
|
|
9
|
+
#
|
|
10
|
+
# PostgreSQL only.
|
|
11
|
+
#
|
|
12
|
+
# @api private — call `StandardLedger.refresh!` instead.
|
|
13
|
+
module Matview
|
|
14
|
+
IDENTIFIER = /\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?\z/
|
|
15
|
+
|
|
16
|
+
# `REFRESH ... CONCURRENTLY` needs a populated view and at least one
|
|
17
|
+
# valid UNIQUE index that uses only column names and covers all rows (no
|
|
18
|
+
# expressions, no WHERE clause). `to_regclass` returns NULL for a
|
|
19
|
+
# missing relation, so a missing view yields no row.
|
|
20
|
+
CONCURRENCY_PROBE_SQL = <<~SQL.squish.freeze
|
|
21
|
+
SELECT c.relispopulated AS populated,
|
|
22
|
+
EXISTS (
|
|
23
|
+
SELECT 1 FROM pg_index i
|
|
24
|
+
WHERE i.indrelid = c.oid
|
|
25
|
+
AND i.indisunique
|
|
26
|
+
AND i.indisvalid
|
|
27
|
+
AND i.indpred IS NULL
|
|
28
|
+
AND i.indexprs IS NULL
|
|
29
|
+
) AS unique_index
|
|
30
|
+
FROM pg_class c
|
|
31
|
+
WHERE c.oid = to_regclass(?) AND c.relkind = 'm'
|
|
32
|
+
SQL
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Resolve a requested `concurrently` value (`true`, `false`, `:auto`)
|
|
36
|
+
# to the Boolean actually used for the refresh.
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def resolve_concurrently(view_name, requested)
|
|
40
|
+
return requested unless requested == :auto
|
|
41
|
+
|
|
42
|
+
validate_view_name!(view_name)
|
|
43
|
+
concurrent_refresh_possible?(view_name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Issue `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` and emit
|
|
47
|
+
# `<prefix>.projection.refreshed` on success, or
|
|
48
|
+
# `<prefix>.projection.failed` before re-raising on SQL failure.
|
|
49
|
+
#
|
|
50
|
+
# The view name is validated against a SQL-identifier regex at the
|
|
51
|
+
# boundary as defence in depth — a careless host could pass through a
|
|
52
|
+
# config value or other untrusted string.
|
|
53
|
+
#
|
|
54
|
+
# @param view_name [String, Symbol] bare or `schema.view` identifier.
|
|
55
|
+
# @param concurrently [Boolean]
|
|
56
|
+
# @return [void]
|
|
57
|
+
# @raise [ArgumentError] when `view_name` is not a valid SQL identifier.
|
|
58
|
+
# @raise [StandardLedger::RefreshInsideTransaction] for a concurrent
|
|
59
|
+
# refresh inside an open transaction.
|
|
60
|
+
def refresh!(view_name, concurrently:)
|
|
61
|
+
validate_view_name!(view_name)
|
|
62
|
+
check_transaction_state!(view_name, concurrently: concurrently)
|
|
63
|
+
|
|
64
|
+
prefix = StandardLedger.config.notification_namespace
|
|
65
|
+
sql = build_refresh_sql(view_name, concurrently: concurrently)
|
|
66
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
connection.execute(sql)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
StandardLedger::EventEmitter.emit(
|
|
72
|
+
"#{prefix}.projection.failed",
|
|
73
|
+
view: view_name.to_s, concurrently: concurrently, mode: :matview, error: e
|
|
74
|
+
)
|
|
75
|
+
raise
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
79
|
+
StandardLedger::EventEmitter.emit(
|
|
80
|
+
"#{prefix}.projection.refreshed",
|
|
81
|
+
view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def connection
|
|
88
|
+
ActiveRecord::Base.connection
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Bare identifier OR exactly one `schema.view` qualification. Names
|
|
92
|
+
# containing semicolons, quotes, comment markers, whitespace, trailing
|
|
93
|
+
# dots or extra qualification are rejected before any SQL is issued.
|
|
94
|
+
def validate_view_name!(view_name)
|
|
95
|
+
return if view_name.to_s.match?(IDENTIFIER)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"view_name must be a valid SQL identifier; got #{view_name.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `view_name` has already passed `validate_view_name!`. It is left
|
|
102
|
+
# unquoted on purpose: quoting would make the name case-sensitive and
|
|
103
|
+
# change which relation a mixed-case name resolves to.
|
|
104
|
+
def build_refresh_sql(view_name, concurrently:)
|
|
105
|
+
if concurrently
|
|
106
|
+
"REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
|
|
107
|
+
else
|
|
108
|
+
"REFRESH MATERIALIZED VIEW #{view_name}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside a
|
|
113
|
+
# transaction block; catch it at the gem boundary so the failure is
|
|
114
|
+
# attributable instead of a raw `PG::ActiveSqlTransaction`. The plain
|
|
115
|
+
# form is allowed inside transactions, so only the concurrent path is
|
|
116
|
+
# guarded.
|
|
117
|
+
def check_transaction_state!(view_name, concurrently:)
|
|
118
|
+
return unless concurrently
|
|
119
|
+
return unless connection.transaction_open?
|
|
120
|
+
|
|
121
|
+
raise StandardLedger::RefreshInsideTransaction,
|
|
122
|
+
"StandardLedger.refresh!(#{view_name.inspect}) cannot run inside a transaction with " \
|
|
123
|
+
"concurrently: true — Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside " \
|
|
124
|
+
"transaction blocks. Move the call outside the transaction, defer it via " \
|
|
125
|
+
"`connection.add_transaction_record { ... }`, or pass `concurrently: :auto`."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `:auto` decision. A missing view returns false so the plain refresh
|
|
129
|
+
# raises Postgres's own "relation does not exist" error. A failing probe
|
|
130
|
+
# is reported (handled) and degrades to a plain refresh, which is
|
|
131
|
+
# always valid.
|
|
132
|
+
def concurrent_refresh_possible?(view_name)
|
|
133
|
+
return false if connection.transaction_open?
|
|
134
|
+
|
|
135
|
+
row = connection.select_one(
|
|
136
|
+
ActiveRecord::Base.sanitize_sql_array([ CONCURRENCY_PROBE_SQL, view_name.to_s ]),
|
|
137
|
+
"StandardLedger refresh probe"
|
|
138
|
+
)
|
|
139
|
+
return false if row.nil?
|
|
140
|
+
|
|
141
|
+
boolean(row["populated"]) && boolean(row["unique_index"])
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
report_error(e, view: view_name.to_s)
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def boolean(value)
|
|
148
|
+
ActiveModel::Type::Boolean.new.cast(value) == true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def report_error(error, context)
|
|
152
|
+
reporter =
|
|
153
|
+
if defined?(::Rails) && ::Rails.respond_to?(:error)
|
|
154
|
+
::Rails.error
|
|
155
|
+
elsif ActiveSupport.respond_to?(:error_reporter)
|
|
156
|
+
ActiveSupport.error_reporter
|
|
157
|
+
end
|
|
158
|
+
reporter&.report(error, handled: true, severity: :warning, context: context, source: "standard_ledger")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -1,39 +1,32 @@
|
|
|
1
1
|
module StandardLedger
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# delta updates), prefer the block form on `Projector#projects_onto`
|
|
9
|
-
# instead — extracting a class is for non-trivial projectors.
|
|
2
|
+
# Optional base class for host-side projector objects: plain Ruby classes
|
|
3
|
+
# that update an aggregate from ledger entries. The gem does not invoke
|
|
4
|
+
# projectors itself (the declarative `projects_onto` engine was removed in
|
|
5
|
+
# 0.6.0) — the host calls `apply` / `rebuild` from its own operations.
|
|
6
|
+
# Subclassing gives a shared shape and sensible failures for unimplemented
|
|
7
|
+
# methods.
|
|
10
8
|
#
|
|
11
9
|
# @example
|
|
12
|
-
# class
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# order.update!(
|
|
16
|
-
# fulfillable_balance: order.fulfillment_records.group(:key).sum(:amount),
|
|
17
|
-
# fulfillable_status: order.fulfillment_records.group(:key).sum(:amount).values.all?(&:zero?) ? :fulfilled : :pending
|
|
18
|
-
# )
|
|
10
|
+
# class Validations::ProfileProjector < StandardLedger::Projection
|
|
11
|
+
# def apply(profile, validation)
|
|
12
|
+
# profile.increment!(:successful_loans_count) if validation.successful?
|
|
19
13
|
# end
|
|
20
14
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# apply(order, nil)
|
|
15
|
+
# def rebuild(profile)
|
|
16
|
+
# profile.update!(successful_loans_count: profile.validations.successful.count)
|
|
24
17
|
# end
|
|
25
18
|
# end
|
|
19
|
+
#
|
|
20
|
+
# Validations::ProfileProjector.new.apply(profile, validation)
|
|
26
21
|
class Projection
|
|
27
|
-
# Apply a single entry's effect to the target.
|
|
28
|
-
# transactional or async boundary of the chosen mode.
|
|
22
|
+
# Apply a single entry's effect to the target.
|
|
29
23
|
def apply(_target, _entry)
|
|
30
24
|
raise NotImplementedError, "#{self.class}#apply must be implemented"
|
|
31
25
|
end
|
|
32
26
|
|
|
33
|
-
# Recompute the target
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# `StandardLedger::NotRebuildable`.
|
|
27
|
+
# Recompute the target from the full entry log. Projectors that cannot
|
|
28
|
+
# be rebuilt (e.g. delta-only ones) leave this unimplemented and it
|
|
29
|
+
# raises `StandardLedger::NotRebuildable`.
|
|
37
30
|
def rebuild(_target)
|
|
38
31
|
raise NotRebuildable, "#{self.class}#rebuild not implemented; this projector cannot be rebuilt from the entry log"
|
|
39
32
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module StandardLedger
|
|
2
2
|
# The gem's default result type, returned by `StandardLedger.post` and
|
|
3
|
-
# `StandardLedger.
|
|
3
|
+
# `StandardLedger.refresh!` when the host has not configured an adapter
|
|
4
4
|
# for its own Result class.
|
|
5
5
|
#
|
|
6
6
|
# Hosts with their own Result type (e.g. `ApplicationOperation::Result`)
|
|
@@ -16,7 +16,7 @@ module StandardLedger
|
|
|
16
16
|
# @param entry [ActiveRecord::Base, nil] the persisted entry record.
|
|
17
17
|
# @param idempotent [Boolean] true when the create was a no-op because an
|
|
18
18
|
# existing row already satisfied the idempotency key.
|
|
19
|
-
# @param projections [Hash]
|
|
19
|
+
# @param projections [Hash] `{}` for `post`; `{ refreshed: [{ view:, concurrently: }] }` for `refresh!`.
|
|
20
20
|
def initialize(success:, value: nil, errors: [], entry: nil, idempotent: false, projections: {})
|
|
21
21
|
@success = success
|
|
22
22
|
@value = value
|
|
@@ -3,42 +3,20 @@ require "rspec/expectations"
|
|
|
3
3
|
|
|
4
4
|
require "standard_ledger"
|
|
5
5
|
require "standard_ledger/rspec/matchers"
|
|
6
|
-
require "standard_ledger/rspec/helpers"
|
|
7
6
|
|
|
8
7
|
# Opt-in test support for host apps. Hosts add this require to their
|
|
9
8
|
# `spec/rails_helper.rb` (or equivalent):
|
|
10
9
|
#
|
|
11
10
|
# require "standard_ledger/rspec"
|
|
12
11
|
#
|
|
13
|
-
# Loading this file
|
|
12
|
+
# Loading this file defines the `post_ledger_entry` matcher for assertions of
|
|
13
|
+
# the form `expect { ... }.to post_ledger_entry(EntryClass).with(kind: ...)`.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# initializer (`StandardLedger.configure { |c| c.result_adapter = ... }`),
|
|
20
|
-
# and wiping `@config` between examples would silently undo that
|
|
21
|
-
# configuration for every spec. The reset is wired via `RSpec.configure`
|
|
22
|
-
# rather than a custom shared context so it applies to every example group
|
|
23
|
-
# automatically.
|
|
24
|
-
# - Defines the `post_ledger_entry` matcher (see
|
|
25
|
-
# `StandardLedger::RSpec::Matchers`) for assertions of the form
|
|
26
|
-
# `expect { ... }.to post_ledger_entry(EntryClass).with(kind: ...)`.
|
|
27
|
-
# - Includes `StandardLedger::RSpec::Helpers` into every example group so
|
|
28
|
-
# specs can call `with_modes(...)` directly without the module prefix.
|
|
29
|
-
#
|
|
30
|
-
# We intentionally avoid touching subscribers, AR connections, or any host
|
|
31
|
-
# state — the gem only owns its own configuration. Hosts that need additional
|
|
32
|
-
# cleanup wire their own hooks alongside this one.
|
|
15
|
+
# It registers no hooks: the gem keeps no per-example state (the thread-local
|
|
16
|
+
# `with_modes` overrides it used to reset were removed in 0.6.0), and it never
|
|
17
|
+
# touches the host's `Config`, so an initializer-configured `result_adapter`
|
|
18
|
+
# survives across examples.
|
|
33
19
|
module StandardLedger
|
|
34
20
|
module RSpec
|
|
35
21
|
end
|
|
36
22
|
end
|
|
37
|
-
|
|
38
|
-
::RSpec.configure do |config|
|
|
39
|
-
config.before(:each) do
|
|
40
|
-
StandardLedger.reset_mode_overrides!
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
config.include StandardLedger::RSpec::Helpers
|
|
44
|
-
end
|