hecks 1.2.0 → 1.3.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/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -115
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- metadata +23 -2
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../adapters/driven/heki"
|
|
5
|
+
require_relative "../runtime/saga_interpreter"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module Fuzzing
|
|
9
|
+
# A SECOND COMPARISON AXIS — every OTHER check this practice runs is
|
|
10
|
+
# DIFFERENTIAL: Ruby vs the compiled Rust conformance binary, on the
|
|
11
|
+
# same generated sequence (`bin/qa_sweep`'s own `diff_ruby_vs_rust`,
|
|
12
|
+
# `Properties.check`). Differential comparison structurally cannot
|
|
13
|
+
# catch a bug where both engines are wrong the SAME way, or where a
|
|
14
|
+
# single engine is self-inconsistent with nothing to differentially
|
|
15
|
+
# compare it against. This module asks a DIFFERENT question of ONE
|
|
16
|
+
# engine at a time: does it agree with ITSELF?
|
|
17
|
+
#
|
|
18
|
+
# THREE CHECKS, run on the SAME already-generated sequence and its
|
|
19
|
+
# resulting live state — no second fuzzing pass, no re-dispatch
|
|
20
|
+
# through the command layer:
|
|
21
|
+
#
|
|
22
|
+
# 1. `check_rehydration` — does reloading an aggregate from its
|
|
23
|
+
# own durable journal reproduce exactly the state a live dispatch
|
|
24
|
+
# already produced?
|
|
25
|
+
# 2. `check_idempotency` — does replaying that SAME journal a
|
|
26
|
+
# second time change anything? (a variant of #1, but a different
|
|
27
|
+
# failure mode: no leaked state between applications, not just
|
|
28
|
+
# "cold load works once.")
|
|
29
|
+
# 3. `check_value_object_round_trip` — does every value object the
|
|
30
|
+
# sequence actually built survive `to_json` then rebuilt back?
|
|
31
|
+
#
|
|
32
|
+
# THE REHYDRATION PATH, FOUND, NOT GUESSED. hecks is not event-sourced
|
|
33
|
+
# at the aggregate level — there is no `AccountOpened`-shaped log a
|
|
34
|
+
# `CommandInterpreter` folds to rebuild state. What there IS, real and
|
|
35
|
+
# already shipping, is `Ports::Persistence::AppendOnly` (lib/hecks/
|
|
36
|
+
# ports/persistence/append_only.rb): every adapter accepts the same
|
|
37
|
+
# `Entry` stream (`operation`, `id`, the FULL state after that
|
|
38
|
+
# command — not a delta) and answers `#entries`; `#recover!` — "an
|
|
39
|
+
# append is durable before a projection is attempted; replaying the
|
|
40
|
+
# log restores a snapshot/table after a crash in that small window" —
|
|
41
|
+
# is called on EVERY repository this runtime ever builds
|
|
42
|
+
# (`RepositoryFactory.build`'s own `recover: true` default). That IS
|
|
43
|
+
# the production cold-rehydration path. Reusing `#recover!` directly
|
|
44
|
+
# against the LIVE adapter would prove nothing, though: `Fuzzing::
|
|
45
|
+
# Replay` runs against `Adapters::Memory` (`IsolatedBoot`'s own
|
|
46
|
+
# default), and Memory's own `Entry#state` is a SHALLOW `instance.
|
|
47
|
+
# state.dup` — the exact same `Runtime::Value` objects a command
|
|
48
|
+
# produced ride along unchanged, so folding them straight back through
|
|
49
|
+
# Memory's own `#project` is a tautology that can never fail (`Value#
|
|
50
|
+
# for_attribute`'s own `value.is_a?(self) && value.type_name == ...`
|
|
51
|
+
# branch passes an already-typed value straight through, no
|
|
52
|
+
# rebuilding at all).
|
|
53
|
+
#
|
|
54
|
+
# So `cold_read`, below, feeds the SAME entries through `Adapters::
|
|
55
|
+
# Heki` instead — a REAL, already-shipped, disk-backed adapter
|
|
56
|
+
# (examples/banking's own `persisted_by("Heki")`), in a throwaway
|
|
57
|
+
# directory. Writing forces every value through `JSON.generate`
|
|
58
|
+
# (Heki's own journal line, its own compressed snapshot); reading
|
|
59
|
+
# back through a FRESH `Heki` instance (unmemoized `@store`) forces
|
|
60
|
+
# `read_snapshot`/`replay_journal` — real `JSON.parse`, real
|
|
61
|
+
# `Zlib::Inflate`, real bytes off a real filesystem — which is what
|
|
62
|
+
# makes `Instance.hydrate_with_defaults` → `Value.hydrate` →
|
|
63
|
+
# `Value.for_attribute` actually REBUILD every value object from raw
|
|
64
|
+
# data via `Value.build`, the same coercion/validation path a real
|
|
65
|
+
# restart takes, rather than pass the live object through unchanged.
|
|
66
|
+
# This is the exact mechanism `AppendOnly#recover!` names in its own
|
|
67
|
+
# comment ("restores a snapshot/table after a crash"), just exercised
|
|
68
|
+
# against the one adapter whose own `#entries` actually forces the
|
|
69
|
+
# JSON boundary Memory's does not.
|
|
70
|
+
module SelfConsistency
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# THE WHOLE PASS — called once, with the runtime STILL LIVE (inside
|
|
74
|
+
# `Replay.call`'s own `IsolatedBoot.call` block, before the tmp
|
|
75
|
+
# directory and its adapters go out of scope) and the `history`
|
|
76
|
+
# `Replay.call` is about to return. Nothing here boots a second
|
|
77
|
+
# runtime or dispatches a single command; every check below reads
|
|
78
|
+
# data this ONE replay already produced. Just the three checks
|
|
79
|
+
# below, run and collected — kept as three independently callable
|
|
80
|
+
# methods (not fused into one shared fold) so a spec proving one
|
|
81
|
+
# check can fire never has to reason about the other two.
|
|
82
|
+
def check(runtime, history)
|
|
83
|
+
{ rehydration: check_rehydration(runtime), idempotency: check_idempotency(runtime),
|
|
84
|
+
value_object_round_trip: check_value_object_round_trip(history),
|
|
85
|
+
saga_rehydration: check_saga_rehydration(runtime, history),
|
|
86
|
+
saga_redelivery_idempotency: check_saga_idempotency(runtime, history) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# CHECK 1 — REHYDRATE-FROM-JOURNAL == LIVE STATE.
|
|
90
|
+
def check_rehydration(runtime)
|
|
91
|
+
each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
|
|
92
|
+
live = snapshot(repository)
|
|
93
|
+
Dir.mktmpdir("hecks-self-consistency") do |tmp|
|
|
94
|
+
writer = Adapters::Heki.new(aggregate: aggregate, root: tmp)
|
|
95
|
+
rehydrated = fold!(writer, tmp, aggregate, entries)
|
|
96
|
+
next if rehydrated == live
|
|
97
|
+
|
|
98
|
+
{ field: "rehydration", domain: domain_name, aggregate: aggregate.hecks_name,
|
|
99
|
+
live: live, rehydrated: rehydrated }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# CHECK 2 — REPLAY IDEMPOTENCY: folding the SAME entries into the
|
|
105
|
+
# SAME durable store a second time must change nothing. A variant
|
|
106
|
+
# of check 1, worth stating separately — this catches a
|
|
107
|
+
# replay-specific bug (leaked state between applications, a
|
|
108
|
+
# double-applied effect) that a single, one-shot cold read could
|
|
109
|
+
# never see, even one that already agrees with live state.
|
|
110
|
+
def check_idempotency(runtime)
|
|
111
|
+
each_touched_repository(runtime).filter_map do |domain_name, aggregate, repository, entries|
|
|
112
|
+
Dir.mktmpdir("hecks-self-consistency") do |tmp|
|
|
113
|
+
writer = Adapters::Heki.new(aggregate: aggregate, root: tmp)
|
|
114
|
+
once = fold!(writer, tmp, aggregate, entries)
|
|
115
|
+
twice = fold!(writer, tmp, aggregate, entries)
|
|
116
|
+
next if once == twice
|
|
117
|
+
|
|
118
|
+
{ field: "idempotency", domain: domain_name, aggregate: aggregate.hecks_name,
|
|
119
|
+
once: once, twice: twice }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# CHECK 3 — every `Runtime::Value` the sequence actually built
|
|
125
|
+
# (walked out of the replay's own `instances`/`events`/`queries`,
|
|
126
|
+
# never a hand-picked example), round-tripped through the REAL
|
|
127
|
+
# serialize/deserialize pair: `Value#to_json` (JSON.generate(to_h),
|
|
128
|
+
# value.rb) out, `Value.build` (value/coercion.rb — the same
|
|
129
|
+
# constructor a command argument's own raw JSON goes through) back
|
|
130
|
+
# in. There is no class-level `VO.from_json` in this codebase (that
|
|
131
|
+
# spelling is Rust's — rust/src/exemplar/json.rs's generated
|
|
132
|
+
# `from_json` per closed set/value object); `Value.build` is the
|
|
133
|
+
# actual Ruby door a raw, untyped Hash becomes a validated,
|
|
134
|
+
# admitted, invariant-checked value object through.
|
|
135
|
+
# `aggregate:` THREADED ALONGSIDE EVERY VALUE FOUND, NOT DROPPED —
|
|
136
|
+
# `Value.build(value_object, fields, aggregate)`'s third argument is
|
|
137
|
+
# what lets `normalize_composite_fields` resolve a NESTED composite
|
|
138
|
+
# field's own type by name (`value_object_for(aggregate, type)`).
|
|
139
|
+
# Building with `aggregate: nil` (this method's first version, live-
|
|
140
|
+
# tested against `examples/pizzas` while this was being written)
|
|
141
|
+
# silently skips that step entirely — `Pizza`'s own `price_cents`/
|
|
142
|
+
# `size` fields round-tripped back as bare, STRING-keyed Hashes
|
|
143
|
+
# instead of rebuilt `Money`/`PizzaSize` value objects, a false
|
|
144
|
+
# POSITIVE this check would have reported as a real bug on every
|
|
145
|
+
# single sweep. Resolved from `history[:instances]`' own key
|
|
146
|
+
# (`"Domain::Aggregate#id"`, `Replay#snapshot_instances`) and
|
|
147
|
+
# `history[:events]`' own `event[:aggregate]` (`"Domain::Aggregate"`,
|
|
148
|
+
# domain-qualified) against `history[:bluebooks]` — the exact same
|
|
149
|
+
# loaded chapter map every other replay-time check already reads
|
|
150
|
+
# off `history` rather than a second lookup. `history[:queries]`'
|
|
151
|
+
# own rows have no single owning aggregate reliably named on the
|
|
152
|
+
# entry itself (a cross-aggregate read model, a `for_each` target),
|
|
153
|
+
# so they are left OUT of this walk rather than risk the same false
|
|
154
|
+
# positive `nil` already produced once — `instances` and `events`
|
|
155
|
+
# alone already reach every value object a generated sequence
|
|
156
|
+
# actually persisted or announced.
|
|
157
|
+
def check_value_object_round_trip(history)
|
|
158
|
+
bluebooks = history[:bluebooks] || {}
|
|
159
|
+
seen = {}.compare_by_identity
|
|
160
|
+
found = []
|
|
161
|
+
|
|
162
|
+
history[:instances].each do |key, state|
|
|
163
|
+
domain_name, aggregate_name = key.to_s.split("#", 2).first.to_s.split("::", 2)
|
|
164
|
+
aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
|
|
165
|
+
walk_value_objects(state, found, seen, aggregate)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
history[:events].each do |event|
|
|
169
|
+
domain_name, aggregate_name = event[:aggregate].to_s.split("::", 2)
|
|
170
|
+
aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
|
|
171
|
+
walk_value_objects(event[:payload], found, seen, aggregate)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
found.filter_map do |value, aggregate|
|
|
175
|
+
begin
|
|
176
|
+
rebuilt = Runtime::Value.build(value.value_object, JSON.parse(value.to_json), aggregate)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
next { field: "value_object_round_trip", type: value.type_name, original: value.to_h,
|
|
179
|
+
error: "#{e.class}: #{e.message}" }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
next if rebuilt == value
|
|
183
|
+
|
|
184
|
+
{ field: "value_object_round_trip", type: value.type_name, original: value.to_h,
|
|
185
|
+
rehydrated: rebuilt.to_h }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# CHECK 4 — SAGA COLD-REHYDRATION (ANGLE-10). Checks 1/2 above cold-
|
|
190
|
+
# read an AGGREGATE's own journal through Heki; nothing in this file
|
|
191
|
+
# ever exercised the OTHER durable store `SagaInterpreter#checkpoint`
|
|
192
|
+
# writes through — `Ports::Persistence::NullSagaStore`'s own header
|
|
193
|
+
# calls Heki's `SagaStore` (`adapters/driven/heki/saga_store.rb`) the
|
|
194
|
+
# OPTIONAL saga-persistence capability, and it is real and already
|
|
195
|
+
# shipping, just never fuzzed: `Registry#rehydrate_sagas!` — the
|
|
196
|
+
# production "process just restarted" path — folds exactly what
|
|
197
|
+
# `each_saga` yields back into `@saga_instances`, and until now
|
|
198
|
+
# nothing ever proved that round trip faithful for a sequence this
|
|
199
|
+
# practice actually generated. `BUG#6`/`#9`/`#10` all came out of
|
|
200
|
+
# this exact interpreter, which is why this checks it specifically
|
|
201
|
+
# rather than folding it into checks 1/2's own aggregate walk.
|
|
202
|
+
#
|
|
203
|
+
# `history[:saga_instances]` (`replay.rb`'s own `saga_instances`
|
|
204
|
+
# local, built once at the very end of a replay) is the SAME
|
|
205
|
+
# materialized `{pm_name => {correlation => {state:, memory:}}}`
|
|
206
|
+
# shape `SagaInterpreter#checkpoint` itself hands a real adapter —
|
|
207
|
+
# read from `history`, not re-derived from the (by-now-live, already
|
|
208
|
+
# mutated by whatever `check_saga_idempotency` ran first, see that
|
|
209
|
+
# method's own header) `runtime.registry.saga_instances`. Written
|
|
210
|
+
# through a REAL `Adapters::Heki` (a throwaway tmpdir, one per
|
|
211
|
+
# process manager so two process managers with correlations that
|
|
212
|
+
# happen to collide as strings never share a store), read back
|
|
213
|
+
# through a FRESH instance (unmemoized `@store`/`@saga_store`, same
|
|
214
|
+
# reason `fold!` above uses one) — forcing the identical
|
|
215
|
+
# `JSON.generate`/`JSON.parse` boundary a real crash-then-restart
|
|
216
|
+
# takes, not a live-object pass-through.
|
|
217
|
+
#
|
|
218
|
+
# ONE FINDING PER (domain, process manager) — every correlation this
|
|
219
|
+
# process manager's own `history[:saga_instances]` entry holds,
|
|
220
|
+
# compared as a whole Hash — the same aggregate-granularity (not
|
|
221
|
+
# per-record) `check_rehydration` already reports at.
|
|
222
|
+
#
|
|
223
|
+
# `completed_compensations` is DELIBERATELY OUT OF SCOPE — `history[
|
|
224
|
+
# :saga_instances]` never captures it (`replay.rb`'s own comment:
|
|
225
|
+
# only `state`/`memory` are threaded through, since a saga's
|
|
226
|
+
# in-flight compensation ledger is a fact about a leg still running,
|
|
227
|
+
# not the settled snapshot this history exists to describe), so
|
|
228
|
+
# there is no ground truth to compare it against here. Written as an
|
|
229
|
+
# empty array on the way in and never read back on the way out.
|
|
230
|
+
def check_saga_rehydration(runtime, history)
|
|
231
|
+
saga_instances = history[:saga_instances] || {}
|
|
232
|
+
each_domain_process_manager(runtime).filter_map do |domain_name, process_manager|
|
|
233
|
+
persisted = saga_instances[process_manager.name]
|
|
234
|
+
next if persisted.nil? || persisted.empty?
|
|
235
|
+
|
|
236
|
+
anchor = runtime.registry.bluebook(domain_name).aggregates.first
|
|
237
|
+
next unless anchor
|
|
238
|
+
|
|
239
|
+
Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
|
|
240
|
+
writer = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
241
|
+
persisted.each do |correlation, saga|
|
|
242
|
+
writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
|
|
243
|
+
state: saga[:state], memory: saga[:memory], completed_compensations: [])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
live = normalize_saga_rows(persisted)
|
|
247
|
+
rehydrated = cold_read_saga_rows(anchor, tmp, domain_name)
|
|
248
|
+
next if rehydrated == live
|
|
249
|
+
|
|
250
|
+
{ field: "saga_rehydration", domain: domain_name, process_manager: process_manager.name,
|
|
251
|
+
live: live, rehydrated: rehydrated }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# CHECK 5 — REDELIVERY IDEMPOTENCY OF THE CHECKPOINT-THEN-LOAD PATH.
|
|
257
|
+
# `check_saga_rehydration` above proves cold-reading a checkpoint
|
|
258
|
+
# reproduces the same DATA; this proves the OTHER half of a real
|
|
259
|
+
# crash/restart — a message an at-least-once delivery mechanism (an
|
|
260
|
+
# outbox redrive, a queue redelivery) hands the rehydrated saga a
|
|
261
|
+
# SECOND time — does not silently re-advance it. There is no flag
|
|
262
|
+
# for this in production (`SagaInterpreter#unwind`'s own comment:
|
|
263
|
+
# "the check is the guard") — the (event, current state) lookup
|
|
264
|
+
# `handler_for` performs is the ENTIRE mechanism, and it has never
|
|
265
|
+
# been exercised against a state this practice loaded from cold
|
|
266
|
+
# storage rather than one still sitting in a live process's memory.
|
|
267
|
+
#
|
|
268
|
+
# ONE (PROCESS MANAGER, CORRELATION) TESTED, using a fresh
|
|
269
|
+
# `Runtime::SagaInterpreter` sharing `runtime`'s own `registry` and
|
|
270
|
+
# `door: runtime` — the identical two objects the DISPATCHER'S own
|
|
271
|
+
# `@sagas` was built from (`dispatcher.rb`'s own `SagaInterpreter.
|
|
272
|
+
# new(registry, door: self)`) — not a hand-rolled re-implementation
|
|
273
|
+
# of `advance_saga`'s own state-guard. `only: process_manager` scopes
|
|
274
|
+
# the redelivery to exactly the one procedure under test, the same
|
|
275
|
+
# keyword the outbox relay already uses to run one consumer alone
|
|
276
|
+
# (`Runtime::Outbox::Relay#run_consumer`).
|
|
277
|
+
#
|
|
278
|
+
# WHICH EVENT TO REDELIVER — `runtime.registry.saga_log`'s own last
|
|
279
|
+
# `advanced: true` row for this (process manager, correlation) names
|
|
280
|
+
# the event BY NAME ONLY; the REAL `Runtime::Event` object (payload,
|
|
281
|
+
# aggregate, id, `correlation` — everything `saga_correlation`/
|
|
282
|
+
# `dispatch_args` actually read) lives in `runtime.events`, still
|
|
283
|
+
# live for exactly this reason (this file's own header: "runtime IS
|
|
284
|
+
# STILL LIVE HERE"). Matched back by NAME plus `saga_correlation`
|
|
285
|
+
# itself (`Runtime::SagaInterpreter::Correlation`, `private`) —
|
|
286
|
+
# reused via `send` rather than reproduced, because reproducing its
|
|
287
|
+
# three-tier fallback (a dotted payload field, a stamped passthrough,
|
|
288
|
+
# a self-identifying `event.id`) here would be exactly the
|
|
289
|
+
# hand-rolled approximation this file was told not to build. A
|
|
290
|
+
# `:refused`-driven (compensating) transition is skipped outright —
|
|
291
|
+
# its own `saga_log` row's `on:` is the synthetic `REFUSED` trigger
|
|
292
|
+
# name, never a real domain event, so there is nothing to redeliver.
|
|
293
|
+
#
|
|
294
|
+
# SIMULATING "JUST RESTARTED" — the live registry's own in-memory
|
|
295
|
+
# `saga_instances[pm][correlation]` slot is overwritten, IN PLACE,
|
|
296
|
+
# with whatever a cold Heki read of the SAME checkpoint answers
|
|
297
|
+
# (exactly what `Registry#rehydrate_sagas!` does for real on every
|
|
298
|
+
# boot), the redelivery is driven through the real interpreter, and
|
|
299
|
+
# the slot is put back — `ensure`d — once this correlation's own
|
|
300
|
+
# check is done. Safe ONLY because `check`/`Replay.call` run this,
|
|
301
|
+
# synchronously, single-threaded, as the very last thing before
|
|
302
|
+
# `runtime` and its whole tmp directory go out of scope for good;
|
|
303
|
+
# nothing downstream of this method ever reads the LIVE registry
|
|
304
|
+
# again (`check_saga_rehydration`, `check_rehydration`, `check_
|
|
305
|
+
# idempotency`, `check_value_object_round_trip` all read `history`'s
|
|
306
|
+
# own frozen snapshot instead, never `runtime.registry` — so calling
|
|
307
|
+
# order relative to this method's own mutation doesn't matter).
|
|
308
|
+
#
|
|
309
|
+
# THE ASSERTION IS ABOUT `state`/`memory`, NOT "did a dispatch fire"
|
|
310
|
+
# — a leg whose own `from:`/`to:` are the SAME state (every existing
|
|
311
|
+
# saga's own starts_on self-transition, `waybill.bluebook`'s own leg
|
|
312
|
+
# 1/2) is EXPECTED to re-run on redelivery with no visible state
|
|
313
|
+
# change at all; that is a property of the declared handler graph,
|
|
314
|
+
# not a rehydration defect, and asserting against it here would
|
|
315
|
+
# manufacture a false positive on every saga this corpus has. A
|
|
316
|
+
# correlation whose current state has no declared handler at all for
|
|
317
|
+
# the redelivered event name (the ordinary, expected case once a
|
|
318
|
+
# saga has moved past the leg that produced its own current
|
|
319
|
+
# checkpoint) is exactly what this proves stays put.
|
|
320
|
+
def check_saga_idempotency(runtime, history)
|
|
321
|
+
saga_instances = history[:saga_instances] || {}
|
|
322
|
+
interpreter = Runtime::SagaInterpreter.new(runtime.registry, door: runtime)
|
|
323
|
+
|
|
324
|
+
each_domain_process_manager(runtime).flat_map do |domain_name, process_manager|
|
|
325
|
+
persisted = saga_instances[process_manager.name]
|
|
326
|
+
next [] if persisted.nil? || persisted.empty?
|
|
327
|
+
|
|
328
|
+
anchor = runtime.registry.bluebook(domain_name).aggregates.first
|
|
329
|
+
next [] unless anchor
|
|
330
|
+
|
|
331
|
+
persisted.filter_map do |correlation, saga|
|
|
332
|
+
redelivery = last_advancing_event(runtime, interpreter, process_manager, correlation)
|
|
333
|
+
next unless redelivery
|
|
334
|
+
|
|
335
|
+
check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
|
|
336
|
+
correlation, saga, redelivery)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# ── Rust-side self-consistency ───────────────────────────────────
|
|
342
|
+
#
|
|
343
|
+
# THE COMPILED BINARY'S OWN REHYDRATION DOOR, ALREADY SHIPPING —
|
|
344
|
+
# `kernel/cli.rs`'s `run` accepts an OPTIONAL top-level `"seed"` key
|
|
345
|
+
# ("the exact 'Domain::Aggregate#id' -> state shape THIS run's own
|
|
346
|
+
# 'instances' output already produces... lets a HOST seed prior
|
|
347
|
+
# state back in instead of replaying `steps` from scratch every
|
|
348
|
+
# invocation"). `Store::from_seed`/`Store::instances` are that
|
|
349
|
+
# mechanism's own two halves — `rust/host` (docs/implemented/
|
|
350
|
+
# decisions/0012) depends on them being true inverses for real,
|
|
351
|
+
# today. `rust_seed_round_trip` exercises exactly that: a SECOND,
|
|
352
|
+
# independent invocation of the SAME binary, `"steps": []` (nothing
|
|
353
|
+
# new dispatched — this is rehydration, not re-dispatch), seeded
|
|
354
|
+
# with whatever `"instances"` a PRIOR invocation already produced.
|
|
355
|
+
# `differ` (unused, kept in the signature — see below) is a
|
|
356
|
+
# `RustConformanceHelpers`-including instance (`bin/qa_sweep`'s own
|
|
357
|
+
# `Differ`), the same argument `check_rust_rehydration`/`check_
|
|
358
|
+
# rust_idempotency` already take from their own callers; kept here
|
|
359
|
+
# rather than dropped from all three signatures at once so a
|
|
360
|
+
# future differential-style reduction has a door already open,
|
|
361
|
+
# without this module ever `require`ing `spec/support/` itself.
|
|
362
|
+
# NEITHER `strip_emitted_flags!` NOR ANY OTHER DIFFERENTIAL-ONLY
|
|
363
|
+
# REDUCTION RUNS HERE — that reduction exists so a Rust-only
|
|
364
|
+
# bookkeeping field (`emitted_<event>`, docs/decisions/0049) never
|
|
365
|
+
# counts against Ruby, which has no equivalent field to agree with
|
|
366
|
+
# at all (`RustConformanceHelpers#strip_emitted_flags!`'s own
|
|
367
|
+
# comment). This check has no Ruby side to spare — it is asking the
|
|
368
|
+
# Rust binary whether it agrees with ITSELF, so `emitted_*` fields
|
|
369
|
+
# are exactly as real a fact to compare as any other. Stripping them
|
|
370
|
+
# here (an earlier version of this method did) silently deleted
|
|
371
|
+
# them from the RETURNED seed-round-trip result while leaving them
|
|
372
|
+
# present on the ORIGINAL `seed_instances` a caller passes in — an
|
|
373
|
+
# asymmetric comparison that reported EVERY record carrying one as
|
|
374
|
+
# a rehydration divergence, unconditionally, on every domain that
|
|
375
|
+
# has one at all. Found live against `examples/banking`
|
|
376
|
+
# (`Banking::Account`'s own `corrects` reaction) while this
|
|
377
|
+
# integration was being written, by comparing this method's own
|
|
378
|
+
# answer against the compiled binary's RAW stdout for the identical
|
|
379
|
+
# seed call: the raw round trip preserved `emitted_fee_applied`
|
|
380
|
+
# correctly; only THIS method's own stripping dropped it. `spec/
|
|
381
|
+
# self_consistency_rust_spec.rb`'s own "banking" example pins the
|
|
382
|
+
# regression against a real domain going forward.
|
|
383
|
+
def rust_seed_round_trip(binary, _differ, seed_instances)
|
|
384
|
+
stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => [], "seed" => seed_instances }))
|
|
385
|
+
return { "__self_consistency_error__" => "rust binary exited #{status.exitstatus}: #{stdout}" } \
|
|
386
|
+
unless status.success?
|
|
387
|
+
|
|
388
|
+
parsed = JSON.parse(stdout)
|
|
389
|
+
return { "__self_consistency_error__" => parsed["error"] } if parsed["error"]
|
|
390
|
+
|
|
391
|
+
parsed["instances"]
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# CHECK 1, RUST SIDE — seeding a fresh invocation with a PRIOR
|
|
395
|
+
# invocation's own live `"instances"` must reproduce that same
|
|
396
|
+
# state, unchanged. `live_instances` is `rust_output["instances"]`
|
|
397
|
+
# — the exact same value `bin/qa_sweep`'s own differential compare
|
|
398
|
+
# already diffed against Ruby, reused here rather than re-derived.
|
|
399
|
+
def check_rust_rehydration(binary, differ, live_instances)
|
|
400
|
+
rehydrated = rust_seed_round_trip(binary, differ, live_instances)
|
|
401
|
+
return [] if rehydrated == live_instances
|
|
402
|
+
|
|
403
|
+
[{ field: "rust_rehydration", live: live_instances, rehydrated: rehydrated }]
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# CHECK 2, RUST SIDE — seeding with what a first seed round trip
|
|
407
|
+
# already produced, a SECOND time, must not drift any further. The
|
|
408
|
+
# same "replay it again, byte for byte" claim `check_idempotency`
|
|
409
|
+
# proves for Ruby, aimed at the one rehydration door this compiled
|
|
410
|
+
# binary actually has.
|
|
411
|
+
def check_rust_idempotency(binary, differ, live_instances)
|
|
412
|
+
once = rust_seed_round_trip(binary, differ, live_instances)
|
|
413
|
+
twice = rust_seed_round_trip(binary, differ, once)
|
|
414
|
+
return [] if once == twice
|
|
415
|
+
|
|
416
|
+
[{ field: "rust_idempotency", once: once, twice: twice }]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# ── shared plumbing ─────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
# EVERY [domain, aggregate] PAIR THIS SEQUENCE ACTUALLY WROTE TO —
|
|
422
|
+
# an aggregate with an empty `#entries` never had anything dispatch
|
|
423
|
+
# against it this run, so there is nothing to rehydrate and no
|
|
424
|
+
# finding a "clean, nothing touched" report would mean anything
|
|
425
|
+
# for. Mirrors `Replay#snapshot_instances`' own
|
|
426
|
+
# `bluebooks.each { aggregates.each { repository(...) } }` walk.
|
|
427
|
+
def each_touched_repository(runtime)
|
|
428
|
+
found = []
|
|
429
|
+
runtime.registry.bluebooks.each do |domain_name, bluebook|
|
|
430
|
+
bluebook.aggregates.each do |aggregate|
|
|
431
|
+
repository = runtime.registry.repository(domain_name, aggregate)
|
|
432
|
+
entries = repository.entries
|
|
433
|
+
next if entries.empty?
|
|
434
|
+
|
|
435
|
+
found << [domain_name, aggregate, repository, entries]
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
found
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def snapshot(repository)
|
|
442
|
+
repository.all.to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# EVERY [domain, process manager] PAIR ANY LOADED BLUEBOOK DECLARES —
|
|
446
|
+
# regardless of whether this replay's own `history[:saga_instances]`
|
|
447
|
+
# ever touched it (mirrors `each_touched_repository`'s own walk one
|
|
448
|
+
# level up; the "did anything actually persist" filter lives in each
|
|
449
|
+
# check's own caller, same as that method's `entries.empty?` guard).
|
|
450
|
+
# An empty return here IS the "domain declares no process manager"
|
|
451
|
+
# skip `check_saga_rehydration`/`check_saga_idempotency` both need —
|
|
452
|
+
# `filter_map`/`flat_map` over an empty Array already answers `[]`,
|
|
453
|
+
# identical to "ran and found nothing," which is deliberate: neither
|
|
454
|
+
# check has a positive "passed" artifact to report either way (see
|
|
455
|
+
# this file's own header on why silence is never a claimed pass).
|
|
456
|
+
def each_domain_process_manager(runtime)
|
|
457
|
+
found = []
|
|
458
|
+
runtime.registry.bluebooks.each do |domain_name, bluebook|
|
|
459
|
+
bluebook.process_managers.each { |pm| found << [domain_name, pm] }
|
|
460
|
+
end
|
|
461
|
+
found
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# THE LIVE-SIDE GROUND TRUTH, key-shape-normalized (see `deep_
|
|
465
|
+
# stringify_keys`'s own comment) so it compares fairly against a
|
|
466
|
+
# real Heki round trip's own shallow-symbolize convention.
|
|
467
|
+
def normalize_saga_rows(persisted)
|
|
468
|
+
persisted.each_with_object({}) do |(correlation, saga), rows|
|
|
469
|
+
rows[correlation.to_s] = { state: saga[:state], memory: deep_stringify_keys(saga[:memory]) }
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# A FRESH `Adapters::Heki` AT THE SAME `tmp`/`domain` — unmemoized
|
|
474
|
+
# `@store`/`@saga_store`, so `#each_saga` is forced back through
|
|
475
|
+
# `read_snapshot`/`replay_journal`, real bytes off real disk, not
|
|
476
|
+
# whatever the writer that just wrote them still holds in its own
|
|
477
|
+
# process memory (the same reason `fold!`, above, opens a second
|
|
478
|
+
# `Adapters::Heki` instance rather than reading its own writer back).
|
|
479
|
+
def cold_read_saga_rows(anchor, tmp, domain_name)
|
|
480
|
+
reader = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
481
|
+
reader.each_saga.with_object({}) do |(_pm, correlation, state, memory, _completed), rows|
|
|
482
|
+
rows[correlation] = { state: state, memory: deep_stringify_keys(memory) }
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# `SagaStore#each_saga` ONLY EVER SYMBOLIZES `memory`'S OWN TOP-LEVEL
|
|
487
|
+
# KEYS (`heki/saga_store.rb`'s own `each_saga`, one level deep) —
|
|
488
|
+
# `Registry::SagaPersistence#warn_stalled_saga` already documents
|
|
489
|
+
# this exact asymmetry for the one reserved key production code
|
|
490
|
+
# cares about (`SAGA_PENDING_DISPATCH_KEY`). A NESTED composite
|
|
491
|
+
# memory field (any saga whose starting event carries a value
|
|
492
|
+
# object, which is most of them — `waybill.bluebook`'s own
|
|
493
|
+
# `ConsignmentRequested` alone has three) comes back with STRING
|
|
494
|
+
# keys at every level BELOW the top, while `history[:saga_instances]`
|
|
495
|
+
# 's own `Runtime::Value.materialize` call produces SYMBOL keys
|
|
496
|
+
# throughout. That asymmetry is Heki's own documented, accepted
|
|
497
|
+
# storage convention — an "opaque, adapter-agnostic JSON blob"
|
|
498
|
+
# (`SagaInterpreter#checkpoint`'s own comment), never a typed
|
|
499
|
+
# rebuild the way an AGGREGATE's own composite fields get on cold
|
|
500
|
+
# read (this file's own header: there is no VO schema to rebuild
|
|
501
|
+
# against for a saga's memory blob at all) — not a rehydration
|
|
502
|
+
# defect this check exists to find. Recursively re-stringifying
|
|
503
|
+
# BOTH sides before comparing is what tells that KNOWN, accepted
|
|
504
|
+
# shape difference apart from an ACTUAL data-loss bug (a dropped
|
|
505
|
+
# key, a changed value, a missing field) — exactly the kind (b)'s
|
|
506
|
+
# own seeded fixture in `spec/fuzzing/self_consistency_saga_spec.rb`
|
|
507
|
+
# proves this still catches.
|
|
508
|
+
def deep_stringify_keys(value)
|
|
509
|
+
case value
|
|
510
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
|
|
511
|
+
when Array then value.map { |item| deep_stringify_keys(item) }
|
|
512
|
+
else value
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# THE REAL, ALREADY-ANNOUNCED EVENT this correlation's CURRENT
|
|
517
|
+
# checkpoint came from — walked back out of `runtime.registry.
|
|
518
|
+
# saga_log`'s own `advanced: true` rows (newest first), skipping the
|
|
519
|
+
# synthetic `REFUSED` trigger (`Runtime::SagaInterpreter::REFUSED`
|
|
520
|
+
# — a compensating transition's own log entry names that, never a
|
|
521
|
+
# real domain event; there is nothing in `runtime.events` to
|
|
522
|
+
# redeliver for it). `nil` when no real advancing event exists at
|
|
523
|
+
# all (a correlation only ever `begin_saga`'d, never advanced) —
|
|
524
|
+
# `check_saga_idempotency`'s own caller skips a `nil` outright,
|
|
525
|
+
# exactly like `each_touched_repository`'s own "nothing to check"
|
|
526
|
+
# skip one level up.
|
|
527
|
+
#
|
|
528
|
+
# `interpreter.send(:saga_correlation, ...)` — `Correlation` is
|
|
529
|
+
# `private`, and reproducing its own three-tier fallback (a dotted
|
|
530
|
+
# payload field, a stamped passthrough, a self-identifying
|
|
531
|
+
# `event.id`) here rather than reusing it would be exactly the
|
|
532
|
+
# "hand-rolled approximation" this check exists to avoid; `send` on
|
|
533
|
+
# an interpreter sharing this SAME `runtime`'s own registry is the
|
|
534
|
+
# real thing, not a copy of it.
|
|
535
|
+
def last_advancing_event(runtime, interpreter, process_manager, correlation)
|
|
536
|
+
entry = runtime.registry.saga_log.reverse_each.find do |row|
|
|
537
|
+
row[:process_manager] == process_manager.name && row[:instance] == correlation &&
|
|
538
|
+
row[:advanced] && row[:on] != Runtime::SagaInterpreter::REFUSED
|
|
539
|
+
end
|
|
540
|
+
return nil unless entry
|
|
541
|
+
|
|
542
|
+
runtime.events.reverse_each.find do |event|
|
|
543
|
+
event.name == entry[:on] && interpreter.send(:saga_correlation, process_manager, event) == correlation
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# ONE (process manager, correlation)'s OWN redelivery check — pulled
|
|
548
|
+
# out of `check_saga_idempotency` itself so that method's own
|
|
549
|
+
# `flat_map`/`filter_map` walk stays readable; every local this
|
|
550
|
+
# shares with its caller (`interpreter`, `anchor`) is passed in
|
|
551
|
+
# rather than re-derived.
|
|
552
|
+
def check_one_saga_redelivery(runtime, interpreter, domain_name, process_manager, anchor,
|
|
553
|
+
correlation, saga, redelivery)
|
|
554
|
+
Dir.mktmpdir("hecks-self-consistency-saga") do |tmp|
|
|
555
|
+
writer = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
556
|
+
writer.save_saga(process_manager: process_manager.name, correlation: correlation.to_s,
|
|
557
|
+
state: saga[:state], memory: saga[:memory], completed_compensations: [])
|
|
558
|
+
|
|
559
|
+
rehydrated = Adapters::Heki.new(aggregate: anchor, root: tmp, settings: { domain: domain_name })
|
|
560
|
+
.each_saga.find { |_pm, corr, *| corr == correlation.to_s }
|
|
561
|
+
next unless rehydrated
|
|
562
|
+
|
|
563
|
+
_pm, _corr, state, memory, compensations = rehydrated
|
|
564
|
+
before = { state: state, memory: deep_stringify_keys(memory) }
|
|
565
|
+
|
|
566
|
+
saga_instances = runtime.registry.saga_instances[process_manager.name]
|
|
567
|
+
original = saga_instances[correlation]
|
|
568
|
+
begin
|
|
569
|
+
saga_instances[correlation] = { state: state, memory: memory, completed_compensations: compensations || [] }
|
|
570
|
+
interpreter.advance(redelivery, domain_name, only: process_manager)
|
|
571
|
+
|
|
572
|
+
after = saga_instances[correlation]
|
|
573
|
+
after_shape = after && { state: after[:state], memory: deep_stringify_keys(after[:memory]) }
|
|
574
|
+
next if after_shape == before
|
|
575
|
+
|
|
576
|
+
{ field: "saga_redelivery_idempotency", domain: domain_name, process_manager: process_manager.name,
|
|
577
|
+
correlation: correlation, on: redelivery.name, before: before, after: after_shape }
|
|
578
|
+
ensure
|
|
579
|
+
if original
|
|
580
|
+
saga_instances[correlation] = original
|
|
581
|
+
else
|
|
582
|
+
saga_instances.delete(correlation)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# ONE FOLD OF `entries` INTO `writer` (a real, already-open `Heki`
|
|
589
|
+
# adapter at `tmp`), THEN A COLD READ BACK through a BRAND NEW `Heki`
|
|
590
|
+
# instance at the SAME path — `@store` on a fresh instance starts
|
|
591
|
+
# unmemoized, so `#all` below is forced through `#read` →
|
|
592
|
+
# `#read_snapshot`/`#replay_journal`, real disk bytes, not whatever
|
|
593
|
+
# `writer` still holds cached in its own process memory. Called
|
|
594
|
+
# TWICE in a row against the SAME `writer` (see `check`/
|
|
595
|
+
# `check_idempotency` above) is exactly "replay the same journal a
|
|
596
|
+
# second time" — `writer` already holds everything the first fold
|
|
597
|
+
# wrote, so a second fold re-applies the identical operations on
|
|
598
|
+
# top, and the two cold reads either agree (idempotent) or don't.
|
|
599
|
+
def fold!(writer, tmp, aggregate, entries)
|
|
600
|
+
entries.each do |entry|
|
|
601
|
+
writer.append(entry)
|
|
602
|
+
writer.project(entry)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
Adapters::Heki.new(aggregate: aggregate, root: tmp).all
|
|
606
|
+
.to_h { |record| [record.id.to_s, Runtime::Value.materialize(record.state)] }
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# RECURSES THROUGH A `Value`'S OWN FIELDS VIA `#[]`, NOT `#to_h` —
|
|
610
|
+
# `#to_h` already materializes every field (`Value.materialize`),
|
|
611
|
+
# which would hide a nested `Value` from this walk before it ever
|
|
612
|
+
# got here. In practice a value object's OWN composite fields
|
|
613
|
+
# (`Coercion#normalize_composite_fields`) are validated but stored
|
|
614
|
+
# as plain, already-materialized Hashes, not re-wrapped `Value`
|
|
615
|
+
# instances — confirmed live, not assumed — so this recursion finds
|
|
616
|
+
# nothing further past the field it started from FOR TODAY'S
|
|
617
|
+
# coercion pipeline specifically. Kept anyway, not dead code: the
|
|
618
|
+
# generic `Hash`/`Array` branches below reach the exact same nested
|
|
619
|
+
# data through `node[attribute.name]` regardless, and a future
|
|
620
|
+
# change that DOES start wrapping composite fields as real `Value`
|
|
621
|
+
# instances would be walked correctly here with no change needed.
|
|
622
|
+
# `seen` is a `compare_by_identity` Hash: the same INSTANCE can legitimately
|
|
623
|
+
# appear more than once (an aggregate's live state and an event
|
|
624
|
+
# payload both reference the exact same frozen object), and
|
|
625
|
+
# checking it twice would just waste time, never change the
|
|
626
|
+
# answer — identity, not `Value#==`, is the right notion of
|
|
627
|
+
# "already found" here (two DIFFERENT value objects that happen to
|
|
628
|
+
# hold equal fields are still two separate round trips to prove).
|
|
629
|
+
def walk_value_objects(node, found, seen, aggregate)
|
|
630
|
+
case node
|
|
631
|
+
when Runtime::Value
|
|
632
|
+
return if seen[node]
|
|
633
|
+
|
|
634
|
+
seen[node] = true
|
|
635
|
+
found << [node, aggregate]
|
|
636
|
+
node.value_object.attributes.each do |attribute|
|
|
637
|
+
walk_value_objects(node[attribute.name], found, seen, aggregate)
|
|
638
|
+
end
|
|
639
|
+
when Hash
|
|
640
|
+
node.each_value { |value| walk_value_objects(value, found, seen, aggregate) }
|
|
641
|
+
when Array
|
|
642
|
+
node.each { |value| walk_value_objects(value, found, seen, aggregate) }
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
end
|