typed_eav 0.5.0 → 0.7.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/README.md +271 -61
  4. data/app/models/typed_eav/field/base.rb +90 -33
  5. data/app/models/typed_eav/field/currency.rb +43 -0
  6. data/app/models/typed_eav/field/file.rb +1 -1
  7. data/app/models/typed_eav/field/image.rb +1 -1
  8. data/app/models/typed_eav/field/reference.rb +11 -0
  9. data/app/models/typed_eav/option.rb +0 -8
  10. data/app/models/typed_eav/section.rb +11 -5
  11. data/app/models/typed_eav/value.rb +95 -32
  12. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
  13. data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +54 -0
  14. data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
  15. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +1 -9
  16. data/lib/typed_eav/bulk_read.rb +41 -9
  17. data/lib/typed_eav/bulk_upsert.rb +141 -0
  18. data/lib/typed_eav/bulk_write.rb +65 -39
  19. data/lib/typed_eav/config.rb +19 -21
  20. data/lib/typed_eav/csv_mapper.rb +1 -1
  21. data/lib/typed_eav/engine.rb +16 -27
  22. data/lib/typed_eav/entity_query.rb +42 -8
  23. data/lib/typed_eav/event_dispatcher.rb +21 -30
  24. data/lib/typed_eav/field/typed_storage.rb +48 -0
  25. data/lib/typed_eav/field_deletion.rb +75 -0
  26. data/lib/typed_eav/filter_query.rb +9 -7
  27. data/lib/typed_eav/has_typed_eav/instance_methods.rb +11 -8
  28. data/lib/typed_eav/partition.rb +8 -3
  29. data/lib/typed_eav/query_builder.rb +17 -27
  30. data/lib/typed_eav/registry.rb +7 -8
  31. data/lib/typed_eav/schema_portability/import_index.rb +58 -0
  32. data/lib/typed_eav/schema_portability.rb +22 -25
  33. data/lib/typed_eav/version.rb +1 -1
  34. data/lib/typed_eav/versioning/subscriber.rb +25 -29
  35. data/lib/typed_eav/versioning.rb +59 -41
  36. data/lib/typed_eav.rb +2 -0
  37. metadata +19 -5
@@ -155,6 +155,41 @@ module TypedEAV
155
155
  value_record[self.class.value_columns.first] = default_value
156
156
  end
157
157
 
158
+ # A logical value is missing only when every physical storage cell is
159
+ # nil. Multi-cell fields may legitimately be partially populated; such
160
+ # a row is present and must not be overwritten by a default backfill.
161
+ def logical_value_missing?(value_record)
162
+ self.class.value_columns.all? { |column| value_record[column].nil? }
163
+ end
164
+
165
+ # Normalize values before QueryBuilder emits SQL. Keeping this beside
166
+ # the write caster makes query and write semantics use the same rules.
167
+ def cast_query_operand(operator, raw)
168
+ case operator.to_sym
169
+ when :between
170
+ bounds = if raw.is_a?(Range)
171
+ [raw.begin, raw.end]
172
+ elsif raw.is_a?(Array) && raw.length == 2
173
+ raw
174
+ end
175
+ raise ArgumentError, ":between expects a Range or two-element Array" unless bounds
176
+
177
+ casted = bounds.map { |bound| cast_query_value(bound) }
178
+ casted.first..casted.last
179
+ when :any_eq
180
+ raise ArgumentError, ":any_eq expects a single array element" if raw.is_a?(Array)
181
+
182
+ values = cast_query_value([raw])
183
+ values&.first
184
+ when :all_eq
185
+ raise ArgumentError, ":all_eq expects an Array" unless raw.is_a?(Array)
186
+
187
+ cast_query_value(raw)
188
+ else
189
+ cast_query_value(raw)
190
+ end
191
+ end
192
+
158
193
  # ── Concrete snapshot helpers (NOT overridable) ──
159
194
 
160
195
  # True iff ANY of the field's value_columns had a saved change in the
@@ -200,6 +235,19 @@ module TypedEAV
200
235
  raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
201
236
  end
202
237
  end
238
+
239
+ private
240
+
241
+ def cast_query_value(raw)
242
+ casted, invalid = cast(raw)
243
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}" if invalid
244
+
245
+ casted
246
+ rescue TypeError, ArgumentError => e
247
+ raise e if e.is_a?(ArgumentError) && e.message.start_with?("Invalid ")
248
+
249
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}"
250
+ end
203
251
  end
204
252
  end
205
253
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Explicit, resumable deletion for large Field value populations. The public
5
+ # Field#destroy_with_values_in_batches! contract requires a persisted
6
+ # `field_dependent: :destroy` Field, positive batch size, no open transaction,
7
+ # and one shared Field/Value/ValueVersion connection pool.
8
+ module FieldDeletion
9
+ module_function
10
+
11
+ def destroy!(field, batch_size: 1_000)
12
+ validate!(field, batch_size)
13
+ last_id = 0
14
+
15
+ loop do
16
+ deleted = delete_batch!(field, last_id, batch_size)
17
+ break if deleted.empty?
18
+
19
+ last_id = deleted.last
20
+ end
21
+
22
+ finalize!(field, batch_size)
23
+ end
24
+
25
+ def validate!(field, batch_size)
26
+ raise ArgumentError, "field must be persisted" unless field.persisted?
27
+ raise ArgumentError, "field_dependent must be destroy" unless field.field_dependent == "destroy"
28
+ unless batch_size.is_a?(Integer) && batch_size.positive?
29
+ raise ArgumentError, "batch_size must be a positive Integer"
30
+ end
31
+ if field.class.connection.transaction_open?
32
+ raise ArgumentError, "destroy_with_values_in_batches! cannot run inside an open transaction"
33
+ end
34
+
35
+ pools = [field.class.connection_pool, TypedEAV::Value.connection_pool, TypedEAV::ValueVersion.connection_pool]
36
+ return if pools.uniq.size == 1
37
+
38
+ raise ArgumentError, "field deletion requires Field, Value, and ValueVersion to share a connection pool"
39
+ end
40
+ private_class_method :validate!
41
+
42
+ def delete_batch!(field, last_id, batch_size)
43
+ field.class.transaction do
44
+ values = locked_values(field.id, last_id, batch_size).to_a
45
+ values.each(&:destroy!)
46
+ values.map(&:id)
47
+ end
48
+ end
49
+ private_class_method :delete_batch!
50
+
51
+ def locked_values(field_id, last_id, limit)
52
+ TypedEAV::Value
53
+ .where(field_id: field_id)
54
+ .where("id > ?", last_id)
55
+ .order(:id)
56
+ .limit(limit)
57
+ .lock
58
+ end
59
+ private_class_method :locked_values
60
+
61
+ def finalize!(field, batch_size)
62
+ field.class.transaction do
63
+ field.lock!
64
+ values = locked_values(field.id, 0, batch_size + 1).to_a
65
+ raise "field deletion residual drain exceeded batch_size" if values.size > batch_size
66
+
67
+ values.each(&:destroy!)
68
+ raise "field deletion residual proof failed" if TypedEAV::Value.exists?(field_id: field.id)
69
+
70
+ field.destroy!
71
+ end
72
+ end
73
+ private_class_method :finalize!
74
+ end
75
+ end
@@ -147,9 +147,7 @@ module TypedEAV
147
147
  # field def has a non-NULL value for it. Union the non-missing
148
148
  # entity_ids across all per-tenant field defs, then complement at
149
149
  # the host level. ADR-0006.
150
- non_missing_ids = fields.flat_map do |f|
151
- TypedEAV::QueryBuilder.entity_ids(f, :is_not_null, nil).pluck(:entity_id)
152
- end.uniq
150
+ non_missing_ids = union_entity_ids(fields, :is_not_null, nil)
153
151
  query.where.not(id: non_missing_ids)
154
152
  else
155
153
  union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
@@ -168,11 +166,15 @@ module TypedEAV
168
166
  end
169
167
 
170
168
  # OR-across all field_ids that share the same name (across tenants),
171
- # while preserving AND between filters via the chained `.where`. Use the
172
- # underlying Value scope (`.filter`) and `pluck(:entity_id)` to collapse
173
- # to a plain integer array we can union across tenants.
169
+ # while preserving AND between filters via the chained `.where`. Keep the
170
+ # union as a composable entity-id subquery so matching IDs never need to
171
+ # be materialized in Ruby.
174
172
  def union_entity_ids(fields, operator, value)
175
- fields.flat_map { |f| TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id) }.uniq
173
+ fields
174
+ .map { |field| TypedEAV::QueryBuilder.filter(field, operator, value) }
175
+ .reduce { |union, relation| union.or(relation) }
176
+ .distinct
177
+ .select(:entity_id)
176
178
  end
177
179
 
178
180
  def parse_filter(filter)
@@ -166,13 +166,14 @@ module TypedEAV
166
166
  typed_values.filter_map { |tv| tv.field_id || tv.field&.id }
167
167
  end
168
168
 
169
- # Selects the candidate value for `typed_eav_value`. On a collision,
170
- # prefer the row attached to the winning field_id; otherwise fall back
171
- # to the first orphan/non-collision candidate.
169
+ # Selects the candidate value for `typed_eav_value`. When a definition
170
+ # wins for the current partition, only a row attached to that field_id
171
+ # may surface. Retained rows from a host's former partition must not be
172
+ # used as a fallback when the current winner has no value yet.
172
173
  def select_winning_value(candidates, winning)
173
174
  return candidates.first unless winning
174
175
 
175
- candidates.detect { |v| (v.field_id || v.field&.id) == winning.id } || candidates.first
176
+ candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
176
177
  end
177
178
 
178
179
  # Hash-builder helper for `typed_eav_hash`. When a winner is registered
@@ -234,10 +235,12 @@ module TypedEAV
234
235
  # fast without forcing that contract.
235
236
  def loaded_typed_values_with_fields
236
237
  if typed_values.loaded?
237
- # Don't re-query if the caller already preloaded; ensure each value's
238
- # field is materialized (fall back to per-row load if the nested
239
- # `:field` was not preloaded).
240
- typed_values.to_a
238
+ values = typed_values.to_a
239
+ missing_fields = values.reject { |value| value.association(:field).loaded? }
240
+ if missing_fields.any?
241
+ ActiveRecord::Associations::Preloader.new(records: missing_fields, associations: :field).call
242
+ end
243
+ values
241
244
  else
242
245
  typed_values.includes(:field).to_a
243
246
  end
@@ -21,13 +21,18 @@ module TypedEAV
21
21
  # scope-only rows, and full-tuple rows. Passing mode: :all_partitions is
22
22
  # the deliberate admin bypass; it is distinct from `scope: nil`, which
23
23
  # means the global partition only.
24
- def visible_fields(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
24
+ def visible_fields(entity_type: nil, scope: nil, parent_scope: nil, mode: :partition)
25
25
  validate_mode!(mode)
26
- return TypedEAV::Field::Base.where(entity_type: entity_type) if mode == :all_partitions
26
+ fields = TypedEAV::Field::Base.all
27
+ fields = fields.where(entity_type: entity_type) if entity_type
28
+ return fields if mode == :all_partitions
27
29
 
28
30
  raise ArgumentError, ORPHAN_PARENT_MESSAGE unless ScopeTuple.invariant_satisfied?(scope, parent_scope)
29
31
 
30
- TypedEAV::Field::Base.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
32
+ fields.where(
33
+ scope: [scope, nil].uniq,
34
+ parent_scope: [parent_scope, nil].uniq,
35
+ )
31
36
  end
32
37
 
33
38
  # One visible field per name after collision resolution. Most-specific
@@ -3,16 +3,10 @@
3
3
  module TypedEAV
4
4
  # Replaces the per-type Finder class hierarchy from active_fields.
5
5
  #
6
- # Because values live in native typed columns, ActiveRecord already knows
7
- # the column types from the schema. Arel predicates (eq, gt, lt, matches, etc.)
8
- # automatically go through the column's ActiveRecord::Type for casting.
9
- #
10
- # This means:
11
- # where(integer_value: "42") -> Rails casts "42" to 42 automatically
12
- # arel[:date_value].gt(value) -> Rails casts string dates to Date objects
13
- #
14
- # No manual CAST() calls. No per-type caster classes for queries.
15
- # One module handles all field types.
6
+ # Field owns operand casting and operator validation before this layer builds
7
+ # Arel. Active Record supplies bind plumbing for the already-normalized
8
+ # typed value; no manual SQL CAST() calls or per-type query caster classes
9
+ # are needed here.
16
10
  #
17
11
  # Usage:
18
12
  # QueryBuilder.filter(field, :gt, 42)
@@ -53,6 +47,8 @@ module TypedEAV
53
47
  arel_col = values_table[col]
54
48
 
55
49
  base = value_scope(field)
50
+ excluded = %i[is_null is_not_null contains not_contains starts_with ends_with]
51
+ operand = field.cast_query_operand(operator, value) unless excluded.include?(operator)
56
52
 
57
53
  case operator
58
54
  when :eq, :currency_eq
@@ -64,9 +60,9 @@ module TypedEAV
64
60
  # dispatch resolved correctly. The operator-validation gate at
65
61
  # the top of #filter still narrows :currency_eq to Field::Currency
66
62
  # only — no other field type accepts it.
67
- eq_predicate(base, arel_col, col, value)
63
+ eq_predicate(base, arel_col, col, operand)
68
64
  when :not_eq
69
- not_eq_predicate(base, arel_col, col, value)
65
+ not_eq_predicate(base, arel_col, col, operand)
70
66
  when :references
71
67
  # Phase 5 Reference field. `value` may be an Integer FK OR an
72
68
  # AR record instance — `field.cast` normalizes both to an
@@ -79,27 +75,21 @@ module TypedEAV
79
75
  # The :references operator is registered ONLY on Field::Reference
80
76
  # (the operator-validation gate above keeps it from leaking to
81
77
  # other types).
82
- fk, invalid = field.cast(value)
83
- if invalid || fk.nil?
78
+ if operand.nil?
84
79
  base.none
85
80
  else
86
- base.where(arel_col.eq(fk))
81
+ base.where(arel_col.eq(operand))
87
82
  end
88
83
  when :gt
89
- base.where(arel_col.gt(value))
84
+ base.where(arel_col.gt(operand))
90
85
  when :gteq
91
- base.where(arel_col.gteq(value))
86
+ base.where(arel_col.gteq(operand))
92
87
  when :lt
93
- base.where(arel_col.lt(value))
88
+ base.where(arel_col.lt(operand))
94
89
  when :lteq
95
- base.where(arel_col.lteq(value))
90
+ base.where(arel_col.lteq(operand))
96
91
  when :between
97
- unless value.respond_to?(:first) && value.respond_to?(:last)
98
- raise ArgumentError,
99
- ":between expects a Range or two-element Array"
100
- end
101
-
102
- base.where(arel_col.between(value.first..value.last))
92
+ base.where(arel_col.between(operand))
103
93
  when :contains
104
94
  base.where(arel_col.matches("%#{sanitize_like(value)}%"))
105
95
  when :not_contains
@@ -114,10 +104,10 @@ module TypedEAV
114
104
  base.where.not(col => nil)
115
105
  when :any_eq
116
106
  # For json_value arrays: contains the given element
117
- base.where("#{col} @> ?", [value].to_json)
107
+ base.where("#{col} @> ?", [operand].to_json)
118
108
  when :all_eq
119
109
  # For json_value arrays: contains all given elements
120
- base.where("#{col} @> ?", Array(value).to_json)
110
+ base.where("#{col} @> ?", operand.to_json)
121
111
  else
122
112
  raise ArgumentError, "Unhandled operator: #{operator}"
123
113
  end
@@ -25,12 +25,11 @@ module TypedEAV
25
25
  # Register an entity type with optional type restrictions and optional
26
26
  # versioning opt-in.
27
27
  #
28
- # `versioned:` is the per-entity Phase 04 opt-in flag. When true, AND
29
- # `Config.versioning = true` at engine load (gem-level master switch),
30
- # the Phase 04 subscriber writes a TypedEAV::ValueVersion row per
31
- # Value mutation on this entity_type. Default false — apps not using
32
- # versioning pay zero cost (one Hash#dig per write at most when
33
- # `Config.versioning = true`, nothing when off).
28
+ # `versioned:` is the per-entity opt-in flag. When true and the
29
+ # boot-latched transactional callbacks are installed, the version
30
+ # writer records a TypedEAV::ValueVersion row per Value mutation on
31
+ # this entity_type. Default false — apps not using versioning pay zero
32
+ # version-row work.
34
33
  #
35
34
  # Backward compat: existing callers `register(name, types: types)`
36
35
  # continue to work — the new kwarg defaults to false. The entry hash
@@ -69,8 +68,8 @@ module TypedEAV
69
68
  # Returns the stored boolean for opted-in entities; false for
70
69
  # unregistered entities (defensive — callers might query before
71
70
  # `has_typed_eav` runs in a particular load order). The Phase 04
72
- # subscriber calls this on every Value write when `Config.versioning =
73
- # true` performance is one Hash#dig per write, negligible.
71
+ # transactional writer calls this only after its boot-latched callback
72
+ # has been installed; this lookup remains a small Hash#dig per write.
74
73
  #
75
74
  # `entities.dig(entity_type, :versioned)` returns nil when
76
75
  # `entities[entity_type]` is missing (no register call) OR when the
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module SchemaPortability
5
+ class ImportIndex
6
+ attr_reader :fields, :sections
7
+
8
+ def initialize(field_entries, section_entries)
9
+ @fields = preload_fields(field_entries)
10
+ @sections = preload_sections(section_entries)
11
+ end
12
+
13
+ def field_identity(field_or_entry)
14
+ identity(field_or_entry, :name)
15
+ end
16
+
17
+ def section_identity(section_or_entry)
18
+ identity(section_or_entry, :code)
19
+ end
20
+
21
+ private
22
+
23
+ def preload_fields(entries)
24
+ return {} if entries.empty?
25
+
26
+ identity_relation(TypedEAV::Field::Base, entries, :name)
27
+ .includes(:field_options)
28
+ .index_by { |field| field_identity(field) }
29
+ end
30
+
31
+ def preload_sections(entries)
32
+ return {} if entries.empty?
33
+
34
+ identity_relation(TypedEAV::Section, entries, :code)
35
+ .index_by { |section| section_identity(section) }
36
+ end
37
+
38
+ def identity_relation(model, entries, key)
39
+ relations = entries.uniq { |entry| identity(entry, key) }.map do |entry|
40
+ model.where(
41
+ key => entry[key.to_s],
42
+ entity_type: entry["entity_type"],
43
+ scope: entry["scope"],
44
+ parent_scope: entry["parent_scope"],
45
+ )
46
+ end
47
+ relations.reduce { |union, relation| union.or(relation) }
48
+ end
49
+
50
+ def identity(record_or_entry, key)
51
+ attributes = [key, :entity_type, :scope, :parent_scope]
52
+ return attributes.map { |attribute| record_or_entry[attribute.to_s] } if record_or_entry.is_a?(Hash)
53
+
54
+ attributes.map { |attribute| record_or_entry.public_send(attribute) }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "schema_portability/import_index"
4
+
3
5
  module TypedEAV
4
6
  # Export and import field + section definitions for an exact partition
5
7
  # tuple. Value rows are intentionally out of scope.
@@ -89,14 +91,18 @@ module TypedEAV
89
91
  validate_conflict_policy!(on_conflict)
90
92
 
91
93
  result = { "created" => 0, "updated" => 0, "skipped" => 0, "unchanged" => 0, "errors" => [] }
94
+ field_entries = Array(hash["fields"])
95
+ section_entries = Array(hash["sections"])
92
96
 
93
97
  TypedEAV::Field::Base.transaction do
94
- Array(hash["fields"]).each do |entry|
95
- import_field_entry(entry, on_conflict, result)
98
+ import_index = ImportIndex.new(field_entries, section_entries)
99
+
100
+ field_entries.each do |entry|
101
+ import_field_entry(entry, on_conflict, result, import_index)
96
102
  end
97
103
 
98
- Array(hash["sections"]).each do |entry|
99
- import_section_entry(entry, on_conflict, result)
104
+ section_entries.each do |entry|
105
+ import_section_entry(entry, on_conflict, result, import_index)
100
106
  end
101
107
  end
102
108
 
@@ -105,7 +111,7 @@ module TypedEAV
105
111
 
106
112
  private
107
113
 
108
- # rubocop:disable Metrics/AbcSize -- flat projection is the canonical field export shape.
114
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- flat projection is the canonical field export shape.
109
115
  def export_field_entry(field)
110
116
  entry = {
111
117
  "name" => field.name,
@@ -141,7 +147,7 @@ module TypedEAV
141
147
 
142
148
  entry
143
149
  end
144
- # rubocop:enable Metrics/AbcSize
150
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
145
151
 
146
152
  # Lean per-field projection used by {.export_snapshot_schema}. Mirrors
147
153
  # the option-row ordering rule from {.export_field_entry} — sort
@@ -207,13 +213,9 @@ module TypedEAV
207
213
  "Supported: #{valid_policies.map { |policy| ":#{policy}" }.join(", ")}."
208
214
  end
209
215
 
210
- def import_field_entry(entry, on_conflict, result)
211
- existing = TypedEAV::Field::Base.find_by(
212
- name: entry["name"],
213
- entity_type: entry["entity_type"],
214
- scope: entry["scope"],
215
- parent_scope: entry["parent_scope"],
216
- )
216
+ def import_field_entry(entry, on_conflict, result, import_index)
217
+ identity = import_index.field_identity(entry)
218
+ existing = import_index.fields[identity]
217
219
 
218
220
  if existing
219
221
  reject_type_swap!(existing, entry)
@@ -233,7 +235,7 @@ module TypedEAV
233
235
  result["updated"] += 1
234
236
  end
235
237
  else
236
- create_field!(entry)
238
+ import_index.fields[identity] = create_field!(entry)
237
239
  result["created"] += 1
238
240
  end
239
241
  end
@@ -282,7 +284,7 @@ module TypedEAV
282
284
 
283
285
  def create_field!(entry)
284
286
  field = TypedEAV::Field::Base.create!(entry.except("options_data"))
285
- return unless field.optionable?
287
+ return field unless field.optionable?
286
288
 
287
289
  Array(entry["options_data"]).each do |option|
288
290
  field.field_options.create!(
@@ -291,16 +293,12 @@ module TypedEAV
291
293
  sort_order: option["sort_order"],
292
294
  )
293
295
  end
296
+ field
294
297
  end
295
298
 
296
- # rubocop:disable Metrics/MethodLength -- mirrors field import for section rows without option replacement.
297
- def import_section_entry(entry, on_conflict, result)
298
- existing = TypedEAV::Section.find_by(
299
- code: entry["code"],
300
- entity_type: entry["entity_type"],
301
- scope: entry["scope"],
302
- parent_scope: entry["parent_scope"],
303
- )
299
+ def import_section_entry(entry, on_conflict, result, import_index)
300
+ identity = import_index.section_identity(entry)
301
+ existing = import_index.sections[identity]
304
302
 
305
303
  if existing
306
304
  if section_export_row_equal?(existing, entry)
@@ -322,11 +320,10 @@ module TypedEAV
322
320
  result["updated"] += 1
323
321
  end
324
322
  else
325
- TypedEAV::Section.create!(entry)
323
+ import_index.sections[identity] = TypedEAV::Section.create!(entry)
326
324
  result["created"] += 1
327
325
  end
328
326
  end
329
- # rubocop:enable Metrics/MethodLength
330
327
 
331
328
  def raise_divergent_section!(entry)
332
329
  raise ArgumentError,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -2,15 +2,15 @@
2
2
 
3
3
  module TypedEAV
4
4
  module Versioning
5
- # The Phase 04 internal subscriber. Conditionally registered with
6
- # EventDispatcher.register_internal_value_change at engine boot via
7
- # `TypedEAV::Versioning.register_if_enabled`. When registered, runs
8
- # at slot 0 of the value-change subscriber chain.
5
+ # The transactional version writer. Conditionally installed on Value's
6
+ # create/update `after` and destroy `before` callbacks at engine boot via
7
+ # `TypedEAV::Versioning.register_if_enabled`; every write is therefore
8
+ # part of the source transaction.
9
9
  #
10
10
  # ## Contract
11
11
  #
12
- # `call(value, change_type, context)` — called by EventDispatcher.
13
- # Returns nil (return value is ignored by EventDispatcher; the
12
+ # `call(value, change_type, context)` — called by Value's lifecycle
13
+ # callback. Returns nil (the return value is ignored; the
14
14
  # method's job is the side effect of writing a ValueVersion row).
15
15
  #
16
16
  # Two-gate short-circuit (the master switch is enforced at
@@ -54,16 +54,13 @@ module TypedEAV
54
54
  # nil").
55
55
  module Subscriber
56
56
  class << self
57
- # Public entry point. EventDispatcher calls this with the locked
58
- # 3-arg signature `(value, change_type, context)`.
57
+ # Public entry point. Value's transactional callbacks call this with
58
+ # the locked 3-arg signature `(value, change_type, context)`.
59
59
  #
60
- # NOTE: there is NO `Config.versioning` gate here. The subscriber
61
- # is only registered with EventDispatcher when `Config.versioning`
62
- # was true at engine `config.after_initialize` time (see
63
- # `TypedEAV::Versioning.register_if_enabled`, invoked from
64
- # lib/typed_eav/engine.rb's `config.after_initialize` block). If
65
- # versioning is off, the subscriber is never registered and never
66
- # reached. The remaining gates are:
60
+ # NOTE: there is NO `Config.versioning` gate here. The subscriber is
61
+ # installed only when `Config.versioning` was true at engine
62
+ # `config.after_initialize` time. If versioning is off, the callback
63
+ # is never installed and this method is never reached. The remaining gates are:
67
64
  # 1. field-presence (orphan guard — Value's field_id may have
68
65
  # been NULLed by Phase 02's ON DELETE SET NULL cascade).
69
66
  # 2. per-entity opt-in (Registry.versioned?).
@@ -72,6 +69,8 @@ module TypedEAV
72
69
  return unless TypedEAV.registry.versioned?(value.entity_type)
73
70
 
74
71
  write_version_row(value, change_type, context)
72
+ ensure
73
+ value.consume_pending_version_group_id if value.respond_to?(:consume_pending_version_group_id)
75
74
  end
76
75
 
77
76
  private
@@ -85,15 +84,9 @@ module TypedEAV
85
84
  before_value = field.before_snapshot(value, change_type)
86
85
  after_value = field.after_snapshot(value, change_type)
87
86
 
88
- # CRITICAL: for :destroy events, write `value_id: nil`.
89
- # By the time `after_commit on: :destroy` fires, the parent row
90
- # in `typed_eav_values` has already been deleted (Postgres
91
- # commits the DELETE before invoking after_commit callbacks).
92
- # The FK is `ON DELETE SET NULL`, but at the moment we INSERT
93
- # the version row, Postgres validates the FK against the
94
- # current state of typed_eav_values — which no longer contains
95
- # the parent. Writing `value.id` (still readable in-memory on
96
- # the destroyed AR record) would FK-fail at INSERT.
87
+ # CRITICAL: for :destroy events, write `value_id: nil`. The row is
88
+ # written before the source DELETE and must not retain an identity
89
+ # that the FK will null when the Value is removed.
97
90
  #
98
91
  # The audit trail for destroy events stays queryable via:
99
92
  # - entity_type + entity_id (host record identity)
@@ -102,8 +95,7 @@ module TypedEAV
102
95
  # `field_id` remains populated because destroying a Value does
103
96
  # not destroy its Field — `value.field_id` is a live reference.
104
97
  #
105
- # For :create and :update events, `value_id: value.id` is
106
- # correct (parent row exists at after_commit time).
98
+ # For :create and :update, `value_id: value.id` is correct.
107
99
  version_value_id = change_type == :destroy ? nil : value.id
108
100
 
109
101
  # Phase 06 bulk-operations correlation tag. Two delivery paths:
@@ -113,7 +105,7 @@ module TypedEAV
113
105
  # on each affected Value object BEFORE `record.save`, INSIDE
114
106
  # the per-record `with_context` block. Reading the snapshot
115
107
  # here (not `current_context`) guarantees the UUID survives
116
- # the outer-transaction `after_commit` boundary — by the time
108
+ # the source transaction boundary — by the time
117
109
  # we run, the lexical `with_context` block has unwound and
118
110
  # `current_context` would be empty, but the per-Value ivar
119
111
  # persists. Mirrors the existing in-memory ivar pattern at
@@ -127,12 +119,16 @@ module TypedEAV
127
119
  # request id). For those paths the Value ivar is unset; the
128
120
  # `||` falls through to `context[:version_group_id]`. Also
129
121
  # used as the belt-and-suspenders fallback for any future
130
- # after_commit-inside-savepoint dispatch path that bulk writes
122
+ # savepoint path that bulk writes
131
123
  # might leverage.
132
124
  #
133
125
  # When neither path supplies a UUID the column (added in
134
126
  # `db/migrate/20260506000001`) stays NULL — backward-compatible:
135
127
  # unchanged subscribers and unchanged callers continue to work.
128
+ pending_group_id = if value.respond_to?(:consume_pending_version_group_id)
129
+ value.consume_pending_version_group_id
130
+ end
131
+
136
132
  TypedEAV::ValueVersion.create!(
137
133
  value_id: version_value_id,
138
134
  field_id: value.field_id,
@@ -142,7 +138,7 @@ module TypedEAV
142
138
  before_value: before_value,
143
139
  after_value: after_value,
144
140
  context: context.to_h, # frozen → unfrozen jsonb-serializable hash
145
- version_group_id: value.pending_version_group_id || context[:version_group_id],
141
+ version_group_id: pending_group_id || context[:version_group_id],
146
142
  change_type: change_type.to_s,
147
143
  changed_at: Time.current,
148
144
  )