typed_eav 0.8.0 → 0.8.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 +25 -2
- data/README.md +79 -1710
- data/RELEASING.md +81 -0
- data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
- data/docs/adr/0002-entity-query-orchestration.md +33 -0
- data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
- data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
- data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
- data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
- data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
- data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
- data/docs/adr/0009-string-search-indexing.md +110 -0
- data/docs/adr/0010-planner-statistics-policy.md +109 -0
- data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
- data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
- data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
- data/docs/adr/index.md +101 -0
- data/docs/getting-started.md +79 -0
- data/docs/guides/architecture.md +125 -0
- data/docs/guides/bulk-operations.md +205 -0
- data/docs/guides/csv-import.md +88 -0
- data/docs/guides/development.md +73 -0
- data/docs/guides/events-and-versioning.md +360 -0
- data/docs/guides/fields.md +342 -0
- data/docs/guides/performance.md +107 -0
- data/docs/guides/queries.md +188 -0
- data/docs/guides/schema.md +134 -0
- data/docs/guides/scoping.md +254 -0
- data/docs/guides/upgrading.md +26 -0
- data/docs/guides/usage.md +259 -0
- data/docs/index.md +44 -0
- data/docs/maintaining.md +82 -0
- data/docs/reference/api.md +133 -0
- data/docs/reference/configuration.md +64 -0
- data/docs/reference/index.md +16 -0
- data/lib/typed_eav/version.rb +1 -1
- metadata +35 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Event hooks and versioning"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Event hooks and versioning
|
|
6
|
+
|
|
7
|
+
[Documentation home](../index.md)
|
|
8
|
+
|
|
9
|
+
## Event hooks
|
|
10
|
+
|
|
11
|
+
`typed_eav` fires `after_commit` events for value and field changes. Use them
|
|
12
|
+
for audit logs, search-index synchronization, cache invalidation, or any
|
|
13
|
+
out-of-band reaction that must wait until the database write is durable.
|
|
14
|
+
|
|
15
|
+
### Public callback slots
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
TypedEAV.configure do |c|
|
|
19
|
+
c.on_value_change = ->(value, change_type, context) {
|
|
20
|
+
# change_type ∈ [:create, :update, :destroy]
|
|
21
|
+
# context is a frozen Hash (see `with_context` below) — read-only
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
c.on_field_change = ->(field, change_type) {
|
|
25
|
+
# change_type ∈ [:create, :update, :destroy, :rename]
|
|
26
|
+
# NOTE: no context arg — field changes are CRUD-on-config, not
|
|
27
|
+
# per-entity user actions
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The `:rename` change_type fires whenever the field's `name` column changed
|
|
33
|
+
in the just-committed save, even when bundled with other attribute changes
|
|
34
|
+
(options, sort_order, default_value, etc.). The detection is intentionally
|
|
35
|
+
escalating so any registered consumer receives a rename event whenever the
|
|
36
|
+
persisted name changes.
|
|
37
|
+
|
|
38
|
+
`:update` on Value fires only when the typed value column changed. Saving
|
|
39
|
+
a Value record without modifying its typed column (e.g., touching only
|
|
40
|
+
bookkeeping columns) is a no-op for event dispatch.
|
|
41
|
+
|
|
42
|
+
`field_dependent: :nullify` cascades produce **no** Value `:destroy`
|
|
43
|
+
events. The FK `ON DELETE SET NULL` runs at the database level and
|
|
44
|
+
bypasses AR callbacks. Only the Field `:destroy` event fires. Use
|
|
45
|
+
`field_dependent: :destroy` if your consumer needs per-Value events on
|
|
46
|
+
field deletion.
|
|
47
|
+
|
|
48
|
+
For a persisted `field_dependent: :destroy` field with a large population,
|
|
49
|
+
call `field.destroy_with_values_in_batches!(batch_size: 1_000)` outside an
|
|
50
|
+
open transaction. The opt-in API selects only that exact `field_id` in ordered
|
|
51
|
+
primary-key batches, calls `Value#destroy!` for callback/version behavior, and
|
|
52
|
+
commits each batch independently. A retry resumes from the remaining rows. The
|
|
53
|
+
Field is retained until a locked, bounded residual drain proves zero rows, then
|
|
54
|
+
its ordinary callback-preserving `destroy!` runs. The API rejects unsaved or
|
|
55
|
+
non-destroy fields, open transactions, invalid batch sizes, and mismatched
|
|
56
|
+
connection pools. Existing `destroy`/`destroy!`, `:nullify`, and `:restrict`
|
|
57
|
+
behavior is unchanged.
|
|
58
|
+
|
|
59
|
+
### Thread-local context with `with_context`
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
TypedEAV.with_context(request_id: request.uuid, actor_id: current_user.id) do
|
|
63
|
+
contact.update!(typed_eav: { phone: "555-1234" })
|
|
64
|
+
# on_value_change receives { request_id: "...", actor_id: 42 } as context
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`with_context` is a thread-local stack with shallow per-key merge:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
TypedEAV.with_context(request_id: "abc") do
|
|
72
|
+
TypedEAV.with_context(source: :bulk) do
|
|
73
|
+
# current context: { request_id: "abc", source: :bulk }
|
|
74
|
+
end
|
|
75
|
+
# current context: { request_id: "abc" }
|
|
76
|
+
end
|
|
77
|
+
# current context: {}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The current-context hash is frozen — callbacks cannot mutate it. Outer
|
|
81
|
+
context is restored on exit even if the inner block raises.
|
|
82
|
+
|
|
83
|
+
`TypedEAV.current_context` returns the current frozen Hash (or a shared
|
|
84
|
+
frozen `{}` when no `with_context` block is active). It's safe to call
|
|
85
|
+
from any code path; it never returns nil.
|
|
86
|
+
|
|
87
|
+
### Error policy
|
|
88
|
+
|
|
89
|
+
User callbacks (`Config.on_value_change`, `Config.on_field_change`) are
|
|
90
|
+
rescued — exceptions are logged via `Rails.logger.error` and **do not
|
|
91
|
+
propagate** to the user's save call. The save row is already committed
|
|
92
|
+
when `after_commit` fires; re-raising would surface a misleading
|
|
93
|
+
"save failed" error.
|
|
94
|
+
|
|
95
|
+
This is the deliberate split with first-party features. Internal
|
|
96
|
+
observers used by `typed_eav` itself follow a different rule: their exceptions
|
|
97
|
+
**propagate**. Transactional version-writing errors are separate: they
|
|
98
|
+
propagate inside and roll back the source transaction.
|
|
99
|
+
|
|
100
|
+
### Ordering guarantee
|
|
101
|
+
|
|
102
|
+
When multiple subscribers are registered, they fire in this order:
|
|
103
|
+
|
|
104
|
+
1. First-party generic observers, in registration order. Errors propagate.
|
|
105
|
+
2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
|
|
106
|
+
last. Errors are rescued and logged.
|
|
107
|
+
|
|
108
|
+
Reassigning `Config.on_value_change` after gem initialization does **not**
|
|
109
|
+
disable internal subscribers — they live on a separate dispatcher list
|
|
110
|
+
and survive `Config.reset!`.
|
|
111
|
+
|
|
112
|
+
### Reset semantics
|
|
113
|
+
|
|
114
|
+
| Method | What it resets |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `TypedEAV::Config.reset!` | User procs (`on_value_change`, `on_field_change`) plus `field_types`, `scope_resolver`, `require_scope`. Does **not** clear internal subscribers. |
|
|
117
|
+
| `TypedEAV::EventDispatcher.reset!` | Internal subscribers only. Does **not** touch Config. |
|
|
118
|
+
|
|
119
|
+
Production code rarely calls either — they exist for test isolation and
|
|
120
|
+
for the rare case where a host app wants to fully unwire the gem in a
|
|
121
|
+
specific request lifecycle.
|
|
122
|
+
|
|
123
|
+
## Versioning
|
|
124
|
+
|
|
125
|
+
`typed_eav` ships an opt-in append-only audit log for changes to typed
|
|
126
|
+
values. When enabled, each `:create` / `:update` / `:destroy` event on
|
|
127
|
+
a Value writes a row to `typed_eav_value_versions` capturing the
|
|
128
|
+
before-state, after-state, actor, context, and timestamp.
|
|
129
|
+
|
|
130
|
+
Default off. Apps that don't enable it pay zero overhead — transactional
|
|
131
|
+
Value callbacks are not installed at boot
|
|
132
|
+
at all when `Config.versioning = false`. Zero callable in the dispatcher
|
|
133
|
+
chain, zero per-write method dispatch, zero per-write config read.
|
|
134
|
+
|
|
135
|
+
### Enabling versioning
|
|
136
|
+
|
|
137
|
+
Two steps:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# 1. Set the gem-level master switch in an initializer.
|
|
141
|
+
# config/initializers/typed_eav.rb
|
|
142
|
+
TypedEAV.configure do |c|
|
|
143
|
+
c.versioning = true
|
|
144
|
+
c.actor_resolver = -> { Current.user } # optional; nil is permissive
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# 2. Opt the host model in. Either via the kwarg form:
|
|
148
|
+
class Contact < ApplicationRecord
|
|
149
|
+
has_typed_eav scope_method: :tenant_id, versioned: true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Or via the concern (equivalent — pick whichever fits your conventions):
|
|
153
|
+
class Contact < ApplicationRecord
|
|
154
|
+
has_typed_eav scope_method: :tenant_id
|
|
155
|
+
include TypedEAV::Versioned
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The two opt-in forms produce identical Registry state. The kwarg form is
|
|
160
|
+
preferred for new code; the concern form fits codebases with established
|
|
161
|
+
mixin-based feature wiring.
|
|
162
|
+
|
|
163
|
+
### Querying history
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
contact.typed_eav_attributes = [{ name: "age", value: 41 }]
|
|
167
|
+
contact.save!
|
|
168
|
+
contact.typed_eav_attributes = [{ name: "age", value: 42 }]
|
|
169
|
+
contact.save!
|
|
170
|
+
|
|
171
|
+
value = contact.typed_values.find_by(field: age_field)
|
|
172
|
+
value.history # most-recent-first relation
|
|
173
|
+
# => [<ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42}>,
|
|
174
|
+
# <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41}>]
|
|
175
|
+
|
|
176
|
+
value.history.first.changed_by # => "42" (User#42 — coerced to id.to_s)
|
|
177
|
+
value.history.first.context # => { "request_id" => "abc-123" } if with_context was active
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`value.history` is a chainable relation. Filter, paginate, pluck:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
value.history.where(change_type: "update").pluck(:changed_at, :changed_by)
|
|
184
|
+
value.history.limit(5).each { |v| ... }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Querying full audit history (including destroy events)
|
|
188
|
+
|
|
189
|
+
`Value#history` returns versions where `value_id` matches the live Value
|
|
190
|
+
record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
|
|
191
|
+
nullifies `value_id` on the existing version rows, and the new `:destroy`
|
|
192
|
+
version is written by the transactional destroy callback with `value_id: nil`
|
|
193
|
+
before the parent row is removed. So `Value#history`
|
|
194
|
+
cannot surface destroy versions, and after Value destruction it can no
|
|
195
|
+
longer be called at all.
|
|
196
|
+
|
|
197
|
+
To query the FULL audit history for a given (entity, field), including
|
|
198
|
+
destroy events and post-destruction lookup, use the entity-scoped query
|
|
199
|
+
directly:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
TypedEAV::ValueVersion
|
|
203
|
+
.where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
|
|
204
|
+
.order(changed_at: :desc, id: :desc)
|
|
205
|
+
# => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
|
|
206
|
+
# <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
|
|
207
|
+
# <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41} value_id: nil>]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This pattern is the canonical way to surface "what happened to this
|
|
211
|
+
field on this entity" across the full lifecycle, including post-destroy.
|
|
212
|
+
The `entity_type` + `entity_id` columns remain the durable identity even
|
|
213
|
+
after the parent Value row is gone, and `field_id` survives because
|
|
214
|
+
destroying a Value does not destroy its Field.
|
|
215
|
+
|
|
216
|
+
For broader audit views — "show all version history across all fields
|
|
217
|
+
for a given entity" (e.g., admin entity-history pages, compliance
|
|
218
|
+
exports) — drop the `field_id` filter:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
TypedEAV::ValueVersion
|
|
222
|
+
.where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
|
|
223
|
+
.order(changed_at: :desc, id: :desc)
|
|
224
|
+
# => all version rows for every typed field on this contact, most-recent-first.
|
|
225
|
+
# Includes :create, :update, and :destroy events across every field the
|
|
226
|
+
# entity has ever had a typed value for.
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The field-scoped query (with `field_id:`) is the common case for
|
|
230
|
+
"history of a single field"; the entity-scoped query (without `field_id:`)
|
|
231
|
+
is the broad-audit case for "all version history across all fields for
|
|
232
|
+
this entity".
|
|
233
|
+
|
|
234
|
+
### Version row jsonb shape
|
|
235
|
+
|
|
236
|
+
`before_value` and `after_value` are jsonb hashes keyed by typed-column
|
|
237
|
+
name:
|
|
238
|
+
|
|
239
|
+
| Field type | Snapshot shape (single key) |
|
|
240
|
+
|---|---|
|
|
241
|
+
| `text`, `email`, `url`, `color` | `{"string_value": "..."}` |
|
|
242
|
+
| `long_text` | `{"text_value": "..."}` |
|
|
243
|
+
| `integer` | `{"integer_value": 42}` |
|
|
244
|
+
| `decimal` | `{"decimal_value": "10.5"}` |
|
|
245
|
+
| `boolean` | `{"boolean_value": true}` |
|
|
246
|
+
| `date` | `{"date_value": "2026-05-05"}` |
|
|
247
|
+
| `date_time` | `{"datetime_value": "2026-05-05T12:00:00Z"}` |
|
|
248
|
+
| `select` | `{"string_value": "..."}` |
|
|
249
|
+
| `multi_select`, `*_array`, `json` | `{"json_value": [...]}` |
|
|
250
|
+
|
|
251
|
+
Multi-cell field types (e.g., `Currency`) produce two-key snapshots:
|
|
252
|
+
`{"decimal_value": "99.99", "string_value": "USD"}`. The version row's
|
|
253
|
+
snapshot asks the field's storage contract for its cells, so new field
|
|
254
|
+
types get the right shape automatically.
|
|
255
|
+
|
|
256
|
+
`{}` (empty hash) and `{"<col>": null}` are distinct semantics:
|
|
257
|
+
|
|
258
|
+
- `{}` means **no recorded value** — typical of `before_value` on a
|
|
259
|
+
`:create` event, or `after_value` on a `:destroy` event.
|
|
260
|
+
- `{"<col>": null}` means **recorded nil** — the user explicitly
|
|
261
|
+
cleared the cell.
|
|
262
|
+
|
|
263
|
+
### Reverting
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
target = value.history.find_by(change_type: "update")
|
|
267
|
+
value.revert_to(target)
|
|
268
|
+
# value's typed columns now match target.before_value.
|
|
269
|
+
# A NEW version row is written capturing the revert (append-only).
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`revert_to` writes the targeted version's `before_value` columns back
|
|
273
|
+
via `self[col] = …` and `save!`. The transactional version callback writes a
|
|
274
|
+
NEW version row whose
|
|
275
|
+
`after_value` reflects the targeted version's `before_value`. The
|
|
276
|
+
audit log is append-only — every revert is itself versioned.
|
|
277
|
+
|
|
278
|
+
To record the intent of the revert, wrap the call in `with_context`:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
TypedEAV.with_context(reverted_from_version_id: target.id, actor: current_user) do
|
|
282
|
+
value.revert_to(target)
|
|
283
|
+
end
|
|
284
|
+
# The new version row's `context` column captures both keys.
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`revert_to` raises `ArgumentError` in three documented conditions, checked in order:
|
|
288
|
+
|
|
289
|
+
- when `version.value_id` is nil (the source Value was destroyed — destroy
|
|
290
|
+
versions have `value_id: nil` per the locked subscriber contract; you
|
|
291
|
+
can't restore a destroyed AR record by `save!`);
|
|
292
|
+
- when the version's `before_value` is empty (the version represents a
|
|
293
|
+
`:create` event with no before-state to revert to);
|
|
294
|
+
- when the version belongs to a different Value (`value_id` mismatch).
|
|
295
|
+
|
|
296
|
+
In practice only `:update` versions are revertable. To restore a
|
|
297
|
+
destroyed entity's typed values, create a new `TypedEAV::Value` record
|
|
298
|
+
manually using `version.before_value` as the seed state.
|
|
299
|
+
|
|
300
|
+
### Hook ordering guarantee
|
|
301
|
+
|
|
302
|
+
Versioning is installed as boot-latched transactional callbacks on `Value`,
|
|
303
|
+
and the public callback remains an after-commit observer. The version row is
|
|
304
|
+
written in the source transaction.
|
|
305
|
+
```
|
|
306
|
+
Value#save! → transactional Value callback → ValueVersion.create!
|
|
307
|
+
→ after_commit → EventDispatcher.dispatch_value_change:
|
|
308
|
+
1. ... any other generic internal observers ...
|
|
309
|
+
2. Config.on_value_change user proc # sees the persisted version
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Internal observer errors propagate. Transactional version-writing errors also
|
|
313
|
+
propagate inside and roll back the source transaction.
|
|
314
|
+
User proc errors are rescued and logged via `Rails.logger.error` —
|
|
315
|
+
the save itself already committed.
|
|
316
|
+
|
|
317
|
+
### Actor resolution
|
|
318
|
+
|
|
319
|
+
`Config.actor_resolver` mirrors `Config.scope_resolver`'s callable shape
|
|
320
|
+
but returns whatever the app chooses (an AR record, a string, an integer,
|
|
321
|
+
nil). The subscriber coerces non-nil returns via `id.to_s` (for AR
|
|
322
|
+
records) or `to_s` (for scalars) before storing in the `changed_by`
|
|
323
|
+
column (string, nullable).
|
|
324
|
+
|
|
325
|
+
`nil` is the documented permissive sentinel: system writes, migrations,
|
|
326
|
+
console-without-actor, and background jobs without a `with_context(actor:
|
|
327
|
+
...)` wrap all flow through with `changed_by: nil`. This is intentional —
|
|
328
|
+
forcing every Versioned write to have an actor would reject every console
|
|
329
|
+
save and every migration backfill, which is hostile-by-default for a gem.
|
|
330
|
+
|
|
331
|
+
Apps that need stricter enforcement do it inside the resolver:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
c.actor_resolver = -> { Current.user || raise(MyApp::ActorRequired) }
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`Config.reset!` (documented in [Event hooks](#event-hooks)) also resets `Config.versioning`
|
|
338
|
+
to `false` and `Config.actor_resolver` to `nil`.
|
|
339
|
+
|
|
340
|
+
### What versioning does not do
|
|
341
|
+
|
|
342
|
+
- **No branching/merging across version chains.** Phase 4 ships event-log
|
|
343
|
+
shape only. Roadmap explicitly defers branching to a future design.
|
|
344
|
+
- **No snapshot storage by default.** `typed_eav_value_versions` is an
|
|
345
|
+
event log — one row per change, not a full-row snapshot. For
|
|
346
|
+
high-volume apps that want snapshot storage, extend `ValueVersion` in
|
|
347
|
+
your own code (the gem keeps the event-log shape canonical so future
|
|
348
|
+
upgrades don't break your extension).
|
|
349
|
+
- **No automatic `reverted_from_version_id` injection.** Use
|
|
350
|
+
`with_context` to record revert intent; the gem captures whatever
|
|
351
|
+
context the caller set.
|
|
352
|
+
- **No per-Field versioning toggle.** Opt-in is per-entity (host model)
|
|
353
|
+
in Phase 4. Per-field granularity may land later if a real need
|
|
354
|
+
surfaces.
|
|
355
|
+
- **No GIN indexes on `before_value` / `after_value` content.** Apps
|
|
356
|
+
that need to query inside the snapshot jsonb add their own indexes.
|
|
357
|
+
Phase 4 ships only the temporal indexes (`changed_at DESC` keyed on
|
|
358
|
+
`value_id`, `(entity_type, entity_id)`, and `field_id`).
|
|
359
|
+
|
|
360
|
+
For the gem’s callback metadata and test setup, see [Development and test isolation](development.md).
|