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,466 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Upkeep
|
|
7
|
+
module Dependencies
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :source, :key, :metadata
|
|
10
|
+
|
|
11
|
+
def initialize(source:, key:, metadata: {})
|
|
12
|
+
@source = source
|
|
13
|
+
@key = key
|
|
14
|
+
@metadata = metadata
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cache_key
|
|
18
|
+
[source, key]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def matches_change?(_change)
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def identity?
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def identity_key
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visibility
|
|
34
|
+
:public
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def precision
|
|
38
|
+
:unknown
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def narrow_frame_safe?
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
source: source,
|
|
48
|
+
key: key,
|
|
49
|
+
visibility: visibility,
|
|
50
|
+
precision: precision,
|
|
51
|
+
metadata: metadata
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class ActiveRecordAttribute < Base
|
|
57
|
+
def initialize(table:, id:, attribute:, model: nil)
|
|
58
|
+
super(
|
|
59
|
+
source: :active_record_attribute,
|
|
60
|
+
key: { table: table, id: id, attribute: attribute },
|
|
61
|
+
metadata: { model: model }.compact
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def matches_change?(change)
|
|
66
|
+
key.fetch(:table) == change.fetch(:table) &&
|
|
67
|
+
(!change[:id] || key.fetch(:id) == change[:id]) &&
|
|
68
|
+
change.fetch(:changed_attributes, []).include?(key.fetch(:attribute))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def precision
|
|
72
|
+
:record_attribute
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def narrow_frame_safe?
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class ActiveRecordCollection < Base
|
|
81
|
+
UNKNOWN = Object.new
|
|
82
|
+
|
|
83
|
+
def initialize(
|
|
84
|
+
primary_table:,
|
|
85
|
+
table_columns:,
|
|
86
|
+
coverage:,
|
|
87
|
+
sql:,
|
|
88
|
+
predicates: [],
|
|
89
|
+
source: :active_record_collection,
|
|
90
|
+
precision: :collection_predicate
|
|
91
|
+
)
|
|
92
|
+
table_columns = normalize_table_columns(table_columns)
|
|
93
|
+
coverage = coverage.to_sym
|
|
94
|
+
unless coverage == :columns
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"unsupported Active Record predicate coverage: #{coverage}; collection dependencies require proven column coverage"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@precision = precision.to_sym
|
|
100
|
+
super(
|
|
101
|
+
source: source.to_sym,
|
|
102
|
+
key: {
|
|
103
|
+
table: primary_table,
|
|
104
|
+
predicate_digest: Digest::SHA256.hexdigest(sql)[0, 16]
|
|
105
|
+
},
|
|
106
|
+
metadata: {
|
|
107
|
+
primary_table: primary_table,
|
|
108
|
+
table_columns: table_columns,
|
|
109
|
+
coverage: coverage.to_s,
|
|
110
|
+
sql: sql,
|
|
111
|
+
predicates: normalize_predicates(predicates)
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def matches_change?(change)
|
|
117
|
+
return false unless table_columns.key?(change.fetch(:table))
|
|
118
|
+
|
|
119
|
+
predicate_match = predicate_match(change)
|
|
120
|
+
return predicate_match unless predicate_match == UNKNOWN
|
|
121
|
+
|
|
122
|
+
return true if create_change?(change)
|
|
123
|
+
return true if delete_change?(change)
|
|
124
|
+
|
|
125
|
+
table_columns.fetch(change.fetch(:table)).intersect?(change.fetch(:changed_attributes, []))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def precision
|
|
129
|
+
@precision
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def collection_lookup_tables
|
|
133
|
+
table_columns.keys.sort
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def collection_lookup_columns
|
|
137
|
+
table_columns.flat_map do |table, columns|
|
|
138
|
+
columns.map { |column| [table, column] }
|
|
139
|
+
end.sort
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def predicate_match(change)
|
|
145
|
+
predicates = predicates_for_table(change.fetch(:table))
|
|
146
|
+
return UNKNOWN if predicates.empty?
|
|
147
|
+
|
|
148
|
+
old_match = values_match_predicates(change.fetch(:old_values, {}), predicates)
|
|
149
|
+
new_match = values_match_predicates(change.fetch(:new_values, {}), predicates)
|
|
150
|
+
return true if old_match == true || new_match == true
|
|
151
|
+
|
|
152
|
+
if old_match == false || new_match == false
|
|
153
|
+
return false if predicate_columns(predicates).intersect?(change.fetch(:changed_attributes, [])) ||
|
|
154
|
+
create_change?(change) ||
|
|
155
|
+
delete_change?(change)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
UNKNOWN
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def create_change?(change)
|
|
162
|
+
change.fetch(:type).to_s.include?("create")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def delete_change?(change)
|
|
166
|
+
type = change.fetch(:type).to_s
|
|
167
|
+
type.include?("delete") || type.include?("destroy")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def values_match_predicates(values, predicates)
|
|
171
|
+
values = stringify_keys(values)
|
|
172
|
+
return UNKNOWN unless predicates.all? { |predicate| values.key?(predicate.fetch(:column)) }
|
|
173
|
+
|
|
174
|
+
predicates.all? do |predicate|
|
|
175
|
+
value = values.fetch(predicate.fetch(:column))
|
|
176
|
+
predicate.fetch(:values).include?(value)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def predicates_for_table(table)
|
|
181
|
+
predicates.select { |predicate| predicate.fetch(:table) == table.to_s }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def predicate_columns(predicates)
|
|
185
|
+
predicates.map { |predicate| predicate.fetch(:column) }.uniq
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def coverage
|
|
189
|
+
metadata.fetch(:coverage).to_sym.tap do |value|
|
|
190
|
+
raise ArgumentError, "unsupported Active Record collection coverage: #{value}" unless value == :columns
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def table_columns
|
|
195
|
+
metadata.fetch(:table_columns)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def predicates
|
|
199
|
+
metadata.fetch(:predicates)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_table_columns(value)
|
|
203
|
+
Dependencies.symbolize_keys(value).to_h do |table, columns|
|
|
204
|
+
[table.to_s, Array(columns).map(&:to_s).uniq.sort]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def normalize_predicates(value)
|
|
209
|
+
Array(value).filter_map do |predicate|
|
|
210
|
+
predicate = Dependencies.symbolize_keys(predicate)
|
|
211
|
+
values = Array(predicate[:values]).compact
|
|
212
|
+
next if predicate[:table].nil? || predicate[:column].nil? || values.empty?
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
table: predicate.fetch(:table).to_s,
|
|
216
|
+
column: predicate.fetch(:column).to_s,
|
|
217
|
+
operator: predicate.fetch(:operator, "eq").to_s,
|
|
218
|
+
values: values.uniq
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def stringify_keys(values)
|
|
224
|
+
values.to_h.transform_keys(&:to_s)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class ActiveRecordQuery < ActiveRecordCollection
|
|
229
|
+
def initialize(primary_table:, table_columns:, coverage:, sql:, predicates: [])
|
|
230
|
+
super(
|
|
231
|
+
primary_table: primary_table,
|
|
232
|
+
table_columns: table_columns,
|
|
233
|
+
coverage: coverage,
|
|
234
|
+
sql: sql,
|
|
235
|
+
predicates: predicates,
|
|
236
|
+
source: :active_record_query,
|
|
237
|
+
precision: :query_predicate
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
class Identity < Base
|
|
243
|
+
def initialize(source:, key:, value:, metadata: {})
|
|
244
|
+
super(
|
|
245
|
+
source: source,
|
|
246
|
+
key: { key: key, value: value },
|
|
247
|
+
metadata: metadata
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def identity?
|
|
252
|
+
true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def identity_key
|
|
256
|
+
{ source: source, key: key.fetch(:key), value: key.fetch(:value) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def visibility
|
|
260
|
+
:identity_bound
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def precision
|
|
264
|
+
:identity
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
class WardenUser < Identity
|
|
269
|
+
def initialize(scope:, user:)
|
|
270
|
+
super(
|
|
271
|
+
source: :warden_user,
|
|
272
|
+
key: scope.to_s,
|
|
273
|
+
value: Dependencies.model_identity(user),
|
|
274
|
+
metadata: Dependencies.model_metadata(user).merge(scope: scope.to_s)
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
class CurrentAttribute < Identity
|
|
280
|
+
def initialize(owner:, name:, value:)
|
|
281
|
+
super(
|
|
282
|
+
source: :current_attribute,
|
|
283
|
+
key: "#{owner}.#{name}",
|
|
284
|
+
value: Dependencies.canonical_identity(value),
|
|
285
|
+
metadata: { owner: owner.to_s, name: name.to_s }
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
class SessionValue < Identity
|
|
291
|
+
def initialize(key:, value:)
|
|
292
|
+
super(
|
|
293
|
+
source: :session,
|
|
294
|
+
key: key.to_s,
|
|
295
|
+
value: Dependencies.private_fingerprint(value),
|
|
296
|
+
metadata: { key: key.to_s, value_class: value.class.name }
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
class CookieValue < Identity
|
|
302
|
+
def initialize(key:, value:)
|
|
303
|
+
super(
|
|
304
|
+
source: :cookie,
|
|
305
|
+
key: key.to_s,
|
|
306
|
+
value: Dependencies.private_fingerprint(value),
|
|
307
|
+
metadata: { key: key.to_s, value_class: value.class.name }
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class RequestValue < Identity
|
|
313
|
+
def initialize(key:, value:)
|
|
314
|
+
super(
|
|
315
|
+
source: :request,
|
|
316
|
+
key: key.to_s,
|
|
317
|
+
value: Dependencies.private_fingerprint(value),
|
|
318
|
+
metadata: { key: key.to_s, value_class: value.class.name }
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
class Unknown < Base
|
|
324
|
+
def initialize(source:, metadata: {})
|
|
325
|
+
super(
|
|
326
|
+
source: source,
|
|
327
|
+
key: Digest::SHA256.hexdigest(metadata.inspect)[0, 16],
|
|
328
|
+
metadata: metadata
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def visibility
|
|
333
|
+
:private
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
class Restored < Base
|
|
338
|
+
def initialize(source:, key:, metadata:, visibility:, precision:)
|
|
339
|
+
super(source: source, key: key, metadata: metadata)
|
|
340
|
+
@visibility = visibility.to_sym
|
|
341
|
+
@precision = precision.to_sym
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def identity?
|
|
345
|
+
visibility == :identity_bound
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def identity_key
|
|
349
|
+
return unless identity?
|
|
350
|
+
|
|
351
|
+
{ source: source, key: key.fetch(:key), value: key.fetch(:value) }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
attr_reader :visibility, :precision
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
module_function
|
|
358
|
+
|
|
359
|
+
def from_h(snapshot)
|
|
360
|
+
snapshot = symbolize_keys(snapshot)
|
|
361
|
+
source = snapshot.fetch(:source)
|
|
362
|
+
key = symbolize_keys(snapshot.fetch(:key))
|
|
363
|
+
metadata = symbolize_keys(snapshot.fetch(:metadata))
|
|
364
|
+
|
|
365
|
+
case source.to_sym
|
|
366
|
+
when :active_record_attribute
|
|
367
|
+
ActiveRecordAttribute.new(
|
|
368
|
+
table: key.fetch(:table),
|
|
369
|
+
id: key.fetch(:id),
|
|
370
|
+
attribute: key.fetch(:attribute),
|
|
371
|
+
model: metadata[:model]
|
|
372
|
+
)
|
|
373
|
+
when :active_record_collection, :active_record_query
|
|
374
|
+
dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
|
|
375
|
+
dependency_class.new(
|
|
376
|
+
primary_table: metadata.fetch(:primary_table),
|
|
377
|
+
table_columns: metadata.fetch(:table_columns),
|
|
378
|
+
coverage: metadata.fetch(:coverage),
|
|
379
|
+
sql: metadata.fetch(:sql),
|
|
380
|
+
predicates: metadata.fetch(:predicates)
|
|
381
|
+
)
|
|
382
|
+
else
|
|
383
|
+
if snapshot.fetch(:visibility).to_sym == :identity_bound
|
|
384
|
+
Identity.new(
|
|
385
|
+
source: source,
|
|
386
|
+
key: key.fetch(:key),
|
|
387
|
+
value: key.fetch(:value),
|
|
388
|
+
metadata: metadata
|
|
389
|
+
)
|
|
390
|
+
else
|
|
391
|
+
Restored.new(
|
|
392
|
+
source: source,
|
|
393
|
+
key: key,
|
|
394
|
+
metadata: metadata,
|
|
395
|
+
visibility: snapshot.fetch(:visibility),
|
|
396
|
+
precision: snapshot.fetch(:precision)
|
|
397
|
+
)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def model_identity(value)
|
|
403
|
+
return nil unless value
|
|
404
|
+
|
|
405
|
+
if value.respond_to?(:id) && value.class.respond_to?(:name)
|
|
406
|
+
{ model: value.class.name, id: value.id }
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def model_metadata(value)
|
|
411
|
+
return {} unless value
|
|
412
|
+
|
|
413
|
+
{
|
|
414
|
+
model: value.class.name,
|
|
415
|
+
table: value.class.respond_to?(:table_name) ? value.class.table_name : nil,
|
|
416
|
+
id: value.respond_to?(:id) ? value.id : nil
|
|
417
|
+
}.compact
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def canonical_identity(value)
|
|
421
|
+
case value
|
|
422
|
+
when nil, true, false, Numeric, String, Symbol
|
|
423
|
+
value
|
|
424
|
+
else
|
|
425
|
+
model_identity(value) || private_fingerprint(value)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def private_fingerprint(value)
|
|
430
|
+
Digest::SHA256.hexdigest(JSON.generate(private_fingerprint_payload(value)))[0, 16]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def private_fingerprint_payload(value)
|
|
434
|
+
case value
|
|
435
|
+
when nil, true, false, Numeric, String
|
|
436
|
+
[value.class.name, value]
|
|
437
|
+
when Symbol
|
|
438
|
+
["Symbol", value.to_s]
|
|
439
|
+
when Array
|
|
440
|
+
["Array", value.map { |item| private_fingerprint_payload(item) }]
|
|
441
|
+
when Hash
|
|
442
|
+
entries = value.keys.sort_by { |key| JSON.generate(private_fingerprint_payload(key)) }.map do |key|
|
|
443
|
+
[private_fingerprint_payload(key), private_fingerprint_payload(value.fetch(key))]
|
|
444
|
+
end
|
|
445
|
+
["Hash", entries]
|
|
446
|
+
else
|
|
447
|
+
identity = model_identity(value)
|
|
448
|
+
identity ? ["Model", identity] : ["Object", value.class.name, value.inspect]
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def symbolize_keys(value)
|
|
453
|
+
case value
|
|
454
|
+
when Hash
|
|
455
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
456
|
+
normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
457
|
+
result[normalized_key] = symbolize_keys(nested_value)
|
|
458
|
+
end
|
|
459
|
+
when Array
|
|
460
|
+
value.map { |nested_value| symbolize_keys(nested_value) }
|
|
461
|
+
else
|
|
462
|
+
value
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "template_manifest"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module HerbSupport
|
|
7
|
+
class DeveloperReport
|
|
8
|
+
FALLBACK_ACTIONS = {
|
|
9
|
+
"helper_hidden_collection" => "Move collection rendering out of helper-only HTML and into an explicit render site.",
|
|
10
|
+
"manifest_runtime_mismatch" => "Inspect manifest provenance mismatches before trusting narrow updates.",
|
|
11
|
+
"multi_root_partial" => "Wrap the partial in one stable root element so it can carry a fragment marker.",
|
|
12
|
+
"no_herb_render_site" => "Extract updateable repeated markup into a partial render so Herb can plan a render site.",
|
|
13
|
+
"page_dependency_without_narrower_frame" => "Add a fragment or render-site boundary around the data-dependent region.",
|
|
14
|
+
"parse_failure" => "Fix the Herb parse error before using source-derived update addresses.",
|
|
15
|
+
"preloaded_plain_data" => "Keep record identity available to the view or attach summaries to their source records."
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(manifests:, proof_report: nil)
|
|
19
|
+
@manifests = manifests
|
|
20
|
+
@proof_report = proof_report
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
actions = template_actions + fallback_actions
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
summary: TemplateManifest.summary(manifests).merge(
|
|
28
|
+
page_fallback_reasons: page_fallback_reasons,
|
|
29
|
+
actionable_items: actions.size,
|
|
30
|
+
gate_passed: actions.all? { |action| action.fetch(:message) }
|
|
31
|
+
),
|
|
32
|
+
templates: manifests.map { |manifest| template_report(manifest) },
|
|
33
|
+
actions: actions
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :manifests, :proof_report
|
|
40
|
+
|
|
41
|
+
def template_report(manifest)
|
|
42
|
+
{
|
|
43
|
+
path: manifest.path,
|
|
44
|
+
kind: manifest.partial? ? "partial" : "page",
|
|
45
|
+
parse_ok: manifest.parse.fetch(:ok),
|
|
46
|
+
render_sites: manifest.render_nodes.size,
|
|
47
|
+
fragment_root_tags: manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "fragment_root" },
|
|
48
|
+
helper_lowered_elements: manifest.helper_lowered_elements.size,
|
|
49
|
+
blockers: template_blockers(manifest)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def template_actions
|
|
54
|
+
manifests.flat_map do |manifest|
|
|
55
|
+
template_blockers(manifest).map do |blocker|
|
|
56
|
+
{
|
|
57
|
+
source: "template",
|
|
58
|
+
path: manifest.path,
|
|
59
|
+
reason: blocker,
|
|
60
|
+
message: template_action_message(blocker)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def template_blockers(manifest)
|
|
67
|
+
blockers = []
|
|
68
|
+
blockers << "parse_failure" unless manifest.parse.fetch(:ok)
|
|
69
|
+
blockers << "partial_without_single_root" if manifest.partial? && manifest.parse.fetch(:ok) && !manifest.root_shape.fetch(:single_root, false)
|
|
70
|
+
blockers << "page_without_render_site" if !manifest.partial? && manifest.parse.fetch(:ok) && manifest.render_nodes.empty?
|
|
71
|
+
blockers << "helper_lowered_html" if manifest.helper_lowered_elements.any?
|
|
72
|
+
blockers
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def template_action_message(blocker)
|
|
76
|
+
case blocker
|
|
77
|
+
when "parse_failure"
|
|
78
|
+
FALLBACK_ACTIONS.fetch("parse_failure")
|
|
79
|
+
when "partial_without_single_root"
|
|
80
|
+
FALLBACK_ACTIONS.fetch("multi_root_partial")
|
|
81
|
+
when "page_without_render_site"
|
|
82
|
+
FALLBACK_ACTIONS.fetch("no_herb_render_site")
|
|
83
|
+
when "helper_lowered_html"
|
|
84
|
+
"Replace helper-lowered HTML with explicit template structure when it should be independently updateable."
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fallback_actions
|
|
89
|
+
proof_cases.flat_map do |test_case|
|
|
90
|
+
test_case.fetch(:selected_targets).filter_map do |target|
|
|
91
|
+
next unless target.fetch(:kind) == "page"
|
|
92
|
+
|
|
93
|
+
fallback_reason = target[:fallback_reason]
|
|
94
|
+
next unless fallback_reason
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
source: "proof",
|
|
98
|
+
case: test_case.fetch(:name),
|
|
99
|
+
target: target.fetch(:id),
|
|
100
|
+
reason: fallback_reason,
|
|
101
|
+
message: FALLBACK_ACTIONS.fetch(fallback_reason, "Add a narrower template boundary or keep the page fallback.")
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def proof_cases
|
|
108
|
+
Array(proof_report&.fetch(:cases, []))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def page_fallback_reasons
|
|
112
|
+
proof_report&.fetch(:summary, {})&.fetch(:page_fallback_reasons, {}) || {}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "manifest_diff"
|
|
5
|
+
|
|
6
|
+
module Upkeep
|
|
7
|
+
module HerbSupport
|
|
8
|
+
class ManifestCache
|
|
9
|
+
Entry = Data.define(:path, :source_digest, :source, :manifest, :last_update)
|
|
10
|
+
|
|
11
|
+
attr_reader :entries
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@entries = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(path:, source:, parse_options: ManifestDiff::PARSE_OPTIONS)
|
|
18
|
+
source_digest = digest(source)
|
|
19
|
+
entry = entries[path]
|
|
20
|
+
|
|
21
|
+
return entry.manifest if entry&.source_digest == source_digest
|
|
22
|
+
|
|
23
|
+
update = update_for(path: path, old_source: entry&.source, new_source: source, parse_options: parse_options)
|
|
24
|
+
manifest = update[:new_manifest] || TemplateManifest.build(path: path, source: source, parse_options: parse_options)
|
|
25
|
+
entries[path] = Entry.new(path, source_digest, source.dup, manifest, update_payload(update))
|
|
26
|
+
|
|
27
|
+
manifest
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def last_update_for(path)
|
|
31
|
+
entries.fetch(path).last_update
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summary
|
|
35
|
+
updates = entries.values.map(&:last_update)
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
entries: entries.size,
|
|
39
|
+
actions: updates.map { |update| update.fetch(:action) }.tally,
|
|
40
|
+
topology_changes: updates.count { |update| update.fetch(:topology_changed, false) }
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear
|
|
45
|
+
entries.clear
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def update_for(path:, old_source:, new_source:, parse_options:)
|
|
51
|
+
return initial_update(path: path, source: new_source, parse_options: parse_options) unless old_source
|
|
52
|
+
|
|
53
|
+
ManifestDiff.plan(path: path, old_source: old_source, new_source: new_source, parse_options: parse_options).to_h
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initial_update(path:, source:, parse_options:)
|
|
57
|
+
manifest = TemplateManifest.build(path: path, source: source, parse_options: parse_options)
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
path: path,
|
|
61
|
+
action: "initial_build",
|
|
62
|
+
reason: "new_template",
|
|
63
|
+
topology_changed: true,
|
|
64
|
+
diff_identical: false,
|
|
65
|
+
operation_types: [],
|
|
66
|
+
operations: [],
|
|
67
|
+
new_manifest: manifest,
|
|
68
|
+
new_manifest_fingerprint: manifest.fingerprint,
|
|
69
|
+
stable_topology: false,
|
|
70
|
+
gate_passed: manifest.parse.fetch(:ok)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def update_payload(update)
|
|
75
|
+
update.reject { |key, _value| %i[old_manifest new_manifest old_topology_signature new_topology_signature].include?(key) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def digest(source)
|
|
79
|
+
Digest::SHA256.hexdigest(source)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|