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 +4 -4
- data/lib/glib/engine.rb +132 -0
- data/lib/glib/snapshot.rb +56 -11
- data/lib/tasks/db.rake +6 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8378cb302a462be1ed8ee965c9ace54b77c4eb29f7d1cc9d83a469c89d0f6d93
|
|
4
|
+
data.tar.gz: 91f29270361f13067e3bc513cd01091e82b09d7a89a40eadb4dab893b59f7a66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
118
|
-
if
|
|
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(
|
|
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(
|
|
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
|
-
|
|
194
|
-
|
|
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.
|
|
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:
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activestorage
|