glib-web 6.8.1 → 6.9.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.
- checksums.yaml +4 -4
- data/app/jobs/glib/purge_unattached_job.rb +28 -0
- data/app/models/concerns/glib/snapshot_v2.rb +462 -0
- data/app/models/snapshot_blob_reference.rb +20 -0
- data/lib/generators/glib/snapshot_v2_generator.rb +54 -0
- data/lib/generators/templates/snapshot_v2/add_snapshot_v2_columns.rb +18 -0
- data/lib/generators/templates/snapshot_v2/create_snapshot_blob_references.rb +10 -0
- data/lib/glib/engine.rb +25 -0
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bed80d7507f4438018d07a12e8222c4fadc6b518e2e857db8d315845c65cace
|
|
4
|
+
data.tar.gz: 971c2518cd4ed3b39fcbf8f03b8076ca16c61ba6af2ba92a226efd99d3ac379f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe7febf0f24cc8e05140fc6a2905ed1998ad5427a3d0316daab114edf4652e0b5002e732da4043d6c3a9e62e2d22cf88eb79bfb7ae4544d66a9eec88280f3c8e
|
|
7
|
+
data.tar.gz: d0f2705eb05a5977eba46d8327d47999a01b71cd3740e5bff0e7db536229fa26b5de1601d2efdfeecc8a0b1abad7a994e35e2aa43e4d2531faa040c2261f05b8
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Glib
|
|
2
|
+
# Daily cleanup for ActiveStorage blobs that have no attachments. Excludes blobs referenced by
|
|
3
|
+
# any `SnapshotBlobReference` row, so old file versions that still appear in some snapshot's
|
|
4
|
+
# timeline (i.e. a previous version of an attached file) survive the purge.
|
|
5
|
+
#
|
|
6
|
+
# Opt-in: if the host app has not run the gem's `create_snapshot_blob_references` migration,
|
|
7
|
+
# `SnapshotBlobReference.table_exists?` is false and the exclusion subquery short-circuits —
|
|
8
|
+
# the job then behaves identically to Rails' built-in unattached-blob cleanup, just delayed
|
|
9
|
+
# by 8 hours to give upload flows time to attach.
|
|
10
|
+
#
|
|
11
|
+
# Schedule from the host app (e.g. via sidekiq-cron):
|
|
12
|
+
# Glib::PurgeUnattachedJob.perform_later
|
|
13
|
+
class PurgeUnattachedJob < ApplicationJob
|
|
14
|
+
queue_as :cleanup
|
|
15
|
+
|
|
16
|
+
def perform(*_args)
|
|
17
|
+
scope = ActiveStorage::Blob
|
|
18
|
+
.unattached
|
|
19
|
+
.where('active_storage_blobs.created_at < ?', 8.hours.ago)
|
|
20
|
+
|
|
21
|
+
if SnapshotBlobReference.table_exists?
|
|
22
|
+
scope = scope.where.not(id: SnapshotBlobReference.select(:blob_id))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
scope.find_each(&:purge_later)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
require 'active_snapshot'
|
|
2
|
+
require 'hashdiff'
|
|
3
|
+
|
|
4
|
+
# V2 snapshot concern adapted from mapping-web. Promotes action/version/diff/source from the
|
|
5
|
+
# monolithic `metadata` JSON to real columns, enabling indexed JSONB queries and a `source`
|
|
6
|
+
# attribution concept. Defaults to unlimited retention (max_snapshots nil).
|
|
7
|
+
#
|
|
8
|
+
# Patches applied on top of mapping-web's SnapshotV2:
|
|
9
|
+
# 1. ActiveStorage support in check_snapshot_changed (ported from V1 gem).
|
|
10
|
+
# 2. Nil has_one safe-nav in check_snapshot_changed.
|
|
11
|
+
# 3. has_one wrapping in diff_associations (V2 only tested with has_many in mapping-web).
|
|
12
|
+
# 4. ActiveStorage in diff_associations (convert Attached::One/Many to attachment records).
|
|
13
|
+
#
|
|
14
|
+
# To get all records of a model, you can use the following query:
|
|
15
|
+
# MyModel.joins(:snapshots).distinct
|
|
16
|
+
module Glib
|
|
17
|
+
module SnapshotV2
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
# The CRUD backbone covers most flows; the approval-gate admin actions are distinct
|
|
21
|
+
# audit events (request_changes / approve / undo_approve) that the timeline UI labels
|
|
22
|
+
# off the action string, so they earn their own slots here rather than being collapsed
|
|
23
|
+
# into :update (which would lose the "what happened" signal in the audit trail).
|
|
24
|
+
KNOWN_ACTIONS = [:create, :update, :destroy, :request_changes, :approve, :undo_approve].freeze
|
|
25
|
+
|
|
26
|
+
included do
|
|
27
|
+
include ::ActiveSnapshot
|
|
28
|
+
|
|
29
|
+
attr_accessor :snapshot_changed
|
|
30
|
+
attr_accessor :attributes_before_save
|
|
31
|
+
|
|
32
|
+
# Save-boundary state capture for the snapshot diff engine -- not a hidden side
|
|
33
|
+
# effect. Records the pre-save DB state into `attributes_before_save` and
|
|
34
|
+
# computes `snapshot_changed` so the diff (see `diff` / `same_as_before?`) has a
|
|
35
|
+
# "before" to compare against. `attributes_in_database` is only readable inside
|
|
36
|
+
# the save boundary, and this is a generic concern mixed into many models, so it
|
|
37
|
+
# can't be hoisted to an individual save site the way notification callbacks were.
|
|
38
|
+
# It only sets instance state (no writes, no notifications) -- the actual snapshot
|
|
39
|
+
# row is written by the explicit, call-site-visible `save_with_snapshot` /
|
|
40
|
+
# `glib_create_snapshot!` paths, which is where the acting user is known.
|
|
41
|
+
before_save do # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
|
|
42
|
+
# Only set snapshot_changed if not already set (handles case where before_save
|
|
43
|
+
# is called multiple times - e.g., when associated records are saved before parent)
|
|
44
|
+
if snapshot_changed.nil?
|
|
45
|
+
self.attributes_before_save = attributes_in_database
|
|
46
|
+
self.snapshot_changed = check_snapshot_changed
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_snapshot_changed
|
|
52
|
+
return true if changed?
|
|
53
|
+
|
|
54
|
+
associations_for_snapshot.each do |association_name|
|
|
55
|
+
association = self.class.reflect_on_association(association_name)
|
|
56
|
+
association_value = public_send(association_name)
|
|
57
|
+
|
|
58
|
+
# ActiveStorage associations (has_one_attached / has_many_attached) are not
|
|
59
|
+
# reflected as standard AR macros; handle them before the case block.
|
|
60
|
+
if active_storage_association?(association, association_value)
|
|
61
|
+
active_storage_records(association_value).each do |record|
|
|
62
|
+
return true if record.changed?
|
|
63
|
+
end
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
raise "Invalid association: #{association_name}" if association.nil?
|
|
68
|
+
|
|
69
|
+
case association.macro
|
|
70
|
+
when :has_many
|
|
71
|
+
records = association_value
|
|
72
|
+
records.each do |record|
|
|
73
|
+
return true if record.changed?
|
|
74
|
+
end
|
|
75
|
+
if new_record? && records.count > 0
|
|
76
|
+
return true
|
|
77
|
+
end
|
|
78
|
+
when :has_one, :belongs_to
|
|
79
|
+
return true if association_value&.changed?
|
|
80
|
+
else
|
|
81
|
+
raise "Unexpected association macro: #{association.macro}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# information need to be store:
|
|
89
|
+
# - action: create, update, destroy
|
|
90
|
+
# - track changes
|
|
91
|
+
def glib_create_snapshot!(action, source, user: nil)
|
|
92
|
+
action_sym = action.to_sym
|
|
93
|
+
raise "unknown action: #{action}" unless KNOWN_ACTIONS.include?(action_sym)
|
|
94
|
+
raise 'source must be present' if source.blank?
|
|
95
|
+
raise 'user must be present when source is "user"' if source == 'user' && user.nil?
|
|
96
|
+
|
|
97
|
+
# Lock is required to prevent race conditions when calculating version number.
|
|
98
|
+
if (result =
|
|
99
|
+
with_lock do
|
|
100
|
+
version = last_version + 1
|
|
101
|
+
calculated_diff = diff
|
|
102
|
+
|
|
103
|
+
snapshot_obj = {
|
|
104
|
+
identifier: snapshot_identifier(version),
|
|
105
|
+
user: user,
|
|
106
|
+
metadata: {} # Keep for potential future use, but main data in columns
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Don't create version if same as before
|
|
110
|
+
unless same_as_before?
|
|
111
|
+
# No nil guard: active_snapshot's `create_snapshot!` raises on failure and
|
|
112
|
+
# always returns the snapshot.
|
|
113
|
+
snapshot = create_snapshot!(**snapshot_obj)
|
|
114
|
+
# update_columns is the second half of the insert, not a domain update: the
|
|
115
|
+
# gem's create_snapshot! signature can't set these V2-promoted columns, and
|
|
116
|
+
# no validation/callback (gem or initializer) concerns them. Runs inside
|
|
117
|
+
# with_lock's transaction, so no half-written row ever commits.
|
|
118
|
+
snapshot.update_columns( # rubocop:disable DevDoc/Rails/AvoidBypassingValidation
|
|
119
|
+
action: action.to_s,
|
|
120
|
+
version: version,
|
|
121
|
+
diff: calculated_diff,
|
|
122
|
+
source: source
|
|
123
|
+
)
|
|
124
|
+
record_blob_references(snapshot, calculated_diff)
|
|
125
|
+
snapshot
|
|
126
|
+
end
|
|
127
|
+
end)
|
|
128
|
+
# Cleanup operations - run outside lock and transaction
|
|
129
|
+
remove_old_snapshot
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def glib_revert_snapshot!(version)
|
|
136
|
+
snapshot = snapshots.find_by!(identifier: snapshot_identifier(version))
|
|
137
|
+
snapshot.restore!
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def snapshot_identifier(version)
|
|
141
|
+
"#{self.class.to_s.underscore}_#{id}_version_#{version}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def interval_from_prev_version
|
|
145
|
+
return nil if snapshot_prev.nil?
|
|
146
|
+
|
|
147
|
+
updated_at - snapshot_prev.created_at
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def diff(snapshot = snapshot_prev)
|
|
151
|
+
default_ignored_keys = ['updated_at', 'created_at']
|
|
152
|
+
ignore_keys = build_ignore_keys(default_ignored_keys)
|
|
153
|
+
|
|
154
|
+
item, associations = fetch_snapshot_items(snapshot)
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
'item' => ::Hashdiff.diff(
|
|
158
|
+
normalize_attributes_for_diff(item.attributes.except(*ignore_keys)),
|
|
159
|
+
normalize_attributes_for_diff(attributes.except(*ignore_keys))
|
|
160
|
+
),
|
|
161
|
+
'associations' => diff_associations(associations, default_ignored_keys)
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def snapshot_prev
|
|
166
|
+
snapshots.order(version: :desc).first
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def same_as_before?
|
|
170
|
+
return !snapshot_changed if snapshot_prev.blank?
|
|
171
|
+
|
|
172
|
+
computed_diff = diff
|
|
173
|
+
item_unchanged = computed_diff['item'].blank?
|
|
174
|
+
assoc_diff = computed_diff['associations']
|
|
175
|
+
associations_unchanged = assoc_diff.nil? || assoc_diff.values.all?(&:blank?)
|
|
176
|
+
|
|
177
|
+
item_unchanged && associations_unchanged
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def last_version
|
|
181
|
+
max_version = snapshots.maximum(:version)
|
|
182
|
+
return 0 if max_version.blank?
|
|
183
|
+
|
|
184
|
+
max_version
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def remove_old_snapshot
|
|
188
|
+
return if max_snapshots.nil?
|
|
189
|
+
|
|
190
|
+
newest_ids = snapshots.order(version: :desc, id: :desc).limit(max_snapshots).ids
|
|
191
|
+
return unless newest_ids.size >= max_snapshots
|
|
192
|
+
|
|
193
|
+
snapshots.where.not(id: newest_ids).destroy_all
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def watched_keys_for_snapshot
|
|
197
|
+
raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{self.class}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def associations_for_snapshot
|
|
201
|
+
children_to_snapshot.keys.filter { |key| respond_to?(key) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def max_snapshots
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Return number of days to retain snapshot content, or nil for unlimited.
|
|
209
|
+
def content_retention_days
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
# Truncate Time/TimeWithZone values to the JSON encoding precision so that
|
|
215
|
+
# reified (JSON-round-tripped) attributes compare equal to live DB values
|
|
216
|
+
# that carry microsecond precision. Without this, Hashdiff reports a
|
|
217
|
+
# spurious change for every datetime field on every re-snapshot.
|
|
218
|
+
#
|
|
219
|
+
# The divisor is derived from ActiveSupport::JSON::Encoding.time_precision so a consumer who
|
|
220
|
+
# raises it above the Rails default of 3 (ms) is not over-truncated — mirrors V1's
|
|
221
|
+
# Glib::Snapshot#normalize_attributes / snapshot_time_divisor exactly. no-op when the
|
|
222
|
+
# precision is already 6 (microseconds).
|
|
223
|
+
def normalize_attributes_for_diff(attrs)
|
|
224
|
+
divisor = snapshot_time_divisor
|
|
225
|
+
attrs.transform_values do |v|
|
|
226
|
+
if (v.is_a?(Time) || v.is_a?(ActiveSupport::TimeWithZone)) && divisor > 1
|
|
227
|
+
v.change(usec: v.usec - v.usec % divisor)
|
|
228
|
+
else
|
|
229
|
+
v
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def snapshot_time_divisor
|
|
235
|
+
10 ** (6 - ActiveSupport::JSON::Encoding.time_precision)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_ignore_keys(default_ignored_keys)
|
|
239
|
+
ignore_keys = default_ignored_keys
|
|
240
|
+
|
|
241
|
+
unless watched_keys_for_snapshot.nil?
|
|
242
|
+
ignored_keys_for_snapshot = attributes.except(*watched_keys_for_snapshot.map(&:to_s)).keys
|
|
243
|
+
ignore_keys = (default_ignored_keys + ignored_keys_for_snapshot.map(&:to_s)).uniq
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
ignore_keys
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def fetch_snapshot_items(snapshot)
|
|
250
|
+
if snapshot.present?
|
|
251
|
+
snapshot.fetch_reified_items
|
|
252
|
+
else
|
|
253
|
+
[OpenStruct.new(attributes: attributes_before_save || {}), {}]
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def diff_associations(associations, default_ignored_keys)
|
|
258
|
+
associations_for_snapshot.reduce({}) do |prev, association_name|
|
|
259
|
+
before_records = associations[association_name] || []
|
|
260
|
+
|
|
261
|
+
records = send(association_name)
|
|
262
|
+
|
|
263
|
+
# ActiveStorage: convert Attached::One/Many to their underlying attachment records.
|
|
264
|
+
association = self.class.reflect_on_association(association_name)
|
|
265
|
+
if active_storage_association?(association, records)
|
|
266
|
+
records = active_storage_records(records)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# has_one wrapping: V2 tested only with has_many in mapping-web; a has_one returns
|
|
270
|
+
# a single record (or nil), not a collection. Wrap it so the diff logic below works.
|
|
271
|
+
unless records.respond_to?(:each)
|
|
272
|
+
records = [records].compact
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# id is the universal snapshot identity here: the diff below matches records
|
|
276
|
+
# by id (index_by(&:id)), so this order only pins stable diff-path indices.
|
|
277
|
+
# created_at isn't guaranteed across every association this generic concern
|
|
278
|
+
# snapshots (ActiveStorage, through-tables) and can be backdated, so id — not
|
|
279
|
+
# created_at — is the right deterministic key. Generic engine, hence the disable.
|
|
280
|
+
now_records = records.respond_to?(:order) ? records.order(id: :asc) : records.to_a.sort_by { |r| r.id.to_i } # rubocop:disable DevDoc/Rails/AvoidOrderingById
|
|
281
|
+
|
|
282
|
+
association_diff = diff_single_association(
|
|
283
|
+
association_name,
|
|
284
|
+
before_records,
|
|
285
|
+
now_records,
|
|
286
|
+
default_ignored_keys
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
prev.merge(association_name.to_s => association_diff)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def diff_single_association(_association_name, before_records, now_records, default_ignored_keys)
|
|
294
|
+
association_ignored_keys = build_association_ignore_keys(now_records.first, default_ignored_keys)
|
|
295
|
+
|
|
296
|
+
if before_records.blank?
|
|
297
|
+
return ::Hashdiff.diff([], now_records.map { |record| normalize_attributes_for_diff(record.attributes.except(*association_ignored_keys)) })
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Create hash lookups to avoid N+1 queries
|
|
301
|
+
now_records_by_id = now_records.index_by(&:id)
|
|
302
|
+
before_records_by_id = before_records.index_by(&:id)
|
|
303
|
+
|
|
304
|
+
# Detect deleted and updated records
|
|
305
|
+
deleted_or_updated = before_records.map.with_index do |record_before, index|
|
|
306
|
+
record_now = now_records_by_id[record_before.id]
|
|
307
|
+
if record_now.blank?
|
|
308
|
+
['-', "[#{index}]", normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))]
|
|
309
|
+
else
|
|
310
|
+
before_attrs = normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))
|
|
311
|
+
now_attrs = normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))
|
|
312
|
+
next if Hashdiff.diff(before_attrs, now_attrs).blank?
|
|
313
|
+
|
|
314
|
+
['~', "[#{index}]", before_attrs, now_attrs]
|
|
315
|
+
end
|
|
316
|
+
end.compact_blank
|
|
317
|
+
|
|
318
|
+
# Detect added records
|
|
319
|
+
added = now_records.map.with_index do |record_now, index|
|
|
320
|
+
record_before = before_records_by_id[record_now.id]
|
|
321
|
+
if record_before.blank?
|
|
322
|
+
['+', "[#{index}]", normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))]
|
|
323
|
+
end
|
|
324
|
+
end.compact_blank
|
|
325
|
+
|
|
326
|
+
deleted_or_updated + added
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def build_association_ignore_keys(first_record, default_ignored_keys)
|
|
330
|
+
if first_record.present? && !active_storage_record?(first_record) && !first_record.respond_to?(:watched_keys_for_snapshot)
|
|
331
|
+
raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{first_record.class}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
if active_storage_record?(first_record)
|
|
335
|
+
default_ignored_keys
|
|
336
|
+
elsif first_record.try(:watched_keys_for_snapshot).nil?
|
|
337
|
+
default_ignored_keys
|
|
338
|
+
else
|
|
339
|
+
assoc_ignore_keys = first_record.attributes.except(*first_record.watched_keys_for_snapshot.map(&:to_s)).keys.map(&:to_s)
|
|
340
|
+
(default_ignored_keys + assoc_ignore_keys).uniq
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# --- Blob reference tracking ---
|
|
345
|
+
#
|
|
346
|
+
# When a snapshot diff includes attachment changes, extract the blob_ids and persist them as
|
|
347
|
+
# SnapshotBlobReference records. This prevents Glib::PurgeUnattachedJob from deleting old file
|
|
348
|
+
# versions that are still visible in the snapshot timeline.
|
|
349
|
+
#
|
|
350
|
+
# Opt-in: the host app must run the gem's `create_snapshot_blob_references` migration. The
|
|
351
|
+
# `table_exists?` guard makes this a silent no-op for consumers that skip the migration, so
|
|
352
|
+
# `include Glib::SnapshotV2` never crashes a model whose owner only wants the diff/timeline
|
|
353
|
+
# half.
|
|
354
|
+
#
|
|
355
|
+
# Only file-bearing associations are processed — see `file_bearing_association?`. This avoids
|
|
356
|
+
# the over-eager extraction the original code shipped with (it grabbed `id` from *every*
|
|
357
|
+
# association's payload, rescued only by a downstream existence filter that masks the mistake
|
|
358
|
+
# until a non-file record's PK happens to collide with a real blob PK, at which point a
|
|
359
|
+
# spurious reference silently "protects" an unrelated blob from purge).
|
|
360
|
+
def record_blob_references(snapshot, calculated_diff)
|
|
361
|
+
return unless SnapshotBlobReference.table_exists?
|
|
362
|
+
|
|
363
|
+
associations = calculated_diff['associations']
|
|
364
|
+
return if associations.blank?
|
|
365
|
+
|
|
366
|
+
blob_ids = Set.new
|
|
367
|
+
associations.each do |assoc_name, changes|
|
|
368
|
+
next if changes.blank?
|
|
369
|
+
next unless file_bearing_association?(assoc_name.to_s)
|
|
370
|
+
|
|
371
|
+
changes.each do |tuple|
|
|
372
|
+
# tuple format: [op, index, payload] or [op, index, from, to]
|
|
373
|
+
extract_blob_ids_from_payload(tuple[2], blob_ids)
|
|
374
|
+
to_payload = tuple[3]
|
|
375
|
+
extract_blob_ids_from_payload(to_payload, blob_ids) if to_payload
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
return if blob_ids.empty?
|
|
380
|
+
|
|
381
|
+
# Belt-and-suspenders: file_bearing_association? is the primary defense (non-file
|
|
382
|
+
# associations never reach here), but a blob could still be purged between diff-compute
|
|
383
|
+
# and reference-write inside the with_lock block. Filter to existing blobs to avoid an
|
|
384
|
+
# FK violation on snapshot_blob_references.blob_id. :: prefix — see
|
|
385
|
+
# file_bearing_association? for the Glib::ActiveStorage shadowing rationale.
|
|
386
|
+
existing_blob_ids = ::ActiveStorage::Blob.where(id: blob_ids.to_a).pluck(:id)
|
|
387
|
+
existing_blob_ids.each do |blob_id|
|
|
388
|
+
SnapshotBlobReference.create!(snapshot: snapshot, blob_id: blob_id)
|
|
389
|
+
rescue ActiveRecord::RecordNotUnique
|
|
390
|
+
# Already referenced (e.g. same blob_id in both from and to of an update) — safe to skip.
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# An association is "file-bearing" if its diff payloads carry ActiveStorage blob/attachment
|
|
395
|
+
# row data. Two shapes:
|
|
396
|
+
# 1. Direct ActiveStorage macros: has_one_attached / has_many_attached
|
|
397
|
+
# (e.g. OrganizationDocument#file, V2Post#photos). NOTE: these create NO AR reflection
|
|
398
|
+
# under their own name — they're detected by their value type (Attached::One/Many),
|
|
399
|
+
# the same value-based detection check_snapshot_changed and diff_associations use
|
|
400
|
+
# (active_storage_association?).
|
|
401
|
+
# 2. Through associations whose terminal klass is ActiveStorage::Blob or ::Attachment
|
|
402
|
+
# (e.g. Submission's snapshot_document_blobs: has_many ..., through: ..., source: :blob).
|
|
403
|
+
# These have real reflections with a blob/attachment klass.
|
|
404
|
+
# Non-file associations (has_many :comments, has_one :profile, etc.) are skipped so their
|
|
405
|
+
# record PKs never enter the blob-id extraction path.
|
|
406
|
+
#
|
|
407
|
+
# The :: prefix on ActiveStorage constants is REQUIRED: inside `module Glib`, a bare
|
|
408
|
+
# `ActiveStorage` resolves lexically to Glib::ActiveStorage (the gem's dynamic-text mirror
|
|
409
|
+
# subclass of ::ActiveStorage::Blob), and class equality against it would always be false.
|
|
410
|
+
def file_bearing_association?(assoc_name)
|
|
411
|
+
reflection = self.class.reflect_on_association(assoc_name)
|
|
412
|
+
association_value = public_send(assoc_name)
|
|
413
|
+
|
|
414
|
+
return true if active_storage_association?(reflection, association_value)
|
|
415
|
+
|
|
416
|
+
klass = reflection&.klass
|
|
417
|
+
klass && [::ActiveStorage::Blob, ::ActiveStorage::Attachment].include?(klass)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def extract_blob_ids_from_payload(payload, blob_ids)
|
|
421
|
+
return unless payload.is_a?(Hash)
|
|
422
|
+
|
|
423
|
+
# Old snapshots diff ActiveStorage::Attachment rows (blob_id key);
|
|
424
|
+
# new snapshots diff ActiveStorage::Blob rows directly (id key = Blob PK).
|
|
425
|
+
blob_id = payload['blob_id'] || payload[:blob_id] || payload['id'] || payload[:id]
|
|
426
|
+
blob_ids.add(blob_id) if blob_id.present?
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# --- ActiveStorage helpers (ported from V1 gem) ---
|
|
430
|
+
|
|
431
|
+
def active_storage_association?(association, association_value)
|
|
432
|
+
macro = association&.macro
|
|
433
|
+
return true if [:has_many_attached, :has_one_attached].include?(macro)
|
|
434
|
+
return false if association_value.nil?
|
|
435
|
+
|
|
436
|
+
# :: prefix — see file_bearing_association? for the Glib::ActiveStorage shadowing
|
|
437
|
+
# rationale. Without it, `defined?(Glib::ActiveStorage::Attached)` is nil and the
|
|
438
|
+
# is_a? checks below silently never run (the respond_to? fallback masked this).
|
|
439
|
+
if defined?(::ActiveStorage::Attached)
|
|
440
|
+
return true if association_value.is_a?(::ActiveStorage::Attached::Many) || association_value.is_a?(::ActiveStorage::Attached::One)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
association_value.respond_to?(:attachments) || association_value.respond_to?(:attachment)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def active_storage_records(value)
|
|
447
|
+
if value.respond_to?(:attachments)
|
|
448
|
+
value.attachments
|
|
449
|
+
elsif value.respond_to?(:attachment)
|
|
450
|
+
Array(value.attachment).compact
|
|
451
|
+
else
|
|
452
|
+
[]
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def active_storage_record?(record)
|
|
457
|
+
return false if record.nil?
|
|
458
|
+
|
|
459
|
+
record.class.name.start_with?('ActiveStorage::')
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Top-level (NOT namespaced under Glib::) by design — see issue #960's hard constraint:
|
|
2
|
+
#
|
|
3
|
+
# The gem's SnapshotBlobReference must stay a top-level model on table
|
|
4
|
+
# `snapshot_blob_references` — not namespaced. Drop-in compatibility holds only
|
|
5
|
+
# because `isolate_namespace` is off in lib/glib/engine.rb. Namespacing it (e.g.
|
|
6
|
+
# Glib::SnapshotBlobReference → table `glib_snapshot_blob_references`) or enabling
|
|
7
|
+
# isolate_namespace orphans every existing consumer's table + rows and forces a
|
|
8
|
+
# rename/data migration.
|
|
9
|
+
#
|
|
10
|
+
# This file lives at `app/models/snapshot_blob_reference.rb` (not under `app/models/glib/`)
|
|
11
|
+
# so the Rails autoloader resolves the constant as top-level `SnapshotBlobReference`, which
|
|
12
|
+
# in turn maps to the un-prefixed table name `snapshot_blob_references`. The host app's
|
|
13
|
+
# existing `SnapshotBlobReference` model can be deleted verbatim when it bumps the gem.
|
|
14
|
+
#
|
|
15
|
+
# Inherits from the host's `ApplicationRecord` (the standard since Rails 5.0) so the model
|
|
16
|
+
# shares the primary DB connection — same pattern as every other top-level host model.
|
|
17
|
+
class SnapshotBlobReference < ApplicationRecord
|
|
18
|
+
belongs_to :snapshot, class_name: 'ActiveSnapshot::Snapshot'
|
|
19
|
+
belongs_to :blob, class_name: 'ActiveStorage::Blob'
|
|
20
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Glib
|
|
2
|
+
module Generators
|
|
3
|
+
# Installs the Glib::SnapshotV2 database prerequisites. Two opt-in pieces, each produced
|
|
4
|
+
# only when the corresponding flag is passed:
|
|
5
|
+
#
|
|
6
|
+
# `rails g glib:snapshot_v2` # adds V2 columns only (required for V2)
|
|
7
|
+
# `rails g glib:snapshot_v2 --with-blob-references` # also creates the blob-reference table
|
|
8
|
+
#
|
|
9
|
+
# Deliberately separate from `glib:install`: that generator overwrites `config/database.yml`
|
|
10
|
+
# and writes dynamic-text migrations to the non-standard `db/dynamic_text_migrate/` path,
|
|
11
|
+
# neither of which a snapshot adopter wants re-run on their app. Snapshot tables live in the
|
|
12
|
+
# primary DB and use the standard `db/migrate/` path.
|
|
13
|
+
#
|
|
14
|
+
# The V2 columns migration adds `action` / `version` / `diff` (jsonb) / `source` plus the
|
|
15
|
+
# two composite indexes Glib::SnapshotV2's queries rely on. It does NOT backfill — adopters
|
|
16
|
+
# who need to migrate legacy V1 snapshots (stored under metadata) own that one-way data
|
|
17
|
+
# conversion themselves. See issue #960.
|
|
18
|
+
class SnapshotV2Generator < Rails::Generators::Base
|
|
19
|
+
include Rails::Generators::Migration
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path('../templates/snapshot_v2', __dir__)
|
|
22
|
+
|
|
23
|
+
class_option :with_blob_references, type: :boolean, default: false,
|
|
24
|
+
desc: 'Also create the snapshot_blob_references table (required to version attached files)'
|
|
25
|
+
|
|
26
|
+
def copy_v2_columns_migration
|
|
27
|
+
migration_template 'add_snapshot_v2_columns.rb',
|
|
28
|
+
'db/migrate/add_snapshot_v2_columns.rb'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def copy_blob_references_migration
|
|
32
|
+
return unless options[:with_blob_references]
|
|
33
|
+
|
|
34
|
+
migration_template 'create_snapshot_blob_references.rb',
|
|
35
|
+
'db/migrate/create_snapshot_blob_references.rb'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
# Rails::Generators::Migration requires this class method. Returns the UTC timestamp for
|
|
40
|
+
# the next migration filename. The mutex + @last_number pair guarantees the second template
|
|
41
|
+
# (create_snapshot_blob_references) lands strictly AFTER the first (add_snapshot_v2_columns)
|
|
42
|
+
# within a single invocation — otherwise both share the same timestamp and load order is
|
|
43
|
+
# non-deterministic.
|
|
44
|
+
@@migration_mutex = Mutex.new
|
|
45
|
+
|
|
46
|
+
def self.next_migration_number(_dirname)
|
|
47
|
+
@@migration_mutex.synchronize do
|
|
48
|
+
@last_snapshot_v2_number ||= (Time.now.utc - 1.second).strftime('%Y%m%d%H%M%S')
|
|
49
|
+
@last_snapshot_v2_number = (@last_snapshot_v2_number.to_i + 1).to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class AddSnapshotV2Columns < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# PostgreSQL has jsonb (binary, indexable); SQLite (used by glib-web's dummy-app) and MySQL
|
|
4
|
+
# only have json. Pick the richest type the adapter supports so consumers on PG get indexable
|
|
5
|
+
# diffs while greenfield adopters on SQLite/MySQL still work without edits.
|
|
6
|
+
diff_type = connection.adapter_name.match?(/PostgreSQL/i) ? :jsonb : :json
|
|
7
|
+
|
|
8
|
+
change_table :snapshots, bulk: true do |t|
|
|
9
|
+
t.string :action
|
|
10
|
+
t.integer :version
|
|
11
|
+
t.column :diff, diff_type
|
|
12
|
+
t.string :source
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :snapshots, [:action, :item_type, :item_id]
|
|
16
|
+
add_index :snapshots, [:item_type, :item_id, :version]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class CreateSnapshotBlobReferences < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :snapshot_blob_references do |t|
|
|
4
|
+
t.belongs_to :snapshot, null: false, foreign_key: { to_table: :snapshots }
|
|
5
|
+
t.belongs_to :blob, null: false, foreign_key: { to_table: :active_storage_blobs }
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
add_index :snapshot_blob_references, [:snapshot_id, :blob_id], unique: true
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/glib/engine.rb
CHANGED
|
@@ -22,6 +22,25 @@ module Glib
|
|
|
22
22
|
scope :created_desc, -> { order(created_at: :desc, id: :desc) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Cascade-destroy blob references when a snapshot is destroyed (e.g. by max_snapshots
|
|
26
|
+
# retention in Glib::SnapshotV2#remove_old_snapshot). Without this, the FK on
|
|
27
|
+
# snapshot_blob_references would raise PG::ForeignKeyViolation.
|
|
28
|
+
#
|
|
29
|
+
# Opt-in: only consumers who run the gem's `create_snapshot_blob_references` migration
|
|
30
|
+
# have the table. The connection+table_exists? guard (wrapped in a rescue for boot
|
|
31
|
+
# sequences with no DB yet — `rails db:create`, parallel test setup) makes this a no-op
|
|
32
|
+
# for everyone else, so a bare `include Glib::SnapshotV2` never crashes a boot.
|
|
33
|
+
unless respond_to?(:snapshot_blob_references)
|
|
34
|
+
begin
|
|
35
|
+
if connection.table_exists?(:snapshot_blob_references)
|
|
36
|
+
has_many :snapshot_blob_references, dependent: :destroy
|
|
37
|
+
end
|
|
38
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
|
|
39
|
+
# Boot before the DB exists (e.g. `rails db:create`). The association gets re-registered
|
|
40
|
+
# on the next successful boot that finds the table.
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
25
44
|
# Parses the stored diff into a flat hash of Change structs:
|
|
26
45
|
# { 'item' => [Change, ...], 'association_name' => [Change, ...], ... }
|
|
27
46
|
# Returns an empty structure if no diff exists (e.g. the first snapshot).
|
|
@@ -93,7 +112,13 @@ module Glib
|
|
|
93
112
|
|
|
94
113
|
private
|
|
95
114
|
# Item-level change: from/to are scalar column values. Cast each to the column's real type.
|
|
115
|
+
#
|
|
116
|
+
# `index` is normalized to a Symbol so consumers compare against symbol literals
|
|
117
|
+
# (matching AR attribute keys, e.g. `change.index == :title`). Association-level
|
|
118
|
+
# changes are NOT symbolized — their index is a collection position from a path like
|
|
119
|
+
# "[0]", a positional digit, not a name.
|
|
96
120
|
def cast_item_change(change, root)
|
|
121
|
+
change.index = change.index.to_sym
|
|
97
122
|
change.from = cast_column_value(root, change.index, change.from)
|
|
98
123
|
change.to = cast_column_value(root, change.index, change.to)
|
|
99
124
|
change
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: glib-web
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 6.
|
|
4
|
+
version: 6.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ''
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activestorage
|
|
@@ -24,6 +24,20 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '8.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activejob
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '8.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '8.0'
|
|
27
41
|
- !ruby/object:Gem::Dependency
|
|
28
42
|
name: pundit
|
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -251,9 +265,11 @@ files:
|
|
|
251
265
|
- app/helpers/glib/json_ui/view_builder/multimedia.rb
|
|
252
266
|
- app/helpers/glib/json_ui/view_builder/panels.rb
|
|
253
267
|
- app/helpers/glib/urls_helper.rb
|
|
268
|
+
- app/jobs/glib/purge_unattached_job.rb
|
|
254
269
|
- app/models/concerns/glib/enum_humanization.rb
|
|
255
270
|
- app/models/concerns/glib/enum_symbolization.rb
|
|
256
271
|
- app/models/concerns/glib/nilify_blanks.rb
|
|
272
|
+
- app/models/concerns/glib/snapshot_v2.rb
|
|
257
273
|
- app/models/concerns/glib/soft_deletable.rb
|
|
258
274
|
- app/models/glib/active_storage/attachment.rb
|
|
259
275
|
- app/models/glib/active_storage/blob.rb
|
|
@@ -263,6 +279,7 @@ files:
|
|
|
263
279
|
- app/models/glib/dynamic_text_record.rb
|
|
264
280
|
- app/models/glib/state_list.rb
|
|
265
281
|
- app/models/glib/text.rb
|
|
282
|
+
- app/models/snapshot_blob_reference.rb
|
|
266
283
|
- app/policies/glib/application_policy.rb
|
|
267
284
|
- app/validators/email_typo_validator.rb
|
|
268
285
|
- app/validators/email_validator.rb
|
|
@@ -534,12 +551,15 @@ files:
|
|
|
534
551
|
- config/routes.rb
|
|
535
552
|
- lib/active_storage/service/glib_s3_service.rb
|
|
536
553
|
- lib/generators/glib/install_generator.rb
|
|
554
|
+
- lib/generators/glib/snapshot_v2_generator.rb
|
|
537
555
|
- lib/generators/templates/20191017062519_create_texts.rb
|
|
538
556
|
- lib/generators/templates/20191024063257_add_scope_to_texts.rb
|
|
539
557
|
- lib/generators/templates/20191112095018_add_lang_to_texts.rb
|
|
540
558
|
- lib/generators/templates/20191126071051_create_active_storage_tables.active_storage.rb
|
|
541
559
|
- lib/generators/templates/database.yml
|
|
542
560
|
- lib/generators/templates/dynamic_text.rb
|
|
561
|
+
- lib/generators/templates/snapshot_v2/add_snapshot_v2_columns.rb
|
|
562
|
+
- lib/generators/templates/snapshot_v2/create_snapshot_blob_references.rb
|
|
543
563
|
- lib/glib-web.rb
|
|
544
564
|
- lib/glib/all_helpers.rb
|
|
545
565
|
- lib/glib/crypt/utils.rb
|