glib-web 6.2.0 → 6.3.1

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: 52b6b62180ee0cd668652772110774bc915d8789f1fd65e9ef22fadd781977a4
4
- data.tar.gz: 3d47de6a06859cce937834478bbef460e2cac999bbfbdf72cef10a781399e68f
3
+ metadata.gz: 8378cb302a462be1ed8ee965c9ace54b77c4eb29f7d1cc9d83a469c89d0f6d93
4
+ data.tar.gz: 91f29270361f13067e3bc513cd01091e82b09d7a89a40eadb4dab893b59f7a66
5
5
  SHA512:
6
- metadata.gz: c6537178f38fa7703d8c3e0436f1c3d4ac1171c340bcda27d18aad936a7ff40e329488c0cbf618dd0974e4e7a7926a3b209eb3c88f29447416b1000db9fee37d
7
- data.tar.gz: c532e338ce8890c52a5efc9c07c734fd99f4adba46f244f5a5c9c160b188b7630d0d18b080ad599a528ad9bb9a68fc5697c84137ec5383524308fc58b53c512b
6
+ metadata.gz: 62c1600d052a52e8fb020662ca4db7455518945e298c6e5ea5663e4960955d9d29129252b0780107b138bd4d585a4463f4bef253dc26460203acee362b083af8
7
+ data.tar.gz: 4d816f8451f71efbbb96fbcbe64fe1586211b3e328c42ec89580d711abe9f5d62033ff645f1622b855fa31e86eee5a383ed2ff6732d0a817ba6462bea6e23821
data/lib/glib/engine.rb CHANGED
@@ -1,7 +1,139 @@
1
+ require 'active_snapshot/models/snapshot'
2
+
1
3
  module Glib
2
4
  module Web
3
5
  class Engine < ::Rails::Engine
4
6
  # isolate_namespace Glib::Web
7
+
8
+ # Extends ActiveSnapshot::Snapshot (the snapshot record class) with a structured-diff
9
+ # reader so consumers don't have to re-parse raw Hashdiff tuples from the `diff` column.
10
+ # Runs as an engine initializer (not at file-load time) because `scope` and other AR
11
+ # class methods aren't available until ActiveRecord is fully loaded.
12
+ initializer 'glib.active_snapshot_ext' do
13
+ ActiveSnapshot::Snapshot.class_eval do
14
+ unless const_defined?(:Change)
15
+ const_set(:Change, Struct.new(:index, :action, :from, :to))
16
+ end
17
+
18
+ # Mirror `Glib::ApplicationRecord.created_desc` so call sites can use the codebase-wide
19
+ # convention instead of `order(created_at: :desc)`.
20
+ unless respond_to?(:created_desc)
21
+ scope :created_desc, -> { order(created_at: :desc) }
22
+ end
23
+
24
+ # Parses the stored diff into a flat hash of Change structs:
25
+ # { 'item' => [Change, ...], 'association_name' => [Change, ...], ... }
26
+ # Returns an empty structure if no diff exists (e.g. the first snapshot).
27
+ # Reads from a dedicated `diff` column if the table has one (app override),
28
+ # otherwise falls back to `metadata['diff']` (glib-web's default storage).
29
+ #
30
+ # JSONB round-trips every value as a string, so datetime/date column values arrive as
31
+ # ISO8601 strings instead of Time/Date objects. The cast_* methods resolve the column
32
+ # type from the live model schema and cast the value back so consumers (e.g. timeline
33
+ # helpers) receive typed values. Casts are guarded:
34
+ # - Deleted column → type_for_attribute returns a default Value type (type=nil) → skipped
35
+ # - Deleted model → safe_constantize returns nil → skipped
36
+ # - Deleted association → reflect_on_association returns nil → skipped
37
+ # - Changed column type → type.cast returns nil for unparseable values (no crash)
38
+ # - Exotic type failure → rescue returns the raw value (defense-in-depth)
39
+ def changes_from_prev_version
40
+ diff = self[:diff] || metadata&.dig('diff')
41
+ return { 'item' => [] } if diff.nil?
42
+
43
+ root = item_type.to_s.safe_constantize
44
+
45
+ result = {
46
+ 'item' => (diff['item'] || []).map { |data| cast_item_change(build_change(data), root) }
47
+ }
48
+
49
+ (diff['associations'] || {}).each do |association, tuples|
50
+ assoc_model = root&.reflect_on_association(association.to_s)&.klass
51
+ result[association.to_s] = tuples.map { |data| cast_association_change(build_change(data), assoc_model) }
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ # Converts a single raw Hashdiff tuple (e.g. ["~", "field", old, new]) into a Change struct.
58
+ # For item-level tuples, `data[1]` is the column name (e.g. "title").
59
+ # For association tuples, `data[1]` is a numeric path like "[0]" (position in the collection).
60
+ # Both are captured as `index` so consumers can label rows via humanize.
61
+ def build_change(data)
62
+ actions = {
63
+ '+' => 'create',
64
+ '-' => 'destroy',
65
+ '~' => 'update'
66
+ }.freeze
67
+
68
+ change = ActiveSnapshot::Snapshot.const_get(:Change).new
69
+ change.action = actions[data[0].to_s]
70
+
71
+ path = data[1].to_s
72
+ bracket_match = path.match(/\[(?<index>[^\]]+)\]/)
73
+ change.index = bracket_match ? bracket_match[:index] : path
74
+
75
+ payload = data[2]
76
+ case change.action
77
+ when 'create'
78
+ change.from = nil
79
+ change.to = payload
80
+ when 'destroy'
81
+ change.from = payload
82
+ change.to = nil
83
+ when 'update'
84
+ change.from = payload
85
+ change.to = data[3]
86
+ else
87
+ raise "Unexpected Hashdiff op: #{data[0].inspect}"
88
+ end
89
+
90
+ change
91
+ end
92
+
93
+ private
94
+ # Item-level change: from/to are scalar column values. Cast each to the column's real type.
95
+ def cast_item_change(change, root)
96
+ change.from = cast_column_value(root, change.index, change.from)
97
+ change.to = cast_column_value(root, change.index, change.to)
98
+ change
99
+ end
100
+
101
+ # Association-level change: from/to are attribute hashes. Cast each attribute by the
102
+ # association model's column types.
103
+ def cast_association_change(change, assoc_model)
104
+ change.from = cast_attributes(assoc_model, change.from)
105
+ change.to = cast_attributes(assoc_model, change.to)
106
+ change
107
+ end
108
+
109
+ # Cast a single value when `column` is a date/time column on `model`; otherwise return it
110
+ # unchanged. Safeguards for schema drift after the snapshot was taken:
111
+ # - Deleted column → type_for_attribute returns a default type (type=nil) → skipped
112
+ # - Changed column type → type.cast may return nil for the old value → return raw value
113
+ # - Exotic failure → rescue returns raw value
114
+ # In all fallback cases the raw value is returned so the timeline never loses data.
115
+ def cast_column_value(model, column, value)
116
+ return value if value.nil? || model.nil?
117
+
118
+ type = model.type_for_attribute(column.to_s)
119
+ return value unless %i[datetime date time].include?(type.type)
120
+
121
+ casted = type.cast(value)
122
+ # If the column type changed after the snapshot was taken, the old value may not
123
+ # be parseable as the new type — type.cast returns nil silently. Return the raw
124
+ # value so the timeline still shows the original data instead of "<not set>".
125
+ casted.nil? ? value : casted
126
+ rescue StandardError
127
+ value
128
+ end
129
+
130
+ def cast_attributes(model, attrs)
131
+ return attrs unless model && attrs.is_a?(Hash)
132
+
133
+ attrs.to_h { |column, value| [column, cast_column_value(model, column, value)] }
134
+ end
135
+ end
136
+ end
5
137
  end
6
138
  end
7
139
  end
data/lib/glib/snapshot.rb CHANGED
@@ -95,8 +95,20 @@ module Glib
95
95
 
96
96
  raise 'unknown action' if !known_actions.include?(action.to_s)
97
97
 
98
+ # Cheap pre-check WITHOUT a lock — the overwhelmingly common case is "nothing
99
+ # changed". same_as_before? is a pure read (it re-queries snapshot_prev and
100
+ # associations each call) and cannot produce a false "same", so returning nil
101
+ # here is always safe and skips the row lock entirely.
102
+ return nil if same_as_before?
103
+
98
104
  result = nil
99
105
  with_lock do
106
+ # Re-check under the lock: another writer may have snapshotted this exact
107
+ # change between the unlocked pre-check and here. NOT optional — without it,
108
+ # every concurrent writer that passed the pre-check would compute
109
+ # last_version + 1 and insert a duplicate snapshot of the same change.
110
+ next if same_as_before?
111
+
100
112
  version = last_version + 1
101
113
  metadata = {
102
114
  action: action,
@@ -114,11 +126,8 @@ module Glib
114
126
  snapshot_obj.delete(:user)
115
127
  end
116
128
 
117
- # dont create version if same as before
118
- if !same_as_before?
119
- result = create_snapshot!(**snapshot_obj)
120
- remove_old_snapshot if result.present?
121
- end
129
+ result = create_snapshot!(**snapshot_obj)
130
+ remove_old_snapshot if result.present?
122
131
  end
123
132
  result
124
133
  end
@@ -151,8 +160,10 @@ module Glib
151
160
  associations = {}
152
161
  end
153
162
 
163
+ item_attrs = normalize_attributes(item.attributes.except(*ignore_keys))
164
+ current_attrs = normalize_attributes(attributes.except(*ignore_keys))
154
165
  obj = {
155
- 'item' => ::Hashdiff.diff(item.attributes.except(*ignore_keys), attributes.except(*ignore_keys))
166
+ 'item' => ::Hashdiff.diff(item_attrs, current_attrs)
156
167
  }
157
168
 
158
169
  obj['associations'] = associations_for_snapshot.reduce({}) do |prev, curr|
@@ -183,22 +194,29 @@ module Glib
183
194
  now = association_records
184
195
 
185
196
  if before.blank?
186
- prev.merge(curr.to_s => ::Hashdiff.diff([], now.map { |record| record.attributes.except(*association_ignored_keys) }))
197
+ prev.merge(
198
+ curr.to_s => ::Hashdiff.diff(
199
+ [],
200
+ now.map { |record| normalize_attributes(record.attributes.except(*association_ignored_keys)) }
201
+ )
202
+ )
187
203
  else
188
204
  diff = before.map.with_index do |record_before, index|
189
205
  record_now = now.find_by(id: record_before.id)
190
206
  if record_now.blank?
191
- ['-', "[#{index}]", record_before.attributes.except(*association_ignored_keys)]
207
+ ['-', "[#{index}]", normalize_attributes(record_before.attributes.except(*association_ignored_keys))]
192
208
  elsif record_now.present?
193
- next if Hashdiff.diff(record_before.attributes.except(*association_ignored_keys), record_now.attributes.except(*association_ignored_keys)).blank?
194
- ['~', "[#{index}]", record_before.attributes.except(*association_ignored_keys), record_now.attributes.except(*association_ignored_keys)]
209
+ before_attrs = normalize_attributes(record_before.attributes.except(*association_ignored_keys))
210
+ now_attrs = normalize_attributes(record_now.attributes.except(*association_ignored_keys))
211
+ next if Hashdiff.diff(before_attrs, now_attrs).blank?
212
+ ['~', "[#{index}]", before_attrs, now_attrs]
195
213
  end
196
214
  end.compact_blank
197
215
 
198
216
  diff2 = now.map.with_index do |record_now, index|
199
217
  record_before = before.detect { |record| record.id == record_now.id }
200
218
  if record_before.blank?
201
- ['+', "[#{index}]", record_now.attributes.except(*association_ignored_keys)]
219
+ ['+', "[#{index}]", normalize_attributes(record_now.attributes.except(*association_ignored_keys))]
202
220
  end
203
221
  end.compact_blank
204
222
 
@@ -296,5 +314,32 @@ module Glib
296
314
  Array(records)
297
315
  end
298
316
  end
317
+
318
+ # Datetimes lose sub-millisecond precision on the snapshot store→reify round-trip:
319
+ # the JSON column encodes Time via ActiveSupport::JSON::Encoding.time_precision
320
+ # (default 3 = milliseconds, truncated not rounded), and reify casts that string
321
+ # back to a Time whose usec is a multiple of 1000. The live DB value keeps full
322
+ # microsecond precision, so Hashdiff (which compares Time values with ==) reports a
323
+ # phantom change on every re-snapshot of any watched sub-second datetime. Floor both
324
+ # sides to the same precision before the compare. The divisor is derived from
325
+ # time_precision so a consumer who raises it to 6 (microseconds) is not truncated.
326
+ # Because Hashdiff emits these floored values, the stored `metadata['diff']` also
327
+ # carries ms-precision datetimes (not just the in-memory comparison) — readers that
328
+ # introspect the raw diff see `.123000` even when the column holds `.123456`.
329
+ def normalize_attributes(hash)
330
+ divisor = snapshot_time_divisor
331
+ hash.transform_values { |value| normalize_datetime(value, divisor) }
332
+ end
333
+
334
+ def normalize_datetime(value, divisor = snapshot_time_divisor)
335
+ return value unless value.respond_to?(:usec)
336
+ return value if divisor <= 1
337
+
338
+ value.change(usec: value.usec - value.usec % divisor)
339
+ end
340
+
341
+ def snapshot_time_divisor
342
+ 10 ** (6 - ActiveSupport::JSON::Encoding.time_precision)
343
+ end
299
344
  end
300
345
  end
data/lib/tasks/db.rake CHANGED
@@ -81,6 +81,12 @@ namespace :db do
81
81
  attrs[key] = value.to_json
82
82
  elsif value.is_a? BigDecimal
83
83
  attrs[key] = value.to_f
84
+ elsif value.is_a?(IPAddr)
85
+ # inet/cidr columns deserialize to IPAddr. Dump as a plain string so
86
+ # fixtures stay portable: IPAddr's internal `family` constant differs
87
+ # between Linux/WSL and macOS, so a YAML-dumped IPAddr object raises
88
+ # when loaded on the other OS. Strings re-parse via cast_value on any platform.
89
+ attrs[key] = value.to_s
84
90
  end
85
91
  end
86
92
 
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.2.0
4
+ version: 6.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-10-04 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activestorage