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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a632657e59d940a2691d9793e2f9859c423b9ea344fa5b1f67f47d66c4a28c8
4
- data.tar.gz: 44f187b5d84c31e7727b838dfddfe0ba654171d0375973042313c4f53b310f85
3
+ metadata.gz: 3bed80d7507f4438018d07a12e8222c4fadc6b518e2e857db8d315845c65cace
4
+ data.tar.gz: 971c2518cd4ed3b39fcbf8f03b8076ca16c61ba6af2ba92a226efd99d3ac379f
5
5
  SHA512:
6
- metadata.gz: aea444e0d5996db47728356004ad47fea27fd945e446edafa01de8332f4d92ed618c4b9cf29cd01212854d88b13d71ee0aa21958304e39a28d7bb2ef49616a2a
7
- data.tar.gz: 2e89f1c25abc1760d65beb70eb89d1b1a5562d4b286922e6c5b9fd97817b008f16d2e5f8c6658cefad32d1b0c9e430733102ee87024f0199dace2f5561cc6a5e
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.8.1
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-22 00:00:00.000000000 Z
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