glib-web 6.2.0 → 6.3.2
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/app/controllers/concerns/glib/form_stepper/controller.rb +73 -0
- data/app/controllers/concerns/glib/form_stepper/step.rb +12 -0
- data/app/models/glib/country_list.rb +33 -0
- data/app/models/glib/state_list.rb +49 -0
- data/app/views/json_ui/garage/actions/_dialogs_show.json.jbuilder +5 -1
- data/app/views/json_ui/garage/test_page/charts.json.jbuilder +3 -0
- data/lib/glib/engine.rb +132 -0
- data/lib/glib/snapshot.rb +56 -11
- data/lib/glib-web.rb +2 -0
- data/lib/tasks/db.rake +6 -0
- metadata +35 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f55e891cbc4a9325dbf8fe235dcb593982686ec8e36f62d86b6aea853e83f22
|
|
4
|
+
data.tar.gz: 6299bcaf264a60d4a604dc6dc7f44f45f2671f2827b8a6fe26f266979d82dbf5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 368a1c82a9ee9f539b38d4e3396b0fa14cf449fef03d50862239b549d3d9593a19b8961f41e521d8a627bf58530b63b5ac6f1a89310bebe1d0b5a5e61d811361
|
|
7
|
+
data.tar.gz: b8fe1cd9c3256ad9d3d7ea1cafb5adef1ec80e984ec7678edfd5e6aaec585babdc96aaddcef189003269ab9a33b89b00aae5fe5ac1c4988458de0ec3fa33fec4
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Glib
|
|
2
|
+
module FormStepper
|
|
3
|
+
# A session-backed multi-step wizard: per-step answers accumulate in the
|
|
4
|
+
# session and nothing is persisted until the final step commits. Fits
|
|
5
|
+
# pre-account flows and any wizard where an abandoned run should simply
|
|
6
|
+
# disappear.
|
|
7
|
+
#
|
|
8
|
+
# The including controller declares the flow and its session bucket:
|
|
9
|
+
#
|
|
10
|
+
# class ApplyController < ApplicationController
|
|
11
|
+
# include Glib::FormStepper::Controller
|
|
12
|
+
#
|
|
13
|
+
# STEPS = [
|
|
14
|
+
# Glib::FormStepper::Step.new(key: :program, title: "Program", position: 1),
|
|
15
|
+
# Glib::FormStepper::Step.new(key: :auditor, title: "Auditor", position: 2)
|
|
16
|
+
# ].freeze
|
|
17
|
+
# SESSION_KEY = "apply_application".freeze
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# The current step is resolved from `params[:id]`, defaulting to the first step.
|
|
21
|
+
module Controller
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
included do
|
|
25
|
+
helper_method :current_step, :next_step, :prev_step, :step_data, :steps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def steps
|
|
29
|
+
self.class::STEPS
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def current_step
|
|
33
|
+
@current_step ||= find_step(params[:id] || steps.first.key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def next_step
|
|
37
|
+
idx = steps.index(current_step)
|
|
38
|
+
idx && steps[idx + 1]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def prev_step
|
|
42
|
+
idx = steps.index(current_step)
|
|
43
|
+
idx&.positive? ? steps[idx - 1] : nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Every answer gathered so far, one hash per step key.
|
|
47
|
+
def session_data
|
|
48
|
+
session[self.class::SESSION_KEY] ||= {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The current step's answers — what its form reads to prefill itself.
|
|
52
|
+
def step_data
|
|
53
|
+
session_data[current_step.key.to_s] || {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Merge (not replace) so re-submitting a step updates rather than clobbers.
|
|
57
|
+
def save_step_data(data)
|
|
58
|
+
key = current_step.key.to_s
|
|
59
|
+
session_data[key] = (session_data[key] || {}).merge(data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear_session_data
|
|
63
|
+
session.delete(self.class::SESSION_KEY)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
def find_step(key)
|
|
68
|
+
steps.find { |step| step.key == key.to_sym } ||
|
|
69
|
+
raise(ArgumentError, "Unknown step: #{key}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Glib
|
|
2
|
+
module FormStepper
|
|
3
|
+
# One step in a session-backed wizard: just its identity and label. `key` is
|
|
4
|
+
# coerced to a symbol so a step declared with either a symbol or string, and a
|
|
5
|
+
# `params[:id]` that always arrives as a string, resolve to the same step.
|
|
6
|
+
Step = Data.define(:key, :title, :position) do
|
|
7
|
+
def initialize(key:, title:, position:)
|
|
8
|
+
super(key: key.to_sym, title: title, position: position)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'countries' # ISO 3166 data (ISO3166::Country)
|
|
2
|
+
|
|
3
|
+
module Glib
|
|
4
|
+
# ISO 3166 country data for country dropdowns.
|
|
5
|
+
#
|
|
6
|
+
# Backed entirely by the `countries` gem, so there is no table to migrate or
|
|
7
|
+
# seed, and renames (Türkiye, Czechia) arrive with a `bundle update`. Store
|
|
8
|
+
# the alpha-2 code rather than the name, so a rename never invalidates
|
|
9
|
+
# existing data.
|
|
10
|
+
module CountryList
|
|
11
|
+
class << self
|
|
12
|
+
# `common_name` is what people actually call the place ("South Korea",
|
|
13
|
+
# "United Kingdom") rather than the formal ISO name ("Korea (Republic of)").
|
|
14
|
+
def options
|
|
15
|
+
@options ||= ISO3166::Country.all
|
|
16
|
+
.map { |country| { value: country.alpha2, text: country.common_name || country.iso_short_name } }
|
|
17
|
+
.sort_by { |option| option[:text] }
|
|
18
|
+
.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def codes
|
|
22
|
+
@codes ||= options.pluck(:value).freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def name_for(code)
|
|
26
|
+
return if code.blank?
|
|
27
|
+
|
|
28
|
+
country = ISO3166::Country[code]
|
|
29
|
+
country && (country.common_name || country.iso_short_name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require 'countries' # ISO 3166-2 subdivision data (ISO3166::Country)
|
|
2
|
+
|
|
3
|
+
module Glib
|
|
4
|
+
# ISO 3166-2 subdivision (state/province/territory) data for state dropdowns.
|
|
5
|
+
#
|
|
6
|
+
# The companion to Glib::CountryList: backed entirely by the `countries` gem,
|
|
7
|
+
# so there is no table to migrate or seed, and renames arrive with a
|
|
8
|
+
# `bundle update`. Store the subdivision code (e.g. "NSW") rather than the
|
|
9
|
+
# name, so a rename never invalidates existing data. The code is only unique
|
|
10
|
+
# within a country, so always store it alongside the country code.
|
|
11
|
+
#
|
|
12
|
+
# Not every country has subdivisions in the ISO data (e.g. Vatican City); for
|
|
13
|
+
# those `options_for` returns `[]` and the UI should fall back to a plain text
|
|
14
|
+
# field or omit the state input entirely.
|
|
15
|
+
module StateList
|
|
16
|
+
class << self
|
|
17
|
+
# Options for a single country's state dropdown, sorted by display name.
|
|
18
|
+
# `country_code` is an alpha-2 code, matching what Glib::CountryList stores.
|
|
19
|
+
def options_for(country_code)
|
|
20
|
+
return [] if country_code.blank?
|
|
21
|
+
|
|
22
|
+
(@options_by_country ||= {})[country_code.to_s.upcase] ||= build_options(country_code)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def codes_for(country_code)
|
|
26
|
+
options_for(country_code).pluck(:value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name_for(country_code, state_code)
|
|
30
|
+
return if country_code.blank? || state_code.blank?
|
|
31
|
+
|
|
32
|
+
country = ISO3166::Country[country_code]
|
|
33
|
+
subdivision = country&.subdivisions&.dig(state_code.to_s.upcase)
|
|
34
|
+
subdivision&.name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
def build_options(country_code)
|
|
39
|
+
country = ISO3166::Country[country_code]
|
|
40
|
+
return [] if country.nil?
|
|
41
|
+
|
|
42
|
+
country.subdivisions
|
|
43
|
+
.map { |code, subdivision| { value: code, text: subdivision.name } }
|
|
44
|
+
.sort_by { |option| option[:text] }
|
|
45
|
+
.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -24,7 +24,11 @@ markdown = '## Emphasis' + "\n" +
|
|
|
24
24
|
It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.
|
|
25
25
|
It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages,
|
|
26
26
|
and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
|
|
27
|
-
"
|
|
27
|
+
".gsub("\n", " \n")
|
|
28
|
+
# The gsub turns each bare newline into a markdown hard break: the renderer
|
|
29
|
+
# uses standard (breaks: false) semantics, under which these hand-wrapped
|
|
30
|
+
# lines would otherwise reflow into one block — and this dialog exists to
|
|
31
|
+
# demonstrate scrolling, so it needs its ~20 visible lines.
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
options = {}
|
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/glib-web.rb
CHANGED
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,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: glib-web
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 6.2
|
|
4
|
+
version: 6.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ''
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activestorage
|
|
@@ -122,6 +121,34 @@ dependencies:
|
|
|
122
121
|
- - ">="
|
|
123
122
|
- !ruby/object:Gem::Version
|
|
124
123
|
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: ostruct
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: countries
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '7.1'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '7.1'
|
|
125
152
|
- !ruby/object:Gem::Dependency
|
|
126
153
|
name: rubocop
|
|
127
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -150,7 +177,6 @@ dependencies:
|
|
|
150
177
|
- - ">="
|
|
151
178
|
- !ruby/object:Gem::Version
|
|
152
179
|
version: '0'
|
|
153
|
-
description:
|
|
154
180
|
email: ''
|
|
155
181
|
executables: []
|
|
156
182
|
extensions: []
|
|
@@ -163,6 +189,8 @@ files:
|
|
|
163
189
|
- app/controllers/concerns/glib/analytics/funnel.rb
|
|
164
190
|
- app/controllers/concerns/glib/auth/policy.rb
|
|
165
191
|
- app/controllers/concerns/glib/auth/response.rb
|
|
192
|
+
- app/controllers/concerns/glib/form_stepper/controller.rb
|
|
193
|
+
- app/controllers/concerns/glib/form_stepper/step.rb
|
|
166
194
|
- app/controllers/concerns/glib/json/dynamic_text.rb
|
|
167
195
|
- app/controllers/concerns/glib/json/libs.rb
|
|
168
196
|
- app/controllers/concerns/glib/json/new_dynamic_text.rb
|
|
@@ -228,8 +256,10 @@ files:
|
|
|
228
256
|
- app/models/glib/active_storage/attachment.rb
|
|
229
257
|
- app/models/glib/active_storage/blob.rb
|
|
230
258
|
- app/models/glib/application_record.rb
|
|
259
|
+
- app/models/glib/country_list.rb
|
|
231
260
|
- app/models/glib/dummy_job_application.rb
|
|
232
261
|
- app/models/glib/dynamic_text_record.rb
|
|
262
|
+
- app/models/glib/state_list.rb
|
|
233
263
|
- app/models/glib/text.rb
|
|
234
264
|
- app/policies/glib/application_policy.rb
|
|
235
265
|
- app/validators/email_typo_validator.rb
|
|
@@ -541,10 +571,8 @@ files:
|
|
|
541
571
|
- lib/glib/time_returning_mailer.rb
|
|
542
572
|
- lib/glib/value.rb
|
|
543
573
|
- lib/tasks/db.rake
|
|
544
|
-
homepage:
|
|
545
574
|
licenses: []
|
|
546
575
|
metadata: {}
|
|
547
|
-
post_install_message:
|
|
548
576
|
rdoc_options: []
|
|
549
577
|
require_paths:
|
|
550
578
|
- lib
|
|
@@ -559,8 +587,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
559
587
|
- !ruby/object:Gem::Version
|
|
560
588
|
version: '0'
|
|
561
589
|
requirements: []
|
|
562
|
-
rubygems_version:
|
|
563
|
-
signing_key:
|
|
590
|
+
rubygems_version: 4.0.6
|
|
564
591
|
specification_version: 4
|
|
565
592
|
summary: ''
|
|
566
593
|
test_files: []
|