upkeep-rails 0.2.5-aarch64-linux-gnu

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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
@@ -0,0 +1,600 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Upkeep
7
+ module Dependencies
8
+ class Base
9
+ attr_reader :source, :key, :metadata
10
+
11
+ def initialize(source:, key:, metadata: {})
12
+ @source = source
13
+ @key = key
14
+ @metadata = metadata
15
+ end
16
+
17
+ def cache_key
18
+ [source, key]
19
+ end
20
+
21
+ def matches_change?(_change)
22
+ false
23
+ end
24
+
25
+ def identity?
26
+ false
27
+ end
28
+
29
+ def identity_key
30
+ nil
31
+ end
32
+
33
+ def visibility
34
+ :public
35
+ end
36
+
37
+ def precision
38
+ :unknown
39
+ end
40
+
41
+ def narrow_frame_safe?
42
+ false
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ source: source,
48
+ key: key,
49
+ visibility: visibility,
50
+ precision: precision,
51
+ metadata: metadata
52
+ }
53
+ end
54
+ end
55
+
56
+ class ActiveRecordAttribute < Base
57
+ def initialize(table:, id:, attribute:, model: nil, scope: nil)
58
+ scope = normalize_scope(scope)
59
+ key = { table: table, id: id, attribute: attribute }
60
+ key[:scope] = scope if scope
61
+ super(
62
+ source: :active_record_attribute,
63
+ key: key,
64
+ metadata: { model: model }.compact
65
+ )
66
+ end
67
+
68
+ def matches_change?(change)
69
+ key.fetch(:table) == change.fetch(:table) &&
70
+ (key.fetch(:id).nil? || !change[:id] || key.fetch(:id) == change[:id]) &&
71
+ change.fetch(:changed_attributes, []).include?(key.fetch(:attribute)) &&
72
+ scope_matches_change?(change)
73
+ end
74
+
75
+ def precision
76
+ :record_attribute
77
+ end
78
+
79
+ def narrow_frame_safe?
80
+ true
81
+ end
82
+
83
+ private
84
+
85
+ def normalize_scope(scope)
86
+ return nil unless scope.is_a?(Hash)
87
+
88
+ normalized = scope.each_with_object({}) do |(column, value), result|
89
+ result[column.to_s] = value unless value.nil?
90
+ end
91
+ normalized.empty? ? nil : normalized
92
+ end
93
+
94
+ def scope_matches_change?(change)
95
+ scope = key[:scope]
96
+ return true unless scope
97
+
98
+ scope.all? do |column, value|
99
+ observed = observed_scope_values(change, column)
100
+ observed.empty? || observed.any? { |observed_value| scope_values_equal?(observed_value, value) }
101
+ end
102
+ end
103
+
104
+ def observed_scope_values(change, column)
105
+ [change[:new_values], change[:old_values]].compact.filter_map do |values|
106
+ values[column] || values[column.to_sym]
107
+ end
108
+ end
109
+
110
+ def scope_values_equal?(observed, expected)
111
+ observed == expected || observed.to_s == expected.to_s
112
+ end
113
+ end
114
+
115
+ class ActiveRecordCollection < Base
116
+ UNKNOWN = Object.new
117
+
118
+ def initialize(
119
+ primary_table:,
120
+ table_columns:,
121
+ coverage:,
122
+ sql:,
123
+ predicates: [],
124
+ source: :active_record_collection,
125
+ precision: :collection_predicate
126
+ )
127
+ table_columns = normalize_table_columns(table_columns)
128
+ coverage = coverage.to_sym
129
+ unless coverage == :columns
130
+ raise ArgumentError,
131
+ "unsupported Active Record predicate coverage: #{coverage}; collection dependencies require proven column coverage"
132
+ end
133
+
134
+ @precision = precision.to_sym
135
+ super(
136
+ source: source.to_sym,
137
+ key: {
138
+ table: primary_table,
139
+ predicate_digest: Digest::SHA256.hexdigest(sql)[0, 16]
140
+ },
141
+ metadata: {
142
+ primary_table: primary_table,
143
+ table_columns: table_columns,
144
+ coverage: coverage.to_s,
145
+ sql: sql,
146
+ predicates: normalize_predicates(predicates)
147
+ }
148
+ )
149
+ end
150
+
151
+ def matches_change?(change)
152
+ return false unless table_columns.key?(change.fetch(:table))
153
+
154
+ predicate_match = predicate_match(change)
155
+ return predicate_match unless predicate_match == UNKNOWN
156
+
157
+ return true if create_change?(change)
158
+ return true if delete_change?(change)
159
+
160
+ table_columns.fetch(change.fetch(:table)).intersect?(change.fetch(:changed_attributes, []))
161
+ end
162
+
163
+ def precision
164
+ @precision
165
+ end
166
+
167
+ def collection_lookup_tables
168
+ table_columns.keys.sort
169
+ end
170
+
171
+ def collection_lookup_columns
172
+ table_columns.flat_map do |table, columns|
173
+ columns.map { |column| [table, column] }
174
+ end.sort
175
+ end
176
+
177
+ private
178
+
179
+ def predicate_match(change)
180
+ predicates = predicates_for_table(change.fetch(:table))
181
+ return UNKNOWN if predicates.empty?
182
+
183
+ old_match = values_match_predicates(change.fetch(:old_values, {}), predicates)
184
+ new_match = values_match_predicates(change.fetch(:new_values, {}), predicates)
185
+ return true if old_match == true || new_match == true
186
+
187
+ if old_match == false || new_match == false
188
+ return false if predicate_columns(predicates).intersect?(change.fetch(:changed_attributes, [])) ||
189
+ create_change?(change) ||
190
+ delete_change?(change)
191
+ end
192
+
193
+ UNKNOWN
194
+ end
195
+
196
+ def create_change?(change)
197
+ change.fetch(:type).to_s.include?("create")
198
+ end
199
+
200
+ def delete_change?(change)
201
+ type = change.fetch(:type).to_s
202
+ type.include?("delete") || type.include?("destroy")
203
+ end
204
+
205
+ def values_match_predicates(values, predicates)
206
+ values = stringify_keys(values)
207
+ group_matches = predicate_groups(predicates).map do |_group, group_predicates|
208
+ values_match_predicate_group(values, group_predicates)
209
+ end
210
+
211
+ return true if group_matches.include?(true)
212
+ return false if group_matches.all?(false)
213
+
214
+ UNKNOWN
215
+ end
216
+
217
+ def values_match_predicate_group(values, predicates)
218
+ return UNKNOWN unless predicates.all? { |predicate| values.key?(predicate.fetch(:column)) }
219
+
220
+ matches = predicates.map do |predicate|
221
+ predicate_matches_value?(predicate, values.fetch(predicate.fetch(:column)))
222
+ end
223
+ return UNKNOWN if matches.include?(UNKNOWN)
224
+
225
+ matches.all?
226
+ end
227
+
228
+ def predicate_matches_value?(predicate, value)
229
+ values = predicate.fetch(:values)
230
+
231
+ case predicate.fetch(:operator).to_s
232
+ when "eq", "in"
233
+ values.include?(value)
234
+ when "not_eq", "not_in"
235
+ !value.nil? && !values.include?(value)
236
+ else
237
+ UNKNOWN
238
+ end
239
+ end
240
+
241
+ def predicate_groups(predicates)
242
+ predicates.group_by { |predicate| predicate.fetch(:group, "__all__").to_s }
243
+ end
244
+
245
+ def predicates_for_table(table)
246
+ predicates.select { |predicate| predicate.fetch(:table) == table.to_s }
247
+ end
248
+
249
+ def predicate_columns(predicates)
250
+ predicates.map { |predicate| predicate.fetch(:column) }.uniq
251
+ end
252
+
253
+ def coverage
254
+ metadata.fetch(:coverage).to_sym.tap do |value|
255
+ raise ArgumentError, "unsupported Active Record collection coverage: #{value}" unless value == :columns
256
+ end
257
+ end
258
+
259
+ def table_columns
260
+ metadata.fetch(:table_columns)
261
+ end
262
+
263
+ def predicates
264
+ metadata.fetch(:predicates)
265
+ end
266
+
267
+ def normalize_table_columns(value)
268
+ Dependencies.symbolize_keys(value).to_h do |table, columns|
269
+ [table.to_s, Array(columns).map(&:to_s).uniq.sort]
270
+ end
271
+ end
272
+
273
+ def normalize_predicates(value)
274
+ Array(value).filter_map do |predicate|
275
+ predicate = Dependencies.symbolize_keys(predicate)
276
+ values = Array(predicate[:values])
277
+ next if predicate[:table].nil? || predicate[:column].nil? || values.empty?
278
+
279
+ normalized = {
280
+ table: predicate.fetch(:table).to_s,
281
+ column: predicate.fetch(:column).to_s,
282
+ operator: predicate.fetch(:operator, "eq").to_s,
283
+ values: values.uniq
284
+ }
285
+ normalized[:group] = predicate.fetch(:group).to_s if predicate.key?(:group)
286
+ normalized
287
+ end
288
+ end
289
+
290
+ def stringify_keys(values)
291
+ values.to_h.transform_keys(&:to_s)
292
+ end
293
+ end
294
+
295
+ class ActiveRecordQuery < ActiveRecordCollection
296
+ def initialize(primary_table:, table_columns:, coverage:, sql:, predicates: [])
297
+ super(
298
+ primary_table: primary_table,
299
+ table_columns: table_columns,
300
+ coverage: coverage,
301
+ sql: sql,
302
+ predicates: predicates,
303
+ source: :active_record_query,
304
+ precision: :query_predicate
305
+ )
306
+ end
307
+ end
308
+
309
+ class Identity < Base
310
+ def initialize(source:, key:, value:, metadata: {}, partitioning: nil, absent_by_name: nil)
311
+ metadata = metadata.dup
312
+ metadata[:partitioning_identity] = partitioning unless partitioning.nil?
313
+ if absent_by_name&.any?
314
+ metadata[:identity_absent_by_name] = absent_by_name.to_h.transform_keys(&:to_s)
315
+ end
316
+
317
+ super(
318
+ source: source,
319
+ key: { key: key, value: value },
320
+ metadata: metadata
321
+ )
322
+ end
323
+
324
+ def identity?
325
+ true
326
+ end
327
+
328
+ def identity_key
329
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
330
+ end
331
+
332
+ def nil_identity?
333
+ return true if key.fetch(:value).nil?
334
+
335
+ value_class = metadata[:value_class] || metadata["value_class"]
336
+ value_class.to_s == "NilClass"
337
+ end
338
+
339
+ def visibility
340
+ :identity_bound
341
+ end
342
+
343
+ def precision
344
+ :identity
345
+ end
346
+ end
347
+
348
+ class WardenUser < Identity
349
+ def initialize(scope:, user:, partitioning: nil, absent_by_name: nil)
350
+ super(
351
+ source: :warden_user,
352
+ key: scope.to_s,
353
+ value: Dependencies.model_identity(user),
354
+ metadata: Dependencies.model_metadata(user).merge(scope: scope.to_s),
355
+ partitioning: partitioning,
356
+ absent_by_name: absent_by_name
357
+ )
358
+ end
359
+ end
360
+
361
+ class CurrentAttribute < Identity
362
+ def initialize(owner:, name:, value:, partitioning: nil, absent_by_name: nil)
363
+ super(
364
+ source: :current_attribute,
365
+ key: "#{owner}.#{name}",
366
+ value: Dependencies.canonical_identity(value),
367
+ metadata: { owner: owner.to_s, name: name.to_s },
368
+ partitioning: partitioning,
369
+ absent_by_name: absent_by_name
370
+ )
371
+ end
372
+ end
373
+
374
+ class SessionValue < Identity
375
+ def initialize(key:, value:, partitioning: nil, absent_by_name: nil)
376
+ super(
377
+ source: :session,
378
+ key: key.to_s,
379
+ value: Dependencies.private_fingerprint(value),
380
+ metadata: { key: key.to_s, value_class: value.class.name },
381
+ partitioning: partitioning,
382
+ absent_by_name: absent_by_name
383
+ )
384
+ end
385
+ end
386
+
387
+ class CookieValue < Identity
388
+ def initialize(key:, value:, partitioning: nil, absent_by_name: nil)
389
+ super(
390
+ source: :cookie,
391
+ key: key.to_s,
392
+ value: Dependencies.private_fingerprint(value),
393
+ metadata: { key: key.to_s, value_class: value.class.name },
394
+ partitioning: partitioning,
395
+ absent_by_name: absent_by_name
396
+ )
397
+ end
398
+ end
399
+
400
+ class RequestValue < Identity
401
+ # Request values that are stable for a deployment/connection rather than a viewer.
402
+ # They never partition subscribers, but their fingerprints are folded into shared
403
+ # stream names (see SharedStreams.deployment_signature_for) so viewers with
404
+ # different values can never share a stream.
405
+ DEPLOYMENT_STABLE_KEYS = %w[host port protocol ssl request_method].freeze
406
+
407
+ def initialize(key:, value:)
408
+ super(
409
+ source: :request,
410
+ key: key.to_s,
411
+ value: Dependencies.private_fingerprint(value),
412
+ metadata: { key: key.to_s, value_class: value.class.name },
413
+ partitioning: DEPLOYMENT_STABLE_KEYS.include?(key.to_s) ? false : nil
414
+ )
415
+ end
416
+ end
417
+
418
+ class Unknown < Base
419
+ def initialize(source:, metadata: {})
420
+ super(
421
+ source: source,
422
+ key: Digest::SHA256.hexdigest(metadata.inspect)[0, 16],
423
+ metadata: metadata
424
+ )
425
+ end
426
+
427
+ def visibility
428
+ :private
429
+ end
430
+ end
431
+
432
+ class Restored < Base
433
+ def initialize(source:, key:, metadata:, visibility:, precision:)
434
+ super(source: source, key: key, metadata: metadata)
435
+ @visibility = visibility.to_sym
436
+ @precision = precision.to_sym
437
+ end
438
+
439
+ def identity?
440
+ visibility == :identity_bound
441
+ end
442
+
443
+ def identity_key
444
+ return unless identity?
445
+
446
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
447
+ end
448
+
449
+ attr_reader :visibility, :precision
450
+ end
451
+
452
+ module_function
453
+
454
+ def from_h(snapshot)
455
+ snapshot = symbolize_keys(snapshot)
456
+ source = snapshot.fetch(:source)
457
+ key = symbolize_keys(snapshot.fetch(:key))
458
+ metadata = symbolize_keys(snapshot.fetch(:metadata))
459
+
460
+ case source.to_sym
461
+ when :active_record_attribute
462
+ ActiveRecordAttribute.new(
463
+ table: key.fetch(:table),
464
+ id: key.fetch(:id),
465
+ attribute: key.fetch(:attribute),
466
+ model: metadata[:model],
467
+ scope: key[:scope]
468
+ )
469
+ when :active_record_collection, :active_record_query
470
+ dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
471
+ dependency_class.new(
472
+ primary_table: metadata.fetch(:primary_table),
473
+ table_columns: metadata.fetch(:table_columns),
474
+ coverage: metadata.fetch(:coverage),
475
+ sql: metadata.fetch(:sql),
476
+ predicates: metadata.fetch(:predicates)
477
+ )
478
+ else
479
+ if snapshot.fetch(:visibility).to_sym == :identity_bound
480
+ Identity.new(
481
+ source: source,
482
+ key: key.fetch(:key),
483
+ value: key.fetch(:value),
484
+ metadata: metadata
485
+ )
486
+ else
487
+ Restored.new(
488
+ source: source,
489
+ key: key,
490
+ metadata: metadata,
491
+ visibility: snapshot.fetch(:visibility),
492
+ precision: snapshot.fetch(:precision)
493
+ )
494
+ end
495
+ end
496
+ end
497
+
498
+ def model_identity(value)
499
+ return nil unless value
500
+
501
+ if value.respond_to?(:id) && value.class.respond_to?(:name)
502
+ { model: value.class.name, id: value.id }
503
+ end
504
+ end
505
+
506
+ def model_metadata(value)
507
+ return {} unless value
508
+
509
+ {
510
+ model: value.class.name,
511
+ table: value.class.respond_to?(:table_name) ? value.class.table_name : nil,
512
+ id: value.respond_to?(:id) ? value.id : nil
513
+ }.compact
514
+ end
515
+
516
+ def canonical_identity(value)
517
+ case value
518
+ when nil, true, false, Numeric, String, Symbol
519
+ value
520
+ else
521
+ model_identity(value) || private_fingerprint(value)
522
+ end
523
+ end
524
+
525
+ def partitioning_identity?(dependency)
526
+ return false unless dependency.identity?
527
+
528
+ flag = metadata_value(dependency, :partitioning_identity)
529
+ return flag if [true, false].include?(flag)
530
+
531
+ !nil_identity?(dependency)
532
+ end
533
+
534
+ def deployment_stable_request?(dependency)
535
+ return false unless dependency.identity?
536
+ return false unless dependency.source.to_s == "request"
537
+
538
+ RequestValue::DEPLOYMENT_STABLE_KEYS.include?(dependency.key.fetch(:key).to_s)
539
+ end
540
+
541
+ def identity_absent_for?(dependency, name)
542
+ absent_by_name = metadata_value(dependency, :identity_absent_by_name) || {}
543
+ absent_by_name = absent_by_name.transform_keys(&:to_s) if absent_by_name.respond_to?(:transform_keys)
544
+ return absent_by_name.fetch(name.to_s) if absent_by_name.key?(name.to_s)
545
+
546
+ !partitioning_identity?(dependency)
547
+ end
548
+
549
+ def nil_identity?(dependency)
550
+ dependency.respond_to?(:nil_identity?) && dependency.nil_identity?
551
+ end
552
+
553
+ def metadata_value(dependency, key)
554
+ return unless dependency.respond_to?(:metadata)
555
+
556
+ if dependency.metadata.key?(key)
557
+ dependency.metadata.fetch(key)
558
+ elsif dependency.metadata.key?(key.to_s)
559
+ dependency.metadata.fetch(key.to_s)
560
+ end
561
+ end
562
+
563
+ def private_fingerprint(value)
564
+ Digest::SHA256.hexdigest(JSON.generate(private_fingerprint_payload(value)))[0, 16]
565
+ end
566
+
567
+ def private_fingerprint_payload(value)
568
+ case value
569
+ when nil, true, false, Numeric, String
570
+ [value.class.name, value]
571
+ when Symbol
572
+ ["Symbol", value.to_s]
573
+ when Array
574
+ ["Array", value.map { |item| private_fingerprint_payload(item) }]
575
+ when Hash
576
+ entries = value.keys.sort_by { |key| JSON.generate(private_fingerprint_payload(key)) }.map do |key|
577
+ [private_fingerprint_payload(key), private_fingerprint_payload(value.fetch(key))]
578
+ end
579
+ ["Hash", entries]
580
+ else
581
+ identity = model_identity(value)
582
+ identity ? ["Model", identity] : ["Object", value.class.name, value.inspect]
583
+ end
584
+ end
585
+
586
+ def symbolize_keys(value)
587
+ case value
588
+ when Hash
589
+ value.each_with_object({}) do |(key, nested_value), result|
590
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
591
+ result[normalized_key] = symbolize_keys(nested_value)
592
+ end
593
+ when Array
594
+ value.map { |nested_value| symbolize_keys(nested_value) }
595
+ else
596
+ value
597
+ end
598
+ end
599
+ end
600
+ end