trailblazer-developer 0.0.28 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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