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,1276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/current_attributes"
5
+ require "active_support/notifications"
6
+ require "digest"
7
+ require_relative "active_record_query"
8
+
9
+ module Upkeep
10
+ module Runtime
11
+ module Observation
12
+ THREAD_KEY = :upkeep_recorder
13
+
14
+ module_function
15
+
16
+ def capture_request(profile: false)
17
+ previous = Thread.current[THREAD_KEY]
18
+ recorder = Recorder.new(profile: profile)
19
+ Thread.current[THREAD_KEY] = recorder
20
+
21
+ result = yield(recorder)
22
+ recorder.flush_pending_dependencies
23
+ [result, recorder]
24
+ ensure
25
+ Thread.current[THREAD_KEY] = previous
26
+ end
27
+
28
+ def capture_frame(frame_id, metadata = {})
29
+ recorder = Thread.current[THREAD_KEY]
30
+ return yield unless recorder
31
+
32
+ recorder.with_frame(frame_id, metadata) { yield }
33
+ end
34
+
35
+ def record_dependency(dependency)
36
+ Thread.current[THREAD_KEY]&.record_dependency(dependency)
37
+ end
38
+
39
+ def record_ambient_replay_input(source, key, value)
40
+ Thread.current[THREAD_KEY]&.record_ambient_replay_input(source, key, value)
41
+ end
42
+
43
+ def record_relation_provenance(collection, model_name:, analysis:)
44
+ Thread.current[THREAD_KEY]&.record_relation_provenance(collection, model_name: model_name, analysis: analysis)
45
+ end
46
+
47
+ def relation_provenance_for(collection)
48
+ Thread.current[THREAD_KEY]&.relation_provenance_for(collection)
49
+ end
50
+
51
+ def refuse_boundary(boundary)
52
+ Thread.current[THREAD_KEY]&.refuse_boundary(**boundary)
53
+ end
54
+
55
+ def recorder
56
+ Thread.current[THREAD_KEY]
57
+ end
58
+
59
+ def recording?
60
+ !!Thread.current[THREAD_KEY]
61
+ end
62
+
63
+ end
64
+
65
+ RelationProvenance = Data.define(:model_name, :analysis) do
66
+ def primary_table = analysis.primary_table
67
+ def table_columns = analysis.table_columns
68
+ def coverage = analysis.coverage
69
+ def sql = analysis.sql
70
+ def primary_key = analysis.primary_key
71
+ def predicates = analysis.predicates
72
+ def appendable? = analysis.appendable?
73
+ def limit_value = analysis.limit_value
74
+ end
75
+
76
+ class Recorder
77
+ REQUEST_NODE_ID = :request
78
+ RefusedBoundary = Data.define(:reason, :message, :suggestions, :source)
79
+
80
+ attr_reader :graph, :refused_boundaries
81
+
82
+ def initialize(graph: nil, profile: false)
83
+ @frame_stack = []
84
+ @graph = graph || DAG::Graph.new
85
+ @profile = profile
86
+ @profile_timings = Hash.new(0.0)
87
+ @profile_counts = Hash.new(0)
88
+ @refused_boundaries = []
89
+ @ambient_replay_inputs_by_owner = Hash.new do |owners, owner_id|
90
+ owners[owner_id] = Hash.new { |sources, source| sources[source] = {} }
91
+ end
92
+ @pending_dependencies_by_owner = Hash.new { |owners, owner_id| owners[owner_id] = {} }
93
+ @relation_provenance_by_collection_id = {}
94
+ @graph.add_node(REQUEST_NODE_ID, kind: :request, payload: {}) unless @graph.node?(REQUEST_NODE_ID)
95
+ @subscription_shape_trace = DAG::SubscriptionShape::Trace.new(graph_version: @graph.version)
96
+ end
97
+
98
+ def self.from_h(snapshot)
99
+ snapshot = Dependencies.symbolize_keys(snapshot)
100
+ new(graph: DAG::Graph.from_h(snapshot.fetch(:graph)))
101
+ end
102
+
103
+ def replace_scope(scope_id, replacement)
104
+ replacement.graph.dependencies_for(REQUEST_NODE_ID).each do |dependency|
105
+ replacement.graph.add_dependency(scope_id, dependency)
106
+ end
107
+
108
+ composed = if graph.node?(scope_id)
109
+ graph.replace_subgraph(scope_id, replacement.graph)
110
+ else
111
+ graph.attach_subgraph(
112
+ scope_id,
113
+ replacement.graph,
114
+ parent_id: REQUEST_NODE_ID
115
+ )
116
+ end
117
+ self.class.new(graph: composed)
118
+ end
119
+
120
+ def to_h(dependencies: :all)
121
+ flush_pending_dependencies
122
+ { graph: graph.to_h(dependencies: dependencies) }
123
+ end
124
+
125
+ def to_persistent_h
126
+ to_h(dependencies: :identity)
127
+ end
128
+
129
+ def with_frame(frame_id, metadata)
130
+ profile_count(:recorder_frame_count)
131
+ parent_id = nil
132
+ profile_timing(:recorder_frame_ms) do
133
+ invalidate_subscription_shape_trace_if_needed
134
+ parent_id = current_owner
135
+ @graph.add_node(frame_id, kind: :frame, payload: metadata)
136
+ @graph.add_edge(parent_id, frame_id, reason: :contains)
137
+ profile_timing(:recorder_shape_trace_ms) do
138
+ @subscription_shape_trace.record_frame(frame_id, metadata, parent_id: parent_id, graph_version: @graph.version)
139
+ end
140
+ end
141
+ @frame_stack.push(frame_id)
142
+ begin
143
+ yield
144
+ ensure
145
+ flush_pending_dependencies(frame_id)
146
+ @frame_stack.pop
147
+ end
148
+ end
149
+
150
+ def flush_pending_dependencies(owner_id = nil)
151
+ if owner_id
152
+ flush_pending_dependencies_for(owner_id)
153
+ else
154
+ @pending_dependencies_by_owner.keys.each { |pending_owner_id| flush_pending_dependencies_for(pending_owner_id) }
155
+ end
156
+ ensure
157
+ @pending_dependencies_by_owner.delete(owner_id) if owner_id
158
+ end
159
+
160
+ def record_dependency(dependency)
161
+ profile_count(:recorder_dependency_count)
162
+ profile_timing(:recorder_dependency_ms) do
163
+ owner_id = current_owner
164
+ @pending_dependencies_by_owner[owner_id][dependency.cache_key] ||= dependency
165
+ end
166
+ end
167
+
168
+ def subscription_shape(request_signature: nil)
169
+ flush_pending_dependencies
170
+ return @subscription_shape_trace.subscription_shape(request_signature: request_signature) if @subscription_shape_trace.covers?(@graph)
171
+
172
+ DAG::SubscriptionShape.from_graph(@graph, request_signature: request_signature)
173
+ end
174
+
175
+ def record_ambient_replay_input(source, key, value)
176
+ profile_count(:recorder_ambient_replay_input_count)
177
+ profile_timing(:recorder_ambient_replay_input_ms) do
178
+ @ambient_replay_inputs_by_owner[current_owner][source.to_sym][key.to_s] = value
179
+ end
180
+ end
181
+
182
+ def ambient_replay_inputs_for(owner_id)
183
+ @ambient_replay_inputs_by_owner.fetch(owner_id, {}).each_with_object({}) do |(source, values), inputs|
184
+ inputs[source] = values.dup
185
+ end
186
+ end
187
+
188
+ def record_relation_provenance(collection, model_name:, analysis:)
189
+ return unless collection && analysis
190
+
191
+ profile_count(:recorder_relation_provenance_count)
192
+ profile_timing(:recorder_relation_provenance_ms) do
193
+ @relation_provenance_by_collection_id[collection.object_id] =
194
+ RelationProvenance.new(model_name.to_s, analysis)
195
+ end
196
+ end
197
+
198
+ def relation_provenance_for(collection)
199
+ @relation_provenance_by_collection_id[collection.object_id] if collection
200
+ end
201
+
202
+ def refuse_boundary(reason:, message:, suggestions:, source:)
203
+ profile_count(:recorder_refused_boundary_count)
204
+ profile_timing(:recorder_refused_boundary_ms) do
205
+ boundary = RefusedBoundary.new(
206
+ reason.to_s,
207
+ message.to_s,
208
+ Array(suggestions).map(&:to_s),
209
+ source.to_s
210
+ )
211
+ return false if @refused_boundaries.include?(boundary)
212
+
213
+ @refused_boundaries << boundary
214
+ true
215
+ end
216
+ end
217
+
218
+ def profile_timings
219
+ @profile_timings.transform_values { |value| value.round(3) }
220
+ end
221
+
222
+ def profile_counts
223
+ @profile_counts.dup
224
+ end
225
+
226
+ def reactive?
227
+ @refused_boundaries.empty?
228
+ end
229
+
230
+ def current_frame
231
+ @frame_stack.last
232
+ end
233
+
234
+ def current_owner
235
+ current_frame || REQUEST_NODE_ID
236
+ end
237
+
238
+ def identity_profile(frame_id)
239
+ flush_pending_dependencies
240
+ identity_dependencies_for(frame_id).map(&:to_h)
241
+ end
242
+
243
+ def identity_signature(frame_id)
244
+ flush_pending_dependencies
245
+ identity_dependencies = identity_dependencies_for(frame_id)
246
+ return "public" if identity_dependencies.empty?
247
+
248
+ Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
249
+ end
250
+
251
+ private
252
+
253
+ def invalidate_subscription_shape_trace_if_needed
254
+ @subscription_shape_trace.invalidate! unless @subscription_shape_trace.synchronized_with?(@graph)
255
+ end
256
+
257
+ def flush_pending_dependencies_for(owner_id)
258
+ dependencies = @pending_dependencies_by_owner.delete(owner_id)
259
+ return unless dependencies&.any?
260
+
261
+ profile_timing(:recorder_dependency_ms) do
262
+ dependencies.each_value do |dependency|
263
+ profile_count(:recorder_dependency_flush_count)
264
+ invalidate_subscription_shape_trace_if_needed
265
+ if @graph.add_dependency(owner_id, dependency)
266
+ profile_timing(:recorder_shape_trace_ms) do
267
+ @subscription_shape_trace.record_dependency(owner_id, dependency, graph_version: @graph.version)
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+
274
+ def profile_timing(key)
275
+ return yield unless @profile
276
+
277
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
278
+ yield
279
+ ensure
280
+ if @profile && started_at
281
+ @profile_timings[key] += (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
282
+ end
283
+ end
284
+
285
+ def profile_count(key)
286
+ return unless @profile
287
+
288
+ @profile_counts[key] += 1
289
+ end
290
+
291
+ def identity_dependencies_for(frame_id)
292
+ identity_dependency_owner_ids(frame_id)
293
+ .flat_map { |owner_id| @graph.dependencies_for(owner_id) }
294
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
295
+ .uniq(&:cache_key)
296
+ end
297
+
298
+ def identity_dependency_owner_ids(frame_id)
299
+ owner_ids = @graph.contained_node_ids(frame_id)
300
+ frame = @graph.node(frame_id)
301
+
302
+ if frame.kind == :frame && frame.payload[:kind] == "page"
303
+ owner_ids.concat(@graph.ancestor_node_ids(frame_id))
304
+ end
305
+
306
+ owner_ids
307
+ rescue KeyError
308
+ owner_ids
309
+ end
310
+ end
311
+
312
+ module ChangeLog
313
+ THREAD_KEY = :upkeep_change_log_events
314
+ @events = []
315
+ @mutex = Mutex.new
316
+
317
+ module_function
318
+
319
+ def reset
320
+ @mutex.synchronize { @events = [] }
321
+ Thread.current[THREAD_KEY] = nil
322
+ end
323
+
324
+ def record(event)
325
+ if (events = Thread.current[THREAD_KEY])
326
+ events << event
327
+ else
328
+ @mutex.synchronize { @events << event }
329
+ end
330
+ end
331
+
332
+ def events
333
+ if (events = Thread.current[THREAD_KEY])
334
+ events
335
+ else
336
+ @mutex.synchronize { @events.dup }
337
+ end
338
+ end
339
+
340
+ def capturing?
341
+ !Thread.current[THREAD_KEY].nil?
342
+ end
343
+
344
+ def drain
345
+ if (events = Thread.current[THREAD_KEY])
346
+ drained = events.dup
347
+ events.clear
348
+ drained
349
+ else
350
+ @mutex.synchronize do
351
+ events = @events
352
+ @events = []
353
+ events
354
+ end
355
+ end
356
+ end
357
+
358
+ def capture
359
+ previous = Thread.current[THREAD_KEY]
360
+ events = []
361
+ Thread.current[THREAD_KEY] = events
362
+
363
+ [yield, events.dup]
364
+ ensure
365
+ Thread.current[THREAD_KEY] = previous
366
+ end
367
+ end
368
+
369
+ module ChangeEvents
370
+ module_function
371
+
372
+ def active_record_commit(record)
373
+ return active_record_destroy(record) if record.destroyed?
374
+
375
+ attribute_changes = previous_changes(record.previous_changes, record.class)
376
+
377
+ {
378
+ type: created_record?(record, attribute_changes) ? "create" : "update",
379
+ table: record.class.table_name,
380
+ model: record.class.name,
381
+ id: record.id,
382
+ changed_attributes: attribute_changes.keys.sort,
383
+ old_values: attribute_changes.transform_values { |change| change.fetch(:old) },
384
+ new_values: attribute_changes.transform_values { |change| change.fetch(:new) },
385
+ attribute_changes: attribute_changes
386
+ }
387
+ end
388
+
389
+ def active_record_destroy(record)
390
+ old_values = record.attributes.to_h do |attribute, value|
391
+ [attribute.to_s, serialize_attribute_value(record.class, attribute, value)]
392
+ end
393
+
394
+ {
395
+ type: "destroy",
396
+ table: record.class.table_name,
397
+ model: record.class.name,
398
+ id: record.id,
399
+ changed_attributes: old_values.keys.sort,
400
+ old_values: old_values,
401
+ new_values: {},
402
+ attribute_changes: old_values.transform_values { |value| { old: value, new: nil } }
403
+ }
404
+ end
405
+
406
+ def active_record_update_columns(record, changed_attributes:, new_values: {})
407
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
408
+ new_values = new_values.to_h do |attribute, value|
409
+ attribute = attribute.to_s
410
+ [attribute, serialize_attribute_value(record.class, attribute, value)]
411
+ end
412
+
413
+ {
414
+ type: "update",
415
+ table: record.class.table_name,
416
+ model: record.class.name,
417
+ id: record.class.primary_key && record.public_send(record.class.primary_key),
418
+ changed_attributes: changed_attributes,
419
+ old_values: {},
420
+ new_values: new_values,
421
+ attribute_changes: changed_attributes.to_h do |attribute|
422
+ [attribute, { old: nil, new: new_values[attribute] }]
423
+ end
424
+ }
425
+ end
426
+
427
+ def bulk_update(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:, new_values: {}, id: nil)
428
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
429
+ new_values = new_values.transform_keys(&:to_s)
430
+
431
+ event = {
432
+ type: "bulk_update",
433
+ table: table,
434
+ model: model,
435
+ changed_attributes: changed_attributes,
436
+ old_values: {},
437
+ new_values: new_values,
438
+ attribute_changes: changed_attributes.to_h do |attribute|
439
+ [attribute, { old: nil, new: new_values[attribute] }]
440
+ end,
441
+ predicate_sql: predicate_sql,
442
+ predicate_coverage: predicate_coverage,
443
+ predicate_table_columns: predicate_table_columns
444
+ }
445
+ event[:id] = id unless id.nil?
446
+ event
447
+ end
448
+
449
+ def bulk_delete(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:, id: nil)
450
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
451
+
452
+ event = {
453
+ type: "bulk_delete",
454
+ table: table,
455
+ model: model,
456
+ changed_attributes: changed_attributes,
457
+ old_values: {},
458
+ new_values: {},
459
+ attribute_changes: changed_attributes.to_h { |attribute| [attribute, { old: nil, new: nil }] },
460
+ predicate_sql: predicate_sql,
461
+ predicate_coverage: predicate_coverage,
462
+ predicate_table_columns: predicate_table_columns
463
+ }
464
+ event[:id] = id unless id.nil?
465
+ event
466
+ end
467
+
468
+ def previous_changes(changes, model_class)
469
+ changes.to_h do |attribute, (old_value, new_value)|
470
+ attribute = attribute.to_s
471
+ [attribute, {
472
+ old: serialize_attribute_value(model_class, attribute, old_value),
473
+ new: serialize_attribute_value(model_class, attribute, new_value)
474
+ }]
475
+ end
476
+ end
477
+
478
+ def serialize_attribute_value(model_class, attribute, value)
479
+ model_class.type_for_attribute(attribute.to_s).serialize(value)
480
+ rescue StandardError
481
+ value
482
+ end
483
+
484
+ def created_record?(record, attribute_changes)
485
+ primary_key = record.class.primary_key
486
+ return false unless primary_key
487
+
488
+ primary_key_change = attribute_changes[primary_key.to_s]
489
+ primary_key_change && primary_key_change.fetch(:old).nil? && !primary_key_change.fetch(:new).nil?
490
+ end
491
+ end
492
+
493
+ module Ambient
494
+ module_function
495
+
496
+ def record_current_attribute(owner, name, value)
497
+ return unless Observation.recording?
498
+
499
+ dependency = Dependencies::CurrentAttribute.new(
500
+ owner: owner,
501
+ name: name,
502
+ value: value,
503
+ **identity_presence_metadata(:current, { owner: owner, name: name }, value)
504
+ )
505
+ Observation.record_dependency(dependency)
506
+ end
507
+
508
+ def record_session(key, value)
509
+ return unless Observation.recording?
510
+
511
+ dependency = Dependencies::SessionValue.new(
512
+ key: key,
513
+ value: value,
514
+ **identity_presence_metadata(:session, key, value)
515
+ )
516
+ Observation.record_dependency(dependency)
517
+ Observation.record_ambient_replay_input(:session, key, value)
518
+ end
519
+
520
+ def record_cookie(key, value)
521
+ return unless Observation.recording?
522
+
523
+ dependency = Dependencies::CookieValue.new(
524
+ key: key,
525
+ value: value,
526
+ **identity_presence_metadata(:cookie, key, value)
527
+ )
528
+ Observation.record_dependency(dependency)
529
+ Observation.record_ambient_replay_input(:cookie, key, value)
530
+ end
531
+
532
+ def record_request(key, value)
533
+ return unless Observation.recording?
534
+
535
+ dependency = Dependencies::RequestValue.new(key: key, value: value)
536
+ Observation.record_dependency(dependency)
537
+ Observation.record_ambient_replay_input(:request, key, value)
538
+ end
539
+
540
+ def record_warden_user(scope, user)
541
+ return unless Observation.recording?
542
+
543
+ dependency = Dependencies::WardenUser.new(
544
+ scope: scope,
545
+ user: user,
546
+ **identity_presence_metadata(:warden, scope, user)
547
+ )
548
+ Observation.record_dependency(dependency)
549
+ Observation.record_ambient_replay_input(:warden, scope, user)
550
+ end
551
+
552
+ def identity_presence_metadata(source, key, value)
553
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
554
+ Upkeep::Rails.configuration.identity_presence_metadata(source: source, key: key, value: value)
555
+ else
556
+ { partitioning: !value.nil?, absent_by_name: {} }
557
+ end
558
+ end
559
+ end
560
+
561
+ class ObservedHash
562
+ def initialize(source:, values:)
563
+ @source = source
564
+ @values = values || {}
565
+ end
566
+
567
+ def [](key)
568
+ value = lookup(key)
569
+ record(key, value)
570
+ value
571
+ end
572
+
573
+ def fetch(key, *fallback)
574
+ if include_key?(key)
575
+ self[key]
576
+ elsif block_given?
577
+ yield key
578
+ elsif fallback.any?
579
+ fallback.first
580
+ else
581
+ @values.fetch(key)
582
+ end
583
+ end
584
+
585
+ def dig(first_key, *rest)
586
+ value = self[first_key]
587
+ rest.empty? || value.nil? ? value : value.dig(*rest)
588
+ end
589
+
590
+ private
591
+
592
+ def lookup(key)
593
+ return @values[key] if @values.key?(key)
594
+ return @values[key.to_s] if @values.key?(key.to_s)
595
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
596
+
597
+ nil
598
+ end
599
+
600
+ def include_key?(key)
601
+ @values.key?(key) ||
602
+ @values.key?(key.to_s) ||
603
+ (key.respond_to?(:to_sym) && @values.key?(key.to_sym))
604
+ end
605
+
606
+ def record(key, value)
607
+ case @source
608
+ when :session
609
+ Ambient.record_session(key, value)
610
+ when :cookie
611
+ Ambient.record_cookie(key, value)
612
+ end
613
+ end
614
+ end
615
+
616
+ class ObservedRequest
617
+ def initialize(values)
618
+ @values = values || {}
619
+ end
620
+
621
+ def host = read(:host)
622
+
623
+ def subdomain = read(:subdomain)
624
+
625
+ def path = read(:path)
626
+
627
+ def fullpath = read(:fullpath)
628
+
629
+ def request_method = read(:request_method)
630
+
631
+ def user_agent = read(:user_agent)
632
+
633
+ def remote_ip = read(:remote_ip)
634
+
635
+ def params = read(:params)
636
+
637
+ def [](key)
638
+ read(key)
639
+ end
640
+
641
+ private
642
+
643
+ def read(key)
644
+ value = lookup(key)
645
+ Ambient.record_request(key, value)
646
+ value
647
+ end
648
+
649
+ def lookup(key)
650
+ return @values[key] if @values.key?(key)
651
+ return @values[key.to_s] if @values.key?(key.to_s)
652
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
653
+
654
+ nil
655
+ end
656
+ end
657
+
658
+ class ObservedWarden
659
+ def initialize(users_by_scope)
660
+ @users_by_scope = users_by_scope || {}
661
+ end
662
+
663
+ def user(scope = :user, **options)
664
+ scope = scope.fetch(:scope, :user) if scope.is_a?(Hash)
665
+ scope = options.fetch(:scope, scope)
666
+ value = @users_by_scope[scope] || @users_by_scope[scope.to_s] || @users_by_scope[scope.to_sym]
667
+ Ambient.record_warden_user(scope, value)
668
+ value
669
+ end
670
+
671
+ def authenticate(*args, **options)
672
+ user(extract_scope(args, options))
673
+ end
674
+
675
+ def authenticate!(*args, **options)
676
+ scope = extract_scope(args, options)
677
+ user(scope) || throw(:warden, scope: scope)
678
+ end
679
+
680
+ def authenticated?(*args, **options)
681
+ !user(extract_scope(args, options)).nil?
682
+ end
683
+
684
+ private
685
+
686
+ def extract_scope(args, options)
687
+ positional_options = args.last.is_a?(Hash) ? args.last : {}
688
+ options.fetch(:scope) { positional_options.fetch(:scope, :user) }
689
+ end
690
+ end
691
+
692
+ module CurrentAttributesClassObserver
693
+ def attribute(*names, **options)
694
+ result = super
695
+ Runtime.wrap_current_attribute_readers(self, names)
696
+ result
697
+ end
698
+ end
699
+
700
+ module WardenObserver
701
+ def user(*args, **options, &block)
702
+ value = super
703
+ Runtime::Ambient.record_warden_user(warden_user_scope(args, options), value)
704
+ value
705
+ end
706
+
707
+ def authenticate(*args, **options, &block)
708
+ value = super
709
+ Runtime::Ambient.record_warden_user(warden_authentication_scope(args, options), value)
710
+ value
711
+ end
712
+
713
+ def authenticate!(*args, **options, &block)
714
+ value = super
715
+ Runtime::Ambient.record_warden_user(warden_authentication_scope(args, options), value)
716
+ value
717
+ end
718
+
719
+ private
720
+
721
+ def warden_user_scope(args, options)
722
+ positional = args.first
723
+ positional = positional.fetch(:scope, config.default_scope) if positional.is_a?(Hash)
724
+ options.fetch(:scope) { positional || config.default_scope }
725
+ end
726
+
727
+ def warden_authentication_scope(args, options)
728
+ positional_options = args.last.is_a?(Hash) ? args.last : {}
729
+ options.fetch(:scope) { positional_options.fetch(:scope, config.default_scope) }
730
+ end
731
+ end
732
+
733
+ module SessionObserver
734
+ def [](key)
735
+ value = super
736
+ Runtime::Ambient.record_session(key, value)
737
+ value
738
+ end
739
+
740
+ def fetch(key, *args, &block)
741
+ value = super
742
+ Runtime::Ambient.record_session(key, value)
743
+ value
744
+ end
745
+ end
746
+
747
+ module CookieObserver
748
+ def [](key)
749
+ value = super
750
+ Runtime::Ambient.record_cookie(key, value)
751
+ value
752
+ end
753
+ end
754
+
755
+ module RequestObserver
756
+ def host
757
+ value = super
758
+ Runtime::Ambient.record_request(:host, value)
759
+ value
760
+ end
761
+
762
+ def subdomain
763
+ value = super
764
+ Runtime::Ambient.record_request(:subdomain, value)
765
+ value
766
+ end
767
+
768
+ def path
769
+ value = super
770
+ Runtime::Ambient.record_request(:path, value)
771
+ value
772
+ end
773
+
774
+ def fullpath
775
+ value = super
776
+ Runtime::Ambient.record_request(:fullpath, value)
777
+ value
778
+ end
779
+
780
+ def request_method
781
+ value = super
782
+ Runtime::Ambient.record_request(:request_method, value)
783
+ value
784
+ end
785
+
786
+ def user_agent
787
+ value = super
788
+ Runtime::Ambient.record_request(:user_agent, value)
789
+ value
790
+ end
791
+
792
+ def remote_ip
793
+ value = super
794
+ Runtime::Ambient.record_request(:remote_ip, value)
795
+ value
796
+ end
797
+
798
+ def params
799
+ value = super
800
+ Runtime::Ambient.record_request(:params, value)
801
+ value
802
+ end
803
+ end
804
+
805
+ class Current
806
+ THREAD_KEY = :upkeep_current_user
807
+
808
+ class << self
809
+ def set(user:)
810
+ previous = Thread.current[THREAD_KEY]
811
+ Thread.current[THREAD_KEY] = user
812
+ yield
813
+ ensure
814
+ Thread.current[THREAD_KEY] = previous
815
+ end
816
+
817
+ def user
818
+ user = Thread.current[THREAD_KEY]
819
+ return user unless Observation.recording?
820
+
821
+ dependency = Dependencies::Identity.new(
822
+ source: "Current.user",
823
+ key: "id",
824
+ value: user&.id,
825
+ metadata: {
826
+ model: user&.class&.name,
827
+ table: user&.class&.table_name,
828
+ id: user&.id
829
+ }.compact
830
+ )
831
+
832
+ Observation.record_dependency(dependency)
833
+ user
834
+ end
835
+ end
836
+ end
837
+
838
+ module AttributeObserver
839
+ def _read_attribute(attr_name, &block)
840
+ value = super
841
+ return value unless Observation.recording?
842
+
843
+ id = primary_key_value(attr_name, value)
844
+ dependency = Dependencies::ActiveRecordAttribute.new(
845
+ table: self.class.table_name,
846
+ model: self.class.name,
847
+ id: id,
848
+ attribute: attr_name.to_s,
849
+ scope: id.nil? ? upkeep_fresh_record_scope : nil
850
+ )
851
+
852
+ Observation.record_dependency(dependency)
853
+
854
+ value
855
+ end
856
+
857
+ private
858
+
859
+ def primary_key_value(attr_name, value)
860
+ primary_key = self.class.primary_key
861
+ return nil unless primary_key
862
+ return value if attr_name.to_s == primary_key.to_s
863
+
864
+ @attributes.fetch_value(primary_key)
865
+ rescue StandardError
866
+ nil
867
+ end
868
+
869
+ def upkeep_fresh_record_scope
870
+ return nil unless new_record?
871
+
872
+ self.class.reflect_on_all_associations(:belongs_to).each_with_object({}) do |reflection, scope|
873
+ foreign_key = reflection.foreign_key
874
+ next if foreign_key.is_a?(Array)
875
+
876
+ foreign_key = foreign_key.to_s
877
+ fk_value = upkeep_scope_attribute_value(foreign_key)
878
+ next if fk_value.nil?
879
+
880
+ if reflection.polymorphic?
881
+ foreign_type = reflection.foreign_type.to_s
882
+ type_value = upkeep_scope_attribute_value(foreign_type)
883
+ next if type_value.nil?
884
+
885
+ scope[foreign_type] = type_value
886
+ end
887
+
888
+ scope[foreign_key] = fk_value
889
+ end
890
+ rescue StandardError
891
+ nil
892
+ end
893
+
894
+ def upkeep_scope_attribute_value(name)
895
+ return nil unless @attributes.key?(name)
896
+
897
+ value = @attributes.fetch_value(name)
898
+ case value
899
+ when true, false, Numeric, String, Symbol
900
+ value
901
+ end
902
+ end
903
+ end
904
+
905
+ # Hooks `cache_key_with_version` so that any caller (Rails fragment caching,
906
+ # `Rails.cache.fetch("#{record.cache_key_with_version}/...")`, Russian doll,
907
+ # etc.) registers a dependency on the record's `updated_at` attribute. This
908
+ # lets Upkeep stay reactive across `Rails.cache.fetch` blocks: even on cache
909
+ # hits, the participating records are still declared as dependencies, so a
910
+ # touch/update broadcasts an update to subscribers viewing the cached
911
+ # fragment.
912
+ module CacheKeyObserver
913
+ def cache_key_with_version
914
+ record_upkeep_cache_key_dependency
915
+ super
916
+ end
917
+
918
+ private
919
+
920
+ def record_upkeep_cache_key_dependency
921
+ return unless Observation.recording?
922
+ return if new_record? || destroyed?
923
+
924
+ attribute = self.class.timestamp_attributes_for_update_in_model.first.to_s
925
+ Observation.record_dependency(
926
+ Dependencies::ActiveRecordAttribute.new(
927
+ table: self.class.table_name,
928
+ model: self.class.name,
929
+ id: id,
930
+ attribute: attribute
931
+ )
932
+ )
933
+ end
934
+ end
935
+
936
+ module PersistenceObserver
937
+ def touch(*names, **options)
938
+ changed_attributes = upkeep_touch_column_names(names)
939
+
940
+ super.tap do |result|
941
+ if result && changed_attributes.any?
942
+ ChangeLog.record(
943
+ ChangeEvents.active_record_update_columns(
944
+ self,
945
+ changed_attributes: changed_attributes,
946
+ new_values: upkeep_touch_column_values(changed_attributes)
947
+ )
948
+ )
949
+ end
950
+ end
951
+ end
952
+
953
+ def update_columns(attributes)
954
+ new_values = upkeep_update_column_values(attributes)
955
+ changed_attributes = new_values.keys
956
+
957
+ super.tap do |result|
958
+ if result
959
+ ChangeLog.record(
960
+ ChangeEvents.active_record_update_columns(
961
+ self,
962
+ changed_attributes: changed_attributes,
963
+ new_values: new_values
964
+ )
965
+ )
966
+ end
967
+ end
968
+ end
969
+
970
+ private
971
+
972
+ def upkeep_touch_column_names(names)
973
+ (self.class.timestamp_attributes_for_update_in_model + Array(names)).filter_map do |attribute|
974
+ attribute = self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
975
+ attribute if self.class.column_names.include?(attribute)
976
+ end.uniq
977
+ end
978
+
979
+ def upkeep_touch_column_values(attributes)
980
+ attributes.to_h { |attribute| [attribute, public_send(attribute)] }
981
+ end
982
+
983
+ def upkeep_update_column_values(attributes)
984
+ attributes.to_h.reject { |attribute, _value| attribute.to_s == "touch" }.transform_keys do |attribute|
985
+ self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
986
+ end
987
+ end
988
+ end
989
+
990
+ module RelationObserver
991
+ SUPPRESS_DEPENDENCY_KEY = :upkeep_runtime_relation_dependency_suppressed
992
+
993
+ def self.suppress_dependency_tracking
994
+ previous = Thread.current[SUPPRESS_DEPENDENCY_KEY]
995
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = true
996
+ yield
997
+ ensure
998
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = previous
999
+ end
1000
+
1001
+ def self.dependency_tracking_suppressed?
1002
+ Thread.current[SUPPRESS_DEPENDENCY_KEY]
1003
+ end
1004
+
1005
+ def exec_queries(...)
1006
+ analysis = relation_analysis_for_observation
1007
+ super.tap do |records|
1008
+ record_relation_provenance(records, analysis)
1009
+ record_relation_dependency(analysis)
1010
+ end
1011
+ end
1012
+
1013
+ def to_ary
1014
+ analysis = relation_analysis_for_observation
1015
+ super.tap do |records|
1016
+ record_relation_provenance(records, analysis)
1017
+ record_relation_dependency(analysis)
1018
+ end
1019
+ end
1020
+
1021
+ def to_a
1022
+ to_ary
1023
+ end
1024
+
1025
+ def pluck(*column_names)
1026
+ record_query_dependency(column_names)
1027
+ super
1028
+ end
1029
+
1030
+ def calculate(operation, column_name)
1031
+ record_calculation_dependency(operation, column_name)
1032
+ super
1033
+ end
1034
+
1035
+ def update_all(updates)
1036
+ analysis = ActiveRecordQuery.analyze_for_write(self)
1037
+ event = ChangeEvents.bulk_update(
1038
+ table: klass.table_name,
1039
+ model: klass.name,
1040
+ changed_attributes: update_columns(updates),
1041
+ predicate_sql: analysis.sql,
1042
+ predicate_coverage: analysis.coverage.to_s,
1043
+ predicate_table_columns: analysis.table_columns,
1044
+ new_values: update_values(updates),
1045
+ id: single_primary_key_predicate_value(analysis)
1046
+ )
1047
+
1048
+ super.tap { ChangeLog.record(event) }
1049
+ end
1050
+
1051
+ def delete_all
1052
+ analysis = ActiveRecordQuery.analyze_for_write(self)
1053
+ event = ChangeEvents.bulk_delete(
1054
+ table: klass.table_name,
1055
+ model: klass.name,
1056
+ changed_attributes: [klass.primary_key].compact,
1057
+ predicate_sql: analysis.sql,
1058
+ predicate_coverage: analysis.coverage.to_s,
1059
+ predicate_table_columns: analysis.table_columns,
1060
+ id: single_primary_key_predicate_value(analysis)
1061
+ )
1062
+
1063
+ super.tap { ChangeLog.record(event) }
1064
+ end
1065
+
1066
+ private
1067
+
1068
+ def relation_analysis_for_observation
1069
+ return unless Observation.recorder
1070
+ return if RelationObserver.dependency_tracking_suppressed?
1071
+ return @upkeep_relation_analysis if instance_variable_defined?(:@upkeep_relation_analysis)
1072
+
1073
+ @upkeep_relation_analysis = ActiveRecordQuery.analyze(self)
1074
+ rescue ActiveRecordQuery::OpaqueRelationError => error
1075
+ @upkeep_relation_analysis = nil
1076
+ handle_opaque_relation_dependency(error)
1077
+ nil
1078
+ end
1079
+
1080
+ def record_relation_provenance(records, analysis)
1081
+ Observation.record_relation_provenance(records, model_name: klass.name, analysis: analysis) if analysis
1082
+ end
1083
+
1084
+ def record_relation_dependency(analysis)
1085
+ return unless analysis
1086
+ return unless Observation.recorder&.current_frame
1087
+
1088
+ Observation.record_dependency(
1089
+ Dependencies::ActiveRecordQuery.new(
1090
+ primary_table: analysis.primary_table,
1091
+ table_columns: analysis.table_columns,
1092
+ coverage: analysis.coverage,
1093
+ sql: analysis.sql,
1094
+ predicates: analysis.predicates
1095
+ )
1096
+ )
1097
+ end
1098
+
1099
+ def record_query_dependency(column_names)
1100
+ return unless Observation.recorder
1101
+ return if RelationObserver.dependency_tracking_suppressed?
1102
+
1103
+ analysis = ActiveRecordQuery.analyze(reselect(*column_names))
1104
+ return unless analysis
1105
+
1106
+ Observation.record_dependency(
1107
+ Dependencies::ActiveRecordQuery.new(
1108
+ primary_table: analysis.primary_table,
1109
+ table_columns: analysis.table_columns,
1110
+ coverage: analysis.coverage,
1111
+ sql: analysis.sql,
1112
+ predicates: analysis.predicates
1113
+ )
1114
+ )
1115
+ rescue ActiveRecordQuery::OpaqueRelationError => error
1116
+ handle_opaque_relation_dependency(error)
1117
+ end
1118
+
1119
+ def record_calculation_dependency(operation, column_name)
1120
+ column_names = Array(column_name)
1121
+ column_names = select_values if operation.to_s == "count" && column_names.empty? && select_values.any?
1122
+ column_names = [klass.primary_key || Arel.star] if column_names.empty? || column_names == [:all] || column_names == ["*"]
1123
+
1124
+ record_query_dependency(column_names)
1125
+ end
1126
+
1127
+ def handle_opaque_relation_dependency(error)
1128
+ raise error if refused_boundary_behavior == :raise
1129
+
1130
+ payload = {
1131
+ reason: "opaque_active_record_relation",
1132
+ message: error.message,
1133
+ suggestions: error.suggestions,
1134
+ source: "active_record_relation"
1135
+ }
1136
+
1137
+ if Observation.refuse_boundary(payload)
1138
+ ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
1139
+ warn_refused_boundary(payload)
1140
+ end
1141
+ end
1142
+
1143
+ def refused_boundary_behavior
1144
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
1145
+ Upkeep::Rails.configuration.refused_boundary_behavior
1146
+ else
1147
+ :raise
1148
+ end
1149
+ end
1150
+
1151
+ def warn_refused_boundary(payload)
1152
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
1153
+
1154
+ ::Rails.logger.warn(
1155
+ "Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
1156
+ "#{payload.fetch(:suggestions).join(" ")}"
1157
+ )
1158
+ end
1159
+
1160
+ def update_columns(updates)
1161
+ case updates
1162
+ when Hash
1163
+ updates.keys.map(&:to_s)
1164
+ else
1165
+ klass.column_names
1166
+ end
1167
+ end
1168
+
1169
+ def update_values(updates)
1170
+ return {} unless updates.is_a?(Hash)
1171
+
1172
+ updates.transform_keys(&:to_s)
1173
+ end
1174
+
1175
+ def single_primary_key_predicate_value(analysis)
1176
+ primary_key = analysis.primary_key
1177
+ return unless primary_key
1178
+
1179
+ predicates = analysis.predicates.select do |predicate|
1180
+ predicate.fetch(:table) == analysis.primary_table.to_s &&
1181
+ predicate.fetch(:column) == primary_key.to_s &&
1182
+ %w[eq in].include?(predicate.fetch(:operator).to_s)
1183
+ end
1184
+ return unless predicates.size == 1
1185
+
1186
+ values = predicates.first.fetch(:values)
1187
+ values.first if values.size == 1
1188
+ end
1189
+ end
1190
+
1191
+ module Install
1192
+ module_function
1193
+
1194
+ def call
1195
+ return if @installed
1196
+
1197
+ install_current_attributes_observer
1198
+ install_warden_observer
1199
+ install_action_dispatch_observers
1200
+
1201
+ ActiveRecord::AttributeMethods::Read.prepend(AttributeObserver)
1202
+ ActiveRecord::Base.prepend(PersistenceObserver) unless ActiveRecord::Base < PersistenceObserver
1203
+ ActiveRecord::Base.prepend(CacheKeyObserver) unless ActiveRecord::Base < CacheKeyObserver
1204
+ ActiveRecord::Relation.prepend(RelationObserver)
1205
+
1206
+ ActiveRecord::Base.after_commit do |record|
1207
+ ChangeLog.record(ChangeEvents.active_record_commit(record))
1208
+ end
1209
+
1210
+ @installed = true
1211
+ end
1212
+
1213
+ def install_current_attributes_observer
1214
+ singleton = class << ActiveSupport::CurrentAttributes; self; end
1215
+ singleton.prepend(CurrentAttributesClassObserver) unless singleton < CurrentAttributesClassObserver
1216
+
1217
+ ObjectSpace.each_object(Class) do |klass|
1218
+ next unless klass < ActiveSupport::CurrentAttributes
1219
+
1220
+ Runtime.wrap_current_attribute_readers(klass, current_attribute_names(klass))
1221
+ end
1222
+ end
1223
+
1224
+ def install_warden_observer
1225
+ return unless defined?(::Warden::Proxy)
1226
+ return if ::Warden::Proxy < WardenObserver
1227
+
1228
+ ::Warden::Proxy.prepend(WardenObserver)
1229
+ end
1230
+
1231
+ def install_action_dispatch_observers
1232
+ if defined?(::ActionDispatch::Request::Session) && !(::ActionDispatch::Request::Session < SessionObserver)
1233
+ ::ActionDispatch::Request::Session.prepend(SessionObserver)
1234
+ end
1235
+
1236
+ if defined?(::ActionDispatch::Cookies::CookieJar) && !(::ActionDispatch::Cookies::CookieJar < CookieObserver)
1237
+ ::ActionDispatch::Cookies::CookieJar.prepend(CookieObserver)
1238
+ end
1239
+
1240
+ if defined?(::ActionDispatch::Request) && !(::ActionDispatch::Request < RequestObserver)
1241
+ ::ActionDispatch::Request.prepend(RequestObserver)
1242
+ end
1243
+ end
1244
+
1245
+ def current_attribute_names(klass)
1246
+ if klass.respond_to?(:defaults)
1247
+ klass.defaults.keys
1248
+ else
1249
+ []
1250
+ end
1251
+ end
1252
+ end
1253
+
1254
+ module_function
1255
+
1256
+ def wrap_current_attribute_readers(klass, names)
1257
+ wrapped = klass.instance_variable_get(:@upkeep_wrapped_current_attributes) || {}
1258
+
1259
+ names.each do |name|
1260
+ name = name.to_sym
1261
+ next if wrapped[name]
1262
+ next unless klass.method_defined?(name)
1263
+
1264
+ original_reader = klass.instance_method(name)
1265
+ klass.define_method(name) do
1266
+ value = original_reader.bind_call(self)
1267
+ Runtime::Ambient.record_current_attribute(klass.name || klass.inspect, name, value)
1268
+ value
1269
+ end
1270
+ wrapped[name] = true
1271
+ end
1272
+
1273
+ klass.instance_variable_set(:@upkeep_wrapped_current_attributes, wrapped)
1274
+ end
1275
+ end
1276
+ end