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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -2
  3. data/README.md +79 -1710
  4. data/RELEASING.md +81 -0
  5. data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
  6. data/docs/adr/0002-entity-query-orchestration.md +33 -0
  7. data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
  8. data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
  9. data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
  10. data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
  11. data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
  12. data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
  13. data/docs/adr/0009-string-search-indexing.md +110 -0
  14. data/docs/adr/0010-planner-statistics-policy.md +109 -0
  15. data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
  16. data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
  17. data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
  18. data/docs/adr/index.md +101 -0
  19. data/docs/getting-started.md +79 -0
  20. data/docs/guides/architecture.md +125 -0
  21. data/docs/guides/bulk-operations.md +205 -0
  22. data/docs/guides/csv-import.md +88 -0
  23. data/docs/guides/development.md +73 -0
  24. data/docs/guides/events-and-versioning.md +360 -0
  25. data/docs/guides/fields.md +342 -0
  26. data/docs/guides/performance.md +107 -0
  27. data/docs/guides/queries.md +188 -0
  28. data/docs/guides/schema.md +134 -0
  29. data/docs/guides/scoping.md +254 -0
  30. data/docs/guides/upgrading.md +26 -0
  31. data/docs/guides/usage.md +259 -0
  32. data/docs/index.md +44 -0
  33. data/docs/maintaining.md +82 -0
  34. data/docs/reference/api.md +133 -0
  35. data/docs/reference/configuration.md +64 -0
  36. data/docs/reference/index.md +16 -0
  37. data/lib/typed_eav/version.rb +1 -1
  38. 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).