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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +53 -0
- metadata +296 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Replay
|
|
5
|
+
module Payload
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def from_h(snapshot)
|
|
9
|
+
return snapshot if snapshot.is_a?(Payload)
|
|
10
|
+
|
|
11
|
+
snapshot = Replay.symbolize_keys(snapshot || {})
|
|
12
|
+
return Empty.new if snapshot.empty?
|
|
13
|
+
|
|
14
|
+
case snapshot.fetch(:type).to_s
|
|
15
|
+
when "controller_page"
|
|
16
|
+
ControllerPage.new(
|
|
17
|
+
controller_class: snapshot[:controller_class],
|
|
18
|
+
action: snapshot.fetch(:action),
|
|
19
|
+
env: snapshot.fetch(:env)
|
|
20
|
+
)
|
|
21
|
+
when "template"
|
|
22
|
+
Template.new(
|
|
23
|
+
controller_class: snapshot[:controller_class],
|
|
24
|
+
template: snapshot.fetch(:template),
|
|
25
|
+
locals: Replay.value_hash_from_h(snapshot.fetch(:locals))
|
|
26
|
+
)
|
|
27
|
+
when "fragment"
|
|
28
|
+
Fragment.new(
|
|
29
|
+
controller_class: snapshot[:controller_class],
|
|
30
|
+
template: snapshot.fetch(:template),
|
|
31
|
+
locals: Replay.value_hash_from_h(snapshot.fetch(:locals))
|
|
32
|
+
)
|
|
33
|
+
when "collection"
|
|
34
|
+
Collection.new(
|
|
35
|
+
controller_class: snapshot[:controller_class],
|
|
36
|
+
partial: snapshot.fetch(:partial),
|
|
37
|
+
collection: Value.from_h(snapshot.fetch(:collection)),
|
|
38
|
+
options: Replay.value_hash_from_h(snapshot.fetch(:options))
|
|
39
|
+
)
|
|
40
|
+
when "collection_member"
|
|
41
|
+
CollectionMember.new(
|
|
42
|
+
controller_class: snapshot[:controller_class],
|
|
43
|
+
partial: snapshot.fetch(:partial),
|
|
44
|
+
record: Value.from_h(snapshot.fetch(:record)),
|
|
45
|
+
options: Replay.value_hash_from_h(snapshot.fetch(:options))
|
|
46
|
+
)
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "unknown replay payload type: #{snapshot.fetch(:type).inspect}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def empty?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
module Value
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
def from_h(snapshot)
|
|
61
|
+
return snapshot if snapshot.is_a?(Value)
|
|
62
|
+
return LiteralValue.new(value: snapshot) unless snapshot.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
snapshot = Replay.symbolize_keys(snapshot)
|
|
65
|
+
|
|
66
|
+
case snapshot.fetch(:type).to_s
|
|
67
|
+
when "active_record"
|
|
68
|
+
ActiveRecordValue.new(
|
|
69
|
+
model: snapshot.fetch(:model),
|
|
70
|
+
id: snapshot.fetch(:id)
|
|
71
|
+
)
|
|
72
|
+
when "active_record_relation"
|
|
73
|
+
ActiveRecordRelationValue.new(
|
|
74
|
+
model: snapshot.fetch(:model),
|
|
75
|
+
sql: snapshot.fetch(:sql),
|
|
76
|
+
primary_key: snapshot[:primary_key],
|
|
77
|
+
appendable: snapshot.fetch(:appendable),
|
|
78
|
+
limit_value: snapshot.fetch(:limit_value),
|
|
79
|
+
predicates: snapshot.fetch(:predicates),
|
|
80
|
+
member_ids: snapshot.fetch(:member_ids)
|
|
81
|
+
)
|
|
82
|
+
when "array"
|
|
83
|
+
ArrayValue.new(items: snapshot.fetch(:items).map { |item| from_h(item) })
|
|
84
|
+
when "hash"
|
|
85
|
+
HashValue.new(entries: Replay.value_hash_from_h(snapshot.fetch(:entries)))
|
|
86
|
+
when "literal"
|
|
87
|
+
LiteralValue.new(value: snapshot[:value])
|
|
88
|
+
when "unsupported"
|
|
89
|
+
UnsupportedValue.new(class_name: snapshot.fetch(:class))
|
|
90
|
+
when "refused_active_record_relation"
|
|
91
|
+
RefusedActiveRecordRelationValue.new(
|
|
92
|
+
model: snapshot.fetch(:model),
|
|
93
|
+
sql_digest: snapshot.fetch(:sql_digest),
|
|
94
|
+
reason: snapshot.fetch(:reason)
|
|
95
|
+
)
|
|
96
|
+
else
|
|
97
|
+
raise ArgumentError, "unknown replay value type: #{snapshot.fetch(:type).inspect}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Empty
|
|
103
|
+
include Payload
|
|
104
|
+
|
|
105
|
+
def empty?
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_h
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class ControllerPage < Data.define(:controller_class, :action, :env)
|
|
115
|
+
include Payload
|
|
116
|
+
|
|
117
|
+
def type = "controller_page"
|
|
118
|
+
|
|
119
|
+
def to_h
|
|
120
|
+
{
|
|
121
|
+
type: type,
|
|
122
|
+
controller_class: controller_class,
|
|
123
|
+
action: action,
|
|
124
|
+
env: env
|
|
125
|
+
}.compact
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class Template < Data.define(:controller_class, :template, :locals)
|
|
130
|
+
include Payload
|
|
131
|
+
|
|
132
|
+
def type = "template"
|
|
133
|
+
|
|
134
|
+
def to_h
|
|
135
|
+
{
|
|
136
|
+
type: type,
|
|
137
|
+
controller_class: controller_class,
|
|
138
|
+
template: template,
|
|
139
|
+
locals: Replay.value_hash_to_h(locals)
|
|
140
|
+
}.compact
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class Fragment < Data.define(:controller_class, :template, :locals)
|
|
145
|
+
include Payload
|
|
146
|
+
|
|
147
|
+
def type = "fragment"
|
|
148
|
+
|
|
149
|
+
def to_h
|
|
150
|
+
{
|
|
151
|
+
type: type,
|
|
152
|
+
controller_class: controller_class,
|
|
153
|
+
template: template,
|
|
154
|
+
locals: Replay.value_hash_to_h(locals)
|
|
155
|
+
}.compact
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class Collection < Data.define(:controller_class, :partial, :collection, :options)
|
|
160
|
+
include Payload
|
|
161
|
+
|
|
162
|
+
def type = "collection"
|
|
163
|
+
|
|
164
|
+
def derived_partial?
|
|
165
|
+
partial == "derived"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_h
|
|
169
|
+
{
|
|
170
|
+
type: type,
|
|
171
|
+
controller_class: controller_class,
|
|
172
|
+
partial: partial,
|
|
173
|
+
collection: collection.to_h,
|
|
174
|
+
options: Replay.value_hash_to_h(options)
|
|
175
|
+
}.compact
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
class CollectionMember < Data.define(:controller_class, :partial, :record, :options)
|
|
180
|
+
include Payload
|
|
181
|
+
|
|
182
|
+
def type = "collection_member"
|
|
183
|
+
|
|
184
|
+
def to_h
|
|
185
|
+
{
|
|
186
|
+
type: type,
|
|
187
|
+
controller_class: controller_class,
|
|
188
|
+
partial: partial,
|
|
189
|
+
record: record.to_h,
|
|
190
|
+
options: Replay.value_hash_to_h(options)
|
|
191
|
+
}.compact
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class ActiveRecordValue < Data.define(:model, :id)
|
|
196
|
+
include Value
|
|
197
|
+
|
|
198
|
+
def type = "active_record"
|
|
199
|
+
|
|
200
|
+
def to_h
|
|
201
|
+
{ type: type, model: model, id: id }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class ActiveRecordRelationValue < Data.define(:model, :sql, :primary_key, :appendable, :limit_value, :predicates, :member_ids)
|
|
206
|
+
include Value
|
|
207
|
+
|
|
208
|
+
def type = "active_record_relation"
|
|
209
|
+
|
|
210
|
+
def appendable?
|
|
211
|
+
!!appendable
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def to_h
|
|
215
|
+
{
|
|
216
|
+
type: type,
|
|
217
|
+
model: model,
|
|
218
|
+
sql: sql,
|
|
219
|
+
primary_key: primary_key,
|
|
220
|
+
appendable: appendable,
|
|
221
|
+
limit_value: limit_value,
|
|
222
|
+
predicates: predicates,
|
|
223
|
+
member_ids: member_ids
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class ArrayValue < Data.define(:items)
|
|
229
|
+
include Value
|
|
230
|
+
|
|
231
|
+
def type = "array"
|
|
232
|
+
|
|
233
|
+
def to_h
|
|
234
|
+
{ type: type, items: items.map(&:to_h) }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
class HashValue < Data.define(:entries)
|
|
239
|
+
include Value
|
|
240
|
+
|
|
241
|
+
def type = "hash"
|
|
242
|
+
|
|
243
|
+
def to_h
|
|
244
|
+
{ type: type, entries: Replay.value_hash_to_h(entries) }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class LiteralValue < Data.define(:value)
|
|
249
|
+
include Value
|
|
250
|
+
|
|
251
|
+
def type = "literal"
|
|
252
|
+
|
|
253
|
+
def to_h
|
|
254
|
+
{ type: type, value: value }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
class UnsupportedValue < Data.define(:class_name)
|
|
259
|
+
include Value
|
|
260
|
+
|
|
261
|
+
def type = "unsupported"
|
|
262
|
+
|
|
263
|
+
def to_h
|
|
264
|
+
{ type: type, class: class_name }
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
class RefusedActiveRecordRelationValue < Data.define(:model, :sql_digest, :reason)
|
|
269
|
+
include Value
|
|
270
|
+
|
|
271
|
+
def type = "refused_active_record_relation"
|
|
272
|
+
|
|
273
|
+
def to_h
|
|
274
|
+
{
|
|
275
|
+
type: type,
|
|
276
|
+
model: model,
|
|
277
|
+
sql_digest: sql_digest,
|
|
278
|
+
reason: reason
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
module_function
|
|
284
|
+
|
|
285
|
+
def payload(value)
|
|
286
|
+
Payload.from_h(value)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def value(value)
|
|
290
|
+
Value.from_h(value)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def value_hash_from_h(values)
|
|
294
|
+
values.to_h.each_with_object({}) do |(key, nested_value), snapshot|
|
|
295
|
+
snapshot[key.to_s] = Value.from_h(nested_value)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def value_hash_to_h(values)
|
|
300
|
+
values.to_h.each_with_object({}) do |(key, nested_value), snapshot|
|
|
301
|
+
snapshot[key] = Value.from_h(nested_value).to_h
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def active_record_value(record)
|
|
306
|
+
ActiveRecordValue.new(model: record.class.name, id: record.id)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
class Recipe
|
|
310
|
+
attr_reader :kind, :frame_id, :target_kind, :target_id, :template, :metadata, :runtime, :replay
|
|
311
|
+
|
|
312
|
+
def initialize(kind:, frame_id:, target_kind:, target_id:, template: nil, metadata: {}, runtime: nil, replay: nil, &renderer)
|
|
313
|
+
@kind = kind
|
|
314
|
+
@frame_id = frame_id
|
|
315
|
+
@target_kind = target_kind
|
|
316
|
+
@target_id = target_id
|
|
317
|
+
@template = template
|
|
318
|
+
@metadata = metadata
|
|
319
|
+
@runtime = runtime
|
|
320
|
+
@replay = Replay.payload(replay)
|
|
321
|
+
@renderer = renderer
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def render
|
|
325
|
+
return @renderer.call if @renderer
|
|
326
|
+
|
|
327
|
+
runtime_renderer.render(self)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def render_target(target)
|
|
331
|
+
html = render
|
|
332
|
+
return html if target_match?(target)
|
|
333
|
+
|
|
334
|
+
require_relative "targeting"
|
|
335
|
+
Targeting::Extraction.extract_target_html(html, target)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def target_match?(target)
|
|
339
|
+
target && target.kind != "page" && target.kind == target_kind && target.id == target_id
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def manifest_target_render?(target)
|
|
343
|
+
!!manifest_reference && target_match?(target)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def manifest_reference
|
|
347
|
+
metadata[:manifest] || metadata["manifest"]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def to_h
|
|
351
|
+
snapshot = {
|
|
352
|
+
kind: kind,
|
|
353
|
+
frame_id: frame_id,
|
|
354
|
+
target_kind: target_kind,
|
|
355
|
+
target_id: target_id,
|
|
356
|
+
template: template,
|
|
357
|
+
metadata: metadata
|
|
358
|
+
}.compact
|
|
359
|
+
|
|
360
|
+
snapshot[:runtime] = runtime if runtime
|
|
361
|
+
replay_snapshot = replay&.to_h
|
|
362
|
+
snapshot[:replay] = replay_snapshot if replay_snapshot && !replay_snapshot.empty?
|
|
363
|
+
snapshot
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def self.from_h(snapshot)
|
|
367
|
+
snapshot = Replay.symbolize_keys(snapshot)
|
|
368
|
+
|
|
369
|
+
new(
|
|
370
|
+
kind: snapshot.fetch(:kind),
|
|
371
|
+
frame_id: snapshot.fetch(:frame_id),
|
|
372
|
+
target_kind: snapshot.fetch(:target_kind),
|
|
373
|
+
target_id: snapshot.fetch(:target_id),
|
|
374
|
+
template: snapshot[:template],
|
|
375
|
+
metadata: snapshot.fetch(:metadata),
|
|
376
|
+
runtime: snapshot[:runtime],
|
|
377
|
+
replay: snapshot.fetch(:replay, {})
|
|
378
|
+
)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
private
|
|
382
|
+
|
|
383
|
+
def runtime_renderer
|
|
384
|
+
case runtime
|
|
385
|
+
when "rails"
|
|
386
|
+
require_relative "rails/replay"
|
|
387
|
+
Upkeep::Rails::Replay
|
|
388
|
+
else
|
|
389
|
+
raise "replay recipe has no renderer"
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def symbolize_keys(value)
|
|
395
|
+
case value
|
|
396
|
+
when Hash
|
|
397
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
398
|
+
normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
399
|
+
result[normalized_key] = symbolize_keys(nested_value)
|
|
400
|
+
end
|
|
401
|
+
when Array
|
|
402
|
+
value.map { |nested_value| symbolize_keys(nested_value) }
|
|
403
|
+
else
|
|
404
|
+
value
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|