glib-web 7.0.0.beta1 → 7.0.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/glib/auth/policy.rb +5 -1
  3. data/app/controllers/concerns/glib/auth/token_authenticatable.rb +37 -0
  4. data/app/controllers/concerns/glib/json/libs.rb +33 -0
  5. data/app/controllers/glib/api_controller.rb +52 -0
  6. data/app/helpers/glib/json_ui/view_builder/fields.rb +133 -4
  7. data/app/helpers/glib/json_ui/view_builder/panels.rb +8 -144
  8. data/app/jobs/glib/purge_unattached_job.rb +32 -0
  9. data/app/models/concerns/glib/snapshot_v2.rb +514 -0
  10. data/app/models/glib/model.rb +40 -0
  11. data/app/models/glib/plain_model.rb +23 -0
  12. data/app/models/snapshot_blob_reference.rb +20 -0
  13. data/app/views/json_ui/garage/tables/bulk_edit.json.jbuilder +6 -6
  14. data/app/views/json_ui/garage/test_page/_header.json.jbuilder +1 -1
  15. data/app/views/json_ui/garage/test_page/dialog.json.jbuilder +21 -0
  16. data/app/views/json_ui/garage/test_page/fields_change_trigger.json.jbuilder +128 -0
  17. data/app/views/json_ui/garage/test_page/fields_sign.json.jbuilder +49 -1
  18. data/app/views/json_ui/garage/test_page/panels_bulkEdit3.json.jbuilder +2 -2
  19. data/lib/generators/glib/snapshot_v2_generator.rb +54 -0
  20. data/lib/generators/templates/snapshot_v2/add_snapshot_v2_columns.rb +18 -0
  21. data/lib/generators/templates/snapshot_v2/create_snapshot_blob_references.rb +10 -0
  22. data/lib/glib/engine.rb +32 -0
  23. data/lib/glib/json_crawler/action_crawlers/forms_submit.rb +1 -1
  24. data/lib/glib/purge_unattached/config.rb +38 -0
  25. data/lib/glib/test/full_suite_rubocop.rb +87 -0
  26. data/lib/glib/test/rubocop_guards.rb +42 -0
  27. data/lib/glib/test_helpers.rb +9 -7
  28. data/lib/glib-web.rb +4 -2
  29. metadata +29 -3
  30. data/app/views/json_ui/garage/test_page/panels_bulkEdit2.json.jbuilder +0 -244
  31. data/lib/glib/test/changed_files_rubocop.rb +0 -55
@@ -0,0 +1,514 @@
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
+ # 5. same_as_before? derives from the computed diff instead of the instance-local
14
+ # `snapshot_changed` flag (mapping-web's flag-gating silently dropped a target's
15
+ # snapshot when taken after the owner's -- see "Snapshot ordering" below, issue #481),
16
+ # with the flag re-entering as a NEGATIVE signal for first snapshots only (see
17
+ # `same_as_before?` for why an empty association baseline cannot decide those).
18
+ #
19
+ # ## Snapshot ordering: owner vs association targets
20
+ #
21
+ # Owner and association-target snapshots may be taken in ANY order within a request.
22
+ # `same_as_before?` decides from persisted state, so a target snapshotted after its owner
23
+ # (through the replaced association target) still records its version.
24
+ #
25
+ # The one residual ordering caveat: `diff` compares against the instance's IN-MEMORY
26
+ # attributes, so unsaved changes live only on the instance that holds them. If a target is
27
+ # modified in memory without saving, snapshot it via a local captured BEFORE the owner's
28
+ # `glib_create_snapshot!` (whose `with_lock` reload replaces the association targets) --
29
+ # reading the target back through the association snapshots the persisted state instead.
30
+ #
31
+ # `snapshot_changed` / `attributes_before_save` remain writable instance accessors for
32
+ # call-site use; `snapshot_changed` is consulted by the skip decision only as a NEGATIVE
33
+ # signal on first snapshots (see `same_as_before?`).
34
+ #
35
+ # To get all records of a model, you can use the following query:
36
+ # MyModel.joins(:snapshots).distinct
37
+ module Glib
38
+ module SnapshotV2
39
+ extend ActiveSupport::Concern
40
+
41
+ # The CRUD backbone covers most flows; the approval-gate admin actions are distinct
42
+ # audit events (request_changes / approve / undo_approve) that the timeline UI labels
43
+ # off the action string, so they earn their own slots here rather than being collapsed
44
+ # into :update (which would lose the "what happened" signal in the audit trail).
45
+ KNOWN_ACTIONS = [:create, :update, :destroy, :request_changes, :approve, :undo_approve].freeze
46
+
47
+ included do
48
+ include ::ActiveSnapshot
49
+
50
+ attr_accessor :snapshot_changed
51
+ attr_accessor :attributes_before_save
52
+
53
+ # Save-boundary state capture for the snapshot diff engine -- not a hidden side
54
+ # effect. Records the pre-save DB state into `attributes_before_save` and
55
+ # computes `snapshot_changed` so the diff (see `diff` / `same_as_before?`) has a
56
+ # "before" to compare against. `attributes_in_database` is only readable inside
57
+ # the save boundary, and this is a generic concern mixed into many models, so it
58
+ # can't be hoisted to an individual save site the way notification callbacks were.
59
+ # It only sets instance state (no writes, no notifications) -- the actual snapshot
60
+ # row is written by the explicit, call-site-visible `save_with_snapshot` /
61
+ # `glib_create_snapshot!` paths, which is where the acting user is known.
62
+ before_save do # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
63
+ # Only set snapshot_changed if not already set (handles case where before_save
64
+ # is called multiple times - e.g., when associated records are saved before parent)
65
+ if snapshot_changed.nil?
66
+ # For a save with no pending change `attributes_in_database` is empty -- an
67
+ # unchanged persisted record has no database-side delta -- which would make a
68
+ # first snapshot diff every attribute as an addition. Capture the full
69
+ # persisted state instead, so an unchanged save yields an empty item diff and
70
+ # the nil-guard keeps that baseline stable for later changes on the same
71
+ # instance. New records keep `attributes_in_database` (their all-nil
72
+ # originals): that non-empty baseline is exactly why a create's version 1
73
+ # records the full initial state. `unless` keeps the accessor nil, never
74
+ # `false`, for call-site nil-safety.
75
+ self.attributes_before_save = attributes_in_database.presence || (attributes unless new_record?)
76
+ self.snapshot_changed = check_snapshot_changed
77
+ end
78
+ end
79
+ end
80
+
81
+ def check_snapshot_changed
82
+ return true if changed?
83
+
84
+ associations_for_snapshot.each do |association_name|
85
+ association = self.class.reflect_on_association(association_name)
86
+ association_value = public_send(association_name)
87
+
88
+ # ActiveStorage associations (has_one_attached / has_many_attached) are not
89
+ # reflected as standard AR macros; handle them before the case block.
90
+ if active_storage_association?(association, association_value)
91
+ active_storage_records(association_value).each do |record|
92
+ return true if record.changed?
93
+ end
94
+ next
95
+ end
96
+
97
+ raise "Invalid association: #{association_name}" if association.nil?
98
+
99
+ case association.macro
100
+ when :has_many
101
+ records = association_value
102
+ records.each do |record|
103
+ return true if record.changed?
104
+ end
105
+ if new_record? && records.count > 0
106
+ return true
107
+ end
108
+ when :has_one, :belongs_to
109
+ return true if association_value&.changed?
110
+ else
111
+ raise "Unexpected association macro: #{association.macro}"
112
+ end
113
+ end
114
+
115
+ false
116
+ end
117
+
118
+ # information need to be store:
119
+ # - action: create, update, destroy
120
+ # - track changes
121
+ def glib_create_snapshot!(action, source, user: nil)
122
+ action_sym = action.to_sym
123
+ raise "unknown action: #{action}" unless KNOWN_ACTIONS.include?(action_sym)
124
+ raise 'source must be present' if source.blank?
125
+ raise 'user must be present when source is "user"' if source == 'user' && user.nil?
126
+
127
+ # Lock is required to prevent race conditions when calculating version number.
128
+ if (result =
129
+ with_lock do
130
+ version = last_version + 1
131
+ calculated_diff = diff
132
+
133
+ snapshot_obj = {
134
+ identifier: snapshot_identifier(version),
135
+ user: user,
136
+ metadata: {} # Keep for potential future use, but main data in columns
137
+ }
138
+
139
+ # Don't create version if same as before. Reuses `calculated_diff` instead of
140
+ # recomputing it inside `same_as_before?`. `version == 1` (no previous
141
+ # version exists) tells `same_as_before?` this is a FIRST snapshot, where the
142
+ # association baseline is empty and the flag carries decision weight.
143
+ unless same_as_before?(calculated_diff, first_snapshot: version == 1)
144
+ # No nil guard: active_snapshot's `create_snapshot!` raises on failure and
145
+ # always returns the snapshot.
146
+ snapshot = create_snapshot!(**snapshot_obj)
147
+ # update_columns is the second half of the insert, not a domain update: the
148
+ # gem's create_snapshot! signature can't set these V2-promoted columns, and
149
+ # no validation/callback (gem or initializer) concerns them. Runs inside
150
+ # with_lock's transaction, so no half-written row ever commits.
151
+ snapshot.update_columns( # rubocop:disable DevDoc/Rails/AvoidBypassingValidation
152
+ action: action.to_s,
153
+ version: version,
154
+ diff: calculated_diff,
155
+ source: source
156
+ )
157
+ record_blob_references(snapshot, calculated_diff)
158
+ snapshot
159
+ end
160
+ end)
161
+ # Cleanup operations - run outside lock and transaction
162
+ remove_old_snapshot
163
+ end
164
+
165
+ result
166
+ end
167
+
168
+ def glib_revert_snapshot!(version)
169
+ snapshot = snapshots.find_by!(identifier: snapshot_identifier(version))
170
+ snapshot.restore!
171
+ end
172
+
173
+ def snapshot_identifier(version)
174
+ "#{self.class.to_s.underscore}_#{id}_version_#{version}"
175
+ end
176
+
177
+ def interval_from_prev_version
178
+ return nil if snapshot_prev.nil?
179
+
180
+ updated_at - snapshot_prev.created_at
181
+ end
182
+
183
+ def diff(snapshot = snapshot_prev)
184
+ default_ignored_keys = ['updated_at', 'created_at']
185
+ ignore_keys = build_ignore_keys(default_ignored_keys)
186
+
187
+ item, associations = fetch_snapshot_items(snapshot)
188
+
189
+ {
190
+ 'item' => ::Hashdiff.diff(
191
+ normalize_attributes_for_diff(item.attributes.except(*ignore_keys)),
192
+ normalize_attributes_for_diff(attributes.except(*ignore_keys))
193
+ ),
194
+ 'associations' => diff_associations(associations, default_ignored_keys)
195
+ }
196
+ end
197
+
198
+ def snapshot_prev
199
+ snapshots.order(version: :desc).first
200
+ end
201
+
202
+ # Decides whether `glib_create_snapshot!` should skip the version write. Derived from
203
+ # the computed diff (persisted state), not from the instance-local `snapshot_changed`
204
+ # flag: the flag dies with its instance, and an owner's snapshot replaces association
205
+ # targets with fresh instances (`with_lock`'s reload clears the association cache, then
206
+ # the diff walk re-reads the targets), so flag-gating used to silently drop a target's
207
+ # follow-up snapshot taken through the association (issue #481).
208
+ #
209
+ # The one place the flag still decides: a FIRST snapshot whose association diff is
210
+ # meaningless. With no previous version, `fetch_snapshot_items(nil)` returns `{}`
211
+ # for associations, so every pre-existing child row reads as `+` and the diff alone
212
+ # can never prove a no-op — any never-snapshotted record with children would mint a
213
+ # version on every snapshot call (observed as spurious "Edited" audit entries on
214
+ # routine re-saves). There the flag is consulted as a NEGATIVE signal:
215
+ # - `false` — this instance crossed the before_save boundary and
216
+ # `check_snapshot_changed` found no item or child change, and the item diff is
217
+ # net-zero against `attributes_before_save` => skip;
218
+ # - `nil` — no save boundary on this instance (an owner-triggered snapshot of a
219
+ # replaced association target, issue #481) => mint;
220
+ # - `true` — a change was detected at the save boundary; falls through to the
221
+ # diff-based decision (a reverted change still no-ops).
222
+ def same_as_before?(computed_diff = diff, first_snapshot: false)
223
+ return true if first_snapshot && snapshot_changed == false && computed_diff['item'].blank?
224
+
225
+ item_unchanged = computed_diff['item'].blank?
226
+ assoc_diff = computed_diff['associations']
227
+ associations_unchanged = assoc_diff.nil? || assoc_diff.values.all?(&:blank?)
228
+
229
+ item_unchanged && associations_unchanged
230
+ end
231
+
232
+ def last_version
233
+ max_version = snapshots.maximum(:version)
234
+ return 0 if max_version.blank?
235
+
236
+ max_version
237
+ end
238
+
239
+ def remove_old_snapshot
240
+ return if max_snapshots.nil?
241
+
242
+ newest_ids = snapshots.order(version: :desc, id: :desc).limit(max_snapshots).ids
243
+ return unless newest_ids.size >= max_snapshots
244
+
245
+ snapshots.where.not(id: newest_ids).destroy_all
246
+ end
247
+
248
+ def watched_keys_for_snapshot
249
+ raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{self.class}"
250
+ end
251
+
252
+ def associations_for_snapshot
253
+ children_to_snapshot.keys.filter { |key| respond_to?(key) }
254
+ end
255
+
256
+ def max_snapshots
257
+ nil
258
+ end
259
+
260
+ # Return number of days to retain snapshot content, or nil for unlimited.
261
+ def content_retention_days
262
+ nil
263
+ end
264
+
265
+ private
266
+ # Truncate Time/TimeWithZone values to the JSON encoding precision so that
267
+ # reified (JSON-round-tripped) attributes compare equal to live DB values
268
+ # that carry microsecond precision. Without this, Hashdiff reports a
269
+ # spurious change for every datetime field on every re-snapshot.
270
+ #
271
+ # The divisor is derived from ActiveSupport::JSON::Encoding.time_precision so a consumer who
272
+ # raises it above the Rails default of 3 (ms) is not over-truncated — mirrors V1's
273
+ # Glib::Snapshot#normalize_attributes / snapshot_time_divisor exactly. no-op when the
274
+ # precision is already 6 (microseconds).
275
+ def normalize_attributes_for_diff(attrs)
276
+ divisor = snapshot_time_divisor
277
+ attrs.transform_values do |v|
278
+ if (v.is_a?(Time) || v.is_a?(ActiveSupport::TimeWithZone)) && divisor > 1
279
+ v.change(usec: v.usec - v.usec % divisor)
280
+ else
281
+ v
282
+ end
283
+ end
284
+ end
285
+
286
+ def snapshot_time_divisor
287
+ 10 ** (6 - ActiveSupport::JSON::Encoding.time_precision)
288
+ end
289
+
290
+ def build_ignore_keys(default_ignored_keys)
291
+ ignore_keys = default_ignored_keys
292
+
293
+ unless watched_keys_for_snapshot.nil?
294
+ ignored_keys_for_snapshot = attributes.except(*watched_keys_for_snapshot.map(&:to_s)).keys
295
+ ignore_keys = (default_ignored_keys + ignored_keys_for_snapshot.map(&:to_s)).uniq
296
+ end
297
+
298
+ ignore_keys
299
+ end
300
+
301
+ def fetch_snapshot_items(snapshot)
302
+ if snapshot.present?
303
+ snapshot.fetch_reified_items
304
+ else
305
+ [OpenStruct.new(attributes: attributes_before_save || {}), {}]
306
+ end
307
+ end
308
+
309
+ def diff_associations(associations, default_ignored_keys)
310
+ associations_for_snapshot.reduce({}) do |prev, association_name|
311
+ before_records = associations[association_name] || []
312
+
313
+ records = send(association_name)
314
+
315
+ # ActiveStorage: convert Attached::One/Many to their underlying attachment records.
316
+ association = self.class.reflect_on_association(association_name)
317
+ if active_storage_association?(association, records)
318
+ records = active_storage_records(records)
319
+ end
320
+
321
+ # has_one wrapping: V2 tested only with has_many in mapping-web; a has_one returns
322
+ # a single record (or nil), not a collection. Wrap it so the diff logic below works.
323
+ unless records.respond_to?(:each)
324
+ records = [records].compact
325
+ end
326
+
327
+ # id is the universal snapshot identity here: the diff below matches records
328
+ # by id (index_by(&:id)), so this order only pins stable diff-path indices.
329
+ # created_at isn't guaranteed across every association this generic concern
330
+ # snapshots (ActiveStorage, through-tables) and can be backdated, so id — not
331
+ # created_at — is the right deterministic key. Generic engine, hence the disable.
332
+ 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
333
+
334
+ association_diff = diff_single_association(
335
+ association_name,
336
+ before_records,
337
+ now_records,
338
+ default_ignored_keys
339
+ )
340
+
341
+ prev.merge(association_name.to_s => association_diff)
342
+ end
343
+ end
344
+
345
+ def diff_single_association(_association_name, before_records, now_records, default_ignored_keys)
346
+ association_ignored_keys = build_association_ignore_keys(now_records.first, default_ignored_keys)
347
+
348
+ if before_records.blank?
349
+ return ::Hashdiff.diff([], now_records.map { |record| normalize_attributes_for_diff(record.attributes.except(*association_ignored_keys)) })
350
+ end
351
+
352
+ # Create hash lookups to avoid N+1 queries
353
+ now_records_by_id = now_records.index_by(&:id)
354
+ before_records_by_id = before_records.index_by(&:id)
355
+
356
+ # Detect deleted and updated records
357
+ deleted_or_updated = before_records.map.with_index do |record_before, index|
358
+ record_now = now_records_by_id[record_before.id]
359
+ if record_now.blank?
360
+ ['-', "[#{index}]", normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))]
361
+ else
362
+ before_attrs = normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))
363
+ now_attrs = normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))
364
+ next if Hashdiff.diff(before_attrs, now_attrs).blank?
365
+
366
+ ['~', "[#{index}]", before_attrs, now_attrs]
367
+ end
368
+ end.compact_blank
369
+
370
+ # Detect added records
371
+ added = now_records.map.with_index do |record_now, index|
372
+ record_before = before_records_by_id[record_now.id]
373
+ if record_before.blank?
374
+ ['+', "[#{index}]", normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))]
375
+ end
376
+ end.compact_blank
377
+
378
+ deleted_or_updated + added
379
+ end
380
+
381
+ def build_association_ignore_keys(first_record, default_ignored_keys)
382
+ if first_record.present? && !active_storage_record?(first_record) && !first_record.respond_to?(:watched_keys_for_snapshot)
383
+ raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{first_record.class}"
384
+ end
385
+
386
+ if active_storage_record?(first_record)
387
+ default_ignored_keys
388
+ elsif first_record.try(:watched_keys_for_snapshot).nil?
389
+ default_ignored_keys
390
+ else
391
+ assoc_ignore_keys = first_record.attributes.except(*first_record.watched_keys_for_snapshot.map(&:to_s)).keys.map(&:to_s)
392
+ (default_ignored_keys + assoc_ignore_keys).uniq
393
+ end
394
+ end
395
+
396
+ # --- Blob reference tracking ---
397
+ #
398
+ # When a snapshot diff includes attachment changes, extract the blob_ids and persist them as
399
+ # SnapshotBlobReference records. This prevents Glib::PurgeUnattachedJob from deleting old file
400
+ # versions that are still visible in the snapshot timeline.
401
+ #
402
+ # Opt-in: the host app must run the gem's `create_snapshot_blob_references` migration. The
403
+ # `table_exists?` guard makes this a silent no-op for consumers that skip the migration, so
404
+ # `include Glib::SnapshotV2` never crashes a model whose owner only wants the diff/timeline
405
+ # half.
406
+ #
407
+ # Only file-bearing associations are processed — see `file_bearing_association?`. This avoids
408
+ # the over-eager extraction the original code shipped with (it grabbed `id` from *every*
409
+ # association's payload, rescued only by a downstream existence filter that masks the mistake
410
+ # until a non-file record's PK happens to collide with a real blob PK, at which point a
411
+ # spurious reference silently "protects" an unrelated blob from purge).
412
+ def record_blob_references(snapshot, calculated_diff)
413
+ return unless SnapshotBlobReference.table_exists?
414
+
415
+ associations = calculated_diff['associations']
416
+ return if associations.blank?
417
+
418
+ blob_ids = Set.new
419
+ associations.each do |assoc_name, changes|
420
+ next if changes.blank?
421
+ next unless file_bearing_association?(assoc_name.to_s)
422
+
423
+ changes.each do |tuple|
424
+ # tuple format: [op, index, payload] or [op, index, from, to]
425
+ extract_blob_ids_from_payload(tuple[2], blob_ids)
426
+ to_payload = tuple[3]
427
+ extract_blob_ids_from_payload(to_payload, blob_ids) if to_payload
428
+ end
429
+ end
430
+
431
+ return if blob_ids.empty?
432
+
433
+ # Belt-and-suspenders: file_bearing_association? is the primary defense (non-file
434
+ # associations never reach here), but a blob could still be purged between diff-compute
435
+ # and reference-write inside the with_lock block. Filter to existing blobs to avoid an
436
+ # FK violation on snapshot_blob_references.blob_id. :: prefix — see
437
+ # file_bearing_association? for the Glib::ActiveStorage shadowing rationale.
438
+ existing_blob_ids = ::ActiveStorage::Blob.where(id: blob_ids.to_a).pluck(:id)
439
+ existing_blob_ids.each do |blob_id|
440
+ SnapshotBlobReference.create!(snapshot: snapshot, blob_id: blob_id)
441
+ rescue ActiveRecord::RecordNotUnique
442
+ # Already referenced (e.g. same blob_id in both from and to of an update) — safe to skip.
443
+ end
444
+ end
445
+
446
+ # An association is "file-bearing" if its diff payloads carry ActiveStorage blob/attachment
447
+ # row data. Two shapes:
448
+ # 1. Direct ActiveStorage macros: has_one_attached / has_many_attached
449
+ # (e.g. OrganizationDocument#file, V2Post#photos). NOTE: these create NO AR reflection
450
+ # under their own name — they're detected by their value type (Attached::One/Many),
451
+ # the same value-based detection check_snapshot_changed and diff_associations use
452
+ # (active_storage_association?).
453
+ # 2. Through associations whose terminal klass is ActiveStorage::Blob or ::Attachment
454
+ # (e.g. Submission's snapshot_document_blobs: has_many ..., through: ..., source: :blob).
455
+ # These have real reflections with a blob/attachment klass.
456
+ # Non-file associations (has_many :comments, has_one :profile, etc.) are skipped so their
457
+ # record PKs never enter the blob-id extraction path.
458
+ #
459
+ # The :: prefix on ActiveStorage constants is REQUIRED: inside `module Glib`, a bare
460
+ # `ActiveStorage` resolves lexically to Glib::ActiveStorage (the gem's dynamic-text mirror
461
+ # subclass of ::ActiveStorage::Blob), and class equality against it would always be false.
462
+ def file_bearing_association?(assoc_name)
463
+ reflection = self.class.reflect_on_association(assoc_name)
464
+ association_value = public_send(assoc_name)
465
+
466
+ return true if active_storage_association?(reflection, association_value)
467
+
468
+ klass = reflection&.klass
469
+ klass && [::ActiveStorage::Blob, ::ActiveStorage::Attachment].include?(klass)
470
+ end
471
+
472
+ def extract_blob_ids_from_payload(payload, blob_ids)
473
+ return unless payload.is_a?(Hash)
474
+
475
+ # Old snapshots diff ActiveStorage::Attachment rows (blob_id key);
476
+ # new snapshots diff ActiveStorage::Blob rows directly (id key = Blob PK).
477
+ blob_id = payload['blob_id'] || payload[:blob_id] || payload['id'] || payload[:id]
478
+ blob_ids.add(blob_id) if blob_id.present?
479
+ end
480
+
481
+ # --- ActiveStorage helpers (ported from V1 gem) ---
482
+
483
+ def active_storage_association?(association, association_value)
484
+ macro = association&.macro
485
+ return true if [:has_many_attached, :has_one_attached].include?(macro)
486
+ return false if association_value.nil?
487
+
488
+ # :: prefix — see file_bearing_association? for the Glib::ActiveStorage shadowing
489
+ # rationale. Without it, `defined?(Glib::ActiveStorage::Attached)` is nil and the
490
+ # is_a? checks below silently never run (the respond_to? fallback masked this).
491
+ if defined?(::ActiveStorage::Attached)
492
+ return true if association_value.is_a?(::ActiveStorage::Attached::Many) || association_value.is_a?(::ActiveStorage::Attached::One)
493
+ end
494
+
495
+ association_value.respond_to?(:attachments) || association_value.respond_to?(:attachment)
496
+ end
497
+
498
+ def active_storage_records(value)
499
+ if value.respond_to?(:attachments)
500
+ value.attachments
501
+ elsif value.respond_to?(:attachment)
502
+ Array(value.attachment).compact
503
+ else
504
+ []
505
+ end
506
+ end
507
+
508
+ def active_storage_record?(record)
509
+ return false if record.nil?
510
+
511
+ record.class.name.start_with?('ActiveStorage::')
512
+ end
513
+ end
514
+ end
@@ -0,0 +1,40 @@
1
+ module Glib
2
+ # Base class for table-less FORM-BACKED domain objects — the ActiveModel
3
+ # counterpart of Glib::ApplicationRecord. Apps inherit through a thin
4
+ # `ApplicationModel < Glib::Model` root (parallel to ApplicationRecord), so
5
+ # app code always reads `< ApplicationModel`.
6
+ #
7
+ # Choosing a base for a class under app/models (the three-way rule; see
8
+ # dev-doc best_practices/backend/en/03_model.md item 9):
9
+ #
10
+ # ApplicationRecord — the class is persisted (it has a table).
11
+ # ApplicationModel — table-less, but it BACKS A FORM/REQUEST: it is built
12
+ # from params (`assign_attributes(create_params)`),
13
+ # validates one user transaction, reports errors back
14
+ # to the dialog, and pairs by name with a controller
15
+ # and policy. (This class.)
16
+ # PlainModel — neither: plain domain logic with no framework
17
+ # machinery — catalogs, derivations, tallies,
18
+ # write-orchestration helpers (see Glib::PlainModel).
19
+ #
20
+ # If you are reaching for a bare `include ActiveModel::Model`, you want
21
+ # ApplicationModel instead — the shared mechanics live here.
22
+ class Model
23
+ include ActiveModel::Model
24
+
25
+ # Declares multi-select id-list attributes: a reader plus a normalizing
26
+ # writer that drops the blank entries browsers submit for empty
27
+ # multi-selects and coerces the surviving ids to integers.
28
+ #
29
+ # attr_id_list :member_ids, :assignee_group_ids
30
+ def self.attr_id_list(*attr_names)
31
+ attr_names.each do |attr_name|
32
+ attr_reader attr_name
33
+
34
+ define_method("#{attr_name}=") do |ids|
35
+ instance_variable_set("@#{attr_name}", Array(ids).map(&:presence).compact.map(&:to_i))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ module Glib
2
+ # Marker base for PLAIN domain objects: domain logic under app/models that
3
+ # neither persists (no table — not ApplicationRecord) nor backs a form or
4
+ # request (no params/validations/errors — not ApplicationModel). Catalogs,
5
+ # derivations, tallies, write-orchestration helpers. Apps inherit through a
6
+ # thin `PlainModel < Glib::PlainModel` root; the base-choosing guide lives on
7
+ # Glib::Model.
8
+ #
9
+ # Deliberately empty: what plain domain objects share is a CONTRACT, not
10
+ # mechanics —
11
+ # - domain logic only: no external I/O (HTTP clients and provider SDKs
12
+ # belong in app/services adapters);
13
+ # - no enqueues (`perform_later`/`deliver_later` are the calling
14
+ # controller's or job's trigger);
15
+ # - writes, when any, go through the ActiveRecord models whose invariants
16
+ # they are.
17
+ # Inheriting this class is the author's declaration that the contract holds.
18
+ # The moment a subclass wants shared BEHAVIOR from here, that behavior
19
+ # belongs in Glib::Model or a concern instead — this base stays a
20
+ # classification, so that inheriting it keeps meaning exactly one thing.
21
+ class PlainModel
22
+ end
23
+ 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
@@ -37,17 +37,17 @@ page.scroll childViews: ->(scroll) do
37
37
  displayed: false
38
38
  end
39
39
 
40
- wres.panels_bulkEdit2 \
40
+ wres.panels_bulkEdit3 \
41
41
  id: 'import_table',
42
42
  width: 'matchParent',
43
43
  paramNameForRowId: 'row_id',
44
44
  import: { submitUrl: json_ui_garage_url(path: 'forms/bulk_edit_post2'), paramName: 'user' },
45
45
  viewHeaders: [
46
- { text: 'Month', id: 'month', importable: true, tooltip: { text: 'Month', placement: 'top' } },
47
- { text: 'Electricity usage', id: 'electricity_usage', importable: true },
48
- { text: 'Gas usage', id: 'gas_usage', importable: true },
49
- { text: 'Sources', id: 'sources', importable: true },
50
- { text: 'Compliant', id: 'compliant', importable: true }
46
+ { text: 'Month', id: 'month', tooltip: { text: 'Month', placement: 'top' } },
47
+ { text: 'Electricity usage', id: 'electricity_usage' },
48
+ { text: 'Gas usage', id: 'gas_usage' },
49
+ { text: 'Sources', id: 'sources' },
50
+ { text: 'Compliant', id: 'compliant' }
51
51
  ],
52
52
  viewCells: [
53
53
  ->(view) do
@@ -37,6 +37,7 @@ nav_groups = {
37
37
  'fields_stripeExternalAccount',
38
38
  'fields_creditCard',
39
39
  'fields_timer',
40
+ 'fields_change_trigger',
40
41
  'fields_upload',
41
42
  'fields_url_fragment',
42
43
  'fields_captcha',
@@ -55,7 +56,6 @@ nav_groups = {
55
56
  'flow',
56
57
  'grid',
57
58
  'horizontal',
58
- 'panels_bulkEdit2',
59
59
  'pagination',
60
60
  'responsive',
61
61
  'scroll',
@@ -89,6 +89,27 @@ page.body(
89
89
  end
90
90
  )
91
91
  res.spacer height: 4
92
+ res.button(
93
+ text: 'Dialog alert onClose',
94
+ onClick: ->(action) do
95
+ action.dialogs_alert(
96
+ message: 'Close me',
97
+ onClose: ->(subaction) do
98
+ subaction.dialogs_show(
99
+ content: ->(dialog) do
100
+ dialog.body(
101
+ padding: glib_json_padding_body,
102
+ childViews: ->(sbody) do
103
+ sbody.h1 text: 'onClose fired'
104
+ end
105
+ )
106
+ end
107
+ )
108
+ end
109
+ )
110
+ end
111
+ )
112
+ res.spacer height: 4
92
113
  res.button(
93
114
  text: 'Dialog notification',
94
115
  onClick: ->(action) do