upkeep-rails 0.1.6

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,1100 @@
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 to_h(dependencies: :all)
104
+ flush_pending_dependencies
105
+ { graph: graph.to_h(dependencies: dependencies) }
106
+ end
107
+
108
+ def to_persistent_h
109
+ to_h(dependencies: :identity)
110
+ end
111
+
112
+ def with_frame(frame_id, metadata)
113
+ profile_count(:recorder_frame_count)
114
+ parent_id = nil
115
+ profile_timing(:recorder_frame_ms) do
116
+ invalidate_subscription_shape_trace_if_needed
117
+ parent_id = current_owner
118
+ @graph.add_node(frame_id, kind: :frame, payload: metadata)
119
+ @graph.add_edge(parent_id, frame_id, reason: :contains)
120
+ profile_timing(:recorder_shape_trace_ms) do
121
+ @subscription_shape_trace.record_frame(frame_id, metadata, parent_id: parent_id, graph_version: @graph.version)
122
+ end
123
+ end
124
+ @frame_stack.push(frame_id)
125
+ begin
126
+ yield
127
+ ensure
128
+ flush_pending_dependencies(frame_id)
129
+ @frame_stack.pop
130
+ end
131
+ end
132
+
133
+ def flush_pending_dependencies(owner_id = nil)
134
+ if owner_id
135
+ flush_pending_dependencies_for(owner_id)
136
+ else
137
+ @pending_dependencies_by_owner.keys.each { |pending_owner_id| flush_pending_dependencies_for(pending_owner_id) }
138
+ end
139
+ ensure
140
+ @pending_dependencies_by_owner.delete(owner_id) if owner_id
141
+ end
142
+
143
+ def record_dependency(dependency)
144
+ profile_count(:recorder_dependency_count)
145
+ profile_timing(:recorder_dependency_ms) do
146
+ owner_id = current_owner
147
+ @pending_dependencies_by_owner[owner_id][dependency.cache_key] ||= dependency
148
+ end
149
+ end
150
+
151
+ def subscription_shape(request_signature: nil)
152
+ flush_pending_dependencies
153
+ return @subscription_shape_trace.subscription_shape(request_signature: request_signature) if @subscription_shape_trace.covers?(@graph)
154
+
155
+ DAG::SubscriptionShape.from_graph(@graph, request_signature: request_signature)
156
+ end
157
+
158
+ def record_ambient_replay_input(source, key, value)
159
+ profile_count(:recorder_ambient_replay_input_count)
160
+ profile_timing(:recorder_ambient_replay_input_ms) do
161
+ @ambient_replay_inputs_by_owner[current_owner][source.to_sym][key.to_s] = value
162
+ end
163
+ end
164
+
165
+ def ambient_replay_inputs_for(owner_id)
166
+ @ambient_replay_inputs_by_owner.fetch(owner_id, {}).each_with_object({}) do |(source, values), inputs|
167
+ inputs[source] = values.dup
168
+ end
169
+ end
170
+
171
+ def record_relation_provenance(collection, model_name:, analysis:)
172
+ return unless collection && analysis
173
+
174
+ profile_count(:recorder_relation_provenance_count)
175
+ profile_timing(:recorder_relation_provenance_ms) do
176
+ @relation_provenance_by_collection_id[collection.object_id] =
177
+ RelationProvenance.new(model_name.to_s, analysis)
178
+ end
179
+ end
180
+
181
+ def relation_provenance_for(collection)
182
+ @relation_provenance_by_collection_id[collection.object_id] if collection
183
+ end
184
+
185
+ def refuse_boundary(reason:, message:, suggestions:, source:)
186
+ profile_count(:recorder_refused_boundary_count)
187
+ profile_timing(:recorder_refused_boundary_ms) do
188
+ boundary = RefusedBoundary.new(
189
+ reason.to_s,
190
+ message.to_s,
191
+ Array(suggestions).map(&:to_s),
192
+ source.to_s
193
+ )
194
+ return false if @refused_boundaries.include?(boundary)
195
+
196
+ @refused_boundaries << boundary
197
+ true
198
+ end
199
+ end
200
+
201
+ def profile_timings
202
+ @profile_timings.transform_values { |value| value.round(3) }
203
+ end
204
+
205
+ def profile_counts
206
+ @profile_counts.dup
207
+ end
208
+
209
+ def reactive?
210
+ @refused_boundaries.empty?
211
+ end
212
+
213
+ def current_frame
214
+ @frame_stack.last
215
+ end
216
+
217
+ def current_owner
218
+ current_frame || REQUEST_NODE_ID
219
+ end
220
+
221
+ def identity_profile(frame_id)
222
+ flush_pending_dependencies
223
+ identity_dependencies_for(frame_id).map(&:to_h)
224
+ end
225
+
226
+ def identity_signature(frame_id)
227
+ flush_pending_dependencies
228
+ identity_dependencies = identity_dependencies_for(frame_id)
229
+ return "public" if identity_dependencies.empty?
230
+
231
+ Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
232
+ end
233
+
234
+ private
235
+
236
+ def invalidate_subscription_shape_trace_if_needed
237
+ @subscription_shape_trace.invalidate! unless @subscription_shape_trace.synchronized_with?(@graph)
238
+ end
239
+
240
+ def flush_pending_dependencies_for(owner_id)
241
+ dependencies = @pending_dependencies_by_owner.delete(owner_id)
242
+ return unless dependencies&.any?
243
+
244
+ profile_timing(:recorder_dependency_ms) do
245
+ dependencies.each_value do |dependency|
246
+ profile_count(:recorder_dependency_flush_count)
247
+ invalidate_subscription_shape_trace_if_needed
248
+ if @graph.add_dependency(owner_id, dependency)
249
+ profile_timing(:recorder_shape_trace_ms) do
250
+ @subscription_shape_trace.record_dependency(owner_id, dependency, graph_version: @graph.version)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+
257
+ def profile_timing(key)
258
+ return yield unless @profile
259
+
260
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
261
+ yield
262
+ ensure
263
+ if @profile && started_at
264
+ @profile_timings[key] += (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
265
+ end
266
+ end
267
+
268
+ def profile_count(key)
269
+ return unless @profile
270
+
271
+ @profile_counts[key] += 1
272
+ end
273
+
274
+ def identity_dependencies_for(frame_id)
275
+ identity_dependency_owner_ids(frame_id)
276
+ .flat_map { |owner_id| @graph.dependencies_for(owner_id) }
277
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
278
+ .uniq(&:cache_key)
279
+ end
280
+
281
+ def identity_dependency_owner_ids(frame_id)
282
+ owner_ids = @graph.contained_node_ids(frame_id)
283
+ frame = @graph.node(frame_id)
284
+
285
+ if frame.kind == :frame && frame.payload[:kind] == "page"
286
+ owner_ids.concat(@graph.ancestor_node_ids(frame_id))
287
+ end
288
+
289
+ owner_ids
290
+ rescue KeyError
291
+ owner_ids
292
+ end
293
+ end
294
+
295
+ module ChangeLog
296
+ THREAD_KEY = :upkeep_change_log_events
297
+ @events = []
298
+ @mutex = Mutex.new
299
+
300
+ module_function
301
+
302
+ def reset
303
+ @mutex.synchronize { @events = [] }
304
+ Thread.current[THREAD_KEY] = nil
305
+ end
306
+
307
+ def record(event)
308
+ if (events = Thread.current[THREAD_KEY])
309
+ events << event
310
+ else
311
+ @mutex.synchronize { @events << event }
312
+ end
313
+ end
314
+
315
+ def events
316
+ if (events = Thread.current[THREAD_KEY])
317
+ events
318
+ else
319
+ @mutex.synchronize { @events.dup }
320
+ end
321
+ end
322
+
323
+ def drain
324
+ if (events = Thread.current[THREAD_KEY])
325
+ drained = events.dup
326
+ events.clear
327
+ drained
328
+ else
329
+ @mutex.synchronize do
330
+ events = @events
331
+ @events = []
332
+ events
333
+ end
334
+ end
335
+ end
336
+
337
+ def capture
338
+ previous = Thread.current[THREAD_KEY]
339
+ events = []
340
+ Thread.current[THREAD_KEY] = events
341
+
342
+ [yield, events.dup]
343
+ ensure
344
+ Thread.current[THREAD_KEY] = previous
345
+ end
346
+ end
347
+
348
+ module ChangeEvents
349
+ module_function
350
+
351
+ def active_record_commit(record)
352
+ return active_record_destroy(record) if record.destroyed?
353
+
354
+ attribute_changes = previous_changes(record.previous_changes)
355
+
356
+ {
357
+ type: created_record?(record, attribute_changes) ? "create" : "update",
358
+ table: record.class.table_name,
359
+ model: record.class.name,
360
+ id: record.id,
361
+ changed_attributes: attribute_changes.keys.sort,
362
+ old_values: attribute_changes.transform_values { |change| change.fetch(:old) },
363
+ new_values: attribute_changes.transform_values { |change| change.fetch(:new) },
364
+ attribute_changes: attribute_changes
365
+ }
366
+ end
367
+
368
+ def active_record_destroy(record)
369
+ old_values = record.attributes.transform_keys(&:to_s)
370
+
371
+ {
372
+ type: "destroy",
373
+ table: record.class.table_name,
374
+ model: record.class.name,
375
+ id: record.id,
376
+ changed_attributes: old_values.keys.sort,
377
+ old_values: old_values,
378
+ new_values: {},
379
+ attribute_changes: old_values.transform_values { |value| { old: value, new: nil } }
380
+ }
381
+ end
382
+
383
+ def active_record_update_columns(record, changed_attributes:, new_values: {})
384
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
385
+ new_values = new_values.transform_keys(&:to_s)
386
+
387
+ {
388
+ type: "update",
389
+ table: record.class.table_name,
390
+ model: record.class.name,
391
+ id: record.class.primary_key && record.public_send(record.class.primary_key),
392
+ changed_attributes: changed_attributes,
393
+ old_values: {},
394
+ new_values: new_values,
395
+ attribute_changes: changed_attributes.to_h do |attribute|
396
+ [attribute, { old: nil, new: new_values[attribute] }]
397
+ end
398
+ }
399
+ end
400
+
401
+ def bulk_update(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:, new_values: {})
402
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
403
+ new_values = new_values.transform_keys(&:to_s)
404
+
405
+ {
406
+ type: "bulk_update",
407
+ table: table,
408
+ model: model,
409
+ changed_attributes: changed_attributes,
410
+ old_values: {},
411
+ new_values: new_values,
412
+ attribute_changes: changed_attributes.to_h do |attribute|
413
+ [attribute, { old: nil, new: new_values[attribute] }]
414
+ end,
415
+ predicate_sql: predicate_sql,
416
+ predicate_coverage: predicate_coverage,
417
+ predicate_table_columns: predicate_table_columns
418
+ }
419
+ end
420
+
421
+ def bulk_delete(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:)
422
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
423
+
424
+ {
425
+ type: "bulk_delete",
426
+ table: table,
427
+ model: model,
428
+ changed_attributes: changed_attributes,
429
+ old_values: {},
430
+ new_values: {},
431
+ attribute_changes: changed_attributes.to_h { |attribute| [attribute, { old: nil, new: nil }] },
432
+ predicate_sql: predicate_sql,
433
+ predicate_coverage: predicate_coverage,
434
+ predicate_table_columns: predicate_table_columns
435
+ }
436
+ end
437
+
438
+ def previous_changes(changes)
439
+ changes.to_h.transform_keys(&:to_s).transform_values do |(old_value, new_value)|
440
+ { old: old_value, new: new_value }
441
+ end
442
+ end
443
+
444
+ def created_record?(record, attribute_changes)
445
+ primary_key = record.class.primary_key
446
+ return false unless primary_key
447
+
448
+ primary_key_change = attribute_changes[primary_key.to_s]
449
+ primary_key_change && primary_key_change.fetch(:old).nil? && !primary_key_change.fetch(:new).nil?
450
+ end
451
+ end
452
+
453
+ module Ambient
454
+ module_function
455
+
456
+ def record_current_attribute(owner, name, value)
457
+ return unless Observation.recording?
458
+
459
+ dependency = Dependencies::CurrentAttribute.new(
460
+ owner: owner,
461
+ name: name,
462
+ value: value,
463
+ **identity_presence_metadata(:current, { owner: owner, name: name }, value)
464
+ )
465
+ Observation.record_dependency(dependency)
466
+ end
467
+
468
+ def record_session(key, value)
469
+ return unless Observation.recording?
470
+
471
+ dependency = Dependencies::SessionValue.new(
472
+ key: key,
473
+ value: value,
474
+ **identity_presence_metadata(:session, key, value)
475
+ )
476
+ Observation.record_dependency(dependency)
477
+ Observation.record_ambient_replay_input(:session, key, value)
478
+ end
479
+
480
+ def record_cookie(key, value)
481
+ return unless Observation.recording?
482
+
483
+ dependency = Dependencies::CookieValue.new(
484
+ key: key,
485
+ value: value,
486
+ **identity_presence_metadata(:cookie, key, value)
487
+ )
488
+ Observation.record_dependency(dependency)
489
+ Observation.record_ambient_replay_input(:cookie, key, value)
490
+ end
491
+
492
+ def record_request(key, value)
493
+ return unless Observation.recording?
494
+
495
+ dependency = Dependencies::RequestValue.new(key: key, value: value)
496
+ Observation.record_dependency(dependency)
497
+ Observation.record_ambient_replay_input(:request, key, value)
498
+ end
499
+
500
+ def record_warden_user(scope, user)
501
+ return unless Observation.recording?
502
+
503
+ dependency = Dependencies::WardenUser.new(
504
+ scope: scope,
505
+ user: user,
506
+ **identity_presence_metadata(:warden, scope, user)
507
+ )
508
+ Observation.record_dependency(dependency)
509
+ end
510
+
511
+ def identity_presence_metadata(source, key, value)
512
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
513
+ Upkeep::Rails.configuration.identity_presence_metadata(source: source, key: key, value: value)
514
+ else
515
+ { partitioning: !value.nil?, absent_by_name: {} }
516
+ end
517
+ end
518
+ end
519
+
520
+ class ObservedHash
521
+ def initialize(source:, values:)
522
+ @source = source
523
+ @values = values || {}
524
+ end
525
+
526
+ def [](key)
527
+ value = lookup(key)
528
+ record(key, value)
529
+ value
530
+ end
531
+
532
+ def fetch(key, *fallback)
533
+ if include_key?(key)
534
+ self[key]
535
+ elsif block_given?
536
+ yield key
537
+ elsif fallback.any?
538
+ fallback.first
539
+ else
540
+ @values.fetch(key)
541
+ end
542
+ end
543
+
544
+ def dig(first_key, *rest)
545
+ value = self[first_key]
546
+ rest.empty? || value.nil? ? value : value.dig(*rest)
547
+ end
548
+
549
+ private
550
+
551
+ def lookup(key)
552
+ return @values[key] if @values.key?(key)
553
+ return @values[key.to_s] if @values.key?(key.to_s)
554
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
555
+
556
+ nil
557
+ end
558
+
559
+ def include_key?(key)
560
+ @values.key?(key) ||
561
+ @values.key?(key.to_s) ||
562
+ (key.respond_to?(:to_sym) && @values.key?(key.to_sym))
563
+ end
564
+
565
+ def record(key, value)
566
+ case @source
567
+ when :session
568
+ Ambient.record_session(key, value)
569
+ when :cookie
570
+ Ambient.record_cookie(key, value)
571
+ end
572
+ end
573
+ end
574
+
575
+ class ObservedRequest
576
+ def initialize(values)
577
+ @values = values || {}
578
+ end
579
+
580
+ def host = read(:host)
581
+
582
+ def subdomain = read(:subdomain)
583
+
584
+ def path = read(:path)
585
+
586
+ def fullpath = read(:fullpath)
587
+
588
+ def request_method = read(:request_method)
589
+
590
+ def user_agent = read(:user_agent)
591
+
592
+ def remote_ip = read(:remote_ip)
593
+
594
+ def params = read(:params)
595
+
596
+ def [](key)
597
+ read(key)
598
+ end
599
+
600
+ private
601
+
602
+ def read(key)
603
+ value = lookup(key)
604
+ Ambient.record_request(key, value)
605
+ value
606
+ end
607
+
608
+ def lookup(key)
609
+ return @values[key] if @values.key?(key)
610
+ return @values[key.to_s] if @values.key?(key.to_s)
611
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
612
+
613
+ nil
614
+ end
615
+ end
616
+
617
+ class ObservedWarden
618
+ def initialize(users_by_scope)
619
+ @users_by_scope = users_by_scope || {}
620
+ end
621
+
622
+ def user(scope = :user, **options)
623
+ scope = options.fetch(:scope, scope)
624
+ value = @users_by_scope[scope] || @users_by_scope[scope.to_s] || @users_by_scope[scope.to_sym]
625
+ Ambient.record_warden_user(scope, value)
626
+ value
627
+ end
628
+
629
+ def authenticate(*args, **options)
630
+ user(extract_scope(args, options))
631
+ end
632
+
633
+ def authenticated?(*args, **options)
634
+ !user(extract_scope(args, options)).nil?
635
+ end
636
+
637
+ private
638
+
639
+ def extract_scope(args, options)
640
+ options.fetch(:scope) { args.first || :user }
641
+ end
642
+ end
643
+
644
+ module CurrentAttributesClassObserver
645
+ def attribute(*names, **options)
646
+ result = super
647
+ Runtime.wrap_current_attribute_readers(self, names)
648
+ result
649
+ end
650
+ end
651
+
652
+ module WardenObserver
653
+ def user(*args, **options, &block)
654
+ value = super
655
+ Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
656
+ value
657
+ end
658
+
659
+ def authenticate(*args, **options, &block)
660
+ value = super
661
+ Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
662
+ value
663
+ end
664
+
665
+ private
666
+
667
+ def warden_scope(args, options)
668
+ options.fetch(:scope) { args.first || :user }
669
+ end
670
+ end
671
+
672
+ module SessionObserver
673
+ def [](key)
674
+ value = super
675
+ Runtime::Ambient.record_session(key, value)
676
+ value
677
+ end
678
+
679
+ def fetch(key, *args, &block)
680
+ value = super
681
+ Runtime::Ambient.record_session(key, value)
682
+ value
683
+ end
684
+ end
685
+
686
+ module CookieObserver
687
+ def [](key)
688
+ value = super
689
+ Runtime::Ambient.record_cookie(key, value)
690
+ value
691
+ end
692
+ end
693
+
694
+ module RequestObserver
695
+ def host
696
+ value = super
697
+ Runtime::Ambient.record_request(:host, value)
698
+ value
699
+ end
700
+
701
+ def subdomain
702
+ value = super
703
+ Runtime::Ambient.record_request(:subdomain, value)
704
+ value
705
+ end
706
+
707
+ def path
708
+ value = super
709
+ Runtime::Ambient.record_request(:path, value)
710
+ value
711
+ end
712
+
713
+ def fullpath
714
+ value = super
715
+ Runtime::Ambient.record_request(:fullpath, value)
716
+ value
717
+ end
718
+
719
+ def request_method
720
+ value = super
721
+ Runtime::Ambient.record_request(:request_method, value)
722
+ value
723
+ end
724
+
725
+ def user_agent
726
+ value = super
727
+ Runtime::Ambient.record_request(:user_agent, value)
728
+ value
729
+ end
730
+
731
+ def remote_ip
732
+ value = super
733
+ Runtime::Ambient.record_request(:remote_ip, value)
734
+ value
735
+ end
736
+
737
+ def params
738
+ value = super
739
+ Runtime::Ambient.record_request(:params, value)
740
+ value
741
+ end
742
+ end
743
+
744
+ class Current
745
+ THREAD_KEY = :upkeep_current_user
746
+
747
+ class << self
748
+ def set(user:)
749
+ previous = Thread.current[THREAD_KEY]
750
+ Thread.current[THREAD_KEY] = user
751
+ yield
752
+ ensure
753
+ Thread.current[THREAD_KEY] = previous
754
+ end
755
+
756
+ def user
757
+ user = Thread.current[THREAD_KEY]
758
+ return user unless Observation.recording?
759
+
760
+ dependency = Dependencies::Identity.new(
761
+ source: "Current.user",
762
+ key: "id",
763
+ value: user&.id,
764
+ metadata: {
765
+ model: user&.class&.name,
766
+ table: user&.class&.table_name,
767
+ id: user&.id
768
+ }.compact
769
+ )
770
+
771
+ Observation.record_dependency(dependency)
772
+ user
773
+ end
774
+ end
775
+ end
776
+
777
+ module AttributeObserver
778
+ def _read_attribute(attr_name, &block)
779
+ value = super
780
+ return value unless Observation.recording?
781
+
782
+ dependency = Dependencies::ActiveRecordAttribute.new(
783
+ table: self.class.table_name,
784
+ model: self.class.name,
785
+ id: primary_key_value(attr_name, value),
786
+ attribute: attr_name.to_s
787
+ )
788
+
789
+ Observation.record_dependency(dependency)
790
+
791
+ value
792
+ end
793
+
794
+ private
795
+
796
+ def primary_key_value(attr_name, value)
797
+ primary_key = self.class.primary_key
798
+ return nil unless primary_key
799
+ return value if attr_name.to_s == primary_key.to_s
800
+
801
+ @attributes.fetch_value(primary_key)
802
+ rescue StandardError
803
+ nil
804
+ end
805
+ end
806
+
807
+ module PersistenceObserver
808
+ def update_columns(attributes)
809
+ new_values = upkeep_update_column_values(attributes)
810
+ changed_attributes = new_values.keys
811
+
812
+ super.tap do |result|
813
+ if result
814
+ ChangeLog.record(
815
+ ChangeEvents.active_record_update_columns(
816
+ self,
817
+ changed_attributes: changed_attributes,
818
+ new_values: new_values
819
+ )
820
+ )
821
+ end
822
+ end
823
+ end
824
+
825
+ private
826
+
827
+ def upkeep_update_column_values(attributes)
828
+ attributes.to_h.reject { |attribute, _value| attribute.to_s == "touch" }.transform_keys do |attribute|
829
+ self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
830
+ end
831
+ end
832
+ end
833
+
834
+ module RelationObserver
835
+ SUPPRESS_DEPENDENCY_KEY = :upkeep_runtime_relation_dependency_suppressed
836
+
837
+ def self.suppress_dependency_tracking
838
+ previous = Thread.current[SUPPRESS_DEPENDENCY_KEY]
839
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = true
840
+ yield
841
+ ensure
842
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = previous
843
+ end
844
+
845
+ def self.dependency_tracking_suppressed?
846
+ Thread.current[SUPPRESS_DEPENDENCY_KEY]
847
+ end
848
+
849
+ def exec_queries(...)
850
+ analysis = relation_analysis_for_observation
851
+ super.tap { |records| record_relation_provenance(records, analysis) }
852
+ end
853
+
854
+ def to_ary
855
+ super.tap { |records| record_relation_provenance(records, relation_analysis_for_observation) }
856
+ end
857
+
858
+ def to_a
859
+ to_ary
860
+ end
861
+
862
+ def pluck(*column_names)
863
+ record_query_dependency(column_names)
864
+ super
865
+ end
866
+
867
+ def update_all(updates)
868
+ analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
869
+ event = ChangeEvents.bulk_update(
870
+ table: klass.table_name,
871
+ model: klass.name,
872
+ changed_attributes: update_columns(updates),
873
+ predicate_sql: analysis.sql,
874
+ predicate_coverage: analysis.coverage.to_s,
875
+ predicate_table_columns: analysis.table_columns,
876
+ new_values: update_values(updates)
877
+ )
878
+
879
+ super.tap { ChangeLog.record(event) }
880
+ end
881
+
882
+ def delete_all
883
+ analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
884
+ event = ChangeEvents.bulk_delete(
885
+ table: klass.table_name,
886
+ model: klass.name,
887
+ changed_attributes: [klass.primary_key].compact,
888
+ predicate_sql: analysis.sql,
889
+ predicate_coverage: analysis.coverage.to_s,
890
+ predicate_table_columns: analysis.table_columns
891
+ )
892
+
893
+ super.tap { ChangeLog.record(event) }
894
+ end
895
+
896
+ private
897
+
898
+ def relation_analysis_for_observation
899
+ return unless Observation.recorder
900
+ return if RelationObserver.dependency_tracking_suppressed?
901
+ return @upkeep_relation_analysis if instance_variable_defined?(:@upkeep_relation_analysis)
902
+
903
+ @upkeep_relation_analysis = ActiveRecordQuery.analyze(self)
904
+ rescue ActiveRecordQuery::OpaqueRelationError => error
905
+ @upkeep_relation_analysis = nil
906
+ handle_opaque_relation_dependency(error)
907
+ nil
908
+ end
909
+
910
+ def record_relation_provenance(records, analysis)
911
+ Observation.record_relation_provenance(records, model_name: klass.name, analysis: analysis) if analysis
912
+ end
913
+
914
+ def record_query_dependency(column_names)
915
+ analysis = relation_analysis_for_observation
916
+ return unless analysis
917
+
918
+ Observation.record_dependency(
919
+ Dependencies::ActiveRecordQuery.new(
920
+ primary_table: analysis.primary_table,
921
+ table_columns: relation_table_columns(analysis, pluck_dependency_columns(column_names)),
922
+ coverage: analysis.coverage,
923
+ sql: analysis.sql,
924
+ predicates: analysis.predicates
925
+ )
926
+ )
927
+ rescue ActiveRecordQuery::OpaqueRelationError => error
928
+ handle_opaque_relation_dependency(error)
929
+ end
930
+
931
+ def relation_table_columns(analysis, extra_columns)
932
+ analysis.table_columns.merge(
933
+ klass.table_name => (analysis.table_columns.fetch(klass.table_name, []) + extra_columns).uniq.sort
934
+ )
935
+ end
936
+
937
+ def pluck_dependency_columns(column_names)
938
+ column_names.flatten.map do |column_name|
939
+ pluck_dependency_column(column_name)
940
+ end
941
+ end
942
+
943
+ def pluck_dependency_column(column_name)
944
+ case column_name
945
+ when Symbol
946
+ return column_name.to_s
947
+ when String
948
+ return column_name if klass.column_names.include?(column_name)
949
+ else
950
+ if column_name.respond_to?(:to_sym)
951
+ name = column_name.to_sym.to_s
952
+ return name if klass.column_names.include?(name)
953
+ end
954
+
955
+ if column_name.respond_to?(:name) && column_name.respond_to?(:relation)
956
+ name = column_name.name.to_s
957
+ return name if klass.column_names.include?(name)
958
+ end
959
+ end
960
+
961
+ raise ActiveRecordQuery::OpaqueRelationError.new(
962
+ self,
963
+ reasons: ["opaque pluck column #{column_name.inspect}"]
964
+ )
965
+ end
966
+
967
+ def handle_opaque_relation_dependency(error)
968
+ raise error if refused_boundary_behavior == :raise
969
+
970
+ payload = {
971
+ reason: "opaque_active_record_relation",
972
+ message: error.message,
973
+ suggestions: error.suggestions,
974
+ source: "active_record_relation"
975
+ }
976
+
977
+ if Observation.refuse_boundary(payload)
978
+ ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
979
+ warn_refused_boundary(payload)
980
+ end
981
+ end
982
+
983
+ def refused_boundary_behavior
984
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
985
+ Upkeep::Rails.configuration.refused_boundary_behavior
986
+ else
987
+ :raise
988
+ end
989
+ end
990
+
991
+ def warn_refused_boundary(payload)
992
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
993
+
994
+ ::Rails.logger.warn(
995
+ "Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
996
+ "#{payload.fetch(:suggestions).join(" ")}"
997
+ )
998
+ end
999
+
1000
+ def update_columns(updates)
1001
+ case updates
1002
+ when Hash
1003
+ updates.keys.map(&:to_s)
1004
+ else
1005
+ klass.column_names
1006
+ end
1007
+ end
1008
+
1009
+ def update_values(updates)
1010
+ return {} unless updates.is_a?(Hash)
1011
+
1012
+ updates.transform_keys(&:to_s)
1013
+ end
1014
+ end
1015
+
1016
+ module Install
1017
+ module_function
1018
+
1019
+ def call
1020
+ return if @installed
1021
+
1022
+ install_current_attributes_observer
1023
+ install_warden_observer
1024
+ install_action_dispatch_observers
1025
+
1026
+ ActiveRecord::AttributeMethods::Read.prepend(AttributeObserver)
1027
+ ActiveRecord::Base.prepend(PersistenceObserver) unless ActiveRecord::Base < PersistenceObserver
1028
+ ActiveRecord::Relation.prepend(RelationObserver)
1029
+
1030
+ ActiveRecord::Base.after_commit do |record|
1031
+ ChangeLog.record(ChangeEvents.active_record_commit(record))
1032
+ end
1033
+
1034
+ @installed = true
1035
+ end
1036
+
1037
+ def install_current_attributes_observer
1038
+ singleton = class << ActiveSupport::CurrentAttributes; self; end
1039
+ singleton.prepend(CurrentAttributesClassObserver) unless singleton < CurrentAttributesClassObserver
1040
+
1041
+ ObjectSpace.each_object(Class) do |klass|
1042
+ next unless klass < ActiveSupport::CurrentAttributes
1043
+
1044
+ Runtime.wrap_current_attribute_readers(klass, current_attribute_names(klass))
1045
+ end
1046
+ end
1047
+
1048
+ def install_warden_observer
1049
+ return unless defined?(::Warden::Proxy)
1050
+ return if ::Warden::Proxy < WardenObserver
1051
+
1052
+ ::Warden::Proxy.prepend(WardenObserver)
1053
+ end
1054
+
1055
+ def install_action_dispatch_observers
1056
+ if defined?(::ActionDispatch::Request::Session) && !(::ActionDispatch::Request::Session < SessionObserver)
1057
+ ::ActionDispatch::Request::Session.prepend(SessionObserver)
1058
+ end
1059
+
1060
+ if defined?(::ActionDispatch::Cookies::CookieJar) && !(::ActionDispatch::Cookies::CookieJar < CookieObserver)
1061
+ ::ActionDispatch::Cookies::CookieJar.prepend(CookieObserver)
1062
+ end
1063
+
1064
+ if defined?(::ActionDispatch::Request) && !(::ActionDispatch::Request < RequestObserver)
1065
+ ::ActionDispatch::Request.prepend(RequestObserver)
1066
+ end
1067
+ end
1068
+
1069
+ def current_attribute_names(klass)
1070
+ if klass.respond_to?(:defaults)
1071
+ klass.defaults.keys
1072
+ else
1073
+ []
1074
+ end
1075
+ end
1076
+ end
1077
+
1078
+ module_function
1079
+
1080
+ def wrap_current_attribute_readers(klass, names)
1081
+ wrapped = klass.instance_variable_get(:@upkeep_wrapped_current_attributes) || {}
1082
+
1083
+ names.each do |name|
1084
+ name = name.to_sym
1085
+ next if wrapped[name]
1086
+ next unless klass.method_defined?(name)
1087
+
1088
+ original_reader = klass.instance_method(name)
1089
+ klass.define_method(name) do
1090
+ value = original_reader.bind_call(self)
1091
+ Runtime::Ambient.record_current_attribute(klass.name || klass.inspect, name, value)
1092
+ value
1093
+ end
1094
+ wrapped[name] = true
1095
+ end
1096
+
1097
+ klass.instance_variable_set(:@upkeep_wrapped_current_attributes, wrapped)
1098
+ end
1099
+ end
1100
+ end