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
@@ -0,0 +1,188 @@
1
+ class AddParentScopeToTypedEAVPartitions < ActiveRecord::Migration[7.1]
2
+ # Production deployments may carry millions of rows in `typed_eav_fields` /
3
+ # `typed_eav_sections` by the time they upgrade. Concurrent index DDL keeps
4
+ # writes online during the rebuild — but it cannot run inside a DDL
5
+ # transaction, so we drop the implicit per-migration transaction and
6
+ # explicitly implement `up`/`down` (since `algorithm: :concurrently` is
7
+ # not auto-reversible from a `change` block).
8
+ disable_ddl_transaction!
9
+
10
+ def up
11
+ # 1. Add the nullable `parent_scope` column on both partition tables.
12
+ # Postgres treats `ADD COLUMN ... NULL` as a catalog-only change — no
13
+ # table rewrite, instantaneous regardless of row count, safe outside a
14
+ # transaction.
15
+ add_column :typed_eav_fields, :parent_scope, :string
16
+ add_column :typed_eav_sections, :parent_scope, :string
17
+
18
+ # 2. Drop the existing scope-only paired-partial indexes plus the fields
19
+ # lookup index. `if_exists: true` keeps re-runs idempotent (e.g., after
20
+ # a partial failure on a long index drop).
21
+ remove_index :typed_eav_fields, name: :idx_te_fields_unique_scoped,
22
+ if_exists: true, algorithm: :concurrently
23
+ remove_index :typed_eav_fields, name: :idx_te_fields_unique_global,
24
+ if_exists: true, algorithm: :concurrently
25
+ remove_index :typed_eav_fields, name: :idx_te_fields_lookup,
26
+ if_exists: true, algorithm: :concurrently
27
+ remove_index :typed_eav_sections, name: :idx_te_sections_unique_scoped,
28
+ if_exists: true, algorithm: :concurrently
29
+ remove_index :typed_eav_sections, name: :idx_te_sections_unique_global,
30
+ if_exists: true, algorithm: :concurrently
31
+
32
+ # 3. Create the new triple-aware paired-partial unique indexes
33
+ # (Option B split — see migration header) plus refreshed lookup
34
+ # indexes.
35
+ #
36
+ # Why three partials per table instead of two: Postgres unique
37
+ # indexes treat NULL as distinct from NULL. A single partial
38
+ # `(name, entity_type, scope, parent_scope) WHERE scope IS NOT NULL`
39
+ # would NOT prevent two rows with `(name='f', entity_type='X',
40
+ # scope='t1', parent_scope=NULL)` from coexisting — both satisfy
41
+ # `scope IS NOT NULL` and `NULL ≠ NULL` keeps them distinct in the
42
+ # unique key. Splitting into `_scoped_full` (parent_scope set) and
43
+ # `_scoped_only` (parent_scope NULL) closes the hole using only
44
+ # standard semantics, so we don't need `NULLS NOT DISTINCT` (PG ≥ 15).
45
+ #
46
+ # Option A (`nulls_not_distinct: true`) was rejected because the
47
+ # gemspec floor is `rails >= 7.1` and there is no PG-server-version
48
+ # pin — consumer apps may run PG 12/13/14 where the option does
49
+ # not exist.
50
+ #
51
+ # The global partials (`scope IS NULL`) deliberately omit
52
+ # `parent_scope` from the column list: the orphan-parent invariant
53
+ # enforced at the model layer (plans 03/04) guarantees
54
+ # `parent_scope IS NULL` whenever `scope IS NULL`, so a fourth
55
+ # `(parent_scope NOT NULL, scope NULL)` partial would never be
56
+ # populated.
57
+
58
+ # Fields — three partial unique indexes
59
+ add_index :typed_eav_fields,
60
+ %i[name entity_type scope parent_scope],
61
+ unique: true,
62
+ where: "scope IS NOT NULL AND parent_scope IS NOT NULL",
63
+ name: :idx_te_fields_uniq_scoped_full,
64
+ algorithm: :concurrently,
65
+ if_not_exists: true
66
+
67
+ add_index :typed_eav_fields,
68
+ %i[name entity_type scope],
69
+ unique: true,
70
+ where: "scope IS NOT NULL AND parent_scope IS NULL",
71
+ name: :idx_te_fields_uniq_scoped_only,
72
+ algorithm: :concurrently,
73
+ if_not_exists: true
74
+
75
+ add_index :typed_eav_fields,
76
+ %i[name entity_type],
77
+ unique: true,
78
+ where: "scope IS NULL",
79
+ name: :idx_te_fields_uniq_global,
80
+ algorithm: :concurrently,
81
+ if_not_exists: true
82
+
83
+ # Sections — three partial unique indexes
84
+ add_index :typed_eav_sections,
85
+ %i[entity_type code scope parent_scope],
86
+ unique: true,
87
+ where: "scope IS NOT NULL AND parent_scope IS NOT NULL",
88
+ name: :idx_te_sections_uniq_scoped_full,
89
+ algorithm: :concurrently,
90
+ if_not_exists: true
91
+
92
+ add_index :typed_eav_sections,
93
+ %i[entity_type code scope],
94
+ unique: true,
95
+ where: "scope IS NOT NULL AND parent_scope IS NULL",
96
+ name: :idx_te_sections_uniq_scoped_only,
97
+ algorithm: :concurrently,
98
+ if_not_exists: true
99
+
100
+ add_index :typed_eav_sections,
101
+ %i[entity_type code],
102
+ unique: true,
103
+ where: "scope IS NULL",
104
+ name: :idx_te_sections_uniq_global,
105
+ algorithm: :concurrently,
106
+ if_not_exists: true
107
+
108
+ # Lookup indexes — refreshed `idx_te_fields_lookup` with parent_scope and
109
+ # a brand-new `idx_te_sections_lookup` for parity. Section ordering helpers
110
+ # ship in Phase 2; adding the index now is one extra concurrent CREATE and
111
+ # avoids a follow-on migration.
112
+ add_index :typed_eav_fields,
113
+ %i[entity_type scope parent_scope sort_order name],
114
+ name: :idx_te_fields_lookup,
115
+ algorithm: :concurrently,
116
+ if_not_exists: true
117
+
118
+ add_index :typed_eav_sections,
119
+ %i[entity_type scope parent_scope sort_order name],
120
+ name: :idx_te_sections_lookup,
121
+ algorithm: :concurrently,
122
+ if_not_exists: true
123
+ end
124
+
125
+ def down
126
+ # 1. Drop the eight new indexes (six paired-partial + two lookup).
127
+ remove_index :typed_eav_fields, name: :idx_te_fields_uniq_scoped_full,
128
+ if_exists: true, algorithm: :concurrently
129
+ remove_index :typed_eav_fields, name: :idx_te_fields_uniq_scoped_only,
130
+ if_exists: true, algorithm: :concurrently
131
+ remove_index :typed_eav_fields, name: :idx_te_fields_uniq_global,
132
+ if_exists: true, algorithm: :concurrently
133
+ remove_index :typed_eav_fields, name: :idx_te_fields_lookup,
134
+ if_exists: true, algorithm: :concurrently
135
+ remove_index :typed_eav_sections, name: :idx_te_sections_uniq_scoped_full,
136
+ if_exists: true, algorithm: :concurrently
137
+ remove_index :typed_eav_sections, name: :idx_te_sections_uniq_scoped_only,
138
+ if_exists: true, algorithm: :concurrently
139
+ remove_index :typed_eav_sections, name: :idx_te_sections_uniq_global,
140
+ if_exists: true, algorithm: :concurrently
141
+ remove_index :typed_eav_sections, name: :idx_te_sections_lookup,
142
+ if_exists: true, algorithm: :concurrently
143
+
144
+ # 2. Restore the original five indexes verbatim from
145
+ # db/migrate/20260330000000_create_typed_eav_tables.rb (using
146
+ # `algorithm: :concurrently` for production safety; the original
147
+ # migration ran inside a transaction without it, but the resulting
148
+ # index definitions are identical).
149
+ add_index :typed_eav_fields,
150
+ %i[name entity_type scope],
151
+ unique: true,
152
+ where: "scope IS NOT NULL",
153
+ name: :idx_te_fields_unique_scoped,
154
+ algorithm: :concurrently,
155
+ if_not_exists: true
156
+ add_index :typed_eav_fields,
157
+ %i[name entity_type],
158
+ unique: true,
159
+ where: "scope IS NULL",
160
+ name: :idx_te_fields_unique_global,
161
+ algorithm: :concurrently,
162
+ if_not_exists: true
163
+ add_index :typed_eav_fields,
164
+ %i[entity_type scope sort_order name],
165
+ name: :idx_te_fields_lookup,
166
+ algorithm: :concurrently,
167
+ if_not_exists: true
168
+ add_index :typed_eav_sections,
169
+ %i[entity_type code scope],
170
+ unique: true,
171
+ where: "scope IS NOT NULL",
172
+ name: :idx_te_sections_unique_scoped,
173
+ algorithm: :concurrently,
174
+ if_not_exists: true
175
+ add_index :typed_eav_sections,
176
+ %i[entity_type code],
177
+ unique: true,
178
+ where: "scope IS NULL",
179
+ name: :idx_te_sections_unique_global,
180
+ algorithm: :concurrently,
181
+ if_not_exists: true
182
+
183
+ # 3. Drop the parent_scope columns last — sections first, then fields,
184
+ # matching the inverse of `add_column` order in `up`.
185
+ remove_column :typed_eav_sections, :parent_scope
186
+ remove_column :typed_eav_fields, :parent_scope
187
+ end
188
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCascadePolicyToTypedEAVFields < ActiveRecord::Migration[7.1]
4
+ def up
5
+ # Cascade policy column. String (not enum) for forward-compat with future
6
+ # policies; default "destroy" preserves v0.1.0 behavior. NOT NULL because
7
+ # the AR validator narrows to a closed set and the model relies on a
8
+ # non-nil value at every read.
9
+ add_column :typed_eav_fields, :field_dependent, :string,
10
+ null: false, default: "destroy"
11
+
12
+ # Allow Value rows to outlive their Field row when field_dependent is
13
+ # "nullify". The existing read-path orphan guards in
14
+ # InstanceMethods#typed_eav_value / typed_eav_hash already skip
15
+ # field-nil rows silently — see CONCERNS.md.
16
+ change_column_null :typed_eav_values, :field_id, true
17
+
18
+ # Drop and recreate the FK to switch ON DELETE CASCADE → ON DELETE SET
19
+ # NULL. PG requires drop-and-recreate for an ON DELETE policy change.
20
+ # Using the column-form helpers so we don't hardcode the auto-generated
21
+ # fk_rails_* constraint name.
22
+ remove_foreign_key :typed_eav_values, column: :field_id
23
+ add_foreign_key :typed_eav_values, :typed_eav_fields,
24
+ column: :field_id, on_delete: :nullify
25
+ end
26
+
27
+ def down
28
+ # Reverse FK first (must precede NOT NULL restoration because any orphan
29
+ # rows would otherwise block change_column_null).
30
+ remove_foreign_key :typed_eav_values, column: :field_id
31
+ add_foreign_key :typed_eav_values, :typed_eav_fields,
32
+ column: :field_id, on_delete: :cascade
33
+
34
+ # Re-impose NOT NULL. If any field_id IS NULL rows exist (created while
35
+ # the up-version was live), this raises — operator must clean them up
36
+ # before rolling back. Document in CHANGELOG when shipping.
37
+ change_column_null :typed_eav_values, :field_id, false
38
+
39
+ remove_column :typed_eav_fields, :field_dependent
40
+ end
41
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTypedEAVValueVersions < ActiveRecord::Migration[7.1]
4
+ def change
5
+ # Append-only audit log of TypedEAV::Value mutations. Phase 04 versioning
6
+ # writes one row per :create / :update / :destroy event when the host
7
+ # entity opted in (via `has_typed_eav versioned: true` — landing in plan
8
+ # 04-02) AND the gem-level master switch is on (`config.versioning = true`).
9
+ #
10
+ # Storage shape decisions:
11
+ # - before_value / after_value are jsonb keyed by typed-column name
12
+ # (e.g., {"integer_value": 42}). Single-cell field types produce
13
+ # one-key hashes; Phase 05 Currency will produce two-key hashes
14
+ # ({"decimal_value": 99.99, "string_value": "USD"}). The {} default
15
+ # means "no recorded value" (e.g., the before snapshot of a :create
16
+ # event); {"<column>": null} means "recorded nil" (e.g., user
17
+ # cleared the cell). Distinct semantics on purpose.
18
+ # - changed_by is a plain string. Per Lead's plan-time decision (see
19
+ # Plan §Plan-time decisions §1), the gem coerces actor_resolver
20
+ # returns via the same `normalize_one`-style coercion that Phase 1
21
+ # uses for scope (lib/typed_eav.rb:239-243). Apps that need
22
+ # polymorphic actor querying resolve `changed_by` to a model on the
23
+ # read side — `User.find_by(id: version.changed_by)`.
24
+ # - context is jsonb so apps can store arbitrary `with_context`
25
+ # payloads (request_id, source, anything the caller passed).
26
+ # Default {} matches `TypedEAV.current_context`'s frozen-empty
27
+ # return shape when no with_context block is active.
28
+ # - changed_at is a separate column from created_at because callers
29
+ # may want to record event-time vs persistence-time distinctly
30
+ # (e.g., backfilling a historical version row from an external
31
+ # audit log). Default to `Time.current` at write time in the
32
+ # subscriber (plan 04-02); migration just declares NOT NULL.
33
+ create_table :typed_eav_value_versions do |t|
34
+ # Source row references. Both nullable + ON DELETE SET NULL so the
35
+ # audit log survives Value/Field destruction. Phase 02 made
36
+ # typed_eav_values.field_id ON DELETE SET NULL using exactly this
37
+ # pattern (db/migrate/20260501000000 lines 22-24). Same rationale:
38
+ # losing audit history because the live row was destroyed defeats
39
+ # the "append-only audit log" contract from 04-CONTEXT.md.
40
+ t.references :value,
41
+ null: true,
42
+ foreign_key: { to_table: :typed_eav_values, on_delete: :nullify }
43
+ t.references :field,
44
+ null: true,
45
+ foreign_key: { to_table: :typed_eav_fields, on_delete: :nullify }
46
+
47
+ # Polymorphic entity reference. Mirrors the typed_eav_values.entity
48
+ # pair exactly (Value belongs_to :entity polymorphic). NOT NULL
49
+ # because the entity tuple is the durable identity of a version
50
+ # row even after the Value (live cell) is destroyed — Phase 04
51
+ # consumers query history by `(entity_type, entity_id)`, not by
52
+ # value_id (which may be NULL). The polymorphic _type/_id columns
53
+ # default to NOT NULL via the t.references helper when null: false.
54
+ t.references :entity, polymorphic: true, null: false
55
+
56
+ # Actor identifier. Nullable per 04-CONTEXT.md §"actor_resolver
57
+ # returning nil" — system writes, migrations, console-without-actor,
58
+ # background jobs without `with_context(actor: ...)` all produce
59
+ # nil. Apps that need strict enforcement do it in their own
60
+ # actor_resolver lambda (`-> { Current.user || raise }`).
61
+ t.string :changed_by
62
+
63
+ # Snapshot columns. Default {} (NOT null) so the subscriber never
64
+ # writes nil — distinguishes "no recorded value" ({}) from "recorded
65
+ # nil" ({"<col>": null}). The change_type semantic is:
66
+ # :create → before_value: {}, after_value: {"<col>": <new>}
67
+ # :update → before_value: {"<col>": <old>}, after_value: {"<col>": <new>}
68
+ # :destroy → before_value: {"<col>": <old>}, after_value: {}
69
+ # Phase 05 Currency emits two-key snapshots automatically when its
70
+ # `value_columns` override returns [:decimal_value, :string_value]
71
+ # (subscriber loops over value_columns).
72
+ t.jsonb :before_value, null: false, default: {}
73
+ t.jsonb :after_value, null: false, default: {}
74
+
75
+ # `with_context` payload at write time (TypedEAV.current_context).
76
+ # Frozen Hash captured by EventDispatcher.dispatch_value_change
77
+ # (event_dispatcher.rb:89). Default {} matches the empty-context
78
+ # return shape; subscriber stores the captured Hash verbatim.
79
+ t.jsonb :context, null: false, default: {}
80
+
81
+ # Lifecycle metadata. change_type is a string (not enum) for forward
82
+ # compat — same rationale as Phase 02's field_dependent column
83
+ # (string-not-enum keeps schema migrations additive). Validator on
84
+ # the AR model narrows to the locked closed set.
85
+ t.string :change_type, null: false
86
+
87
+ # Event-time. Distinct from created_at to allow backfill scenarios
88
+ # where the subscriber writes a historical event-time. The :create
89
+ # / :update / :destroy normal path sets changed_at = Time.current
90
+ # in the subscriber (plan 04-02).
91
+ t.datetime :changed_at, null: false
92
+
93
+ t.timestamps
94
+ end
95
+
96
+ # Indexes — ship three at initial migration per Lead's plan-time
97
+ # decision (see Plan §Plan-time decisions §2). All use idx_te_vvs_*
98
+ # naming convention (matches CHANGELOG.md / Phase 1-2 idx_te_*
99
+ # precedent). DESC on changed_at because Value#history (plan 04-03)
100
+ # returns most-recent-first by default.
101
+ #
102
+ # No GIN index on before_value/after_value — deferred per CONTEXT.
103
+ # No partial unique indexes — multiple version rows per value_id are
104
+ # the whole point of the audit log.
105
+ add_index :typed_eav_value_versions,
106
+ %i[value_id changed_at],
107
+ order: { changed_at: :desc },
108
+ name: "idx_te_vvs_value"
109
+
110
+ add_index :typed_eav_value_versions,
111
+ %i[entity_type entity_id changed_at],
112
+ order: { changed_at: :desc },
113
+ name: "idx_te_vvs_entity"
114
+
115
+ add_index :typed_eav_value_versions,
116
+ %i[field_id changed_at],
117
+ order: { changed_at: :desc },
118
+ name: "idx_te_vvs_field"
119
+ end
120
+ end
@@ -0,0 +1,76 @@
1
+ class AddVersionGroupIdToTypedEAVValueVersions < ActiveRecord::Migration[7.1]
2
+ # Phase 06 — Bulk operations correlation tag.
3
+ #
4
+ # Adds a nullable, indexed `version_group_id` UUID column to
5
+ # `typed_eav_value_versions`. A bulk-write API (Plan 06-03) will inject
6
+ # the correlation tag via `TypedEAV.with_context(version_group_id: uuid)
7
+ # { ... }` and the Phase 04 versioning subscriber forwards it onto each
8
+ # ValueVersion row written during the bulk operation. Non-bulk writes
9
+ # leave the column NULL — backward-compatible with every row written
10
+ # by Phase 04's initial migration (`20260505000000_create_typed_eav_value_versions.rb`).
11
+ #
12
+ # ## Type rationale (uuid, not bigint or string)
13
+ #
14
+ # The correlation tag has no shared sequence — there's no parent
15
+ # `bulk_operations` row to FK to, and we deliberately do NOT introduce
16
+ # one (locked at 06-CONTEXT.md §version_group_id mechanism). UUID is the
17
+ # idiomatic choice for an unkeyed correlation token: 16 bytes vs 36 for
18
+ # a `:string` UUID, native Postgres equality / btree, and
19
+ # `SecureRandom.uuid` is the canonical Ruby generator. Postgres-only
20
+ # commitment is binding (ROADMAP §Cross-cutting requirements) so the
21
+ # `:uuid` column type is portable across all supported deployments.
22
+ #
23
+ # ## Nullability rationale
24
+ #
25
+ # Every existing version row was written without this column. Adding
26
+ # `null: false` would force a backfill, but there is no defensible
27
+ # value to backfill with — a per-row UUID would create a misleading
28
+ # signal that those historical rows were part of a bulk operation when
29
+ # they were not. Locked at 06-CONTEXT.md §version_grouping default:
30
+ # non-bulk writes leave the column NULL.
31
+ #
32
+ # ## Concurrent index DDL
33
+ #
34
+ # Mirrors the production-safety pattern from
35
+ # `db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb:1-8`:
36
+ # `algorithm: :concurrently` cannot run inside a DDL transaction, so we
37
+ # `disable_ddl_transaction!` and implement explicit `up` / `down`
38
+ # methods (the auto-reverse from a `change` block does not understand
39
+ # `algorithm: :concurrently`). `if_not_exists:` / `if_exists:` keep
40
+ # re-runs idempotent on partial-failure recovery.
41
+ #
42
+ # ## Index naming
43
+ #
44
+ # `idx_te_vvs_group` joins the existing `idx_te_vvs_*` family on this
45
+ # table (CONVENTIONS.md §Naming line 117 — `vvs` = "value versions"
46
+ # keeps the four-character partition fits in Postgres' 63-byte limit).
47
+ disable_ddl_transaction!
48
+
49
+ def up
50
+ # 1. Add the nullable `version_group_id` UUID column. Postgres treats
51
+ # `ADD COLUMN ... NULL` as a catalog-only change — no table rewrite,
52
+ # instantaneous regardless of row count, safe outside a transaction.
53
+ add_column :typed_eav_value_versions, :version_group_id, :uuid
54
+
55
+ # 2. Concurrent btree index. The dominant read pattern (Plan 06+ /
56
+ # consumer queries) is `WHERE version_group_id = ?` to fetch all
57
+ # rows produced by a single bulk operation; a plain btree on the
58
+ # column suffices. Composite indexes (e.g., `[entity_type,
59
+ # version_group_id]`) are deferred until a workload justifies them.
60
+ add_index :typed_eav_value_versions,
61
+ :version_group_id,
62
+ name: "idx_te_vvs_group",
63
+ algorithm: :concurrently,
64
+ if_not_exists: true
65
+ end
66
+
67
+ def down
68
+ # Drop the index first, then the column — inverse of `up` order.
69
+ remove_index :typed_eav_value_versions,
70
+ name: "idx_te_vvs_group",
71
+ if_exists: true,
72
+ algorithm: :concurrently
73
+
74
+ remove_column :typed_eav_value_versions, :version_group_id
75
+ end
76
+ end
@@ -3,8 +3,9 @@
3
3
  # TypedEAV configuration.
4
4
  #
5
5
  # `scope_resolver` is the single integration point for multi-tenancy /
6
- # partitioning. It's a callable that returns the current partition value
7
- # (a tenant id, account id, workspace id — whatever your app uses) or nil.
6
+ # partitioning. It's a callable that returns `[scope, parent_scope]`
7
+ # (tenant id, workspace id — whatever axes your app uses) or nil.
8
+ # If you only use one partition axis, return `[scope, nil]`.
8
9
  #
9
10
  # Class-level queries like `Contact.where_typed_eav(...)` consult this
10
11
  # resolver when no explicit `scope:` kwarg or `TypedEAV.with_scope(...)`
@@ -21,13 +22,14 @@ TypedEAV.configure do |c|
21
22
  # no change is needed here.
22
23
 
23
24
  # --- Rails CurrentAttributes ---
24
- # c.scope_resolver = -> { Current.account&.id }
25
+ # c.scope_resolver = -> { [Current.account&.id, nil] }
26
+ # c.scope_resolver = -> { [Current.account&.id, Current.workspace&.id] }
25
27
 
26
28
  # --- Custom Current-like class ---
27
- # c.scope_resolver = -> { MyApp::Tenancy.current_workspace_id }
29
+ # c.scope_resolver = -> { [MyApp::Tenancy.current_tenant_id, MyApp::Tenancy.current_workspace_id] }
28
30
 
29
31
  # --- Subdomain / session / thread-local ---
30
- # c.scope_resolver = -> { Thread.current[:org_id] }
32
+ # c.scope_resolver = -> { [Thread.current[:org_id], nil] }
31
33
 
32
34
  # --- Disable ambient resolution entirely (explicit `scope:` kwarg only) ---
33
35
  # c.scope_resolver = nil