hecks 3.4.0 → 3.4.1

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: b6180e44456a48f89b02b8812b4f327fe1642f98756e8e4c0b1bb295a04f826b
4
- data.tar.gz: 6bc10cd76fb9c112ce9d7a5b82295a2e2a4c101d1b10b195ab48cd1afefd75c7
3
+ metadata.gz: b47e9f61f073e7c8512365b3bb5112f4e214c26cec46e08a323c6f02c509d555
4
+ data.tar.gz: d84b642199556cc1b9bbcbc6635c2e248f3f4b63232bb0ca6abee589f85661ca
5
5
  SHA512:
6
- metadata.gz: 56cfe2cebc08a7aac900d8ef700590aa66855ea0baf28eebb9096f0f74c4d51da5ef42167cffdc6f1bcc733c3cacf8e329bf1352821655247c36e17275614123
7
- data.tar.gz: 37cc4287ecf68af46b507dff79b3041de0a6e28fe2a1a7e5d89bea393b74d5f84eead4e11509c0f16d47d11ec8abfe9b292d73ec14845d9466e086dcec3bf350
6
+ metadata.gz: e2280fdb747d6d8a299bd58f4ab261e71a0d977ed805e9fcfe86ae108cc248724c96960614b9af6796d4617ef9102be1343107a91c6044123a97f298ba6002f1
7
+ data.tar.gz: 6558a4e9698ca6e01a665ab3d9aa435da6990e20a69ef06b0c0cd91bc0edcedc771a976dc664c4501d56883ebca1435adb534d3516a20d8fd55dca3c90ff40ff
@@ -0,0 +1,137 @@
1
+ require "objspace"
2
+ require_relative "../../../freezer"
3
+ require_relative "../../../ports/persistence/state_codec"
4
+ require_relative "../../../runtime/value"
5
+
6
+ module Hecks
7
+ module Adapters
8
+ class Memory
9
+ # Lets a Memory save of a growing `list_of` copy and hydrate only the elements it has not
10
+ # seen. A hydrated composite element is frozen, so its codec copy (the journal form) and
11
+ # its hydrated form are each computed once, frozen, and shared by every journal entry and
12
+ # record that holds it. Shared nodes are immutable, so a caller cannot reach the journal
13
+ # through a returned instance. Everything else in the state goes through `StateCodec`
14
+ # whole. Both tables are weak and identity-keyed: a cache, never the only holder.
15
+ class SharedElements
16
+ # @param aggregate [Bluebook::Aggregate] the aggregate whose state this shares elements of
17
+ def initialize(aggregate)
18
+ @aggregate = aggregate
19
+ reset!
20
+ end
21
+
22
+ # Forgets every remembered element.
23
+ #
24
+ # @return [SharedElements] self, now empty
25
+ def reset!
26
+ @journal = ObjectSpace::WeakMap.new # live element => frozen decoded copy
27
+ @live = ObjectSpace::WeakMap.new # frozen decoded copy => hydrated element
28
+ self
29
+ end
30
+
31
+ # Copies state into the shape the journal holds: `StateCodec.copy`, with every
32
+ # shareable list element copied once and reused.
33
+ #
34
+ # @param state [Hash, nil] live state, such as `Instance#state`; nil for a delete entry
35
+ # @return [Hash{Symbol => Object}, nil] the decoded copy; its shareable list elements
36
+ # are frozen and shared with every other copy that holds them, nothing else is shared
37
+ # with `state`
38
+ def journal_state(state)
39
+ lists = shareable_lists(state)
40
+ return Ports::Persistence::StateCodec.copy(@aggregate, state) if lists.empty?
41
+
42
+ copied = Ports::Persistence::StateCodec.copy(@aggregate, placeholders(state, lists))
43
+ lists.each { |name, (attribute, elements)| copied[name] = journal_list(attribute, elements) }
44
+ copied
45
+ end
46
+
47
+ # Hydrates journal-shaped state into the state a record holds.
48
+ #
49
+ # @param decoded [Hash] a fresh state in the shape `journal_state` returns; its non-shared
50
+ # values are hydrated in place of copies, so it must not be a journal entry's own state
51
+ # @return [Hash{Symbol => Object}] hydrated state, defaults filled; the lists it shares
52
+ # are frozen arrays of frozen elements, and nothing mutable is shared with `decoded`
53
+ def live_state(decoded)
54
+ lists = shareable_lists(decoded)
55
+ hydrated = Runtime::Instance.hydrate_with_defaults(@aggregate, placeholders(decoded, lists))
56
+ lists.each { |name, (attribute, elements)| hydrated[name] = live_list(attribute, elements) }
57
+ hydrated
58
+ end
59
+
60
+ private
61
+
62
+ # The list attributes of `state` whose elements can be shared: composite, not a
63
+ # reference, and actually held as an Array.
64
+ def shareable_lists(state)
65
+ return {} unless state.is_a?(Hash)
66
+
67
+ composite_lists.each_with_object({}) do |(name, attribute), found|
68
+ elements = state[name]
69
+ found[name] = [attribute, elements] if elements.is_a?(Array)
70
+ end
71
+ end
72
+
73
+ def composite_lists
74
+ @composite_lists ||= @aggregate.attributes.each_with_object({}) do |attribute, found|
75
+ next unless attribute.list? && !attribute.reference? && composite?(attribute)
76
+
77
+ found[attribute.name.to_sym] = attribute
78
+ end
79
+ end
80
+
81
+ def composite?(attribute)
82
+ type = attribute.type.to_s
83
+ !Runtime::Value.find_entity(@aggregate, type).nil? ||
84
+ (@aggregate.respond_to?(:value_object) && !Runtime::Value.value_object_for(@aggregate, type).nil?)
85
+ end
86
+
87
+ # `state` with each shared list replaced by nil, which the codec leaves alone and
88
+ # which holds the list's place in the key order.
89
+ def placeholders(state, lists)
90
+ return state if lists.empty?
91
+
92
+ state.merge(lists.transform_values { nil })
93
+ end
94
+
95
+ def journal_list(attribute, elements)
96
+ elements.map { |element| journal_element(attribute, element) }.freeze
97
+ end
98
+
99
+ def journal_element(attribute, element)
100
+ known = @journal[element]
101
+ return known if known
102
+ return element if @live.key?(element)
103
+
104
+ copy = Ports::Persistence::StateCodec.copy_list_element(@aggregate, attribute, element)
105
+ return copy unless immutable?(element)
106
+
107
+ @journal[element] = Freezer.deep(copy)
108
+ end
109
+
110
+ def live_list(attribute, elements)
111
+ elements.map { |element| live_element(attribute, element) }.freeze
112
+ end
113
+
114
+ def live_element(attribute, element)
115
+ known = @live[element]
116
+ return known if known
117
+
118
+ live = Runtime::Value.trusting_stored_state do
119
+ Runtime::Value.hydrate_entity_list(@aggregate, attribute, [element]).first
120
+ end
121
+ return live unless element.is_a?(Hash) && immutable?(element)
122
+
123
+ @journal[live] = element
124
+ @live[element] = live
125
+ end
126
+
127
+ def immutable?(element)
128
+ case element
129
+ when Runtime::Value then element.frozen?
130
+ when Hash then Freezer.deeply_frozen?(element)
131
+ else false
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -2,6 +2,7 @@ require_relative "../../ports/persistence/append_only"
2
2
  require_relative "../../ports/query/in_memory"
3
3
  require_relative "in_memory_ordering"
4
4
  require_relative "../../runtime/instance"
5
+ require_relative "memory/shared_elements"
5
6
 
6
7
  module Hecks
7
8
  module Adapters
@@ -30,6 +31,7 @@ module Hecks
30
31
  # constructor shape and ignored
31
32
  def initialize(aggregate:, settings: {}, root: nil)
32
33
  @aggregate = aggregate
34
+ @elements = SharedElements.new(aggregate)
33
35
  @records = {}
34
36
  @events = []
35
37
  @entries = []
@@ -105,7 +107,7 @@ module Hecks
105
107
  # @param instance [Runtime::Instance] the instance to store
106
108
  # @return [Runtime::Instance] the stored record, a fresh instance over a copy of the state
107
109
  def save(instance)
108
- entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: copy(instance.state))
110
+ entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state)
109
111
  append(entry)
110
112
  project(entry)
111
113
  end
@@ -164,6 +166,7 @@ module Hecks
164
166
  @entries = []
165
167
  @outbox = []
166
168
  @outbox_deliveries = {}
169
+ @elements.reset!
167
170
  self
168
171
  end
169
172
 
@@ -260,15 +263,20 @@ module Hecks
260
263
  def copy(state)
261
264
  return state.dup if bootstrap_fast_path?(state)
262
265
 
263
- Ports::Persistence::StateCodec.copy(@aggregate, state)
266
+ @elements.journal_state(state)
264
267
  end
265
268
 
266
269
  def build_instance(entry)
267
270
  if bootstrap_fast_path?(entry.state)
268
271
  Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state.dup, hydrate: false)
269
272
  else
270
- decoded = Ports::Persistence::StateCodec.copy(@aggregate, entry.state)
271
- Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: decoded)
273
+ decoded = @elements.journal_state(entry.state)
274
+ # The state is decoded by construction, so the boundary's re-walk of every shared
275
+ # list element would only repeat the copy that just built it.
276
+ Ports::Persistence::CodecBoundary.outside do
277
+ Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: decoded,
278
+ hydrate_with: @elements.method(:live_state))
279
+ end
272
280
  end
273
281
  end
274
282
  end
@@ -46,6 +46,19 @@ module Hecks
46
46
  # Array with `state`; nil when `state` is nil
47
47
  def copy(aggregate, state) = decode(aggregate, encode(aggregate, state))
48
48
 
49
+ # Deep-copies one element of a `list_of` composite attribute, exactly as `copy` would
50
+ # copy it inside its list.
51
+ #
52
+ # @param aggregate [Bluebook::Aggregate, Bluebook::Entity] the construct that declares
53
+ # the list attribute
54
+ # @param attribute [Bluebook::Attribute] a list attribute of `aggregate`
55
+ # @param element [Hash, Runtime::Value, Object] one element of the live list
56
+ # @return [Hash, Object] a new decoded Hash sharing nothing with `element`; a leaf
57
+ # element is returned as its JSON round trip
58
+ def copy_list_element(aggregate, attribute, element)
59
+ decode_composite(aggregate, attribute.type.to_s, encode_value(element))
60
+ end
61
+
49
62
  # Maps every declared top-level field name to its attribute.
50
63
  #
51
64
  # An attribute that shares a name with the lifecycle field or a projected field keeps
@@ -16,7 +16,10 @@ module Hecks
16
16
  # `args` is the creation payload, used only to fill a composite identity's heads.
17
17
  # `hydrate: false` skips the default re-walk for an already-hydrated, validated state,
18
18
  # keeping a `list_of` entity's Nth save O(1) rather than O(N).
19
- def initialize(aggregate:, id:, state: nil, args: nil, hydrate: true)
19
+ # `hydrate_with:` replaces the default hydration with a callable that takes the given
20
+ # state and answers the hydrated one (defaults filled), for an adapter that can reuse
21
+ # work across saves.
22
+ def initialize(aggregate:, id:, state: nil, args: nil, hydrate: true, hydrate_with: nil)
20
23
  @aggregate = aggregate
21
24
  @id = id
22
25
  # Inside a persistence adapter call this refuses undecoded stored state.
@@ -24,7 +27,7 @@ module Hecks
24
27
  @state = if !hydrate
25
28
  state || self.class.defaults(aggregate)
26
29
  elsif state
27
- self.class.hydrate_with_defaults(aggregate, state)
30
+ hydrate_with ? hydrate_with.call(state) : self.class.hydrate_with_defaults(aggregate, state)
28
31
  else
29
32
  self.class.defaults(aggregate)
30
33
  end
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.4.0".freeze
4
+ VERSION = "3.4.1".freeze
5
5
  end
@@ -1 +1 @@
1
- 3.4.0
1
+ 3.4.1
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.4.0
4
+ version: 3.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -74,6 +74,7 @@ files:
74
74
  - lib/hecks/adapters/driven/local_storage.rb
75
75
  - lib/hecks/adapters/driven/memory.adapter
76
76
  - lib/hecks/adapters/driven/memory.rb
77
+ - lib/hecks/adapters/driven/memory/shared_elements.rb
77
78
  - lib/hecks/adapters/driven/mock_stripe_adapter.adapter
78
79
  - lib/hecks/adapters/driven/mock_stripe_adapter.rb
79
80
  - lib/hecks/adapters/driven/postgres.adapter