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
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,23 @@ 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.
24
+
25
+ ## Compatibility
26
+
27
+ The canonical support contract lives in
28
+ [`.github/compatibility.json`](.github/compatibility.json). Typed EAV supports:
29
+
30
+ | Runtime | Supported versions |
31
+ |---|---|
32
+ | Ruby | 3.3 through 4.0 (`>= 3.3`, `< 4.1`) |
33
+ | Rails | 7.2 through 8.1 (`>= 7.2`, `< 8.2`) |
34
+ | PostgreSQL | 15 through 18 |
35
+
36
+ CI proves representative floor, middle, and ceiling combinations rather than
37
+ every Cartesian product. Versions outside these ranges and prerelease versions
38
+ are outside the support guarantee. PostgreSQL compatibility claims assume the
39
+ current minor release for each supported major version.
29
40
 
30
41
  ## Installation
31
42
 
@@ -141,7 +152,7 @@ contact.typed_eav_hash # => { "age" => 40, "status" => "active", ..
141
152
 
142
153
  ### 4. Query with the DSL
143
154
 
144
- This is where typed columns pay off. All queries go through native columns with proper indexes.
155
+ 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.
145
156
 
146
157
  ```ruby
147
158
  # Short form - single field filter
@@ -201,25 +212,108 @@ Contact.where(company_id: 42)
201
212
  | `:is_null` | all | Value is NULL |
202
213
  | `:is_not_null` | all | Value is not NULL |
203
214
 
215
+ ### Optional trigram indexing for string search
216
+
217
+ TypedEAV keeps its partial-covering `text_pattern_ops` B-tree as the default
218
+ string index. Equality uses that B-tree, while `:starts_with`, `:contains`, and
219
+ `:ends_with` use `ILIKE`; `:not_contains` uses `NOT ILIKE`. The gem does not
220
+ require or install `pg_trgm` and does not create a trigram index automatically.
221
+
222
+ An application with frequent positive `ILIKE` searches containing at least
223
+ three useful characters may evaluate its own partial GIN index. This is a
224
+ workload decision: the representative benchmark used GIN for measured prefix,
225
+ contains, suffix, and escaped-literal patterns, but not for `NOT ILIKE` or
226
+ one/two-character probes. It does not prove that every positive pattern or
227
+ selectivity will benefit. A `lower(string_value) LIKE ...` expression index is
228
+ not equivalent to TypedEAV's public `ILIKE`, and the benchmark did not justify
229
+ GiST.
230
+
231
+ Application owners should check extension availability and deploy-role
232
+ privileges in preproduction, then create the extension and index in their own
233
+ migrations. Use nontransactional `CREATE INDEX CONCURRENTLY`, a stable
234
+ application-specific name, and workload-specific `EXPLAIN (ANALYZE, BUFFERS,
235
+ WAL, SETTINGS)` plus storage and write-WAL measurements. Rollback should drop
236
+ only the application-owned index concurrently; do not drop the database-wide
237
+ extension because other objects may share it. See
238
+ [ADR 0009](docs/adr/0009-string-search-indexing.md) and the
239
+ [benchmark guide](bench/README.md#phase-3-string-search-benchmark) for the
240
+ operator matrix, measured costs, SQL, and evidence limits.
241
+
242
+ ### Optional planner statistics for correlated field/value predicates
243
+
244
+ TypedEAV does not install PostgreSQL extended-statistics objects. An application
245
+ whose own plans persistently misestimate `field_id = ... AND typed_value = ...`
246
+ may evaluate application-owned `dependencies` statistics for that exact typed
247
+ column. Dependency statistics apply to compatible equality and `IN` clauses,
248
+ not range predicates. `mcv` describes common value combinations, while
249
+ `ndistinct` primarily informs distinct-group estimates; neither should be added
250
+ without workload evidence.
251
+
252
+ The representative PostgreSQL 17 benchmark found better aggregate equality
253
+ estimates from dependencies, but no plan-shape or demonstrated runtime benefit.
254
+ Its combined object mirrored MCV on the four changed probes because matching MCV
255
+ groups supplied those estimates. The experiment's target of 100 was a controlled
256
+ input, not a universal recommendation. One probe labeled common-date equality
257
+ actually queried an absent date and returned zero rows; it is not evidence about
258
+ common-date estimates.
259
+
260
+ Applications should own stable names and DDL, select targets from representative
261
+ data, run `ANALYZE`, and compare estimated/actual rows, plans, runtime, planning
262
+ cost, maintenance cost, and data churn before retaining an object. Coordinate
263
+ ownership in shared databases, inspect catalog definitions before changing
264
+ objects, and drop only application-owned statistics during rollback. See
265
+ [ADR 0010](docs/adr/0010-planner-statistics-policy.md) and the
266
+ [benchmark guide](bench/README.md#phase-4a-planner-extended-statistics) for safe
267
+ evaluation SQL and evidence limits.
268
+
269
+ ### Multi-filter query strategy
270
+
271
+ TypedEAV retains its current multi-filter query shape: it resolves each field,
272
+ builds the corresponding typed value subquery, and chains those results onto
273
+ the host relation with `id IN (...)`. There is no adaptive strategy or alternate
274
+ production query API.
275
+
276
+ A PostgreSQL 17 benchmark compared the shipped shape with `INTERSECT`,
277
+ correlated `EXISTS`, and direct grouped `HAVING` under resource-capped
278
+ co-tenancy. The run retained 2,940 attempts, including 622 right-censored
279
+ timeouts, and 294 representative identity oracles. Twelve oracles timed out, so
280
+ representative equivalence is unproved even though all 282 completed oracles
281
+ matched and the smaller 98-oracle smoke matched. Alternatives remain
282
+ research-only. Grouped `HAVING` is additionally ineligible for missing-value,
283
+ host-universe complement, and empty-filter semantics.
284
+
285
+ The result also does not establish valid buffer comparisons or 20-distinct-
286
+ field scaling. A parser defect made every derived buffer total a false zero;
287
+ nonzero counters remain recoverable from the retained raw plans. The
288
+ 20-predicate workloads repeat ten fields, and the skewed 10/20 workloads repeat
289
+ five. Future research must repair and validate buffer extraction, exercise
290
+ actual 10/20 distinct fields, complete every representative equivalence oracle,
291
+ cover the full scope/NULL/missing/polymorphic/error contract, and show the
292
+ pre-registered p95, planning-time, buffer, and plan-shape gates before any
293
+ adaptive or replacement proposal. See
294
+ [ADR 0011](docs/adr/0011-multi-filter-query-strategy.md) and the
295
+ [benchmark guide](bench/README.md#phase-4b-multi-filter-query-shapes).
296
+
204
297
  ### How Type Inference Works
205
298
 
206
- You don't need to think about types when querying. Rails handles it:
299
+ The owning Field casts and validates query operands before SQL generation;
300
+ Active Record supplies the SQL bind plumbing:
207
301
 
208
302
  ```ruby
209
- # You pass a string, Rails casts to integer via the column type
303
+ # The Integer Field casts and validates the operand before SQL generation
210
304
  Contact.with_field("age", :gt, "21")
211
305
  # SQL: WHERE integer_value > 21 (not '21')
212
306
 
213
- # You pass a string, Rails casts to date
307
+ # The Date Field owns date parsing and validation
214
308
  Contact.with_field("birthday", :lt, "2000-01-01")
215
309
  # SQL: WHERE date_value < '2000-01-01'::date
216
310
 
217
- # Boolean columns handle truthy/falsy casting
311
+ # The Boolean Field owns truthy/falsy casting
218
312
  Contact.with_field("active", "true")
219
313
  # SQL: WHERE boolean_value = TRUE
220
314
  ```
221
315
 
222
- 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`.
316
+ 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.
223
317
 
224
318
  ## Forms
225
319
 
@@ -357,6 +451,16 @@ end
357
451
 
358
452
  Both are exception-safe via `ensure` and nest cleanly.
359
453
 
454
+ `unscoped` is an explicit administrative/analytics escape hatch, not the
455
+ ordinary tenant request path. It keeps every same-name definition across the
456
+ visible partitions and unions their matches for each filter. For broad audits
457
+ or migrations, bound the definition universe to the work you actually need and
458
+ batch the job at an application-owned boundary. TypedEAV does not prescribe a
459
+ universal limit or batch size; measure generated SQL, planning/execution,
460
+ memory, and workload interference in your application. Keep normal request
461
+ traffic on scoped resolution so global, scope-only, and full-tuple definitions
462
+ collapse to the most-specific match.
463
+
360
464
  ### Explicit `scope:` override
361
465
 
362
466
  Any query method accepts `scope:` as an override for admin tools and tests:
@@ -471,12 +575,26 @@ within one workspace" has no semantic resolution path; the row would never
471
575
  match any record's resolver. The paired partial unique indexes rely on this
472
576
  invariant.
473
577
 
578
+ The shipped migration chain also includes
579
+ `EnforceParentScopeInvariant`, which declares the database check constraints
580
+ nontransactionally and validates them after its preflight, and
581
+ `UsePartialCoveringScalarIndexes`, which creates the six `*_present` indexes
582
+ before removing their legacy counterparts. Both migrations use
583
+ `disable_ddl_transaction!`; run them through the normal migration command and
584
+ do not wrap them in an application transaction.
585
+
474
586
  ### Name collisions across scopes
475
587
 
476
588
  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.
477
589
 
478
590
  `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.
479
591
 
592
+ Because that administrative path constructs work for every matching
593
+ definition, applications should narrow and batch high-cardinality audits rather
594
+ than treating `unscoped` as tenant-request routing. No built-in numeric
595
+ threshold is implied; choose operational bounds from measurements of the
596
+ consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-query-policy.md).
597
+
480
598
  ## Field Types
481
599
 
482
600
  | Type | Column | Ruby Type | Options |
@@ -769,7 +887,7 @@ exactly three method overrides.
769
887
 
770
888
  - **`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.
771
889
 
772
- - **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`).
890
+ - **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`).
773
891
 
774
892
  - **`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.
775
893
 
@@ -838,8 +956,8 @@ end
838
956
  The `:rename` change_type fires whenever the field's `name` column changed
839
957
  in the just-committed save, even when bundled with other attribute changes
840
958
  (options, sort_order, default_value, etc.). The detection is intentionally
841
- escalating Phase 7's materialized index needs to regenerate column DDL on
842
- every rename.
959
+ escalating so any registered consumer receives a rename event whenever the
960
+ persisted name changes.
843
961
 
844
962
  `:update` on Value fires only when the typed value column changed. Saving
845
963
  a Value record without modifying its typed column (e.g., touching only
@@ -851,6 +969,17 @@ bypasses AR callbacks. Only the Field `:destroy` event fires. Use
851
969
  `field_dependent: :destroy` if your consumer needs per-Value events on
852
970
  field deletion.
853
971
 
972
+ For a persisted `field_dependent: :destroy` field with a large population,
973
+ call `field.destroy_with_values_in_batches!(batch_size: 1_000)` outside an
974
+ open transaction. The opt-in API selects only that exact `field_id` in ordered
975
+ primary-key batches, calls `Value#destroy!` for callback/version behavior, and
976
+ commits each batch independently. A retry resumes from the remaining rows. The
977
+ Field is retained until a locked, bounded residual drain proves zero rows, then
978
+ its ordinary callback-preserving `destroy!` runs. The API rejects unsaved or
979
+ non-destroy fields, open transactions, invalid batch sizes, and mismatched
980
+ connection pools. Existing `destroy`/`destroy!`, `:nullify`, and `:restrict`
981
+ behavior is unchanged.
982
+
854
983
  ### Thread-local context with `with_context`
855
984
 
856
985
  ```ruby
@@ -888,16 +1017,15 @@ when `after_commit` fires; re-raising would surface a misleading
888
1017
  "save failed" error.
889
1018
 
890
1019
  This is the deliberate split with first-party features. Internal
891
- subscribers used by `typed_eav` itself (Phase 4 versioning, Phase 7
892
- materialized index) follow a different rule: their exceptions
893
- **propagate**. Versioning corruption must be loud.
1020
+ observers used by `typed_eav` itself follow a different rule: their exceptions
1021
+ **propagate**. Transactional version-writing errors are separate: they
1022
+ propagate inside and roll back the source transaction.
894
1023
 
895
1024
  ### Ordering guarantee
896
1025
 
897
1026
  When multiple subscribers are registered, they fire in this order:
898
1027
 
899
- 1. First-party internal subscribers (versioning, matview, etc.), in
900
- registration order. Errors propagate.
1028
+ 1. First-party generic observers, in registration order. Errors propagate.
901
1029
  2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
902
1030
  last. Errors are rescued and logged.
903
1031
 
@@ -956,8 +1084,8 @@ values. When enabled, each `:create` / `:update` / `:destroy` event on
956
1084
  a Value writes a row to `typed_eav_value_versions` capturing the
957
1085
  before-state, after-state, actor, context, and timestamp.
958
1086
 
959
- Default off. Apps that don't enable it pay zero overhead — the Phase 04
960
- internal subscriber is not registered with `EventDispatcher.value_change_internals`
1087
+ Default off. Apps that don't enable it pay zero overhead — transactional
1088
+ Value callbacks are not installed at boot
961
1089
  at all when `Config.versioning = false`. Zero callable in the dispatcher
962
1090
  chain, zero per-write method dispatch, zero per-write config read.
963
1091
 
@@ -1018,9 +1146,8 @@ value.history.limit(5).each { |v| ... }
1018
1146
  `Value#history` returns versions where `value_id` matches the live Value
1019
1147
  record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
1020
1148
  nullifies `value_id` on the existing version rows, and the new `:destroy`
1021
- version is also written with `value_id: nil` (the parent
1022
- `typed_eav_values` row is gone by `after_commit on: :destroy` time —
1023
- writing a non-nil `value_id` would FK-fail at INSERT). So `Value#history`
1149
+ version is written by the transactional destroy callback with `value_id: nil`
1150
+ before the parent row is removed. So `Value#history`
1024
1151
  cannot surface destroy versions, and after Value destruction it can no
1025
1152
  longer be called at all.
1026
1153
 
@@ -1100,8 +1227,8 @@ value.revert_to(target)
1100
1227
  ```
1101
1228
 
1102
1229
  `revert_to` writes the targeted version's `before_value` columns back
1103
- via `self[col] = …` and `save!`. The existing `after_commit` chain
1104
- fires; the versioning subscriber writes a NEW version row whose
1230
+ via `self[col] = …` and `save!`. The transactional version callback writes a
1231
+ NEW version row whose
1105
1232
  `after_value` reflects the targeted version's `before_value`. The
1106
1233
  audit log is append-only — every revert is itself versioned.
1107
1234
 
@@ -1129,19 +1256,18 @@ manually using `version.before_value` as the seed state.
1129
1256
 
1130
1257
  ### Hook ordering guarantee
1131
1258
 
1132
- Versioning is registered as an internal subscriber on
1133
- `TypedEAV::EventDispatcher`. It runs **first** (slot 0) for every Value
1134
- event. Your `Config.on_value_change` user proc fires **last**, after
1135
- the version row is persisted:
1136
-
1259
+ Versioning is installed as boot-latched transactional callbacks on `Value`,
1260
+ and the public callback remains an after-commit observer. The version row is
1261
+ written in the source transaction.
1137
1262
  ```
1138
- Value#save! → after_commitEventDispatcher.dispatch_value_change:
1139
- 1. TypedEAV::Versioning::Subscriber.call # writes version row
1140
- 2. ... any other internal subscribers (Phase 7 matview, etc.) ...
1141
- 3. Config.on_value_change user proc # sees the persisted version
1263
+ Value#save! → transactional Value callback ValueVersion.create!
1264
+ after_commit EventDispatcher.dispatch_value_change:
1265
+ 1. ... any other generic internal observers ...
1266
+ 2. Config.on_value_change user proc # sees the persisted version
1142
1267
  ```
1143
1268
 
1144
- Internal subscriber errors propagate (versioning corruption is loud).
1269
+ Internal observer errors propagate. Transactional version-writing errors also
1270
+ propagate inside and roll back the source transaction.
1145
1271
  User proc errors are rescued and logged via `Rails.logger.error` —
1146
1272
  the save itself already committed.
1147
1273
 
@@ -1198,14 +1324,8 @@ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
1198
1324
  before do
1199
1325
  TypedEAV.registry.register("Contact", versioned: true)
1200
1326
  TypedEAV::Config.versioning = true
1201
- # CRITICAL: the :event_callbacks hook clears
1202
- # EventDispatcher.value_change_internals at example entry, so the
1203
- # engine-boot-registered subscriber is gone for the duration of
1204
- # the example. Re-register explicitly inside the before block.
1205
- # The hook's ensure block restores the snapshot — no leak.
1206
- TypedEAV::EventDispatcher.register_internal_value_change(
1207
- TypedEAV::Versioning::Subscriber.method(:call),
1208
- )
1327
+ # Transactional Value callbacks are boot-latched and remain installed;
1328
+ # the hook isolates only public and generic EventDispatcher observers.
1209
1329
  end
1210
1330
  after { TypedEAV.registry.register("Contact", versioned: false) }
1211
1331
 
@@ -1215,12 +1335,11 @@ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
1215
1335
  end
1216
1336
  ```
1217
1337
 
1218
- The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshot/
1219
- restores `Config.versioning`, `Config.actor_resolver`, and the
1220
- EventDispatcher subscriber lists around each example, so your changes
1221
- don't leak to subsequent tests. The snapshot/restore CLEARS the
1222
- internals list at example entry — that's why the re-registration
1223
- above is required for any spec that needs the subscriber to fire. The
1338
+ The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
1339
+ restores `Config.versioning`, `Config.actor_resolver`, and generic
1340
+ EventDispatcher observer lists around each example. Transactional Value
1341
+ callback installation is tested independently through callback-chain and
1342
+ boot-latch specs. The
1224
1343
  `:real_commits` hook disables transactional fixtures (so `after_commit`
1225
1344
  fires durably) and cleans up `TypedEAV::ValueVersion` rows in
1226
1345
  FK-respecting order between examples.
@@ -1233,16 +1352,19 @@ As of v0.2.0, the paired partial unique indexes cover the three-key partition tu
1233
1352
 
1234
1353
  ## Schema
1235
1354
 
1236
- The gem creates four tables:
1355
+ The gem creates five tables:
1237
1356
 
1238
1357
  - `typed_eav_fields` - field definitions (STI, one row per field per entity type)
1239
1358
  - `typed_eav_values` - values (one row per entity per field, with typed columns)
1240
1359
  - `typed_eav_options` - allowed values for select/multi-select fields
1241
1360
  - `typed_eav_sections` - optional UI grouping
1361
+ - `typed_eav_value_versions` - opt-in, append-only audit history for Value
1362
+ create, update, and destroy events; it retains durable entity identity even
1363
+ when the live Value row is later removed
1242
1364
 
1243
1365
  ## Architecture
1244
1366
 
1245
- Internal module layout as of 0.3.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-0005](docs/adr/0005-keep-phase-six-modules-independent.md).
1367
+ 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.
1246
1368
 
1247
1369
  ### Macro entry: `HasTypedEav`
1248
1370
 
@@ -1287,19 +1409,55 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1287
1409
 
1288
1410
  `typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
1289
1411
 
1290
- 1. Groups records by partition tuple via `TypedEAV::Partition.definitions_by_name`.
1291
- 2. Issues one batched `WHERE entity_id IN (...) AND field_id IN (...)` query per partition.
1292
- 3. Returns a `{record_id => {field_name => value}}` map.
1412
+ 1. Resolves visible definitions and groups the requested field IDs.
1413
+ 2. Loads definitions, values, and field associations through one batched
1414
+ definition query, one values query, and one field-association preload
1415
+ (three SQL queries total; no host-table query).
1416
+ 3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
1417
+ values and preserving logical missingness.
1418
+
1419
+ The final production characterization reduced the 1,002 SQL statements observed
1420
+ across 1,000 scopes to three for the same BulkRead shape. This is a statement-
1421
+ count result, not a representative throughput claim; applications should still
1422
+ measure their own scope cardinality, selected fields, hydration, and contention.
1293
1423
 
1294
1424
  Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
1295
1425
 
1296
1426
  ### Bulk writes: `BulkWrite`
1297
1427
 
1298
- `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`, an executor that:
1428
+ `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
1429
+ and `bulk_set_typed_eav_values_per_record(values_by_record)` is its sibling for
1430
+ record-varying hashes. Both are semantic writers that:
1299
1431
 
1300
1432
  1. Memoizes field definitions for the call via `Thread.current[:typed_eav_bulk_defs_memo]`.
1301
1433
  2. Validates each attribute against its field type's cast contract.
1302
- 3. Upserts in a single SQL round trip per typed column.
1434
+ 3. Saves each host through the normal callback/validation path inside an outer
1435
+ transaction with per-record savepoints.
1436
+
1437
+ `bulk_set_typed_eav_values_per_record` uses records as Hash keys, so two AR
1438
+ instances of the same persisted row collapse to one entry; sequence separate
1439
+ calls for two ordered updates, while the uniform Array API preserves duplicate
1440
+ instances and caller order.
1441
+
1442
+ Under `transaction: :all`, per-record validation failures are captured at their
1443
+ savepoints while other successes can commit, but an uncaught exception rolls
1444
+ the entire outer transaction back. The default `transaction: :all` commits the
1445
+ whole successful batch or rolls it back;
1446
+ `transaction: :chunks, chunk_size: N` commits completed chunks while isolating
1447
+ later failures, preserving earlier committed chunks. Both forms require the
1448
+ host, Field, and Value pools to match.
1449
+ `bulk_upsert_typed_eav_values` is a separate reduced-semantics fast path: it
1450
+ casts and validates typed values, then performs one PostgreSQL upsert while
1451
+ omitting host saves, persistence callbacks, delete shorthand, and versioning.
1452
+
1453
+ Callers must pass `acknowledge_reduced_semantics: true`. The same values hash
1454
+ applies to every record; records must be persisted and unique, and string or
1455
+ symbol field keys that normalize to the same name are rejected. The return
1456
+ value is the integer number of value rows upserted, not a semantic
1457
+ `successes`/`errors_by_record` result. Value casting, domain/entity/partition
1458
+ checks, and Value validation callbacks remain; host callbacks and validations,
1459
+ Value persistence callbacks, versioning, delete shorthand, and per-record
1460
+ savepoint isolation are skipped.
1303
1461
 
1304
1462
  `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.
1305
1463
 
@@ -1314,6 +1472,16 @@ Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMetho
1314
1472
 
1315
1473
  Every method uses `TypedEAV::Partition.definitions_by_name` so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
1316
1474
 
1475
+ ### Partition visibility: `Partition`
1476
+
1477
+ Host applications that need to inspect effective schema should use the
1478
+ documented-public `TypedEAV::Partition` seam rather than rebuilding tuple
1479
+ predicates. It exposes `visible_fields`, `effective_fields_by_name`,
1480
+ `definitions_by_name`, `definitions_multimap_by_name`, `visible_sections`,
1481
+ and `find_visible_section!`. These methods preserve global, scope-only, and
1482
+ full-tuple precedence; ADR-0006 additionally fixes include-missing set
1483
+ composition at the `FilterQuery` altitude.
1484
+
1317
1485
  ### Field types and storage: `Field::TypedStorage`
1318
1486
 
1319
1487
  `TypedEAV::Field::Base` is the STI parent of every field type. The shared storage surface lives in the `TypedEAV::Field::TypedStorage` concern (`lib/typed_eav/field/typed_storage.rb`, ~200 LOC), auto-included on `Field::Base`. Per [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md), it provides:
@@ -1362,6 +1530,48 @@ The definitions helpers used to live as class methods on `HasTypedEav` before 0.
1362
1530
 
1363
1531
  `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.
1364
1532
 
1533
+ ### Bulk operation guarantees
1534
+
1535
+ `bulk_upsert_typed_eav_values` is an explicit reduced-semantics API: it
1536
+ prevalidates/casts values and performs a PostgreSQL upsert, while intentionally
1537
+ omitting host callbacks and versioning. Use the regular bulk writer when those
1538
+ semantics are required; chunked semantic transactions are opt-in.
1539
+
1540
+ The fast path still casts and runs domain, entity, partition, and validation
1541
+ callbacks before its single upsert against the exact entity/field conflict
1542
+ target; it omits host saves/host callbacks, Value persistence callbacks,
1543
+ delete shorthand, and versioning. It requires one shared connection pool and
1544
+ returns validation errors before SQL. `:all` is one unit; `:chunks` commits
1545
+ completed chunks before a later failure. Semantic writes retain host saves,
1546
+ per-record savepoint/error isolation, and one outer `:all` transaction.
1547
+
1548
+ BulkWrite evidence is intentionally bounded to the exercised 100- and 1,000-host
1549
+ lanes. It does not establish 10,000- or 100,000-host throughput, nor does it
1550
+ justify a universal batch size or storage choice.
1551
+
1552
+ ### Operational guarantees
1553
+
1554
+ The semantic writer preserves the caller's transaction and callback/versioning
1555
+ contract. Version rows are written in the source transaction, so a rollback
1556
+ rolls back the Value mutation and its audit row together. The reduced-semantics
1557
+ upsert is intentionally separate and does not claim those callbacks or audit
1558
+ guarantees.
1559
+
1560
+ Field deletion has a callback-preserving, keyset-batched path that locks and
1561
+ destroys only the exact field's Values before bounded finalization. It scales by
1562
+ bounded primary-key batches and preserves the Field if a batch fails; it is not
1563
+ a claim of unbounded deletion throughput.
1564
+
1565
+ ### Default backfill narrowing
1566
+
1567
+ `Field::Base#backfill_default!` optionally accepts an exact-host
1568
+ `ActiveRecord::Relation` to SQL-narrow eligible entities before batching. The default
1569
+ all-host behavior remains unchanged; partition checks, batch transactions,
1570
+ callbacks, validations, idempotence, versions, and errors remain in force.
1571
+ Typed storage defines logical missingness across all declared cells, so a
1572
+ partially populated multi-cell value is present while a fully empty Currency
1573
+ value is missing.
1574
+
1365
1575
  ## License
1366
1576
 
1367
1577
  MIT