typed_eav 0.6.0 → 0.7.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +187 -0
  3. data/README.md +258 -62
  4. data/app/models/typed_eav/field/base.rb +77 -27
  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/section.rb +11 -5
  10. data/app/models/typed_eav/value.rb +95 -32
  11. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
  12. data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +4 -0
  13. data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
  14. data/lib/typed_eav/bulk_read.rb +13 -14
  15. data/lib/typed_eav/bulk_upsert.rb +137 -0
  16. data/lib/typed_eav/bulk_write.rb +65 -39
  17. data/lib/typed_eav/config.rb +19 -21
  18. data/lib/typed_eav/csv_mapper.rb +1 -1
  19. data/lib/typed_eav/engine.rb +16 -27
  20. data/lib/typed_eav/entity_query.rb +34 -9
  21. data/lib/typed_eav/event_dispatcher.rb +21 -30
  22. data/lib/typed_eav/field/typed_storage.rb +48 -0
  23. data/lib/typed_eav/field_deletion.rb +75 -0
  24. data/lib/typed_eav/filter_query.rb +2 -2
  25. data/lib/typed_eav/has_typed_eav/instance_methods.rb +2 -2
  26. data/lib/typed_eav/has_typed_eav.rb +1 -1
  27. data/lib/typed_eav/partition/definition_batch.rb +70 -0
  28. data/lib/typed_eav/partition.rb +2 -0
  29. data/lib/typed_eav/query_builder.rb +17 -27
  30. data/lib/typed_eav/registry.rb +7 -8
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioned.rb +8 -6
  33. data/lib/typed_eav/versioning/subscriber.rb +25 -29
  34. data/lib/typed_eav/versioning.rb +59 -41
  35. data/lib/typed_eav.rb +2 -0
  36. metadata +5 -1
data/README.md CHANGED
@@ -2,22 +2,17 @@
2
2
 
3
3
  Add dynamic custom fields to ActiveRecord models at runtime, backed by **native database typed columns** instead of jsonb blobs.
4
4
 
5
- TypedEAV uses a hybrid EAV (Entity-Attribute-Value) pattern where each value type gets its own column (`integer_value`, `date_value`, `string_value`, etc.) in the values table. This means the database can natively index, sort, and enforce constraints on your custom field data with zero runtime type casting.
5
+ TypedEAV uses a hybrid EAV (Entity-Attribute-Value) pattern where each value type gets its own column (`integer_value`, `date_value`, `string_value`, etc.) in the values table. This lets PostgreSQL index, sort, and enforce constraints on custom field data while the Field owns input normalization and validation.
6
6
 
7
7
  ## Why Typed Columns?
8
8
 
9
- Most Rails custom field gems serialize everything into a single `jsonb` column. When you query, they generate SQL like:
9
+ JSONB is a useful fit when an application owns stable paths and wants expression B-tree indexes or GIN containment indexes. A single JSONB document is not inherently faster or slower than TypedEAV; the right choice depends on access patterns, selectivity, update shape, and operational constraints. For example, an application-owned expression index may support a stable path:
10
10
 
11
11
  ```sql
12
12
  CAST(value_meta->>'const' AS bigint) = 42
13
13
  ```
14
14
 
15
- This works, but:
16
-
17
- - **No B-tree indexes** on the actual values (only GIN for jsonb containment)
18
- - **Runtime CAST overhead** on every query
19
- - **No database-level type enforcement** (a "number" could be stored as a string)
20
- - **The query planner can't optimize** range scans, sorts, or joins
15
+ This can work well for stable, known paths. It does not provide the same column-level schema and typed-value contract as TypedEAV, and arbitrary paths still require application-owned index and validation decisions. GIN is useful for containment workloads; expression B-trees are useful for selected stable scalar paths.
21
16
 
22
17
  TypedEAV stores values in native columns, so queries become:
23
18
 
@@ -25,7 +20,7 @@ TypedEAV stores values in native columns, so queries become:
25
20
  WHERE integer_value = 42
26
21
  ```
27
22
 
28
- Standard B-tree indexes work. Range scans work. The query planner is happy. ActiveRecord handles all type casting automatically through the column's registered type.
23
+ TypedEAV supplies stable typed columns and ordinary per-type indexes. Range scans and sorts can use those indexes, while each Field casts and validates the operand according to its own semantics before the query reaches the typed column. Neither design is a universal storage winner; choose from measured workload fit.
29
24
 
30
25
  ## Compatibility
31
26
 
@@ -124,6 +119,11 @@ tags.field_options.create!([
124
119
  ])
125
120
  ```
126
121
 
122
+ When deriving `entity_type` from a model class, use
123
+ `Contact.polymorphic_name`. Rails stores polymorphic associations under that
124
+ canonical name, which is the base-class type for STI hosts and respects the
125
+ application's namespaced-polymorphism setting.
126
+
127
127
  ### 3. Set values on records
128
128
 
129
129
  ```ruby
@@ -157,7 +157,7 @@ contact.typed_eav_hash # => { "age" => 40, "status" => "active", ..
157
157
 
158
158
  ### 4. Query with the DSL
159
159
 
160
- This is where typed columns pay off. All queries go through native columns with proper indexes.
160
+ Queries use native typed columns and the indexes shipped for each type. The Field remains the owner of operand casting and validation; the query builder receives a field-normalized value rather than applying a generic Active Record cast.
161
161
 
162
162
  ```ruby
163
163
  # Short form - single field filter
@@ -217,25 +217,108 @@ Contact.where(company_id: 42)
217
217
  | `:is_null` | all | Value is NULL |
218
218
  | `:is_not_null` | all | Value is not NULL |
219
219
 
220
+ ### Optional trigram indexing for string search
221
+
222
+ TypedEAV keeps its partial-covering `text_pattern_ops` B-tree as the default
223
+ string index. Equality uses that B-tree, while `:starts_with`, `:contains`, and
224
+ `:ends_with` use `ILIKE`; `:not_contains` uses `NOT ILIKE`. The gem does not
225
+ require or install `pg_trgm` and does not create a trigram index automatically.
226
+
227
+ An application with frequent positive `ILIKE` searches containing at least
228
+ three useful characters may evaluate its own partial GIN index. This is a
229
+ workload decision: the representative benchmark used GIN for measured prefix,
230
+ contains, suffix, and escaped-literal patterns, but not for `NOT ILIKE` or
231
+ one/two-character probes. It does not prove that every positive pattern or
232
+ selectivity will benefit. A `lower(string_value) LIKE ...` expression index is
233
+ not equivalent to TypedEAV's public `ILIKE`, and the benchmark did not justify
234
+ GiST.
235
+
236
+ Application owners should check extension availability and deploy-role
237
+ privileges in preproduction, then create the extension and index in their own
238
+ migrations. Use nontransactional `CREATE INDEX CONCURRENTLY`, a stable
239
+ application-specific name, and workload-specific `EXPLAIN (ANALYZE, BUFFERS,
240
+ WAL, SETTINGS)` plus storage and write-WAL measurements. Rollback should drop
241
+ only the application-owned index concurrently; do not drop the database-wide
242
+ extension because other objects may share it. See
243
+ [ADR 0009](docs/adr/0009-string-search-indexing.md) and the
244
+ [benchmark guide](bench/README.md#phase-3-string-search-benchmark) for the
245
+ operator matrix, measured costs, SQL, and evidence limits.
246
+
247
+ ### Optional planner statistics for correlated field/value predicates
248
+
249
+ TypedEAV does not install PostgreSQL extended-statistics objects. An application
250
+ whose own plans persistently misestimate `field_id = ... AND typed_value = ...`
251
+ may evaluate application-owned `dependencies` statistics for that exact typed
252
+ column. Dependency statistics apply to compatible equality and `IN` clauses,
253
+ not range predicates. `mcv` describes common value combinations, while
254
+ `ndistinct` primarily informs distinct-group estimates; neither should be added
255
+ without workload evidence.
256
+
257
+ The representative PostgreSQL 17 benchmark found better aggregate equality
258
+ estimates from dependencies, but no plan-shape or demonstrated runtime benefit.
259
+ Its combined object mirrored MCV on the four changed probes because matching MCV
260
+ groups supplied those estimates. The experiment's target of 100 was a controlled
261
+ input, not a universal recommendation. One probe labeled common-date equality
262
+ actually queried an absent date and returned zero rows; it is not evidence about
263
+ common-date estimates.
264
+
265
+ Applications should own stable names and DDL, select targets from representative
266
+ data, run `ANALYZE`, and compare estimated/actual rows, plans, runtime, planning
267
+ cost, maintenance cost, and data churn before retaining an object. Coordinate
268
+ ownership in shared databases, inspect catalog definitions before changing
269
+ objects, and drop only application-owned statistics during rollback. See
270
+ [ADR 0010](docs/adr/0010-planner-statistics-policy.md) and the
271
+ [benchmark guide](bench/README.md#phase-4a-planner-extended-statistics) for safe
272
+ evaluation SQL and evidence limits.
273
+
274
+ ### Multi-filter query strategy
275
+
276
+ TypedEAV retains its current multi-filter query shape: it resolves each field,
277
+ builds the corresponding typed value subquery, and chains those results onto
278
+ the host relation with `id IN (...)`. There is no adaptive strategy or alternate
279
+ production query API.
280
+
281
+ A PostgreSQL 17 benchmark compared the shipped shape with `INTERSECT`,
282
+ correlated `EXISTS`, and direct grouped `HAVING` under resource-capped
283
+ co-tenancy. The run retained 2,940 attempts, including 622 right-censored
284
+ timeouts, and 294 representative identity oracles. Twelve oracles timed out, so
285
+ representative equivalence is unproved even though all 282 completed oracles
286
+ matched and the smaller 98-oracle smoke matched. Alternatives remain
287
+ research-only. Grouped `HAVING` is additionally ineligible for missing-value,
288
+ host-universe complement, and empty-filter semantics.
289
+
290
+ The result also does not establish valid buffer comparisons or 20-distinct-
291
+ field scaling. A parser defect made every derived buffer total a false zero;
292
+ nonzero counters remain recoverable from the retained raw plans. The
293
+ 20-predicate workloads repeat ten fields, and the skewed 10/20 workloads repeat
294
+ five. Future research must repair and validate buffer extraction, exercise
295
+ actual 10/20 distinct fields, complete every representative equivalence oracle,
296
+ cover the full scope/NULL/missing/polymorphic/error contract, and show the
297
+ pre-registered p95, planning-time, buffer, and plan-shape gates before any
298
+ adaptive or replacement proposal. See
299
+ [ADR 0011](docs/adr/0011-multi-filter-query-strategy.md) and the
300
+ [benchmark guide](bench/README.md#phase-4b-multi-filter-query-shapes).
301
+
220
302
  ### How Type Inference Works
221
303
 
222
- You don't need to think about types when querying. Rails handles it:
304
+ The owning Field casts and validates query operands before SQL generation;
305
+ Active Record supplies the SQL bind plumbing:
223
306
 
224
307
  ```ruby
225
- # You pass a string, Rails casts to integer via the column type
308
+ # The Integer Field casts and validates the operand before SQL generation
226
309
  Contact.with_field("age", :gt, "21")
227
310
  # SQL: WHERE integer_value > 21 (not '21')
228
311
 
229
- # You pass a string, Rails casts to date
312
+ # The Date Field owns date parsing and validation
230
313
  Contact.with_field("birthday", :lt, "2000-01-01")
231
314
  # SQL: WHERE date_value < '2000-01-01'::date
232
315
 
233
- # Boolean columns handle truthy/falsy casting
316
+ # The Boolean Field owns truthy/falsy casting
234
317
  Contact.with_field("active", "true")
235
318
  # SQL: WHERE boolean_value = TRUE
236
319
  ```
237
320
 
238
- This works because `ActiveRecord::Base.columns_hash` knows every column's type from the schema, and `where()` / Arel predicates automatically cast values through the column's registered `ActiveRecord::Type`.
321
+ Field-owned casting keeps query operands aligned with write semantics, including strict range/array shapes and specialized fields such as Currency and Reference. The resulting normalized operand is bound against the Field's typed column; Active Record supplies SQL bind plumbing, not the field's domain semantics.
239
322
 
240
323
  ## Forms
241
324
 
@@ -373,6 +456,16 @@ end
373
456
 
374
457
  Both are exception-safe via `ensure` and nest cleanly.
375
458
 
459
+ `unscoped` is an explicit administrative/analytics escape hatch, not the
460
+ ordinary tenant request path. It keeps every same-name definition across the
461
+ visible partitions and unions their matches for each filter. For broad audits
462
+ or migrations, bound the definition universe to the work you actually need and
463
+ batch the job at an application-owned boundary. TypedEAV does not prescribe a
464
+ universal limit or batch size; measure generated SQL, planning/execution,
465
+ memory, and workload interference in your application. Keep normal request
466
+ traffic on scoped resolution so global, scope-only, and full-tuple definitions
467
+ collapse to the most-specific match.
468
+
376
469
  ### Explicit `scope:` override
377
470
 
378
471
  Any query method accepts `scope:` as an override for admin tools and tests:
@@ -487,12 +580,26 @@ within one workspace" has no semantic resolution path; the row would never
487
580
  match any record's resolver. The paired partial unique indexes rely on this
488
581
  invariant.
489
582
 
583
+ The shipped migration chain also includes
584
+ `EnforceParentScopeInvariant`, which declares the database check constraints
585
+ nontransactionally and validates them after its preflight, and
586
+ `UsePartialCoveringScalarIndexes`, which creates the six `*_present` indexes
587
+ before removing their legacy counterparts. Both migrations use
588
+ `disable_ddl_transaction!`; run them through the normal migration command and
589
+ do not wrap them in an application transaction.
590
+
490
591
  ### Name collisions across scopes
491
592
 
492
593
  When both a global field (`scope: nil`) and a scoped field share a name, the **scoped definition wins** for the partition that owns it: forms render exactly one input (the scoped one), reads return the scoped value, and writes target the scoped row.
493
594
 
494
595
  `TypedEAV.unscoped { Contact.where_typed_eav(...) }` OR-across every partition's matching `field_id` per filter (still AND-ing across filters), so cross-tenant audit queries see every partition's matches — they don't collapse to a single tenant.
495
596
 
597
+ Because that administrative path constructs work for every matching
598
+ definition, applications should narrow and batch high-cardinality audits rather
599
+ than treating `unscoped` as tenant-request routing. No built-in numeric
600
+ threshold is implied; choose operational bounds from measurements of the
601
+ consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-query-policy.md).
602
+
496
603
  ## Field Types
497
604
 
498
605
  | Type | Column | Ruby Type | Options |
@@ -785,7 +892,7 @@ exactly three method overrides.
785
892
 
786
893
  - **`File`:** Same shape as `Field::Image` but without image-specific semantics. Stores `signed_id` in `string_value`; same operator set; same options (`allowed_content_types`, `max_size_bytes`). The Image vs File distinction is by `value.field.class` at runtime — apps that want strict image-only validation set `allowed_content_types: ["image/*"]` on `Field::Image`; `Field::File` is a general-purpose attachment slot.
787
894
 
788
- - **Active Storage dependency:** Lazy soft-detect via `defined?(::ActiveStorage::Blob)`. The gem does NOT add Active Storage as a hard dependency — apps that never use Image/File never need to install it. To use Image or File fields, add `gem "activestorage"` to your Gemfile (already included in Rails 7.1+ via the `rails` meta-gem) and run `bin/rails active_storage:install` to create the `active_storage_blobs` / `active_storage_attachments` / `active_storage_variant_records` tables. The mirror precedent is `acts_as_tenant`, which is also soft-detected (see `Config::DEFAULT_SCOPE_RESOLVER`).
895
+ - **Active Storage dependency:** Lazy soft-detect via `defined?(::ActiveStorage::Blob)`. The gem does NOT add Active Storage as a hard dependency — apps that never use Image/File never need to install it. To use Image or File fields, add `gem "activestorage"` to your Gemfile (included in supported Rails versions via the `rails` meta-gem) and run `bin/rails active_storage:install` to create the `active_storage_blobs` / `active_storage_attachments` / `active_storage_variant_records` tables. The mirror precedent is `acts_as_tenant`, which is also soft-detected (see `Config::DEFAULT_SCOPE_RESOLVER`).
789
896
 
790
897
  - **`on_image_attached` hook:** Fires from `after_commit` on `TypedEAV::Value` when a `Field::Image`-typed Value's attachment is added or replaced. Receives `(value, blob)`. Configure via `TypedEAV.configure { |c| c.on_image_attached = ->(v, b) { ... } }`. Hook ordering: runs AFTER versioning (Phase 4) and AFTER `on_value_change` (Phase 3) so it sees the persisted version row and the user-callback context. File attachments do NOT fire this hook — the name is image-specific by design. Use `on_value_change` for a generic value-mutation signal that covers File-typed Values too.
791
898
 
@@ -854,8 +961,8 @@ end
854
961
  The `:rename` change_type fires whenever the field's `name` column changed
855
962
  in the just-committed save, even when bundled with other attribute changes
856
963
  (options, sort_order, default_value, etc.). The detection is intentionally
857
- escalating Phase 7's materialized index needs to regenerate column DDL on
858
- every rename.
964
+ escalating so any registered consumer receives a rename event whenever the
965
+ persisted name changes.
859
966
 
860
967
  `:update` on Value fires only when the typed value column changed. Saving
861
968
  a Value record without modifying its typed column (e.g., touching only
@@ -867,6 +974,17 @@ bypasses AR callbacks. Only the Field `:destroy` event fires. Use
867
974
  `field_dependent: :destroy` if your consumer needs per-Value events on
868
975
  field deletion.
869
976
 
977
+ For a persisted `field_dependent: :destroy` field with a large population,
978
+ call `field.destroy_with_values_in_batches!(batch_size: 1_000)` outside an
979
+ open transaction. The opt-in API selects only that exact `field_id` in ordered
980
+ primary-key batches, calls `Value#destroy!` for callback/version behavior, and
981
+ commits each batch independently. A retry resumes from the remaining rows. The
982
+ Field is retained until a locked, bounded residual drain proves zero rows, then
983
+ its ordinary callback-preserving `destroy!` runs. The API rejects unsaved or
984
+ non-destroy fields, open transactions, invalid batch sizes, and mismatched
985
+ connection pools. Existing `destroy`/`destroy!`, `:nullify`, and `:restrict`
986
+ behavior is unchanged.
987
+
870
988
  ### Thread-local context with `with_context`
871
989
 
872
990
  ```ruby
@@ -904,16 +1022,15 @@ when `after_commit` fires; re-raising would surface a misleading
904
1022
  "save failed" error.
905
1023
 
906
1024
  This is the deliberate split with first-party features. Internal
907
- subscribers used by `typed_eav` itself (Phase 4 versioning, Phase 7
908
- materialized index) follow a different rule: their exceptions
909
- **propagate**. Versioning corruption must be loud.
1025
+ observers used by `typed_eav` itself follow a different rule: their exceptions
1026
+ **propagate**. Transactional version-writing errors are separate: they
1027
+ propagate inside and roll back the source transaction.
910
1028
 
911
1029
  ### Ordering guarantee
912
1030
 
913
1031
  When multiple subscribers are registered, they fire in this order:
914
1032
 
915
- 1. First-party internal subscribers (versioning, matview, etc.), in
916
- registration order. Errors propagate.
1033
+ 1. First-party generic observers, in registration order. Errors propagate.
917
1034
  2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
918
1035
  last. Errors are rescued and logged.
919
1036
 
@@ -972,8 +1089,8 @@ values. When enabled, each `:create` / `:update` / `:destroy` event on
972
1089
  a Value writes a row to `typed_eav_value_versions` capturing the
973
1090
  before-state, after-state, actor, context, and timestamp.
974
1091
 
975
- Default off. Apps that don't enable it pay zero overhead — the Phase 04
976
- internal subscriber is not registered with `EventDispatcher.value_change_internals`
1092
+ Default off. Apps that don't enable it pay zero overhead — transactional
1093
+ Value callbacks are not installed at boot
977
1094
  at all when `Config.versioning = false`. Zero callable in the dispatcher
978
1095
  chain, zero per-write method dispatch, zero per-write config read.
979
1096
 
@@ -1034,9 +1151,8 @@ value.history.limit(5).each { |v| ... }
1034
1151
  `Value#history` returns versions where `value_id` matches the live Value
1035
1152
  record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
1036
1153
  nullifies `value_id` on the existing version rows, and the new `:destroy`
1037
- version is also written with `value_id: nil` (the parent
1038
- `typed_eav_values` row is gone by `after_commit on: :destroy` time —
1039
- writing a non-nil `value_id` would FK-fail at INSERT). So `Value#history`
1154
+ version is written by the transactional destroy callback with `value_id: nil`
1155
+ before the parent row is removed. So `Value#history`
1040
1156
  cannot surface destroy versions, and after Value destruction it can no
1041
1157
  longer be called at all.
1042
1158
 
@@ -1046,7 +1162,7 @@ directly:
1046
1162
 
1047
1163
  ```ruby
1048
1164
  TypedEAV::ValueVersion
1049
- .where(entity_type: contact.class.name, entity_id: contact.id, field_id: age_field.id)
1165
+ .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
1050
1166
  .order(changed_at: :desc, id: :desc)
1051
1167
  # => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
1052
1168
  # <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
@@ -1065,7 +1181,7 @@ exports) — drop the `field_id` filter:
1065
1181
 
1066
1182
  ```ruby
1067
1183
  TypedEAV::ValueVersion
1068
- .where(entity_type: contact.class.name, entity_id: contact.id)
1184
+ .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
1069
1185
  .order(changed_at: :desc, id: :desc)
1070
1186
  # => all version rows for every typed field on this contact, most-recent-first.
1071
1187
  # Includes :create, :update, and :destroy events across every field the
@@ -1116,8 +1232,8 @@ value.revert_to(target)
1116
1232
  ```
1117
1233
 
1118
1234
  `revert_to` writes the targeted version's `before_value` columns back
1119
- via `self[col] = …` and `save!`. The existing `after_commit` chain
1120
- fires; the versioning subscriber writes a NEW version row whose
1235
+ via `self[col] = …` and `save!`. The transactional version callback writes a
1236
+ NEW version row whose
1121
1237
  `after_value` reflects the targeted version's `before_value`. The
1122
1238
  audit log is append-only — every revert is itself versioned.
1123
1239
 
@@ -1145,19 +1261,18 @@ manually using `version.before_value` as the seed state.
1145
1261
 
1146
1262
  ### Hook ordering guarantee
1147
1263
 
1148
- Versioning is registered as an internal subscriber on
1149
- `TypedEAV::EventDispatcher`. It runs **first** (slot 0) for every Value
1150
- event. Your `Config.on_value_change` user proc fires **last**, after
1151
- the version row is persisted:
1152
-
1264
+ Versioning is installed as boot-latched transactional callbacks on `Value`,
1265
+ and the public callback remains an after-commit observer. The version row is
1266
+ written in the source transaction.
1153
1267
  ```
1154
- Value#save! → after_commitEventDispatcher.dispatch_value_change:
1155
- 1. TypedEAV::Versioning::Subscriber.call # writes version row
1156
- 2. ... any other internal subscribers (Phase 7 matview, etc.) ...
1157
- 3. Config.on_value_change user proc # sees the persisted version
1268
+ Value#save! → transactional Value callback ValueVersion.create!
1269
+ after_commit EventDispatcher.dispatch_value_change:
1270
+ 1. ... any other generic internal observers ...
1271
+ 2. Config.on_value_change user proc # sees the persisted version
1158
1272
  ```
1159
1273
 
1160
- Internal subscriber errors propagate (versioning corruption is loud).
1274
+ Internal observer errors propagate. Transactional version-writing errors also
1275
+ propagate inside and roll back the source transaction.
1161
1276
  User proc errors are rescued and logged via `Rails.logger.error` —
1162
1277
  the save itself already committed.
1163
1278
 
@@ -1214,14 +1329,8 @@ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
1214
1329
  before do
1215
1330
  TypedEAV.registry.register("Contact", versioned: true)
1216
1331
  TypedEAV::Config.versioning = true
1217
- # CRITICAL: the :event_callbacks hook clears
1218
- # EventDispatcher.value_change_internals at example entry, so the
1219
- # engine-boot-registered subscriber is gone for the duration of
1220
- # the example. Re-register explicitly inside the before block.
1221
- # The hook's ensure block restores the snapshot — no leak.
1222
- TypedEAV::EventDispatcher.register_internal_value_change(
1223
- TypedEAV::Versioning::Subscriber.method(:call),
1224
- )
1332
+ # Transactional Value callbacks are boot-latched and remain installed;
1333
+ # the hook isolates only public and generic EventDispatcher observers.
1225
1334
  end
1226
1335
  after { TypedEAV.registry.register("Contact", versioned: false) }
1227
1336
 
@@ -1231,12 +1340,11 @@ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
1231
1340
  end
1232
1341
  ```
1233
1342
 
1234
- The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshot/
1235
- restores `Config.versioning`, `Config.actor_resolver`, and the
1236
- EventDispatcher subscriber lists around each example, so your changes
1237
- don't leak to subsequent tests. The snapshot/restore CLEARS the
1238
- internals list at example entry — that's why the re-registration
1239
- above is required for any spec that needs the subscriber to fire. The
1343
+ The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
1344
+ restores `Config.versioning`, `Config.actor_resolver`, and generic
1345
+ EventDispatcher observer lists around each example. Transactional Value
1346
+ callback installation is tested independently through callback-chain and
1347
+ boot-latch specs. The
1240
1348
  `:real_commits` hook disables transactional fixtures (so `after_commit`
1241
1349
  fires durably) and cleans up `TypedEAV::ValueVersion` rows in
1242
1350
  FK-respecting order between examples.
@@ -1261,7 +1369,7 @@ The gem creates five tables:
1261
1369
 
1262
1370
  ## Architecture
1263
1371
 
1264
- Internal module layout as of 0.5.0. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md) through [ADR-0006](docs/adr/0006-include-missing-via-set-complement.md).
1372
+ Internal module layout as of 0.7.0. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by ADR-0001 through ADR-0013.
1265
1373
 
1266
1374
  ### Macro entry: `HasTypedEav`
1267
1375
 
@@ -1306,19 +1414,65 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1306
1414
 
1307
1415
  `typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
1308
1416
 
1309
- 1. Groups records by partition tuple via `TypedEAV::Partition.definitions_by_name`.
1310
- 2. Issues one batched `WHERE entity_id IN (...) AND field_id IN (...)` query per partition.
1311
- 3. Returns a `{record_id => {field_name => value}}` map.
1417
+ 1. Resolves visible definitions and groups the requested field IDs.
1418
+ 2. Loads definitions, values, and field associations through one batched
1419
+ definition query, one values query, and one field-association preload
1420
+ (three SQL queries total; no host-table query).
1421
+ 3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
1422
+ values and preserving logical missingness.
1423
+
1424
+ Definitions, filters, reads, registry entries, and writes all use the host's
1425
+ Rails `polymorphic_name`, so an STI leaf class reads and queries the same rows
1426
+ written under its base-class polymorphic type.
1427
+
1428
+ The final production characterization reduced the 1,002 SQL statements observed
1429
+ across 1,000 scopes to three for the same BulkRead shape. This is a statement-
1430
+ count result, not a representative throughput claim; applications should still
1431
+ measure their own scope cardinality, selected fields, hydration, and contention.
1312
1432
 
1313
1433
  Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
1314
1434
 
1315
1435
  ### Bulk writes: `BulkWrite`
1316
1436
 
1317
- `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`, an executor that:
1437
+ `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
1438
+ and `bulk_set_typed_eav_values_per_record(values_by_record)` is its sibling for
1439
+ record-varying hashes. Both are semantic writers that:
1318
1440
 
1319
1441
  1. Memoizes field definitions for the call via `Thread.current[:typed_eav_bulk_defs_memo]`.
1320
1442
  2. Validates each attribute against its field type's cast contract.
1321
- 3. Upserts in a single SQL round trip per typed column.
1443
+ 3. Saves each host through the normal callback/validation path inside an outer
1444
+ transaction with per-record savepoints.
1445
+
1446
+ `bulk_set_typed_eav_values_per_record` uses records as Hash keys, so two AR
1447
+ instances of the same persisted row collapse to one entry; sequence separate
1448
+ calls for two ordered updates, while the uniform Array API preserves duplicate
1449
+ instances and caller order.
1450
+
1451
+ Under `transaction: :all`, per-record validation failures are captured at their
1452
+ savepoints while other successes can commit, but an uncaught exception rolls
1453
+ the entire outer transaction back. The default `transaction: :all` commits the
1454
+ whole successful batch or rolls it back;
1455
+ `transaction: :chunks, chunk_size: N` commits completed chunks while isolating
1456
+ later failures, preserving earlier committed chunks. Both forms require the
1457
+ host, Field, and Value pools to match.
1458
+ `bulk_upsert_typed_eav_values` is a separate reduced-semantics fast path: it
1459
+ casts and validates typed values, then performs one PostgreSQL upsert while
1460
+ omitting host saves, persistence callbacks, delete shorthand, and versioning.
1461
+
1462
+ Callers must pass `acknowledge_reduced_semantics: true`. The same values hash
1463
+ applies to every record; records must be persisted and unique, and string or
1464
+ symbol field keys that normalize to the same name are rejected. The return
1465
+ value is the integer number of value rows upserted, not a semantic
1466
+ `successes`/`errors_by_record` result. Value casting, domain/entity/partition
1467
+ checks, and Value validation callbacks remain; host callbacks and validations,
1468
+ Value persistence callbacks, versioning, delete shorthand, and per-record
1469
+ savepoint isolation are skipped.
1470
+
1471
+ Within each `transaction: :all` unit—or each requested chunk—the upsert path
1472
+ resolves every record partition through one batched field-definition SELECT.
1473
+ It shares BulkRead's internal tuple resolver, retaining global, scope-only, and
1474
+ full-tuple precedence independently for each record without broadening tenant
1475
+ visibility.
1322
1476
 
1323
1477
  `BulkWrite` and `BulkRead` are siblings — one read path, one write path — but they don't share a base class. Per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md), keeping them independent preserves the option to evolve each on its own schedule.
1324
1478
 
@@ -1391,6 +1545,48 @@ The definitions helpers used to live as class methods on `HasTypedEav` before 0.
1391
1545
 
1392
1546
  `TypedEAV::SchemaPortability` and `TypedEAV::CSVMapper` (Phase-6 modules) are deliberately decoupled from the core read/write path per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md). They depend on the public `has_typed_eav` macro surface, never on internal modules.
1393
1547
 
1548
+ ### Bulk operation guarantees
1549
+
1550
+ `bulk_upsert_typed_eav_values` is an explicit reduced-semantics API: it
1551
+ prevalidates/casts values and performs a PostgreSQL upsert, while intentionally
1552
+ omitting host callbacks and versioning. Use the regular bulk writer when those
1553
+ semantics are required; chunked semantic transactions are opt-in.
1554
+
1555
+ The fast path still casts and runs domain, entity, partition, and validation
1556
+ callbacks before its single upsert against the exact entity/field conflict
1557
+ target; it omits host saves/host callbacks, Value persistence callbacks,
1558
+ delete shorthand, and versioning. It requires one shared connection pool and
1559
+ returns validation errors before SQL. `:all` is one unit; `:chunks` commits
1560
+ completed chunks before a later failure. Semantic writes retain host saves,
1561
+ per-record savepoint/error isolation, and one outer `:all` transaction.
1562
+
1563
+ BulkWrite evidence is intentionally bounded to the exercised 100- and 1,000-host
1564
+ lanes. It does not establish 10,000- or 100,000-host throughput, nor does it
1565
+ justify a universal batch size or storage choice.
1566
+
1567
+ ### Operational guarantees
1568
+
1569
+ The semantic writer preserves the caller's transaction and callback/versioning
1570
+ contract. Version rows are written in the source transaction, so a rollback
1571
+ rolls back the Value mutation and its audit row together. The reduced-semantics
1572
+ upsert is intentionally separate and does not claim those callbacks or audit
1573
+ guarantees.
1574
+
1575
+ Field deletion has a callback-preserving, keyset-batched path that locks and
1576
+ destroys only the exact field's Values before bounded finalization. It scales by
1577
+ bounded primary-key batches and preserves the Field if a batch fails; it is not
1578
+ a claim of unbounded deletion throughput.
1579
+
1580
+ ### Default backfill narrowing
1581
+
1582
+ `Field::Base#backfill_default!` optionally accepts an exact-host
1583
+ `ActiveRecord::Relation` to SQL-narrow eligible entities before batching. The default
1584
+ all-host behavior remains unchanged; partition checks, batch transactions,
1585
+ callbacks, validations, idempotence, versions, and errors remain in force.
1586
+ Typed storage defines logical missingness across all declared cells, so a
1587
+ partially populated multi-cell value is present while a fully empty Currency
1588
+ value is missing.
1589
+
1394
1590
  ## License
1395
1591
 
1396
1592
  MIT