upkeep-rails 0.1.0

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,1075 @@
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(&:identity?)
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(owner: owner, name: name, value: value)
460
+ Observation.record_dependency(dependency)
461
+ end
462
+
463
+ def record_session(key, value)
464
+ return unless Observation.recording?
465
+
466
+ dependency = Dependencies::SessionValue.new(key: key, value: value)
467
+ Observation.record_dependency(dependency)
468
+ Observation.record_ambient_replay_input(:session, key, value)
469
+ end
470
+
471
+ def record_cookie(key, value)
472
+ return unless Observation.recording?
473
+
474
+ dependency = Dependencies::CookieValue.new(key: key, value: value)
475
+ Observation.record_dependency(dependency)
476
+ Observation.record_ambient_replay_input(:cookie, key, value)
477
+ end
478
+
479
+ def record_request(key, value)
480
+ return unless Observation.recording?
481
+
482
+ dependency = Dependencies::RequestValue.new(key: key, value: value)
483
+ Observation.record_dependency(dependency)
484
+ Observation.record_ambient_replay_input(:request, key, value)
485
+ end
486
+
487
+ def record_warden_user(scope, user)
488
+ return unless Observation.recording?
489
+
490
+ dependency = Dependencies::WardenUser.new(scope: scope, user: user)
491
+ Observation.record_dependency(dependency)
492
+ end
493
+ end
494
+
495
+ class ObservedHash
496
+ def initialize(source:, values:)
497
+ @source = source
498
+ @values = values || {}
499
+ end
500
+
501
+ def [](key)
502
+ value = lookup(key)
503
+ record(key, value)
504
+ value
505
+ end
506
+
507
+ def fetch(key, *fallback)
508
+ if include_key?(key)
509
+ self[key]
510
+ elsif block_given?
511
+ yield key
512
+ elsif fallback.any?
513
+ fallback.first
514
+ else
515
+ @values.fetch(key)
516
+ end
517
+ end
518
+
519
+ def dig(first_key, *rest)
520
+ value = self[first_key]
521
+ rest.empty? || value.nil? ? value : value.dig(*rest)
522
+ end
523
+
524
+ private
525
+
526
+ def lookup(key)
527
+ return @values[key] if @values.key?(key)
528
+ return @values[key.to_s] if @values.key?(key.to_s)
529
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
530
+
531
+ nil
532
+ end
533
+
534
+ def include_key?(key)
535
+ @values.key?(key) ||
536
+ @values.key?(key.to_s) ||
537
+ (key.respond_to?(:to_sym) && @values.key?(key.to_sym))
538
+ end
539
+
540
+ def record(key, value)
541
+ case @source
542
+ when :session
543
+ Ambient.record_session(key, value)
544
+ when :cookie
545
+ Ambient.record_cookie(key, value)
546
+ end
547
+ end
548
+ end
549
+
550
+ class ObservedRequest
551
+ def initialize(values)
552
+ @values = values || {}
553
+ end
554
+
555
+ def host = read(:host)
556
+
557
+ def subdomain = read(:subdomain)
558
+
559
+ def path = read(:path)
560
+
561
+ def fullpath = read(:fullpath)
562
+
563
+ def request_method = read(:request_method)
564
+
565
+ def user_agent = read(:user_agent)
566
+
567
+ def remote_ip = read(:remote_ip)
568
+
569
+ def params = read(:params)
570
+
571
+ def [](key)
572
+ read(key)
573
+ end
574
+
575
+ private
576
+
577
+ def read(key)
578
+ value = lookup(key)
579
+ Ambient.record_request(key, value)
580
+ value
581
+ end
582
+
583
+ def lookup(key)
584
+ return @values[key] if @values.key?(key)
585
+ return @values[key.to_s] if @values.key?(key.to_s)
586
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
587
+
588
+ nil
589
+ end
590
+ end
591
+
592
+ class ObservedWarden
593
+ def initialize(users_by_scope)
594
+ @users_by_scope = users_by_scope || {}
595
+ end
596
+
597
+ def user(scope = :user, **options)
598
+ scope = options.fetch(:scope, scope)
599
+ value = @users_by_scope[scope] || @users_by_scope[scope.to_s] || @users_by_scope[scope.to_sym]
600
+ Ambient.record_warden_user(scope, value)
601
+ value
602
+ end
603
+
604
+ def authenticate(*args, **options)
605
+ user(extract_scope(args, options))
606
+ end
607
+
608
+ def authenticated?(*args, **options)
609
+ !user(extract_scope(args, options)).nil?
610
+ end
611
+
612
+ private
613
+
614
+ def extract_scope(args, options)
615
+ options.fetch(:scope) { args.first || :user }
616
+ end
617
+ end
618
+
619
+ module CurrentAttributesClassObserver
620
+ def attribute(*names, **options)
621
+ result = super
622
+ Runtime.wrap_current_attribute_readers(self, names)
623
+ result
624
+ end
625
+ end
626
+
627
+ module WardenObserver
628
+ def user(*args, **options, &block)
629
+ value = super
630
+ Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
631
+ value
632
+ end
633
+
634
+ def authenticate(*args, **options, &block)
635
+ value = super
636
+ Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
637
+ value
638
+ end
639
+
640
+ private
641
+
642
+ def warden_scope(args, options)
643
+ options.fetch(:scope) { args.first || :user }
644
+ end
645
+ end
646
+
647
+ module SessionObserver
648
+ def [](key)
649
+ value = super
650
+ Runtime::Ambient.record_session(key, value)
651
+ value
652
+ end
653
+
654
+ def fetch(key, *args, &block)
655
+ value = super
656
+ Runtime::Ambient.record_session(key, value)
657
+ value
658
+ end
659
+ end
660
+
661
+ module CookieObserver
662
+ def [](key)
663
+ value = super
664
+ Runtime::Ambient.record_cookie(key, value)
665
+ value
666
+ end
667
+ end
668
+
669
+ module RequestObserver
670
+ def host
671
+ value = super
672
+ Runtime::Ambient.record_request(:host, value)
673
+ value
674
+ end
675
+
676
+ def subdomain
677
+ value = super
678
+ Runtime::Ambient.record_request(:subdomain, value)
679
+ value
680
+ end
681
+
682
+ def path
683
+ value = super
684
+ Runtime::Ambient.record_request(:path, value)
685
+ value
686
+ end
687
+
688
+ def fullpath
689
+ value = super
690
+ Runtime::Ambient.record_request(:fullpath, value)
691
+ value
692
+ end
693
+
694
+ def request_method
695
+ value = super
696
+ Runtime::Ambient.record_request(:request_method, value)
697
+ value
698
+ end
699
+
700
+ def user_agent
701
+ value = super
702
+ Runtime::Ambient.record_request(:user_agent, value)
703
+ value
704
+ end
705
+
706
+ def remote_ip
707
+ value = super
708
+ Runtime::Ambient.record_request(:remote_ip, value)
709
+ value
710
+ end
711
+
712
+ def params
713
+ value = super
714
+ Runtime::Ambient.record_request(:params, value)
715
+ value
716
+ end
717
+ end
718
+
719
+ class Current
720
+ THREAD_KEY = :upkeep_current_user
721
+
722
+ class << self
723
+ def set(user:)
724
+ previous = Thread.current[THREAD_KEY]
725
+ Thread.current[THREAD_KEY] = user
726
+ yield
727
+ ensure
728
+ Thread.current[THREAD_KEY] = previous
729
+ end
730
+
731
+ def user
732
+ user = Thread.current[THREAD_KEY]
733
+ return user unless Observation.recording?
734
+
735
+ dependency = Dependencies::Identity.new(
736
+ source: "Current.user",
737
+ key: "id",
738
+ value: user&.id,
739
+ metadata: {
740
+ model: user&.class&.name,
741
+ table: user&.class&.table_name,
742
+ id: user&.id
743
+ }.compact
744
+ )
745
+
746
+ Observation.record_dependency(dependency)
747
+ user
748
+ end
749
+ end
750
+ end
751
+
752
+ module AttributeObserver
753
+ def _read_attribute(attr_name, &block)
754
+ value = super
755
+ return value unless Observation.recording?
756
+
757
+ dependency = Dependencies::ActiveRecordAttribute.new(
758
+ table: self.class.table_name,
759
+ model: self.class.name,
760
+ id: primary_key_value(attr_name, value),
761
+ attribute: attr_name.to_s
762
+ )
763
+
764
+ Observation.record_dependency(dependency)
765
+
766
+ value
767
+ end
768
+
769
+ private
770
+
771
+ def primary_key_value(attr_name, value)
772
+ primary_key = self.class.primary_key
773
+ return nil unless primary_key
774
+ return value if attr_name.to_s == primary_key.to_s
775
+
776
+ @attributes.fetch_value(primary_key)
777
+ rescue StandardError
778
+ nil
779
+ end
780
+ end
781
+
782
+ module PersistenceObserver
783
+ def update_columns(attributes)
784
+ new_values = upkeep_update_column_values(attributes)
785
+ changed_attributes = new_values.keys
786
+
787
+ super.tap do |result|
788
+ if result
789
+ ChangeLog.record(
790
+ ChangeEvents.active_record_update_columns(
791
+ self,
792
+ changed_attributes: changed_attributes,
793
+ new_values: new_values
794
+ )
795
+ )
796
+ end
797
+ end
798
+ end
799
+
800
+ private
801
+
802
+ def upkeep_update_column_values(attributes)
803
+ attributes.to_h.reject { |attribute, _value| attribute.to_s == "touch" }.transform_keys do |attribute|
804
+ self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
805
+ end
806
+ end
807
+ end
808
+
809
+ module RelationObserver
810
+ SUPPRESS_DEPENDENCY_KEY = :upkeep_runtime_relation_dependency_suppressed
811
+
812
+ def self.suppress_dependency_tracking
813
+ previous = Thread.current[SUPPRESS_DEPENDENCY_KEY]
814
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = true
815
+ yield
816
+ ensure
817
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = previous
818
+ end
819
+
820
+ def self.dependency_tracking_suppressed?
821
+ Thread.current[SUPPRESS_DEPENDENCY_KEY]
822
+ end
823
+
824
+ def exec_queries(...)
825
+ analysis = relation_analysis_for_observation
826
+ super.tap { |records| record_relation_provenance(records, analysis) }
827
+ end
828
+
829
+ def to_ary
830
+ super.tap { |records| record_relation_provenance(records, relation_analysis_for_observation) }
831
+ end
832
+
833
+ def to_a
834
+ to_ary
835
+ end
836
+
837
+ def pluck(*column_names)
838
+ record_query_dependency(column_names)
839
+ super
840
+ end
841
+
842
+ def update_all(updates)
843
+ analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
844
+ event = ChangeEvents.bulk_update(
845
+ table: klass.table_name,
846
+ model: klass.name,
847
+ changed_attributes: update_columns(updates),
848
+ predicate_sql: analysis.sql,
849
+ predicate_coverage: analysis.coverage.to_s,
850
+ predicate_table_columns: analysis.table_columns,
851
+ new_values: update_values(updates)
852
+ )
853
+
854
+ super.tap { ChangeLog.record(event) }
855
+ end
856
+
857
+ def delete_all
858
+ analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
859
+ event = ChangeEvents.bulk_delete(
860
+ table: klass.table_name,
861
+ model: klass.name,
862
+ changed_attributes: [klass.primary_key].compact,
863
+ predicate_sql: analysis.sql,
864
+ predicate_coverage: analysis.coverage.to_s,
865
+ predicate_table_columns: analysis.table_columns
866
+ )
867
+
868
+ super.tap { ChangeLog.record(event) }
869
+ end
870
+
871
+ private
872
+
873
+ def relation_analysis_for_observation
874
+ return unless Observation.recorder
875
+ return if RelationObserver.dependency_tracking_suppressed?
876
+ return @upkeep_relation_analysis if instance_variable_defined?(:@upkeep_relation_analysis)
877
+
878
+ @upkeep_relation_analysis = ActiveRecordQuery.analyze(self)
879
+ rescue ActiveRecordQuery::OpaqueRelationError => error
880
+ @upkeep_relation_analysis = nil
881
+ handle_opaque_relation_dependency(error)
882
+ nil
883
+ end
884
+
885
+ def record_relation_provenance(records, analysis)
886
+ Observation.record_relation_provenance(records, model_name: klass.name, analysis: analysis) if analysis
887
+ end
888
+
889
+ def record_query_dependency(column_names)
890
+ analysis = relation_analysis_for_observation
891
+ return unless analysis
892
+
893
+ Observation.record_dependency(
894
+ Dependencies::ActiveRecordQuery.new(
895
+ primary_table: analysis.primary_table,
896
+ table_columns: relation_table_columns(analysis, pluck_dependency_columns(column_names)),
897
+ coverage: analysis.coverage,
898
+ sql: analysis.sql,
899
+ predicates: analysis.predicates
900
+ )
901
+ )
902
+ rescue ActiveRecordQuery::OpaqueRelationError => error
903
+ handle_opaque_relation_dependency(error)
904
+ end
905
+
906
+ def relation_table_columns(analysis, extra_columns)
907
+ analysis.table_columns.merge(
908
+ klass.table_name => (analysis.table_columns.fetch(klass.table_name, []) + extra_columns).uniq.sort
909
+ )
910
+ end
911
+
912
+ def pluck_dependency_columns(column_names)
913
+ column_names.flatten.map do |column_name|
914
+ pluck_dependency_column(column_name)
915
+ end
916
+ end
917
+
918
+ def pluck_dependency_column(column_name)
919
+ case column_name
920
+ when Symbol
921
+ return column_name.to_s
922
+ when String
923
+ return column_name if klass.column_names.include?(column_name)
924
+ else
925
+ if column_name.respond_to?(:to_sym)
926
+ name = column_name.to_sym.to_s
927
+ return name if klass.column_names.include?(name)
928
+ end
929
+
930
+ if column_name.respond_to?(:name) && column_name.respond_to?(:relation)
931
+ name = column_name.name.to_s
932
+ return name if klass.column_names.include?(name)
933
+ end
934
+ end
935
+
936
+ raise ActiveRecordQuery::OpaqueRelationError.new(
937
+ self,
938
+ reasons: ["opaque pluck column #{column_name.inspect}"]
939
+ )
940
+ end
941
+
942
+ def handle_opaque_relation_dependency(error)
943
+ raise error if refused_boundary_behavior == :raise
944
+
945
+ payload = {
946
+ reason: "opaque_active_record_relation",
947
+ message: error.message,
948
+ suggestions: error.suggestions,
949
+ source: "active_record_relation"
950
+ }
951
+
952
+ if Observation.refuse_boundary(payload)
953
+ ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
954
+ warn_refused_boundary(payload)
955
+ end
956
+ end
957
+
958
+ def refused_boundary_behavior
959
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
960
+ Upkeep::Rails.configuration.refused_boundary_behavior
961
+ else
962
+ :raise
963
+ end
964
+ end
965
+
966
+ def warn_refused_boundary(payload)
967
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
968
+
969
+ ::Rails.logger.warn(
970
+ "Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
971
+ "#{payload.fetch(:suggestions).join(" ")}"
972
+ )
973
+ end
974
+
975
+ def update_columns(updates)
976
+ case updates
977
+ when Hash
978
+ updates.keys.map(&:to_s)
979
+ else
980
+ klass.column_names
981
+ end
982
+ end
983
+
984
+ def update_values(updates)
985
+ return {} unless updates.is_a?(Hash)
986
+
987
+ updates.transform_keys(&:to_s)
988
+ end
989
+ end
990
+
991
+ module Install
992
+ module_function
993
+
994
+ def call
995
+ return if @installed
996
+
997
+ install_current_attributes_observer
998
+ install_warden_observer
999
+ install_action_dispatch_observers
1000
+
1001
+ ActiveRecord::AttributeMethods::Read.prepend(AttributeObserver)
1002
+ ActiveRecord::Base.prepend(PersistenceObserver) unless ActiveRecord::Base < PersistenceObserver
1003
+ ActiveRecord::Relation.prepend(RelationObserver)
1004
+
1005
+ ActiveRecord::Base.after_commit do |record|
1006
+ ChangeLog.record(ChangeEvents.active_record_commit(record))
1007
+ end
1008
+
1009
+ @installed = true
1010
+ end
1011
+
1012
+ def install_current_attributes_observer
1013
+ singleton = class << ActiveSupport::CurrentAttributes; self; end
1014
+ singleton.prepend(CurrentAttributesClassObserver) unless singleton < CurrentAttributesClassObserver
1015
+
1016
+ ObjectSpace.each_object(Class) do |klass|
1017
+ next unless klass < ActiveSupport::CurrentAttributes
1018
+
1019
+ Runtime.wrap_current_attribute_readers(klass, current_attribute_names(klass))
1020
+ end
1021
+ end
1022
+
1023
+ def install_warden_observer
1024
+ return unless defined?(::Warden::Proxy)
1025
+ return if ::Warden::Proxy < WardenObserver
1026
+
1027
+ ::Warden::Proxy.prepend(WardenObserver)
1028
+ end
1029
+
1030
+ def install_action_dispatch_observers
1031
+ if defined?(::ActionDispatch::Request::Session) && !(::ActionDispatch::Request::Session < SessionObserver)
1032
+ ::ActionDispatch::Request::Session.prepend(SessionObserver)
1033
+ end
1034
+
1035
+ if defined?(::ActionDispatch::Cookies::CookieJar) && !(::ActionDispatch::Cookies::CookieJar < CookieObserver)
1036
+ ::ActionDispatch::Cookies::CookieJar.prepend(CookieObserver)
1037
+ end
1038
+
1039
+ if defined?(::ActionDispatch::Request) && !(::ActionDispatch::Request < RequestObserver)
1040
+ ::ActionDispatch::Request.prepend(RequestObserver)
1041
+ end
1042
+ end
1043
+
1044
+ def current_attribute_names(klass)
1045
+ if klass.respond_to?(:defaults)
1046
+ klass.defaults.keys
1047
+ else
1048
+ []
1049
+ end
1050
+ end
1051
+ end
1052
+
1053
+ module_function
1054
+
1055
+ def wrap_current_attribute_readers(klass, names)
1056
+ wrapped = klass.instance_variable_get(:@upkeep_wrapped_current_attributes) || {}
1057
+
1058
+ names.each do |name|
1059
+ name = name.to_sym
1060
+ next if wrapped[name]
1061
+ next unless klass.method_defined?(name)
1062
+
1063
+ original_reader = klass.instance_method(name)
1064
+ klass.define_method(name) do
1065
+ value = original_reader.bind_call(self)
1066
+ Runtime::Ambient.record_current_attribute(klass.name || klass.inspect, name, value)
1067
+ value
1068
+ end
1069
+ wrapped[name] = true
1070
+ end
1071
+
1072
+ klass.instance_variable_set(:@upkeep_wrapped_current_attributes, wrapped)
1073
+ end
1074
+ end
1075
+ end