trailblazer-developer 0.0.28 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bc71f425eee589bda27a8b681b796650443e688c3dc46ddb7d12e6daf092f08
4
- data.tar.gz: 2f128bcf1675ba3147db4737f348b52d9a1f96562e317feeddda79846c1ca0e8
3
+ metadata.gz: 343ca726bbb8da50f05aca2a552f018ac67683d4c0021226f828a30132ecf85c
4
+ data.tar.gz: f4ab036e3da016ab3283f258c749ef98cee474a9c69b8303fad36bdd9b6df945
5
5
  SHA512:
6
- metadata.gz: 3b10040b636e96bc396ec89b41db298030552b0460ad7fa88b94ebf5a767e881cdf2c219ab573522f4226cb84d6e6d5baac786d384b7ef93d2788576dea4183f
7
- data.tar.gz: 2eb0f50c4af74ab44ea0353e61b45a4645adb0f70132be92d329dd8123d0fd20561b58047be1bd9b2a25cb9e6bd5ccd2a6ac67089c7546e0041c78c24c455c62
6
+ metadata.gz: f66fa880eb4a0673fd881598fe9fa9addebadbd4989fded9366f7960b7ed9ab34794fe8d8a6c4ab3a315b13fe45b048061ac4f4330cb71942a82a3adc1d12a82
7
+ data.tar.gz: 7ecac119ec1d406442e1ddb5292639bac41e13df24d870f462065d90d5bf690ab4417b0736c9a134b51c2d2929a6952912803cfe2686cfca7d7b16bc14cfaa4a
data/CHANGES.md CHANGED
@@ -1,3 +1,78 @@
1
+ # 0.1.0
2
+
3
+ * Improvement of tracing performance: factor ~4.
4
+ * Consistent interface for tracing and presentation.
5
+
6
+ ## Adding `Debugger` layer
7
+
8
+ * Introduce `Debugger::Trace` with `Trace::Node`s and `variable_versions` field
9
+ to maintain all data produced by tracing in one entity.
10
+ * In `Trace::Present.call`, you need to pass a block with options for customization instead
11
+ of passing `:node_options`. Further on, the per-node customization is now keyed by
12
+ `Trace::Node` instance and not a Stack element anymore.
13
+ * In `render_method`, remove `:tree` keyword argument, use `:debugger_trace`.
14
+ * The `render_method` is called with keyword arguments, only. The first positional argument
15
+ `debugger_nodes` is now `:debugger_trace`.
16
+
17
+ ```ruby
18
+ output = Dev::Trace::Present.(
19
+ stack,
20
+ node_options: {
21
+ stack.to_a[0] => {label: "Create"}
22
+ }
23
+ )
24
+ ```
25
+ is now
26
+
27
+ ```ruby
28
+ Dev::Trace::Present.(stack) do |trace_nodes:, **|
29
+ {
30
+ node_options: {
31
+ trace_nodes[0] => {label: "Create"}
32
+ }
33
+ }
34
+ end
35
+ ```
36
+
37
+ ## `Trace::Node`
38
+
39
+ * `Node.build_for_stack` is now called in `Trace::Present` and produces a list
40
+ of `Trace::Node` instances that are containers for matching before and after snapshots.
41
+ * Add `Node::Incomplete` nodes that have no `snapshot_after` as they represent
42
+ a part of the flow that was canceled.
43
+
44
+ ## Debugger::Normalizer
45
+
46
+ * Deprecate `:captured_node` keyword argument in normalizer steps and
47
+ rename it to `:trace_node`.
48
+ * Remove the concept of `:runtime_path` and `:compile_path`. You can always
49
+ compute paths after or during the debug rendering yourself. However, we simply
50
+ don't need it for IDE or wtf?.
51
+
52
+ ## `Trace.wtf?`
53
+
54
+ * Rename `:output_data_collector` and `:input_data_collector` to `:after_snapshooter` and `:before_snapshooter`
55
+ as they both produce instances of `Snapshot`. The return signature has changed, the snapshooters
56
+ return two values now: the data object and an object representing the variable versioning.
57
+
58
+ ## Snapshot
59
+
60
+ * Introduce the concept of `Snapshot` which can be a moment in time before or after a step.
61
+ The `Snapshot::Before` and `Snapshot::After` (renamed from `Captured::Input` and `Captured::Output`)
62
+ are created during the tracing and after tracing associated to a specific `Trace::Node`.
63
+ * Add `Snapshot::Ctx` and `Snapshot::Versions` which is a new, faster way of capturing variables.
64
+ Instead of always calling `ctx.inspect` for every trace step, only variables that have changed
65
+ are snapshooted using the (configurable) `:value_snapshooter`. This improves `#wtf?` performance
66
+ up to factor 10.
67
+
68
+ # 0.0.29
69
+
70
+ * The `:render_method` callable can now return output along with additional returned values.
71
+ * Pass the top-level `:activity` into the `:render_method`.
72
+ * In `#wtf?`, only complete the stack when an exception occurred.
73
+ * `wtf?` now returns all original return values plus the computed output from `:render_method`,
74
+ followed by an arbitrary object from the rendering code.
75
+
1
76
  # 0.0.28
2
77
 
3
78
  * Move `Introspect::Graph` over from `trailblazer-activity`. It's a data structure very specific
data/Gemfile CHANGED
@@ -11,3 +11,5 @@ gemspec
11
11
  # gem "trailblazer-activity", github: "trailblazer/trailblazer-activity"
12
12
  # gem "trailblazer-activity-dsl-linear", github: "trailblazer/trailblazer-activity-dsl-linear"
13
13
 
14
+
15
+ gem "benchmark-ips"
@@ -0,0 +1,60 @@
1
+ module Trailblazer
2
+ module Developer
3
+ module Debugger
4
+ # @private
5
+ # Public entry point to add Debugger::Node normalizer steps.
6
+ def self.add_normalizer_step!(step, id:, normalizer: Normalizer::PIPELINES.last, **options)
7
+ task = Normalizer.Task(step)
8
+
9
+ # We have a TaskWrap::Pipeline (a very simple style of "activity" used for normalizers) and
10
+ # add another step using the "friendly interface" from {Activity::Adds}.
11
+ options = {append: nil} unless options.any?
12
+
13
+ pipeline_extension = Activity::TaskWrap::Extension.build([task, id: id, **options])
14
+
15
+ Normalizer::PIPELINES << pipeline_extension.(normalizer)
16
+ end
17
+
18
+ # Run at runtime when preparing a Trace::Nodes for presentation.
19
+ module Normalizer
20
+ def self.Task(user_step) # TODO: we could keep this in the {activity} gem.
21
+ Activity::TaskWrap::Pipeline::TaskAdapter.for_step(user_step, option: false) # we don't need Option as we don't have ciruit_options here, and no {:exec_context}
22
+ end
23
+
24
+ # Default steps for the Debugger::Node options pipeline, following the step-interface.
25
+ module Default
26
+ def self.compile_id(ctx, activity:, task:, **)
27
+ ctx[:compile_id] = Activity::Introspect.Nodes(activity, task: task)[:id]
28
+ end
29
+
30
+ def self.runtime_id(ctx, compile_id:, **)
31
+ ctx[:runtime_id] = compile_id
32
+ end
33
+
34
+ def self.label(ctx, label: nil, runtime_id:, **)
35
+ ctx[:label] = label || runtime_id
36
+ end
37
+
38
+ def self.data(ctx, data: {}, **)
39
+ ctx[:data] = data
40
+ end
41
+
42
+ def self.incomplete?(ctx, trace_node:, **)
43
+ ctx[:incomplete?] = trace_node.is_a?(Developer::Trace::Node::Incomplete)
44
+ end
45
+ end
46
+
47
+ default_steps = {
48
+ compile_id: Normalizer.Task(Default.method(:compile_id)),
49
+ runtime_id: Normalizer.Task(Default.method(:runtime_id)),
50
+ label: Normalizer.Task(Default.method(:label)),
51
+ data: Normalizer.Task(Default.method(:data)),
52
+ incomplete?: Normalizer.Task(Default.method(:incomplete?)),
53
+ }.
54
+ collect { |id, task| Activity::TaskWrap::Pipeline.Row(id, task) }
55
+
56
+ PIPELINES = [Activity::TaskWrap::Pipeline.new(default_steps)] # we do mutate this constant at compile-time. Maybe # DISCUSS and find a better way.
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,97 @@
1
+ module Trailblazer
2
+ module Developer
3
+ # Code in Debugger is only executed if the user wants to render the stack.
4
+ module Debugger
5
+ ATTRS = [
6
+ :id,
7
+ :trace_node,
8
+ :task,
9
+ :activity,
10
+ :compile_id,
11
+ :runtime_id,
12
+ :label,
13
+ :data,
14
+ :snapshot_before,
15
+ :snapshot_after,
16
+ :level,
17
+ :incomplete?,
18
+ :captured_node, # TODO: remove once macro is 2.2
19
+ ]
20
+
21
+ # The {Debugger::Node} is an abstraction between Trace::Node and the actual rendering layer (why?)
22
+ #
23
+ # TODO: class, "type",
24
+ # which track, return signal, etc
25
+ class Node < Struct.new(*ATTRS, keyword_init: true)
26
+ # we always key options for specific nodes by Stack::Captured::Input, so we don't confuse activities if they were called multiple times.
27
+ #
28
+ # @return [Debugger::Node] array of Debugger::Node
29
+ def self.build(trace_nodes, node_options: {}, normalizer: Debugger::Normalizer::PIPELINES.last, **options_for_nodes)
30
+ # DISCUSS: this might change if we introduce a new Node type for Trace.
31
+ _debugger_nodes = trace_nodes.collect do |trace_node|
32
+ # it's possible to pass per-node options, like {label: "Yo!"} via {:node_options[<snapshot_before>]}
33
+ options_from_user = node_options[trace_node] || {}
34
+
35
+ options_from_trace_node = trace_node
36
+ .to_h # :level, :snapshot_before, :snapshot_after
37
+ .merge(
38
+ id: trace_node.object_id,
39
+ trace_node: trace_node,
40
+ activity: trace_node.snapshot_before.activity,
41
+ task: trace_node.task,
42
+ captured_node: DeprecatedCapturedNode, # TODO: remove once macro is 2.2
43
+ )
44
+
45
+ options_for_debugger_node, _ = normalizer.(
46
+ {
47
+ **options_from_trace_node,
48
+ **options_from_user
49
+ },
50
+ []
51
+ )
52
+
53
+ # these attributes are not changing with the presentation
54
+ Debugger::Node.new(**options_for_debugger_node).freeze
55
+ end
56
+ end
57
+
58
+ # TODO: remove once macro is 2.2
59
+ class DeprecatedCapturedNode
60
+ def self.method_missing(*)
61
+ raise "[Trailblazer] The `:captured_node` argument is deprecated, please upgrade to `trailblazer-developer-0.1.0` and use `:trace_node` if the upgrade doesn't fix it."
62
+ end
63
+ end
64
+ end # Node
65
+
66
+ # Interface for data (nodes, versions, etc) between tracing code and presentation layer.
67
+ # We have no concept of {Stack} here anymore. Nodes and arbitrary objects such as "versions".
68
+ # Debugger::Trace interface abstracts away the fact we have two snapshots. Here,
69
+ # we only have a node per task.
70
+ #
71
+ class Trace
72
+ # Called in {Trace::Present}.
73
+ # Design note: goal here is to have as little computation as possible.
74
+ def self.build(stack, trace_nodes, **options_for_debugger_nodes)
75
+ nodes = Debugger::Node.build(
76
+ trace_nodes,
77
+ **options_for_debugger_nodes,
78
+ )
79
+
80
+ new(nodes: nodes, variable_versions: stack.variable_versions) # after this, the concept of "Stack" doesn't exist anymore.
81
+ end
82
+
83
+ def initialize(nodes:, variable_versions:)
84
+ @options = {nodes: nodes, variable_versions: variable_versions}
85
+ end
86
+
87
+ def to_h
88
+ @options
89
+ end
90
+
91
+ def to_a
92
+ to_h[:nodes].to_a
93
+ end
94
+ end
95
+ end # Debugger
96
+ end
97
+ end
@@ -0,0 +1,103 @@
1
+ module Trailblazer
2
+ module Developer
3
+ module Trace
4
+ # Build array of {Trace::Node} from a snapshots stack.
5
+ # @private
6
+ def self.build_nodes(snapshots)
7
+ instructions = [
8
+ [0, snapshots]
9
+ ]
10
+
11
+ _nodes = Node.process_instructions(instructions)
12
+ end
13
+
14
+ # Datastructure representing a trace.
15
+ class Node < Struct.new(:level, :task, :snapshot_before, :snapshot_after)
16
+ class Incomplete < Node
17
+ end
18
+
19
+ def self.pop_from_instructions!(instructions)
20
+ while (level, remaining_snapshots = instructions.pop)
21
+ next if level.nil?
22
+ next if remaining_snapshots.empty?
23
+
24
+ return level, remaining_snapshots
25
+ end
26
+
27
+ false
28
+ end
29
+
30
+ # def self.BLA(instructions)
31
+ # instructions.collect do |(level, remaining_snapshots)|
32
+ # [
33
+ # level,
34
+ # remaining_snapshots.collect { |snap| [snap.class, snap.task] }
35
+ # ]
36
+ # end
37
+ # end
38
+
39
+ def self.process_instructions(instructions) # FIXME: mutating argument
40
+ nodes = []
41
+
42
+ while (level, remaining_snapshots = pop_from_instructions!(instructions))
43
+ raise unless remaining_snapshots[0].is_a?(Snapshot::Before) # DISCUSS: remove assertion?
44
+
45
+ node, new_instructions = node_and_instructions_for(remaining_snapshots[0], remaining_snapshots[1..-1], level: level)
46
+ # pp BLA(new_instructions)
47
+
48
+ nodes << node
49
+
50
+ instructions += new_instructions
51
+ end
52
+
53
+ return nodes
54
+ end
55
+
56
+ # Called per snapshot_before "process_branch"
57
+ # 1. Find, for snapshot_before, the matching snapshot_after in the stack
58
+ # 2. Extract snapshots inbetween those two. These are min. 1 level deeper in!
59
+ # 3. Run process_siblings for 2.
60
+ def self.node_and_instructions_for(snapshot_before, descendants, level:)
61
+ # Find closing snapshot for this branch.
62
+ snapshot_after = descendants.find do |snapshot|
63
+ snapshot.is_a?(Snapshot::After) && snapshot.data[:snapshot_before] == snapshot_before
64
+ end
65
+
66
+ if snapshot_after
67
+ snapshot_after_index = descendants.index(snapshot_after)
68
+
69
+ instructions =
70
+ if snapshot_after_index == 0 # E.g. before/Start, after/Start
71
+ [
72
+ [level, descendants[1..-1]]
73
+ ]
74
+ else
75
+ nested_instructions = [
76
+ # instruction to go through the remaining, behind this tuple.
77
+ [
78
+ level,
79
+ descendants[(snapshot_after_index + 1)..-1]
80
+ ],
81
+ # instruction to go through all snapshots between this current tuple.
82
+ [
83
+ level + 1,
84
+ descendants[0..snapshot_after_index - 1], # "new descendants"
85
+ ],
86
+ ]
87
+ end
88
+
89
+ node = new(level, snapshot_before.task, snapshot_before, snapshot_after)
90
+ else # incomplete
91
+ instructions = [
92
+ [level + 1, descendants]
93
+ ]
94
+
95
+ node = Incomplete.new(level, snapshot_before.task, snapshot_before, nil)
96
+ end
97
+
98
+ return node, instructions
99
+ end
100
+ end # Node
101
+ end
102
+ end # Developer
103
+ end
@@ -0,0 +1,32 @@
1
+ module Trailblazer
2
+ module Developer
3
+ module Trace
4
+ # Map each {Node} instance to its parent {Node}.
5
+ module ParentMap # DISCUSS: where does this belong?
6
+ def self.build(trace_nodes)
7
+ levels = {}
8
+ trace_nodes.collect do |node|
9
+ level = node.level
10
+ levels[level] = node
11
+
12
+ [node, levels[level - 1]]
13
+ end.to_h
14
+ end
15
+
16
+ # @public
17
+ def self.path_for(parent_map, node)
18
+ path = []
19
+
20
+ while parent = parent_map[node] # DISCUSS: what if the graphs are cached and present, already?
21
+ node_id = Activity::Introspect.Nodes(node.snapshot_before.activity, task: node.snapshot_before.task).id
22
+ path << node_id
23
+
24
+ node = parent
25
+ end
26
+
27
+ path.reverse
28
+ end
29
+ end # ParentMap
30
+ end
31
+ end # Developer
32
+ end
@@ -2,7 +2,7 @@ require "hirb"
2
2
 
3
3
  module Trailblazer::Developer
4
4
  module Trace
5
- module Present
5
+ module Present # DISCUSS: rename to Debugger?
6
6
  module_function
7
7
 
8
8
  # @private
@@ -12,33 +12,65 @@ module Trailblazer::Developer
12
12
 
13
13
  # Returns the console output string.
14
14
  # @private
15
- def render(debugger_nodes, renderer: method(:default_renderer), **options_for_renderer)
16
- nodes = debugger_nodes.collect do |debugger_node|
17
- renderer.(debugger_node: debugger_node, tree: debugger_nodes, **options_for_renderer)
15
+ def render(debugger_trace:, renderer: method(:default_renderer), **options_for_renderer)
16
+ nodes = debugger_trace.to_a.collect do |debugger_node|
17
+ renderer.(debugger_node: debugger_node, debugger_trace: debugger_trace, **options_for_renderer)
18
18
  end
19
19
 
20
20
  Hirb::Console.format_output(nodes, class: :tree, type: :directory, multi_line_nodes: true)
21
21
  end
22
22
 
23
- # Entry point for rendering a Stack as a "tree branch" the way we do it in {#wtf?}.
24
- def call(stack, render_method: method(:render), node_options: {}, **options)
23
+ # Entry point for rendering a {Trace::Stack}.
24
+ # Used in `#wtf?`.
25
+ def call(stack, render_method: method(:render), **options, &block)
26
+ deprecate_node_options!(**options) # TODO: remove in 0.2.0.
27
+
28
+ # Build a generic array of {Trace::Node}s.
29
+ trace_nodes = Trace.build_nodes(stack.to_a)
30
+
25
31
  # The top activity doesn't have an ID, hence we need to compute a default label.
26
- # TODO: maybe we should deep-merge here.
27
- captured_input_for_top_activity = stack.to_a[0]
28
-
29
- top_activity_options = {
30
- # we can pass particular label "hints".
31
- captured_input_for_top_activity => {
32
- # label: %{#{captured_input_for_top_activity.task.superclass} (anonymous)},
33
- label: captured_input_for_top_activity.task.inspect,
34
- },
32
+ top_activity_trace_node = trace_nodes[0]
33
+
34
+ build_options = {
35
+ node_options: {
36
+ # we can pass particular label "hints".
37
+ top_activity_trace_node => {
38
+ # label: %{#{top_activity_trace_node.task.superclass} (anonymous)},
39
+ label: top_activity_trace_node.task.inspect,
40
+ },
41
+ }
35
42
  }
36
43
 
37
- node_options = top_activity_options.merge(node_options)
44
+ build_options = build_options.merge(options) # since we only have {:node_options} in {build_options}, we can safely merge here.
45
+
46
+ # specific rendering.
47
+ options_from_block = block_given? ? block.call(trace_nodes: trace_nodes, stack: stack, **build_options) : {}
48
+
49
+ build_options = merge_local_options(options_from_block, build_options)
50
+
51
+ # currently, we agree on using a Debugger::Node list as the presentation data structure.
52
+ debugger_trace = Debugger::Trace.build(stack, trace_nodes, **build_options)
38
53
 
39
- debugger_nodes = Debugger::Node.build_for_stack(stack, node_options: node_options, **options) # currently, we agree on using a Debugger::Node list as the presentation data structure.
54
+ return render_method.(debugger_trace: debugger_trace, **build_options)
55
+ end
56
+
57
+ def deprecate_node_options!(node_options: nil, **) # TODO: remove in 0.2.0.
58
+ return unless node_options
59
+
60
+ raise "[Trailblazer] The `:node_options` option for `Trace::Present` is deprecated.
61
+ Please use the block style as described here: https://trailblazer.to/2.1/docs/internals.html#internals-developer-trace-present"
62
+ end
63
+
64
+ # @private
65
+ def merge_local_options(options, local_options)
66
+ merged_hash = options.collect do |key, value|
67
+ [
68
+ key,
69
+ value.is_a?(Hash) ? local_options.fetch(key, {}).merge(value) : value # options are winning over local_options[key]
70
+ ]
71
+ end.to_h
40
72
 
41
- return render_method.(debugger_nodes, **options)
73
+ local_options.merge(merged_hash)
42
74
  end
43
75
  end # Present
44
76
  end
@@ -0,0 +1,39 @@
1
+ module Trailblazer::Developer
2
+ module Trace
3
+ class Snapshot
4
+ # {Value} serializes the variable value using with custom logic, e.g. {value.inspect}.
5
+ # A series of matchers decide which snapshooter is used.
6
+ class Value
7
+ def initialize(matchers)
8
+ @matchers = matchers
9
+ end
10
+
11
+ # DISCUSS: this could be a compiled pattern matching `case/in` block here.
12
+ def call(name, value, **options)
13
+ @matchers.each do |matcher, inspect_method|
14
+ if matcher.(name, value, **options)
15
+ return inspect_method.(name, value, **options)
16
+ end
17
+ end
18
+
19
+ raise "no matcher found for #{name.inspect}" # TODO: this should never happen.
20
+ end
21
+
22
+ def self.default_variable_inspect(name, value, ctx:)
23
+ value.inspect
24
+ end
25
+
26
+ def self.build
27
+ new(
28
+ [
29
+ [
30
+ ->(*) { true }, # matches everything
31
+ method(:default_variable_inspect)
32
+ ]
33
+ ]
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,105 @@
1
+ module Trailblazer::Developer
2
+ module Trace
3
+ class Snapshot
4
+ # Snapshot::Ctx keeps an inspected version of each ctx variable.
5
+ # We figure out if a variable has changed by using `variable.hash` (works
6
+ # even with deeply nested structures).
7
+ #
8
+ # Key idea here is to have minimum work at operation-runtime. Specifics like
9
+ # figuring out what has changed can be done when using the debugger.
10
+ #
11
+ # By keeping "old" versions, we get three benefits.
12
+ # 1. We only need to call {inspect} once on a traced variable. Especially
13
+ # when variables are complex structures or strings, this dramatically speeds
14
+ # up tracing, from same-ish to factor 5!
15
+ # 2. The content sent to our debugger is much smaller which reduces network load
16
+ # and storage space.
17
+ # 3. Presentation becomes simpler as we "know" what variable has changed.
18
+ #
19
+ # Possible problems: when {variable.hash} returns the same key even though the
20
+ # data has changed.
21
+ #
22
+ # DISCUSS: speed up by checking mutable, only?
23
+ # DISCUSS: we currently only use this for testing.
24
+ # DISCUSS: this has knowledge about {Stack} internals.
25
+ #
26
+ # This is for the "rendering" layer.
27
+ # @private
28
+ def self.snapshot_ctx_for(snapshot, variable_versions)
29
+ variable_versions = variable_versions.instance_variable_get(:@variables)
30
+
31
+ snapshot.data[:ctx_variable_changeset].collect do |name, hash, has_changed|
32
+ [
33
+ name,
34
+ {
35
+ value: variable_versions[name][hash],
36
+ has_changed: !!has_changed,
37
+ }
38
+ ]
39
+ end.to_h
40
+ end
41
+
42
+ # A table of all ctx variables, their hashes and serialized values.
43
+ #
44
+ # {:current_user=>
45
+ # {3298051090906520533=>"#<TraceTest::User:0x000055b2e3424460 @id=1>",
46
+ # 3764938782671692590=>"#<TraceTest::User:0x000055b2e33e45b8 @id=2>"},
47
+ # :params=>
48
+ # {2911818769466875657=>"{:name=>\"Q & I\"}",
49
+ # 2238394858183550663=>"{:name=>\"Q & I\", :song=>{...}}"},
50
+ # :seq=>
51
+ # {-105020188158523405=>"[]",
52
+ # -2281497291400788995=>"[:authenticate]",
53
+ # 150926802063554866=>"[:authenticate, :authorize]",
54
+ # 3339595138798116233=>"[:authenticate, :authorize, :model]",
55
+ # -3395325862879242711=>
56
+ # "[:authenticate, :authorize, :model, :screw_params!]"},
57
+ # :model=>{348183403054247453=>"Object"}}
58
+ class Versions
59
+ def initialize
60
+ @variables = {}
61
+ end
62
+
63
+ # DISCUSS: problem with changeset is, we have to go through variables twice.
64
+ def changeset_for(ctx, value_snapshooter:)
65
+ new_versions = []
66
+
67
+ changeset_for_snapshot = ctx.collect do |name, value|
68
+ # DISCUSS: do we have to call that explicitly or does Hash#[] do that for us, anyway?
69
+ value_hash = value.hash # DISCUSS: does this really always change when a deeply nested object changes?
70
+
71
+ if (variable_versions = @variables[name]) && variable_versions.key?(value_hash) # TODO: test {variable: nil} value
72
+ [name, value_hash, nil] # nil means it's an existing reference.
73
+ else
74
+ value_snapshot = value_snapshooter.(name, value, ctx: ctx)
75
+
76
+ version = [name, value_hash, value_snapshot]
77
+
78
+ new_versions << version
79
+ version
80
+ end
81
+ end
82
+
83
+ return changeset_for_snapshot, new_versions
84
+ end
85
+
86
+ def add_changes!(new_versions)
87
+ new_versions.each do |args|
88
+ add_variable_version!(*args)
89
+ end
90
+ end
91
+
92
+ # @private
93
+ def add_variable_version!(name, hash, value)
94
+ @variables[name] ||= {}
95
+
96
+ @variables[name][hash] = value # i hate mutations.
97
+ end
98
+
99
+ def to_h
100
+ @variables
101
+ end
102
+ end
103
+ end # Snapshot
104
+ end
105
+ end