typed_eav 0.1.0 → 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. metadata +36 -2
data/README.md CHANGED
@@ -333,7 +333,12 @@ TypedEAV.configure do |c|
333
333
  end
334
334
  ```
335
335
 
336
- The resolver can return a raw value (`"t1"`, `42`) or an AR record — TypedEAV calls `.id.to_s` when the return value responds to `#id`.
336
+ The resolver MUST return a 2-element Array `[scope, parent_scope]`. Each slot
337
+ accepts a raw value (`"t1"`, `42`), an AR record (TypedEAV calls `.id.to_s`
338
+ on anything that responds to `#id`), or `nil`. If you don't use parent_scope,
339
+ return `[scope, nil]`. A bare scalar return raises `ArgumentError` at the
340
+ next ambient query — see [Migrating from v0.1.x](#migrating-from-v01x) for
341
+ the upgrade path.
337
342
 
338
343
  ### Block APIs
339
344
 
@@ -385,6 +390,87 @@ If your app has existing typed-eav queries that don't yet pass scope, flip `requ
385
390
 
386
391
  To intentionally query across every partition (admin tools, migrations, cross-tenant audits), use the explicit escape hatch `TypedEAV.unscoped { ... }` rather than relying on `require_scope = false`.
387
392
 
393
+ ### Two-level scoping (`parent_scope`)
394
+
395
+ When a single tenant axis isn't enough — say, `tenant_id` for the customer AND
396
+ `workspace_id` for an in-tenant partition — declare both:
397
+
398
+ ```ruby
399
+ class Project < ApplicationRecord
400
+ has_typed_eav scope_method: :tenant_id, parent_scope_method: :workspace_id
401
+ end
402
+ ```
403
+
404
+ Field (and section) definitions partition on the tuple `(entity_type, scope,
405
+ parent_scope)`. A `Project` record reads field definitions in three precedence
406
+ layers: a full-triple `(scope, parent_scope)` match wins, then `(scope, nil)`
407
+ (tenant-wide), then `(nil, nil)` (truly global). The same precedence applies
408
+ to the class-level query path.
409
+
410
+ `parent_scope_method:` requires `scope_method:` — declaring it without a scope
411
+ method raises at macro-expansion time (no host can have a parent partition
412
+ without a scope partition).
413
+
414
+ Both `with_scope` and the configured `scope_resolver` carry the tuple now:
415
+
416
+ ```ruby
417
+ TypedEAV.with_scope(["t1", "w1"]) do
418
+ Project.where_typed_eav({ name: "status", value: "active" })
419
+ end
420
+
421
+ # Single-axis call still works (parent_scope = nil):
422
+ TypedEAV.with_scope("t1") do
423
+ Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
424
+ end
425
+
426
+ # Custom resolver — MUST return [scope, parent_scope]:
427
+ TypedEAV.configure do |c|
428
+ c.scope_resolver = -> { [Current.tenant&.id, Current.workspace&.id] }
429
+ end
430
+ ```
431
+
432
+ Per-query overrides accept `parent_scope:` alongside `scope:` on
433
+ `where_typed_eav`, `with_field`, and `typed_eav_definitions`:
434
+
435
+ ```ruby
436
+ Project.where_typed_eav(
437
+ { name: "priority", value: "high" },
438
+ scope: "t1",
439
+ parent_scope: "w1",
440
+ )
441
+ ```
442
+
443
+ When `acts_as_tenant` is loaded, the auto-detected `DEFAULT_SCOPE_RESOLVER`
444
+ returns `[ActsAsTenant.current_tenant, nil]` — the parent_scope slot is `nil`
445
+ because the tenant gem has no parent-scope analog. Configure your own resolver
446
+ when you need both axes.
447
+
448
+ ### Migrating from v0.1.x
449
+
450
+ The resolver-callable contract is a **breaking change**: any custom
451
+ `Config.scope_resolver` lambda must now return `[scope, parent_scope]` (a
452
+ 2-element Array) instead of a bare scalar. A scalar return raises
453
+ `ArgumentError` at the next ambient query so the failure is loud, not silent.
454
+ If you don't use parent_scope, return `[scope, nil]`.
455
+
456
+ Run `bin/rails typed_eav:install:migrations` to copy the new
457
+ `AddParentScopeToTypedEavPartitions` migration into your app, then
458
+ `bin/rails db:migrate`. The migration is safe on production: it adds a
459
+ nullable `parent_scope` column (catalog-only, instantaneous) and uses
460
+ `CREATE INDEX CONCURRENTLY` for all index changes, so existing rows aren't
461
+ rewritten. Existing fields end up with `parent_scope = NULL` (the
462
+ global-parent shape) and continue to work for every single-scope caller.
463
+
464
+ See the [CHANGELOG](CHANGELOG.md) for the full upgrade checklist.
465
+
466
+ ### Orphan-parent invariant
467
+
468
+ A `Field` or `Section` row with `parent_scope` set and `scope` blank is
469
+ invalid — model-level validation rejects it on save. Reason: a "global field
470
+ within one workspace" has no semantic resolution path; the row would never
471
+ match any record's resolver. The paired partial unique indexes rely on this
472
+ invariant.
473
+
388
474
  ### Name collisions across scopes
389
475
 
390
476
  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.
@@ -412,6 +498,11 @@ When both a global field (`scope: nil`) and a scoped field share a name, the **s
412
498
  | `Url` | `string_value` | String | strips whitespace |
413
499
  | `Color` | `string_value` | String | hex color values |
414
500
  | `Json` | `json_value` | Hash/Array | arbitrary JSON |
501
+ | `Currency` | `decimal_value` + `string_value` | `{amount: BigDecimal, currency: String}` | `default_currency`, `allowed_currencies` |
502
+ | `Percentage` | `decimal_value` | BigDecimal (0..1 range) | `decimal_places`, `display_as: :fraction \| :percent` |
503
+ | `Image` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
504
+ | `File` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
505
+ | `Reference` | `integer_value` (FK) | Integer (target record ID) | `target_entity_type`, `target_scope` |
415
506
 
416
507
  ## Sections (Optional UI Grouping)
417
508
 
@@ -464,6 +555,129 @@ TypedEAV.configure do |c|
464
555
  end
465
556
  ```
466
557
 
558
+ ### Multi-cell field types
559
+
560
+ External field types may store their logical value across multiple typed
561
+ columns. Storage behavior is exposed through `field.storage_contract`,
562
+ which keeps callers from knowing whether a field is single-cell or
563
+ multi-cell. The contract covers:
564
+
565
+ - `value_columns` - the native typed cells used for storage, snapshots,
566
+ reverting, and update change detection.
567
+ - `read(value_record)` / `write(value_record, casted)` - logical value
568
+ reads and writes.
569
+ - `apply_default(value_record)` - default application across the field's
570
+ storage cells.
571
+ - `query_column(operator)` - query routing for each supported operator.
572
+ - `before_snapshot(value_record, change_type)` /
573
+ `after_snapshot(value_record, change_type)` - version row jsonb shape.
574
+ - `changed?(value_record)` - update event gating across every storage
575
+ cell the field owns.
576
+
577
+ Single-cell field types inherit the default contract. For multi-cell
578
+ fields, create a `TypedEAV::FieldStorageContract` subclass and select it
579
+ from the field class:
580
+
581
+ ```ruby
582
+ class MoneyStorageContract < TypedEAV::FieldStorageContract
583
+ def self.value_columns = %i[decimal_value string_value]
584
+ def self.query_column(operator) = operator == :currency_eq ? :string_value : :decimal_value
585
+
586
+ def read(value_record)
587
+ amount = value_record[:decimal_value]
588
+ currency = value_record[:string_value]
589
+ amount.nil? && currency.nil? ? nil : { amount: amount, currency: currency }
590
+ end
591
+
592
+ def write(value_record, casted)
593
+ value_record[:decimal_value] = casted&.fetch(:amount, nil)
594
+ value_record[:string_value] = casted&.fetch(:currency, nil)
595
+ end
596
+
597
+ def apply_default(value_record)
598
+ default = field.default_value
599
+ return unless default.is_a?(Hash)
600
+
601
+ value_record[:decimal_value] = default[:amount] || default["amount"]
602
+ value_record[:string_value] = default[:currency] || default["currency"]
603
+ end
604
+ end
605
+
606
+ class Fields::Money < TypedEAV::Field::Base
607
+ value_column :decimal_value
608
+ storage_contract_class MoneyStorageContract
609
+ end
610
+ ```
611
+
612
+ Compatibility helpers such as `self.value_columns` and
613
+ `self.operator_column(operator)` may delegate to the selected contract
614
+ when older callers still use those class methods.
615
+
616
+ Defaults delegate to `value_column` for single-cell storage, so existing
617
+ single-cell types are unchanged. The built-in `Field::Currency` is the
618
+ canonical multi-cell consumer of these extension points.
619
+
620
+ ### Built-in field types
621
+
622
+ - **`Currency`:** Stores `{amount: BigDecimal, currency: String}` across two typed columns (`decimal_value` for the amount; `string_value` for the ISO 4217 currency code) through `CurrencyStorageContract`. Operators: `:eq`, `:gt`, `:lt`, `:gteq`, `:lteq`, `:between` target the amount; `:currency_eq` targets the currency code; `:is_null` / `:is_not_null` target the amount column (a Currency value is null when its amount is null). Cast input MUST be a hash with `:amount` and/or `:currency` keys — bare numeric/string values are rejected with `:invalid` to enforce explicit currency dimension at write time. Options: `default_currency` (String ISO code, applied as fallback only when an amount is given without an explicit currency), `allowed_currencies` (Array of ISO codes; `validate_typed_value` enforces inclusion). Versioning snapshots automatically capture both columns through the storage contract. The `:currency_eq` operator is registered ONLY on `Field::Currency`; the QueryBuilder operator-validation gate rejects it with a clear `ArgumentError` if invoked on any other field type.
623
+
624
+ ```ruby
625
+ Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD")
626
+ Contact.where_typed_eav(name: "price", op: :between, value: [50, 150])
627
+ ```
628
+
629
+ - **`Percentage`:** A `Field::Decimal` subclass storing the underlying fraction in 0..1 (inclusive). The `:percent` representation is a format-time concern — call `field.format(value)` with `display_as: :percent` to render `0.75` as `"75.0%"`. Options: `decimal_places` (Integer >= 0, default 2; format-time precision only — does NOT alter what's stored in `decimal_value`), `display_as` (`:fraction` default, or `:percent`). Validation: out-of-range values (e.g., `1.5`) fail with the message `"must be between 0.0 and 1.0"`. Storage and operator semantics inherit from `Field::Decimal`.
630
+
631
+ ```ruby
632
+ pf = TypedEAV::Field::Percentage.create!(
633
+ name: "discount", entity_type: "Order", scope: tenant_id,
634
+ options: { display_as: :percent, decimal_places: 1 },
635
+ )
636
+ pf.format(BigDecimal("0.755")) # => "75.5%"
637
+ ```
638
+
639
+ - **`Image`:** Active Storage-backed field type. Stores the attached blob's `signed_id` (a String) in `string_value`. Operators: `:eq`, `:is_null`, `:is_not_null`. Options: `allowed_content_types` (Array of strings; supports exact matches like `"image/png"` and `image/*` family wildcards), `max_size_bytes` (Integer; nil disables the cap). The single `:attachment` has_one_attached association is declared on `TypedEAV::Value` at engine boot when Active Storage is loaded; otherwise `Field::Image#cast` raises `NotImplementedError` with an actionable install message. The `:attachment` association is shared with `Field::File` — Image vs File is a class-identity distinction (used by the `on_image_attached` hook), not a separate association.
640
+
641
+ ```ruby
642
+ field = TypedEAV::Field::Image.create!(
643
+ name: "avatar", entity_type: "Contact",
644
+ options: { allowed_content_types: %w[image/png image/jpeg image/webp], max_size_bytes: 5_000_000 },
645
+ )
646
+ value = TypedEAV::Value.create!(entity: contact, field: field)
647
+ value.attachment.attach(io: file_io, filename: "avatar.png", content_type: "image/png")
648
+ value.update!(string_value: value.attachment.blob.signed_id)
649
+ value.value # => the signed_id String
650
+ ```
651
+
652
+ - **`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.
653
+
654
+ - **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`).
655
+
656
+ - **`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.
657
+
658
+ ```ruby
659
+ TypedEAV.configure do |c|
660
+ c.on_image_attached = ->(value, blob) {
661
+ ProcessImageJob.perform_later(value.id, blob.id)
662
+ }
663
+ end
664
+ ```
665
+
666
+ - **`Reference`:** Foreign-key field type. Stores the target record's integer ID in `integer_value`. Operators: `:eq`, `:is_null`, `:is_not_null`, `:references` (explicit narrowing — does NOT inherit `:integer_value`'s `:gt`/`:lt`/`:between` defaults; arithmetic comparisons on FKs don't carry useful semantics). The `:references` operator accepts AR record instances OR Integer IDs at query time, normalizing via `field.cast` (a class-mismatched record routes to `base.none` rather than `:is_null`). Options: `target_entity_type` (REQUIRED — String class name of the target model, validated to constantize at field save), `target_scope` (OPTIONAL — when set, the field is REJECTED at save time if `target_entity_type` is not registered with `has_typed_eav scope_method:` (Gating Decision 2); when set with a scoped target, value-time validation rejects writes whose target's `typed_eav_scope` does not match `target_scope` via a `target_partition_matches?` helper structurally parallel to Phase 1's `entity_partition_axis_matches?` but on the target axis). Cross-scope safety mirrors the existing `Value#validate_field_scope_matches_entity` guard pattern applied to the target rather than the source.
667
+
668
+ ```ruby
669
+ rf = TypedEAV::Field::Reference.create!(
670
+ name: "manager", entity_type: "Contact", scope: tenant_id,
671
+ options: { target_entity_type: "Contact", target_scope: tenant_id },
672
+ )
673
+ TypedEAV::Value.create!(entity: alice, field: rf, value: bob) # accepts AR record
674
+ TypedEAV::Value.create!(entity: alice, field: rf, value: bob.id) # accepts Integer FK
675
+ Contact.where_typed_eav(name: "manager", op: :references, value: bob) # filter by record
676
+ Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK
677
+ ```
678
+
679
+ - **Summary:** The built-in field types **Image, File, Reference, Currency, Percentage** all preserve the cast-tuple contract (`[casted, invalid?]`), the operator-dispatch model (`supported_operators` + `operator_column` for multi-cell types), and the no-hardcoded-attribute-references foundational principle. The multi-cell extension surface (`read_value`, `apply_default_to`, `operator_column`, and `write_value`) is the canonical way to build any future external multi-cell field type.
680
+
467
681
  ## Validation Behavior
468
682
 
469
683
  A few non-obvious contracts worth knowing about up front:
@@ -474,12 +688,430 @@ A few non-obvious contracts worth knowing about up front:
474
688
  - **`Json` parses string input**: a JSON string posted from a form is parsed; parse failures surface as `:invalid` rather than being stored as the literal string.
475
689
  - **`TextArray` does not support `:contains`**: it backs a jsonb column where SQL `LIKE` doesn't apply. Use `:any_eq` for "array contains element".
476
690
  - **Orphaned values are skipped**: if a field row is deleted while values remain, `typed_eav_value` and `typed_eav_hash` silently skip the orphans rather than raising.
477
- - **Cross-scope writes are rejected**: assigning a `Value` to a record whose `typed_eav_scope` doesn't match the field's `scope` adds a validation error on `:field`.
691
+ - **Cross-scope writes are rejected**: assigning a `Value` to a record whose `typed_eav_scope` doesn't match the field's `scope` adds a validation error on `:field`. The same guard covers the `parent_scope` axis.
692
+ - **Orphan-parent rows rejected**: a `Field` or `Section` row with `parent_scope` set but `scope` blank is invalid. The `Value`-side guard rejects cross-`(scope, parent_scope)` writes too.
693
+ - **Event hooks fire from `after_commit`**: the `on_value_change` and `on_field_change` callbacks fire after the database write is durable; their exceptions never break a save. See §"Event hooks" for the full contract.
694
+ - **Versioning is opt-in**: When enabled (`TypedEAV.config.versioning = true` on the gem; `versioned: true` per host), every `:create` / `:update` / `:destroy` event on a Value writes an append-only audit row in `typed_eav_value_versions`. See §"Versioning" for the full contract.
695
+
696
+ ## Event hooks
697
+
698
+ `typed_eav` fires `after_commit` events for value and field changes. Use them
699
+ for audit logs, search-index synchronization, cache invalidation, or any
700
+ out-of-band reaction that must wait until the database write is durable.
701
+
702
+ ### Public callback slots
703
+
704
+ ```ruby
705
+ TypedEAV.configure do |c|
706
+ c.on_value_change = ->(value, change_type, context) {
707
+ # change_type ∈ [:create, :update, :destroy]
708
+ # context is a frozen Hash (see `with_context` below) — read-only
709
+ }
710
+
711
+ c.on_field_change = ->(field, change_type) {
712
+ # change_type ∈ [:create, :update, :destroy, :rename]
713
+ # NOTE: no context arg — field changes are CRUD-on-config, not
714
+ # per-entity user actions
715
+ }
716
+ end
717
+ ```
718
+
719
+ The `:rename` change_type fires whenever the field's `name` column changed
720
+ in the just-committed save, even when bundled with other attribute changes
721
+ (options, sort_order, default_value, etc.). The detection is intentionally
722
+ escalating — Phase 7's materialized index needs to regenerate column DDL on
723
+ every rename.
724
+
725
+ `:update` on Value fires only when the typed value column changed. Saving
726
+ a Value record without modifying its typed column (e.g., touching only
727
+ bookkeeping columns) is a no-op for event dispatch.
728
+
729
+ `field_dependent: :nullify` cascades produce **no** Value `:destroy`
730
+ events. The FK `ON DELETE SET NULL` runs at the database level and
731
+ bypasses AR callbacks. Only the Field `:destroy` event fires. Use
732
+ `field_dependent: :destroy` if your consumer needs per-Value events on
733
+ field deletion.
734
+
735
+ ### Thread-local context with `with_context`
736
+
737
+ ```ruby
738
+ TypedEAV.with_context(request_id: request.uuid, actor_id: current_user.id) do
739
+ contact.update!(typed_eav: { phone: "555-1234" })
740
+ # on_value_change receives { request_id: "...", actor_id: 42 } as context
741
+ end
742
+ ```
743
+
744
+ `with_context` is a thread-local stack with shallow per-key merge:
745
+
746
+ ```ruby
747
+ TypedEAV.with_context(request_id: "abc") do
748
+ TypedEAV.with_context(source: :bulk) do
749
+ # current context: { request_id: "abc", source: :bulk }
750
+ end
751
+ # current context: { request_id: "abc" }
752
+ end
753
+ # current context: {}
754
+ ```
755
+
756
+ The current-context hash is frozen — callbacks cannot mutate it. Outer
757
+ context is restored on exit even if the inner block raises.
758
+
759
+ `TypedEAV.current_context` returns the current frozen Hash (or a shared
760
+ frozen `{}` when no `with_context` block is active). It's safe to call
761
+ from any code path; it never returns nil.
762
+
763
+ ### Error policy
764
+
765
+ User callbacks (`Config.on_value_change`, `Config.on_field_change`) are
766
+ rescued — exceptions are logged via `Rails.logger.error` and **do not
767
+ propagate** to the user's save call. The save row is already committed
768
+ when `after_commit` fires; re-raising would surface a misleading
769
+ "save failed" error.
770
+
771
+ This is the deliberate split with first-party features. Internal
772
+ subscribers used by `typed_eav` itself (Phase 4 versioning, Phase 7
773
+ materialized index) follow a different rule: their exceptions
774
+ **propagate**. Versioning corruption must be loud.
775
+
776
+ ### Ordering guarantee
777
+
778
+ When multiple subscribers are registered, they fire in this order:
779
+
780
+ 1. First-party internal subscribers (versioning, matview, etc.), in
781
+ registration order. Errors propagate.
782
+ 2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
783
+ last. Errors are rescued and logged.
784
+
785
+ Reassigning `Config.on_value_change` after gem initialization does **not**
786
+ disable internal subscribers — they live on a separate dispatcher list
787
+ and survive `Config.reset!`.
788
+
789
+ ### Test isolation
790
+
791
+ Test files that exercise event hooks should opt in to the `:event_callbacks`
792
+ metadata:
793
+
794
+ ```ruby
795
+ RSpec.describe "my feature", :event_callbacks do
796
+ it "fires the hook" do
797
+ captured = []
798
+ TypedEAV::Config.on_value_change = ->(v, t, _ctx) { captured << [v.id, t] }
799
+ contact.update!(typed_eav: { phone: "555-1234" })
800
+ expect(captured).to include([be_a(Integer), :update])
801
+ end
802
+ end
803
+ ```
804
+
805
+ The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
806
+ restores Config user procs and the internal-subscriber lists around each
807
+ example, so test mutations don't leak across examples and engine-load
808
+ registrations from later phases stay intact.
809
+
810
+ Integration specs that create real AR records and need `after_commit` to
811
+ fire durably should additionally opt in to `:real_commits`:
812
+
813
+ ```ruby
814
+ RSpec.describe "my model", :event_callbacks, :real_commits do
815
+ # ...
816
+ end
817
+ ```
818
+
819
+ `:real_commits` disables transactional fixtures for the example and
820
+ manually deletes typed_eav rows in FK order after.
821
+
822
+ ### Reset semantics
823
+
824
+ | Method | What it resets |
825
+ |---|---|
826
+ | `TypedEAV::Config.reset!` | User procs (`on_value_change`, `on_field_change`) plus `field_types`, `scope_resolver`, `require_scope`. Does **not** clear internal subscribers. |
827
+ | `TypedEAV::EventDispatcher.reset!` | Internal subscribers only. Does **not** touch Config. |
828
+
829
+ Production code rarely calls either — they exist for test isolation and
830
+ for the rare case where a host app wants to fully unwire the gem in a
831
+ specific request lifecycle.
832
+
833
+ ## Versioning
834
+
835
+ `typed_eav` ships an opt-in append-only audit log for changes to typed
836
+ values. When enabled, each `:create` / `:update` / `:destroy` event on
837
+ a Value writes a row to `typed_eav_value_versions` capturing the
838
+ before-state, after-state, actor, context, and timestamp.
839
+
840
+ Default off. Apps that don't enable it pay zero overhead — the Phase 04
841
+ internal subscriber is not registered with `EventDispatcher.value_change_internals`
842
+ at all when `Config.versioning = false`. Zero callable in the dispatcher
843
+ chain, zero per-write method dispatch, zero per-write config read.
844
+
845
+ ### Enabling versioning
846
+
847
+ Two steps:
848
+
849
+ ```ruby
850
+ # 1. Set the gem-level master switch in an initializer.
851
+ # config/initializers/typed_eav.rb
852
+ TypedEAV.configure do |c|
853
+ c.versioning = true
854
+ c.actor_resolver = -> { Current.user } # optional; nil is permissive
855
+ end
856
+
857
+ # 2. Opt the host model in. Either via the kwarg form:
858
+ class Contact < ApplicationRecord
859
+ has_typed_eav scope_method: :tenant_id, versioned: true
860
+ end
861
+
862
+ # Or via the concern (equivalent — pick whichever fits your conventions):
863
+ class Contact < ApplicationRecord
864
+ has_typed_eav scope_method: :tenant_id
865
+ include TypedEAV::Versioned
866
+ end
867
+ ```
868
+
869
+ The two opt-in forms produce identical Registry state. The kwarg form is
870
+ preferred for new code; the concern form fits codebases with established
871
+ mixin-based feature wiring.
872
+
873
+ ### Querying history
874
+
875
+ ```ruby
876
+ contact.typed_eav_attributes = [{ name: "age", value: 41 }]
877
+ contact.save!
878
+ contact.typed_eav_attributes = [{ name: "age", value: 42 }]
879
+ contact.save!
880
+
881
+ value = contact.typed_values.find_by(field: age_field)
882
+ value.history # most-recent-first relation
883
+ # => [<ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42}>,
884
+ # <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41}>]
885
+
886
+ value.history.first.changed_by # => "42" (User#42 — coerced to id.to_s)
887
+ value.history.first.context # => { "request_id" => "abc-123" } if with_context was active
888
+ ```
889
+
890
+ `value.history` is a chainable relation. Filter, paginate, pluck:
891
+
892
+ ```ruby
893
+ value.history.where(change_type: "update").pluck(:changed_at, :changed_by)
894
+ value.history.limit(5).each { |v| ... }
895
+ ```
896
+
897
+ ### Querying full audit history (including destroy events)
898
+
899
+ `Value#history` returns versions where `value_id` matches the live Value
900
+ record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
901
+ nullifies `value_id` on the existing version rows, and the new `:destroy`
902
+ version is also written with `value_id: nil` (the parent
903
+ `typed_eav_values` row is gone by `after_commit on: :destroy` time —
904
+ writing a non-nil `value_id` would FK-fail at INSERT). So `Value#history`
905
+ cannot surface destroy versions, and after Value destruction it can no
906
+ longer be called at all.
907
+
908
+ To query the FULL audit history for a given (entity, field), including
909
+ destroy events and post-destruction lookup, use the entity-scoped query
910
+ directly:
911
+
912
+ ```ruby
913
+ TypedEAV::ValueVersion
914
+ .where(entity_type: contact.class.name, entity_id: contact.id, field_id: age_field.id)
915
+ .order(changed_at: :desc, id: :desc)
916
+ # => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
917
+ # <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
918
+ # <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41} value_id: nil>]
919
+ ```
920
+
921
+ This pattern is the canonical way to surface "what happened to this
922
+ field on this entity" across the full lifecycle, including post-destroy.
923
+ The `entity_type` + `entity_id` columns remain the durable identity even
924
+ after the parent Value row is gone, and `field_id` survives because
925
+ destroying a Value does not destroy its Field.
926
+
927
+ For broader audit views — "show all version history across all fields
928
+ for a given entity" (e.g., admin entity-history pages, compliance
929
+ exports) — drop the `field_id` filter:
930
+
931
+ ```ruby
932
+ TypedEAV::ValueVersion
933
+ .where(entity_type: contact.class.name, entity_id: contact.id)
934
+ .order(changed_at: :desc, id: :desc)
935
+ # => all version rows for every typed field on this contact, most-recent-first.
936
+ # Includes :create, :update, and :destroy events across every field the
937
+ # entity has ever had a typed value for.
938
+ ```
939
+
940
+ The field-scoped query (with `field_id:`) is the common case for
941
+ "history of a single field"; the entity-scoped query (without `field_id:`)
942
+ is the broad-audit case for "all version history across all fields for
943
+ this entity".
944
+
945
+ ### Version row jsonb shape
946
+
947
+ `before_value` and `after_value` are jsonb hashes keyed by typed-column
948
+ name:
949
+
950
+ | Field type | Snapshot shape (single key) |
951
+ |---|---|
952
+ | `text`, `email`, `url`, `color` | `{"string_value": "..."}` |
953
+ | `long_text` | `{"text_value": "..."}` |
954
+ | `integer` | `{"integer_value": 42}` |
955
+ | `decimal` | `{"decimal_value": "10.5"}` |
956
+ | `boolean` | `{"boolean_value": true}` |
957
+ | `date` | `{"date_value": "2026-05-05"}` |
958
+ | `date_time` | `{"datetime_value": "2026-05-05T12:00:00Z"}` |
959
+ | `select` | `{"string_value": "..."}` |
960
+ | `multi_select`, `*_array`, `json` | `{"json_value": [...]}` |
961
+
962
+ Multi-cell field types (e.g., `Currency`) produce two-key snapshots:
963
+ `{"decimal_value": "99.99", "string_value": "USD"}`. The version row's
964
+ snapshot asks the field's storage contract for its cells, so new field
965
+ types get the right shape automatically.
966
+
967
+ `{}` (empty hash) and `{"<col>": null}` are distinct semantics:
968
+
969
+ - `{}` means **no recorded value** — typical of `before_value` on a
970
+ `:create` event, or `after_value` on a `:destroy` event.
971
+ - `{"<col>": null}` means **recorded nil** — the user explicitly
972
+ cleared the cell.
973
+
974
+ ### Reverting
975
+
976
+ ```ruby
977
+ target = value.history.find_by(change_type: "update")
978
+ value.revert_to(target)
979
+ # value's typed columns now match target.before_value.
980
+ # A NEW version row is written capturing the revert (append-only).
981
+ ```
982
+
983
+ `revert_to` writes the targeted version's `before_value` columns back
984
+ via `self[col] = …` and `save!`. The existing `after_commit` chain
985
+ fires; the versioning subscriber writes a NEW version row whose
986
+ `after_value` reflects the targeted version's `before_value`. The
987
+ audit log is append-only — every revert is itself versioned.
988
+
989
+ To record the intent of the revert, wrap the call in `with_context`:
990
+
991
+ ```ruby
992
+ TypedEAV.with_context(reverted_from_version_id: target.id, actor: current_user) do
993
+ value.revert_to(target)
994
+ end
995
+ # The new version row's `context` column captures both keys.
996
+ ```
997
+
998
+ `revert_to` raises `ArgumentError` in three documented conditions, checked in order:
999
+
1000
+ - when `version.value_id` is nil (the source Value was destroyed — destroy
1001
+ versions have `value_id: nil` per the locked subscriber contract; you
1002
+ can't restore a destroyed AR record by `save!`);
1003
+ - when the version's `before_value` is empty (the version represents a
1004
+ `:create` event with no before-state to revert to);
1005
+ - when the version belongs to a different Value (`value_id` mismatch).
1006
+
1007
+ In practice only `:update` versions are revertable. To restore a
1008
+ destroyed entity's typed values, create a new `TypedEAV::Value` record
1009
+ manually using `version.before_value` as the seed state.
1010
+
1011
+ ### Hook ordering guarantee
1012
+
1013
+ Versioning is registered as an internal subscriber on
1014
+ `TypedEAV::EventDispatcher`. It runs **first** (slot 0) for every Value
1015
+ event. Your `Config.on_value_change` user proc fires **last**, after
1016
+ the version row is persisted:
1017
+
1018
+ ```
1019
+ Value#save! → after_commit → EventDispatcher.dispatch_value_change:
1020
+ 1. TypedEAV::Versioning::Subscriber.call # writes version row
1021
+ 2. ... any other internal subscribers (Phase 7 matview, etc.) ...
1022
+ 3. Config.on_value_change user proc # sees the persisted version
1023
+ ```
1024
+
1025
+ Internal subscriber errors propagate (versioning corruption is loud).
1026
+ User proc errors are rescued and logged via `Rails.logger.error` —
1027
+ the save itself already committed.
1028
+
1029
+ ### Actor resolution
1030
+
1031
+ `Config.actor_resolver` mirrors `Config.scope_resolver`'s callable shape
1032
+ but returns whatever the app chooses (an AR record, a string, an integer,
1033
+ nil). The subscriber coerces non-nil returns via `id.to_s` (for AR
1034
+ records) or `to_s` (for scalars) before storing in the `changed_by`
1035
+ column (string, nullable).
1036
+
1037
+ `nil` is the documented permissive sentinel: system writes, migrations,
1038
+ console-without-actor, and background jobs without a `with_context(actor:
1039
+ ...)` wrap all flow through with `changed_by: nil`. This is intentional —
1040
+ forcing every Versioned write to have an actor would reject every console
1041
+ save and every migration backfill, which is hostile-by-default for a gem.
1042
+
1043
+ Apps that need stricter enforcement do it inside the resolver:
1044
+
1045
+ ```ruby
1046
+ c.actor_resolver = -> { Current.user || raise(MyApp::ActorRequired) }
1047
+ ```
1048
+
1049
+ `Config.reset!` (documented in §"Event hooks") also resets `Config.versioning`
1050
+ to `false` and `Config.actor_resolver` to `nil`.
1051
+
1052
+ ### What versioning does not do
1053
+
1054
+ - **No branching/merging across version chains.** Phase 4 ships event-log
1055
+ shape only. Roadmap explicitly defers branching to a future design.
1056
+ - **No snapshot storage by default.** `typed_eav_value_versions` is an
1057
+ event log — one row per change, not a full-row snapshot. For
1058
+ high-volume apps that want snapshot storage, extend `ValueVersion` in
1059
+ your own code (the gem keeps the event-log shape canonical so future
1060
+ upgrades don't break your extension).
1061
+ - **No automatic `reverted_from_version_id` injection.** Use
1062
+ `with_context` to record revert intent; the gem captures whatever
1063
+ context the caller set.
1064
+ - **No per-Field versioning toggle.** Opt-in is per-entity (host model)
1065
+ in Phase 4. Per-field granularity may land later if a real need
1066
+ surfaces.
1067
+ - **No GIN indexes on `before_value` / `after_value` content.** Apps
1068
+ that need to query inside the snapshot jsonb add their own indexes.
1069
+ Phase 4 ships only the temporal indexes (`changed_at DESC` keyed on
1070
+ `value_id`, `(entity_type, entity_id)`, and `field_id`).
1071
+
1072
+ ### Test isolation
1073
+
1074
+ Specs that exercise versioning should opt into the `:event_callbacks`
1075
+ and `:real_commits` metadata flags (see §"Event hooks" — same pattern):
1076
+
1077
+ ```ruby
1078
+ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
1079
+ before do
1080
+ TypedEAV.registry.register("Contact", versioned: true)
1081
+ TypedEAV::Config.versioning = true
1082
+ # CRITICAL: the :event_callbacks hook clears
1083
+ # EventDispatcher.value_change_internals at example entry, so the
1084
+ # engine-boot-registered subscriber is gone for the duration of
1085
+ # the example. Re-register explicitly inside the before block.
1086
+ # The hook's ensure block restores the snapshot — no leak.
1087
+ TypedEAV::EventDispatcher.register_internal_value_change(
1088
+ TypedEAV::Versioning::Subscriber.method(:call),
1089
+ )
1090
+ end
1091
+ after { TypedEAV.registry.register("Contact", versioned: false) }
1092
+
1093
+ it "writes a version row" do
1094
+ # ...
1095
+ end
1096
+ end
1097
+ ```
1098
+
1099
+ The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshot/
1100
+ restores `Config.versioning`, `Config.actor_resolver`, and the
1101
+ EventDispatcher subscriber lists around each example, so your changes
1102
+ don't leak to subsequent tests. The snapshot/restore CLEARS the
1103
+ internals list at example entry — that's why the re-registration
1104
+ above is required for any spec that needs the subscriber to fire. The
1105
+ `:real_commits` hook disables transactional fixtures (so `after_commit`
1106
+ fires durably) and cleans up `TypedEAV::ValueVersion` rows in
1107
+ FK-respecting order between examples.
478
1108
 
479
1109
  ## Database Support
480
1110
 
481
1111
  Requires PostgreSQL. The `text_pattern_ops` index on `string_value` and the jsonb `@>` containment operator are Postgres-specific. MySQL/SQLite support would require removing those index types and changing the array query operators.
482
1112
 
1113
+ As of v0.2.0, the paired partial unique indexes cover the three-key partition tuple `(entity_type, scope, parent_scope)`. The orphan-parent invariant means the `WHERE scope IS NULL` partials don't include `parent_scope` — a global row always has `parent_scope` NULL too.
1114
+
483
1115
  ## Schema
484
1116
 
485
1117
  The gem creates four tables: