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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- 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
|
|
7
|
-
# (
|
|
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
|