julewire-core 1.0.0 → 1.0.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/docs/advanced-configuration.md +2 -1
  4. data/docs/context-and-propagation.md +6 -4
  5. data/docs/contracts.md +2 -1
  6. data/docs/extensions-and-api.md +9 -6
  7. data/docs/internals.md +3 -3
  8. data/docs/outputs-and-lifecycle.md +5 -2
  9. data/docs/records-and-data-policy.md +2 -2
  10. data/lib/julewire/core/cli/log_formats/record_decoder.rb +1 -1
  11. data/lib/julewire/core/context_store.rb +11 -6
  12. data/lib/julewire/core/destinations/destination.rb +3 -3
  13. data/lib/julewire/core/destinations/synchronized_output.rb +51 -15
  14. data/lib/julewire/core/diagnostics/doctor.rb +11 -11
  15. data/lib/julewire/core/diagnostics/failure_snapshot.rb +3 -1
  16. data/lib/julewire/core/execution/handle.rb +2 -0
  17. data/lib/julewire/core/execution/lineage.rb +10 -6
  18. data/lib/julewire/core/execution/scope.rb +4 -4
  19. data/lib/julewire/core/execution/scope_fields.rb +2 -2
  20. data/lib/julewire/core/facade_methods.rb +2 -2
  21. data/lib/julewire/core/fields/field_set.rb +32 -6
  22. data/lib/julewire/core/fields/field_stack.rb +40 -14
  23. data/lib/julewire/core/fields/internal.rb +41 -13
  24. data/lib/julewire/core/fields/stack_set.rb +2 -2
  25. data/lib/julewire/core/integration/destination_health.rb +13 -2
  26. data/lib/julewire/core/propagation/carrier.rb +63 -9
  27. data/lib/julewire/core/propagation.rb +3 -2
  28. data/lib/julewire/core/records/draft.rb +12 -6
  29. data/lib/julewire/core/records/record.rb +43 -13
  30. data/lib/julewire/core/runtime.rb +30 -11
  31. data/lib/julewire/core/serialization/bounded_transform.rb +11 -0
  32. data/lib/julewire/core/serialization/bounded_traversal.rb +33 -27
  33. data/lib/julewire/core/serialization/serializer.rb +1 -1
  34. data/lib/julewire/core/serialization/truncation_metadata.rb +142 -0
  35. data/lib/julewire/core/serialization/value_copy.rb +292 -51
  36. data/lib/julewire/core/testing/contracts/integration.rb +1 -1
  37. data/lib/julewire/core/version.rb +1 -1
  38. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e20df02ec73ceafd282ab52015e46a28f9aa7b1d69461e2622afc301f9e98d40
4
- data.tar.gz: 4e12bb12f787534aa5318084f6fa69fc1888590d85874d253c12dbfc6d6701f7
3
+ metadata.gz: 438690176aa21990767656cfc25cc35ebc89563611eb14f05875c463ef6e54f5
4
+ data.tar.gz: 6ce7ec058019b2ef04a84ec10f9f5fa22d73014b1449e272b8727fc1cbd9d45a
5
5
  SHA512:
6
- metadata.gz: 222e5041e3d984b1e3d50a989c461522cf7622257d1698e7e9fdee9f10f2837537a883fbfe16466ac31abf868558807e1a07642e52c894ac020bf71c628a1896
7
- data.tar.gz: 75f19494c48c6d46ae6bdf997db3605abc93323180589804d0e6338e1c7e592cd58150fbef9be4ccb5bfba577fb1086745ee8b779b6588aa7996572a55a5563f
6
+ metadata.gz: b965bae8da19cd50a0acf2df7d2005bb421c4b256fc286c7cfb89d2c58b034eefcdaafded7bfacf02243948f63977e4d390cde723f4f1c3f34f3b646dc9ed96d
7
+ data.tar.gz: a9623120a2bb1c1af5a5c2c877bfcdc74a71225f6826781348fa479c223dc167ff838fa60b8b0759d829307c1f4a180497746a132b7ab65de94d2f7ceb2f2bb1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 1.0.1 - 2026-06-25
4
+
5
+ - Harden bounded traversal, ingress copying, and carrier extraction against
6
+ noisy or hostile record shapes.
7
+ - Keep normalized record constructors strict: symbol-key contracts validate
8
+ instead of quietly normalizing pipeline-owned data.
9
+ - Default carrier extraction to the byte cap, report extraction status to
10
+ integrations, reserve truncation metadata keys at field ingress, and keep
11
+ custom normalization limits out of the thread-local copier pool.
12
+ - Keep output writes independent from flush/close lifecycle locking.
13
+
3
14
  ## 1.0.0 - 2026-06-21
4
15
 
5
16
  - Initial release: execution-scoped structured logging, propagation, bounded
@@ -13,7 +13,8 @@ is unsupported. Call `Julewire.configure` again.
13
13
  User-supplied destination formatter, encoder, output, and processor instances
14
14
  are shared by reference. Processors, formatters, and encoders must be stateless
15
15
  or otherwise reentrant; core does not synchronize them. Direct outputs are
16
- wrapped with a per-destination mutex.
16
+ wrapped with a per-destination write mutex; `flush` may overlap writes, while
17
+ terminal `close` is serialized with writes.
17
18
 
18
19
  When a reconfigure reuses the same output or destination object, core keeps that
19
20
  resource open for the new active pipeline and skips teardown through the old
@@ -241,9 +241,9 @@ Julewire::Core::Propagation.restore(envelope, link_executions: true) do
241
241
  end
242
242
  ```
243
243
 
244
- Restoring an envelope is an explicit trust decision. Core validates shape, and
245
- outbound injection can enforce a byte limit, but core does not authenticate who
246
- wrote the carrier. At untrusted boundaries, extract only the carrier fields you
244
+ Restoring an envelope is an explicit trust decision. Core validates shape and
245
+ caps inbound carrier bytes by default, but core does not authenticate who wrote
246
+ the carrier. At untrusted boundaries, extract only the carrier fields you
247
247
  intentionally accept, apply any application spoofing/filter policy first, then
248
248
  call `Carrier.restore` on that filtered carrier map.
249
249
 
@@ -269,7 +269,9 @@ Julewire::Core::Propagation::Carrier.restore(trusted) do
269
269
  end
270
270
  ```
271
271
 
272
- Use `max_bytes:` when the carrier target has practical size constraints:
272
+ Inbound extraction and restore default to `Carrier::DEFAULT_MAX_BYTES`; pass
273
+ `max_bytes: nil` only for a trusted unbounded carrier. Use `max_bytes:` on
274
+ injection when the carrier target has stricter size constraints:
273
275
 
274
276
  ```ruby
275
277
  headers = Julewire::Core::Propagation::Carrier.inject({}, max_bytes: 8 * 1024)
data/docs/contracts.md CHANGED
@@ -190,7 +190,8 @@ different Ruby isolation boundary:
190
190
  - `Julewire::Core::Execution::ScopeSnapshot` for detached execution scope transfer.
191
191
  - Parent-runtime hooks: `emit_envelope`, `emit_summary_record`, and `flush`.
192
192
  `emit_envelope` accepts detached input, context, carry, attributes, neutral,
193
- scope snapshot, and an `enforce_level:` flag.
193
+ scope snapshot, `enforce_level:`, and `owned:`. Bridges pass `owned: true`
194
+ only for envelopes that came from Julewire's own wire format.
194
195
 
195
196
  Bridge runtimes may expose `emit_without_level` when integration code inside
196
197
  the bridge has already applied its own level gate. Parent runtime labels,
@@ -300,11 +300,11 @@ health
300
300
  ```
301
301
 
302
302
  Output lifecycle hooks are sync and local. Runtime timeouts provide one carried
303
- deadline while core walks destinations, but plain outputs do not receive the
304
- timeout value and core cannot interrupt blocking raw outputs. After the first
305
- attempted resource, exhausted deadlines skip later resources. Custom
306
- destinations own async drain, retry, reopen targets, rotation, and
307
- timeout-aware shutdown.
303
+ deadline while core walks destinations. Plain outputs receive the timeout only
304
+ when their lifecycle method accepts `timeout:` or `**kwargs`; core still cannot
305
+ interrupt blocking raw output code. After the first attempted resource,
306
+ exhausted deadlines skip later resources. Custom destinations own async drain,
307
+ retry, reopen targets, rotation, and timeout-aware shutdown.
308
308
 
309
309
  Plain output writes are successful when `write` returns without raising and does
310
310
  not return `false`. A plain `false` is treated as a rejected write. A rejection
@@ -363,6 +363,8 @@ an immutable `Record` for formatters and destinations. `Record` is not a raw
363
363
  input builder; it is the read-only destination boundary. Use
364
364
  `Record.from_normalized_hash` only when an extension already owns a complete
365
365
  symbol-key normalized record hash and needs the immutable destination shape.
366
+ That path validates the strict internal contract; it does not clean up
367
+ JSON-style or user-input hashes. Use `RecordDraft.build` at raw boundaries.
366
368
 
367
369
  ## Public Facade
368
370
 
@@ -564,4 +566,5 @@ runtimes fail when called, so application code should not replace it casually.
564
566
 
565
567
  Core exposes the bridge SPI runtime envelope hook used by bridge code. The hook
566
568
  accepts detached input, context, attributes, carry, neutral data, and a scope
567
- snapshot, then routes them through the active pipeline.
569
+ snapshot, then routes them through the active pipeline. Bridge code may pass
570
+ `owned: true` only for data decoded from Julewire-owned wire formats.
data/docs/internals.md CHANGED
@@ -97,11 +97,11 @@ own schedulers because worker ractors cannot share the main scheduler object.
97
97
 
98
98
  ## Remote Envelope Hook
99
99
 
100
- Core keeps `Runtime#emit_envelope(input:, context:, attributes:, carry:, neutral:, scope:, enforce_level:)`
100
+ Core keeps `Runtime#emit_envelope(input:, context:, attributes:, carry:, neutral:, scope:, enforce_level:, owned:)`
101
101
  for bridge code. The bridge reconstructs the scope snapshot, and core rebuilds
102
102
  a normal record from input, context, attributes, carry, neutral, and that
103
- snapshot before emitting through the active pipeline. It is not a public
104
- application API.
103
+ snapshot before emitting through the active pipeline. Bridges pass `owned: true`
104
+ only for Julewire-owned wire data. It is not a public application API.
105
105
 
106
106
  ## Test Seams
107
107
 
@@ -74,8 +74,11 @@ for `$stdout`, caller-owned loggers, and shared objects.
74
74
 
75
75
  ## Synchronous Output
76
76
 
77
- Core wraps direct outputs with one mutex. Concurrent emitters do not interleave
78
- individual encoded records, but a slow sink blocks the emitting thread.
77
+ Core wraps direct output writes with one mutex. Concurrent emitters do not
78
+ interleave individual encoded records, but a slow sink blocks the emitting
79
+ thread. `flush` is lifecycle-only and may overlap writes so slow flushes do not
80
+ stall the emit path; direct outputs should be IO-like or internally safe for
81
+ flush/write overlap. `close` is terminal and serialized with writes.
79
82
 
80
83
  A synchronous output failure is contained. The destination records output
81
84
  failure counters and calls `on_failure`. Core does not retry, back off, or
@@ -181,8 +181,8 @@ Put redaction in processors or a separate policy gem before formatting.
181
181
 
182
182
  Truncated containers, containers pruned by `max_depth`, and circular container
183
183
  references get `_julewire_truncation` metadata. Serializer keys beginning with
184
- `_julewire_` are reserved by convention for core metadata; user payloads must
185
- not use that namespace. Public Julewire record contracts use symbol keys
184
+ `_julewire_` are reserved for core metadata; public field ingress rejects the
185
+ truncation marker as a user key. Public Julewire record contracts use symbol keys
186
186
  internally. Payloads should also use one JSON field name per value; mixed key
187
187
  types that stringify to the same JSON field are outside the serializer contract.
188
188
  Long strings use a `...[Truncated]` suffix. User data that already contains that
@@ -17,7 +17,7 @@ module Julewire
17
17
  def section(value)
18
18
  return {} unless value.is_a?(Hash)
19
19
 
20
- Fields::FieldSet.deep_symbolize_keys(value)
20
+ Fields::FieldSet.deep_symbolize_owned_keys(value)
21
21
  end
22
22
 
23
23
  def sections(source, sections: Fields::Bags.record_hash_sections)
@@ -72,6 +72,7 @@ module Julewire
72
72
  current_field_hash(:neutral)
73
73
  end
74
74
 
75
+ # SectionProxy dispatches these dynamically from the public field readers.
75
76
  def context_value(key, default:)
76
77
  current_field_stack(:context).value_for(key, default: default)
77
78
  end
@@ -139,21 +140,25 @@ module Julewire
139
140
  end
140
141
  end
141
142
 
142
- def with_propagation(context: {}, carry: {}, execution: {}, link_executions: false, &)
143
+ def with_propagation(context: {}, carry: {}, execution: {}, link_executions: false, owned: false, &)
143
144
  scope = current_scope
144
- execution = Fields::FieldSet.deep_symbolize_keys(execution)
145
+ execution = if owned
146
+ Fields::FieldSet.deep_symbolize_owned_keys(execution)
147
+ else
148
+ Fields::FieldSet.deep_symbolize_keys(execution)
149
+ end
145
150
  @execution_overlays.push(execution)
146
151
  @execution_lineage_overlays.push(link_executions ? Execution::Lineage.from_execution_hash(execution) : nil)
147
152
  invalidate_propagation_cache!
148
153
 
149
154
  begin
150
155
  if scope
151
- scope.with_carry(carry) do
152
- scope.with_context(context, &)
156
+ scope.with_carry(carry, owned: owned) do
157
+ scope.with_context(context, owned: owned, &)
153
158
  end
154
159
  else
155
- @ambient_fields.with(:carry, carry) do
156
- @ambient_fields.with(:context, context, &)
160
+ @ambient_fields.with(:carry, carry, owned: owned) do
161
+ @ambient_fields.with(:context, context, owned: owned, &)
157
162
  end
158
163
  end
159
164
  ensure
@@ -242,9 +242,9 @@ module Julewire
242
242
  call_output_lifecycle_safely(method_name, timeout)
243
243
  end
244
244
 
245
- def call_output_lifecycle_safely(method_name, _timeout)
246
- # Collection owns the shared deadline; plain outputs expose no timeout API.
247
- result = @output.public_send(method_name)
245
+ def call_output_lifecycle_safely(method_name, timeout)
246
+ # Sink.wrap centralizes timeout-aware lifecycle dispatch for every output.
247
+ result = @output.public_send(method_name, timeout: timeout)
248
248
  clear_degradation if method_name == :flush && result != false
249
249
  result
250
250
  rescue StandardError => e
@@ -4,16 +4,23 @@ module Julewire
4
4
  module Core
5
5
  module Destinations
6
6
  class SynchronizedOutput
7
+ TIMEOUT_PARAMETER_TYPES = %i[key keyreq].freeze
8
+ private_constant :TIMEOUT_PARAMETER_TYPES
9
+
7
10
  def initialize(output, close_output: false)
8
11
  Sink.validate_writeable!(output)
9
12
  @output = output
10
13
  @close_output = close_output
11
14
  @mutex = Mutex.new
15
+ @lifecycle_mutex = Mutex.new
16
+ @lifecycle = lifecycle_methods
12
17
  end
13
18
 
14
19
  def after_fork!
15
20
  @mutex = Mutex.new
21
+ @lifecycle_mutex = Mutex.new
16
22
  @output.after_fork! if @output.respond_to?(:after_fork!)
23
+ @lifecycle = lifecycle_methods
17
24
  self
18
25
  end
19
26
 
@@ -22,27 +29,36 @@ module Julewire
22
29
  def resource_identity = @output
23
30
 
24
31
  def write(value)
25
- @mutex.synchronize { @output.write(value) }
32
+ @mutex.synchronize do
33
+ return false if output_closed?
34
+
35
+ @output.write(value)
36
+ end
26
37
  end
27
38
 
28
- def flush
29
- @mutex.synchronize do
30
- return true unless @output.respond_to?(:flush)
39
+ def flush(timeout: nil)
40
+ @lifecycle_mutex.synchronize do
41
+ lifecycle = @lifecycle[:flush]
42
+ return true unless lifecycle
31
43
 
32
- @output.flush != false
44
+ call_lifecycle(:flush, lifecycle, timeout: timeout) != false
33
45
  end
34
46
  end
35
47
 
36
- def close
37
- @mutex.synchronize do
38
- return true if output_closed?
39
-
40
- result = if @close_output && @output.respond_to?(:close)
41
- @output.close
42
- elsif @output.respond_to?(:flush)
43
- @output.flush
44
- end
45
- result != false
48
+ def close(timeout: nil)
49
+ @lifecycle_mutex.synchronize do
50
+ # Close is terminal: lifecycle calls stay serialized, and the write mutex
51
+ # keeps the underlying output from being closed while a write is in flight.
52
+ @mutex.synchronize do
53
+ return true if output_closed?
54
+
55
+ result = if @close_output && @lifecycle[:close]
56
+ call_lifecycle(:close, @lifecycle.fetch(:close), timeout: timeout)
57
+ elsif @lifecycle[:flush]
58
+ call_lifecycle(:flush, @lifecycle.fetch(:flush), timeout: timeout)
59
+ end
60
+ result != false
61
+ end
46
62
  end
47
63
  end
48
64
 
@@ -51,6 +67,26 @@ module Julewire
51
67
  def output_closed?
52
68
  @output.respond_to?(:closed?) ? @output.closed? : false
53
69
  end
70
+
71
+ def call_lifecycle(name, lifecycle, timeout:)
72
+ return @output.public_send(name, timeout: timeout) if lifecycle.fetch(:timeout)
73
+
74
+ @output.public_send(name)
75
+ end
76
+
77
+ def lifecycle_methods
78
+ %i[flush close].each_with_object({}) do |name, methods|
79
+ next unless @output.respond_to?(name)
80
+
81
+ method = @output.method(name)
82
+ methods[name] = { timeout: accepts_timeout_keyword?(method) }.freeze
83
+ end.freeze
84
+ end
85
+
86
+ def accepts_timeout_keyword?(method)
87
+ method.parameters.any? { |type, name| TIMEOUT_PARAMETER_TYPES.include?(type) && name == :timeout } ||
88
+ method.parameters.any? { |type, _name| type == :keyrest }
89
+ end
54
90
  end
55
91
  end
56
92
  end
@@ -52,25 +52,25 @@ module Julewire
52
52
 
53
53
  def destination_info(destinations)
54
54
  destinations.transform_values do |destination|
55
- {
56
- counts: destination[:counts],
57
- last_failure: destination[:last_failure],
58
- last_loss: destination[:last_loss],
59
- status: destination[:status]
60
- }.compact
55
+ component_info(destination, include_loss: true)
61
56
  end
62
57
  end
63
58
 
64
59
  def integration_info(integrations)
65
60
  integrations.transform_values do |integration|
66
- {
67
- counts: integration[:counts],
68
- last_failure: integration[:last_failure],
69
- status: integration[:status]
70
- }.compact
61
+ component_info(integration)
71
62
  end
72
63
  end
73
64
 
65
+ def component_info(component, include_loss: false)
66
+ {
67
+ counts: component[:counts],
68
+ last_failure: component[:last_failure],
69
+ last_loss: include_loss ? component[:last_loss] : nil,
70
+ status: component[:status]
71
+ }.compact
72
+ end
73
+
74
74
  def warnings(health)
75
75
  [].tap do |items|
76
76
  items << warning(:runtime_closed, "runtime is closed") if health.fetch(:closed)
@@ -16,7 +16,9 @@ module Julewire
16
16
  integration: metadata[:integration],
17
17
  output_class: metadata[:output_class],
18
18
  phase: metadata[:phase],
19
- record: record_metadata(metadata[:record_metadata])
19
+ reason: metadata[:reason],
20
+ record: record_metadata(metadata[:record_metadata]),
21
+ status: metadata[:status]
20
22
  }.compact.freeze
21
23
  end
22
24
 
@@ -33,6 +33,8 @@ module Julewire
33
33
  end
34
34
 
35
35
  def finish(reason: :closed, fields: {}, attributes: {}, error: nil, severity: nil)
36
+ # Finishing is one-shot: retrying after partial summary mutation can
37
+ # duplicate completion data, so failures are reported and stop here.
36
38
  return false unless mark_finished
37
39
 
38
40
  add_completion_attributes(reason)
@@ -15,12 +15,12 @@ module Julewire
15
15
  clean_hash(execution, RELATIONSHIP_KEYS)
16
16
  end
17
17
 
18
- def clean_owned_execution_hash(execution)
19
- clean_hash!(execution, RELATIONSHIP_KEYS)
18
+ def clean_normalized_lazy_relationship_hash(execution)
19
+ clean_normalized_hash(execution, LAZY_RELATIONSHIP_KEYS)
20
20
  end
21
21
 
22
- def clean_lazy_relationship_hash(execution)
23
- clean_hash(execution, LAZY_RELATIONSHIP_KEYS)
22
+ def clean_owned_execution_hash(execution)
23
+ clean_hash!(execution, RELATIONSHIP_KEYS)
24
24
  end
25
25
 
26
26
  def from_execution_hash(execution)
@@ -50,6 +50,11 @@ module Julewire
50
50
  clean_hash!(copy, keys)
51
51
  end
52
52
 
53
+ def clean_normalized_hash(execution, keys)
54
+ copy = execution.is_a?(Hash) ? execution.dup : {}
55
+ clean_hash!(copy, keys)
56
+ end
57
+
53
58
  def clean_hash!(copy, keys)
54
59
  keys.each do |key|
55
60
  Fields::Internal.delete_key!(copy, key)
@@ -182,8 +187,7 @@ module Julewire
182
187
 
183
188
  def frozen_hash_copy(value)
184
189
  value.each_with_object({}) do |(key, field_value), copy|
185
- copy[Fields::Internal.normalize_key(key)] =
186
- Serialization::ValueCopy.call(field_value, freeze_values: true)
190
+ copy[key] = Serialization::ValueCopy.call(field_value, freeze_values: true)
187
191
  end
188
192
  end
189
193
  end
@@ -109,12 +109,12 @@ module Julewire
109
109
  @fields.with(section, fields, owned: owned, &)
110
110
  end
111
111
 
112
- def with_context(fields, &)
113
- with_field(:context, fields, &)
112
+ def with_context(fields = nil, owned: false, **keyword_fields, &)
113
+ @fields.with(:context, fields, owned: owned, **keyword_fields, &)
114
114
  end
115
115
 
116
- def with_carry(fields, &)
117
- with_field(:carry, fields, &)
116
+ def with_carry(fields = nil, owned: false, **keyword_fields, &)
117
+ @fields.with(:carry, fields, owned: owned, **keyword_fields, &)
118
118
  end
119
119
 
120
120
  def without_carry(path, &)
@@ -55,8 +55,8 @@ module Julewire
55
55
  @stacks.delete(section, path)
56
56
  end
57
57
 
58
- def with(section, fields, owned: false, &)
59
- @stacks.with(section, fields, owned: owned, &)
58
+ def with(section, fields = nil, owned: false, **keyword_fields, &)
59
+ @stacks.with(section, fields, owned: owned, **keyword_fields, &)
60
60
  end
61
61
 
62
62
  def without(section, path, &)
@@ -105,7 +105,7 @@ module Julewire
105
105
  envelope = Core::Propagation.capture_local
106
106
  Fiber.new(**) do |*args|
107
107
  with_cleared_configure_guard do
108
- Core::Propagation.restore(envelope) { yield(*args) }
108
+ Core::Propagation.restore(envelope, owned: true) { yield(*args) }
109
109
  end
110
110
  end
111
111
  end
@@ -132,7 +132,7 @@ module Julewire
132
132
  envelope = Core::Propagation.capture_local
133
133
  Thread.new(*) do |*thread_args|
134
134
  with_cleared_configure_guard do
135
- Core::Propagation.restore(envelope) { yield(*thread_args) }
135
+ Core::Propagation.restore(envelope, owned: true) { yield(*thread_args) }
136
136
  end
137
137
  end
138
138
  end
@@ -32,17 +32,19 @@ module Julewire
32
32
  end
33
33
 
34
34
  def deep_dup(value)
35
- return {} if value.is_a?(Hash) && value.empty?
36
- return [] if value.is_a?(Array) && value.empty?
35
+ deep_dup_with(value, preserve_truncation_metadata: false)
36
+ end
37
37
 
38
- Serialization::ValueCopy.call(value)
38
+ def deep_dup_owned(value)
39
+ deep_dup_with(value, preserve_truncation_metadata: true)
39
40
  end
40
41
 
41
42
  def deep_symbolize_keys(value)
42
- return {} if value.is_a?(Hash) && value.empty?
43
- return [] if value.is_a?(Array) && value.empty?
43
+ deep_symbolize_keys_with(value, preserve_truncation_metadata: false)
44
+ end
44
45
 
45
- Serialization::ValueCopy.call(value, symbolize_keys: true)
46
+ def deep_symbolize_owned_keys(value)
47
+ deep_symbolize_keys_with(value, preserve_truncation_metadata: true)
46
48
  end
47
49
 
48
50
  def frozen_copy(value)
@@ -60,6 +62,30 @@ module Julewire
60
62
 
61
63
  private
62
64
 
65
+ def deep_dup_with(value, preserve_truncation_metadata:)
66
+ return {} if value.is_a?(Hash) && value.empty?
67
+ return [] if value.is_a?(Array) && value.empty?
68
+
69
+ Serialization::ValueCopy.call(
70
+ value,
71
+ preserve_truncation_metadata: preserve_truncation_metadata
72
+ )
73
+ end
74
+
75
+ def deep_symbolize_keys_with(value, preserve_truncation_metadata:)
76
+ return {} if value.is_a?(Hash) && value.empty?
77
+ return [] if value.is_a?(Array) && value.empty?
78
+
79
+ Serialization::ValueCopy.call(
80
+ value,
81
+ max_array_items: Serialization::Serializer::DEFAULT_MAX_ARRAY_ITEMS,
82
+ max_hash_keys: Serialization::Serializer::DEFAULT_MAX_HASH_KEYS,
83
+ max_string_bytes: Serialization::Serializer::DEFAULT_MAX_STRING_BYTES,
84
+ preserve_truncation_metadata: preserve_truncation_metadata,
85
+ symbolize_keys: true
86
+ )
87
+ end
88
+
63
89
  def coerce_fields!(target, fields, invalid:)
64
90
  if fields.is_a?(Hash)
65
91
  merge!(target, fields)