glib-web 7.0.0.beta1 → 7.0.1.beta1

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.
@@ -0,0 +1,485 @@
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
+ #
17
+ # ## Snapshot ordering: owner vs association targets
18
+ #
19
+ # Owner and association-target snapshots may be taken in ANY order within a request.
20
+ # `same_as_before?` decides from persisted state, so a target snapshotted after its owner
21
+ # (through the replaced association target) still records its version.
22
+ #
23
+ # The one residual ordering caveat: `diff` compares against the instance's IN-MEMORY
24
+ # attributes, so unsaved changes live only on the instance that holds them. If a target is
25
+ # modified in memory without saving, snapshot it via a local captured BEFORE the owner's
26
+ # `glib_create_snapshot!` (whose `with_lock` reload replaces the association targets) --
27
+ # reading the target back through the association snapshots the persisted state instead.
28
+ #
29
+ # `snapshot_changed` / `attributes_before_save` remain writable instance accessors for
30
+ # call-site use, but they are no longer consulted for the skip decision.
31
+ #
32
+ # To get all records of a model, you can use the following query:
33
+ # MyModel.joins(:snapshots).distinct
34
+ module Glib
35
+ module SnapshotV2
36
+ extend ActiveSupport::Concern
37
+
38
+ # The CRUD backbone covers most flows; the approval-gate admin actions are distinct
39
+ # audit events (request_changes / approve / undo_approve) that the timeline UI labels
40
+ # off the action string, so they earn their own slots here rather than being collapsed
41
+ # into :update (which would lose the "what happened" signal in the audit trail).
42
+ KNOWN_ACTIONS = [:create, :update, :destroy, :request_changes, :approve, :undo_approve].freeze
43
+
44
+ included do
45
+ include ::ActiveSnapshot
46
+
47
+ attr_accessor :snapshot_changed
48
+ attr_accessor :attributes_before_save
49
+
50
+ # Save-boundary state capture for the snapshot diff engine -- not a hidden side
51
+ # effect. Records the pre-save DB state into `attributes_before_save` and
52
+ # computes `snapshot_changed` so the diff (see `diff` / `same_as_before?`) has a
53
+ # "before" to compare against. `attributes_in_database` is only readable inside
54
+ # the save boundary, and this is a generic concern mixed into many models, so it
55
+ # can't be hoisted to an individual save site the way notification callbacks were.
56
+ # It only sets instance state (no writes, no notifications) -- the actual snapshot
57
+ # row is written by the explicit, call-site-visible `save_with_snapshot` /
58
+ # `glib_create_snapshot!` paths, which is where the acting user is known.
59
+ before_save do # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
60
+ # Only set snapshot_changed if not already set (handles case where before_save
61
+ # is called multiple times - e.g., when associated records are saved before parent)
62
+ if snapshot_changed.nil?
63
+ self.attributes_before_save = attributes_in_database
64
+ self.snapshot_changed = check_snapshot_changed
65
+ end
66
+ end
67
+ end
68
+
69
+ def check_snapshot_changed
70
+ return true if changed?
71
+
72
+ associations_for_snapshot.each do |association_name|
73
+ association = self.class.reflect_on_association(association_name)
74
+ association_value = public_send(association_name)
75
+
76
+ # ActiveStorage associations (has_one_attached / has_many_attached) are not
77
+ # reflected as standard AR macros; handle them before the case block.
78
+ if active_storage_association?(association, association_value)
79
+ active_storage_records(association_value).each do |record|
80
+ return true if record.changed?
81
+ end
82
+ next
83
+ end
84
+
85
+ raise "Invalid association: #{association_name}" if association.nil?
86
+
87
+ case association.macro
88
+ when :has_many
89
+ records = association_value
90
+ records.each do |record|
91
+ return true if record.changed?
92
+ end
93
+ if new_record? && records.count > 0
94
+ return true
95
+ end
96
+ when :has_one, :belongs_to
97
+ return true if association_value&.changed?
98
+ else
99
+ raise "Unexpected association macro: #{association.macro}"
100
+ end
101
+ end
102
+
103
+ false
104
+ end
105
+
106
+ # information need to be store:
107
+ # - action: create, update, destroy
108
+ # - track changes
109
+ def glib_create_snapshot!(action, source, user: nil)
110
+ action_sym = action.to_sym
111
+ raise "unknown action: #{action}" unless KNOWN_ACTIONS.include?(action_sym)
112
+ raise 'source must be present' if source.blank?
113
+ raise 'user must be present when source is "user"' if source == 'user' && user.nil?
114
+
115
+ # Lock is required to prevent race conditions when calculating version number.
116
+ if (result =
117
+ with_lock do
118
+ version = last_version + 1
119
+ calculated_diff = diff
120
+
121
+ snapshot_obj = {
122
+ identifier: snapshot_identifier(version),
123
+ user: user,
124
+ metadata: {} # Keep for potential future use, but main data in columns
125
+ }
126
+
127
+ # Don't create version if same as before. Reuses `calculated_diff` instead of
128
+ # recomputing it inside `same_as_before?`.
129
+ unless same_as_before?(calculated_diff)
130
+ # No nil guard: active_snapshot's `create_snapshot!` raises on failure and
131
+ # always returns the snapshot.
132
+ snapshot = create_snapshot!(**snapshot_obj)
133
+ # update_columns is the second half of the insert, not a domain update: the
134
+ # gem's create_snapshot! signature can't set these V2-promoted columns, and
135
+ # no validation/callback (gem or initializer) concerns them. Runs inside
136
+ # with_lock's transaction, so no half-written row ever commits.
137
+ snapshot.update_columns( # rubocop:disable DevDoc/Rails/AvoidBypassingValidation
138
+ action: action.to_s,
139
+ version: version,
140
+ diff: calculated_diff,
141
+ source: source
142
+ )
143
+ record_blob_references(snapshot, calculated_diff)
144
+ snapshot
145
+ end
146
+ end)
147
+ # Cleanup operations - run outside lock and transaction
148
+ remove_old_snapshot
149
+ end
150
+
151
+ result
152
+ end
153
+
154
+ def glib_revert_snapshot!(version)
155
+ snapshot = snapshots.find_by!(identifier: snapshot_identifier(version))
156
+ snapshot.restore!
157
+ end
158
+
159
+ def snapshot_identifier(version)
160
+ "#{self.class.to_s.underscore}_#{id}_version_#{version}"
161
+ end
162
+
163
+ def interval_from_prev_version
164
+ return nil if snapshot_prev.nil?
165
+
166
+ updated_at - snapshot_prev.created_at
167
+ end
168
+
169
+ def diff(snapshot = snapshot_prev)
170
+ default_ignored_keys = ['updated_at', 'created_at']
171
+ ignore_keys = build_ignore_keys(default_ignored_keys)
172
+
173
+ item, associations = fetch_snapshot_items(snapshot)
174
+
175
+ {
176
+ 'item' => ::Hashdiff.diff(
177
+ normalize_attributes_for_diff(item.attributes.except(*ignore_keys)),
178
+ normalize_attributes_for_diff(attributes.except(*ignore_keys))
179
+ ),
180
+ 'associations' => diff_associations(associations, default_ignored_keys)
181
+ }
182
+ end
183
+
184
+ def snapshot_prev
185
+ snapshots.order(version: :desc).first
186
+ end
187
+
188
+ # Decides whether `glib_create_snapshot!` should skip the version write. Derived from
189
+ # the computed diff (persisted state), never from the instance-local `snapshot_changed`
190
+ # flag: the flag dies with its instance, and an owner's snapshot replaces association
191
+ # targets with fresh instances (`with_lock`'s reload clears the association cache, then
192
+ # the diff walk re-reads the targets), so flag-gating used to silently drop a target's
193
+ # follow-up snapshot taken through the association (issue #481). For a first snapshot
194
+ # (no previous version) the diff runs current state against `attributes_before_save || {}`.
195
+ def same_as_before?(computed_diff = diff)
196
+ item_unchanged = computed_diff['item'].blank?
197
+ assoc_diff = computed_diff['associations']
198
+ associations_unchanged = assoc_diff.nil? || assoc_diff.values.all?(&:blank?)
199
+
200
+ item_unchanged && associations_unchanged
201
+ end
202
+
203
+ def last_version
204
+ max_version = snapshots.maximum(:version)
205
+ return 0 if max_version.blank?
206
+
207
+ max_version
208
+ end
209
+
210
+ def remove_old_snapshot
211
+ return if max_snapshots.nil?
212
+
213
+ newest_ids = snapshots.order(version: :desc, id: :desc).limit(max_snapshots).ids
214
+ return unless newest_ids.size >= max_snapshots
215
+
216
+ snapshots.where.not(id: newest_ids).destroy_all
217
+ end
218
+
219
+ def watched_keys_for_snapshot
220
+ raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{self.class}"
221
+ end
222
+
223
+ def associations_for_snapshot
224
+ children_to_snapshot.keys.filter { |key| respond_to?(key) }
225
+ end
226
+
227
+ def max_snapshots
228
+ nil
229
+ end
230
+
231
+ # Return number of days to retain snapshot content, or nil for unlimited.
232
+ def content_retention_days
233
+ nil
234
+ end
235
+
236
+ private
237
+ # Truncate Time/TimeWithZone values to the JSON encoding precision so that
238
+ # reified (JSON-round-tripped) attributes compare equal to live DB values
239
+ # that carry microsecond precision. Without this, Hashdiff reports a
240
+ # spurious change for every datetime field on every re-snapshot.
241
+ #
242
+ # The divisor is derived from ActiveSupport::JSON::Encoding.time_precision so a consumer who
243
+ # raises it above the Rails default of 3 (ms) is not over-truncated — mirrors V1's
244
+ # Glib::Snapshot#normalize_attributes / snapshot_time_divisor exactly. no-op when the
245
+ # precision is already 6 (microseconds).
246
+ def normalize_attributes_for_diff(attrs)
247
+ divisor = snapshot_time_divisor
248
+ attrs.transform_values do |v|
249
+ if (v.is_a?(Time) || v.is_a?(ActiveSupport::TimeWithZone)) && divisor > 1
250
+ v.change(usec: v.usec - v.usec % divisor)
251
+ else
252
+ v
253
+ end
254
+ end
255
+ end
256
+
257
+ def snapshot_time_divisor
258
+ 10 ** (6 - ActiveSupport::JSON::Encoding.time_precision)
259
+ end
260
+
261
+ def build_ignore_keys(default_ignored_keys)
262
+ ignore_keys = default_ignored_keys
263
+
264
+ unless watched_keys_for_snapshot.nil?
265
+ ignored_keys_for_snapshot = attributes.except(*watched_keys_for_snapshot.map(&:to_s)).keys
266
+ ignore_keys = (default_ignored_keys + ignored_keys_for_snapshot.map(&:to_s)).uniq
267
+ end
268
+
269
+ ignore_keys
270
+ end
271
+
272
+ def fetch_snapshot_items(snapshot)
273
+ if snapshot.present?
274
+ snapshot.fetch_reified_items
275
+ else
276
+ [OpenStruct.new(attributes: attributes_before_save || {}), {}]
277
+ end
278
+ end
279
+
280
+ def diff_associations(associations, default_ignored_keys)
281
+ associations_for_snapshot.reduce({}) do |prev, association_name|
282
+ before_records = associations[association_name] || []
283
+
284
+ records = send(association_name)
285
+
286
+ # ActiveStorage: convert Attached::One/Many to their underlying attachment records.
287
+ association = self.class.reflect_on_association(association_name)
288
+ if active_storage_association?(association, records)
289
+ records = active_storage_records(records)
290
+ end
291
+
292
+ # has_one wrapping: V2 tested only with has_many in mapping-web; a has_one returns
293
+ # a single record (or nil), not a collection. Wrap it so the diff logic below works.
294
+ unless records.respond_to?(:each)
295
+ records = [records].compact
296
+ end
297
+
298
+ # id is the universal snapshot identity here: the diff below matches records
299
+ # by id (index_by(&:id)), so this order only pins stable diff-path indices.
300
+ # created_at isn't guaranteed across every association this generic concern
301
+ # snapshots (ActiveStorage, through-tables) and can be backdated, so id — not
302
+ # created_at — is the right deterministic key. Generic engine, hence the disable.
303
+ 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
304
+
305
+ association_diff = diff_single_association(
306
+ association_name,
307
+ before_records,
308
+ now_records,
309
+ default_ignored_keys
310
+ )
311
+
312
+ prev.merge(association_name.to_s => association_diff)
313
+ end
314
+ end
315
+
316
+ def diff_single_association(_association_name, before_records, now_records, default_ignored_keys)
317
+ association_ignored_keys = build_association_ignore_keys(now_records.first, default_ignored_keys)
318
+
319
+ if before_records.blank?
320
+ return ::Hashdiff.diff([], now_records.map { |record| normalize_attributes_for_diff(record.attributes.except(*association_ignored_keys)) })
321
+ end
322
+
323
+ # Create hash lookups to avoid N+1 queries
324
+ now_records_by_id = now_records.index_by(&:id)
325
+ before_records_by_id = before_records.index_by(&:id)
326
+
327
+ # Detect deleted and updated records
328
+ deleted_or_updated = before_records.map.with_index do |record_before, index|
329
+ record_now = now_records_by_id[record_before.id]
330
+ if record_now.blank?
331
+ ['-', "[#{index}]", normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))]
332
+ else
333
+ before_attrs = normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))
334
+ now_attrs = normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))
335
+ next if Hashdiff.diff(before_attrs, now_attrs).blank?
336
+
337
+ ['~', "[#{index}]", before_attrs, now_attrs]
338
+ end
339
+ end.compact_blank
340
+
341
+ # Detect added records
342
+ added = now_records.map.with_index do |record_now, index|
343
+ record_before = before_records_by_id[record_now.id]
344
+ if record_before.blank?
345
+ ['+', "[#{index}]", normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))]
346
+ end
347
+ end.compact_blank
348
+
349
+ deleted_or_updated + added
350
+ end
351
+
352
+ def build_association_ignore_keys(first_record, default_ignored_keys)
353
+ if first_record.present? && !active_storage_record?(first_record) && !first_record.respond_to?(:watched_keys_for_snapshot)
354
+ raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{first_record.class}"
355
+ end
356
+
357
+ if active_storage_record?(first_record)
358
+ default_ignored_keys
359
+ elsif first_record.try(:watched_keys_for_snapshot).nil?
360
+ default_ignored_keys
361
+ else
362
+ assoc_ignore_keys = first_record.attributes.except(*first_record.watched_keys_for_snapshot.map(&:to_s)).keys.map(&:to_s)
363
+ (default_ignored_keys + assoc_ignore_keys).uniq
364
+ end
365
+ end
366
+
367
+ # --- Blob reference tracking ---
368
+ #
369
+ # When a snapshot diff includes attachment changes, extract the blob_ids and persist them as
370
+ # SnapshotBlobReference records. This prevents Glib::PurgeUnattachedJob from deleting old file
371
+ # versions that are still visible in the snapshot timeline.
372
+ #
373
+ # Opt-in: the host app must run the gem's `create_snapshot_blob_references` migration. The
374
+ # `table_exists?` guard makes this a silent no-op for consumers that skip the migration, so
375
+ # `include Glib::SnapshotV2` never crashes a model whose owner only wants the diff/timeline
376
+ # half.
377
+ #
378
+ # Only file-bearing associations are processed — see `file_bearing_association?`. This avoids
379
+ # the over-eager extraction the original code shipped with (it grabbed `id` from *every*
380
+ # association's payload, rescued only by a downstream existence filter that masks the mistake
381
+ # until a non-file record's PK happens to collide with a real blob PK, at which point a
382
+ # spurious reference silently "protects" an unrelated blob from purge).
383
+ def record_blob_references(snapshot, calculated_diff)
384
+ return unless SnapshotBlobReference.table_exists?
385
+
386
+ associations = calculated_diff['associations']
387
+ return if associations.blank?
388
+
389
+ blob_ids = Set.new
390
+ associations.each do |assoc_name, changes|
391
+ next if changes.blank?
392
+ next unless file_bearing_association?(assoc_name.to_s)
393
+
394
+ changes.each do |tuple|
395
+ # tuple format: [op, index, payload] or [op, index, from, to]
396
+ extract_blob_ids_from_payload(tuple[2], blob_ids)
397
+ to_payload = tuple[3]
398
+ extract_blob_ids_from_payload(to_payload, blob_ids) if to_payload
399
+ end
400
+ end
401
+
402
+ return if blob_ids.empty?
403
+
404
+ # Belt-and-suspenders: file_bearing_association? is the primary defense (non-file
405
+ # associations never reach here), but a blob could still be purged between diff-compute
406
+ # and reference-write inside the with_lock block. Filter to existing blobs to avoid an
407
+ # FK violation on snapshot_blob_references.blob_id. :: prefix — see
408
+ # file_bearing_association? for the Glib::ActiveStorage shadowing rationale.
409
+ existing_blob_ids = ::ActiveStorage::Blob.where(id: blob_ids.to_a).pluck(:id)
410
+ existing_blob_ids.each do |blob_id|
411
+ SnapshotBlobReference.create!(snapshot: snapshot, blob_id: blob_id)
412
+ rescue ActiveRecord::RecordNotUnique
413
+ # Already referenced (e.g. same blob_id in both from and to of an update) — safe to skip.
414
+ end
415
+ end
416
+
417
+ # An association is "file-bearing" if its diff payloads carry ActiveStorage blob/attachment
418
+ # row data. Two shapes:
419
+ # 1. Direct ActiveStorage macros: has_one_attached / has_many_attached
420
+ # (e.g. OrganizationDocument#file, V2Post#photos). NOTE: these create NO AR reflection
421
+ # under their own name — they're detected by their value type (Attached::One/Many),
422
+ # the same value-based detection check_snapshot_changed and diff_associations use
423
+ # (active_storage_association?).
424
+ # 2. Through associations whose terminal klass is ActiveStorage::Blob or ::Attachment
425
+ # (e.g. Submission's snapshot_document_blobs: has_many ..., through: ..., source: :blob).
426
+ # These have real reflections with a blob/attachment klass.
427
+ # Non-file associations (has_many :comments, has_one :profile, etc.) are skipped so their
428
+ # record PKs never enter the blob-id extraction path.
429
+ #
430
+ # The :: prefix on ActiveStorage constants is REQUIRED: inside `module Glib`, a bare
431
+ # `ActiveStorage` resolves lexically to Glib::ActiveStorage (the gem's dynamic-text mirror
432
+ # subclass of ::ActiveStorage::Blob), and class equality against it would always be false.
433
+ def file_bearing_association?(assoc_name)
434
+ reflection = self.class.reflect_on_association(assoc_name)
435
+ association_value = public_send(assoc_name)
436
+
437
+ return true if active_storage_association?(reflection, association_value)
438
+
439
+ klass = reflection&.klass
440
+ klass && [::ActiveStorage::Blob, ::ActiveStorage::Attachment].include?(klass)
441
+ end
442
+
443
+ def extract_blob_ids_from_payload(payload, blob_ids)
444
+ return unless payload.is_a?(Hash)
445
+
446
+ # Old snapshots diff ActiveStorage::Attachment rows (blob_id key);
447
+ # new snapshots diff ActiveStorage::Blob rows directly (id key = Blob PK).
448
+ blob_id = payload['blob_id'] || payload[:blob_id] || payload['id'] || payload[:id]
449
+ blob_ids.add(blob_id) if blob_id.present?
450
+ end
451
+
452
+ # --- ActiveStorage helpers (ported from V1 gem) ---
453
+
454
+ def active_storage_association?(association, association_value)
455
+ macro = association&.macro
456
+ return true if [:has_many_attached, :has_one_attached].include?(macro)
457
+ return false if association_value.nil?
458
+
459
+ # :: prefix — see file_bearing_association? for the Glib::ActiveStorage shadowing
460
+ # rationale. Without it, `defined?(Glib::ActiveStorage::Attached)` is nil and the
461
+ # is_a? checks below silently never run (the respond_to? fallback masked this).
462
+ if defined?(::ActiveStorage::Attached)
463
+ return true if association_value.is_a?(::ActiveStorage::Attached::Many) || association_value.is_a?(::ActiveStorage::Attached::One)
464
+ end
465
+
466
+ association_value.respond_to?(:attachments) || association_value.respond_to?(:attachment)
467
+ end
468
+
469
+ def active_storage_records(value)
470
+ if value.respond_to?(:attachments)
471
+ value.attachments
472
+ elsif value.respond_to?(:attachment)
473
+ Array(value.attachment).compact
474
+ else
475
+ []
476
+ end
477
+ end
478
+
479
+ def active_storage_record?(record)
480
+ return false if record.nil?
481
+
482
+ record.class.name.start_with?('ActiveStorage::')
483
+ end
484
+ end
485
+ 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,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',
@@ -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